diff --git a/README.md b/README.md index 97554b69..0a8567a0 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ It is built with **Python**, running natively on Linux, macOS, and Windows. * **Film Physics**: Models the **H&D Characteristic Curve** in density space — an asymmetric toe-linear-shoulder response with independent softplus toe/shoulder knees and ISO-R paper grades — instead of a linear inversion. * **Smart Auto Conversion**: Per-frame **Auto Density** and **Auto Grade** meter each negative for sensible brightness/contrast — usable out of the box, easy to fine-tune. * **Darkroom Paper Profiles**: Per-paper curve shaping (tone, per-channel gamma, base tint) mapped from Ilford/Kodak/Foma/Fuji datasheets, selectable per roll. -* **Positive/Slide Support**: Dedicated **E-6 mode** with optional normalization to save expired or faded film. +* **Positive/Slide Support**: Dedicated **Transparency mode** with optional normalization to save expired or faded film. **Capture & Input** * **Camera Scanning**: Capture negatives with a tethered camera straight into NegPy — a single RAW, or automated red/green/blue narrowband triplets driven by an RGB [Scanlight](https://github.com/jackw01/scanlight) that feed the RGB Scan merge. macOS/Linux, optional dependency. [Camera Scanning guide](docs/CAMERA_SCANNING.md) diff --git a/docs/CROSSTALK.md b/docs/CROSSTALK.md index 8c1b8e7a..cc44fa74 100644 --- a/docs/CROSSTALK.md +++ b/docs/CROSSTALK.md @@ -8,30 +8,30 @@ own — calibrated for your film *and your scanning setup* — without touching ## Which film a matrix is for -A matrix describes one dye set, and C-41 and E-6 do not share one. Each profile therefore +A matrix describes one dye set, and Color Negative and Transparency do not share one. Each profile therefore declares its film process: ```toml -process = "E-6" # or "C41" (the default when the key is absent) +process = "Transparency" # or "Color Negative" (the default when the key is absent) ``` The dropdown only offers matrices for the film you are processing, and the render gates on the same value, so a mismatched profile resolves to identity rather than mixing the wrong correction in. Every profile written before this key existed is a colour negative stock, -which is why the default is `C41`. +which is why the default is `Color Negative`. Files written before the modes were renamed (`C41`, `B&W`, `E-6`) still load. -No E-6 matrix ships with NegPy today, so on slides the Matrix dropdown starts empty and it +No Transparency matrix ships with NegPy today, so on slides the Matrix dropdown starts empty and it and the Strength slider are disabled. The editor button stays live: open it, press **+** for a new matrix, and it is created for the process you are working in. The editor's **Process** control sets that value on any profile you save, so a matrix built for a slide rig is one you -can then select. (Crosstalk is hidden outright only in B&W, which has one emulsion and so +can then select. (Crosstalk is hidden outright only in B&W Negative, which has one emulsion and so nothing to unmix.) > **Reversal film reads differently.** On a negative the dyes' unwanted absorptions are an > error to remove before inversion, so unmixing moves the render *toward* the scene. A > transparency **is** the finished image — what you see on a lightbox already includes those > absorptions — so unmixing moves it *away* from the slide's own appearance and toward the -> dye-amount image behind it. On E-6 treat Crosstalk as a colour-separation control, not a +> dye-amount image behind it. In Transparency treat Crosstalk as a colour-separation control, not a > fidelity correction, and leave Strength at 0 when the goal is to match the slide. ## What it does @@ -201,7 +201,7 @@ have to hand-edit TOML: folder profiles are read from — so it shows up in the dropdown. - **Apply & Close** keeps what you were previewing; **Cancel** reverts. -> Crosstalk is a color operation and is hidden in B&W mode. Because it changes what +> Crosstalk is a color operation and is hidden in B&W Negative mode. Because it changes what > the normalization meters read, re-run **Batch Analysis** (and re-save locked bounds) > after changing the profile or strength. diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 60f54387..1717e066 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -29,15 +29,15 @@ Here is what actually happens to your image. We apply these steps in order, pass $$E_{log} = \log_{10}(I_{raw})$$ * **Bounding & Polarity**: The engine uses statistical percentiles to detect the usable signal range. To maintain a unified pipeline, we always map the target **White Point** to the **Floor** ($0.0$) and the **Black Point** to the **Ceiling** ($1.0$). - * **Negative (C-41/B&W)**: Raw low-signal (dense Highlights) maps to Floor ($0.0$). Raw high-signal (Film Base / Shadows) maps to Ceiling ($1.0$). Range: 0.01% to 99.99%. - * **Positive (E-6)**: Raw high-signal (Highlights) maps to Floor ($0.0$). Raw low-signal (Shadows) maps to Ceiling ($1.0$). Range: 99.99% to 0.01% — the C-41 percentiles with the ends swapped (`_sample_log_bounds`). **Normalize is off by default** and none of this metering runs then: the window is fixed and the camera matrix is applied instead — see *Transparency transfer* in §3. + * **Negative (Color Negative / B&W Negative)**: Raw low-signal (dense Highlights) maps to Floor ($0.0$). Raw high-signal (Film Base / Shadows) maps to Ceiling ($1.0$). Range: 0.01% to 99.99%. + * **Positive (Transparency)**: Raw high-signal (Highlights) maps to Floor ($0.0$). Raw low-signal (Shadows) maps to Ceiling ($1.0$). Range: 99.99% to 0.01% — the negative percentiles with the ends swapped (`_sample_log_bounds`). **Normalize is off by default** and none of this metering runs then: the window is fixed and the camera matrix is applied instead — see *Transparency transfer* in §3. - Metering a slide is a **rescue path, not the general case**, which is what it was added for ("an optional Normalize step that tries to save expired slides", 0.9.10). A faded slide has lost its range and measuring it back per frame is right. A correctly exposed one has ~3.5 decades of density of which only the top ~1.5 carry picture — the rest runs to Dmax — so a measured stretch puts the median at roughly 13% of the window and the print curve renders that near white, compressing tone and colour together in the shoulder. Measured on a well-exposed frame: median luma $0.88$ and mean saturation $0.14$, against $0.70$ and $0.29$ for the same frame rendered as captured. Note also that the same-pixel chroma-gated colour refs are skipped for E-6, so it takes the cruder percentile fallback on the colour axis. + Metering a slide is a **rescue path, not the general case**, which is what it was added for ("an optional Normalize step that tries to save expired slides", 0.9.10). A faded slide has lost its range and measuring it back per frame is right. A correctly exposed one has ~3.5 decades of density of which only the top ~1.5 carry picture — the rest runs to Dmax — so a measured stretch puts the median at roughly 13% of the window and the print curve renders that near white, compressing tone and colour together in the shoulder. Measured on a well-exposed frame: median luma $0.88$ and mean saturation $0.14$, against $0.70$ and $0.29$ for the same frame rendered as captured. Note also that the same-pixel chroma-gated colour refs are skipped in Transparency, so it takes the cruder percentile fallback on the colour axis. Bounds are sampled on **two independent axes** (`_sample_log_bounds`): a **luma** pass fixes the floor/ceil mean (centre + span), and a **colour** pass fixes each channel's deviation from that mean. The two are recombined, so colour balance is tunable without compressing the luminance range. Identical channels (mono) give zero deviation at any clip. The controls: * **Luma Range Clip** (`luma_range_clip`): Tunes how aggressively the *luminance* percentile window is set, which is the black/white-point span (dynamic range). **Positive** values symmetrically tighten the window before bounds detection, useful for very dense or fogged negatives where a few outlier pixels would otherwise pull the white or black point to an extreme. **Zero** uses robust extremes (a block-median prefilter rejects dust and speculars, and a small base clip excludes tiny outlier populations). **Negative** values push the bounds *outward* beyond the extremes, leaving lifted blacks and unclipped highlights as headroom. - * **Colour Clip** (`color_range_clip`): The sampling depth for the per-channel colour deviation (white balance / orange-mask cast), independent of the luma span. A **tighter** (larger) value samples deeper into the histogram for a more robust, outlier-resistant channel balance; a **gentler** (smaller) value samples nearer the extremes. The default neutral is `base_color_clip` ($1.0$); the slider spans log-interpolated values either side of it. The two ends are sampled differently. The **thin end** (film base) uses per-channel percentiles, which are physically anchored because density on real film is bounded below by base. The **dense end** (scene highlights) reads one shared, chroma-gated pixel set (the luma-extreme band's lowest-chroma subset, base-anchored) so coloured highlight content can't masquerade as film cast the way independent per-channel percentiles allow. When the band holds no trustworthy neutrals (and always for E-6), the dense end falls back to the per-channel percentile pass. - * **White & Black Point Offsets**: Fine-tunes the detected bounds after statistical analysis. Shifting the White Point floor or Black Point ceiling enables precise highlight recovery or shadow crushing without re-running the analysis. A **[Global / R / G / B]** selector on the Process page scopes the sliders to per-layer trims (`white_point_trim_*` / `black_point_trim_*`) added on top of the global offsets: per-dye-layer film-base (Dmin) and Dmax correction, scanner-style per-channel levels (`per_channel_point_offsets`, single source for CPU/GPU; E6 negates; hidden in B&W). + * **Colour Clip** (`color_range_clip`): The sampling depth for the per-channel colour deviation (white balance / orange-mask cast), independent of the luma span. A **tighter** (larger) value samples deeper into the histogram for a more robust, outlier-resistant channel balance; a **gentler** (smaller) value samples nearer the extremes. The default neutral is `base_color_clip` ($1.0$); the slider spans log-interpolated values either side of it. The two ends are sampled differently. The **thin end** (film base) uses per-channel percentiles, which are physically anchored because density on real film is bounded below by base. The **dense end** (scene highlights) reads one shared, chroma-gated pixel set (the luma-extreme band's lowest-chroma subset, base-anchored) so coloured highlight content can't masquerade as film cast the way independent per-channel percentiles allow. When the band holds no trustworthy neutrals (and always in Transparency), the dense end falls back to the per-channel percentile pass. + * **White & Black Point Offsets**: Fine-tunes the detected bounds after statistical analysis. Shifting the White Point floor or Black Point ceiling enables precise highlight recovery or shadow crushing without re-running the analysis. A **[Global / R / G / B]** selector on the Process page scopes the sliders to per-layer trims (`white_point_trim_*` / `black_point_trim_*`) added on top of the global offsets: per-dye-layer film-base (Dmin) and Dmax correction, scanner-style per-channel levels (`per_channel_point_offsets`, single source for CPU/GPU; Transparency negates; hidden in B&W Negative). * **Roll baseline & locks**: Batch Analysis measures every frame of a roll. **Use luma / colour average** (`use_luma_average` / `use_colour_average`) swap in the roll-wide baseline independently per axis (luma span, colour cast) so the whole roll normalizes consistently, and **Lock bounds** (`lock_bounds`) freezes the stored floors/ceils against re-analysis. * **Stretch**: All modes use independent channel bounding. This neutralizes the orange mask in negatives and base tints/fading in reversal film by stretching each channel to the full $[0, 1]$ range. The result is not clamped: tones outside the detected bounds are kept and rolled off later by the soft toe/shoulder of the print curve, rather than being truncated here. * **Per-frame metering**: Normalization also measures a few statistics the Print stage's automatic helpers use later: per-channel **shadow references** ($P_{98}$, for Cast Removal), a per-frame **exposure anchor** ($P_{50}$ luminance) and a **textural range** ($P_{10}$ to $P_{90}$, for Auto Density / Auto Grade). See §3. @@ -50,7 +50,7 @@ Here is what actually happens to your image. We apply these steps in order, pass **Code**: `negpy.features.exposure` * **Virtual Darkroom**: Simulates shining light through the normalized log-signal onto paper. -* **B&W (panchromatic)**: in B&W mode the normalized signal collapses to a **single density** (its luminance) *before* the curve, so the H&D curve shapes one channel instead of mixing three, like paper under a B&W negative. Per-channel colour controls are hidden. +* **B&W Negative (panchromatic)**: in B&W Negative mode the normalized signal collapses to a **single density** (its luminance) *before* the curve, so the H&D curve shapes one channel instead of mixing three, like paper under a B&W negative. Per-channel colour controls are hidden. * **Color Timing**: Applies subtractive filtration (CMY) in log-space. This mimics a dichroic head on an enlarger. Adjustments can be targeted to **Global**, **Shadows**, or **Highlights** regions. The shadow/highlight offsets are weighted by a smooth sigmoid about the midtone, $w_{sh} = \sigma(3 \cdot (v - z))$ with $z$ the midtone zone centre (`anchor_target_density`), so shadow weight rises with density and highlight weight falls. The Temperature slider, WB picker and temperature roll-lock all operate on the *selected region's* M/Y pair. * **The H&D Curve**: Models paper response as an **asymmetric toe-linear-shoulder** curve in **density** space. A straight line of slope $k$ through the exposure pivot is smoothly bounded above by the **toe** (shadows rolling into paper black) and below by the **shoulder** (highlights rolling into paper white). Both bounds are independent **softplus** knees, so each slider shapes only its own end of the scale (film/print convention). With $v = k \cdot (x_{adj} - x_0)$: $$v_1 = D_{min} + \frac{\text{softplus}\big(a_{hl} (v - D_{min})\big)}{a_{hl}} \qquad \text{(shoulder → paper white)}$$ @@ -98,9 +98,9 @@ The defaults are tuned to look right straight out of the box; these helpers do p $$K \cdot \big(n + \sigma \cdot (r - n)\big)$$ with $K =$ `auto_grade_target` ($0.6$), $n =$ `auto_grade_nominal_ratio` ($2.0$, the ratio of a "normal" negative) and $\sigma =$ `auto_grade_strength` ($0.5$). The adaptation strength dampens contrast swings: at $\sigma = 0$ every frame gets the same fixed grade, at $\sigma = 1$ every frame is normalized to identical contrast. The default sits between, so a flat scene gets a lift and a punchy scene stays punchy without being pushed to a harsh extreme. * **Set Targets** (app-global): the five numbers above that set the *aim* (`anchor_target_density`, `anchor_meter_strength`, `anchor_meter_band`, `auto_grade_target`, `auto_grade_strength`) are user-tunable from a dialog beside the two toggles (`TUNABLE_TARGETS` in `features/exposure/models.py`, ranges declared there). They are a **calibration, not per-image state**: `apply_targets()` overlays them onto `EXPOSURE_CONSTANTS` and they persist in the `exposure_targets` global setting, so they apply to every frame including already-edited ones. Because no `WorkspaceConfig` hash sees them, both engines fold a `TARGETS_REVISION` counter into their cache keys: the CPU base stage (which does the metering) and, on the GPU, the analysis cache key plus the exposure-stage diff. Anything added to `TUNABLE_TARGETS` that is read *outside* those stages needs its own invalidation. -* **Cast Removal** (`cast_removal_strength`, default $0.5$; $0$ turns it off): Neutralizes the colour cast a negative leaves in the print, balancing each layer so greys read neutral from deep shadows through highlights, not just at the midtone (the usual cause of shadows/highlights drifting off-colour after a C-41 midtone white balance). The applied strength is `confidence × slider` (`effective_cast_strength`): how cleanly the frame's near-neutrals read biases the correction, so a scene with few greys is corrected gently and the slider trims on top. +* **Cast Removal** (`cast_removal_strength`, default $0.5$; $0$ turns it off): Neutralizes the colour cast a negative leaves in the print, balancing each layer so greys read neutral from deep shadows through highlights, not just at the midtone (the usual cause of shadows/highlights drifting off-colour after a colour-negative midtone white balance). The applied strength is `confidence × slider` (`effective_cast_strength`): how cleanly the frame's near-neutrals read biases the correction, so a scene with few greys is corrected gently and the slider trims on top. - **C-41 only**, in the render and in the UI. The solve needs two measurements — the shadow references and the neutral axis — and both meters are gated to C-41 (`processor.py`, and `needs_refs` in `gpu_engine.py`); without them `per_channel_curve_params` falls through to one shared linear curve and the slider never enters the arithmetic. That gating is the correct behaviour rather than a limitation: what Cast Removal defeats is the **orange mask**, a manufactured cast that is not part of the picture. A transparency has no mask and its cast *is* the photograph, so a neutral-axis solve would remove the scene's own light; B&W collapses to a single density and has no channels to balance. The slider is hidden in both modes so it cannot read as live. + **Color Negative only**, in the render and in the UI. The solve needs two measurements — the shadow references and the neutral axis — and both meters are gated to Color Negative (`processor.py`, and `needs_refs` in `gpu_engine.py`); without them `per_channel_curve_params` falls through to one shared linear curve and the slider never enters the arithmetic. That gating is the correct behaviour rather than a limitation: what Cast Removal defeats is the **orange mask**, a manufactured cast that is not part of the picture. A transparency has no mask and its cast *is* the photograph, so a neutral-axis solve would remove the scene's own light; B&W Negative collapses to a single density and has no channels to balance. The slider is hidden in both modes so it cannot read as live. The primary solve fits each non-green channel to green's **neutral axis**, using per-channel references taken at a highlight, midtone and shadow luma band, each over that band's lowest-chroma pixels (`neutral_axis_*` constants). R and B get a **quadratic** through all three green-matched points, so highlights don't extrapolate past neutral; the midtone is pinned exactly via the pivot. Each channel's deviation from green is clamped ($\pm 0.2$, `midtone_cast_max_offset`) and the curvature is bounded to a fraction of the slope (`neutral_axis_curv_max_ratio` $= 0.45$) to keep the per-channel core monotonic on $[0,1]$. @@ -115,7 +115,7 @@ With the helpers off, the conversion shows you your photography: exactly how the A **paper profile** (`paper_profile`, default *Neutral*) overrides the print *character*, the H&D curve shape, without touching contrast or exposure. Each profile sets the paper's $D_{max}/D_{min}$, toe/shoulder knee sharpness and height, and midtone gamma. Colour papers add a per-channel slope crossover (`channel_gamma`, the dye-layer divergence at the extremes), a paper-base tint (`base_tint_cmy`, an addition to the minimum-density floor that shows in highlights) and a **dye-coupling matrix** (`dye_matrix`, $D_{rgb} = M \cdot D_{dye}$ above base: the dyes' unwanted absorptions, row-normalized at use). Grade still owns contrast and the Density/toe/shoulder sliders still trim on top, and the *Neutral* profile reproduces the defaults exactly. -Profiles are **mode-aware**: C-41 exposes the RA4 colour papers, B&W exposes the B&W papers (paper tone is a Toning job under normal development, so B&W profiles carry no RA4 colour terms — but they do carry `lith_path`, which the Lith stage reads), and E-6 gets only *Neutral*. An incompatible stored value collapses to *Neutral* so it can never leak into a render. Bundled papers: **Neutral**; in *B&W*, Ilford Multigrade RC, Ilford Multigrade FB Classic, Foma Fomatone, Foma Fomabrom; in *RA4*, Kodak Endura Premier and Fujicolor Crystal Archive. Values are loosely mapped from datasheets (mainly $D_{max}$ is grounded; the knee/midtone tweaks are light character touches). +Profiles are **mode-aware**: Color Negative exposes the RA4 colour papers, B&W Negative exposes the B&W papers (paper tone is a Toning job under normal development, so B&W profiles carry no RA4 colour terms — but they do carry `lith_path`, which the Lith stage reads), and Transparency gets only *Neutral*. An incompatible stored value collapses to *Neutral* so it can never leak into a render. Bundled papers: **Neutral**; in *B&W*, Ilford Multigrade RC, Ilford Multigrade FB Classic, Foma Fomatone, Foma Fomabrom; in *RA4*, Kodak Endura Premier and Fujicolor Crystal Archive. Values are loosely mapped from datasheets (mainly $D_{max}$ is grounded; the knee/midtone tweaks are light character touches). ### Dye Separation **Code**: `negpy.features.exposure.papers.resolve_saturation_matrix` / `compose_density_matrices` @@ -168,7 +168,7 @@ Both are fixed (no per-frame metering) so an evenly-exposed roll renders identic ### Transparency transfer, "the slide as captured" **Code**: `negpy.features.exposure.transfer` (+ `shaders/transfer.wgsl`), `negpy.features.process.capture_color` -For **E-6 with Normalize off** — the default (`is_transparency_transfer`) — the Print stage is replaced by a **transfer curve** that is exactly the identity at default settings, so the render *is* the capture. A transparency was exposed deliberately, and the assumption here is that the user wants to start from what they shot and adjust, not to re-print it. +For **Transparency with Normalize off** — the default (`is_transparency_transfer`) — the Print stage is replaced by a **transfer curve** that is exactly the identity at default settings, so the render *is* the capture. A transparency was exposed deliberately, and the assumption here is that the user wants to start from what they shot and adjust, not to re-print it. Two things change, one per stage. @@ -377,7 +377,7 @@ This mimics what lab scanners like Frontier or Noritsu do automatically. For max ## 7. Alternative Processes **Code**: `negpy.features.lith`, `negpy.features.cyanotype`; config `negpy.features.altprocess` -One optional non-enlarging process (B&W mode only), sitting between Lab and Toning so the toners act on the alternative print rather than the other way round. Lith and cyanotype are mutually exclusive — the panel holds a single `alt_process` enum, not a flag each — and default to neither; with neither picked the stage is skipped entirely on both engines rather than run as an identity pass. +One optional non-enlarging process (B&W Negative mode only), sitting between Lab and Toning so the toners act on the alternative print rather than the other way round. Lith and cyanotype are mutually exclusive — the panel holds a single `alt_process` enum, not a flag each — and default to neither; with neither picked the stage is skipped entirely on both engines rather than run as an identity pass. ### 7.1 Lith @@ -418,7 +418,7 @@ Not modelled: **solarisation**, though it is real and dramatic — the reversed ## 8. Toning **Code**: `negpy.features.toning` -* **Chemical Toning** (B&W mode only): six bath simulations (**Selenium**, **Sepia**, **Gold**, **Iron Blue**, **Copper**, **Vanadium Green**) modelled as a **silver ledger** in density space (`TONING_CONSTANTS`). The pixel's original mean density $D_0$ is its metallic-silver reservoir. Each toner converts a fraction of it, and converted silver is locked away from later baths (Rudman/Ilford: the archival selenium-then-sepia split, and "no silver left" exhaustion). +* **Chemical Toning** (B&W Negative mode only): six bath simulations (**Selenium**, **Sepia**, **Gold**, **Iron Blue**, **Copper**, **Vanadium Green**) modelled as a **silver ledger** in density space (`TONING_CONSTANTS`). The pixel's original mean density $D_0$ is its metallic-silver reservoir. Each toner converts a fraction of it, and converted silver is locked away from later baths (Rudman/Ilford: the archival selenium-then-sepia split, and "no silver left" exhaustion). * **Susceptibility**: each toner's conversion $c_i$ is a pure function of $D_0$, a grain property, so sequence only decides who claims silver first: * *silver-proportional, shadows first*: Selenium $c = S \cdot (D_0/D_{ref})^{p}$; Iron Blue and Copper likewise, with a low $D_{ref}$ ($0.9$) so the colour reaches the mids instead of hiding in the deep shadows. * *bleach-limited, highlights first*: Sepia, Gold, Vanadium $c = S \cdot (1 - D_0/D_{ref})^{p}$, so the thinnest silver converts first (split-sepia character comes from the exponent). diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index afd59231..7be48c9e 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -148,11 +148,11 @@ Metered −2 through +3 costs six frames and leaves the decision to you at the e Shorter frames do carry one thing outright — a **blown specular**, the sliver of water highlight or sun disc above the reference's white. On three of the eleven that was a mean 0.03 difference across the near-white 0.1% of the frame. -The merged frame inherits the **film process** of the exposures it came from, so a bracket of slides opens in E-6 rather than reverting to whatever mode you last set by hand. Stitched composites do the same. +The merged frame inherits the **film process** of the exposures it came from, so a bracket of slides opens in Transparency rather than reverting to whatever mode you last set by hand. Stitched composites do the same. It is **named after the first frame in filename order**, with an `-HDR` suffix — a bracket of `_DSC1715`…`_DSC1719` exports as `_DSC1715-HDR.jpg`. Not the reference frame, whose identity depends on picture content; and the suffix means a merge never writes over the export of the single frame it is named after. -**Merging is for transparencies.** The action appears on E-6 frames only. A colour negative holds about 5-6 stops between its base and its densest highlight and an ordinary black-and-white negative nearer 4 — both comfortably inside one capture, so a bracket buys nothing; a transparency runs to 10-12, which is what the merge exists for. On black-and-white the entry is shown but disabled, because reversal-processed monochrome (Scala, dr5, Fomapan R) really is a transparency and does have the range — it is simply not wired up yet. +**Merging is for transparencies.** The action appears on Transparency frames only. A colour negative holds about 5-6 stops between its base and its densest highlight and an ordinary black-and-white negative nearer 4 — both comfortably inside one capture, so a bracket buys nothing; a transparency runs to 10-12, which is what the merge exists for. On black-and-white the entry is shown but disabled, because reversal-processed monochrome (Scala, dr5, Fomapan R) really is a transparency and does have the range — it is simply not wired up yet. Merging is also refused on frames that are already merged, stitched, or RGB-scan triplets: each of those is its own way of building one frame from several files, and combining them is not supported. @@ -234,7 +234,7 @@ It is also where you place a tone: **click a cell, then click that spot on the p #### Probe -A spot densitometer. Hover the image to read the pixel: per-channel density above film base (ΔD, relative to this scan's normalization, not absolute), the displayed tone's reflection print density, and its print zone (0 = paper black, V = 18% mid-grey, X = paper white). In B&W mode the ΔD channels read the pre-conversion colour record. +A spot densitometer. Hover the image to read the pixel: per-channel density above film base (ΔD, relative to this scan's normalization, not absolute), the displayed tone's reflection print density, and its print zone (0 = paper black, V = 18% mid-grey, X = paper white). In B&W Negative mode the ΔD channels read the pre-conversion colour record. #### Zone placement @@ -280,7 +280,7 @@ Everything here corrects the *capture*, not the look: three different things sit This block greys out unless **Linear RAW** is on, since profiles are calibrated against neutral white balance and the as-shot gains would misapply the matrix. Your selection is remembered either way. It is also skipped for RGB-triplet assets, which never had the leak. Because it changes what the analysis reads, **re-run Batch Analysis** after changing it. -**Crosstalk** (hidden in B&W), a channel unmix applied to the raw densities before inversion. The dropdown only lists matrices for the film you are processing — a C-41 matrix does not describe E-6's dye set — and a mismatched stored profile resolves to no correction rather than the wrong one. The film's dyes each absorb outside their own band, but they are not the only cause: your light's spectrum and your sensor's colour filters mix the channels too, and in the density domain all three arrive as the same kind of error. So treat the matrix as *your whole scanning setup*, not just the film — a profile that works beautifully on one rig may be wrong on another with the same stock. +**Crosstalk** (hidden in B&W Negative), a channel unmix applied to the raw densities before inversion. The dropdown only lists matrices for the film you are processing — a Color Negative matrix does not describe a Transparency's dye set — and a mismatched stored profile resolves to no correction rather than the wrong one. The film's dyes each absorb outside their own band, but they are not the only cause: your light's spectrum and your sensor's colour filters mix the channels too, and in the density domain all three arrive as the same kind of error. So treat the matrix as *your whole scanning setup*, not just the film — a profile that works beautifully on one rig may be wrong on another with the same stock. * **Matrix**: the profile to apply, grouped in the dropdown by where its numbers came from (measured, tuned on a rig, or from spec sheets). *Generic C41* is the built-in; drop custom `.toml` matrices in `/NegPy/crosstalk/` (see [CROSSTALK.md](CROSSTALK.md)). The slider button opens a matrix editor, where a **Type** control records that provenance and a **Process** control says which film the numbers describe — that is what decides where the profile appears and whether it applies, so a matrix you build for slides needs it set to E-6. Anything created with **+** is already set to the process you are working in. When the current film process has no matrices at all, the dropdown and **Strength** are disabled and a hint says so, but the editor button stays live — it is the way to build the first one. @@ -309,7 +309,7 @@ The foundation of every edit: film type, how the scan is decoded, and how the ne * **Linear RAW**: (default off) decodes with neutral multipliers for completely raw data. When toggled off decodes RAW with the camera's as-shot white balance. Toggling reloads the file. Let the **Scanning setup** wizard pick it, or try both and pick which yields better results for your setup. * **Narrowband**: corrects the oversaturation typical of narrowband (RGB-LED trichrome) scans using a bundled input profile. Leave off for ordinary broadband scans. An explicit Input ICC in Export overrides it. * **Lock Bounds**: freezes the analyzed normalization bounds for this frame, so cropping or moving sliders no longer re-analyzes it. Lock in once you're happy with the bounds. -* **Mode**: `C41` (colour negative), `B&W`, or `E-6` (slide/reversal). Changes the core conversion math and re-runs the pipeline from scratch. The wand button beside it **auto-detects** the mode when a file loads. +* **Mode**: `Color Negative` (C-41), `B&W Negative` (panchromatic), or `Transparency` (slide/reversal, E-6 and friends). Changes the core conversion math and re-runs the pipeline from scratch. The wand button beside it **auto-detects** the mode when a file loads. What the wizard sets, by rig: | Capture | Light source | Linear RAW | Narrowband | @@ -330,13 +330,13 @@ Applying it sets the defaults for newly loaded files, updates the open frame, an * **Luma Range Clip** (-100 to 100): how aggressively the tonal range (black/white-point span) is set. Neutral already applies a small robust clip. Positive tightens it, which is good for dense or fogged negatives where a few stray pixels would push the bounds to extremes. Negative pushes the bounds *outward* for lifted blacks / unclipped highlights. * **Colour Clip** (-100 to 100): the per-channel colour-balance clip (orange-mask removal), independent of the tonal range. Positive tightens channel balance; negative samples nearer the extremes. -* **Global / R / G / B** selector → **White Point** / **Black Point** (-0.25 to 0.25): manual offsets on top of the auto-detected bounds. Positive white point brightens; positive black point lifts blacks. In R/G/B mode these become per-layer trims: per-dye-layer film-base (Dmin) and Dmax corrections, i.e. scanner-style per-channel levels. The selector is hidden in B&W, where per-layer trims are meaningless, and on E-6 with Normalize off, where the sliders it scopes are hidden with the rest of the normalization tuning. +* **Global / R / G / B** selector → **White Point** / **Black Point** (-0.25 to 0.25): manual offsets on top of the auto-detected bounds. Positive white point brightens; positive black point lifts blacks. In R/G/B mode these become per-layer trims: per-dye-layer film-base (Dmin) and Dmax corrections, i.e. scanner-style per-channel levels. The selector is hidden in B&W Negative, where per-layer trims are meaningless, and in Transparency with Normalize off, where the sliders it scopes are hidden with the rest of the normalization tuning. **Crosstalk**, **Hue Trim** and the sensor unmix all live in **Calibration** (§4.2) — they correct the capture rather than the negative-to-positive conversion. -> **No E-6 matrix ships with NegPy.** On slides the Matrix dropdown therefore starts empty, and it and Strength are disabled until a matrix exists — but the editor button stays live, so you can build your own (press **+**, and it is created for the process you are in). A `.toml` marked `process = "E-6"` dropped into your crosstalk folder works too. Be aware it means something different there: on a negative the dyes' unwanted absorptions are an error to remove before inversion, so unmixing moves the render *toward* the scene, but a transparency **is** the finished image — what you see on a lightbox already includes those absorptions — so unmixing moves it *away* from the slide's own look. On E-6 treat it as a colour-separation control, not a fidelity correction. **Hue Trim** is unaffected by any of this: it corrects the light source, so it applies to slides exactly as it does to negatives. +> **No Transparency matrix ships with NegPy.** On slides the Matrix dropdown therefore starts empty, and it and Strength are disabled until a matrix exists — but the editor button stays live, so you can build your own (press **+**, and it is created for the process you are in). A `.toml` marked `process = "Transparency"` dropped into your crosstalk folder works too (the pre-rename `process = "E-6"` still loads). Be aware it means something different there: on a negative the dyes' unwanted absorptions are an error to remove before inversion, so unmixing moves the render *toward* the scene, but a transparency **is** the finished image — what you see on a lightbox already includes those absorptions — so unmixing moves it *away* from the slide's own look. In Transparency treat it as a colour-separation control, not a fidelity correction. **Hue Trim** is unaffected by any of this: it corrects the light source, so it applies to slides exactly as it does to negatives. -**Normalize** (E-6 only): the switch between two ways of rendering a slide. +**Normalize** (Transparency only): the switch between two ways of rendering a slide. * **On**: auto-stretches the histogram to fill the dynamic range, metered per frame, and prints it through the paper model like a negative. This is a **rescue tool for faded or expired slides** — that is what it was added for. Where the dyes have lost their range, metering it back per frame is exactly right, and because the stretch is metered, two exposures of the same slide converge on a similar render. @@ -351,7 +351,7 @@ Applying it sets the defaults for newly loaded files, updates the open frame, an **Linear RAW** and **Narrowband** are hidden here, because neither applies to an as-captured render and both are made inert rather than merely hidden (they are sticky settings, so leaving them live but invisible would be a trap). Linear RAW decodes without the as-shot white balance, which the camera matrix assumes is present — the multipliers are folded back in, so the render is identical either way. Narrowband's bundled input profile is suppressed, since the camera matrix has already reached the working space and a second input characterisation would compete with it. An explicit Input ICC in Export still applies. - Narrowband capture is not recommended for this mode in any case: reproducing a slide's appearance is a colorimetric problem, and narrowband illumination samples the spectrum at three isolated wavelengths, so the inter-band overlap the eye integrates is never measured (the same reason narrowband scans render oversaturated and hue-rotated). Its real payoffs — defeating the orange mask, clean dye separation ahead of a high-gain inversion — belong to negatives, and a transparency has neither. Both toggles keep working normally for C-41, B&W and E-6 with Normalize on. + Narrowband capture is not recommended for this mode in any case: reproducing a slide's appearance is a colorimetric problem, and narrowband illumination samples the spectrum at three isolated wavelengths, so the inter-band overlap the eye integrates is never measured (the same reason narrowband scans render oversaturated and hue-rotated). Its real payoffs — defeating the orange mask, clean dye separation ahead of a high-gain inversion — belong to negatives, and a transparency has neither. Both toggles keep working normally for Color Negative, B&W Negative and Transparency with Normalize on. ### 4.4 Roll Analysis: a consistent look across the roll @@ -422,9 +422,9 @@ Colour timing, like the dichroic filters on an enlarger head. A **Global / Shado * **Reset** (undo-arrow icon): return the selected region's temperature and CMY to neutral. * **Temperature**: a warm↔cool lever driving the region's magenta/yellow pair (cyan stays put, as in a real darkroom). * **Cyan / Magenta / Yellow** (-1 to 1): the three filtration axes, Cyan↔Red, Magenta↔Green and Yellow↔Blue. -* **Cast Removal** (0.0 to 1.0, **C-41 only**): neutralizes the residual colour cast a negative leaves in the print, balancing each layer so greys stay neutral from deep shadows through highlights. Applied strength scales with how many clean near-neutrals the frame has. Default ~0.5; 0 turns it off. +* **Cast Removal** (0.0 to 1.0, **Color Negative only**): neutralizes the residual colour cast a negative leaves in the print, balancing each layer so greys stay neutral from deep shadows through highlights. Applied strength scales with how many clean near-neutrals the frame has. Default ~0.5; 0 turns it off. - Hidden in E-6 and B&W, because the render ignores it there. What it defeats is the **orange mask** — a cast the manufacturer built into the film, not part of the picture. A slide has no mask and its cast *is* the photograph, so solving for a neutral axis would strip out the light you shot in; B&W has one emulsion and no channels to balance. For a slide's colour use **Temperature** and the CMY sliders above, or **Hue Trim** (§4.2) if an unusual scanning light has rotated the hues. + Hidden in Transparency and B&W Negative, because the render ignores it there. What it defeats is the **orange mask** — a cast the manufacturer built into the film, not part of the picture. A slide has no mask and its cast *is* the photograph, so solving for a neutral axis would strip out the light you shot in; a B&W negative has one emulsion and no channels to balance. For a slide's colour use **Temperature** and the CMY sliders above, or **Hue Trim** (§4.2) if an unusual scanning light has rotated the hues. * **Ring-around** (target icon, or `Shift+F`): prints the frame as a 5×5 mosaic stepping 2cc at a time out to ±4cc on the magenta and yellow axes, so the direction of a colour cast is visible instead of guessed. Each patch is a real render of the part of the frame it covers; click one to keep its filtration. The ladder is absolute and centred on neutral, so a ring printed off one frame compares to the next. `Escape` or a second press clears it, and any edit drops it. See **Rotating a proof** below. @@ -448,14 +448,14 @@ The paper's response. A **Global / R / G / B** selector at the top scopes most c * **ISO-R Grade** (50 to 180): contrast, as a paper ISO-R value. R110 ≈ classic grade 2; **lower R = harder** (more contrast), higher = softer. In R/G/B mode a **Grade** trim rotates one layer's slope about the midtone. * **Shadows Density** (±0.9 ΔD) / **Highlights Density** (±0.5 ΔD): brighten or darken just the shadow or highlight zone, without reshaping the curve. Bounded by paper black/white so a burn can't exceed the print's limits. The ranges differ because density is logarithmic: the same ΔD reads far smaller near paper black than near paper white. - These two also work on E-6 with **Normalize off**, on the same tones (the centres are mapped by position on each curve's own scale, not by raw density), and they are the only mid-sparing controls there — Grade and Toe both drag the whole scale with them. On a slide, Shadows Density alone lifted the quarter-tone from 0.15 to 0.23 with the highlights unmoved (0.946 to 0.947); getting a comparable lift out of Grade and Toe together also dragged the midtones and cost the highlights. + These two also work in Transparency with **Normalize off**, on the same tones (the centres are mapped by position on each curve's own scale, not by raw density), and they are the only mid-sparing controls there — Grade and Toe both drag the whole scale with them. On a slide, Shadows Density alone lifted the quarter-tone from 0.15 to 0.23 with the highlights unmoved (0.946 to 0.947); getting a comparable lift out of Grade and Toe together also dragged the midtones and cost the highlights. * **Shadows Grade** / **Highlights Grade** (split grade, ±50 ISO-R): rotate contrast locally in the deep shadows or highlights, the digital equivalent of split-grade printing. -* **Dye Separation** (0.5 to 1.5, hidden in B&W): saturation in density space. It pushes the print's three dye densities apart *before* the positive is decoded, in the same matrix the paper's own dye crosstalk uses. So it responds to the paper profile you picked, and it eases off automatically where the curve is already compressed at toe and shoulder, instead of forcing colour into tones that have none left to give. Below 1.0 pulls the dyes together instead, toward neutral. 1.0 = off. (Contrast **Chroma** in the Colour tab, which scales colour evenly after decode.) -* **Separation Damping** (0 to 1, hidden in B&W): decides *where* the Dye Separation push lands, rather than adding a push of its own. At 0 every colour gets the same treatment. Turn it up and muted colour keeps the full push while colour that is already saturated gets the opposite, so a hard push puts colour into the tones that had none instead of driving the strongest colours until they flatten into a slab. Below 1.0 separation it mirrors: pastels go grey while the vivid colours survive. **Dead at Dye Separation 1.0**, where the slider greys out, because it has no look of its own. This is not the same as backing Dye Separation off: a lower value takes colour from *everything*, including the tones that had little to start with, where turning damping up takes it only from the colours that already have plenty. +* **Dye Separation** (0.5 to 1.5, hidden in B&W Negative): saturation in density space. It pushes the print's three dye densities apart *before* the positive is decoded, in the same matrix the paper's own dye crosstalk uses. So it responds to the paper profile you picked, and it eases off automatically where the curve is already compressed at toe and shoulder, instead of forcing colour into tones that have none left to give. Below 1.0 pulls the dyes together instead, toward neutral. 1.0 = off. (Contrast **Chroma** in the Colour tab, which scales colour evenly after decode.) +* **Separation Damping** (0 to 1, hidden in B&W Negative): decides *where* the Dye Separation push lands, rather than adding a push of its own. At 0 every colour gets the same treatment. Turn it up and muted colour keeps the full push while colour that is already saturated gets the opposite, so a hard push puts colour into the tones that had none instead of driving the strongest colours until they flatten into a slab. Below 1.0 separation it mirrors: pastels go grey while the vivid colours survive. **Dead at Dye Separation 1.0**, where the slider greys out, because it has no look of its own. This is not the same as backing Dye Separation off: a lower value takes colour from *everything*, including the tones that had little to start with, where turning damping up takes it only from the colours that already have plenty. **Paper Response**, the characteristic-curve shape: -* **Paper profile**: a bundled darkroom-paper profile (RA4 colour papers in C-41, tonal B&W papers in B&W). Re-shapes the curve as a baseline; Grade/Density/toe/shoulder still trim on top. *Neutral* reproduces the defaults. Each B&W paper also carries its own lith colour path, which the Lith panel picks up: Fomatone liths warm and colourful, while *Neutral* and Ilford Multigrade stay nearly colourless. +* **Paper profile**: a bundled darkroom-paper profile (RA4 colour papers in Color Negative, tonal B&W papers in B&W Negative). Re-shapes the curve as a baseline; Grade/Density/toe/shoulder still trim on top. *Neutral* reproduces the defaults. Each B&W paper also carries its own lith colour path, which the Lith panel picks up: Fomatone liths warm and colourful, while *Neutral* and Ilford Multigrade stay nearly colourless. * **Paper White**: simulate paper base density, so whites print at ~0.93 instead of pure white, like a real print. * **Paper Black**: show the paper's true (slightly milky) Dmax instead of compensating it to pure display black. Off (default) applies black-point compensation so the adapted eye reads black as black. * **Snap** (-0.5 to 0.5): midtone gamma, steepening or flattening the S-curve around the reference tone while paper white/black stay put. @@ -498,9 +498,9 @@ Every mask is on the map, including ones whose outline you hid with the eye: tha ### 7.1 Lab: polish and detail -Mimics what a lab scanner (Frontier/Noritsu) does automatically. Colour controls hide in B&W mode. +Mimics what a lab scanner (Frontier/Noritsu) does automatically. Colour controls hide in B&W Negative mode. -**Colour** (hidden in B&W): +**Colour** (hidden in B&W Negative): * **Chroma** (0.0 to 2.0): a colour scale applied after the print is decoded, even across every tone, so it is a retouching move rather than a density-space one. 1.0 = unchanged, 0 = greyscale, 2.0 = double. For saturation that behaves like a print instead, reach for **Dye Separation** in the Exposure tab. Below 1.0 is a flat scale; above 1.0, pixels that would clip the display gamut get a soft per-pixel knee toward their own in-gamut headroom instead of a hard per-channel clamp, since clamping only the overshooting channel(s) shifts the hue the flat scale itself preserves. * **Skin Protection** (0.0 to 1.0, default 0.5): holds skin-hued colour under a chroma ceiling so faces don't go sunburnt. Hue and lightness are untouched and chroma is only ever pulled down, never added, so asking Chroma for 0 still gives you greyscale. It is independent of Chroma and works with it at 1.0 — skin that arrived over-saturated from the print curve or the filtration gets reined in just the same. Higher values lower the ceiling: the 0.5 default only catches genuinely excessive chroma, 1.0 leaves skin matte, 0 is off. The mask is warm hue *and* skin's own chroma *and* mid lightness together, which is what keeps a red coat, a saturated sunset, brick or autumn colour out of it. What it cannot separate is warm objects sitting at the same chroma as skin — bare wood, tan leather, sand — which soften along with it. The same bound cuts the other way: skin that arrives really excessive (a sunburn) is only partly caught, so reach for Chroma or the Filtration panel for that. @@ -525,7 +525,7 @@ Mimics what a lab scanner (Frontier/Noritsu) does automatically. Colour controls ### 7.2 Alternative Processes -Two printing processes that are not ordinary silver-gelatin enlarging. Pick one with the **None / Lith / Cyanotype** buttons at the top; only that process's controls are shown. Both are B&W only, and both are off by default. +Two printing processes that are not ordinary silver-gelatin enlarging. Pick one with the **None / Lith / Cyanotype** buttons at the top; only that process's controls are shown. Both are B&W Negative only, and both are off by default. #### Lith @@ -558,9 +558,9 @@ While Cyanotype is selected, every chemical toner greys out in the Toning panel ### 7.3 Toning -Colour the print itself rather than the scene: chemical toners that convert the silver (B&W only), and a split tint that works in any mode. Lith silver is much finer than normal print silver, so the toners bite harder and differently on a lith print. With Lith on, only Selenium and Gold stay enabled; with Cyanotype on all six grey out, because there is no silver in the print at all. +Colour the print itself rather than the scene: chemical toners that convert the silver (B&W Negative only), and a split tint that works in any mode. Lith silver is much finer than normal print silver, so the toners bite harder and differently on a lith print. With Lith on, only Selenium and Gold stay enabled; with Cyanotype on all six grey out, because there is no silver in the print at all. -**Chemical Toning** (B&W only), simulated as sequential toner baths, in the order shown, each strength 0.0 to 2.0: +**Chemical Toning** (B&W Negative only), simulated as sequential toner baths, in the order shown, each strength 0.0 to 2.0: * **Selenium**: deeper blacks, cool eggplant shadows. On a lith print it reaches much further down the scale, lifts Dmax hard and turns the green-black shadows magenta. * **Sepia**: warm highlights first (partial strength gives split-sepia). diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index e0b69e7d..2c5b815e 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -96,6 +96,7 @@ from negpy.features.retouch.logic import downsample_ir, trace_scratch from negpy.features.retouch.models import RetouchConfig from negpy.features.toning.models import ToningConfig +from negpy.infrastructure.capture.settings import WhiteCaptureMode from negpy.infrastructure.display.color_spaces import ColorSpaceRegistry from negpy.infrastructure.filesystem.watcher import FolderWatchService from negpy.infrastructure.gpu.device import GPUDevice @@ -3370,9 +3371,9 @@ def _on_capture_finished(self, paths: list) -> None: self.session.repo.save_global_setting("rgbscan_mode", rgb and not white) capture_roll = getattr(req, "roll_name", "") if req is not None else "" capture_frame = getattr(req, "frame_number", None) if req is not None else None - if white: # slides/B&W force a positive process - mode = (req.white_process_mode or "auto").lower() - target = {"e-6": ProcessMode.E6, "b&w": ProcessMode.BW}.get(mode) + if white: # slides / B&W negatives force a positive process + mode = WhiteCaptureMode(req.white_process_mode) + target = {WhiteCaptureMode.E6: ProcessMode.E6, WhiteCaptureMode.BW: ProcessMode.BW}.get(mode) self._pending_capture_imports[_capture_import_key(paths[0])] = _PendingCaptureImport( process_mode=target, detect_mode=target is None, diff --git a/negpy/desktop/view/sidebar/colour.py b/negpy/desktop/view/sidebar/colour.py index cf52106d..1abd8a8b 100644 --- a/negpy/desktop/view/sidebar/colour.py +++ b/negpy/desktop/view/sidebar/colour.py @@ -89,7 +89,7 @@ def _init_ui(self) -> None: self.cast_removal_slider.setToolTip( "Cast Removal: neutralizes the colour cast a negative leaves in the print — balances each " "colour layer so greys stay neutral from deep shadows through highlights. 0 = off, 1 = full." - "

C-41 only, and hidden elsewhere: it defeats the orange mask, a manufactured cast " + "

Color Negative only, and hidden elsewhere: it defeats the orange mask, a manufactured cast " "that is not part of the picture. A slide has no mask — its cast IS the photograph — so " "solving for a neutral axis there would remove the light you shot in. For a slide use " "Temperature and the CMY sliders, or Hue Trim for an odd light source." diff --git a/negpy/desktop/view/sidebar/controls_panel.py b/negpy/desktop/view/sidebar/controls_panel.py index 77e02275..d5dec55d 100644 --- a/negpy/desktop/view/sidebar/controls_panel.py +++ b/negpy/desktop/view/sidebar/controls_panel.py @@ -651,14 +651,14 @@ def apply_shortcut_tooltips(self) -> None: ton.selenium_slider.setToolTip( tooltip_with_shortcut( - "Simulates selenium toning — converts the densest silver first: deeper blacks, cool eggplant shadows. B&W mode only", + "Simulates selenium toning — converts the densest silver first: deeper blacks, cool eggplant shadows. B&W Negative mode only", ["selenium_inc", "selenium_dec"], ) ) ton.sepia_slider.setToolTip( tooltip_with_shortcut( "Simulates sepia bleach-redevelop toning — warms the highlights first while shadows hold; " - "partial strength gives the classic split-sepia look. B&W mode only", + "partial strength gives the classic split-sepia look. B&W Negative mode only", ["sepia_inc", "sepia_dec"], ) ) diff --git a/negpy/desktop/view/sidebar/files.py b/negpy/desktop/view/sidebar/files.py index df1e2754..3c121a35 100644 --- a/negpy/desktop/view/sidebar/files.py +++ b/negpy/desktop/view/sidebar/files.py @@ -1140,7 +1140,7 @@ def _add_hdr_merge_action(self, menu, state) -> None: black-and-white negative nearer 4 — both inside a single capture, so a bracket buys nothing. A transparency runs to 10-12, which is what the merge exists for. - Hidden on C-41, disabled with a reason on B&W: reversal-processed monochrome + Hidden on Color Negative, disabled with a reason on B&W Negative: reversal-processed monochrome (Scala, dr5, Fomapan R) *is* a transparency and does have the range, it is simply not wired yet, and a missing menu entry would leave nobody anything to ask about. """ @@ -1148,9 +1148,11 @@ def _add_hdr_merge_action(self, menu, state) -> None: idx = state.selected_file_idx assets = state.uploaded_files - mode = str(self.controller.state.config.process.process_mode) + mode = self.controller.state.config.process.process_mode if 0 <= idx < len(assets): - mode = str(assets[idx].get("process_mode") or mode) + # Coerced, not compared raw: a session blob written before the mode rename + # still carries the old names. + mode = ProcessMode(assets[idx].get("process_mode") or mode) if mode == ProcessMode.C41: return act = menu.addAction("Merge exposures (HDR)") diff --git a/negpy/desktop/view/sidebar/process.py b/negpy/desktop/view/sidebar/process.py index b75da9d3..d2acb378 100644 --- a/negpy/desktop/view/sidebar/process.py +++ b/negpy/desktop/view/sidebar/process.py @@ -105,8 +105,8 @@ def _init_ui(self) -> None: self.mode_combo = QComboBox() self.mode_combo.addItems([m.value for m in ProcessMode]) self.mode_combo.setCurrentText(conf.process_mode) - self.mode_combo.setToolTip("Film process mode: C41 (colour negative), B&W (panchromatic), E-6 (slide/reversal)") - self.autodetect_btn = self._small_toggle("mdi6.auto-fix", "", False, "Auto-detect film process (C41/B&W/E-6) on load") + self.mode_combo.setToolTip("Film process mode: Color Negative (C-41), B&W Negative (panchromatic), Transparency (slide/reversal)") + self.autodetect_btn = self._small_toggle("mdi6.auto-fix", "", False, "Auto-detect the film process on load") self.autodetect_btn.setFixedWidth(28) mode_row.addWidget(field_label("Mode")) mode_row.addWidget(self.mode_combo, stretch=1) diff --git a/negpy/desktop/view/sidebar/scanlight.py b/negpy/desktop/view/sidebar/scanlight.py index 620a6d52..d42cb0ec 100644 --- a/negpy/desktop/view/sidebar/scanlight.py +++ b/negpy/desktop/view/sidebar/scanlight.py @@ -35,7 +35,7 @@ from negpy.desktop.view.styles.templates import section_subheader from negpy.desktop.view.styles.theme import THEME from negpy.infrastructure.capture.gphoto import default_settings_path -from negpy.infrastructure.capture.settings import ScanlightSettings +from negpy.infrastructure.capture.settings import ScanlightSettings, WhiteCaptureMode from negpy.services.capture.calibration import REFERENCE_LEVELS, SHUTTER_CANDIDATES, normalize_start_point, shutter_seconds, usable_ladder from negpy.services.capture.presets import PresetStore, ScanlightPreset, framing_levels @@ -61,10 +61,10 @@ } # Built-in white-light preset (no calibration needed): name → process mode. -# Selecting it switches the panel to a single white-light exposure. B&W and slide/E-6 +# Selecting it switches the panel to a single white-light exposure. B&W and slide film # share the *same* light (plain white), so they're one preset; which process to run is -# left to NegPy's autodetect ("auto") — the user can still force it in NegPy if needed. -_BUILTIN_WHITE_PRESETS = {"White Light (B&W or Slide Film)": "auto"} +# left to NegPy's autodetect — the user can still force it in NegPy if needed. +_BUILTIN_WHITE_PRESETS = {"White Light (B&W or Slide Film)": WhiteCaptureMode.AUTO} # A dropdown sentinel (not a real preset name — user names are stripped, so a NUL can't collide): # picking it unlocks the sliders + exposure steppers to build a preset by hand, then Save bakes it. diff --git a/negpy/desktop/view/sidebar/toning.py b/negpy/desktop/view/sidebar/toning.py index 49ad214a..e7223ba5 100644 --- a/negpy/desktop/view/sidebar/toning.py +++ b/negpy/desktop/view/sidebar/toning.py @@ -25,19 +25,19 @@ def _init_ui(self) -> None: self.sepia_slider = CompactSlider("Sepia", 0.0, 2.0, conf.sepia_strength) self.gold_slider = CompactSlider("Gold", 0.0, 2.0, conf.gold_strength) self.gold_slider.setToolTip( - "Simulates gold toning — cool blue-black on untoned silver, slight Dmax boost; over sepia it shifts the highlights orange-red (B&W only)" + "Simulates gold toning — cool blue-black on untoned silver, slight Dmax boost; over sepia it shifts the highlights orange-red (B&W Negative only)" ) self.blue_slider = CompactSlider("Iron Blue", 0.0, 2.0, conf.blue_strength) self.blue_slider.setToolTip( - "Simulates iron blue (Prussian blue) toning — blues the image shadows-first and intensifies: deeper navy blacks (B&W only)" + "Simulates iron blue (Prussian blue) toning — blues the image shadows-first and intensifies: deeper navy blacks (B&W Negative only)" ) self.copper_slider = CompactSlider("Copper", 0.0, 2.0, conf.copper_strength) self.copper_slider.setToolTip( - "Simulates copper toning — pink to brick-red shift with the classic Dmax loss: blacks weaken as the bath bleaches (B&W only)" + "Simulates copper toning — pink to brick-red shift with the classic Dmax loss: blacks weaken as the bath bleaches (B&W Negative only)" ) self.vanadium_slider = CompactSlider("Vanadium", 0.0, 2.0, conf.vanadium_strength) self.vanadium_slider.setToolTip( - "Simulates vanadium green toning — bleach-then-tone greens the mids and highlights while deep shadows keep their black silver (B&W only)" + "Simulates vanadium green toning — bleach-then-tone greens the mids and highlights while deep shadows keep their black silver (B&W Negative only)" ) for left, right in ( (self.selenium_slider, self.sepia_slider), diff --git a/negpy/desktop/view/widgets/crosstalk_editor_dialog.py b/negpy/desktop/view/widgets/crosstalk_editor_dialog.py index 17178a75..d127c268 100644 --- a/negpy/desktop/view/widgets/crosstalk_editor_dialog.py +++ b/negpy/desktop/view/widgets/crosstalk_editor_dialog.py @@ -24,20 +24,23 @@ from negpy.desktop.view.styles.theme import THEME from negpy.desktop.view.widgets.sliders import CompactSlider from negpy.features.process.models import DEFAULT_CROSSTALK_MATRIX, ProcessMode -from negpy.services.assets.crosstalk import TYPE_MEASURED, TYPE_SPECSHEET, TYPE_TUNED, CrosstalkProfiles +from negpy.services.assets.crosstalk import CrosstalkProfiles, CrosstalkType #: Selectable provenances, in dropdown group order. "Other" is not offered: it exists to #: keep a hand-written type loadable, not as something to choose. +#: Plain str values, like _PROCESS_CHOICES below: combo item data round-trips through +#: QVariant, which does not match an enum member against the equal string from disk. _TYPE_CHOICES: tuple[tuple[str, str], ...] = ( - (TYPE_TUNED, "Tuned on a rig"), - (TYPE_MEASURED, "Measured"), - (TYPE_SPECSHEET, "From spec sheets (approx)"), + (str(CrosstalkType.TUNED), "Tuned on a rig"), + (str(CrosstalkType.MEASURED), "Measured"), + (str(CrosstalkType.SPECSHEET), "From spec sheets (approx)"), ) -#: Film processes a matrix can describe. B&W has one emulsion, so there is nothing to unmix. +#: Film processes a matrix can describe. A B&W negative has one emulsion, so there is +#: nothing to unmix. _PROCESS_CHOICES: tuple[tuple[str, str], ...] = ( - (str(ProcessMode.C41), "C41 — colour negative"), - (str(ProcessMode.E6), "E-6 — slide / reversal"), + (str(ProcessMode.C41), "Color Negative (C-41)"), + (str(ProcessMode.E6), "Transparency (E-6)"), ) @@ -205,7 +208,7 @@ def _init_ui(self) -> None: "
" "The film process these numbers describe. A matrix only reaches the render — and only " "appears in the sidebar's Matrix dropdown — while NegPy is in this mode.

" - "Dye sets do not carry across: a C41 matrix does not describe E-6's dyes, so applying " + "Dye sets do not carry across: a colour negative matrix does not describe a slide's dyes, so applying " "one to a slide corrects a leak that is not there. Note also that on a positive an unmix " "moves the render away from the slide's own colour — use it as a separation " "control, not for fidelity." @@ -359,7 +362,7 @@ def _all_names(self) -> list: return CrosstalkProfiles.list_profiles() def selected_type(self) -> str: - return self.type_combo.currentData() or TYPE_TUNED + return self.type_combo.currentData() or CrosstalkType.TUNED def selected_process(self) -> str: return self.process_combo.currentData() or str(ProcessMode.C41) @@ -372,8 +375,8 @@ def _set_type(self, value: str) -> None: """Select `value`, falling back to Tuned for a built-in or hand-written type. Not the first entry: saving must not relabel an unknown type as a spec-sheet claim.""" - idx = self.type_combo.findData(value) - self.type_combo.setCurrentIndex(idx if idx >= 0 else self.type_combo.findData(TYPE_TUNED)) + idx = self.type_combo.findData(str(value)) + self.type_combo.setCurrentIndex(idx if idx >= 0 else self.type_combo.findData(str(CrosstalkType.TUNED))) def _set_grid(self, flat: List[float]) -> None: grid = flat_to_grid(flat) diff --git a/negpy/desktop/view/widgets/stats.py b/negpy/desktop/view/widgets/stats.py index 4a2aeaec..55e9d372 100644 --- a/negpy/desktop/view/widgets/stats.py +++ b/negpy/desktop/view/widgets/stats.py @@ -39,7 +39,7 @@ class DensitometerRow(QWidget): "Spot densitometer — hover the image to read the pixel: per-channel density above film base " "(ΔD, relative to this scan's normalization, not absolute), the displayed tone's reflection " "print density, and its print zone (0 = paper black, V = 18% mid-gray, X = paper white). " - "In B&W mode the ΔD channels read the pre-conversion colour record." + "In B&W Negative mode the ΔD channels read the pre-conversion colour record." ) def __init__(self, parent=None): diff --git a/negpy/desktop/view/widgets/tutorial_steps.py b/negpy/desktop/view/widgets/tutorial_steps.py index d88fdce8..34ef7621 100644 --- a/negpy/desktop/view/widgets/tutorial_steps.py +++ b/negpy/desktop/view/widgets/tutorial_steps.py @@ -233,8 +233,8 @@ def _edge_burn(w: "MainWindow") -> Optional[QWidget]: "The Process panel is the foundation of every edit: what kind of film this is, " "how the RAW is decoded, and how the negative is normalized into a " "positive.

" - "Mode picks the chemistry — C41 (colour negative), B&W " - "(panchromatic) or E-6 (slide/reversal). It swaps the core conversion " + "Mode picks the chemistry — Color Negative (C-41), B&W Negative " + "(panchromatic) or Transparency (slide/reversal). It swaps the core conversion " "math and re-runs the pipeline from scratch, and the wand beside it " "auto-detects the mode when a file loads.

" "Linear RAW decodes with neutral multipliers, bypassing the camera's " @@ -246,7 +246,7 @@ def _edge_burn(w: "MainWindow") -> Optional[QWidget]: "Narrowband from the answer. It opens by itself once this tour is done.

" "Lock Bounds freezes this frame's analysed bounds, so cropping or moving " "a slider no longer re-meters it — lock in once the conversion looks right. In " - "E-6 mode a Normalize button appears at the bottom, stretching a faded " + "Transparency mode a Normalize button appears at the bottom, stretching a faded " "or expired slide back to the full range." ), target=_mode, @@ -271,7 +271,7 @@ def _edge_burn(w: "MainWindow") -> Optional[QWidget]: "B selector scopes them: Global moves both bounds together, while R, G and " "B become per-dye-layer film-base (Dmin) and Dmax trims, like a scanner's " "per-channel levels — the tool for a mask that reads slightly off in one " - "layer. Hidden in B&W.

" + "layer. Hidden in B&W Negative.

" "The stretch is unclamped: tones outside the bounds survive and roll " "off later in the print curve's toe and shoulder." ), @@ -437,7 +437,7 @@ def _edge_burn(w: "MainWindow") -> Optional[QWidget]: "the zone densities, the autos and the paper toggles are properties of the " "print, not of one emulsion. A dot on a channel button marks a layer you've " "already trimmed, so casts you fixed weeks ago stay findable. The whole " - "selector disappears in B&W — one emulsion, one curve." + "selector disappears in B&W Negative — one emulsion, one curve." ), target=_channel_selector, section_attr="tone_section", @@ -482,8 +482,8 @@ def _edge_burn(w: "MainWindow") -> Optional[QWidget]: "without touching contrast or exposure. Each profile carries its paper's " "tone, per-channel gamma and base tint, mapped from Ilford / Kodak / Foma / " "Fuji datasheets.

" - "Profiles are mode-aware (RA4 colour papers in C-41, tonal papers in B&W) and " - "sticky roll-wide; the dropdown steps aside entirely in E-6, where a slide is " + "Profiles are mode-aware (RA4 colour papers in Color Negative, tonal papers in B&W Negative) and " + "sticky roll-wide; the dropdown steps aside entirely in Transparency, where a slide is " "the final image and no paper is involved. Neutral reproduces the " "defaults exactly — Grade and Density still trim on top." ), @@ -531,7 +531,7 @@ def _edge_burn(w: "MainWindow") -> Optional[QWidget]: "hue angles with their own strength. It works in Lab space, so luminance — and " "therefore grain and detail — is preserved exactly.

" "Selenium and Sepia simulate classic chemical toners on the print's " - "silver density (B&W mode only): selenium converts the densest silver first — " + "silver density (B&W Negative mode only): selenium converts the densest silver first — " "deeper blacks and cool eggplant shadows; sepia bleach-redevelops the thinnest " "silver first — warm highlights that hold the shadows (partial strength gives the " "classic split-sepia look).

" diff --git a/negpy/desktop/workers/capture_worker.py b/negpy/desktop/workers/capture_worker.py index aea0ba33..5053c61c 100644 --- a/negpy/desktop/workers/capture_worker.py +++ b/negpy/desktop/workers/capture_worker.py @@ -23,6 +23,7 @@ ) from negpy.infrastructure.capture.protocol import describe_hardware, has_white_channel from negpy.infrastructure.capture.scanlight import Scanlight +from negpy.infrastructure.capture.settings import WhiteCaptureMode from negpy.kernel.system.logging import get_logger from negpy.services.capture.calibration import REFERENCE_LEVELS, REFERENCE_SHUTTER, CalibrationExposureError, CalibrationService, Roi from negpy.services.capture.service import CaptureService, capture_single @@ -42,7 +43,7 @@ class CaptureRequest: white_mode: bool = False w_level: int = 255 shutter_w: str = "" - white_process_mode: str = "auto" # "auto" | "E-6" | "B&W" + white_process_mode: WhiteCaptureMode = WhiteCaptureMode.AUTO is_retake: bool = False # a retake overwrites an existing frame → keep its files on abort rgb_mode: bool = True # True = Scanlight R/G/B triplet; False = one plain white-light shot (no Scanlight) iso: str = "" # RGB preset's baked ISO/aperture — the triplet forces them; "" = leave as set diff --git a/negpy/domain/interfaces.py b/negpy/domain/interfaces.py index 680ae86e..945044e9 100644 --- a/negpy/domain/interfaces.py +++ b/negpy/domain/interfaces.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, field from negpy.domain.types import ImageBuffer, ROI, Dimensions from negpy.domain.models import WorkspaceConfig +from negpy.features.process.models import ProcessMode @dataclass @@ -19,7 +20,7 @@ class PipelineContext: original_size: Dimensions scale_factor: float - process_mode: str = "C41" + process_mode: ProcessMode = ProcessMode.C41 active_roi: Optional[ROI] = None metrics: dict[str, Any] = field(default_factory=dict) # When set, the crop tool is active: the final crop slice and uv_grid are bypassed diff --git a/negpy/domain/migrations.py b/negpy/domain/migrations.py index 4f44b4c2..ea8ee44b 100644 --- a/negpy/domain/migrations.py +++ b/negpy/domain/migrations.py @@ -5,8 +5,10 @@ Not here: coercions that must run on *every* construction rather than only on load — ``ExposureConfig.__post_init__`` (legacy 0-5 paper grade → ISO R, cast_removal -bool → strength) and the tuple-rehydrating ``__post_init__``s. String literals only -(no ``domain.models`` import — that module imports this one). +bool → strength), ``ProcessConfig.__post_init__`` (pre-rename process-mode names, +which also arrive from sticky settings and asset dicts) and the tuple-rehydrating +``__post_init__``s. String literals only (no ``domain.models`` import — that module +imports this one). Also not here: migrations that rewrite *rows* rather than a config payload, since those need a repository and this module stays dependency-free. They live beside the feature diff --git a/negpy/domain/models.py b/negpy/domain/models.py index 558576f0..b39f70ac 100644 --- a/negpy/domain/models.py +++ b/negpy/domain/models.py @@ -3,11 +3,17 @@ import uuid from dataclasses import dataclass, field, asdict, replace -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, TypeVar from enum import Enum, StrEnum from negpy.features.process.models import ProcessConfig from negpy.features.exposure.models import ExposureConfig, RenderIntent -from negpy.features.geometry.models import GeometryConfig +from negpy.features.geometry.models import ( # noqa: F401 (re-exported: the crop ratios were defined here before they moved beside GeometryConfig) + CROP_RATIO_CHOICES, + FILM_FORMAT_RATIOS, + AspectRatio, + GeometryConfig, + canonical_crop_ratio, +) from negpy.features.lab.models import LabConfig from negpy.features.local.models import LocalAdjustmentsConfig, LocalMask, MaskShape from negpy.features.retouch.models import RetouchConfig @@ -26,103 +32,6 @@ logger = get_logger("domain.models") -class AspectRatio(StrEnum): - FREE = "Free" - ORIGINAL = "Original" - R_1_1 = "1:1" - R_3_2 = "3:2" - R_4_3 = "4:3" - R_5_4 = "5:4" - R_6_7 = "6:7" - R_7_5 = "7:5" - R_65_24 = "65:24" - R_16_9 = "16:9" - R_16_10 = "16:10" - R_8_5_11 = "8.5:11" - # Reciprocal (portrait) mirrors of the ratios above. Not offered in the crop - # tool's ratio picker (see CROP_RATIO_CHOICES) — the crop tool auto-orients - # a ratio to match the current drag/box (overlay._oriented_target_ratio, - # geometry.logic._resolve_ratio_dims), so showing both forms there would just - # duplicate the same shape twice. Kept as real members because (a) the export - # "Paper ratio" picker does NOT auto-orient — a portrait vs. landscape paper - # size are genuinely different choices there — and (b) "Detect closest aspect - # ratio" needs both forms to match a portrait-oriented film frame correctly. - R_2_3 = "2:3" - R_3_4 = "3:4" - R_4_5 = "4:5" - R_7_6 = "7:6" - R_5_7 = "5:7" - R_24_65 = "24:65" - R_9_16 = "9:16" - R_10_16 = "10:16" - R_11_8_5 = "11:8.5" - - -# Ratios offered in the crop tool's ratio picker: one canonical entry per shape -# (see the AspectRatio docstring above for why the reciprocal forms exist but -# aren't listed here). FREE is included since it's the "no constraint" choice. -CROP_RATIO_CHOICES: list[AspectRatio] = [ - AspectRatio.FREE, - AspectRatio.R_1_1, - AspectRatio.R_3_2, - AspectRatio.R_4_3, - AspectRatio.R_5_4, - AspectRatio.R_6_7, - AspectRatio.R_7_5, - AspectRatio.R_65_24, - AspectRatio.R_16_9, - AspectRatio.R_16_10, - AspectRatio.R_8_5_11, -] - -# Maps each reciprocal (portrait) AspectRatio to the canonical entry shown in -# CROP_RATIO_CHOICES, so a value that only exists in its portrait form (e.g. from -# "Detect closest aspect ratio" matching a portrait-oriented frame, or a ratio -# saved before this consolidation) always resolves to something the ratio picker -# can display. -_PORTRAIT_TO_CANONICAL_CROP_RATIO: dict[str, str] = { - AspectRatio.R_2_3: AspectRatio.R_3_2, - AspectRatio.R_3_4: AspectRatio.R_4_3, - AspectRatio.R_4_5: AspectRatio.R_5_4, - AspectRatio.R_7_6: AspectRatio.R_6_7, - AspectRatio.R_5_7: AspectRatio.R_7_5, - AspectRatio.R_24_65: AspectRatio.R_65_24, - AspectRatio.R_9_16: AspectRatio.R_16_9, - AspectRatio.R_10_16: AspectRatio.R_16_10, - AspectRatio.R_11_8_5: AspectRatio.R_8_5_11, -} - - -def canonical_crop_ratio(ratio: str) -> str: - """Maps a ratio to the form shown in the crop tool's ratio picker. Portrait- - oriented AspectRatio values collapse to their landscape/canonical counterpart - (see _PORTRAIT_TO_CANONICAL_CROP_RATIO); "Free", "Original", and anything - already canonical pass through unchanged.""" - return _PORTRAIT_TO_CANONICAL_CROP_RATIO.get(ratio, ratio) - - -# Candidates for "Detect closest aspect ratio" (geometry.logic._closest_standard_ratio): -# real film/scan formats only, both orientations. 7:5, 16:9, 16:10 and 8.5:11 are -# print/screen *output* sizes, not scannable film formats, and sit close enough to -# 3:2 (1.4, 1.778, 1.6) and 5:4 (0.773) in log-ratio space that ordinary contour- -# detection noise on a real 3:2 or 5:4 frame tips the match onto one of them instead -# — including them here regressed detection to reliably misclassify 35mm scans as -# "7:5". Keep this set to formats a camera/scanner could actually produce. -FILM_FORMAT_RATIOS: list[AspectRatio] = [ - AspectRatio.R_1_1, - AspectRatio.R_3_2, - AspectRatio.R_2_3, - AspectRatio.R_4_3, - AspectRatio.R_3_4, - AspectRatio.R_5_4, - AspectRatio.R_4_5, - AspectRatio.R_6_7, - AspectRatio.R_7_6, - AspectRatio.R_65_24, - AspectRatio.R_24_65, -] - - class ExportFormat(StrEnum): JPEG = "JPEG" TIFF = "TIFF" @@ -143,6 +52,21 @@ class ExportResolutionMode(StrEnum): TARGET_PX = "target_px" +_EnumT = TypeVar("_EnumT", bound=Enum) + + +def coerce_enum(enum_cls: type[_EnumT], value: Any, default: _EnumT) -> _EnumT: + """`enum_cls(value)`, falling back to `default` for a value no longer offered. + + Saved presets and edits carry whatever the app wrote at the time, so a retired + or hand-edited value must degrade to the default instead of failing the load. + """ + try: + return enum_cls(value) + except ValueError: + return default + + class ICCMode(Enum): OUTPUT = "Output" INPUT = "Input" @@ -197,7 +121,7 @@ class ExportConfig: userDir: str = field(default_factory=paths.get_default_user_dir) export_path: str = field(default_factory=lambda: os.path.join(paths.get_default_user_dir(), "export")) - export_fmt: str = ExportFormat.JPEG + export_fmt: ExportFormat = ExportFormat.JPEG jpeg_quality: int = 90 jxl_lossless: bool = True jxl_distance: float = 1.0 # libjxl distance; only used when jxl_lossless is False @@ -209,13 +133,13 @@ class ExportConfig: paper_aspect_ratio: str = AspectRatio.ORIGINAL export_print_size: float = 30.0 export_dpi: int = 300 - export_resolution_mode: str = ExportResolutionMode.ORIGINAL.value + export_resolution_mode: ExportResolutionMode = ExportResolutionMode.ORIGINAL export_target_long_edge_px: int = 2000 filename_pattern: str = "{{ original_name }}" # When True, exports silently overwrite existing files; when False, the export # prompts (Overwrite / Rename / Cancel) before clobbering anything. overwrite: bool = False - output_mode: str = ExportPresetOutputMode.ABSOLUTE + output_mode: ExportPresetOutputMode = ExportPresetOutputMode.ABSOLUTE output_subfolder: str = "" icc_input_path: Optional[str] = None icc_output_path: Optional[str] = None @@ -240,7 +164,14 @@ class ExportConfig: export_sidecars_enabled: bool = False def __post_init__(self) -> None: - object.__setattr__(self, "export_fmt", migrate_export_fmt(self.export_fmt)) + fmt = coerce_enum(ExportFormat, migrate_export_fmt(self.export_fmt), ExportFormat.JPEG) + object.__setattr__(self, "export_fmt", fmt) + object.__setattr__( + self, + "export_resolution_mode", + coerce_enum(ExportResolutionMode, self.export_resolution_mode, ExportResolutionMode.ORIGINAL), + ) + object.__setattr__(self, "output_mode", coerce_enum(ExportPresetOutputMode, self.output_mode, ExportPresetOutputMode.ABSOLUTE)) @dataclass @@ -256,7 +187,7 @@ class ExportPreset: render_intent: str = RenderIntent.PRINT # fixed at creation; not exposed in the preset editor form # Format - export_fmt: str = ExportFormat.JPEG + export_fmt: ExportFormat = ExportFormat.JPEG jpeg_quality: int = 90 jxl_lossless: bool = True jxl_distance: float = 1.0 @@ -266,14 +197,14 @@ class ExportPreset: webp_method: int = 4 # Sizing (same field names as ExportConfig for PrintService compatibility) - export_resolution_mode: str = ExportResolutionMode.ORIGINAL.value + export_resolution_mode: ExportResolutionMode = ExportResolutionMode.ORIGINAL paper_aspect_ratio: str = AspectRatio.ORIGINAL export_print_size: float = 30.0 export_dpi: int = 300 export_target_long_edge_px: int = 2000 # Output destination - output_mode: str = ExportPresetOutputMode.SAME_AS_SOURCE + output_mode: ExportPresetOutputMode = ExportPresetOutputMode.SAME_AS_SOURCE output_subfolder: str = "" output_path: str = "" overwrite: bool = False @@ -285,7 +216,9 @@ class ExportPreset: icc_output_path: Optional[str] = None def __post_init__(self) -> None: - self.export_fmt = migrate_export_fmt(self.export_fmt) + self.export_fmt = coerce_enum(ExportFormat, migrate_export_fmt(self.export_fmt), ExportFormat.JPEG) + self.export_resolution_mode = coerce_enum(ExportResolutionMode, self.export_resolution_mode, ExportResolutionMode.ORIGINAL) + self.output_mode = coerce_enum(ExportPresetOutputMode, self.output_mode, ExportPresetOutputMode.SAME_AS_SOURCE) def to_dict(self) -> Dict[str, Any]: return { diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index 62bb12bf..0f030f43 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -5,9 +5,14 @@ import cv2 import numpy as np -from negpy.domain.models import AspectRatio, FILM_FORMAT_RATIOS from negpy.domain.types import ROI, ImageBuffer -from negpy.features.geometry.models import FINE_ROTATION_LIMIT, AutocropMode, GeometryConfig +from negpy.features.geometry.models import ( + FILM_FORMAT_RATIOS, + FINE_ROTATION_LIMIT, + AspectRatio, + AutocropMode, + GeometryConfig, +) from negpy.kernel.image.logic import get_luminance from negpy.kernel.image.validation import ensure_image diff --git a/negpy/features/geometry/models.py b/negpy/features/geometry/models.py index 9b90ef5d..59cd5068 100644 --- a/negpy/features/geometry/models.py +++ b/negpy/features/geometry/models.py @@ -9,6 +9,103 @@ FINE_ROTATION_LIMIT = 45.0 +class AspectRatio(StrEnum): + FREE = "Free" + ORIGINAL = "Original" + R_1_1 = "1:1" + R_3_2 = "3:2" + R_4_3 = "4:3" + R_5_4 = "5:4" + R_6_7 = "6:7" + R_7_5 = "7:5" + R_65_24 = "65:24" + R_16_9 = "16:9" + R_16_10 = "16:10" + R_8_5_11 = "8.5:11" + # Reciprocal (portrait) mirrors of the ratios above. Not offered in the crop + # tool's ratio picker (see CROP_RATIO_CHOICES) — the crop tool auto-orients + # a ratio to match the current drag/box (overlay._oriented_target_ratio, + # geometry.logic._resolve_ratio_dims), so showing both forms there would just + # duplicate the same shape twice. Kept as real members because (a) the export + # "Paper ratio" picker does NOT auto-orient — a portrait vs. landscape paper + # size are genuinely different choices there — and (b) "Detect closest aspect + # ratio" needs both forms to match a portrait-oriented film frame correctly. + R_2_3 = "2:3" + R_3_4 = "3:4" + R_4_5 = "4:5" + R_7_6 = "7:6" + R_5_7 = "5:7" + R_24_65 = "24:65" + R_9_16 = "9:16" + R_10_16 = "10:16" + R_11_8_5 = "11:8.5" + + +# Ratios offered in the crop tool's ratio picker: one canonical entry per shape +# (see the AspectRatio docstring above for why the reciprocal forms exist but +# aren't listed here). FREE is included since it's the "no constraint" choice. +CROP_RATIO_CHOICES: list[AspectRatio] = [ + AspectRatio.FREE, + AspectRatio.R_1_1, + AspectRatio.R_3_2, + AspectRatio.R_4_3, + AspectRatio.R_5_4, + AspectRatio.R_6_7, + AspectRatio.R_7_5, + AspectRatio.R_65_24, + AspectRatio.R_16_9, + AspectRatio.R_16_10, + AspectRatio.R_8_5_11, +] + +# Maps each reciprocal (portrait) AspectRatio to the canonical entry shown in +# CROP_RATIO_CHOICES, so a value that only exists in its portrait form (e.g. from +# "Detect closest aspect ratio" matching a portrait-oriented frame, or a ratio +# saved before this consolidation) always resolves to something the ratio picker +# can display. +_PORTRAIT_TO_CANONICAL_CROP_RATIO: dict[str, str] = { + AspectRatio.R_2_3: AspectRatio.R_3_2, + AspectRatio.R_3_4: AspectRatio.R_4_3, + AspectRatio.R_4_5: AspectRatio.R_5_4, + AspectRatio.R_7_6: AspectRatio.R_6_7, + AspectRatio.R_5_7: AspectRatio.R_7_5, + AspectRatio.R_24_65: AspectRatio.R_65_24, + AspectRatio.R_9_16: AspectRatio.R_16_9, + AspectRatio.R_10_16: AspectRatio.R_16_10, + AspectRatio.R_11_8_5: AspectRatio.R_8_5_11, +} + + +def canonical_crop_ratio(ratio: str) -> str: + """Maps a ratio to the form shown in the crop tool's ratio picker. Portrait- + oriented AspectRatio values collapse to their landscape/canonical counterpart + (see _PORTRAIT_TO_CANONICAL_CROP_RATIO); "Free", "Original", and anything + already canonical pass through unchanged.""" + return _PORTRAIT_TO_CANONICAL_CROP_RATIO.get(ratio, ratio) + + +# Candidates for "Detect closest aspect ratio" (geometry.logic._closest_standard_ratio): +# real film/scan formats only, both orientations. 7:5, 16:9, 16:10 and 8.5:11 are +# print/screen *output* sizes, not scannable film formats, and sit close enough to +# 3:2 (1.4, 1.778, 1.6) and 5:4 (0.773) in log-ratio space that ordinary contour- +# detection noise on a real 3:2 or 5:4 frame tips the match onto one of them instead +# — including them here regressed detection to reliably misclassify 35mm scans as +# "7:5". Keep this set to formats a camera/scanner could actually produce. +FILM_FORMAT_RATIOS: list[AspectRatio] = [ + AspectRatio.R_1_1, + AspectRatio.R_3_2, + AspectRatio.R_2_3, + AspectRatio.R_4_3, + AspectRatio.R_3_4, + AspectRatio.R_5_4, + AspectRatio.R_4_5, + AspectRatio.R_6_7, + AspectRatio.R_7_6, + AspectRatio.R_65_24, + AspectRatio.R_24_65, +] + + class AutocropMode(StrEnum): IMAGE = "image" # crop to exposed image area (default) FILM = "film" # crop to film extent, keep rebate/sprockets @@ -23,8 +120,8 @@ class GeometryConfig: auto_crop_enabled: bool = False autocrop_offset: int = 0 - autocrop_ratio: str = "3:2" - autocrop_mode: str = AutocropMode.IMAGE + autocrop_ratio: AspectRatio = AspectRatio.R_3_2 + autocrop_mode: AutocropMode = AutocropMode.IMAGE # Fraction of the detected rebate to cut: 0.0 stops at the film edge, 1.0 lands on # the image edge, above 1.0 bites into the picture. Image mode only. autocrop_rebate_trim: float = 1.0 @@ -32,8 +129,13 @@ class GeometryConfig: def __post_init__(self) -> None: """Ensure a JSON-loaded list is converted back to a tuple, keeping the - frozen dataclass hashable for pipeline cache keys.""" + frozen dataclass hashable for pipeline cache keys. Enum fields coerce so a + retired or hand-edited saved value degrades to the default, not a load failure.""" if self.manual_crop_rect is not None: object.__setattr__(self, "manual_crop_rect", tuple(self.manual_crop_rect)) if self.autocrop_mode not in (AutocropMode.IMAGE, AutocropMode.FILM): - object.__setattr__(self, "autocrop_mode", AutocropMode.IMAGE.value) + object.__setattr__(self, "autocrop_mode", AutocropMode.IMAGE) + try: + object.__setattr__(self, "autocrop_ratio", AspectRatio(self.autocrop_ratio)) + except ValueError: + object.__setattr__(self, "autocrop_ratio", AspectRatio.R_3_2) diff --git a/negpy/features/process/models.py b/negpy/features/process/models.py index 7bf144d0..affadcc7 100644 --- a/negpy/features/process/models.py +++ b/negpy/features/process/models.py @@ -6,9 +6,22 @@ class ProcessMode(StrEnum): - C41 = "C41" - BW = "B&W" - E6 = "E-6" + C41 = "Color Negative" + BW = "B&W Negative" + E6 = "Transparency" + + @classmethod + def _missing_(cls, value: object) -> "ProcessMode": + """Legacy chemistry codes (the values before the rename), and anything stale. + + An unrecognised mode has always rendered as colour negative — every branch in + the pipeline reads `if BW / elif E6 / else` — so a corrupt saved value stays + non-fatal here rather than raising on load. + """ + return _LEGACY_MODES.get(str(value), cls.C41) + + +_LEGACY_MODES = {"C41": ProcessMode.C41, "B&W": ProcessMode.BW, "E-6": ProcessMode.E6} # Built-in fallback crosstalk matrix (row-major 3x3) used when no profile is baked. @@ -97,6 +110,9 @@ def __post_init__(self) -> None: """ Ensure JSON-loaded lists are converted back to tuples. """ + # Not a MIGRATIONS entry: the pre-rename mode names reach us from sticky settings + # and asset dicts too, not just a loaded flat config, so this runs on every build. + object.__setattr__(self, "process_mode", ProcessMode(self.process_mode)) object.__setattr__(self, "locked_floors", tuple(self.locked_floors)) object.__setattr__(self, "locked_ceils", tuple(self.locked_ceils)) object.__setattr__(self, "local_floors", tuple(self.local_floors)) diff --git a/negpy/infrastructure/capture/settings.py b/negpy/infrastructure/capture/settings.py index 1fea4e5d..8de3626d 100644 --- a/negpy/infrastructure/capture/settings.py +++ b/negpy/infrastructure/capture/settings.py @@ -3,6 +3,23 @@ from __future__ import annotations from dataclasses import dataclass +from enum import StrEnum + +from negpy.features.process.models import ProcessMode + + +class WhiteCaptureMode(StrEnum): + """What a white-light capture is: a slide, a B&W negative, or left to autodetect.""" + + AUTO = "auto" + BW = ProcessMode.BW + E6 = ProcessMode.E6 + + @classmethod + def _missing_(cls, value: object) -> "WhiteCaptureMode": + """Pre-rename mode names in a saved `scanlight_settings` dict, and anything stale.""" + legacy = {"e-6": cls.E6, "b&w": cls.BW} + return legacy.get(str(value).lower(), cls.AUTO) @dataclass(frozen=True) @@ -25,11 +42,15 @@ class ScanlightSettings: shutter_w: str = "" iso: str = "" # RGB preset's calibrated ISO/aperture, forced on the body at scan time aperture: str = "" # "" for a manual-aperture lens (set by hand on the ring) - white_process_mode: str = "auto" + white_process_mode: WhiteCaptureMode = WhiteCaptureMode.AUTO roll_name: str = "Roll001" output_folder: str = "" port: str = "" # Scanlight serial port ("" = autodetect); the camera needs no address + def __post_init__(self) -> None: + # A dict saved before the process-mode rename still carries the old names. + object.__setattr__(self, "white_process_mode", WhiteCaptureMode(self.white_process_mode)) + @classmethod def defaults(cls) -> "ScanlightSettings": return cls() diff --git a/negpy/services/assets/crosstalk.py b/negpy/services/assets/crosstalk.py index 0ed4d8cf..62906088 100644 --- a/negpy/services/assets/crosstalk.py +++ b/negpy/services/assets/crosstalk.py @@ -1,5 +1,6 @@ import os import tomllib +from enum import StrEnum from typing import List, Optional from negpy.kernel.system.config import APP_CONFIG @@ -9,20 +10,25 @@ # Renamed from "Default" DEFAULT_NAME = "Generic C41" -# A profile's `type` records where its numbers came from. Free-form on disk; anything -# outside this set groups under "Other" rather than disappearing. -TYPE_SPECSHEET = "specsheet-based" # read off published spectral dye-density curves -TYPE_MEASURED = "measured" # fitted against real scans of a known reference -TYPE_TUNED = "tuned" # dialled in by eye on a rig; what the editor saves -TYPE_BUILTIN = "built-in" + +class CrosstalkType(StrEnum): + """Where a profile's numbers came from. Free-form on disk: a `type` outside this + set groups under OTHER rather than disappearing, so reads stay plain strings.""" + + SPECSHEET = "specsheet-based" # read off published spectral dye-density curves + MEASURED = "measured" # fitted against real scans of a known reference + TUNED = "tuned" # dialled in by eye on a rig; what the editor saves + BUILTIN = "built-in" + OTHER = "" # catch-all bucket, never written to a file + #: Dropdown group order and headings; the trailing entry is the catch-all. -GROUP_ORDER: tuple[tuple[str, str], ...] = ( - (TYPE_BUILTIN, "Built-in"), - (TYPE_MEASURED, "Measured"), - (TYPE_TUNED, "Tuned on a rig"), - (TYPE_SPECSHEET, "From spec sheets (approx)"), - ("", "Other"), +GROUP_ORDER: tuple[tuple[CrosstalkType, str], ...] = ( + (CrosstalkType.BUILTIN, "Built-in"), + (CrosstalkType.MEASURED, "Measured"), + (CrosstalkType.TUNED, "Tuned on a rig"), + (CrosstalkType.SPECSHEET, "From spec sheets (approx)"), + (CrosstalkType.OTHER, "Other"), ) @@ -129,8 +135,9 @@ def _scan_types() -> dict: def _scan_processes() -> dict: """display-name -> film process the matrix describes; bundled wins, like _scan. - Absent `process` means C-41: every profile that predates the key is a colour - negative stock, so that is the honest default rather than a guess.""" + Absent `process` means colour negative: every profile that predates the key is a + colour negative stock, so that is the honest default rather than a guess. Values are + coerced through ProcessMode, so a file written with the pre-rename names still matches.""" from negpy.features.process.models import ProcessMode out: dict = {} @@ -148,7 +155,7 @@ def _scan_processes() -> dict: raw_name = data.get("name") name = raw_name.strip() if isinstance(raw_name, str) and raw_name.strip() else fname[:-5] value = data.get("process") - out[name] = str(value).strip() if isinstance(value, str) and value.strip() else str(ProcessMode.C41) + out[name] = str(ProcessMode(value.strip() if isinstance(value, str) else "")) out[DEFAULT_NAME] = str(ProcessMode.C41) return out @@ -178,18 +185,18 @@ def grouped_profiles(process_mode: Optional[str] = None) -> List[tuple]: from negpy.features.process.models import ProcessMode if process_mode is None or str(process_mode) == str(ProcessMode.C41): - buckets[TYPE_BUILTIN].append(DEFAULT_NAME) + buckets[CrosstalkType.BUILTIN].append(DEFAULT_NAME) for name in sorted(types): - bucket = types[name] if types[name] in known else "" + bucket = types[name] if types[name] in known else CrosstalkType.OTHER buckets[bucket].append(name) return [(heading, buckets[t]) for t, heading in GROUP_ORDER if buckets[t]] @staticmethod def get_type(name: str) -> str: - """A profile's type, or TYPE_BUILTIN for the built-in / "" when unknown.""" + """A profile's type, or BUILTIN for the built-in / "" when unknown.""" if name == DEFAULT_NAME: - return TYPE_BUILTIN - return CrosstalkProfiles._scan_types().get(name, "") + return CrosstalkType.BUILTIN + return CrosstalkProfiles._scan_types().get(name, CrosstalkType.OTHER) @staticmethod def list_profiles() -> List[str]: @@ -217,7 +224,7 @@ def path_for_name(name: str) -> str: return os.path.join(APP_CONFIG.crosstalk_dir, f"{slugify(name, 'crosstalk')}.toml") @staticmethod - def save(name: str, matrix: List[float], profile_type: str = TYPE_TUNED, process: Optional[str] = None) -> str: + def save(name: str, matrix: List[float], profile_type: str = CrosstalkType.TUNED, process: Optional[str] = None) -> str: """Write a user profile TOML (row-major 3×3) and return its path. Defaults to `tuned` so editor saves are not grouped with the spec-sheet estimates. @@ -230,7 +237,7 @@ def save(name: str, matrix: List[float], profile_type: str = TYPE_TUNED, process content = ( f'name = "{escape_toml_string(name)}"\n' f'type = "{escape_toml_string(profile_type)}"\n' - f'process = "{escape_toml_string(str(process or ProcessMode.C41))}"\n' + f'process = "{escape_toml_string(str(ProcessMode(process or ProcessMode.C41)))}"\n' f"matrix = [\n{rows}\n]\n" ) path = CrosstalkProfiles.path_for_name(name) diff --git a/tests/test_auto_exposure_contrast.py b/tests/test_auto_exposure_contrast.py index 1d287155..8f74a1ad 100644 --- a/tests/test_auto_exposure_contrast.py +++ b/tests/test_auto_exposure_contrast.py @@ -16,10 +16,11 @@ measure_textural_range_from_log, ) from negpy.features.exposure.processor import PhotometricProcessor +from negpy.features.process.models import ProcessMode def _context(density_range): - ctx = PipelineContext(scale_factor=1.0, original_size=(100, 100), process_mode="C41") + ctx = PipelineContext(scale_factor=1.0, original_size=(100, 100), process_mode=ProcessMode.C41) ctx.metrics["norm_density_range"] = density_range return ctx @@ -174,28 +175,27 @@ def test_e6_reversed_bounds(self): class TestAutoTogglesAcrossModes(unittest.TestCase): - """The toggles must render valid output in C41, B&W and E6 (CPU path).""" + """The toggles must render valid output in every process mode (CPU path).""" def _render(self, mode, exposure, normalize=True): from dataclasses import replace from negpy.domain.models import WorkspaceConfig - from negpy.features.process.models import ProcessConfig, ProcessMode + from negpy.features.process.models import ProcessConfig from negpy.services.rendering.engine import DarkroomEngine - mode_enum = {"C41": ProcessMode.C41, "BW": ProcessMode.BW, "E6": ProcessMode.E6}[mode] settings = replace( WorkspaceConfig(), - # E-6 defaults Normalize off, which is the transfer path — pinned on here so - # this stays a test of the print path in all three modes. - process=replace(ProcessConfig(), process_mode=mode_enum, e6_normalize=normalize), + # Transparency defaults Normalize off, which is the transfer path — pinned on + # here so this stays a test of the print path in all three modes. + process=replace(ProcessConfig(), process_mode=mode, e6_normalize=normalize), exposure=exposure, ) img = np.random.default_rng(7).uniform(0.02, 0.9, (48, 48, 3)).astype(np.float32) return DarkroomEngine().process(img, settings, f"mode_{mode}_{normalize}") def test_valid_and_active_in_each_mode(self): - for mode in ("C41", "BW", "E6"): + for mode in ProcessMode: base = self._render(mode, ExposureConfig(auto_exposure=False, auto_normalize_contrast=False)) auto = self._render(mode, ExposureConfig(auto_exposure=True, auto_normalize_contrast=True)) self.assertTrue(np.all(np.isfinite(auto)), mode) @@ -207,8 +207,8 @@ def test_valid_and_active_in_each_mode(self): def test_inert_on_the_transparency_transfer(self): """The complement: with Normalize off there is no metered stretch to grade against, so both toggles must be no-ops — which is why the sidebar hides them there.""" - base = self._render("E6", ExposureConfig(auto_exposure=False, auto_normalize_contrast=False), normalize=False) - auto = self._render("E6", ExposureConfig(auto_exposure=True, auto_normalize_contrast=True), normalize=False) + base = self._render(ProcessMode.E6, ExposureConfig(auto_exposure=False, auto_normalize_contrast=False), normalize=False) + auto = self._render(ProcessMode.E6, ExposureConfig(auto_exposure=True, auto_normalize_contrast=True), normalize=False) self.assertTrue(np.allclose(base, auto)) diff --git a/tests/test_auto_shadow_neutral.py b/tests/test_auto_shadow_neutral.py index 3a1803c2..182b981b 100644 --- a/tests/test_auto_shadow_neutral.py +++ b/tests/test_auto_shadow_neutral.py @@ -6,6 +6,7 @@ from negpy.domain.interfaces import PipelineContext from negpy.domain.models import WorkspaceConfig from negpy.features.exposure.processor import NormalizationProcessor, PhotometricProcessor +from negpy.features.process.models import ProcessMode _H = 1000 @@ -38,7 +39,7 @@ class TestCastRemoval(unittest.TestCase): solve) that neutralizes a negative's residual color cast across the range. """ - def _render(self, img: np.ndarray, strength: float, mode: str = "C41") -> np.ndarray: + def _render(self, img: np.ndarray, strength: float, mode: str = ProcessMode.C41) -> np.ndarray: config = WorkspaceConfig() # No analysis border crop — the fixture's cast fade sits near the # extreme and must stay inside the analyzed region. diff --git a/tests/test_batch_norm.py b/tests/test_batch_norm.py index c5db9c9e..e6dd443c 100644 --- a/tests/test_batch_norm.py +++ b/tests/test_batch_norm.py @@ -5,12 +5,13 @@ from negpy.features.exposure.normalization import analyze_log_exposure_bounds from negpy.features.exposure.processor import NormalizationProcessor, PhotometricProcessor from negpy.domain.interfaces import PipelineContext +from negpy.features.process.models import ProcessMode class TestBatchNormalization(unittest.TestCase): def setUp(self): self.config = WorkspaceConfig() - self.context = PipelineContext(scale_factor=1.0, original_size=(100, 100), process_mode="C41") + self.context = PipelineContext(scale_factor=1.0, original_size=(100, 100), process_mode=ProcessMode.C41) def test_normalization_processor_uses_locked_values(self): """ @@ -85,7 +86,7 @@ def test_trim_stacks_with_global_offset(self): def test_e6_negates_trims(self): img = np.full((10, 10, 3), 10**-0.5, dtype=np.float32) - ctx_e6 = PipelineContext(scale_factor=1.0, original_size=(100, 100), process_mode="E-6") + ctx_e6 = PipelineContext(scale_factor=1.0, original_size=(100, 100), process_mode=ProcessMode.E6) p_neutral = replace(self._neutral_process(), e6_normalize=True) res_neutral = NormalizationProcessor(p_neutral).process(img, ctx_e6) diff --git a/tests/test_capture_controller.py b/tests/test_capture_controller.py index d197dae8..f085ec25 100644 --- a/tests/test_capture_controller.py +++ b/tests/test_capture_controller.py @@ -80,7 +80,7 @@ def test_white_slide_leaves_merge_off(): def test_explicit_e6_applies_to_import_after_hydration_without_detection(): - c = _run(["slide.ARW"], rgb_mode=True, white_mode=True, white_process_mode="E-6") + c = _run(["slide.ARW"], rgb_mode=True, white_mode=True, white_process_mode=ProcessMode.E6) # Capture completion must not mutate whichever asset happened to be open before import. assert c.state.config.process.process_mode == ProcessMode.C41 @@ -92,7 +92,7 @@ def test_explicit_e6_applies_to_import_after_hydration_without_detection(): def test_explicit_bw_applies_to_import_after_hydration_without_detection(): - c = _run(["mono.ARW"], rgb_mode=True, white_mode=True, white_process_mode="B&W") + c = _run(["mono.ARW"], rgb_mode=True, white_mode=True, white_process_mode=ProcessMode.BW) assert c.state.config.process.process_mode == ProcessMode.C41 @@ -112,7 +112,7 @@ def test_automatic_white_import_requests_detection_without_forcing_mode(): def test_failed_discovery_discards_capture_intent(): - c = _run(["missing.ARW"], rgb_mode=True, white_mode=True, white_process_mode="E-6") + c = _run(["missing.ARW"], rgb_mode=True, white_mode=True, white_process_mode=ProcessMode.E6) c._auto_open_after_discovery = False c._replace_after_discovery = False c._reselect_after_discovery = None @@ -125,7 +125,7 @@ def test_failed_discovery_discards_capture_intent(): def test_capture_intent_is_scoped_to_captured_primary_path(): - c = _run(["slide.ARW"], rgb_mode=True, white_mode=True, white_process_mode="E-6") + c = _run(["slide.ARW"], rgb_mode=True, white_mode=True, white_process_mode=ProcessMode.E6) unrelated_task = _hydrate_and_load(c, "other.ARW", ProcessMode.BW, autodetect=True) diff --git a/tests/test_cast_chart_parity.py b/tests/test_cast_chart_parity.py index 7a38c8cd..154ff528 100644 --- a/tests/test_cast_chart_parity.py +++ b/tests/test_cast_chart_parity.py @@ -19,6 +19,7 @@ per_channel_curve_params, ) from negpy.features.exposure.processor import NormalizationProcessor, PhotometricProcessor +from negpy.features.process.models import ProcessMode _H, _W = 600, 400 @@ -39,7 +40,7 @@ def _metrics_and_config(): cfg = WorkspaceConfig() process = replace(cfg.process, analysis_buffer=0.0) exposure = replace(cfg.exposure, cast_removal_strength=0.8) - ctx = PipelineContext(scale_factor=1.0, original_size=(_H, _W), process_mode="C41") + ctx = PipelineContext(scale_factor=1.0, original_size=(_H, _W), process_mode=ProcessMode.C41) norm = NormalizationProcessor(process).process(_curved_negative(), ctx) PhotometricProcessor(exposure).process(norm, ctx) return ctx.metrics, exposure @@ -48,7 +49,7 @@ def _metrics_and_config(): def test_chart_wiring_matches_render(): metrics, config = _metrics_and_config() # The shared resolver the chart and the step wedge both call — so this pins all of them. - slopes, pivots, curvs = curve_params_from_metrics(config, "C41", metrics) + slopes, pivots, curvs = curve_params_from_metrics(config, ProcessMode.C41, metrics) np.testing.assert_allclose(slopes, metrics["print_slopes"], atol=1e-12) assert max(abs(c) for c in curvs) > 1e-6 # curved fixture engages the quadratic diff --git a/tests/test_chart_curve_parity.py b/tests/test_chart_curve_parity.py index fcfce796..dfbfa116 100644 --- a/tests/test_chart_curve_parity.py +++ b/tests/test_chart_curve_parity.py @@ -15,6 +15,7 @@ ) from negpy.features.exposure.models import EXPOSURE_CONSTANTS, ExposureConfig from negpy.kernel.image.logic import working_oetf_encode +from negpy.features.process.models import ProcessMode def _reference_points(config, toe, shoulder): @@ -69,18 +70,18 @@ def test_chart_honours_the_papers_own_dmax(qapp): from negpy.features.exposure.papers import effective_paper_profile config = ExposureConfig(paper_profile="kodak_endura") - paper = effective_paper_profile(config.paper_profile, "C41") + paper = effective_paper_profile(config.paper_profile, ProcessMode.C41) assert paper.d_max > EXPOSURE_CONSTANTS["d_max"] # else this test proves nothing w = PhotometricCurveWidget() - w.update_curve(config, process_mode="C41") + w.update_curve(config, process_mode=ProcessMode.C41) plt_x = np.array([p[0] for p in w._curve_pts], dtype=np.float64) plotted = np.array([p[1] for p in w._curve_pts], dtype=np.float64) slope = grade_to_slope(config.grade, None) d_min = paper.d_min if config.paper_dmin else 0.0 pivot = compute_pivot(slope, config.density, d_min=d_min, paper=paper) - curve = print_curve(config, slope, pivot, "C41") + curve = print_curve(config, slope, pivot, ProcessMode.C41) np.testing.assert_allclose(plotted, print_curve_output(curve, 1.0 - plt_x), atol=1e-9) # Anti-vacuity: the old paper-blind curve must differ. diff --git a/tests/test_colour_sidebar.py b/tests/test_colour_sidebar.py index e88c8b5c..fb55023d 100644 --- a/tests/test_colour_sidebar.py +++ b/tests/test_colour_sidebar.py @@ -4,6 +4,7 @@ from negpy.desktop.session import AppState from negpy.desktop.view.sidebar.colour import ColourSidebar from negpy.features.exposure.logic import wb_to_kelvin +from negpy.features.process.models import ProcessMode def _sidebar(): @@ -91,11 +92,11 @@ def test_cast_removal_is_c41_only(qapp): controller, sidebar = _sidebar() cfg = controller.state.config - controller.state.config = replace(cfg, process=replace(cfg.process, process_mode="C41")) + controller.state.config = replace(cfg, process=replace(cfg.process, process_mode=ProcessMode.C41)) sidebar.sync_ui() assert not sidebar.cast_removal_slider.isHidden() - for mode in ("E-6", "B&W"): + for mode in (ProcessMode.E6, ProcessMode.BW): cfg = controller.state.config controller.state.config = replace(cfg, process=replace(cfg.process, process_mode=mode)) sidebar.sync_ui() @@ -117,7 +118,7 @@ def test_cast_removal_hidden_exactly_where_the_render_ignores_it(qapp): img = np.stack([np.repeat(grad[None, :], 48, 0)] * 3, -1) * np.array([1.0, 0.9, 0.78], np.float32) img = np.ascontiguousarray(img + rng.uniform(0, 0.01, (48, 48, 3)).astype(np.float32)) - for mode, normalize in (("C41", True), ("E-6", True), ("E-6", False), ("B&W", True)): + for mode, normalize in ((ProcessMode.C41, True), (ProcessMode.E6, True), (ProcessMode.E6, False), (ProcessMode.BW, True)): s = WorkspaceConfig() base = replace(s, process=replace(s.process, process_mode=mode, e6_normalize=normalize)) renders = [ diff --git a/tests/test_composite_inheritance.py b/tests/test_composite_inheritance.py index ec28d6db..4b879565 100644 --- a/tests/test_composite_inheritance.py +++ b/tests/test_composite_inheritance.py @@ -19,7 +19,7 @@ class TestProcessModeOverlay(unittest.TestCase): def test_overlays_the_composites_inherited_mode(self): cfg = WorkspaceConfig() # process_mode defaults to C41 - out = resolve_asset_process_mode(cfg, {"process_mode": "E-6"}) + out = resolve_asset_process_mode(cfg, {"process_mode": ProcessMode.E6}) self.assertEqual(out.process.process_mode, ProcessMode.E6) def test_absent_or_empty_leaves_the_config_alone(self): @@ -31,7 +31,7 @@ def test_absent_or_empty_leaves_the_config_alone(self): class TestHydration(unittest.TestCase): """The bug is in the path select_file uses, so assert against that, not the helper.""" - def _session(self, saved_config=None, sticky_mode="C41"): + def _session(self, saved_config=None, sticky_mode=ProcessMode.C41): from negpy.desktop.session import DesktopSessionManager session = DesktopSessionManager.__new__(DesktopSessionManager) @@ -49,8 +49,14 @@ def _session(self, saved_config=None, sticky_mode="C41"): def test_merged_asset_inherits_e6_over_a_stale_sticky_c41(self): """The reported failure: five E-6 exposures merged, composite opened in C41.""" - session = self._session(saved_config=None, sticky_mode="C41") - asset = {"hash": "merged#hdr", "path": "/x/a.nef", "process_mode": "E-6", "hdr_paths": ("/x/b.nef",), "hdr_ratios": (1.0, 0.5)} + session = self._session(saved_config=None, sticky_mode=ProcessMode.C41) + asset = { + "hash": "merged#hdr", + "path": "/x/a.nef", + "process_mode": ProcessMode.E6, + "hdr_paths": ("/x/b.nef",), + "hdr_ratios": (1.0, 0.5), + } config, is_new = session._hydrate_asset_config(asset) self.assertTrue(is_new) self.assertEqual(config.process.process_mode, ProcessMode.E6) @@ -59,8 +65,8 @@ def test_a_saved_edit_still_wins(self): """Inheritance seeds a composite that has no edit of its own; once the user sets a mode on it, that must not be overwritten on every reopen.""" saved = replace(WorkspaceConfig(), process=replace(WorkspaceConfig().process, process_mode=ProcessMode.BW)) - session = self._session(saved_config=saved, sticky_mode="C41") - asset = {"hash": "merged#hdr", "path": "/x/a.nef", "process_mode": "E-6"} + session = self._session(saved_config=saved, sticky_mode=ProcessMode.C41) + asset = {"hash": "merged#hdr", "path": "/x/a.nef", "process_mode": ProcessMode.E6} config, is_new = session._hydrate_asset_config(asset) self.assertFalse(is_new) self.assertEqual(config.process.process_mode, ProcessMode.BW) @@ -99,18 +105,18 @@ def _controller(self, modes): return ctrl, [{"hash": f"h{i}"} for i in range(len(modes))] def test_unanimous(self): - ctrl, files = self._controller(["E-6"] * 5) - self.assertEqual(ctrl._composite_process_mode(files), "E-6") + ctrl, files = self._controller([ProcessMode.E6] * 5) + self.assertEqual(ctrl._composite_process_mode(files), ProcessMode.E6) def test_one_odd_frame_out_does_not_decide_it(self): """A bracket's extreme exposures can autodetect differently — the frame blowing 46% of its area is not a reliable vote — so the majority decides.""" - ctrl, files = self._controller(["C41", "E-6", "E-6", "E-6", "E-6"]) - self.assertEqual(ctrl._composite_process_mode(files), "E-6") + ctrl, files = self._controller([ProcessMode.C41, ProcessMode.E6, ProcessMode.E6, ProcessMode.E6, ProcessMode.E6]) + self.assertEqual(ctrl._composite_process_mode(files), ProcessMode.E6) def test_ties_go_to_the_reference_frame(self): - ctrl, files = self._controller(["E-6", "C41"]) - self.assertEqual(ctrl._composite_process_mode(files), "E-6") + ctrl, files = self._controller([ProcessMode.E6, ProcessMode.C41]) + self.assertEqual(ctrl._composite_process_mode(files), ProcessMode.E6) if __name__ == "__main__": @@ -301,7 +307,7 @@ def _session(self): "hdr_paths": ("/x/b.nef", "/x/c.nef"), "hdr_ratios": (1.0, 2.0, 4.0), "hdr_align": True, - "process_mode": "E-6", + "process_mode": ProcessMode.E6, } ] s.update_config = MagicMock() @@ -418,9 +424,9 @@ class TestStitchGetsTheSameTreatment(unittest.TestCase): would keep passing if the stitch arm of any of those conditions were dropped. """ - _ASSET = {"hash": "c#stitch", "path": "/x/left.nef", "stitch_paths": ("/x/right.nef",), "process_mode": "E-6"} + _ASSET = {"hash": "c#stitch", "path": "/x/left.nef", "stitch_paths": ("/x/right.nef",), "process_mode": ProcessMode.E6} - def _session(self, saved_config=None, sticky_mode="C41"): + def _session(self, saved_config=None, sticky_mode=ProcessMode.C41): from negpy.desktop.session import DesktopSessionManager session = DesktopSessionManager.__new__(DesktopSessionManager) @@ -444,7 +450,7 @@ def test_a_stitch_opts_out_of_the_path_fallback(self): self.assertTrue(self.seen.get("composite")) def test_a_stitch_inherits_its_parts_film_process(self): - config, is_new = self._session(sticky_mode="C41")._hydrate_asset_config(self._ASSET) + config, is_new = self._session(sticky_mode=ProcessMode.C41)._hydrate_asset_config(self._ASSET) self.assertTrue(is_new) self.assertEqual(config.process.process_mode, ProcessMode.E6) @@ -506,15 +512,15 @@ def add_action(label): return actions def test_hidden_for_colour_negative(self): - assert self._menu_labels("C41") == [] + assert self._menu_labels(ProcessMode.C41) == [] def test_present_for_a_slide(self): - acts = self._menu_labels("E-6") + acts = self._menu_labels(ProcessMode.E6) assert [a.label for a in acts] == ["Merge exposures (HDR)"] acts[0].setEnabled.assert_not_called() def test_disabled_with_a_reason_for_black_and_white(self): - acts = self._menu_labels("B&W") + acts = self._menu_labels(ProcessMode.BW) assert [a.label for a in acts] == ["Merge exposures (HDR)"] acts[0].setEnabled.assert_called_once_with(False) assert "reversal" in acts[0].setToolTip.call_args.args[0] diff --git a/tests/test_config_deserialization.py b/tests/test_config_deserialization.py index b5db15ab..8d2dc676 100644 --- a/tests/test_config_deserialization.py +++ b/tests/test_config_deserialization.py @@ -2,7 +2,7 @@ import logging import unittest from dataclasses import replace -from negpy.domain.models import ExportConfig, ExportFormat, ExportPreset, ExportResolutionMode, WorkspaceConfig +from negpy.domain.models import AspectRatio, ExportConfig, ExportFormat, ExportPreset, ExportResolutionMode, WorkspaceConfig from negpy.features.process.models import ProcessMode from negpy.kernel.caching.logic import calculate_config_hash @@ -330,6 +330,24 @@ def test_stitch_config_round_trips(self): self.assertEqual(reloaded.stitch.stitch_sizes, ((2000, 3000), (2100, 3000))) hash(reloaded.stitch) # must not raise + def test_legacy_process_mode_names_still_load(self): + """The modes were renamed (C41 -> Color Negative, B&W -> B&W Negative, E-6 -> + Transparency). Edits saved under the old names must open in the same mode.""" + for legacy, expected in (("C41", ProcessMode.C41), ("B&W", ProcessMode.BW), ("E-6", ProcessMode.E6)): + config = WorkspaceConfig.from_flat_dict({"process_mode": legacy}) + self.assertEqual(config.process.process_mode, expected) + self.assertIsInstance(config.process.process_mode, ProcessMode) + + def test_unknown_process_mode_falls_back_to_colour_negative(self): + """A corrupt or hand-edited value renders as it always did, rather than failing the load.""" + self.assertEqual(WorkspaceConfig.from_flat_dict({"process_mode": "Kodachrome"}).process.process_mode, ProcessMode.C41) + + def test_retired_enum_values_fall_back_to_their_default(self): + config = WorkspaceConfig.from_flat_dict({"export_fmt": "PSD", "export_resolution_mode": "contact_sheet", "autocrop_ratio": "13:17"}) + self.assertEqual(config.export.export_fmt, ExportFormat.JPEG) + self.assertEqual(config.export.export_resolution_mode, ExportResolutionMode.ORIGINAL) + self.assertEqual(config.geometry.autocrop_ratio, AspectRatio.R_3_2) + def test_no_sub_config_is_missing_from_the_known_keys_set(self): """`from_flat_dict` validates incoming keys against a hand-maintained `config_classes` list. A sub-config added to WorkspaceConfig but not to that list diff --git a/tests/test_crosstalk_editor_type.py b/tests/test_crosstalk_editor_type.py index 3105e87a..010ad335 100644 --- a/tests/test_crosstalk_editor_type.py +++ b/tests/test_crosstalk_editor_type.py @@ -16,7 +16,7 @@ from PyQt6.QtWidgets import QApplication from negpy.kernel.system.config import APP_CONFIG -from negpy.services.assets.crosstalk import TYPE_MEASURED, TYPE_SPECSHEET, TYPE_TUNED, CrosstalkProfiles +from negpy.services.assets.crosstalk import CrosstalkProfiles, CrosstalkType _IDENTITY = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0] @@ -41,20 +41,20 @@ def _dialog(_app, select: str): def test_type_survives_a_save(_app, tmp_path): - CrosstalkProfiles.save("Mine", _IDENTITY, TYPE_TUNED) + CrosstalkProfiles.save("Mine", _IDENTITY, CrosstalkType.TUNED) dlg = _dialog(_app, "Mine") - dlg._set_type(TYPE_MEASURED) + dlg._set_type(CrosstalkType.MEASURED) dlg._on_save() - assert CrosstalkProfiles.get_type("Mine") == TYPE_MEASURED + assert CrosstalkProfiles.get_type("Mine") == CrosstalkType.MEASURED assert dict(CrosstalkProfiles.grouped_profiles())["Measured"] == ["Mine"] def test_selecting_a_profile_shows_its_type(_app): - CrosstalkProfiles.save("Sheet", _IDENTITY, TYPE_SPECSHEET) + CrosstalkProfiles.save("Sheet", _IDENTITY, CrosstalkType.SPECSHEET) dlg = _dialog(_app, "Sheet") - assert dlg.selected_type() == TYPE_SPECSHEET + assert dlg.selected_type() == CrosstalkType.SPECSHEET def test_unknown_type_falls_back_to_tuned_not_the_first_entry(_app, tmp_path): @@ -64,15 +64,15 @@ def test_unknown_type_falls_back_to_tuned_not_the_first_entry(_app, tmp_path): f.write('name = "Odd"\ntype = "handed-down-by-owls"\nmatrix = [[1.0,0.0,0.0],[0.0,1.0,0.0],[0.0,0.0,1.0]]\n') dlg = _dialog(_app, "Odd") - assert dlg.selected_type() == TYPE_TUNED + assert dlg.selected_type() == CrosstalkType.TUNED def test_renaming_keeps_the_type(_app): - CrosstalkProfiles.save("Before", _IDENTITY, TYPE_MEASURED) + CrosstalkProfiles.save("Before", _IDENTITY, CrosstalkType.MEASURED) dlg = _dialog(_app, "Before") dlg.name_edit.setText("After") dlg._on_save() - assert CrosstalkProfiles.get_type("After") == TYPE_MEASURED + assert CrosstalkProfiles.get_type("After") == CrosstalkType.MEASURED assert "Before" not in CrosstalkProfiles.list_profiles() diff --git a/tests/test_crosstalk_profiles.py b/tests/test_crosstalk_profiles.py index d1418d9b..e62cd62f 100644 --- a/tests/test_crosstalk_profiles.py +++ b/tests/test_crosstalk_profiles.py @@ -2,6 +2,7 @@ import pytest +from negpy.features.process.models import ProcessMode from negpy.kernel.system.config import APP_CONFIG from negpy.services.assets.crosstalk import CrosstalkProfiles @@ -210,3 +211,18 @@ def test_saved_profiles_are_marked_tuned(tmp_path, monkeypatch): CrosstalkProfiles.save("Mine", [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]) assert CrosstalkProfiles.get_type("Mine") == "tuned" assert dict(CrosstalkProfiles.grouped_profiles())["Tuned on a rig"] == ["Mine"] + + +def test_legacy_process_names_still_match_their_mode(tmp_path, monkeypatch): + """A matrix written before the mode rename declares `process = "E-6"`. It must still + reach the Transparency dropdown instead of matching no mode and disappearing.""" + monkeypatch.setattr(APP_CONFIG, "crosstalk_dir", str(tmp_path)) + _write( + os.path.join(tmp_path, "slide.toml"), + 'name = "Old Slide"\nprocess = "E-6"\nmatrix = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]\n', + ) + + assert CrosstalkProfiles.get_process("Old Slide") == ProcessMode.E6 + flat = [n for _h, names in CrosstalkProfiles.grouped_profiles(ProcessMode.E6) for n in names] + assert flat == ["Old Slide"] + assert CrosstalkProfiles.grouped_profiles(ProcessMode.C41) == [("Built-in", [CrosstalkProfiles.DEFAULT_NAME])] diff --git a/tests/test_desktop_session.py b/tests/test_desktop_session.py index ebf64d87..ad1a3d39 100644 --- a/tests/test_desktop_session.py +++ b/tests/test_desktop_session.py @@ -8,6 +8,7 @@ from negpy.features.rgbscan.models import RgbScanConfig from negpy.infrastructure.storage.repository import StorageRepository from negpy.kernel.system.config import APP_CONFIG +from negpy.features.process.models import ProcessMode _ROWS = {r.label: r for r in all_rows()} @@ -28,7 +29,7 @@ def mock_get_global(key, default=None): if key == "last_export_config": return {} if key == "process_mode": - return "C41" + return ProcessMode.C41 return default self.mock_repo.get_global_setting.side_effect = mock_get_global @@ -68,14 +69,14 @@ def test_config_for_asset_saved_uses_saved_edits_and_global_overlays_only(self): saved = replace( defaults, exposure=replace(defaults.exposure, density=1.7), - process=replace(defaults.process, process_mode="E-6"), + process=replace(defaults.process, process_mode=ProcessMode.E6), geometry=replace(defaults.geometry, autocrop_ratio="4:3"), ) sticky = { "last_export_config": {"jpeg_quality": 73}, "last_protect_original_metadata": True, # Workflow defaults must not overwrite an edited/saved asset. - "last_process_mode": "C41", + "last_process_mode": ProcessMode.C41, "last_aspect_ratio": "1:1", } self.mock_repo.get_global_setting.side_effect = lambda key, default=None: sticky.get(key, default) @@ -87,7 +88,7 @@ def test_config_for_asset_saved_uses_saved_edits_and_global_overlays_only(self): hydrate.assert_called_once_with(self.mock_repo, "saved-hash", "/roll/saved.dng", half=0, composite=False) self.assertEqual(config.exposure.density, 1.7) - self.assertEqual(config.process.process_mode, "E-6") + self.assertEqual(config.process.process_mode, ProcessMode.E6) self.assertEqual(config.geometry.autocrop_ratio, "4:3") self.assertEqual(config.export.jpeg_quality, 73) self.assertTrue(config.metadata.protect_original_metadata) @@ -103,7 +104,7 @@ def test_config_for_asset_fresh_starts_clean_not_from_active_creative_edits(self self.session.state.config = active sticky = { "last_export_config": {}, - "last_process_mode": "E-6", + "last_process_mode": ProcessMode.E6, "last_aspect_ratio": "1:1", "last_autocrop_offset": 7, "last_auto_exposure": True, @@ -119,7 +120,7 @@ def test_config_for_asset_fresh_starts_clean_not_from_active_creative_edits(self self.assertEqual(config.lab.saturation, defaults.lab.saturation) self.assertTrue(config.exposure.auto_exposure) self.assertTrue(config.process.narrowband_scan) - self.assertEqual(config.process.process_mode, "E-6") + self.assertEqual(config.process.process_mode, ProcessMode.E6) self.assertEqual(config.geometry.autocrop_ratio, "1:1") self.assertEqual(config.geometry.autocrop_offset, 7) self.assertIs(self.session.state.config, active) @@ -419,7 +420,7 @@ def test_sync_selected_settings_exclusions(self): exposure=replace(WorkspaceConfig().exposure, density=1.5), geometry=GeometryConfig(rotation=1, fine_rotation=5.5, manual_crop_rect=(0, 0, 1, 1)), retouch=RetouchConfig(dust_remove=True, manual_dust_spots=[(0.1, 0.1, 5)]), - process=ProcessConfig(process_mode="E-6", e6_normalize=True), + process=ProcessConfig(process_mode=ProcessMode.E6, e6_normalize=True), ) self.session.state.selected_file_idx = 0 self.session.state.current_file_hash = "hash1" @@ -429,7 +430,7 @@ def test_sync_selected_settings_exclusions(self): exposure=replace(WorkspaceConfig().exposure, density=0.0), geometry=GeometryConfig(rotation=0, fine_rotation=0.0, manual_crop_rect=None), retouch=RetouchConfig(dust_remove=False, manual_dust_spots=[]), - process=ProcessConfig(process_mode="C41", e6_normalize=False), + process=ProcessConfig(process_mode=ProcessMode.C41, e6_normalize=False), ) self.mock_repo.load_file_settings.return_value = target_config @@ -441,7 +442,7 @@ def test_sync_selected_settings_exclusions(self): saved_config = args[1] self.assertEqual(saved_config.exposure.density, 1.5) - self.assertEqual(saved_config.process.process_mode, "E-6") + self.assertEqual(saved_config.process.process_mode, ProcessMode.E6) # Geometry not selected → entirely preserved from target self.assertEqual(saved_config.geometry.rotation, 0) @@ -456,7 +457,7 @@ def test_sync_selected_settings_edits_with_geometry(self): exposure=replace(WorkspaceConfig().exposure, density=1.5), geometry=GeometryConfig(rotation=1, fine_rotation=5.5, manual_crop_rect=(0.1, 0.1, 0.9, 0.9)), retouch=RetouchConfig(dust_remove=True, manual_dust_spots=[(0.1, 0.1, 5)]), - process=ProcessConfig(process_mode="E-6", e6_normalize=True), + process=ProcessConfig(process_mode=ProcessMode.E6, e6_normalize=True), ) self.session.state.selected_file_idx = 0 self.session.state.current_file_hash = "hash1" @@ -466,7 +467,7 @@ def test_sync_selected_settings_edits_with_geometry(self): exposure=replace(WorkspaceConfig().exposure, density=0.0), geometry=GeometryConfig(rotation=0, fine_rotation=0.0, manual_crop_rect=None), retouch=RetouchConfig(dust_remove=False, manual_dust_spots=[(0.5, 0.5, 3)]), - process=ProcessConfig(process_mode="C41", e6_normalize=False), + process=ProcessConfig(process_mode=ProcessMode.C41, e6_normalize=False), ) self.mock_repo.load_file_settings.return_value = target_config @@ -532,7 +533,7 @@ def test_sync_fresh_target_keeps_sticky_workflow_not_bare_defaults(self): doesn't silently reset its scan/process-mode to dataclass defaults.""" sticky = { "last_export_config": {}, - "last_process_mode": "E-6", + "last_process_mode": ProcessMode.E6, "last_narrowband_scan": True, } self.mock_repo.get_global_setting.side_effect = lambda key, default=None: sticky.get(key, default) @@ -551,7 +552,7 @@ def test_sync_fresh_target_keeps_sticky_workflow_not_bare_defaults(self): self.assertEqual(saved.exposure.density, 1.5) # the one synced field # Sticky workflow settings survive because the base was config_for_asset, not defaults. self.assertTrue(saved.process.narrowband_scan) - self.assertEqual(saved.process.process_mode, "E-6") + self.assertEqual(saved.process.process_mode, ProcessMode.E6) def test_sync_selected_settings_empty_is_noop(self): self.session.state.selected_file_idx = 0 diff --git a/tests/test_dye_separation.py b/tests/test_dye_separation.py index e92cc241..f2254566 100644 --- a/tests/test_dye_separation.py +++ b/tests/test_dye_separation.py @@ -18,6 +18,7 @@ resolve_saturation_matrix, ) from negpy.services.rendering.engine import DarkroomEngine +from negpy.features.process.models import ProcessMode class TestResolveSaturationMatrix: @@ -115,8 +116,8 @@ def test_per_channel_trim_changes_output(self): assert not np.array_equal(global_only, with_trim) def test_bw_mode_is_inert(self): - bw_off = self._render({"process_mode": "B&W", "dye_separation": 1.0}) - bw_on = self._render({"process_mode": "B&W", "dye_separation": 1.8}) + bw_off = self._render({"process_mode": ProcessMode.BW, "dye_separation": 1.0}) + bw_on = self._render({"process_mode": ProcessMode.BW, "dye_separation": 1.8}) np.testing.assert_allclose(bw_off, bw_on, atol=1e-6) diff --git a/tests/test_exposure_pivot.py b/tests/test_exposure_pivot.py index 124cfeeb..006bcc49 100644 --- a/tests/test_exposure_pivot.py +++ b/tests/test_exposure_pivot.py @@ -8,6 +8,7 @@ from negpy.features.exposure.logic import apply_characteristic_curve, compute_pivot, grade_to_slope from negpy.features.exposure.models import EXPOSURE_CONSTANTS from negpy.features.exposure.processor import PhotometricProcessor +from negpy.features.process.models import ProcessMode def _density_at(x_ref, slope, pivot, d_min): @@ -67,7 +68,7 @@ def test_reference_pixel_prints_at_target_brightness(self): # paper_black on (BPC off) pinned: BPC remaps the whole tone range around # paper Dmax and is orthogonal to the pivot invariant under test here. config = replace(WorkspaceConfig().exposure, paper_black=True) - ctx = PipelineContext(scale_factor=1.0, original_size=(8, 8), process_mode="C41") + ctx = PipelineContext(scale_factor=1.0, original_size=(8, 8), process_mode=ProcessMode.C41) ctx.metrics["norm_density_range"] = 1.3 x_ref = EXPOSURE_CONSTANTS["assumed_anchor"] @@ -86,7 +87,7 @@ def test_skewed_negative_reaches_paper_black(self): """Regression: film-toe-compressed shadows must still print near paper black — a symmetric L=d_max curve starved them at ~0.17 sRGB.""" config = WorkspaceConfig().exposure - ctx = PipelineContext(scale_factor=1.0, original_size=(8, 8), process_mode="C41") + ctx = PipelineContext(scale_factor=1.0, original_size=(8, 8), process_mode=ProcessMode.C41) ctx.metrics["norm_density_range"] = 1.6 img = np.full((8, 8, 3), 1.0, dtype=np.float32) # deepest measured shadow diff --git a/tests/test_finish_processor.py b/tests/test_finish_processor.py index d193de9a..004d066b 100644 --- a/tests/test_finish_processor.py +++ b/tests/test_finish_processor.py @@ -5,6 +5,7 @@ from negpy.domain.interfaces import PipelineContext from negpy.features.finish.models import FinishConfig from negpy.features.finish.processor import FinishProcessor +from negpy.features.process.models import ProcessMode class TestFinishProcessor(unittest.TestCase): @@ -12,7 +13,7 @@ def _gradient_image(self) -> np.ndarray: return np.full((100, 100, 3), 0.5, dtype=np.float32) def _context(self) -> PipelineContext: - return PipelineContext(original_size=(100, 100), scale_factor=1.0, process_mode="C41") + return PipelineContext(original_size=(100, 100), scale_factor=1.0, process_mode=ProcessMode.C41) def test_noop_when_stops_zero(self) -> None: """Processor returns image unchanged when burn is 0 stops.""" diff --git a/tests/test_grade_calibration.py b/tests/test_grade_calibration.py index 21f331a1..0f99dd51 100644 --- a/tests/test_grade_calibration.py +++ b/tests/test_grade_calibration.py @@ -8,6 +8,7 @@ from negpy.features.exposure.logic import grade_to_slope from negpy.features.exposure.models import EXPOSURE_CONSTANTS, ExposureConfig from negpy.features.exposure.processor import NormalizationProcessor, PhotometricProcessor +from negpy.features.process.models import ProcessMode class TestGradeToSlope(unittest.TestCase): @@ -66,7 +67,7 @@ def setUp(self): self.config = WorkspaceConfig() def _context(self): - return PipelineContext(scale_factor=1.0, original_size=(100, 100), process_mode="C41") + return PipelineContext(scale_factor=1.0, original_size=(100, 100), process_mode=ProcessMode.C41) def test_metric_set_on_local_bounds(self): process = replace(self.config.process, local_floors=(-2.0, -1.5, -1.0), local_ceils=(-0.1, -0.3, -0.5)) @@ -104,7 +105,7 @@ def setUp(self): self.config = WorkspaceConfig(exposure=ExposureConfig(auto_normalize_contrast=False, auto_exposure=False)) def _run(self, density_range): - ctx = PipelineContext(scale_factor=1.0, original_size=(100, 100), process_mode="C41") + ctx = PipelineContext(scale_factor=1.0, original_size=(100, 100), process_mode=ProcessMode.C41) if density_range is not None: ctx.metrics["norm_density_range"] = density_range img = np.full((8, 8, 3), 0.3, dtype=np.float32) diff --git a/tests/test_image_processor.py b/tests/test_image_processor.py index 466d4a13..87315657 100644 --- a/tests/test_image_processor.py +++ b/tests/test_image_processor.py @@ -1,6 +1,7 @@ import numpy as np from negpy.services.rendering.image_processor import ImageProcessor from negpy.domain.models import WorkspaceConfig +from negpy.features.process.models import ProcessMode def test_image_service_buffer_to_pil_8bit() -> None: @@ -17,7 +18,7 @@ def test_image_service_buffer_to_pil_8bit() -> None: def test_image_service_buffer_to_pil_16bit_bw() -> None: service = ImageProcessor() buffer = np.array([[0.0, 1.0]], dtype=np.float32) # Single channel (grayscale) - settings = WorkspaceConfig.from_flat_dict({"process_mode": "B&W"}) + settings = WorkspaceConfig.from_flat_dict({"process_mode": ProcessMode.BW}) img = service.buffer_to_pil(buffer, settings, bit_depth=16) # PIL uses 'I;16' for 16-bit single channel @@ -29,7 +30,7 @@ def test_image_service_bw_conversion() -> None: service = ImageProcessor() # 3-channel input but B&W mode buffer = np.zeros((10, 10, 3), dtype=np.float32) - settings = WorkspaceConfig.from_flat_dict({"process_mode": "B&W"}) + settings = WorkspaceConfig.from_flat_dict({"process_mode": ProcessMode.BW}) img = service.buffer_to_pil(buffer, settings, bit_depth=8) assert img.mode == "L" @@ -45,7 +46,7 @@ def test_image_service_bw_toned_keeps_color() -> None: service = ImageProcessor() buffer = np.full((4, 4, 3), 0.5, dtype=np.float32) - bw = WorkspaceConfig.from_flat_dict({"process_mode": "B&W"}) + bw = WorkspaceConfig.from_flat_dict({"process_mode": ProcessMode.BW}) for kw in ( {"blue_strength": 1.0}, diff --git a/tests/test_midtone_neutral.py b/tests/test_midtone_neutral.py index 57aec32a..104fccdc 100644 --- a/tests/test_midtone_neutral.py +++ b/tests/test_midtone_neutral.py @@ -17,6 +17,7 @@ from negpy.features.exposure.logic import per_channel_curve_params from negpy.features.exposure.processor import NormalizationProcessor, PhotometricProcessor from negpy.kernel.image.logic import rgb_to_lab_working +from negpy.features.process.models import ProcessMode _H, _W = 600, 400 @@ -42,7 +43,7 @@ def _negative(green_log: float | None = -0.22) -> np.ndarray: return (10.0**log).astype(np.float32) -def _render(img: np.ndarray, cast_removal: bool, mode: str = "C41") -> np.ndarray: +def _render(img: np.ndarray, cast_removal: bool, mode: str = ProcessMode.C41) -> np.ndarray: cfg = WorkspaceConfig() process = replace(cfg.process, analysis_buffer=0.0) ctx = PipelineContext(scale_factor=1.0, original_size=img.shape[:2], process_mode=mode) diff --git a/tests/test_normalization_unclamped.py b/tests/test_normalization_unclamped.py index 8f916249..86d868df 100644 --- a/tests/test_normalization_unclamped.py +++ b/tests/test_normalization_unclamped.py @@ -6,12 +6,13 @@ from negpy.domain.interfaces import PipelineContext from negpy.domain.models import WorkspaceConfig from negpy.features.exposure.processor import NormalizationProcessor +from negpy.features.process.models import ProcessMode class TestNormalizationUnclamped(unittest.TestCase): def setUp(self): self.config = WorkspaceConfig() - self.context = PipelineContext(scale_factor=1.0, original_size=(100, 100), process_mode="C41") + self.context = PipelineContext(scale_factor=1.0, original_size=(100, 100), process_mode=ProcessMode.C41) def test_unclamped_out_of_bounds(self): """ diff --git a/tests/test_paper_dmin.py b/tests/test_paper_dmin.py index 84ef52e4..99ed3ab0 100644 --- a/tests/test_paper_dmin.py +++ b/tests/test_paper_dmin.py @@ -7,6 +7,7 @@ from negpy.domain.models import WorkspaceConfig from negpy.features.exposure.models import EXPOSURE_CONSTANTS from negpy.features.exposure.processor import PhotometricProcessor +from negpy.features.process.models import ProcessMode class TestPaperDmin(unittest.TestCase): @@ -14,7 +15,7 @@ def setUp(self): self.config = WorkspaceConfig().exposure def _run(self, value: float, paper_dmin: bool) -> float: - ctx = PipelineContext(scale_factor=1.0, original_size=(8, 8), process_mode="C41") + ctx = PipelineContext(scale_factor=1.0, original_size=(8, 8), process_mode=ProcessMode.C41) img = np.full((8, 8, 3), value, dtype=np.float32) res = PhotometricProcessor(replace(self.config, paper_dmin=paper_dmin)).process(img, ctx) return float(res[0, 0, 0]) diff --git a/tests/test_preview_load_worker_detect_mode.py b/tests/test_preview_load_worker_detect_mode.py index b91879c5..c3dff441 100644 --- a/tests/test_preview_load_worker_detect_mode.py +++ b/tests/test_preview_load_worker_detect_mode.py @@ -15,6 +15,7 @@ import numpy as np from negpy.features.rgbscan.models import RgbScanConfig +from negpy.features.process.models import ProcessMode def _task(use_camera_wb: bool): @@ -104,8 +105,8 @@ def test_automatic_import_returns_classifier_result_from_public_process(qapp): detect_mode=True, ) - with patch("negpy.features.process.logic.detect_process_mode", return_value="E-6") as dpm: + with patch("negpy.features.process.logic.detect_process_mode", return_value=ProcessMode.E6) as dpm: worker.process(task) dpm.assert_called_once_with(raw) - assert finished[0][5] == "E-6" + assert finished[0][5] == ProcessMode.E6 diff --git a/tests/test_process_sidebar.py b/tests/test_process_sidebar.py index 26447fed..bad5be38 100644 --- a/tests/test_process_sidebar.py +++ b/tests/test_process_sidebar.py @@ -3,6 +3,7 @@ from negpy.desktop.session import AppState from negpy.desktop.view.sidebar.process import ProcessSidebar +from negpy.features.process.models import ProcessMode def _sidebar(): @@ -54,7 +55,7 @@ def test_channel_selector_hidden_in_bw(qapp): sidebar.ch_r_btn.setChecked(True) cfg = controller.state.config - controller.state.config = replace(cfg, process=replace(cfg.process, process_mode="B&W")) + controller.state.config = replace(cfg, process=replace(cfg.process, process_mode=ProcessMode.BW)) sidebar.sync_ui() for w in (sidebar.ch_global_btn, sidebar.ch_r_btn, sidebar.ch_g_btn, sidebar.ch_b_btn): assert w.isHidden() @@ -68,7 +69,7 @@ def test_channel_selector_hidden_on_the_transparency_transfer(qapp): controller, sidebar = _sidebar() cfg = controller.state.config - controller.state.config = replace(cfg, process=replace(cfg.process, process_mode="E-6", e6_normalize=True)) + controller.state.config = replace(cfg, process=replace(cfg.process, process_mode=ProcessMode.E6, e6_normalize=True)) sidebar.sync_ui() assert not sidebar.ch_r_btn.isHidden() sidebar.ch_r_btn.setChecked(True) diff --git a/tests/test_scanlight_sidebar.py b/tests/test_scanlight_sidebar.py index 957ff178..d8895643 100644 --- a/tests/test_scanlight_sidebar.py +++ b/tests/test_scanlight_sidebar.py @@ -13,6 +13,7 @@ from PyQt6.QtWidgets import QApplication from negpy.desktop.view.sidebar.scanlight import ScanlightSidebar +from negpy.infrastructure.capture.settings import ScanlightSettings, WhiteCaptureMode from negpy.services.capture.presets import ScanlightPreset if not QApplication.instance(): @@ -129,7 +130,7 @@ def test_builtin_white_preset_sets_white_mode(): w.preset_combo.setCurrentIndex(idx) w._on_preset_selected(idx) assert w._settings.white_mode is True - assert w._settings.white_process_mode == "auto" # B&W/slide merged → NegPy autodetects + assert w._settings.white_process_mode is WhiteCaptureMode.AUTO # B&W/slide merged → NegPy autodetects def test_white_preset_does_not_inherit_the_rgb_shutter(tmp_path): @@ -1325,3 +1326,11 @@ def test_calibrate_request_carries_the_iso_aperture_normalized_start_point(tmp_p assert req.start_shutter == "1/20" # 1/5 scaled 2 stops faster for ISO 400 assert req.start_levels == REFERENCE_LEVELS # unchanged — same light for every body assert req.shutter_candidates == ("1/250", "1/20", "1/5") + + +def test_legacy_white_process_mode_names_still_load(): + """A `scanlight_settings` dict saved before the process-mode rename carries the old + names; they must resolve to a mode rather than silently reading as autodetect.""" + assert ScanlightSettings(white_process_mode="E-6").white_process_mode is WhiteCaptureMode.E6 + assert ScanlightSettings(white_process_mode="B&W").white_process_mode is WhiteCaptureMode.BW + assert ScanlightSettings(white_process_mode="nonsense").white_process_mode is WhiteCaptureMode.AUTO diff --git a/tests/test_sensor_sidebar.py b/tests/test_sensor_sidebar.py index 3484d1a3..a5617061 100644 --- a/tests/test_sensor_sidebar.py +++ b/tests/test_sensor_sidebar.py @@ -18,6 +18,7 @@ from negpy.kernel.system.config import APP_CONFIG from negpy.services.assets.crosstalk import CrosstalkProfiles from negpy.services.assets.sensor import SensorProfiles +from negpy.features.process.models import ProcessMode if not QApplication.instance(): _app = QApplication(sys.argv) @@ -86,7 +87,7 @@ def test_crosstalk_stays_reachable_with_no_matrices_for_the_process(tmp_path, mo _empty_crosstalk_gallery(tmp_path, monkeypatch) w = _sidebar() cfg = w.state.config - w.state.config = replace(cfg, process=replace(cfg.process, process_mode="E-6")) + w.state.config = replace(cfg, process=replace(cfg.process, process_mode=ProcessMode.E6)) w.sync_ui() assert not w.crosstalk_header.isHidden() @@ -99,11 +100,11 @@ def test_crosstalk_stays_reachable_with_no_matrices_for_the_process(tmp_path, mo def test_crosstalk_controls_go_live_once_a_matrix_exists(tmp_path, monkeypatch): _empty_crosstalk_gallery(tmp_path, monkeypatch) - CrosstalkProfiles.save("My Slide Rig", [1.0, -0.05, 0.0, 0.0, 1.0, 0.0, 0.0, -0.05, 1.0], process="E-6") + CrosstalkProfiles.save("My Slide Rig", [1.0, -0.05, 0.0, 0.0, 1.0, 0.0, 0.0, -0.05, 1.0], process=ProcessMode.E6) w = _sidebar() cfg = w.state.config - w.state.config = replace(cfg, process=replace(cfg.process, process_mode="E-6")) + w.state.config = replace(cfg, process=replace(cfg.process, process_mode=ProcessMode.E6)) w.sync_ui() assert w.crosstalk_combo.isEnabled() @@ -117,7 +118,7 @@ def test_crosstalk_hidden_on_bw(tmp_path, monkeypatch): _empty_crosstalk_gallery(tmp_path, monkeypatch) w = _sidebar() cfg = w.state.config - w.state.config = replace(cfg, process=replace(cfg.process, process_mode="B&W")) + w.state.config = replace(cfg, process=replace(cfg.process, process_mode=ProcessMode.BW)) w.sync_ui() for widget in (w.crosstalk_header, w.crosstalk_combo, w.manage_crosstalk_btn, w.crosstalk_strength_slider): diff --git a/tests/test_storage_repo.py b/tests/test_storage_repo.py index f9626cbd..f5ef1810 100644 --- a/tests/test_storage_repo.py +++ b/tests/test_storage_repo.py @@ -4,6 +4,7 @@ from negpy.domain.models import WorkspaceConfig from negpy.features.metadata.models import MetadataConfig from negpy.infrastructure.storage.repository import StorageRepository +from negpy.features.process.models import ProcessMode def _repo(tmp_path): @@ -27,7 +28,7 @@ def test_save_global_settings_batch_round_trip(tmp_path): def test_save_global_settings_matches_single_write_path(tmp_path): repo_batch = _repo(tmp_path / "batch") repo_single = _repo(tmp_path / "single") - values = {"mode": "C41", "clip": 0.01, "matrix": [[1, 0], [0, 1]], "flag": False} + values = {"mode": ProcessMode.C41, "clip": 0.01, "matrix": [[1, 0], [0, 1]], "flag": False} repo_batch.save_global_settings(values) for key, value in values.items(): diff --git a/tests/test_toe_shoulder_smooth.py b/tests/test_toe_shoulder_smooth.py index 54756794..5b398a6d 100644 --- a/tests/test_toe_shoulder_smooth.py +++ b/tests/test_toe_shoulder_smooth.py @@ -8,6 +8,7 @@ from negpy.domain.models import WorkspaceConfig from negpy.features.exposure.logic import CharacteristicCurve, apply_characteristic_curve from negpy.features.exposure.processor import PhotometricProcessor +from negpy.features.process.models import ProcessMode def _ramp_image(n: int = 256) -> np.ndarray: @@ -89,7 +90,7 @@ def test_chart_matches_kernel(self): class TestBWLuminanceBeforeCurve(unittest.TestCase): def test_bw_output_channels_identical(self): config = replace(WorkspaceConfig().exposure, toe=0.5, shoulder=0.3) - ctx = PipelineContext(scale_factor=1.0, original_size=(8, 8), process_mode="B&W") + ctx = PipelineContext(scale_factor=1.0, original_size=(8, 8), process_mode=ProcessMode.BW) rng = np.random.default_rng(0) img = rng.uniform(0.0, 1.0, (8, 8, 3)).astype(np.float32) diff --git a/tests/test_tone_sidebar.py b/tests/test_tone_sidebar.py index 98c23416..176776e5 100644 --- a/tests/test_tone_sidebar.py +++ b/tests/test_tone_sidebar.py @@ -3,6 +3,7 @@ from negpy.desktop.session import AppState from negpy.desktop.view.sidebar.tone import ToneSidebar +from negpy.features.process.models import ProcessMode def _combo_items(combo): @@ -173,7 +174,7 @@ def test_channel_selector_hidden_in_bw(qapp): sidebar.ch_r_btn.setChecked(True) cfg = controller.state.config - controller.state.config = replace(cfg, process=replace(cfg.process, process_mode="B&W")) + controller.state.config = replace(cfg, process=replace(cfg.process, process_mode=ProcessMode.BW)) sidebar.sync_ui() for w in (sidebar.ch_global_btn, sidebar.ch_r_btn, sidebar.ch_g_btn, sidebar.ch_b_btn): assert w.isHidden()