From 247db85711eea253993195e307c6a74283efe1e1 Mon Sep 17 00:00:00 2001 From: Akinyemi Paul Odulate Date: Fri, 30 Oct 2026 13:29:11 +0000 Subject: [PATCH 01/46] Lento Dock: start the scan at the S press The scan waited for "SHOT done", the end of the dock's own 3 s exposure. It now starts at the STATE st=EXPOSE that begins it. On the dock, CONTRGB is accepted during that exposure, and TW does not shorten it. A film move or eject waits until the exposure ends. Refs #1375 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CiaS7mdcgupjy3zYtMa4Su --- docs/CAMERA_SCANNING.md | 4 ++-- negpy/infrastructure/capture/lento_dock.py | 25 ++++++++++++++------ negpy/infrastructure/simulated/lento_dock.py | 20 +++++++++++----- tests/test_lento_dock_driver.py | 6 +++-- 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/docs/CAMERA_SCANNING.md b/docs/CAMERA_SCANNING.md index d92cc9196..718c731a3 100644 --- a/docs/CAMERA_SCANNING.md +++ b/docs/CAMERA_SCANNING.md @@ -203,8 +203,8 @@ described above. dock then finds the first frame itself. Opening the dock's port resets the dock, so it does not know the position of a strip that was in it before. - While NegPy uses the dock, the dock's S button works as Scan: it starts a scan, or stops one. - The dock answers the press with its own exposure first, so the scan starts a few seconds - later. An arrow button nudges the film on a tap and moves it while you hold it. NegPy + The scan starts at the press. The dock also makes a 3-second exposure of its own, and the + film moves only after it ends. An arrow button nudges the film on a tap and moves it while you hold it. NegPy leaves the dock's button mode (`BTNMODE`) as you set it. - When NegPy releases the dock, it puts back the dock's saved light mode and control mode. diff --git a/negpy/infrastructure/capture/lento_dock.py b/negpy/infrastructure/capture/lento_dock.py index 8660d4141..84d16cfb9 100644 --- a/negpy/infrastructure/capture/lento_dock.py +++ b/negpy/infrastructure/capture/lento_dock.py @@ -46,6 +46,7 @@ PING_INTERVAL_S = 5.0 #: The motor moves one frame and checks the frame edge within this time. ADVANCE_TIMEOUT_S = 15.0 +EXPOSE_WAIT_S = 5.0 # the dock's own exposure on an S press lasts 3 s EJECT_TIMEOUT_S = 30.0 HOLD_INTERVAL_S = 30.0 @@ -227,6 +228,7 @@ def advance_frame(self) -> Optional[int]: """ self._enter_continuous() self._command("STATE?", r"^STATE\b") + self._wait_out_exposure() if self._state.get("film") == "0": return None self._last_frameat = "" @@ -242,6 +244,7 @@ def advance_frame(self) -> Optional[int]: def eject(self) -> None: """Move the strip out of the dock. The last frame stops in the window, short of the exit.""" self._enter_continuous() + self._wait_out_exposure() self._command("EJECT", r"^(OK|ERR)", then=((r"^(EJECTED\b|STATE\b.*\bentrance=0\b)", EJECT_TIMEOUT_S),)) self._light_on = False # the next set_color holds the light again, for a shot of the empty dock @@ -377,6 +380,13 @@ def _reader_loop(self) -> None: finally: self._replies.put(_READER_DIED) + def _wait_out_exposure(self) -> None: + """An S press holds the dock in its own exposure (st=EXPOSE), and NegPy's scan starts at + the press; the film may move only once that exposure ends.""" + deadline = time.monotonic() + EXPOSE_WAIT_S + while self._state.get("st") == "EXPOSE" and time.monotonic() < deadline: + time.sleep(0.05) + def _dispatch(self, line: str) -> None: logger.debug("Lento Dock: %s", line) if line.startswith("FRAMEAT"): @@ -384,14 +394,15 @@ def _dispatch(self, line: str) -> None: elif line.startswith(("END_OF_ROLL", "ROLLEND")): self._last_frameat = "" if line.startswith("STATE"): + was = self._state.get("st") self._state = parse_kv(line) + # NegPy starts no exposure of the dock's own: one that begins outside a NegPy command is + # the S button. The dock accepts light commands during it. + if self._state.get("st") == "EXPOSE" and was != "EXPOSE" and not self._in_command and self.on_scan_button: + try: + self.on_scan_button() + except Exception: + logger.exception("Lento Dock scan button handler failed") elif line.startswith("BOOT"): self._booted.set() - elif line.startswith("SHOT done") and not self._in_command and self.on_scan_button is not None: - # NegPy sends no SHOT: an exposure it did not ask for is the S button, which the dock - # answers with its own exposure before it reports. - try: - self.on_scan_button() - except Exception: - logger.exception("Lento Dock scan button handler failed") self._replies.put(line) diff --git a/negpy/infrastructure/simulated/lento_dock.py b/negpy/infrastructure/simulated/lento_dock.py index 843682b97..abe1f6aa6 100644 --- a/negpy/infrastructure/simulated/lento_dock.py +++ b/negpy/infrastructure/simulated/lento_dock.py @@ -2,6 +2,7 @@ and moves a strip of `frames` frames.""" import queue +import threading _IDENT = "IDENT serial=035SIM/0001/26 model=35 hwrev=v1.0" _VER = "VER fw=1.0.224 model=35" @@ -47,16 +48,23 @@ def _state(self) -> str: f"busy=0 expmode={self.expmode} shutter=JACK" ) - def press_scan(self) -> None: - """The S button in host mode: the dock makes its own exposure, then reports it.""" + def press_scan(self, hold_s: float = 0.0) -> None: + """The S button in host mode: the dock holds its own exposure for `hold_s`, then reports it.""" self.st = "EXPOSE" self._say(self._state()) - self._say("SHOT done ms=3000") - self.st = "READY" - self._say(self._state()) + + def done() -> None: + self._say("SHOT done ms=3000") + self.st = "READY" + self._say(self._state()) + + if hold_s: + threading.Timer(hold_s, done).start() + else: + done() def _frame_next(self) -> None: - if self.mode != "SEMI" or self.frames == 0: + if self.mode != "SEMI" or self.frames == 0 or self.st == "EXPOSE": self._say("ERR STATE") return self._say("OK") diff --git a/tests/test_lento_dock_driver.py b/tests/test_lento_dock_driver.py index df5939f17..1f69631ea 100644 --- a/tests/test_lento_dock_driver.py +++ b/tests/test_lento_dock_driver.py @@ -261,9 +261,11 @@ def test_the_s_button_calls_the_scan_handler(sim): dock.on_scan_button = lambda: pressed.append(1) dock.advance_frame() assert pressed == [] # NegPy's own commands are never a press - sim.press_scan() - for _ in range(100): + sim.press_scan(hold_s=0.5) + for _ in range(30): if pressed: break time.sleep(0.01) + assert pressed == [1] # at the press, not at the end of the dock's exposure + assert dock.advance_frame() == 1 # the film waits for the dock's exposure to end assert pressed == [1] From 2a8c73abd92175a1863d1fdd5a4b3eb50eb7832d Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:06:54 +0000 Subject: [PATCH 02/46] fix(transparency): split a camera RAW's matrix at the white-balance fold around the density stage A camera RAW applied its whole matrix before the log, so the crosstalk unmix, Fade Restoration and Remove Stain acted on mixed working-space channels and not on the film's own dye channels. The camera matrix now splits into two parts (camera_matrix_parts). The white-balance gains apply before the log. The primaries apply after the density matrix, in linear light, on the Positive and Transfer paths. The primaries rows sum to 1, so a border that Remove Stain makes neutral in white-balanced camera channels stays neutral. With the whole matrix after the stain offset, a synthetic border (WB 2:1:1.5, white 0.8) came out at log (+0.201, -0.100, +0.076). It now comes out at -0.1 in each channel. A frame with no density matrix keeps its bits. The print path does not change. Fade Estimate now reads a camera RAW in white-balanced camera channels, which are the channels the fade acts on. The slide characterization goldens move by at most 0.0074 because they use a crosstalk matrix. Bumps THUMBNAIL_RENDER_VERSION to 32. BOUNDS_VERSION does not change: only the transfer meters move, and the app does not save them as bounds. --- docs/PIPELINE.md | 4 +- .../exposure/shaders/normalization.wgsl | 6 +- negpy/features/process/capture_color.py | 81 +++++++---- negpy/features/transparency/logic.py | 38 +++-- negpy/features/transparency/processor.py | 22 +-- .../services/assets/thumbnail_fingerprint.py | 2 +- negpy/services/rendering/gpu_engine.py | 55 +++----- tests/test_pipeline_parity.py | 31 +++++ tests/test_positive_source_primaries.py | 28 ++-- tests/test_raw_density_order.py | 131 ++++++++++++++++++ tests/test_slide_paths_characterization.py | 20 +-- tests/thumbnail_fingerprint_defaults.json | 2 +- 12 files changed, 303 insertions(+), 117 deletions(-) create mode 100644 tests/test_raw_density_order.py diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 36cd247cb..5dbbe9f66 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -61,7 +61,7 @@ Pixel values are linear transmittance. The source corrections run in that domain * **Channel unmix / crosstalk** (`crosstalk_strength` / `crosstalk_matrix`, docs/CROSSTALK.md): a 3×3 unmix of the negative log densities before bounds and stretch, where dye absorptions, light spectrum and CFA are linear, so a profile describes a scanning setup. It is blended with identity by strength and row-normalized, so grays stay. Every meter reads the unmixed film, so a change needs a new Roll Analysis. * **Fade restoration** (`fade_ratio_r` / `fade_ratio_g` / `fade_ratio_b`, slides only: TRANSFER and POSITIVE): composes a diagonal restoration matrix $F^{-1}$ after the crosstalk unmix, $M = F^{-1} M_{\text{unmix}}$ (`features/process/fade.py::unmix_with_fade`). It runs before Cast Removal, which then meters the restored film. $F = \mathrm{diag}(a_r,\, a_r a_g,\, a_r a_b)$ acts on densities. $a_g$ and $a_b$ are survival ratios to red, which a neutral can constrain. $a_r$ is red's own survival and is set by eye. $F$ is the identity at unit survival, and it is not row-normalized. - Estimate (`features/process/fade.py`) runs on request, never in a render. It renders the frame on the CPU with $F$ at identity and White Anchor off, and reads the transparency meter grid (`transfer_grid`: bakes, geometry, crop, camera matrix and unmix; a Positive source's grid stays in its own primaries). It meters the grays with `measure_neutral_axis_from_log` against the frame's own bounds: first with the foliage and sky sectors excluded, against bounds from each channel's extreme cells with decoded zeros dropped, at confidence `MIN_CONFIDENCE` ($0.2$) or more. Else it reads against bounds from the `ROBUST_EXTREME_PCT` ($0.1$) percentiles, with the sectors excluded, then not, at `ROBUST_MIN_CONFIDENCE` ($0.4$). MIDTONE reads the midtone densities and also sets $a_r$; SPREAD reads each channel's midtone-to-shadow spread. Ratios clamp to $[0.2, 5]$, and the hint shows the unclamped value. The hint also shows the other method's reading: a large gap is a cast that a fade does not explain. A near-monochrome slide meters as faded. + Estimate (`features/process/fade.py`) runs on request, never in a render. It renders the frame on the CPU with $F$ at identity and White Anchor off, and reads the transparency meter grid (`transfer_grid`: bakes, geometry, crop, camera matrix and unmix; the grid is in the channels the density matrix acts on, `density_channel_matrices`). It meters the grays with `measure_neutral_axis_from_log` against the frame's own bounds: first with the foliage and sky sectors excluded, against bounds from each channel's extreme cells with decoded zeros dropped, at confidence `MIN_CONFIDENCE` ($0.2$) or more. Else it reads against bounds from the `ROBUST_EXTREME_PCT` ($0.1$) percentiles, with the sectors excluded, then not, at `ROBUST_MIN_CONFIDENCE` ($0.4$). MIDTONE reads the midtone densities and also sets $a_r$; SPREAD reads each channel's midtone-to-shadow spread. Ratios clamp to $[0.2, 5]$, and the hint shows the unclamped value. The hint also shows the other method's reading: a large gap is a cast that a fade does not explain. A near-monochrome slide meters as faded. White Anchor (`fade_white_anchor`, UI "Remove Stain") makes the matrix affine, $M(L - w) + t$. The stain $w$ is each channel's `WHITE_PCT` cell of the meter grid in log transmittance, without decoded zeros and cells at full scale in every channel; Estimate stores it as `fade_white_r/g/b`. It maps to the neutral $t = \min(\max_c w_c, -0.1)$ (`FADE_WHITE_HEADROOM`): the cleanest channel's own level, never within $0.1$ D of full scale. $F^{-1}$ acts on $L - w$ before $t$ is added, so the Estimate reads the grays on $\min(L - w, 0)$. The offset rides in the w lanes of the unmix rows on the GPU. * **Density above base**: `density_above_base` is (Dmin, Dmed, Dmax) above the clear base, luminance-weighted: the thin and dense bounds (before trims; the roll's under Roll Analysis) and the metered median, each minus the base. The base is the picked one, else the rebate reader's, unmixed like the meters. A pick is the open roll's (`rolls.roll_film_base`), or the frame's own with no roll open (`process.film_base`, unhashed); in an open roll the roll's wins, with none open the frame's. The preview carries it as `film_base`; export never reads it. The reader takes the flat 8-px blocks (std over mean under 0.03, unclipped) in the outer 15% of the film box (`film_and_picture` at `AUTOCROP_DETECT_RES`), groups them by brightness with a 0.04-decade gap, and takes the largest group of at least five. It finds none without a film box, with a base darker than the picture, or on the transfer path. The preview reads it once per source, on the flat-fielded, unmixed source before the defect repairs, and passes the read to both engines. @@ -174,7 +174,7 @@ For Transparency and Positive a transfer curve replaces the Print stage, exactly * **Neutral decode**: this path always decodes without the as-shot white balance (`effective_linear_raw`), because the camera matrix folds it back in. Decode, both matrices and every cache token ask that helper; it also makes a TIFF load as literal linear data. Positive is exempt: a TIFF decodes by Source Encoding (`path.positive_decode`, `loaders/source_encoding.py`), whatever Linear RAW says; Linear and 2.2 override Auto, and Input ICC applies a LUT profile. Auto skips a device profile (class `scnr`, `is_device_profile`), which describes the scanner rather than the file's encoding. Otherwise it applies the embedded profile's tone curves (`curv`, `para`); a LUT-only profile falls back to its name (sRGB, Adobe RGB). The colorants to Adobe RGB are relative to the D50 PCS. A source whose gamut fits Adobe RGB (no negative matrix entry) keeps its own primaries, and the matrix becomes the frame's camera matrix (`decode_positive`, `source_cam_xyz`). A wider source converts in the decode, where out-of-gamut pixels move toward their own luminance until they fit, which keeps hue. A JPEG decodes by Auto, as it has no Encoding control. A JXL loads as linear. An untagged TIFF is linear above 8 bits and sRGB at 8 bits. An identity decode of 16-bit samples passes them through exactly. The encoding joins `linear_raw_token` and `source_token`. A Slide that is not a camera RAW (`LoaderFactory.is_camera_raw`: TIFF, JPEG, JXL, Pakon, Noritsu, Coolscan NEF, Flextight FFF) starts Positive on a first open and on a switch to Slide, unless its roll's Film card holds a Positive value (`loaders/source_kind.py`). Positive off sends it down the as-captured transfer. * **Highlight reconstruction** (`highlight_reconstruction`, `effective_highlight_reconstruction`): libraw `highlight_mode` `0` Clip, `2` Blend, `3` to `9` Reconstruct (the sidebar uses `5`). It is forced to `0` off Transparency, under Narrowband and on a merged bracket, whose weighting needs pixels near the sensor ceiling (`WorkspaceConfig.__post_init__`; `HdrWorker.run` zeroes it per frame). Active reconstruction bakes the real white balance and skips the matrix fold (`highlight_reconstruction_bakes_wb`, `should_fold_camera_wb`), because libraw finds clipping from its own gains; `highlight_reconstruction_bright_gain` passes the darkening ratio back as libraw's `bright`. -* **Camera matrix**: libraw's `rgb_xyz_matrix` (`PipelineContext.cam_xyz`) becomes the working→camera matrix, row-normalized, then inverted (dcraw's `cam_xyz_coeff` order); normalizing after inversion gives a magenta cast. It applies in linear before the log. A Positive source profile's matrix (`source_cam_xyz`, marked by its 4th row, `is_source_profile`) applies after the density matrix (crosstalk, fade or stain offset) instead, in linear light (`density_stage_matrices`, `relog_matrix_image`, the shader's `post` rows): the density corrections act on the source's own channels, which the primaries matrix mixes. A camera's matrix applies before the log on every path. An explicit Input ICC swaps in `wb_only_cam_xyz` (`AppController._effective_cam_xyz`), which reduces to the white-balance fold. +* **Camera matrix**: libraw's `rgb_xyz_matrix` (`PipelineContext.cam_xyz`) becomes the working→camera matrix, row-normalized, then inverted (dcraw's `cam_xyz_coeff` order); normalizing after inversion gives a magenta cast. It applies in linear before the log. With a density matrix (crosstalk, fade or stain offset) on a slide path, the matrix splits around it (`density_stage_matrices`). A camera's matrix splits at the white-balance fold (`camera_matrix_parts`): the per-channel gains apply before the log, and the primaries apply after the density matrix, in linear light (`relog_matrix_image`, the shader's `post` rows). The density corrections then act on the white-balanced camera channels, which the primaries mix. The primaries rows sum to 1, so a border that Remove Stain makes neutral stays neutral. A Positive source profile's matrix (`source_cam_xyz`, marked by its 4th row, `is_source_profile`) applies after the density matrix in the same way. An explicit Input ICC swaps in `wb_only_cam_xyz` (`AppController._effective_cam_xyz`), which reduces to the white-balance fold. * **Fixed bounds**: a constant window from the decoder's white level ($D = 0$) to `TRANSFER_DENSITY_RANGE` $= 3.0$ decades below. White and Black Point (`per_channel_point_offsets`) shift it. No dye-crosstalk unmix. * **Cast Removal bands**: the neutral axis (§3, Automatic helpers) takes its highlight, midtone and shadow bands from the fixed window, before White and Black Point. A Positive file has no sensor white, so its bands span the 1st to 99th percentile of its luma-weighted log density, one offset on all channels (`transfer_axis_bounds`, `POSITIVE_AXIS_PERCENTILES`). When that finds no axis, the dark end is taken again over the cells more than `POSITIVE_AXIS_FLOOR_MARGIN` (0.1 D) above it, so a black border does not fill the shadow band (`measure_transfer_neutral_axis`). The bands then select the same pixels at any exposure or encoding of the file, and the refs stay raw log densities. diff --git a/negpy/features/exposure/shaders/normalization.wgsl b/negpy/features/exposure/shaders/normalization.wgsl index 18e6a5f8b..fc05b66b8 100644 --- a/negpy/features/exposure/shaders/normalization.wgsl +++ b/negpy/features/exposure/shaders/normalization.wgsl @@ -20,9 +20,9 @@ struct NormUniforms { cam0: vec4, cam1: vec4, cam2: vec4, - // A Positive source's primaries, applied in LINEAR after the density matrix so the unmix, - // fade and stain offset act on its own channels (density_stage_matrices). post0.w is 1.0 - // when it applies; the cam rows are then identity. + // A camera's or a Positive source's primaries, applied in LINEAR after the density matrix so + // the unmix, fade and stain offset act on its own channels (density_stage_matrices). post0.w + // is 1.0 when it applies; the cam rows then hold only the white-balance gains, or identity. post0: vec4, post1: vec4, post2: vec4, diff --git a/negpy/features/process/capture_color.py b/negpy/features/process/capture_color.py index d3a98c937..f8cce1302 100644 --- a/negpy/features/process/capture_color.py +++ b/negpy/features/process/capture_color.py @@ -19,7 +19,7 @@ magenta cast that reads like wildly excessive saturation. """ -from typing import Optional, Sequence +from typing import Optional, Sequence, Tuple import negpy_core import numpy as np @@ -36,6 +36,40 @@ _WORKING_TO_XYZ = np.linalg.inv(_XYZ_TO_WORKING) +def _primaries(cam_xyz: Optional[Sequence[Sequence[float]]]) -> Optional[np.ndarray]: + """The float64 working-from-camera primaries mix without the white-balance fold, or None.""" + if cam_xyz is None: + return None + m = np.asarray(cam_xyz, dtype=np.float64) + if m.ndim != 2 or m.shape[1] != 3 or m.shape[0] < 3: + return None + m = m[:3, :] + if not np.all(np.isfinite(m)) or abs(float(np.linalg.det(m))) < 1e-9: + return None + + # Normalize the forward (working to cam) rows, then invert, which is dcraw's order. The + # other way round distorts every non-neutral color; see the module docstring. + forward = m @ _WORKING_TO_XYZ + sums = forward.sum(axis=1, keepdims=True) + if not np.all(np.isfinite(sums)) or np.any(np.abs(sums) < 1e-9): + return None + out = np.linalg.pinv(forward / sums) + if not np.all(np.isfinite(out)): + return None + return out + + +def _wb_gains(camera_wb: Optional[Sequence[float]]) -> Optional[np.ndarray]: + """The as-shot multipliers normalized to green, or None when they are missing or invalid. + Green is libraw's own scaling reference, so only the channel ratios apply and exposure stays.""" + if camera_wb is None: + return None + wb = np.asarray(camera_wb, dtype=np.float64).reshape(-1)[:3] + if wb.shape[0] == 3 and np.all(np.isfinite(wb)) and float(wb.min()) > 0.0 and float(wb[1]) > 0.0: + return wb / wb[1] + return None + + def camera_to_working_matrix( cam_xyz: Optional[Sequence[Sequence[float]]], camera_wb: Optional[Sequence[float]] = None, @@ -56,33 +90,30 @@ def camera_to_working_matrix( singular/degenerate — the caller then treats the buffer as already in the working space, which is the correct reading for a profiled source. """ - if cam_xyz is None: - return None - m = np.asarray(cam_xyz, dtype=np.float64) - if m.ndim != 2 or m.shape[1] != 3 or m.shape[0] < 3: - return None - m = m[:3, :] - if not np.all(np.isfinite(m)) or abs(float(np.linalg.det(m))) < 1e-9: - return None - - # Normalize the forward (working to cam) rows, then invert, which is dcraw's order. The - # other way round distorts every non-neutral color; see the module docstring. - forward = m @ _WORKING_TO_XYZ - sums = forward.sum(axis=1, keepdims=True) - if not np.all(np.isfinite(sums)) or np.any(np.abs(sums) < 1e-9): - return None - out = np.linalg.pinv(forward / sums) - if not np.all(np.isfinite(out)): + out = _primaries(cam_xyz) + if out is None: return None + gains = _wb_gains(camera_wb) + if gains is not None: + out = out @ np.diag(gains) + return np.ascontiguousarray(out, dtype=np.float32) - if camera_wb is not None: - wb = np.asarray(camera_wb, dtype=np.float64).reshape(-1)[:3] - if wb.shape[0] == 3 and np.all(np.isfinite(wb)) and float(wb.min()) > 0.0 and float(wb[1]) > 0.0: - # Normalized to green, matching libraw's own scaling reference, so only the channel - # ratios are applied and overall exposure is untouched. - out = out @ np.diag(wb / wb[1]) - return np.ascontiguousarray(out, dtype=np.float32) +def camera_matrix_parts( + cam_xyz: Optional[Sequence[Sequence[float]]], + camera_wb: Optional[Sequence[float]] = None, +) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]: + """(gains, primaries): `camera_to_working_matrix` split at the white-balance fold, so that + matrix is `primaries @ gains`. `gains` is the diagonal fold, None without one; `primaries` + mixes the channels and keeps a neutral neutral. Both None when there is no matrix.""" + out = _primaries(cam_xyz) + if out is None: + return None, None + gains = _wb_gains(camera_wb) + return ( + None if gains is None else np.ascontiguousarray(np.diag(gains), dtype=np.float32), + np.ascontiguousarray(out, dtype=np.float32), + ) def wb_only_cam_xyz(cam_xyz: Optional[Sequence[Sequence[float]]]) -> Optional[list]: diff --git a/negpy/features/transparency/logic.py b/negpy/features/transparency/logic.py index 752ed69f7..fba741e83 100644 --- a/negpy/features/transparency/logic.py +++ b/negpy/features/transparency/logic.py @@ -483,24 +483,38 @@ def transfer_point_offsets(process: ProcessConfig) -> Tuple[Tuple[float, float, return (-wp3[0], -wp3[1], -wp3[2]), (-bp3[0], -bp3[1], -bp3[2]) -def source_channels_first(process: ProcessConfig, cam_xyz: Optional[list]) -> bool: - """True when the density stage acts on a Positive source profile's own channels, so its - primaries convert after it. A camera matrix always applies before the log.""" - from negpy.features.process.capture_color import is_source_profile +def density_channel_matrices( + process: ProcessConfig, cam_xyz: Optional[list], camera_wb: Optional[list] +) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]: + """(before the log, after the density matrix) when a density matrix acts. The pre matrix gives + the channels the crosstalk unmix, fade and stain offset act on. A camera splits at its white-balance + fold: the gains go before the log, the primaries after. A Positive source profile's primaries go after; + off Positive the whole matrix goes before. `camera_wb` is the as-shot white balance, folded only + when `should_fold_camera_wb` says so.""" + from negpy.features.process.capture_color import camera_matrix_parts, camera_to_working_matrix, is_source_profile + from negpy.features.process.logic import should_fold_camera_wb from negpy.features.process.path import RenderPath, render_path - return is_source_profile(cam_xyz) and render_path(process) is RenderPath.POSITIVE + fold = camera_wb if should_fold_camera_wb(process) else None + if is_source_profile(cam_xyz) and render_path(process) is not RenderPath.POSITIVE: + return camera_to_working_matrix(cam_xyz, fold), None + return camera_matrix_parts(cam_xyz, fold) def density_stage_matrices( - process: ProcessConfig, cam_xyz: Optional[list], cam: Optional[np.ndarray], unmix: Optional[np.ndarray] + process: ProcessConfig, cam_xyz: Optional[list], camera_wb: Optional[list], unmix: Optional[np.ndarray] ) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]: - """(before the log, after the density matrix): where the source-to-working 3x3 *cam* applies. - A Positive source's primaries convert after the crosstalk unmix, fade and stain offset, which - act on the source's own channels; the conversion mixes them. Else it applies before the log.""" - if cam is not None and unmix is not None and source_channels_first(process, cam_xyz): - return None, cam - return cam, None + """(before the log, after the density matrix) for the source-to-working matrix. With a density + matrix *unmix* on a slide path it splits as `density_channel_matrices` says, because the primaries + mix the dye channels the density corrections act on. Else the whole matrix applies before the log.""" + from negpy.features.process.path import RenderPath, render_path + + if unmix is None or render_path(process) is RenderPath.PRINT: + from negpy.features.process.capture_color import camera_to_working_matrix + from negpy.features.process.logic import should_fold_camera_wb + + return camera_to_working_matrix(cam_xyz, camera_wb if should_fold_camera_wb(process) else None), None + return density_channel_matrices(process, cam_xyz, camera_wb) def transfer_bounds(density_range: float = TRANSFER_DENSITY_RANGE) -> Tuple[Tuple[float, ...], Tuple[float, ...]]: diff --git a/negpy/features/transparency/processor.py b/negpy/features/transparency/processor.py index e69e78f74..29f0f02ef 100644 --- a/negpy/features/transparency/processor.py +++ b/negpy/features/transparency/processor.py @@ -26,15 +26,14 @@ unmix_log_image, ) from negpy.features.process.fade import unmix_with_fade -from negpy.features.process.capture_color import apply_camera_matrix, camera_to_working_matrix -from negpy.features.process.logic import should_fold_camera_wb +from negpy.features.process.capture_color import apply_camera_matrix from negpy.features.process.models import ProcessConfig from negpy.features.process.path import RenderPath, render_path from negpy.features.transparency.logic import ( TRANSFER_DENSITY_RANGE, apply_transfer_curve, + density_channel_matrices, density_stage_matrices, - source_channels_first, measure_transfer_neutral_axis, transfer_assumed_anchor, transfer_auto_terms, @@ -73,14 +72,17 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: # narrowband light, where the fold never runs: an as-shot WB estimate describes a # continuous-spectrum scene, and there is no such scene to describe (see # should_fold_camera_wb). - matrix = camera_to_working_matrix(context.cam_xyz, context.camera_wb if should_fold_camera_wb(self.config) else None) image = np.ascontiguousarray(image, dtype=np.float32) # Honoured here too: a rig-calibrated matrix is a capture correction like Hue Trim, and # the mode gate keeps a negative's profile from touching a slide. Inert at the shipped # default, so the as-captured render is unperturbed. unmix = unmix_with_fade(self.config, context.process_mode) - pre, post = density_stage_matrices(self.config, context.cam_xyz, matrix, unmix) - linear = image if pre is None else apply_camera_matrix(negpy_core.nan_to_num(image, epsilon, 1.0, epsilon), pre) + pre, post = density_stage_matrices(self.config, context.cam_xyz, context.camera_wb, unmix) + + def to_linear(matrix: Optional[np.ndarray]) -> np.ndarray: + return image if matrix is None else apply_camera_matrix(negpy_core.nan_to_num(image, epsilon, 1.0, epsilon), matrix) + + linear = to_linear(pre) img_log = negpy_core.log_density_open(np.ascontiguousarray(linear, dtype=np.float32)) img_log = relog_matrix_image(unmix_log_image(img_log, unmix), post) floors, ceils = transfer_bounds() @@ -105,9 +107,11 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: prefiltered = relog_matrix_image(source_grid, post) if context.readback_metrics: # Fade Estimate meters this grid, rendered with the fade at identity, in the channels - # the fade acts on: a Positive source's own primaries. - if post is None and pre is not None and source_channels_first(self.config, context.cam_xyz): - source_grid = unmix_log_image(prefilter_log_grid(image, an_roi, an_buffer), unmix) + # the fade acts on: a camera's white-balanced channels or a Positive source's own. + if unmix is None: + channel_pre, channel_post = density_channel_matrices(self.config, context.cam_xyz, context.camera_wb) + if channel_post is not None: + source_grid = prefilter_log_grid(to_linear(channel_pre), an_roi, an_buffer) context.metrics["transfer_grid"] = source_grid # Cast Removal's neutral axis is metered on the working-space log image the curve diff --git a/negpy/services/assets/thumbnail_fingerprint.py b/negpy/services/assets/thumbnail_fingerprint.py index 7f279af1f..e140f064d 100644 --- a/negpy/services/assets/thumbnail_fingerprint.py +++ b/negpy/services/assets/thumbnail_fingerprint.py @@ -12,7 +12,7 @@ from negpy.domain.models import WorkspaceConfig # Bump in any change that alters rendered pixels for unchanged settings, a changed default included. -THUMBNAIL_RENDER_VERSION = 31 +THUMBNAIL_RENDER_VERSION = 32 # Marks a thumbnail made from the source preview, which runs none of the frame's settings. QUICK = "quick" diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index c38027ce7..46ddffa37 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -80,7 +80,6 @@ from negpy.features.local.models import MAX_KEYED_MASKS from negpy.features.transparency.logic import ( density_stage_matrices, - source_channels_first, TRANSFER_CONSTANTS, ZONE_BLACK_TAPER, TRANSFER_DENSITY_RANGE, @@ -96,8 +95,7 @@ zone_geometry, ) from negpy.features.process.fade import fade_white_target, unmix_with_fade -from negpy.features.process.capture_color import apply_camera_matrix, camera_to_working_matrix -from negpy.features.process.logic import should_fold_camera_wb +from negpy.features.process.capture_color import apply_camera_matrix from negpy.features.exposure.bw_filter import collapse_weights, plane_weights, printed_exposure from negpy.features.process.models import ProcessMode, per_channel_point_offsets, pooled_neutral_axis from negpy.features.process.path import RenderPath, render_path @@ -833,8 +831,8 @@ def process_to_texture( analysis_source = None unmix_m = unmix_with_fade(settings.process, settings.process.process_mode) # The transparency curve reads working space, so its meter must too: the same - # camera matrix TransparencyBaseProcessor applies, on the grid. - cam_m = camera_to_working_matrix(cam_xyz, camera_wb if should_fold_camera_wb(settings.process) else None) if transfer else None + # camera matrices TransparencyBaseProcessor applies, on the grid. + pre_m, post_m = density_stage_matrices(settings.process, cam_xyz, camera_wb, unmix_m) if transfer else (None, None) # A drag renders without metrics and caches no densities; the release that reads them # back re-runs the grid once so the cache fills. # A source with no base is known as such, so it never re-enters the grid for it. @@ -873,8 +871,8 @@ def process_to_texture( p.fade_white_b, p.process_mode, p.positive_source, - source_channels_first(p, cam_xyz), - None if cam_m is None else tuple(np.asarray(cam_m).ravel().tolist()), + None if pre_m is None else tuple(np.asarray(pre_m).ravel().tolist()), + None if post_m is None else tuple(np.asarray(post_m).ravel().tolist()), ) if analysis_source_hash is not None and not tiling_mode else None @@ -902,15 +900,12 @@ def process_to_texture( if transfer: # No camera matrix (a scanner TIFF) means the buffer is already in # working space, so the shared grid is the working-space grid. - pre_m, post_m = density_stage_matrices(settings.process, cam_xyz, cam_m, unmix_m) - if cam_m is None: - cam_prefiltered = prefiltered - elif post_m is not None: - cam_prefiltered = relog_matrix_image(prefiltered, post_m) - else: - cam_prefiltered = unmix_log_image( - prefilter_log_grid(apply_camera_matrix(analysis_source, pre_m), None, an_buffer), unmix_m - ) + channel_grid = ( + prefiltered + if pre_m is None + else unmix_log_image(prefilter_log_grid(apply_camera_matrix(analysis_source, pre_m), None, an_buffer), unmix_m) + ) + cam_prefiltered = relog_matrix_image(channel_grid, post_m) if prefilter_key is not None: self._prefilter_cache = (prefilter_key, prefiltered, scan_clip_fractions, None, cam_prefiltered) if analysis_key is not None: @@ -1926,16 +1921,11 @@ def _upload_unified_uniforms( # and the render stops depending on which decode produced the buffer — unless the # capture is narrowband, where an as-shot WB estimate has no scene to describe and # never folds (see should_fold_camera_wb). - cam, post = density_stage_matrices( - settings.process, - cam_xyz, - camera_to_working_matrix(cam_xyz, camera_wb if should_fold_camera_wb(settings.process) else None), - unmix_or_none, - ) + cam, post = density_stage_matrices(settings.process, cam_xyz, camera_wb, unmix_or_none) if cam is None: cam = np.eye(3, dtype=np.float32) - # After the density matrix, a Positive source's primaries (density_stage_matrices); the - # w lane of the first row turns the conversion on. + # After the density matrix, a camera's or a Positive source's primaries + # (density_stage_matrices); the w lane of the first row turns the conversion on. post_on = 1.0 if post is not None else 0.0 if post is None: post = np.eye(3, dtype=np.float32) @@ -3021,16 +3011,13 @@ def _meter_grid() -> np.ndarray: if not transfer: return _prefiltered() if transfer_grid is None: - cam_m = camera_to_working_matrix(cam_xyz, camera_wb if should_fold_camera_wb(settings.process) else None) - pre_m, post_m = density_stage_matrices(settings.process, cam_xyz, cam_m, unmix_m) - if cam_m is None: - transfer_grid = _prefiltered() - elif post_m is not None: - transfer_grid = relog_matrix_image(_prefiltered(), post_m) - else: - transfer_grid = unmix_log_image( - prefilter_log_grid(apply_camera_matrix(_analysis_img(), pre_m), meter_roi, meter_buffer), unmix_m - ) + pre_m, post_m = density_stage_matrices(settings.process, cam_xyz, camera_wb, unmix_m) + channel_grid = ( + _prefiltered() + if pre_m is None + else unmix_log_image(prefilter_log_grid(apply_camera_matrix(_analysis_img(), pre_m), meter_roi, meter_buffer), unmix_m) + ) + transfer_grid = relog_matrix_image(channel_grid, post_m) return transfer_grid meter_bounds = LogNegativeBounds(*transfer_bounds()) if transfer else global_anchor_bounds diff --git a/tests/test_pipeline_parity.py b/tests/test_pipeline_parity.py index 000fa3351..5606b35c7 100644 --- a/tests/test_pipeline_parity.py +++ b/tests/test_pipeline_parity.py @@ -388,6 +388,37 @@ def test_positive_source_primaries(self, fade): ) _assert_mostly_close(cpu, self.gpu._readback_downsampled(tex), atol=2e-2, rtol=2e-2, max_violation_frac=0.01) + @pytest.mark.parametrize("positive", [False, True]) + def test_camera_raw_split_at_the_white_balance_fold(self, positive): + # With a fade and Remove Stain, both engines apply a camera's white-balance gains before + # the log and its primaries after the density matrix. + from negpy.domain.interfaces import PipelineContext + from negpy.kernel.system.config import render_scale_factor + + cam_xyz = [[0.6722, -0.0635, -0.0963], [-0.4287, 1.246, 0.2046], [-0.0908, 0.2268, 0.5762]] + wb = [2.0, 1.0, 1.5, 1.0] + img = (self.img / np.asarray(wb[:3], dtype=np.float32)).astype(np.float32) + base = _make_base_settings() + fade = dict( + fade_ratio_r=0.6, + fade_ratio_g=1.4, + fade_ratio_b=1.2, + fade_white_anchor=True, + fade_white_r=-0.2, + fade_white_g=-0.1, + fade_white_b=-0.15, + ) + s = replace(base, process=replace(base.process, process_mode=ProcessMode.E6, positive_source=positive, linear_raw=True, **fade)) + h, w = img.shape[:2] + ctx = PipelineContext( + original_size=(h, w), scale_factor=render_scale_factor(img.shape), process_mode=ProcessMode.E6, cam_xyz=cam_xyz, camera_wb=wb + ) + cpu = self.cpu.process(img, s, f"parity_raw_split_{positive}", ctx) + tex, _ = self.gpu.process_to_texture( + img, s, scale_factor=max(h, w) / 1024.0, apply_layout=False, readback_metrics=False, cam_xyz=cam_xyz, camera_wb=wb + ) + _assert_mostly_close(cpu, self.gpu._readback_downsampled(tex), atol=2e-2, rtol=2e-2, max_violation_frac=0.01) + class TestLabParity: """CPU vs GPU parity for the lab color/sharpening shader.""" diff --git a/tests/test_positive_source_primaries.py b/tests/test_positive_source_primaries.py index 6f9d92efc..e3d7b8aef 100644 --- a/tests/test_positive_source_primaries.py +++ b/tests/test_positive_source_primaries.py @@ -41,17 +41,19 @@ def test_source_cam_xyz_gives_back_the_profile_matrix(): @pytest.mark.parametrize( - ("positive", "unmix", "cam_xyz", "expected"), - [(True, True, _SOURCE, "post"), (True, False, _SOURCE, "pre"), (False, True, _SOURCE, "pre"), (True, True, _CAMERA, "pre")], + ("positive", "unmix", "expected"), + [(True, True, "post"), (True, False, "pre"), (False, True, "pre")], ) -def test_the_primaries_follow_the_density_matrix_only_for_a_positive_source_profile(positive, unmix, cam_xyz, expected): +def test_the_primaries_follow_the_density_matrix_only_for_a_positive_source_profile(positive, unmix, expected): m = np.eye(3) * 2.0 if unmix else None - pre, post = density_stage_matrices(_slide(positive_source=positive), cam_xyz, _P, m) - assert (pre is _P, post is _P) == ((expected == "pre"), (expected == "post")) + pre, post = density_stage_matrices(_slide(positive_source=positive), _SOURCE, None, m) + moved = post if expected == "post" else pre + assert (pre is None, post is None) == ((expected == "post"), (expected == "pre")) + np.testing.assert_array_equal(moved, camera_to_working_matrix(_SOURCE)) def test_no_source_matrix_has_nothing_to_move(): - assert density_stage_matrices(_slide(positive_source=True), _SOURCE, None, np.eye(3)) == (None, None) + assert density_stage_matrices(_slide(positive_source=True), None, None, np.eye(3)) == (None, None) def _render(config: ProcessConfig, img: np.ndarray, cam_xyz=_SOURCE, camera_wb=None): @@ -122,17 +124,3 @@ def test_a_positive_decode_is_not_a_camera_raw(): assert camera_raw_preview(SimpleNamespace(preview_cam_xyz=_CAMERA)) assert not camera_raw_preview(SimpleNamespace(preview_cam_xyz=_SOURCE)) assert not camera_raw_preview(SimpleNamespace(preview_cam_xyz=None)) - - -def test_a_camera_raw_on_positive_converts_before_the_density_stage(): - """A Linear RAW folds the as-shot white balance into the camera matrix, so it applies before the - fade and the stain offset, which then act on balanced channels.""" - wb = (2.0, 1.0, 1.5) - img = np.full((40, 60, 3), 0.8, np.float32) / np.asarray(wb, np.float32) - config = _slide( - positive_source=True, linear_raw=True, fade_white_anchor=True, fade_white_r=-0.3, fade_white_g=-0.1, fade_white_b=-0.2, **_FADE - ) - _, ctx = _render(config, img, cam_xyz=_CAMERA, camera_wb=list(wb)) - cam = camera_to_working_matrix(_CAMERA, wb) - expected = unmix_log_image(np.log10(img @ cam.T), unmix_with_fade(config, ProcessMode.E6)) - np.testing.assert_allclose(_restored_log(ctx), expected, atol=1e-4) diff --git a/tests/test_raw_density_order.py b/tests/test_raw_density_order.py new file mode 100644 index 000000000..8826e962b --- /dev/null +++ b/tests/test_raw_density_order.py @@ -0,0 +1,131 @@ +"""A camera RAW's matrix splits at its white-balance fold around the density stage: the gains apply +before the log, the primaries after the crosstalk unmix, fade and stain offset.""" + +import negpy_core +import numpy as np +import pytest + +from negpy.domain.interfaces import PipelineContext +from negpy.features.exposure.normalization import normalize_log_image +from negpy.features.process.capture_color import _primaries, apply_camera_matrix, camera_matrix_parts, camera_to_working_matrix +from negpy.features.process.fade import measure_white, unmix_with_fade +from negpy.features.process.models import ProcessConfig, ProcessMode +from negpy.features.transparency.logic import density_stage_matrices +from negpy.features.transparency.processor import TransparencyBaseProcessor + +_CAMERA = [[0.6722, -0.0635, -0.0963], [-0.4287, 1.246, 0.2046], [-0.0908, 0.2268, 0.5762]] +_WB = [2.0, 1.0, 1.5, 1.0] +_FADE = dict(fade_ratio_r=0.4, fade_ratio_g=2.0, fade_ratio_b=1.5) +_PATHS = [pytest.param(False, id="transfer"), pytest.param(True, id="positive")] + + +def _slide(positive: bool, **kw) -> ProcessConfig: + return ProcessConfig(process_mode=ProcessMode.E6, positive_source=positive, linear_raw=True, **kw) + + +def _render(config: ProcessConfig, img: np.ndarray, readback: bool = False): + ctx = PipelineContext( + original_size=img.shape[:2], + scale_factor=1.0, + process_mode=ProcessMode.E6, + cam_xyz=_CAMERA, + camera_wb=_WB, + readback_metrics=readback, + ) + return TransparencyBaseProcessor(config).process(img, ctx), ctx + + +def _restored_log(ctx) -> np.ndarray: + floors, ceils = ctx.metrics["log_bounds"].floors, ctx.metrics["log_bounds"].ceils + return ctx.metrics["normalized_log"] * (np.asarray(ceils) - np.asarray(floors)) + np.asarray(floors) + + +def _unbalanced(balanced: np.ndarray) -> np.ndarray: + """The Linear RAW buffer whose white-balanced camera channels are *balanced*.""" + return (balanced / np.asarray(_WB[:3], dtype=np.float32)).astype(np.float32) + + +def test_the_parts_rebuild_the_matrix_and_leave_its_bits(): + gains, primaries = camera_matrix_parts(_CAMERA, _WB) + np.testing.assert_array_equal(gains, np.diag(np.float32([2.0, 1.0, 1.5]))) + np.testing.assert_allclose(primaries @ gains, camera_to_working_matrix(_CAMERA, _WB), atol=1e-6) + np.testing.assert_allclose(primaries.astype(np.float64).sum(axis=1), 1.0, atol=1e-6) + reference = np.ascontiguousarray(_primaries(_CAMERA) @ np.diag(np.array(_WB[:3]) / _WB[1]), dtype=np.float32) + np.testing.assert_array_equal(camera_to_working_matrix(_CAMERA, _WB), reference) + assert camera_matrix_parts(_CAMERA, None)[0] is None + assert camera_matrix_parts(_CAMERA, [0.0, 1.0, 1.0])[0] is None + assert camera_matrix_parts(None, _WB) == (None, None) + + +@pytest.mark.parametrize("positive", _PATHS) +def test_a_camera_splits_at_the_fold_only_with_a_density_matrix(positive): + config = _slide(positive) + gains, primaries = camera_matrix_parts(_CAMERA, _WB) + pre, post = density_stage_matrices(config, _CAMERA, _WB, np.eye(3)) + np.testing.assert_array_equal(pre, gains) + np.testing.assert_array_equal(post, primaries) + pre, post = density_stage_matrices(config, _CAMERA, _WB, None) + np.testing.assert_array_equal(pre, camera_to_working_matrix(_CAMERA, _WB)) + assert post is None + + +def test_a_balanced_decode_moves_only_the_primaries(): + """Highlight reconstruction bakes the white balance into the decode, so there is no fold.""" + config = ProcessConfig(process_mode=ProcessMode.E6, linear_raw=False, highlight_reconstruction=5) + pre, post = density_stage_matrices(config, _CAMERA, _WB, np.eye(3)) + assert pre is None + np.testing.assert_array_equal(post, camera_matrix_parts(_CAMERA, None)[1]) + + +def test_a_negative_keeps_the_whole_matrix_before_the_log(): + config = ProcessConfig(process_mode=ProcessMode.C41, linear_raw=True) + pre, post = density_stage_matrices(config, _CAMERA, _WB, np.eye(3)) + np.testing.assert_array_equal(pre, camera_to_working_matrix(_CAMERA, _WB)) + assert post is None + + +@pytest.mark.parametrize("positive", _PATHS) +def test_remove_stain_leaves_a_white_border_neutral(positive): + """Estimate meters the white in white-balanced camera channels, and the primaries keep it neutral.""" + img = _unbalanced(np.full((40, 60, 3), 0.8, np.float32)) + _, ctx = _render(_slide(positive), img, readback=True) + white = measure_white(ctx.metrics["transfer_grid"]) + assert white is not None + np.testing.assert_allclose(white, np.log10(0.8), atol=1e-4) + config = _slide(positive, fade_white_anchor=True, fade_white_r=white[0], fade_white_g=white[1], fade_white_b=white[2], **_FADE) + _, ctx = _render(config, img) + np.testing.assert_allclose(_restored_log(ctx).reshape(-1, 3), -0.1, atol=1e-4) + + +@pytest.mark.parametrize("positive", _PATHS) +def test_a_faded_gray_ramp_restores_to_neutral(positive): + config = _slide(positive, **_FADE) + restore = unmix_with_fade(config, ProcessMode.E6) + gray = np.repeat(np.linspace(-1.5, -0.05, 60, dtype=np.float64)[None, :, None], 3, axis=2) + faded_log = gray @ np.linalg.inv(restore).T + img = _unbalanced(np.repeat(10.0**faded_log, 4, axis=0).astype(np.float32)) + _, ctx = _render(config, img) + out = _restored_log(ctx) + np.testing.assert_allclose(out - out[..., 1:2], 0.0, atol=1e-4) + np.testing.assert_allclose(out[..., 1], np.repeat(gray[..., 1], 4, axis=0), atol=1e-4) + # Converted before the log, the primaries mix the faded channels and the ramp takes a cast. + full = camera_to_working_matrix(_CAMERA, _WB) + converted_first = negpy_core.log_density_open(apply_camera_matrix(img, full)) @ restore.T.astype(np.float32) + assert float(np.abs(converted_first - converted_first[..., 1:2]).max()) > 0.05 + + +@pytest.mark.parametrize("positive", _PATHS) +def test_no_density_matrix_renders_as_the_whole_matrix_first(positive): + img = np.random.default_rng(4).uniform(0.02, 1.0, (40, 60, 3)).astype(np.float32) + config = _slide(positive) + _, ctx = _render(config, img) + linear = apply_camera_matrix(negpy_core.nan_to_num(img, 1e-6, 1.0, 1e-6), camera_to_working_matrix(_CAMERA, _WB)) + expected = normalize_log_image(negpy_core.log_density_open(linear), ctx.metrics["log_bounds"]) + np.testing.assert_array_equal(ctx.metrics["normalized_log"], expected) + + +@pytest.mark.parametrize("positive", _PATHS) +def test_estimate_reads_white_balanced_camera_channels(positive): + flat = _unbalanced(np.full((40, 60, 3), (0.5, 0.2, 0.1), np.float32)) + _, ctx = _render(_slide(positive), flat, readback=True) + np.testing.assert_allclose(ctx.metrics["transfer_grid"].reshape(-1, 3)[0], np.log10((0.5, 0.2, 0.1)), atol=1e-4) diff --git a/tests/test_slide_paths_characterization.py b/tests/test_slide_paths_characterization.py index 25676a2ef..58147fb2d 100644 --- a/tests/test_slide_paths_characterization.py +++ b/tests/test_slide_paths_characterization.py @@ -32,18 +32,18 @@ GOLDEN = { # as_captured moves with transfer_grade_ref; the Positive row meters its own contrast and does not. "as_captured": [ - [0.355, 0.3229, 0.25], - [0.0644, 0.0334, 0.0241], - [0.0674, 0.1275, 0.1034], - [0.4293, 0.3762, 0.2609], - [0.911, 0.8912, 0.8237], + [0.3561, 0.3216, 0.2524], + [0.0645, 0.0328, 0.0239], + [0.0655, 0.1239, 0.096], + [0.4317, 0.3747, 0.2652], + [0.9122, 0.8903, 0.8281], ], "positive": [ - [0.3672, 0.3488, 0.2921], - [0.1434, 0.1171, 0.0932], - [0.1763, 0.2376, 0.2186], - [0.4065, 0.3843, 0.3164], - [0.8079, 0.7709, 0.6635], + [0.3685, 0.3481, 0.2947], + [0.1439, 0.1163, 0.093], + [0.1734, 0.2369, 0.2153], + [0.4084, 0.384, 0.32], + [0.809, 0.7671, 0.6695], ], } diff --git a/tests/thumbnail_fingerprint_defaults.json b/tests/thumbnail_fingerprint_defaults.json index c3a0dde20..2d37dcedf 100644 --- a/tests/thumbnail_fingerprint_defaults.json +++ b/tests/thumbnail_fingerprint_defaults.json @@ -211,5 +211,5 @@ "vanadium_strength": "0.0" } }, - "render_version": 31 + "render_version": 32 } From e5080ddc9b40cee6ad10fa56b21a04fc127e37e4 Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:44:37 +0000 Subject: [PATCH 03/46] refactor(transparency): fold camera white-balance gains into the density matrix offset The camera white-balance gains are a per-channel log offset. They now go into the last column of the density matrix (density_stage), so neither engine multiplies the full image by a diagonal matrix before the log. The GPU cam rows are identity in this case. The GPU meter grids reuse the log grid they already have. Identity primaries (an Input ICC, wb_only_cam_xyz) apply nothing after the density matrix. The Fade Estimate grid (transfer_grid) is made only when PipelineContext.wants_fade_grid is set. fade_estimate_grid sets it. Before, every CPU readback render of a camera RAW without a density matrix did a second full-image pass for that grid. density_stage returns one DensityStage (pre, unmix, post). The white-balance fold is decided in one place, and both engines build their meter grid with density_stage_grid. The Remove Stain test now has a colored patch. It fails if the camera matrix applies before the log. --- docs/PIPELINE.md | 4 +- negpy/desktop/workers/chart_fit.py | 1 + negpy/domain/interfaces.py | 3 + .../exposure/shaders/normalization.wgsl | 4 +- negpy/features/transparency/logic.py | 97 ++++++++++++++----- negpy/features/transparency/processor.py | 35 +++---- negpy/services/rendering/gpu_engine.py | 62 +++++------- negpy/services/rendering/image_processor.py | 2 + tests/test_effective_linear_raw.py | 13 +-- tests/test_fade_estimation.py | 2 +- tests/test_positive_source_primaries.py | 21 ++-- tests/test_raw_density_order.py | 91 ++++++++++++----- 12 files changed, 207 insertions(+), 128 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 5dbbe9f66..d4c5c6370 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -61,7 +61,7 @@ Pixel values are linear transmittance. The source corrections run in that domain * **Channel unmix / crosstalk** (`crosstalk_strength` / `crosstalk_matrix`, docs/CROSSTALK.md): a 3×3 unmix of the negative log densities before bounds and stretch, where dye absorptions, light spectrum and CFA are linear, so a profile describes a scanning setup. It is blended with identity by strength and row-normalized, so grays stay. Every meter reads the unmixed film, so a change needs a new Roll Analysis. * **Fade restoration** (`fade_ratio_r` / `fade_ratio_g` / `fade_ratio_b`, slides only: TRANSFER and POSITIVE): composes a diagonal restoration matrix $F^{-1}$ after the crosstalk unmix, $M = F^{-1} M_{\text{unmix}}$ (`features/process/fade.py::unmix_with_fade`). It runs before Cast Removal, which then meters the restored film. $F = \mathrm{diag}(a_r,\, a_r a_g,\, a_r a_b)$ acts on densities. $a_g$ and $a_b$ are survival ratios to red, which a neutral can constrain. $a_r$ is red's own survival and is set by eye. $F$ is the identity at unit survival, and it is not row-normalized. - Estimate (`features/process/fade.py`) runs on request, never in a render. It renders the frame on the CPU with $F$ at identity and White Anchor off, and reads the transparency meter grid (`transfer_grid`: bakes, geometry, crop, camera matrix and unmix; the grid is in the channels the density matrix acts on, `density_channel_matrices`). It meters the grays with `measure_neutral_axis_from_log` against the frame's own bounds: first with the foliage and sky sectors excluded, against bounds from each channel's extreme cells with decoded zeros dropped, at confidence `MIN_CONFIDENCE` ($0.2$) or more. Else it reads against bounds from the `ROBUST_EXTREME_PCT` ($0.1$) percentiles, with the sectors excluded, then not, at `ROBUST_MIN_CONFIDENCE` ($0.4$). MIDTONE reads the midtone densities and also sets $a_r$; SPREAD reads each channel's midtone-to-shadow spread. Ratios clamp to $[0.2, 5]$, and the hint shows the unclamped value. The hint also shows the other method's reading: a large gap is a cast that a fade does not explain. A near-monochrome slide meters as faded. + Estimate (`features/process/fade.py`) runs on request, never in a render. It renders the frame on the CPU with $F$ at identity and White Anchor off, and reads the transparency meter grid (`transfer_grid`, made only on request through `PipelineContext.wants_fade_grid`: bakes, geometry, crop and unmix, in the channels the density matrix acts on, `density_channels`). It meters the grays with `measure_neutral_axis_from_log` against the frame's own bounds: first with the foliage and sky sectors excluded, against bounds from each channel's extreme cells with decoded zeros dropped, at confidence `MIN_CONFIDENCE` ($0.2$) or more. Else it reads against bounds from the `ROBUST_EXTREME_PCT` ($0.1$) percentiles, with the sectors excluded, then not, at `ROBUST_MIN_CONFIDENCE` ($0.4$). MIDTONE reads the midtone densities and also sets $a_r$; SPREAD reads each channel's midtone-to-shadow spread. Ratios clamp to $[0.2, 5]$, and the hint shows the unclamped value. The hint also shows the other method's reading: a large gap is a cast that a fade does not explain. A near-monochrome slide meters as faded. White Anchor (`fade_white_anchor`, UI "Remove Stain") makes the matrix affine, $M(L - w) + t$. The stain $w$ is each channel's `WHITE_PCT` cell of the meter grid in log transmittance, without decoded zeros and cells at full scale in every channel; Estimate stores it as `fade_white_r/g/b`. It maps to the neutral $t = \min(\max_c w_c, -0.1)$ (`FADE_WHITE_HEADROOM`): the cleanest channel's own level, never within $0.1$ D of full scale. $F^{-1}$ acts on $L - w$ before $t$ is added, so the Estimate reads the grays on $\min(L - w, 0)$. The offset rides in the w lanes of the unmix rows on the GPU. * **Density above base**: `density_above_base` is (Dmin, Dmed, Dmax) above the clear base, luminance-weighted: the thin and dense bounds (before trims; the roll's under Roll Analysis) and the metered median, each minus the base. The base is the picked one, else the rebate reader's, unmixed like the meters. A pick is the open roll's (`rolls.roll_film_base`), or the frame's own with no roll open (`process.film_base`, unhashed); in an open roll the roll's wins, with none open the frame's. The preview carries it as `film_base`; export never reads it. The reader takes the flat 8-px blocks (std over mean under 0.03, unclipped) in the outer 15% of the film box (`film_and_picture` at `AUTOCROP_DETECT_RES`), groups them by brightness with a 0.04-decade gap, and takes the largest group of at least five. It finds none without a film box, with a base darker than the picture, or on the transfer path. The preview reads it once per source, on the flat-fielded, unmixed source before the defect repairs, and passes the read to both engines. @@ -174,7 +174,7 @@ For Transparency and Positive a transfer curve replaces the Print stage, exactly * **Neutral decode**: this path always decodes without the as-shot white balance (`effective_linear_raw`), because the camera matrix folds it back in. Decode, both matrices and every cache token ask that helper; it also makes a TIFF load as literal linear data. Positive is exempt: a TIFF decodes by Source Encoding (`path.positive_decode`, `loaders/source_encoding.py`), whatever Linear RAW says; Linear and 2.2 override Auto, and Input ICC applies a LUT profile. Auto skips a device profile (class `scnr`, `is_device_profile`), which describes the scanner rather than the file's encoding. Otherwise it applies the embedded profile's tone curves (`curv`, `para`); a LUT-only profile falls back to its name (sRGB, Adobe RGB). The colorants to Adobe RGB are relative to the D50 PCS. A source whose gamut fits Adobe RGB (no negative matrix entry) keeps its own primaries, and the matrix becomes the frame's camera matrix (`decode_positive`, `source_cam_xyz`). A wider source converts in the decode, where out-of-gamut pixels move toward their own luminance until they fit, which keeps hue. A JPEG decodes by Auto, as it has no Encoding control. A JXL loads as linear. An untagged TIFF is linear above 8 bits and sRGB at 8 bits. An identity decode of 16-bit samples passes them through exactly. The encoding joins `linear_raw_token` and `source_token`. A Slide that is not a camera RAW (`LoaderFactory.is_camera_raw`: TIFF, JPEG, JXL, Pakon, Noritsu, Coolscan NEF, Flextight FFF) starts Positive on a first open and on a switch to Slide, unless its roll's Film card holds a Positive value (`loaders/source_kind.py`). Positive off sends it down the as-captured transfer. * **Highlight reconstruction** (`highlight_reconstruction`, `effective_highlight_reconstruction`): libraw `highlight_mode` `0` Clip, `2` Blend, `3` to `9` Reconstruct (the sidebar uses `5`). It is forced to `0` off Transparency, under Narrowband and on a merged bracket, whose weighting needs pixels near the sensor ceiling (`WorkspaceConfig.__post_init__`; `HdrWorker.run` zeroes it per frame). Active reconstruction bakes the real white balance and skips the matrix fold (`highlight_reconstruction_bakes_wb`, `should_fold_camera_wb`), because libraw finds clipping from its own gains; `highlight_reconstruction_bright_gain` passes the darkening ratio back as libraw's `bright`. -* **Camera matrix**: libraw's `rgb_xyz_matrix` (`PipelineContext.cam_xyz`) becomes the working→camera matrix, row-normalized, then inverted (dcraw's `cam_xyz_coeff` order); normalizing after inversion gives a magenta cast. It applies in linear before the log. With a density matrix (crosstalk, fade or stain offset) on a slide path, the matrix splits around it (`density_stage_matrices`). A camera's matrix splits at the white-balance fold (`camera_matrix_parts`): the per-channel gains apply before the log, and the primaries apply after the density matrix, in linear light (`relog_matrix_image`, the shader's `post` rows). The density corrections then act on the white-balanced camera channels, which the primaries mix. The primaries rows sum to 1, so a border that Remove Stain makes neutral stays neutral. A Positive source profile's matrix (`source_cam_xyz`, marked by its 4th row, `is_source_profile`) applies after the density matrix in the same way. An explicit Input ICC swaps in `wb_only_cam_xyz` (`AppController._effective_cam_xyz`), which reduces to the white-balance fold. +* **Camera matrix**: libraw's `rgb_xyz_matrix` (`PipelineContext.cam_xyz`) becomes the working→camera matrix, row-normalized, then inverted (dcraw's `cam_xyz_coeff` order); normalizing after inversion gives a magenta cast. It applies in linear before the log. With a density matrix (crosstalk, fade or stain offset) on a slide path, the source stays in its own channels through it (`density_stage`, `density_channels`). A camera's matrix splits at the white-balance fold (`camera_matrix_parts`). The gains become a log offset in the density matrix's last column, and the primaries apply after the density matrix, in linear light (`relog_matrix_image`, the shader's `post` rows). Identity primaries (an Input ICC) apply nothing. The primaries rows sum to 1, so a border that Remove Stain makes neutral stays neutral. On Positive, a source profile's matrix (`source_cam_xyz`, marked by its 4th row, `is_source_profile`) applies after the density matrix; on Transfer it applies before the log. An explicit Input ICC swaps in `wb_only_cam_xyz` (`AppController._effective_cam_xyz`), which reduces to the white-balance fold. * **Fixed bounds**: a constant window from the decoder's white level ($D = 0$) to `TRANSFER_DENSITY_RANGE` $= 3.0$ decades below. White and Black Point (`per_channel_point_offsets`) shift it. No dye-crosstalk unmix. * **Cast Removal bands**: the neutral axis (§3, Automatic helpers) takes its highlight, midtone and shadow bands from the fixed window, before White and Black Point. A Positive file has no sensor white, so its bands span the 1st to 99th percentile of its luma-weighted log density, one offset on all channels (`transfer_axis_bounds`, `POSITIVE_AXIS_PERCENTILES`). When that finds no axis, the dark end is taken again over the cells more than `POSITIVE_AXIS_FLOOR_MARGIN` (0.1 D) above it, so a black border does not fill the shadow band (`measure_transfer_neutral_axis`). The bands then select the same pixels at any exposure or encoding of the file, and the refs stay raw log densities. diff --git a/negpy/desktop/workers/chart_fit.py b/negpy/desktop/workers/chart_fit.py index 754f6ce4b..0b93119d9 100644 --- a/negpy/desktop/workers/chart_fit.py +++ b/negpy/desktop/workers/chart_fit.py @@ -132,5 +132,6 @@ def fade_estimate_grid(source: ChartSource) -> Optional[np.ndarray]: cache_stages=False, gain_slices=source.gain_slices, source_orientation=source.orientation, + wants_fade_grid=True, ) return metrics.get("transfer_grid") diff --git a/negpy/domain/interfaces.py b/negpy/domain/interfaces.py index 4938defd7..9b43c52c2 100644 --- a/negpy/domain/interfaces.py +++ b/negpy/domain/interfaces.py @@ -41,6 +41,9 @@ class PipelineContext: # Whether the caller reads the Analysis metrics back. A render that does not (a splash, a # thumbnail, a test strip) skips measurements that only the Analysis panel shows. readback_metrics: bool = True + # Whether the render stores the slide meter grid in the density channels (`transfer_grid`). + # Only Fade Estimate reads it, and it can cost a second pass over the frame. + wants_fade_grid: bool = False class IImageSource(Protocol): diff --git a/negpy/features/exposure/shaders/normalization.wgsl b/negpy/features/exposure/shaders/normalization.wgsl index fc05b66b8..52f4243eb 100644 --- a/negpy/features/exposure/shaders/normalization.wgsl +++ b/negpy/features/exposure/shaders/normalization.wgsl @@ -21,8 +21,8 @@ struct NormUniforms { cam1: vec4, cam2: vec4, // A camera's or a Positive source's primaries, applied in LINEAR after the density matrix so - // the unmix, fade and stain offset act on its own channels (density_stage_matrices). post0.w - // is 1.0 when it applies; the cam rows then hold only the white-balance gains, or identity. + // the unmix, fade and stain offset act on its own channels (density_stage). post0.w is 1.0 + // when it applies; the cam rows are then identity, and white-balance gains ride the unmix w. post0: vec4, post1: vec4, post2: vec4, diff --git a/negpy/features/transparency/logic.py b/negpy/features/transparency/logic.py index fba741e83..a70235345 100644 --- a/negpy/features/transparency/logic.py +++ b/negpy/features/transparency/logic.py @@ -33,7 +33,7 @@ (see logic.separation_damping_gain) """ -from typing import Any, Dict, Mapping, Optional, Tuple +from typing import Any, Callable, Dict, Mapping, NamedTuple, Optional, Tuple import negpy_core import numpy as np @@ -483,38 +483,83 @@ def transfer_point_offsets(process: ProcessConfig) -> Tuple[Tuple[float, float, return (-wp3[0], -wp3[1], -wp3[2]), (-bp3[0], -bp3[1], -bp3[2]) -def density_channel_matrices( - process: ProcessConfig, cam_xyz: Optional[list], camera_wb: Optional[list] -) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]: - """(before the log, after the density matrix) when a density matrix acts. The pre matrix gives - the channels the crosstalk unmix, fade and stain offset act on. A camera splits at its white-balance - fold: the gains go before the log, the primaries after. A Positive source profile's primaries go after; - off Positive the whole matrix goes before. `camera_wb` is the as-shot white balance, folded only - when `should_fold_camera_wb` says so.""" - from negpy.features.process.capture_color import camera_matrix_parts, camera_to_working_matrix, is_source_profile +class DensityStage(NamedTuple): + """Where the source-to-working matrix applies around the log-density matrix of a render.""" + + pre: Optional[np.ndarray] # linear 3x3 before the log + unmix: Optional[np.ndarray] # log-density matrix, (3, 3) or affine (3, 4) + post: Optional[np.ndarray] # linear 3x3 after the log-density matrix + + +def _whole_matrix(process: ProcessConfig, cam_xyz: Optional[list], camera_wb: Optional[list]) -> Optional[np.ndarray]: + from negpy.features.process.capture_color import camera_to_working_matrix + + return camera_to_working_matrix(cam_xyz, _fold(process, camera_wb)) + + +def _fold(process: ProcessConfig, camera_wb: Optional[list]) -> Optional[list]: + """The as-shot white balance the matrix folds in: only for a decode without it (`should_fold_camera_wb`).""" from negpy.features.process.logic import should_fold_camera_wb - from negpy.features.process.path import RenderPath, render_path - fold = camera_wb if should_fold_camera_wb(process) else None - if is_source_profile(cam_xyz) and render_path(process) is not RenderPath.POSITIVE: - return camera_to_working_matrix(cam_xyz, fold), None - return camera_matrix_parts(cam_xyz, fold) + return camera_wb if should_fold_camera_wb(process) else None -def density_stage_matrices( - process: ProcessConfig, cam_xyz: Optional[list], camera_wb: Optional[list], unmix: Optional[np.ndarray] -) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]: - """(before the log, after the density matrix) for the source-to-working matrix. With a density - matrix *unmix* on a slide path it splits as `density_channel_matrices` says, because the primaries - mix the dye channels the density corrections act on. Else the whole matrix applies before the log.""" +def _with_log_offset(unmix: Optional[np.ndarray], offset: Optional[np.ndarray]) -> Optional[np.ndarray]: + """`unmix` applied to `L + offset`: the offset folds into the affine's last column.""" + if offset is None: + return unmix + m = np.eye(3) if unmix is None else np.asarray(unmix, dtype=np.float64) + linear = m[:, :3] + t = m[:, 3] if m.shape[1] == 4 else np.zeros(3) + return np.hstack([linear, (t + linear @ offset)[:, None]]) + + +def density_channels(process: ProcessConfig, cam_xyz: Optional[list], camera_wb: Optional[list]) -> DensityStage: + """The stage that puts a source in the channels the crosstalk unmix, fade and stain offset act on, + then converts them to working space. A camera's white-balance gains are a log offset (`unmix`) and + its primaries convert after. A Positive source profile's primaries convert after. A source profile + off Positive converts first.""" + from negpy.features.process.capture_color import camera_matrix_parts, is_source_profile from negpy.features.process.path import RenderPath, render_path - if unmix is None or render_path(process) is RenderPath.PRINT: - from negpy.features.process.capture_color import camera_to_working_matrix - from negpy.features.process.logic import should_fold_camera_wb + if is_source_profile(cam_xyz) and render_path(process) is not RenderPath.POSITIVE: + return DensityStage(_whole_matrix(process, cam_xyz, camera_wb), None, None) + gains, primaries = camera_matrix_parts(cam_xyz, _fold(process, camera_wb)) + offset = None if gains is None else np.log10(np.diag(gains).astype(np.float64)) + if primaries is not None and np.allclose(primaries, np.eye(3), rtol=0.0, atol=1e-6): + primaries = None + return DensityStage(None, _with_log_offset(None, offset), primaries) + + +def density_stage(process: ProcessConfig, cam_xyz: Optional[list], camera_wb: Optional[list], unmix: Optional[np.ndarray]) -> DensityStage: + """The stage a render applies. With a log-density matrix *unmix* on a slide path the source stays in + its own channels through it (`density_channels`), because the primaries mix the dye channels the + density corrections act on. Else the whole matrix applies before the log.""" + from negpy.features.process.path import RenderPath, render_path - return camera_to_working_matrix(cam_xyz, camera_wb if should_fold_camera_wb(process) else None), None - return density_channel_matrices(process, cam_xyz, camera_wb) + if unmix is None or render_path(process) is RenderPath.PRINT: + return DensityStage(_whole_matrix(process, cam_xyz, camera_wb), unmix, None) + channels = density_channels(process, cam_xyz, camera_wb) + offset = None if channels.unmix is None else channels.unmix[:, 3] + return DensityStage(channels.pre, _with_log_offset(unmix, offset), channels.post) + + +def density_stage_grid( + stage: DensityStage, + linear: ImageBuffer, + roi: Optional[tuple[int, int, int, int]], + analysis_buffer: float, + log_grid: Optional[Callable[[], ImageBuffer]] = None, +) -> ImageBuffer: + """The working-space meter grid of *linear* through *stage*. *log_grid* gives the prefiltered grid of *linear* when the caller has it, reused when there is no `pre`.""" + from negpy.features.exposure.normalization import prefilter_log_grid, relog_matrix_image, unmix_log_image + from negpy.features.process.capture_color import apply_camera_matrix + + if stage.pre is None and log_grid is not None: + grid = log_grid() + else: + grid = prefilter_log_grid(linear if stage.pre is None else apply_camera_matrix(linear, stage.pre), roi, analysis_buffer) + return relog_matrix_image(unmix_log_image(grid, stage.unmix), stage.post) def transfer_bounds(density_range: float = TRANSFER_DENSITY_RANGE) -> Tuple[Tuple[float, ...], Tuple[float, ...]]: diff --git a/negpy/features/transparency/processor.py b/negpy/features/transparency/processor.py index 29f0f02ef..3c95de97f 100644 --- a/negpy/features/transparency/processor.py +++ b/negpy/features/transparency/processor.py @@ -32,8 +32,8 @@ from negpy.features.transparency.logic import ( TRANSFER_DENSITY_RANGE, apply_transfer_curve, - density_channel_matrices, - density_stage_matrices, + density_channels, + density_stage, measure_transfer_neutral_axis, transfer_assumed_anchor, transfer_auto_terms, @@ -66,25 +66,15 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: slide starts with both off (auto_meter_for_mode). """ epsilon = 1e-6 - # Linear RAW decodes without white balance, which the row-normalized camera matrix - # assumes. Folding the as-shot multipliers back in makes this render independent of which - # decode produced the buffer, so the toggle cannot cast the image here — except under - # narrowband light, where the fold never runs: an as-shot WB estimate describes a - # continuous-spectrum scene, and there is no such scene to describe (see - # should_fold_camera_wb). image = np.ascontiguousarray(image, dtype=np.float32) # Honoured here too: a rig-calibrated matrix is a capture correction like Hue Trim, and # the mode gate keeps a negative's profile from touching a slide. Inert at the shipped # default, so the as-captured render is unperturbed. unmix = unmix_with_fade(self.config, context.process_mode) - pre, post = density_stage_matrices(self.config, context.cam_xyz, context.camera_wb, unmix) - - def to_linear(matrix: Optional[np.ndarray]) -> np.ndarray: - return image if matrix is None else apply_camera_matrix(negpy_core.nan_to_num(image, epsilon, 1.0, epsilon), matrix) - - linear = to_linear(pre) + stage = density_stage(self.config, context.cam_xyz, context.camera_wb, unmix) + linear = image if stage.pre is None else apply_camera_matrix(negpy_core.nan_to_num(image, epsilon, 1.0, epsilon), stage.pre) img_log = negpy_core.log_density_open(np.ascontiguousarray(linear, dtype=np.float32)) - img_log = relog_matrix_image(unmix_log_image(img_log, unmix), post) + img_log = relog_matrix_image(unmix_log_image(img_log, stage.unmix), stage.post) floors, ceils = transfer_bounds() pre_trim_bounds = LogNegativeBounds(floors=floors, ceils=ceils) # White/Black Point manually deviate the fixed window, same technique as the measured @@ -103,16 +93,15 @@ def to_linear(matrix: Optional[np.ndarray]) -> np.ndarray: an_roi, an_buffer = resolve_analysis_region( linear.shape, context.active_roi, self.config.analysis_buffer, self.config.analysis_rect, self.config.meter_rect ) - source_grid = unmix_log_image(prefilter_log_grid(linear, an_roi, an_buffer), unmix) - prefiltered = relog_matrix_image(source_grid, post) - if context.readback_metrics: + log_grid = prefilter_log_grid(linear, an_roi, an_buffer) + prefiltered = relog_matrix_image(unmix_log_image(log_grid, stage.unmix), stage.post) + if context.wants_fade_grid: # Fade Estimate meters this grid, rendered with the fade at identity, in the channels # the fade acts on: a camera's white-balanced channels or a Positive source's own. - if unmix is None: - channel_pre, channel_post = density_channel_matrices(self.config, context.cam_xyz, context.camera_wb) - if channel_post is not None: - source_grid = prefilter_log_grid(to_linear(channel_pre), an_roi, an_buffer) - context.metrics["transfer_grid"] = source_grid + channels = stage if unmix is not None else density_channels(self.config, context.cam_xyz, context.camera_wb) + if channels.pre is None and stage.pre is not None: + log_grid = prefilter_log_grid(image, an_roi, an_buffer) + context.metrics["transfer_grid"] = unmix_log_image(log_grid, channels.unmix) # Cast Removal's neutral axis is metered on the working-space log image the curve # consumes, since the camera matrix above would leave a meter reading a different diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index 46ddffa37..1d2abfc90 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -38,7 +38,6 @@ measure_clip_fractions, measure_neutral_axis_from_log, unmix_log_image, - relog_matrix_image, measure_highlight_point_from_log, measure_shadow_point_from_log, measure_textural_range_from_log, @@ -79,7 +78,8 @@ from negpy.features.local.logic import compute_local_maps, limited_masks from negpy.features.local.models import MAX_KEYED_MASKS from negpy.features.transparency.logic import ( - density_stage_matrices, + density_stage, + density_stage_grid, TRANSFER_CONSTANTS, ZONE_BLACK_TAPER, TRANSFER_DENSITY_RANGE, @@ -95,7 +95,6 @@ zone_geometry, ) from negpy.features.process.fade import fade_white_target, unmix_with_fade -from negpy.features.process.capture_color import apply_camera_matrix from negpy.features.exposure.bw_filter import collapse_weights, plane_weights, printed_exposure from negpy.features.process.models import ProcessMode, per_channel_point_offsets, pooled_neutral_axis from negpy.features.process.path import RenderPath, render_path @@ -832,7 +831,7 @@ def process_to_texture( unmix_m = unmix_with_fade(settings.process, settings.process.process_mode) # The transparency curve reads working space, so its meter must too: the same # camera matrices TransparencyBaseProcessor applies, on the grid. - pre_m, post_m = density_stage_matrices(settings.process, cam_xyz, camera_wb, unmix_m) if transfer else (None, None) + slide_stage = density_stage(settings.process, cam_xyz, camera_wb, unmix_m) if transfer else None # A drag renders without metrics and caches no densities; the release that reads them # back re-runs the grid once so the cache fills. # A source with no base is known as such, so it never re-enters the grid for it. @@ -871,8 +870,9 @@ def process_to_texture( p.fade_white_b, p.process_mode, p.positive_source, - None if pre_m is None else tuple(np.asarray(pre_m).ravel().tolist()), - None if post_m is None else tuple(np.asarray(post_m).ravel().tolist()), + None + if slide_stage is None + else tuple(None if m is None else tuple(np.asarray(m).ravel().tolist()) for m in slide_stage), ) if analysis_source_hash is not None and not tiling_mode else None @@ -895,17 +895,11 @@ def process_to_texture( ) # Shared prefilter, once for all five meters (ROI already applied). # Unmixed like the CPU path so every meter reads the unmixed film. - prefiltered = unmix_log_image(prefilter_log_grid(analysis_source, None, an_buffer), unmix_m) + log_grid = prefilter_log_grid(analysis_source, None, an_buffer) + prefiltered = unmix_log_image(log_grid, unmix_m) scan_clip_fractions = measure_clip_fractions(analysis_source, None, an_buffer) - if transfer: - # No camera matrix (a scanner TIFF) means the buffer is already in - # working space, so the shared grid is the working-space grid. - channel_grid = ( - prefiltered - if pre_m is None - else unmix_log_image(prefilter_log_grid(apply_camera_matrix(analysis_source, pre_m), None, an_buffer), unmix_m) - ) - cam_prefiltered = relog_matrix_image(channel_grid, post_m) + if slide_stage is not None: + cam_prefiltered = density_stage_grid(slide_stage, analysis_source, None, an_buffer, lambda: log_grid) if prefilter_key is not None: self._prefilter_cache = (prefilter_key, prefiltered, scan_clip_fractions, None, cam_prefiltered) if analysis_key is not None: @@ -1908,24 +1902,16 @@ def _upload_unified_uniforms( adj_floors = (t_floors[0] + wp3[0], t_floors[1] + wp3[1], t_floors[2] + wp3[2]) adj_ceils = (t_ceils[0] + bp3[0], t_ceils[1] + bp3[1], t_ceils[2] + bp3[2]) - # Capture-side dye-unmix rows, resolved once per frame by the caller and shared - # with NormalizationProcessor. Identity when off; a (3, 4) affine packs its offset in w. - unmix_or_none = unmix + # Capture-side dye-unmix rows, resolved once per frame by the caller and shared with + # NormalizationProcessor, and the source matrix around them (density_stage): the cam rows + # before the log, read only on the transfer path, and the post rows after. Identity when + # off; a (3, 4) affine packs its offset in w, and the w lane of post0 turns post on. + cam, unmix, post = density_stage(settings.process, cam_xyz, camera_wb, unmix) if unmix is None: unmix = np.eye(3) unmix_w = unmix[:, 3] if unmix.shape[1] == 4 else (0.0, 0.0, 0.0) - - # Working-space-from-camera rows, identity when the source carries no matrix. The - # shader reads them only on the transfer path. - # Linear RAW decodes without white balance, so fold the as-shot multipliers back in - # and the render stops depending on which decode produced the buffer — unless the - # capture is narrowband, where an as-shot WB estimate has no scene to describe and - # never folds (see should_fold_camera_wb). - cam, post = density_stage_matrices(settings.process, cam_xyz, camera_wb, unmix_or_none) if cam is None: cam = np.eye(3, dtype=np.float32) - # After the density matrix, a camera's or a Positive source's primaries - # (density_stage_matrices); the w lane of the first row turns the conversion on. post_on = 1.0 if post is not None else 0.0 if post is None: post = np.eye(3, dtype=np.float32) @@ -2970,13 +2956,20 @@ def _analysis_img() -> np.ndarray: # Unmixed like the non-tiled path, lazily: skipped when bounds are locked and no # auto refs, anchor or textural range need it. unmix_m = unmix_with_fade(settings.process, settings.process.process_mode) + log_grid_cache: Optional[np.ndarray] = None prefiltered_cache: Optional[np.ndarray] = None sorted_cache: Optional[np.ndarray] = None + def _log_grid() -> np.ndarray: + nonlocal log_grid_cache + if log_grid_cache is None: + log_grid_cache = prefilter_log_grid(_analysis_img(), meter_roi, meter_buffer) + return log_grid_cache + def _prefiltered() -> np.ndarray: nonlocal prefiltered_cache if prefiltered_cache is None: - prefiltered_cache = unmix_log_image(prefilter_log_grid(_analysis_img(), meter_roi, meter_buffer), unmix_m) + prefiltered_cache = unmix_log_image(_log_grid(), unmix_m) return prefiltered_cache def _sorted() -> np.ndarray: @@ -3011,13 +3004,8 @@ def _meter_grid() -> np.ndarray: if not transfer: return _prefiltered() if transfer_grid is None: - pre_m, post_m = density_stage_matrices(settings.process, cam_xyz, camera_wb, unmix_m) - channel_grid = ( - _prefiltered() - if pre_m is None - else unmix_log_image(prefilter_log_grid(apply_camera_matrix(_analysis_img(), pre_m), meter_roi, meter_buffer), unmix_m) - ) - transfer_grid = relog_matrix_image(channel_grid, post_m) + slide_stage = density_stage(settings.process, cam_xyz, camera_wb, unmix_m) + transfer_grid = density_stage_grid(slide_stage, _analysis_img(), meter_roi, meter_buffer, _log_grid) return transfer_grid meter_bounds = LogNegativeBounds(*transfer_bounds()) if transfer else global_anchor_bounds diff --git a/negpy/services/rendering/image_processor.py b/negpy/services/rendering/image_processor.py index 750eb17ac..5502738ed 100644 --- a/negpy/services/rendering/image_processor.py +++ b/negpy/services/rendering/image_processor.py @@ -790,6 +790,7 @@ def run_pipeline( gain_slices: tuple = (), source_orientation: int = 1, film_base: Optional[tuple] = None, + wants_fade_grid: bool = False, ) -> Tuple[Any, Dict[str, Any]]: """Executes rendering pipeline. Returns result (ndarray/GPUTexture) and metrics. ``skip_flatfield``: the export CPU fallbacks pass an already flat-fielded buffer. ``gain_slices``: the ``slice_half`` cuts that took *img* out of the @@ -892,6 +893,7 @@ def run_pipeline( camera_wb=camera_wb, cache_stages=cache_stages, readback_metrics=readback_metrics, + wants_fade_grid=wants_fade_grid, ) if metrics: context.metrics.update(metrics) diff --git a/tests/test_effective_linear_raw.py b/tests/test_effective_linear_raw.py index ee25b9014..a32092290 100644 --- a/tests/test_effective_linear_raw.py +++ b/tests/test_effective_linear_raw.py @@ -96,18 +96,19 @@ class TestDecodeAndMatrixAgree: """ def test_both_sides_read_one_helper(self): - """The decode asks effective_linear_raw directly; both matrix consumers ask - should_fold_camera_wb, which wraps it. Either way, both sides of the agreement - trace back to the one flag.""" + """The decode asks effective_linear_raw directly; the matrix asks should_fold_camera_wb, + which wraps it, in density_stage, which the CPU matrix reads. Either way, both sides of + the agreement trace back to the one flag.""" import inspect + from negpy.features.transparency import logic from negpy.features.transparency import processor as cpu from negpy.services.rendering import image_processor as ip src = inspect.getsource(ip) assert "effective_linear_raw" in src, "the decode no longer asks the shared helper" - src = inspect.getsource(cpu) - assert "should_fold_camera_wb" in src, "the CPU matrix no longer asks the shared helper" + assert "should_fold_camera_wb" in inspect.getsource(logic), "the matrix no longer asks the shared helper" + assert "density_stage(" in inspect.getsource(cpu), "the CPU matrix no longer reads density_stage" def test_batch_analysis_decodes_on_the_render_path_s_white_balance(self): """Its own comment is the requirement: the roll-average bounds are applied to the @@ -137,7 +138,7 @@ def test_the_gpu_matrix_agrees_with_the_cpu_one(self): from negpy.services.rendering import gpu_engine - assert "should_fold_camera_wb" in inspect.getsource(gpu_engine), "GPU matrix would drift from the CPU one" + assert "density_stage(" in inspect.getsource(gpu_engine), "GPU matrix would drift from the CPU one" class TestSourceIdentity: diff --git a/tests/test_fade_estimation.py b/tests/test_fade_estimation.py index afa579d48..515862098 100644 --- a/tests/test_fade_estimation.py +++ b/tests/test_fade_estimation.py @@ -180,7 +180,7 @@ def test_estimate_meters_the_render_grid_before_the_fade(): assert grid is not None and whole is not None and grid.shape != whole.shape _, metrics = ImageProcessor(use_gpu=False).run_pipeline( - image, cfg, "fade-grid", render_size_ref=256.0, prefer_gpu=False, cache_stages=False + image, cfg, "fade-grid", render_size_ref=256.0, prefer_gpu=False, cache_stages=False, wants_fade_grid=True ) fade = resolve_fade_matrix(1.0, ratio_g, ratio_b) np.testing.assert_allclose(metrics["transfer_grid"], grid @ fade.T, atol=1e-5) diff --git a/tests/test_positive_source_primaries.py b/tests/test_positive_source_primaries.py index e3d7b8aef..cb85392db 100644 --- a/tests/test_positive_source_primaries.py +++ b/tests/test_positive_source_primaries.py @@ -13,7 +13,7 @@ from negpy.features.process.capture_color import camera_to_working_matrix, is_source_profile, source_cam_xyz from negpy.features.process.fade import unmix_with_fade from negpy.features.process.models import ProcessConfig, ProcessMode -from negpy.features.transparency.logic import density_stage_matrices +from negpy.features.transparency.logic import density_stage from negpy.features.transparency.processor import TransparencyBaseProcessor from negpy.infrastructure.loaders.helpers import camera_xyz_matrix from negpy.infrastructure.loaders.jpeg_loader import JpegLoader @@ -46,18 +46,27 @@ def test_source_cam_xyz_gives_back_the_profile_matrix(): ) def test_the_primaries_follow_the_density_matrix_only_for_a_positive_source_profile(positive, unmix, expected): m = np.eye(3) * 2.0 if unmix else None - pre, post = density_stage_matrices(_slide(positive_source=positive), _SOURCE, None, m) - moved = post if expected == "post" else pre - assert (pre is None, post is None) == ((expected == "post"), (expected == "pre")) + stage = density_stage(_slide(positive_source=positive), _SOURCE, None, m) + moved = stage.post if expected == "post" else stage.pre + assert (stage.pre is None, stage.post is None) == ((expected == "post"), (expected == "pre")) np.testing.assert_array_equal(moved, camera_to_working_matrix(_SOURCE)) + assert stage.unmix is m def test_no_source_matrix_has_nothing_to_move(): - assert density_stage_matrices(_slide(positive_source=True), None, None, np.eye(3)) == (None, None) + stage = density_stage(_slide(positive_source=True), None, None, np.eye(3)) + assert stage.pre is None and stage.post is None def _render(config: ProcessConfig, img: np.ndarray, cam_xyz=_SOURCE, camera_wb=None): - ctx = PipelineContext(original_size=img.shape[:2], scale_factor=1.0, process_mode=ProcessMode.E6, cam_xyz=cam_xyz, camera_wb=camera_wb) + ctx = PipelineContext( + original_size=img.shape[:2], + scale_factor=1.0, + process_mode=ProcessMode.E6, + cam_xyz=cam_xyz, + camera_wb=camera_wb, + wants_fade_grid=True, + ) return TransparencyBaseProcessor(config).process(img, ctx), ctx diff --git a/tests/test_raw_density_order.py b/tests/test_raw_density_order.py index 8826e962b..26dff52d6 100644 --- a/tests/test_raw_density_order.py +++ b/tests/test_raw_density_order.py @@ -6,11 +6,17 @@ import pytest from negpy.domain.interfaces import PipelineContext -from negpy.features.exposure.normalization import normalize_log_image -from negpy.features.process.capture_color import _primaries, apply_camera_matrix, camera_matrix_parts, camera_to_working_matrix +from negpy.features.exposure.normalization import normalize_log_image, relog_matrix_image, unmix_log_image +from negpy.features.process.capture_color import ( + _primaries, + apply_camera_matrix, + camera_matrix_parts, + camera_to_working_matrix, + wb_only_cam_xyz, +) from negpy.features.process.fade import measure_white, unmix_with_fade from negpy.features.process.models import ProcessConfig, ProcessMode -from negpy.features.transparency.logic import density_stage_matrices +from negpy.features.transparency.logic import density_stage from negpy.features.transparency.processor import TransparencyBaseProcessor _CAMERA = [[0.6722, -0.0635, -0.0963], [-0.4287, 1.246, 0.2046], [-0.0908, 0.2268, 0.5762]] @@ -23,14 +29,14 @@ def _slide(positive: bool, **kw) -> ProcessConfig: return ProcessConfig(process_mode=ProcessMode.E6, positive_source=positive, linear_raw=True, **kw) -def _render(config: ProcessConfig, img: np.ndarray, readback: bool = False): +def _render(config: ProcessConfig, img: np.ndarray, fade_grid: bool = False, cam_xyz=_CAMERA): ctx = PipelineContext( original_size=img.shape[:2], scale_factor=1.0, process_mode=ProcessMode.E6, - cam_xyz=_CAMERA, + cam_xyz=cam_xyz, camera_wb=_WB, - readback_metrics=readback, + wants_fade_grid=fade_grid, ) return TransparencyBaseProcessor(config).process(img, ctx), ctx @@ -57,44 +63,73 @@ def test_the_parts_rebuild_the_matrix_and_leave_its_bits(): assert camera_matrix_parts(None, _WB) == (None, None) +_LOG_GAINS = np.log10([2.0, 1.0, 1.5]) + + @pytest.mark.parametrize("positive", _PATHS) def test_a_camera_splits_at_the_fold_only_with_a_density_matrix(positive): + """The gains become a log offset in the density matrix; the primaries move after it.""" config = _slide(positive) - gains, primaries = camera_matrix_parts(_CAMERA, _WB) - pre, post = density_stage_matrices(config, _CAMERA, _WB, np.eye(3)) - np.testing.assert_array_equal(pre, gains) - np.testing.assert_array_equal(post, primaries) - pre, post = density_stage_matrices(config, _CAMERA, _WB, None) - np.testing.assert_array_equal(pre, camera_to_working_matrix(_CAMERA, _WB)) - assert post is None + unmix = np.array([[1.0, -0.1, 0.0], [0.0, 1.0, -0.2], [-0.05, 0.0, 1.0]]) + stage = density_stage(config, _CAMERA, _WB, unmix) + assert stage.pre is None + np.testing.assert_allclose(stage.unmix, np.hstack([unmix, (unmix @ _LOG_GAINS)[:, None]]), atol=1e-12) + np.testing.assert_array_equal(stage.post, camera_matrix_parts(_CAMERA, _WB)[1]) + stage = density_stage(config, _CAMERA, _WB, None) + np.testing.assert_array_equal(stage.pre, camera_to_working_matrix(_CAMERA, _WB)) + assert stage.unmix is None and stage.post is None + + +def test_the_gains_add_to_a_stain_offset(): + affine = np.hstack([np.eye(3) * 2.0, np.array([[0.1], [0.2], [0.3]])]) + stage = density_stage(_slide(False), _CAMERA, _WB, affine) + np.testing.assert_allclose(stage.unmix[:, 3], affine[:, 3] + 2.0 * _LOG_GAINS, atol=1e-12) def test_a_balanced_decode_moves_only_the_primaries(): """Highlight reconstruction bakes the white balance into the decode, so there is no fold.""" config = ProcessConfig(process_mode=ProcessMode.E6, linear_raw=False, highlight_reconstruction=5) - pre, post = density_stage_matrices(config, _CAMERA, _WB, np.eye(3)) - assert pre is None - np.testing.assert_array_equal(post, camera_matrix_parts(_CAMERA, None)[1]) + unmix = np.eye(3) * 2.0 + stage = density_stage(config, _CAMERA, _WB, unmix) + assert stage.pre is None and stage.unmix is unmix + np.testing.assert_array_equal(stage.post, camera_matrix_parts(_CAMERA, None)[1]) + + +def test_identity_primaries_do_not_convert(): + """An Input ICC leaves the camera only its white-balance fold (`wb_only_cam_xyz`): nothing to convert after.""" + stage = density_stage(_slide(False), wb_only_cam_xyz(_CAMERA), _WB, np.eye(3)) + assert stage.pre is None and stage.post is None + np.testing.assert_allclose(stage.unmix[:, 3], _LOG_GAINS, atol=1e-12) def test_a_negative_keeps_the_whole_matrix_before_the_log(): config = ProcessConfig(process_mode=ProcessMode.C41, linear_raw=True) - pre, post = density_stage_matrices(config, _CAMERA, _WB, np.eye(3)) - np.testing.assert_array_equal(pre, camera_to_working_matrix(_CAMERA, _WB)) - assert post is None + unmix = np.eye(3) * 2.0 + stage = density_stage(config, _CAMERA, _WB, unmix) + np.testing.assert_array_equal(stage.pre, camera_to_working_matrix(_CAMERA, _WB)) + assert stage.unmix is unmix and stage.post is None @pytest.mark.parametrize("positive", _PATHS) -def test_remove_stain_leaves_a_white_border_neutral(positive): - """Estimate meters the white in white-balanced camera channels, and the primaries keep it neutral.""" - img = _unbalanced(np.full((40, 60, 3), 0.8, np.float32)) - _, ctx = _render(_slide(positive), img, readback=True) +def test_remove_stain_leaves_a_white_border_neutral_and_converts_after(positive): + """Estimate meters the white in white-balanced camera channels, the primaries keep the border + neutral, and a colored patch takes the primaries after the stain offset.""" + balanced = np.full((40, 60, 3), 0.8, np.float32) + balanced[10:30, 15:45] = (0.3, 0.15, 0.08) + img = _unbalanced(balanced) + _, ctx = _render(_slide(positive), img, fade_grid=True) white = measure_white(ctx.metrics["transfer_grid"]) assert white is not None np.testing.assert_allclose(white, np.log10(0.8), atol=1e-4) config = _slide(positive, fade_white_anchor=True, fade_white_r=white[0], fade_white_g=white[1], fade_white_b=white[2], **_FADE) _, ctx = _render(config, img) - np.testing.assert_allclose(_restored_log(ctx).reshape(-1, 3), -0.1, atol=1e-4) + restored = _restored_log(ctx) + np.testing.assert_allclose(restored[:5].reshape(-1, 3), -0.1, atol=1e-4) + unmix = unmix_with_fade(config, ProcessMode.E6) + expected = relog_matrix_image(unmix_log_image(np.log10(balanced), unmix), camera_matrix_parts(_CAMERA, _WB)[1]) + np.testing.assert_allclose(restored, expected, atol=1e-4) + converted_first = unmix_log_image(np.log10(img @ camera_to_working_matrix(_CAMERA, _WB).T), unmix) + assert float(np.abs(restored - converted_first)[10:30, 15:45].max()) > 0.05 @pytest.mark.parametrize("positive", _PATHS) @@ -127,5 +162,11 @@ def test_no_density_matrix_renders_as_the_whole_matrix_first(positive): @pytest.mark.parametrize("positive", _PATHS) def test_estimate_reads_white_balanced_camera_channels(positive): flat = _unbalanced(np.full((40, 60, 3), (0.5, 0.2, 0.1), np.float32)) - _, ctx = _render(_slide(positive), flat, readback=True) + _, ctx = _render(_slide(positive), flat, fade_grid=True) np.testing.assert_allclose(ctx.metrics["transfer_grid"].reshape(-1, 3)[0], np.log10((0.5, 0.2, 0.1)), atol=1e-4) + + +def test_a_render_stores_the_fade_grid_only_on_request(): + img = _unbalanced(np.full((40, 60, 3), 0.5, np.float32)) + _, ctx = _render(_slide(False), img) + assert "transfer_grid" not in ctx.metrics From 36b4ff84f02b393f325f17a9dafbfa2815e2cb4f Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:09:47 +0100 Subject: [PATCH 04/46] fix(fade): scale a camera RAW's density from its own white Fade Restoration on camera RAWs gave casts that changed from frame to frame. Two faults caused them. The restoration scaled density from the decoder's white. A JPEG or scanner TIFF has its white there, but a camera captures clear film about 2 stops lower, so the restoration scaled that offset into a cast. The cast moved with exposure and white balance: on a faded NEF, +-1 stop moved the white's B/G from 0.35 to 0.81 and the Estimate's Blue Survival from 0.75 to 0.63. A camera RAW now renders M (L - w) + w, so density above the white is restored and the white stays. Remove Stain only maps the white to neutral. Estimate reads the grays above the white and stores it with Remove Stain off too. The Estimate and the Positive render now do not change with exposure. The stored white could come from another color space than the render. Two Positive NEFs held a white read without the camera matrix, 0.28 D off in red, which rendered magenta under Remove Stain. The white now carries fade_white_space, a hash of the camera matrix and as-shot white balance it was read through. A render in another space ignores it, and the desktop measures it again on the open frame, with no roll lock. JPEG and TIFF sources keep the empty tag and render as before. Bumps THUMBNAIL_RENDER_VERSION to 32. --- docs/PIPELINE.md | 2 + negpy/desktop/controller.py | 26 ++- negpy/desktop/settings_catalog.py | 3 +- negpy/desktop/view/sidebar/controls_panel.py | 1 + negpy/desktop/view/sidebar/sensor.py | 12 +- negpy/features/process/fade.py | 62 +++++-- negpy/features/process/models.py | 3 + negpy/features/transparency/processor.py | 4 +- negpy/services/assets/rolls.py | 11 +- negpy/services/rendering/engine.py | 9 +- negpy/services/rendering/gpu_engine.py | 10 +- tests/test_fade_white_space.py | 180 +++++++++++++++++++ tests/test_interactive_decoupling.py | 1 + tests/test_navigate_back_memo.py | 1 + tests/thumbnail_fingerprint_defaults.json | 8 + 15 files changed, 299 insertions(+), 34 deletions(-) create mode 100644 tests/test_fade_white_space.py diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index d4c5c6370..810aeb788 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -64,6 +64,8 @@ Pixel values are linear transmittance. The source corrections run in that domain Estimate (`features/process/fade.py`) runs on request, never in a render. It renders the frame on the CPU with $F$ at identity and White Anchor off, and reads the transparency meter grid (`transfer_grid`, made only on request through `PipelineContext.wants_fade_grid`: bakes, geometry, crop and unmix, in the channels the density matrix acts on, `density_channels`). It meters the grays with `measure_neutral_axis_from_log` against the frame's own bounds: first with the foliage and sky sectors excluded, against bounds from each channel's extreme cells with decoded zeros dropped, at confidence `MIN_CONFIDENCE` ($0.2$) or more. Else it reads against bounds from the `ROBUST_EXTREME_PCT` ($0.1$) percentiles, with the sectors excluded, then not, at `ROBUST_MIN_CONFIDENCE` ($0.4$). MIDTONE reads the midtone densities and also sets $a_r$; SPREAD reads each channel's midtone-to-shadow spread. Ratios clamp to $[0.2, 5]$, and the hint shows the unclamped value. The hint also shows the other method's reading: a large gap is a cast that a fade does not explain. A near-monochrome slide meters as faded. White Anchor (`fade_white_anchor`, UI "Remove Stain") makes the matrix affine, $M(L - w) + t$. The stain $w$ is each channel's `WHITE_PCT` cell of the meter grid in log transmittance, without decoded zeros and cells at full scale in every channel; Estimate stores it as `fade_white_r/g/b`. It maps to the neutral $t = \min(\max_c w_c, -0.1)$ (`FADE_WHITE_HEADROOM`): the cleanest channel's own level, never within $0.1$ D of full scale. $F^{-1}$ acts on $L - w$ before $t$ is added, so the Estimate reads the grays on $\min(L - w, 0)$. The offset rides in the w lanes of the unmix rows on the GPU. + + A camera RAW's clear film sits below the decoder's white by the exposure and the light, so $F^{-1}$ about $L = 0$ would scale that offset into a cast. On a RAW the matrix is always $M(L - w) + w$, and Remove Stain changes only $t$; Estimate stores $w$ with White Anchor off too and reads the grays on $\min(L - w, 0)$. A JPEG or scanner TIFF has its white at full scale and keeps $L = 0$. `fade_white_space` tags $w$ with the camera matrix and as-shot white balance it was read through, a hash of `camera_to_working_matrix(cam_xyz, camera_wb)`, "" with no camera matrix. A render whose tag differs ignores $w$, and the desktop measures it again on the open frame (`_refresh_stale_fade_white`), with no roll lock. * **Density above base**: `density_above_base` is (Dmin, Dmed, Dmax) above the clear base, luminance-weighted: the thin and dense bounds (before trims; the roll's under Roll Analysis) and the metered median, each minus the base. The base is the picked one, else the rebate reader's, unmixed like the meters. A pick is the open roll's (`rolls.roll_film_base`), or the frame's own with no roll open (`process.film_base`, unhashed); in an open roll the roll's wins, with none open the frame's. The preview carries it as `film_base`; export never reads it. The reader takes the flat 8-px blocks (std over mean under 0.03, unclipped) in the outer 15% of the film box (`film_and_picture` at `AUTOCROP_DETECT_RES`), groups them by brightness with a 0.04-decade gap, and takes the largest group of at least five. It finds none without a film box, with a base darker than the picture, or on the transfer path. The preview reads it once per source, on the flat-fielded, unmixed source before the defect repairs, and passes the read to both engines. * **Scan-clip warning**: `scan_clip_fractions` reports the per-channel fraction of source pixels at or above $0.99$ linear. On a negative this clips film base and shadows to $D=0$; nothing reconstructs it. diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index e419197d9..c17ecbab7 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -3756,7 +3756,7 @@ def _handle_film_base_pick(self, nx: float, ny: float) -> None: unmix, so the roll keeps a raw base that every render unmixes like the rebate reader's. One click: the tool disarms, and leaving it re-renders the roll's frame above the base.""" from negpy.features.exposure.normalization import log_from_val - from negpy.features.process.fade import unmix_with_fade + from negpy.features.process.fade import fade_white_space, unmix_with_fade bounds = self.state.last_metrics.get("final_bounds") or self.state.last_metrics.get("log_bounds") patch = self._sample_normalized_patch(nx, ny, radius=4) @@ -3769,7 +3769,9 @@ def _handle_film_base_pick(self, nx: float, ny: float) -> None: self.set_status(tr("Film Base: that spot is not flat film; click the clear rebate"), 4000, "warning") return base = logs.mean(axis=0) - unmix = unmix_with_fade(self.state.config.process, self.state.config.process.process_mode) + unmix = unmix_with_fade( + self.state.config.process, self.state.config.process.process_mode, fade_white_space(*self._effective_cam_xyz()) + ) if unmix is not None: unmix = np.asarray(unmix, dtype=np.float64) if unmix.shape[1] == 4: @@ -9935,9 +9937,29 @@ def _on_render_finished(self, _result: Any, metrics: Dict[str, Any]) -> None: AppController._continue_background_work(self) return + if self._pending_render_task is None and not metrics.get("ephemeral") and not metrics.get("interactive"): + self._refresh_stale_fade_white() self._dispatch_pending_render() AppController._continue_background_work(self) + def _refresh_stale_fade_white(self) -> None: + """Measure a camera RAW's fade white again once the stored one belongs to another decode + space (`fade_white_stale`). It is a reading of this frame's decode, so it locks no card.""" + from negpy.desktop.workers.chart_fit import fade_estimate_grid + from negpy.features.process.fade import fade_white_space, fade_white_stale, measure_white + + process = self.state.config.process + space = fade_white_space(*self._effective_cam_xyz()) + if not fade_white_stale(process, space): + return + source = self.chart_fit_source() + grid = None if source is None else fade_estimate_grid(source) + if grid is None: + return + r, g, b = measure_white(grid) or (0.0, 0.0, 0.0) + refreshed = replace(process, fade_white_r=r, fade_white_g=g, fade_white_b=b, fade_white_space=space) + self.apply_config(replace(self.state.config, process=refreshed), persist=True) + def _freeze_resolved_auto_crop(self, metrics: Dict[str, Any]) -> None: """Store the crop this render detected, so nothing detects it a second time. diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py index b35d0c28c..f0e64b824 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -171,7 +171,7 @@ def _fmt_highlights(values: tuple) -> str: # Strength, profile, the baked matrix and the process it was baked for copy atomically: # strength alone would leave the target on a stale or None matrix. _row(mark("Crosstalk"), "process", "crosstalk_strength", "crosstalk_profile", "crosstalk_matrix", "crosstalk_process", fmt=lambda v: _fmt_scalar(v[0]), sticky=True), - _row(mark("Fade Restoration"), "process", "fade_ratio_r", "fade_ratio_g", "fade_ratio_b", "fade_white_anchor", "fade_white_r", "fade_white_g", "fade_white_b", fmt=lambda v: _fmt_scalar(v[0])), + _row(mark("Fade Restoration"), "process", "fade_ratio_r", "fade_ratio_g", "fade_ratio_b", "fade_white_anchor", "fade_white_r", "fade_white_g", "fade_white_b", "fade_white_space", fmt=lambda v: _fmt_scalar(v[0])), _row(mark("Sensor Crosstalk Profile"), "process", "sensor_profile", "sensor_matrix", "sensor_unmix", fmt=lambda v: _fmt_scalar(v[0]), sticky=True), # Absent from BOUNDS_INPUT_FIELDS: it acts after inversion, so it never feeds the meters. _row(mark("Hue Trim"), "process", "hue_trim", sticky=True), @@ -390,6 +390,7 @@ def _fmt_highlights(values: tuple) -> str: "fade_white_r", "fade_white_g", "fade_white_b", + "fade_white_space", "sensor_profile", "sensor_matrix", "sensor_unmix", diff --git a/negpy/desktop/view/sidebar/controls_panel.py b/negpy/desktop/view/sidebar/controls_panel.py index 30cec7989..c5fb2e984 100644 --- a/negpy/desktop/view/sidebar/controls_panel.py +++ b/negpy/desktop/view/sidebar/controls_panel.py @@ -82,6 +82,7 @@ "fade_white_r", "fade_white_g", "fade_white_b", + "fade_white_space", "hue_trim", ) # ProcessConfig is split across five cards. Each tuple is both the card's reset scope and diff --git a/negpy/desktop/view/sidebar/sensor.py b/negpy/desktop/view/sidebar/sensor.py index b3e90dbda..681d9f7ee 100644 --- a/negpy/desktop/view/sidebar/sensor.py +++ b/negpy/desktop/view/sidebar/sensor.py @@ -537,7 +537,7 @@ def _on_estimate_fade(self) -> None: def _run_estimate(self, white_anchor: bool) -> None: from negpy.desktop.workers.chart_fit import fade_estimate_grid - from negpy.features.process.fade import estimate_fade + from negpy.features.process.fade import estimate_fade, fade_white_space conf = self.state.config.process if render_path(conf) is RenderPath.PRINT: @@ -549,14 +549,16 @@ def _run_estimate(self, white_anchor: bool) -> None: return method = EstimateMethod(self.estimate_method_combo.currentData()) anchor = white_anchor - estimates = estimate_fade(grid, white_anchor=anchor) + space = fade_white_space(source.cam_xyz, source.camera_wb) + estimates = estimate_fade(grid, white_anchor=anchor, camera_raw=bool(space)) chosen, other = estimates[method], estimates[next(m for m in EstimateMethod if m != method)] failed = bool(chosen.reason) and chosen.green == 1.0 and chosen.blue == 1.0 # The white is stored with the survivals and never measured per render. A failed reading - # stores it only where it is plainly a stain on clear film. + # stores it only where it is plainly a stain on clear film. A camera RAW's render scales + # density above its white, so it stores the white with Remove Stain off too. white = {} - if anchor and chosen.white is not None and (not failed or stain_suggested(chosen.white)): - white = dict(zip(("fade_white_r", "fade_white_g", "fade_white_b"), chosen.white)) + if (anchor or space) and chosen.white is not None and (not failed or space or stain_suggested(chosen.white)): + white = dict(zip(("fade_white_r", "fade_white_g", "fade_white_b"), chosen.white), fade_white_space=space) stain = "" if not anchor and chosen.white is not None and stain_suggested(chosen.white): stain = tr("The clear areas have a yellow stain: turn on Remove Stain.") diff --git a/negpy/features/process/fade.py b/negpy/features/process/fade.py index ab05af34b..0cbf21818 100644 --- a/negpy/features/process/fade.py +++ b/negpy/features/process/fade.py @@ -1,9 +1,10 @@ """Fade Restoration: the restoration matrix a Slide renders with, and the per-image estimate of the dye survivals.""" +import hashlib from dataclasses import dataclass from enum import StrEnum -from typing import Optional +from typing import Optional, Sequence import numpy as np @@ -14,6 +15,7 @@ effective_crosstalk_matrix, measure_neutral_axis_from_log, ) +from negpy.features.process.capture_color import camera_to_working_matrix, is_source_profile from negpy.features.process.models import ProcessConfig from negpy.features.process.path import RenderPath, render_path @@ -64,23 +66,44 @@ def fade_matrix(process: ProcessConfig) -> Optional[np.ndarray]: return resolve_fade_matrix(*ratios) -def unmix_with_fade(process: ProcessConfig, process_mode: Optional[str]) -> Optional[np.ndarray]: +def fade_white_space(cam_xyz: Optional[Sequence[Sequence[float]]], camera_wb: Optional[Sequence[float]]) -> str: + """The space a camera RAW's log white is measured in: its camera matrix with the as-shot white + balance, which every RAW decode applies at the decode or in the matrix. "" for a source with no + camera matrix, whose white is the file's own full scale.""" + if cam_xyz is None or is_source_profile(cam_xyz): + return "" + matrix = camera_to_working_matrix(cam_xyz, camera_wb) + if matrix is None: + return "" + return hashlib.sha1(np.round(np.asarray(matrix, dtype=np.float64), 4).tobytes()).hexdigest()[:12] + + +def unmix_with_fade(process: ProcessConfig, process_mode: Optional[str], white_space: str = "") -> Optional[np.ndarray]: """The log-density matrix a render applies: the crosstalk unmix, then the fade restoration of - the densities it recovers (`fade @ unmix`). None when neither applies. With Remove Stain on it - is a (3, 4) affine `M (L - w) + t`; the last column is the log offset `t - M w`.""" + the densities it recovers (`fade @ unmix`). None when neither applies. *white_space* is the + render's `fade_white_space`. A stored white makes it a (3, 4) affine `M (L - w) + t`, the last + column the log offset `t - M w`: Remove Stain maps the white to a neutral t; on a camera RAW, + whose white sits below full scale, t = w keeps the white and scales density above it.""" unmix = effective_crosstalk_matrix(process, process_mode) fade = fade_matrix(process) matrix = unmix if fade is None else (fade if unmix is None else fade @ unmix) - white = _fade_white(process) + white = _fade_white(process, white_space) if white is None: return matrix + if process.fade_white_anchor: + target = np.full(3, _white_target(white)) + elif white_space and matrix is not None: + target = white + else: + return matrix linear = np.eye(3) if matrix is None else matrix - return np.hstack([linear, (_white_target(white) - linear @ white)[:, None]]) + return np.hstack([linear, (target - linear @ white)[:, None]]) -def _fade_white(process: ProcessConfig) -> Optional[np.ndarray]: - """The stored per-channel white (log transmittance) when Remove Stain acts, else None.""" - if not process.fade_white_anchor or render_path(process) is RenderPath.PRINT: +def _fade_white(process: ProcessConfig, white_space: str = "") -> Optional[np.ndarray]: + """The stored per-channel white (log transmittance) when it applies to this render, else None. + A white measured in another space than the render's reads the wrong channels, so it does not apply.""" + if render_path(process) is RenderPath.PRINT or process.fade_white_space != white_space: return None white = np.array([process.fade_white_r, process.fade_white_g, process.fade_white_b], dtype=np.float64) return white if bool(np.any(white != 0.0)) else None @@ -91,12 +114,20 @@ def _white_target(white: np.ndarray) -> float: return min(float(white.max()), -FADE_WHITE_HEADROOM) -def fade_white_target(process: ProcessConfig) -> Optional[float]: +def fade_white_target(process: ProcessConfig, white_space: str = "") -> Optional[float]: """The neutral log level Remove Stain maps the stored white to in this render, else None.""" - white = _fade_white(process) + white = _fade_white(process, white_space) if process.fade_white_anchor else None return None if white is None else _white_target(white) +def fade_white_stale(process: ProcessConfig, white_space: str) -> bool: + """Whether a camera RAW's fade needs its white measured again: Fade Restoration or Remove Stain + acts, and the stored white was measured in another space (or never) for this render.""" + if not white_space or render_path(process) is RenderPath.PRINT or process.fade_white_space == white_space: + return False + return process.fade_white_anchor or fade_matrix(process) is not None + + #: The return shape of `measure_neutral_axis_from_log`: midtone, shadow, highlight or None, confidence. NeutralAxisRefs = tuple[tuple[float, float, float], tuple[float, float, float], object, float] @@ -233,16 +264,17 @@ def fade_ratios_from_neutral_axis(refs: Optional[NeutralAxisRefs], method: str = return ratio_g, ratio_b, "" -def estimate_fade(grid: ImageBuffer, white_anchor: bool = False) -> dict[EstimateMethod, FadeEstimate]: +def estimate_fade(grid: ImageBuffer, white_anchor: bool = False, camera_raw: bool = False) -> dict[EstimateMethod, FadeEstimate]: """Both methods' estimates from one measurement of the grays. `grid` is the transparency meter's grid with the crosstalk unmix, no fade and no Remove Stain (`TransparencyBaseProcessor`). MIDTONE also sets Red Survival so the best-kept layer lands at 1.0; a ratio above 1 says red - faded most. With `white_anchor`, the grays are read as density above the frame's white, as - Remove Stain renders them.""" + faded most. With `white_anchor`, or on a `camera_raw` whose render scales density above its + white (`unmix_with_fade`), the grays are read as density above the frame's white.""" white = measure_white(grid) if white_anchor and white is None: return {m: FadeEstimate(None, 1.0, 1.0, tr("No clear area to measure the stain on this frame.")) for m in EstimateMethod} - refs, reason = neutral_axis_from_grid(white_anchored_grid(grid, white) if white_anchor and white is not None else grid) + anchored = white is not None and (white_anchor or camera_raw) + refs, reason = neutral_axis_from_grid(white_anchored_grid(grid, white) if anchored else grid) out = {} for method in EstimateMethod: if refs is None: diff --git a/negpy/features/process/models.py b/negpy/features/process/models.py index 3d0011f53..d02f84a69 100644 --- a/negpy/features/process/models.py +++ b/negpy/features/process/models.py @@ -221,6 +221,9 @@ class ProcessConfig: fade_white_r: float = 0.0 fade_white_g: float = 0.0 fade_white_b: float = 0.0 + # `fade.fade_white_space` of the decode the whites were measured on, "" for a source with no + # camera matrix. A render in another space ignores them. + fade_white_space: str = "" # Sensor (CFA) crosstalk unmix on the LINEAR capture, before inversion. A # per-setup property calibrated from three bare-light R/G/B exposures diff --git a/negpy/features/transparency/processor.py b/negpy/features/transparency/processor.py index 3c95de97f..66f027d44 100644 --- a/negpy/features/transparency/processor.py +++ b/negpy/features/transparency/processor.py @@ -25,7 +25,7 @@ resolve_analysis_region, unmix_log_image, ) -from negpy.features.process.fade import unmix_with_fade +from negpy.features.process.fade import fade_white_space, unmix_with_fade from negpy.features.process.capture_color import apply_camera_matrix from negpy.features.process.models import ProcessConfig from negpy.features.process.path import RenderPath, render_path @@ -70,7 +70,7 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: # Honoured here too: a rig-calibrated matrix is a capture correction like Hue Trim, and # the mode gate keeps a negative's profile from touching a slide. Inert at the shipped # default, so the as-captured render is unperturbed. - unmix = unmix_with_fade(self.config, context.process_mode) + unmix = unmix_with_fade(self.config, context.process_mode, fade_white_space(context.cam_xyz, context.camera_wb)) stage = density_stage(self.config, context.cam_xyz, context.camera_wb, unmix) linear = image if stage.pre is None else apply_camera_matrix(negpy_core.nan_to_num(image, epsilon, 1.0, epsilon), stage.pre) img_log = negpy_core.log_density_open(np.ascontiguousarray(linear, dtype=np.float32)) diff --git a/negpy/services/assets/rolls.py b/negpy/services/assets/rolls.py index c2ad967a4..72130faf0 100644 --- a/negpy/services/assets/rolls.py +++ b/negpy/services/assets/rolls.py @@ -683,7 +683,16 @@ def virtual_rolls(repo: Any) -> List[tuple]: # all; its own card, so a per-frame Estimate never locks the crosstalk. "fade": ( "process", - ("fade_ratio_r", "fade_ratio_g", "fade_ratio_b", "fade_white_anchor", "fade_white_r", "fade_white_g", "fade_white_b"), + ( + "fade_ratio_r", + "fade_ratio_g", + "fade_ratio_b", + "fade_white_anchor", + "fade_white_r", + "fade_white_g", + "fade_white_b", + "fade_white_space", + ), ), # How the roll's camera RAWs decode: white balance, demosaic and highlight recovery. "demosaic": ("process", ("linear_raw", "demosaic_preview", "demosaic_export", "highlight_reconstruction")), diff --git a/negpy/services/rendering/engine.py b/negpy/services/rendering/engine.py index 696dd33af..baaca9014 100644 --- a/negpy/services/rendering/engine.py +++ b/negpy/services/rendering/engine.py @@ -28,7 +28,7 @@ geometry_kwargs, normalized_roi, ) -from negpy.features.process.fade import fade_white_target +from negpy.features.process.fade import fade_white_space, fade_white_target from negpy.features.process.hue import apply_hue_trim from negpy.features.process.path import RenderPath, render_path from negpy.features.transparency.processor import TransferProcessor, TransparencyBaseProcessor @@ -55,7 +55,7 @@ def base_processor(settings: WorkspaceConfig) -> Any: return TransparencyBaseProcessor(settings.process, settings.exposure.cast_removal_strength) -def exposure_processor(settings: WorkspaceConfig) -> Any: +def exposure_processor(settings: WorkspaceConfig, white_space: str = "") -> Any: """The exposure stage's curve: the print or a slide's transfer, or the Flat master on either base.""" if settings.exposure.render_intent == RenderIntent.FLAT or render_path(settings.process) is RenderPath.PRINT: return PhotometricProcessor( @@ -64,7 +64,7 @@ def exposure_processor(settings: WorkspaceConfig) -> Any: return TransferProcessor( settings.exposure, settings.process.positive_source, - fade_white_target(settings.process), + fade_white_target(settings.process, white_space), ) @@ -246,6 +246,7 @@ def run_base(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: settings.process.fade_white_r, settings.process.fade_white_g, settings.process.fade_white_b, + settings.process.fade_white_space, settings.process.lock_bounds, settings.process.use_cast_average, settings.process.locked_neutral_axis, @@ -338,7 +339,7 @@ def run_base(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: timing.lap("diffusion") def run_exposure(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: - img_out = exposure_processor(settings).process(img_in, ctx) + img_out = exposure_processor(settings, fade_white_space(ctx.cam_xyz, ctx.camera_wb)).process(img_in, ctx) # Rides this stage: it needs the print, and its own stage would re-run everything behind # it on a drag. Stays inside the flat intent below, being a capture fix, not a look. return apply_hue_trim(img_out, settings.process.hue_trim) diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index 1d2abfc90..100ab574d 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -94,7 +94,7 @@ wb_split_geometry, zone_geometry, ) -from negpy.features.process.fade import fade_white_target, unmix_with_fade +from negpy.features.process.fade import fade_white_space, fade_white_target, unmix_with_fade from negpy.features.exposure.bw_filter import collapse_weights, plane_weights, printed_exposure from negpy.features.process.models import ProcessMode, per_channel_point_offsets, pooled_neutral_axis from negpy.features.process.path import RenderPath, render_path @@ -270,6 +270,7 @@ def _analysis_cache_key(settings: WorkspaceConfig, analysis_source_hash: str, fi p.fade_white_r, p.fade_white_g, p.fade_white_b, + p.fade_white_space, g.rotation, g.flip_horizontal, g.flip_vertical, @@ -828,7 +829,7 @@ def process_to_texture( sorted_grid = None scan_clip_fractions = None analysis_source = None - unmix_m = unmix_with_fade(settings.process, settings.process.process_mode) + unmix_m = unmix_with_fade(settings.process, settings.process.process_mode, fade_white_space(cam_xyz, camera_wb)) # The transparency curve reads working space, so its meter must too: the same # camera matrices TransparencyBaseProcessor applies, on the grid. slide_stage = density_stage(settings.process, cam_xyz, camera_wb, unmix_m) if transfer else None @@ -868,6 +869,7 @@ def process_to_texture( p.fade_white_r, p.fade_white_g, p.fade_white_b, + p.fade_white_space, p.process_mode, p.positive_source, None @@ -1982,7 +1984,7 @@ def _upload_unified_uniforms( LogNegativeBounds(adj_floors, adj_ceils), neutral_axis_refs, settings.exposure.cast_removal_strength, - fade_white_target(settings.process), + fade_white_target(settings.process, fade_white_space(cam_xyz, camera_wb)), ) # A finished positive skips the baseline gain, and the zone_taper.y lane below tells # the shader to skip display_rendering too. Matches transparency/logic.py. @@ -2955,7 +2957,7 @@ def _analysis_img() -> np.ndarray: # Unmixed like the non-tiled path, lazily: skipped when bounds are locked and no # auto refs, anchor or textural range need it. - unmix_m = unmix_with_fade(settings.process, settings.process.process_mode) + unmix_m = unmix_with_fade(settings.process, settings.process.process_mode, fade_white_space(cam_xyz, camera_wb)) log_grid_cache: Optional[np.ndarray] = None prefiltered_cache: Optional[np.ndarray] = None sorted_cache: Optional[np.ndarray] = None diff --git a/tests/test_fade_white_space.py b/tests/test_fade_white_space.py new file mode 100644 index 000000000..3eb5bddcb --- /dev/null +++ b/tests/test_fade_white_space.py @@ -0,0 +1,180 @@ +"""A camera RAW's fade white: tagged with the decode space it was measured in, and the zero point the +restoration scales density from.""" + +from dataclasses import replace +from types import SimpleNamespace + +import numpy as np +import pytest + +from negpy.desktop.controller import AppController +from negpy.domain.interfaces import PipelineContext +from negpy.domain.models import WorkspaceConfig +from negpy.features.exposure.normalization import prefilter_log_grid, unmix_log_image +from negpy.features.process.capture_color import source_cam_xyz +from negpy.features.process.fade import ( + EstimateMethod, + estimate_fade, + fade_white_space, + fade_white_stale, + fade_white_target, + resolve_fade_matrix, + unmix_with_fade, +) +from negpy.features.process.models import ProcessConfig, ProcessMode +from negpy.features.transparency.processor import TransparencyBaseProcessor +from negpy.infrastructure.gpu.device import GPUDevice +from tests.test_fade_estimation import _synthetic_neutral_slide + +_CAMERA = [[0.6722, -0.0635, -0.0963], [-0.4287, 1.246, 0.2046], [-0.0908, 0.2268, 0.5762]] +_WB = [2.0, 1.0, 1.5] +_SPACE = fade_white_space(_CAMERA, _WB) +_FADE = dict(fade_ratio_g=0.8, fade_ratio_b=0.6) +# Clear film two stops under the decoder's white, as a camera captures a slide. +_RAW_WHITE = (-0.6, -0.55, -0.65) + + +def _slide(**kw) -> ProcessConfig: + return ProcessConfig(process_mode=ProcessMode.E6, **kw) + + +def _white(white=_RAW_WHITE, space=_SPACE) -> dict: + return {"fade_white_r": white[0], "fade_white_g": white[1], "fade_white_b": white[2], "fade_white_space": space} + + +def test_the_space_names_a_camera_and_its_white_balance(): + assert _SPACE and _SPACE == fade_white_space(_CAMERA, list(_WB)) + assert fade_white_space(_CAMERA, [1.8, 1.0, 1.5]) != _SPACE + assert fade_white_space(_CAMERA, None) != _SPACE + assert fade_white_space(None, _WB) == "" + assert fade_white_space(source_cam_xyz(np.eye(3)), None) == "" + + +def test_a_camera_raw_scales_density_above_its_white_and_keeps_the_white(): + process = _slide(**_FADE, **_white()) + matrix = unmix_with_fade(process, ProcessMode.E6, _SPACE) + density = np.array([-0.3, -0.24, -0.18]) + pixel = (np.asarray(_RAW_WHITE) + density)[None, None, :].astype(np.float32) + expected = np.asarray(_RAW_WHITE) + resolve_fade_matrix(1.0, 0.8, 0.6) @ density + np.testing.assert_allclose(unmix_log_image(pixel, matrix)[0, 0], expected, atol=1e-6) + white = np.asarray(_RAW_WHITE, dtype=np.float32)[None, None, :] + np.testing.assert_allclose(unmix_log_image(white, matrix)[0, 0], _RAW_WHITE, atol=1e-6) + assert fade_white_target(process, _SPACE) is None + + +def test_the_render_is_the_same_at_any_exposure(): + """A stop more light moves every channel's log by the same amount, white included.""" + process = _slide(**_FADE, **_white()) + shifted = _slide(**_FADE, **_white(tuple(w + 0.3 for w in _RAW_WHITE))) + pixel = np.array([[[-0.9, -0.8, -0.75]]], dtype=np.float32) + out = unmix_log_image(pixel, unmix_with_fade(process, ProcessMode.E6, _SPACE)) + brighter = unmix_log_image(pixel + 0.3, unmix_with_fade(shifted, ProcessMode.E6, _SPACE)) + np.testing.assert_allclose(brighter - out, 0.3, atol=1e-6) + + +def test_remove_stain_still_maps_the_white_to_neutral(): + process = _slide(**_FADE, fade_white_anchor=True, **_white()) + white = np.asarray(_RAW_WHITE, dtype=np.float32)[None, None, :] + np.testing.assert_allclose(unmix_log_image(white, unmix_with_fade(process, ProcessMode.E6, _SPACE))[0, 0], -0.55, atol=1e-6) + assert fade_white_target(process, _SPACE) == pytest.approx(-0.55) + + +@pytest.mark.parametrize("stored, render", [("", _SPACE), (_SPACE, ""), (_SPACE, fade_white_space(_CAMERA, None))]) +def test_a_white_from_another_space_does_not_apply(stored, render): + anchored = _slide(**_FADE, fade_white_anchor=True, **_white(space=stored)) + np.testing.assert_array_equal(unmix_with_fade(anchored, ProcessMode.E6, render), resolve_fade_matrix(1.0, 0.8, 0.6)) + assert fade_white_target(anchored, render) is None + + +def test_a_file_without_a_camera_matrix_keeps_the_full_scale_zero(): + """A JPEG or scanner TIFF has its white at full scale, so only Remove Stain moves the zero.""" + process = _slide(**_FADE, **_white(space="")) + np.testing.assert_array_equal(unmix_with_fade(process, ProcessMode.E6, ""), resolve_fade_matrix(1.0, 0.8, 0.6)) + + +def test_the_white_goes_stale_only_where_it_acts(): + assert fade_white_stale(_slide(**_FADE), _SPACE) + assert fade_white_stale(_slide(fade_white_anchor=True), _SPACE) + assert fade_white_stale(_slide(**_FADE, **_white(space="old")), _SPACE) + assert not fade_white_stale(_slide(**_FADE, **_white()), _SPACE) + assert not fade_white_stale(_slide(), _SPACE) + assert not fade_white_stale(_slide(**_FADE), "") + assert not fade_white_stale(ProcessConfig(process_mode=ProcessMode.C41, **_FADE), _SPACE) + + +def test_a_camera_raw_estimate_ignores_the_exposure(): + grid = prefilter_log_grid(_synthetic_neutral_slide(0.8, 0.6), None, 0.0) + under = grid - np.float32(0.3) + for method in EstimateMethod: + a = estimate_fade(grid, camera_raw=True)[method] + b = estimate_fade(under, camera_raw=True)[method] + assert (a.green, a.blue) == pytest.approx((b.green, b.blue), abs=1e-3) + + +def test_the_processor_reads_the_space_from_the_capture(): + """The CPU render tags the white with the context's camera, so a stale white falls back to no zero shift.""" + img = np.random.default_rng(1).uniform(0.05, 0.25, (32, 48, 3)).astype(np.float32) + + def restored(process): + ctx = PipelineContext( + original_size=img.shape[:2], scale_factor=1.0, process_mode=ProcessMode.E6, cam_xyz=_CAMERA, camera_wb=_WB, wants_uv_grid=False + ) + TransparencyBaseProcessor(process).process(img, ctx) + return ctx.metrics["normalized_log"] + + plain = restored(_slide(linear_raw=True, **_FADE)) + stale = restored(_slide(linear_raw=True, **_FADE, **_white(space="old"))) + tagged = restored(_slide(linear_raw=True, **_FADE, **_white())) + np.testing.assert_array_equal(stale, plain) + assert not np.allclose(tagged, plain) + + +def test_a_stale_white_is_measured_again_without_a_roll_lock(monkeypatch): + import negpy.desktop.workers.chart_fit as chart_fit + + grid = np.full((8, 8, 3), -0.7, dtype=np.float32) + grid[:2] = (-0.5, -0.45, -0.6) + monkeypatch.setattr(chart_fit, "fade_estimate_grid", lambda source: grid) + applied = [] + cfg = WorkspaceConfig() + fake = SimpleNamespace( + state=SimpleNamespace(config=replace(cfg, process=_slide(**_FADE))), + _effective_cam_xyz=lambda: (_CAMERA, _WB), + chart_fit_source=lambda: object(), + apply_config=lambda config, persist: applied.append((config.process, persist)), + ) + AppController._refresh_stale_fade_white(fake) + ((process, persist),) = applied + assert persist and process.fade_white_space == _SPACE + assert process.fade_white_r == pytest.approx(-0.5, abs=0.01) and process.fade_white_b == pytest.approx(-0.6, abs=0.01) + + +@pytest.mark.skipif(not GPUDevice.get().is_available, reason="GPU not available") +@pytest.mark.parametrize("anchor", [False, True]) +def test_gpu_matches_cpu_on_a_camera_raw(anchor): + from negpy.services.rendering.image_processor import ImageProcessor + + processor = ImageProcessor() + if processor.engine_gpu is None: + pytest.skip("GPU engine not initialised") + cfg = WorkspaceConfig() + settings = replace(cfg, process=_slide(linear_raw=True, fade_white_anchor=anchor, **_FADE, **_white())) + img = np.random.default_rng(2).uniform(0.03, 0.25, (96, 128, 3)).astype(np.float32) + + def render(prefer_gpu): + out, _ = processor.run_pipeline( + img.copy(), + settings, + "raw-white-parity", + render_size_ref=128.0, + prefer_gpu=prefer_gpu, + readback_metrics=False, + cam_xyz=_CAMERA, + camera_wb=_WB, + ) + arr = out.readback() if hasattr(out, "readback") else out + return np.asarray(arr)[:, :, :3].astype(np.float64) + + cpu, gpu = render(False), render(True) + assert float(np.mean(np.abs(cpu - gpu))) < 0.005 + assert float(np.max(np.abs(cpu - gpu))) < 0.04 diff --git a/tests/test_interactive_decoupling.py b/tests/test_interactive_decoupling.py index ecc129a73..4a15729bd 100644 --- a/tests/test_interactive_decoupling.py +++ b/tests/test_interactive_decoupling.py @@ -85,6 +85,7 @@ def _render_finished_stub(self): _gpu_fallback_notified=True, _freeze_resolved_auto_crop=MagicMock(), _freeze_resolved_meter=MagicMock(), + _refresh_stale_fade_white=MagicMock(), state=SimpleNamespace( config=object(), metrics_lock=MagicMock(__enter__=lambda s: None, __exit__=lambda s, *a: None), diff --git a/tests/test_navigate_back_memo.py b/tests/test_navigate_back_memo.py index 217e3bfcf..584a0fb0b 100644 --- a/tests/test_navigate_back_memo.py +++ b/tests/test_navigate_back_memo.py @@ -41,6 +41,7 @@ def _stub(memo, **overrides): _gpu_fallback_notified=True, _freeze_resolved_auto_crop=MagicMock(), _freeze_resolved_meter=MagicMock(), + _refresh_stale_fade_white=MagicMock(), state=SimpleNamespace( config=object(), metrics_lock=MagicMock(__enter__=lambda s: None, __exit__=lambda s, *a: None), diff --git a/tests/thumbnail_fingerprint_defaults.json b/tests/thumbnail_fingerprint_defaults.json index 2d37dcedf..e1cf01ebf 100644 --- a/tests/thumbnail_fingerprint_defaults.json +++ b/tests/thumbnail_fingerprint_defaults.json @@ -154,6 +154,14 @@ "crosstalk_strength": "0.0", "demosaic_export": "\"Auto\"", "demosaic_preview": "\"Auto\"", + "fade_ratio_b": "1.0", + "fade_ratio_g": "1.0", + "fade_ratio_r": "1.0", + "fade_white_anchor": "false", + "fade_white_b": "0.0", + "fade_white_g": "0.0", + "fade_white_r": "0.0", + "fade_white_space": "\"\"", "film_base": "null", "highlight_reconstruction": "0", "hue_trim": "0.0", From 979dcac823bfc9e1db6a02492d999835e900e3cb Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:13:29 +0000 Subject: [PATCH 05/46] fix(fade): name the white's channel space in its tag On top of the camera-matrix split, a camera RAW's fade white is read in the white-balanced camera channels, not in working space. The fade_white_space tag hashed only the camera matrix and white balance, so a white read in working space on an earlier build matched the tag and applied in the wrong channels. The hash now also holds a channel marker. A white with an older tag reads as stale, and the desktop measures it again on the open frame. --- docs/PIPELINE.md | 2 +- negpy/features/process/fade.py | 14 ++++++++++---- tests/test_raw_density_order.py | 20 +++++++++++++++++--- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 810aeb788..716eb3b96 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -65,7 +65,7 @@ Pixel values are linear transmittance. The source corrections run in that domain White Anchor (`fade_white_anchor`, UI "Remove Stain") makes the matrix affine, $M(L - w) + t$. The stain $w$ is each channel's `WHITE_PCT` cell of the meter grid in log transmittance, without decoded zeros and cells at full scale in every channel; Estimate stores it as `fade_white_r/g/b`. It maps to the neutral $t = \min(\max_c w_c, -0.1)$ (`FADE_WHITE_HEADROOM`): the cleanest channel's own level, never within $0.1$ D of full scale. $F^{-1}$ acts on $L - w$ before $t$ is added, so the Estimate reads the grays on $\min(L - w, 0)$. The offset rides in the w lanes of the unmix rows on the GPU. - A camera RAW's clear film sits below the decoder's white by the exposure and the light, so $F^{-1}$ about $L = 0$ would scale that offset into a cast. On a RAW the matrix is always $M(L - w) + w$, and Remove Stain changes only $t$; Estimate stores $w$ with White Anchor off too and reads the grays on $\min(L - w, 0)$. A JPEG or scanner TIFF has its white at full scale and keeps $L = 0$. `fade_white_space` tags $w$ with the camera matrix and as-shot white balance it was read through, a hash of `camera_to_working_matrix(cam_xyz, camera_wb)`, "" with no camera matrix. A render whose tag differs ignores $w$, and the desktop measures it again on the open frame (`_refresh_stale_fade_white`), with no roll lock. + A camera RAW's clear film sits below the decoder's white by the exposure and the light, so $F^{-1}$ about $L = 0$ would scale that offset into a cast. On a RAW the matrix is always $M(L - w) + w$, and Remove Stain changes only $t$; Estimate stores $w$ with White Anchor off too and reads the grays on $\min(L - w, 0)$. A JPEG or scanner TIFF has its white at full scale and keeps $L = 0$. `fade_white_space` tags $w$ with the space it was read in: the white-balanced camera channels, named by a hash of `camera_to_working_matrix(cam_xyz, camera_wb)` and a channel marker, "" with no camera matrix. A render whose tag differs ignores $w$, and the desktop measures it again on the open frame (`_refresh_stale_fade_white`), with no roll lock. * **Density above base**: `density_above_base` is (Dmin, Dmed, Dmax) above the clear base, luminance-weighted: the thin and dense bounds (before trims; the roll's under Roll Analysis) and the metered median, each minus the base. The base is the picked one, else the rebate reader's, unmixed like the meters. A pick is the open roll's (`rolls.roll_film_base`), or the frame's own with no roll open (`process.film_base`, unhashed); in an open roll the roll's wins, with none open the frame's. The preview carries it as `film_base`; export never reads it. The reader takes the flat 8-px blocks (std over mean under 0.03, unclipped) in the outer 15% of the film box (`film_and_picture` at `AUTOCROP_DETECT_RES`), groups them by brightness with a 0.04-decade gap, and takes the largest group of at least five. It finds none without a film box, with a base darker than the picture, or on the transfer path. The preview reads it once per source, on the flat-fielded, unmixed source before the defect repairs, and passes the read to both engines. * **Scan-clip warning**: `scan_clip_fractions` reports the per-channel fraction of source pixels at or above $0.99$ linear. On a negative this clips film base and shadows to $D=0$; nothing reconstructs it. diff --git a/negpy/features/process/fade.py b/negpy/features/process/fade.py index 0cbf21818..7d07b5bea 100644 --- a/negpy/features/process/fade.py +++ b/negpy/features/process/fade.py @@ -67,15 +67,21 @@ def fade_matrix(process: ProcessConfig) -> Optional[np.ndarray]: def fade_white_space(cam_xyz: Optional[Sequence[Sequence[float]]], camera_wb: Optional[Sequence[float]]) -> str: - """The space a camera RAW's log white is measured in: its camera matrix with the as-shot white - balance, which every RAW decode applies at the decode or in the matrix. "" for a source with no - camera matrix, whose white is the file's own full scale.""" + """The space a camera RAW's log white is measured in: its white-balanced camera channels + (`density_channels`), named by its camera matrix with the as-shot white balance, which every RAW + decode applies at the decode or in the matrix. "" for a source with no camera matrix, whose white + is the file's own full scale.""" if cam_xyz is None or is_source_profile(cam_xyz): return "" matrix = camera_to_working_matrix(cam_xyz, camera_wb) if matrix is None: return "" - return hashlib.sha1(np.round(np.asarray(matrix, dtype=np.float64), 4).tobytes()).hexdigest()[:12] + return hashlib.sha1(_WHITE_SPACE_CHANNELS + np.round(np.asarray(matrix, dtype=np.float64), 4).tobytes()).hexdigest()[:12] + + +# Names the channels in `fade_white_space`, so a white read in working space (the camera matrix +# before the log) reads as stale and is measured again. +_WHITE_SPACE_CHANNELS = b"camera-channels" def unmix_with_fade(process: ProcessConfig, process_mode: Optional[str], white_space: str = "") -> Optional[np.ndarray]: diff --git a/tests/test_raw_density_order.py b/tests/test_raw_density_order.py index 26dff52d6..d9a259add 100644 --- a/tests/test_raw_density_order.py +++ b/tests/test_raw_density_order.py @@ -14,7 +14,7 @@ camera_to_working_matrix, wb_only_cam_xyz, ) -from negpy.features.process.fade import measure_white, unmix_with_fade +from negpy.features.process.fade import fade_white_space, measure_white, unmix_with_fade from negpy.features.process.models import ProcessConfig, ProcessMode from negpy.features.transparency.logic import density_stage from negpy.features.transparency.processor import TransparencyBaseProcessor @@ -121,11 +121,13 @@ def test_remove_stain_leaves_a_white_border_neutral_and_converts_after(positive) white = measure_white(ctx.metrics["transfer_grid"]) assert white is not None np.testing.assert_allclose(white, np.log10(0.8), atol=1e-4) - config = _slide(positive, fade_white_anchor=True, fade_white_r=white[0], fade_white_g=white[1], fade_white_b=white[2], **_FADE) + space = fade_white_space(_CAMERA, _WB) + stored = dict(fade_white_r=white[0], fade_white_g=white[1], fade_white_b=white[2], fade_white_space=space) + config = _slide(positive, fade_white_anchor=True, **stored, **_FADE) _, ctx = _render(config, img) restored = _restored_log(ctx) np.testing.assert_allclose(restored[:5].reshape(-1, 3), -0.1, atol=1e-4) - unmix = unmix_with_fade(config, ProcessMode.E6) + unmix = unmix_with_fade(config, ProcessMode.E6, space) expected = relog_matrix_image(unmix_log_image(np.log10(balanced), unmix), camera_matrix_parts(_CAMERA, _WB)[1]) np.testing.assert_allclose(restored, expected, atol=1e-4) converted_first = unmix_log_image(np.log10(img @ camera_to_working_matrix(_CAMERA, _WB).T), unmix) @@ -170,3 +172,15 @@ def test_a_render_stores_the_fade_grid_only_on_request(): img = _unbalanced(np.full((40, 60, 3), 0.5, np.float32)) _, ctx = _render(_slide(False), img) assert "transfer_grid" not in ctx.metrics + + +def test_a_white_read_in_working_space_reads_as_stale(): + """A tag that hashes the camera matrix alone named a white read after the matrix, in working space.""" + import hashlib + + from negpy.features.process.fade import fade_white_stale + + working = hashlib.sha1(np.round(np.asarray(camera_to_working_matrix(_CAMERA, _WB), dtype=np.float64), 4).tobytes()).hexdigest()[:12] + config = _slide(False, fade_white_r=-0.5, fade_white_g=-0.4, fade_white_b=-0.6, fade_white_space=working, **_FADE) + assert working != fade_white_space(_CAMERA, _WB) + assert fade_white_stale(config, fade_white_space(_CAMERA, _WB)) From 3e1decb7337ff33b5f08b1b25ddafed73244386a Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:43:36 +0000 Subject: [PATCH 06/46] fix(fade): scale a camera RAW's density about a neutral level at its white A slide's rebate is black, so a frame with no clear film in the picture has no true white. The measured white is then the brightest picture color in each channel, for example a blue sky. With Remove Stain off, the render added that white back with its color, while Estimate had made the grays neutral relative to it. Every gray took the white's color: on a synthetic faded slide the cast was +0.33 D in R-G and +0.50 D in B-G. With Remove Stain off, a camera RAW now scales density about the neutral level of the white's cleanest channel (density_anchor), and Estimate reads the grays against the same level. The grays stay within 0.03 D of neutral at any exposure, with or without clear film, on Positive and Transfer. Remove Stain on does not change. The automatic re-measure of a stale white records no undo step. Before, an undo back to the stale white measured it again and dropped the redo branch. --- docs/PIPELINE.md | 2 +- negpy/desktop/controller.py | 4 ++- negpy/features/process/fade.py | 28 +++++++++++++++------ tests/test_fade_white_space.py | 45 +++++++++++++++++++++++++++------- 4 files changed, 60 insertions(+), 19 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 716eb3b96..8e91a6abf 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -65,7 +65,7 @@ Pixel values are linear transmittance. The source corrections run in that domain White Anchor (`fade_white_anchor`, UI "Remove Stain") makes the matrix affine, $M(L - w) + t$. The stain $w$ is each channel's `WHITE_PCT` cell of the meter grid in log transmittance, without decoded zeros and cells at full scale in every channel; Estimate stores it as `fade_white_r/g/b`. It maps to the neutral $t = \min(\max_c w_c, -0.1)$ (`FADE_WHITE_HEADROOM`): the cleanest channel's own level, never within $0.1$ D of full scale. $F^{-1}$ acts on $L - w$ before $t$ is added, so the Estimate reads the grays on $\min(L - w, 0)$. The offset rides in the w lanes of the unmix rows on the GPU. - A camera RAW's clear film sits below the decoder's white by the exposure and the light, so $F^{-1}$ about $L = 0$ would scale that offset into a cast. On a RAW the matrix is always $M(L - w) + w$, and Remove Stain changes only $t$; Estimate stores $w$ with White Anchor off too and reads the grays on $\min(L - w, 0)$. A JPEG or scanner TIFF has its white at full scale and keeps $L = 0$. `fade_white_space` tags $w$ with the space it was read in: the white-balanced camera channels, named by a hash of `camera_to_working_matrix(cam_xyz, camera_wb)` and a channel marker, "" with no camera matrix. A render whose tag differs ignores $w$, and the desktop measures it again on the open frame (`_refresh_stale_fade_white`), with no roll lock. + A camera RAW's clear film sits below the decoder's white by the exposure and the light, so $F^{-1}$ about $L = 0$ would scale that offset into a cast. On a RAW the matrix is always $M(L - a) + t$ (`density_anchor`). Remove Stain sets $a = w$ and the neutral $t$ above. Without it, $a = t$ is the neutral level $\max_c w_c$, so the white keeps its color: a slide's rebate is black, and with no clear film in the picture $w$ is the brightest picture color, which a per-channel anchor would add to every gray. Estimate stores $w$ with White Anchor off too and reads the grays on $\min(L - a, 0)$. A JPEG or scanner TIFF has its white at full scale and keeps $L = 0$. `fade_white_space` tags $w$ with the space it was read in: the white-balanced camera channels, named by a hash of `camera_to_working_matrix(cam_xyz, camera_wb)` and a channel marker, "" with no camera matrix. A render whose tag differs ignores $w$, and the desktop measures it again on the open frame (`_refresh_stale_fade_white`), with no roll lock. * **Density above base**: `density_above_base` is (Dmin, Dmed, Dmax) above the clear base, luminance-weighted: the thin and dense bounds (before trims; the roll's under Roll Analysis) and the metered median, each minus the base. The base is the picked one, else the rebate reader's, unmixed like the meters. A pick is the open roll's (`rolls.roll_film_base`), or the frame's own with no roll open (`process.film_base`, unhashed); in an open roll the roll's wins, with none open the frame's. The preview carries it as `film_base`; export never reads it. The reader takes the flat 8-px blocks (std over mean under 0.03, unclipped) in the outer 15% of the film box (`film_and_picture` at `AUTOCROP_DETECT_RES`), groups them by brightness with a 0.04-decade gap, and takes the largest group of at least five. It finds none without a film box, with a base darker than the picture, or on the transfer path. The preview reads it once per source, on the flat-fielded, unmixed source before the defect repairs, and passes the read to both engines. * **Scan-clip warning**: `scan_clip_fractions` reports the per-channel fraction of source pixels at or above $0.99$ linear. On a negative this clips film base and shadows to $D=0$; nothing reconstructs it. diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index c17ecbab7..7870215b1 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -9958,7 +9958,9 @@ def _refresh_stale_fade_white(self) -> None: return r, g, b = measure_white(grid) or (0.0, 0.0, 0.0) refreshed = replace(process, fade_white_r=r, fade_white_g=g, fade_white_b=b, fade_white_space=space) - self.apply_config(replace(self.state.config, process=refreshed), persist=True) + # No undo step: undoing to the stale white would measure it again and drop the redo branch. + self.session.update_config(replace(self.state.config, process=refreshed), persist=True, record_history=False) + self.request_render() def _freeze_resolved_auto_crop(self, metrics: Dict[str, Any]) -> None: """Store the crop this render detected, so nothing detects it a second time. diff --git a/negpy/features/process/fade.py b/negpy/features/process/fade.py index 7d07b5bea..26d6c838e 100644 --- a/negpy/features/process/fade.py +++ b/negpy/features/process/fade.py @@ -87,9 +87,10 @@ def fade_white_space(cam_xyz: Optional[Sequence[Sequence[float]]], camera_wb: Op def unmix_with_fade(process: ProcessConfig, process_mode: Optional[str], white_space: str = "") -> Optional[np.ndarray]: """The log-density matrix a render applies: the crosstalk unmix, then the fade restoration of the densities it recovers (`fade @ unmix`). None when neither applies. *white_space* is the - render's `fade_white_space`. A stored white makes it a (3, 4) affine `M (L - w) + t`, the last - column the log offset `t - M w`: Remove Stain maps the white to a neutral t; on a camera RAW, - whose white sits below full scale, t = w keeps the white and scales density above it.""" + render's `fade_white_space`. A stored white makes it a (3, 4) affine `M (L - a) + t`, the last + column the log offset `t - M a`. Remove Stain maps each channel's white a = w to a neutral t. On a + camera RAW, whose white sits below full scale, Remove Stain off scales density above the neutral + level a = t of the white's cleanest channel (`density_anchor`), so the white keeps its color.""" unmix = effective_crosstalk_matrix(process, process_mode) fade = fade_matrix(process) matrix = unmix if fade is None else (fade if unmix is None else fade @ unmix) @@ -97,13 +98,23 @@ def unmix_with_fade(process: ProcessConfig, process_mode: Optional[str], white_s if white is None: return matrix if process.fade_white_anchor: - target = np.full(3, _white_target(white)) + anchor, target = white, np.full(3, _white_target(white)) elif white_space and matrix is not None: - target = white + anchor = target = np.asarray(density_anchor(white, False), dtype=np.float64) else: return matrix linear = np.eye(3) if matrix is None else matrix - return np.hstack([linear, (target - linear @ white)[:, None]]) + return np.hstack([linear, (target - linear @ anchor)[:, None]]) + + +def density_anchor(white: Sequence[float], remove_stain: bool) -> tuple[float, float, float]: + """The log point a camera RAW's fade scales density about: each channel's white with Remove Stain, + else the white's cleanest channel on all three. A neutral point keeps a gray neutral when the white + is a colored highlight, which is all a slide shows when it holds no clear film.""" + if remove_stain: + return (float(white[0]), float(white[1]), float(white[2])) + level = float(max(white)) + return (level, level, level) def _fade_white(process: ProcessConfig, white_space: str = "") -> Optional[np.ndarray]: @@ -275,12 +286,13 @@ def estimate_fade(grid: ImageBuffer, white_anchor: bool = False, camera_raw: boo meter's grid with the crosstalk unmix, no fade and no Remove Stain (`TransparencyBaseProcessor`). MIDTONE also sets Red Survival so the best-kept layer lands at 1.0; a ratio above 1 says red faded most. With `white_anchor`, or on a `camera_raw` whose render scales density above its - white (`unmix_with_fade`), the grays are read as density above the frame's white.""" + white (`unmix_with_fade`), the grays are read as density above the point the render scales about + (`density_anchor`).""" white = measure_white(grid) if white_anchor and white is None: return {m: FadeEstimate(None, 1.0, 1.0, tr("No clear area to measure the stain on this frame.")) for m in EstimateMethod} anchored = white is not None and (white_anchor or camera_raw) - refs, reason = neutral_axis_from_grid(white_anchored_grid(grid, white) if anchored else grid) + refs, reason = neutral_axis_from_grid(white_anchored_grid(grid, density_anchor(white, white_anchor)) if anchored else grid) out = {} for method in EstimateMethod: if refs is None: diff --git a/tests/test_fade_white_space.py b/tests/test_fade_white_space.py index 3eb5bddcb..a8350f4d5 100644 --- a/tests/test_fade_white_space.py +++ b/tests/test_fade_white_space.py @@ -50,18 +50,40 @@ def test_the_space_names_a_camera_and_its_white_balance(): assert fade_white_space(source_cam_xyz(np.eye(3)), None) == "" -def test_a_camera_raw_scales_density_above_its_white_and_keeps_the_white(): +def test_a_camera_raw_scales_density_above_a_neutral_level_at_its_white(): + """Without Remove Stain the anchor is the white's cleanest channel on all three, so the white keeps its color.""" process = _slide(**_FADE, **_white()) matrix = unmix_with_fade(process, ProcessMode.E6, _SPACE) + level = max(_RAW_WHITE) density = np.array([-0.3, -0.24, -0.18]) - pixel = (np.asarray(_RAW_WHITE) + density)[None, None, :].astype(np.float32) - expected = np.asarray(_RAW_WHITE) + resolve_fade_matrix(1.0, 0.8, 0.6) @ density + pixel = (level + density)[None, None, :].astype(np.float32) + expected = level + resolve_fade_matrix(1.0, 0.8, 0.6) @ density np.testing.assert_allclose(unmix_log_image(pixel, matrix)[0, 0], expected, atol=1e-6) - white = np.asarray(_RAW_WHITE, dtype=np.float32)[None, None, :] - np.testing.assert_allclose(unmix_log_image(white, matrix)[0, 0], _RAW_WHITE, atol=1e-6) + anchor = np.full((1, 1, 3), level, dtype=np.float32) + np.testing.assert_allclose(unmix_log_image(anchor, matrix)[0, 0], level, atol=1e-6) assert fade_white_target(process, _SPACE) is None +def test_estimate_then_render_keeps_grays_neutral_with_a_colored_white(): + """A slide with no clear film: its white is the brightest picture color, here a blue sky. The + survivals Estimate finds against the render's own anchor restore the grays to neutral.""" + rng = np.random.default_rng(3) + true_log = np.empty((120, 180, 3)) + true_log[:] = np.linspace(-2.2, -0.35, 180)[None, :, None] + true_log[:30, 20:150] = np.log10((0.15, 0.32, 0.55)) + true_log += rng.normal(0, 0.01, true_log.shape) + faded = true_log @ np.linalg.inv(resolve_fade_matrix(0.7, 1.4, 1.25)).T - 0.6 + grid = faded[30:].astype(np.float32) + whole = faded.astype(np.float32) + for remove_stain in (False, True): + est = estimate_fade(whole, white_anchor=remove_stain, camera_raw=True)[EstimateMethod.MIDTONE] + stored = _white(est.white) + process = _slide(fade_ratio_r=est.red, fade_ratio_g=est.green, fade_ratio_b=est.blue, fade_white_anchor=remove_stain, **stored) + out = unmix_log_image(grid, unmix_with_fade(process, ProcessMode.E6, _SPACE))[20:80, 5:175].reshape(-1, 3) + cast = out - out[:, 1:2] + assert np.all(np.abs(cast.mean(axis=0)) < 0.03), (remove_stain, cast.mean(axis=0)) + + def test_the_render_is_the_same_at_any_exposure(): """A stop more light moves every channel's log by the same amount, white included.""" process = _slide(**_FADE, **_white()) @@ -129,7 +151,7 @@ def restored(process): assert not np.allclose(tagged, plain) -def test_a_stale_white_is_measured_again_without_a_roll_lock(monkeypatch): +def test_a_stale_white_is_measured_again_without_a_roll_lock_or_an_undo_step(monkeypatch): import negpy.desktop.workers.chart_fit as chart_fit grid = np.full((8, 8, 3), -0.7, dtype=np.float32) @@ -141,11 +163,16 @@ def test_a_stale_white_is_measured_again_without_a_roll_lock(monkeypatch): state=SimpleNamespace(config=replace(cfg, process=_slide(**_FADE))), _effective_cam_xyz=lambda: (_CAMERA, _WB), chart_fit_source=lambda: object(), - apply_config=lambda config, persist: applied.append((config.process, persist)), + session=SimpleNamespace( + update_config=lambda config, persist, record_history: applied.append((config.process, persist, record_history)) + ), + request_render=lambda: rendered.append(True), ) + rendered = [] AppController._refresh_stale_fade_white(fake) - ((process, persist),) = applied - assert persist and process.fade_white_space == _SPACE + ((process, persist, record_history),) = applied + assert persist and not record_history and rendered + assert process.fade_white_space == _SPACE assert process.fade_white_r == pytest.approx(-0.5, abs=0.01) and process.fade_white_b == pytest.approx(-0.6, abs=0.01) From 18633ea93c401493cf4df6b1e1000b1baf76e234 Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:27:31 +0000 Subject: [PATCH 07/46] fix(fade): light past the film passes the fade as a neutral Highlight Reconstruction decodes a blown highlight as neutral at full scale. On a camera RAW the fade scales density about the frame's white, which sits below full scale, so the blown highlight sat above the anchor and the fade scaled it per channel. It turned pink with Remove Stain off and yellow with Remove Stain on. On a synthetic faded slide the highlight came out (1.00, 0.83, 1.00) and (1.00, 0.97, 0.73). Dyed film is not brighter than its own white, so a pixel above the anchor in every channel is light past the film. The fade now acts on each channel capped at the anchor (fade_anchor), and the amount the pixel is above the anchor in every channel is added after it, equal in each channel. The CPU uses the new negpy_core.unmix_log_anchored kernel, and the GPU uses an anchor row in the normalization shader. At Cast Removal 0, a reconstructed highlight now renders the same neutral white with and without the fade. Pixels below the anchor do not change. Estimate clears the stored white before it renders its grid, so the anchor does not cap the grid that a new white is read from. --- docs/PIPELINE.md | 2 +- negpy/desktop/workers/chart_fit.py | 12 +++++- negpy/features/exposure/normalization.py | 15 +++++-- .../exposure/shaders/normalization.wgsl | 15 +++++-- negpy/features/process/fade.py | 34 ++++++++++++---- negpy/features/transparency/logic.py | 19 +++++++-- negpy/features/transparency/processor.py | 14 ++++--- negpy/services/rendering/gpu_engine.py | 21 +++++++--- negpy_core/src/analysis.rs | 36 +++++++++++++++++ negpy_core/src/py/analysis.rs | 30 ++++++++++++++ tests/test_core_same_bits.py | 6 +++ tests/test_core_unmix.py | 39 +++++++++++++++++++ tests/test_fade_white_space.py | 39 +++++++++++++++++++ tests/test_pipeline_parity.py | 29 ++++++++++++++ 14 files changed, 278 insertions(+), 33 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 8e91a6abf..37d24388c 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -65,7 +65,7 @@ Pixel values are linear transmittance. The source corrections run in that domain White Anchor (`fade_white_anchor`, UI "Remove Stain") makes the matrix affine, $M(L - w) + t$. The stain $w$ is each channel's `WHITE_PCT` cell of the meter grid in log transmittance, without decoded zeros and cells at full scale in every channel; Estimate stores it as `fade_white_r/g/b`. It maps to the neutral $t = \min(\max_c w_c, -0.1)$ (`FADE_WHITE_HEADROOM`): the cleanest channel's own level, never within $0.1$ D of full scale. $F^{-1}$ acts on $L - w$ before $t$ is added, so the Estimate reads the grays on $\min(L - w, 0)$. The offset rides in the w lanes of the unmix rows on the GPU. - A camera RAW's clear film sits below the decoder's white by the exposure and the light, so $F^{-1}$ about $L = 0$ would scale that offset into a cast. On a RAW the matrix is always $M(L - a) + t$ (`density_anchor`). Remove Stain sets $a = w$ and the neutral $t$ above. Without it, $a = t$ is the neutral level $\max_c w_c$, so the white keeps its color: a slide's rebate is black, and with no clear film in the picture $w$ is the brightest picture color, which a per-channel anchor would add to every gray. Estimate stores $w$ with White Anchor off too and reads the grays on $\min(L - a, 0)$. A JPEG or scanner TIFF has its white at full scale and keeps $L = 0$. `fade_white_space` tags $w$ with the space it was read in: the white-balanced camera channels, named by a hash of `camera_to_working_matrix(cam_xyz, camera_wb)` and a channel marker, "" with no camera matrix. A render whose tag differs ignores $w$, and the desktop measures it again on the open frame (`_refresh_stale_fade_white`), with no roll lock. + A camera RAW's clear film sits below the decoder's white by the exposure and the light, so $F^{-1}$ about $L = 0$ would scale that offset into a cast. On a RAW the matrix is always $M(L - a) + t$ (`density_anchor`). Remove Stain sets $a = w$ and the neutral $t$ above. Without it, $a = t$ is the neutral level $\max_c w_c$, so the white keeps its color: a slide's rebate is black, and with no clear film in the picture $w$ is the brightest picture color, which a per-channel anchor would add to every gray. Estimate stores $w$ with White Anchor off too and reads the grays on $\min(L - a, 0)$. A pixel above $a$ in every channel is light past the film, such as a highlight that Highlight Reconstruction decodes neutral at full scale. The matrix acts on $\min(L, a)$, and $e = \max(\min_c(L_c - a_c), 0)$ is added to every channel after it (`fade_anchor`, `unmix_log_anchored`, the shader's `anchor` row), so that light passes as a neutral. Estimate reads its grid with the stored white cleared. A JPEG or scanner TIFF has its white at full scale and keeps $L = 0$. `fade_white_space` tags $w$ with the space it was read in: the white-balanced camera channels, named by a hash of `camera_to_working_matrix(cam_xyz, camera_wb)` and a channel marker, "" with no camera matrix. A render whose tag differs ignores $w$, and the desktop measures it again on the open frame (`_refresh_stale_fade_white`), with no roll lock. * **Density above base**: `density_above_base` is (Dmin, Dmed, Dmax) above the clear base, luminance-weighted: the thin and dense bounds (before trims; the roll's under Roll Analysis) and the metered median, each minus the base. The base is the picked one, else the rebate reader's, unmixed like the meters. A pick is the open roll's (`rolls.roll_film_base`), or the frame's own with no roll open (`process.film_base`, unhashed); in an open roll the roll's wins, with none open the frame's. The preview carries it as `film_base`; export never reads it. The reader takes the flat 8-px blocks (std over mean under 0.03, unclipped) in the outer 15% of the film box (`film_and_picture` at `AUTOCROP_DETECT_RES`), groups them by brightness with a 0.04-decade gap, and takes the largest group of at least five. It finds none without a film box, with a base darker than the picture, or on the transfer path. The preview reads it once per source, on the flat-fielded, unmixed source before the defect repairs, and passes the read to both engines. * **Scan-clip warning**: `scan_clip_fractions` reports the per-channel fraction of source pixels at or above $0.99$ linear. On a negative this clips film base and shadows to $D=0$; nothing reconstructs it. diff --git a/negpy/desktop/workers/chart_fit.py b/negpy/desktop/workers/chart_fit.py index 0b93119d9..93b43e476 100644 --- a/negpy/desktop/workers/chart_fit.py +++ b/negpy/desktop/workers/chart_fit.py @@ -118,7 +118,17 @@ def run(self) -> None: def fade_estimate_grid(source: ChartSource) -> Optional[np.ndarray]: """The transparency meter's grid for the open frame, rendered on the CPU with the fade at identity and Remove Stain off, so Estimate never reads its own correction. None on a negative.""" - process = replace(source.config.process, fade_ratio_r=1.0, fade_ratio_g=1.0, fade_ratio_b=1.0, fade_white_anchor=False) + # The stored white is zeroed too: on a camera RAW it would cap the grid the new white is read from. + process = replace( + source.config.process, + fade_ratio_r=1.0, + fade_ratio_g=1.0, + fade_ratio_b=1.0, + fade_white_anchor=False, + fade_white_r=0.0, + fade_white_g=0.0, + fade_white_b=0.0, + ) buf = np.asarray(source.buffer, dtype=np.float32) _, metrics = ImageProcessor(use_gpu=False).run_pipeline( buf, diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index ab06d912c..4b1783d9c 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional, TYPE_CHECKING, Tuple +from typing import Any, Dict, Optional, Sequence, TYPE_CHECKING, Tuple import negpy_core import numpy as np @@ -190,13 +190,20 @@ def resolve_crosstalk_matrix(strength: float, matrix: Optional[tuple]) -> Option return applied / np.maximum(row_sums, 1e-6) -def unmix_log_image(img_log: ImageBuffer, matrix: Optional[np.ndarray]) -> ImageBuffer: +def unmix_log_image(img_log: ImageBuffer, matrix: Optional[np.ndarray], anchor: Optional[Sequence[float]] = None) -> ImageBuffer: """Apply the unmix matrix to a (H, W, 3) log-density image; identity when None. A (3, 4) - matrix is an affine: its last column is added after the 3x3, as normalization.wgsl does.""" + matrix is an affine: its last column is added after the 3x3, as normalization.wgsl does. + With an *anchor* (`fade.fade_anchor`), each channel is capped at it before the 3x3, and the + amount the pixel is above it in every channel passes through as a neutral.""" if matrix is None: return img_log m = np.ascontiguousarray(matrix, dtype=np.float32) - out = negpy_core.unmix_log(np.ascontiguousarray(img_log, dtype=np.float32), np.ascontiguousarray(m[:, :3])) + img = np.ascontiguousarray(img_log, dtype=np.float32) + lin = np.ascontiguousarray(m[:, :3]) + if anchor is None: + out = negpy_core.unmix_log(img, lin) + else: + out = negpy_core.unmix_log_anchored(img, lin, [float(v) for v in anchor]) # A (3, 4) matrix is Remove Stain's affine: the last column is a log offset added after the 3x3. return out + m[:, 3] if m.shape[1] == 4 else out diff --git a/negpy/features/exposure/shaders/normalization.wgsl b/negpy/features/exposure/shaders/normalization.wgsl index 52f4243eb..a6d044779 100644 --- a/negpy/features/exposure/shaders/normalization.wgsl +++ b/negpy/features/exposure/shaders/normalization.wgsl @@ -26,6 +26,9 @@ struct NormUniforms { post0: vec4, post1: vec4, post2: vec4, + // The fade's anchor (fade_anchor), on when w is 1.0: each channel is capped at it before the unmix, + // and the amount the pixel is above it in every channel is added after, as a neutral. + anchor: vec4, }; @group(0) @binding(0) var input_tex: texture_2d; @@ -64,10 +67,16 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // honours Hue Trim. The CPU gates it by film process and packs identity rows when it // does not apply, so there is nothing to branch on here (and branching here is // exactly how this silently stopped working on the GPU once before). + var excess = 0.0; + if (params.anchor.w > 0.5) { + let over = log_color - params.anchor.xyz; + excess = max(min(over.x, min(over.y, over.z)), 0.0); + log_color = min(log_color, params.anchor.xyz); + } log_color = vec3( - dot(params.unmix0.xyz, log_color) + params.unmix0.w, - dot(params.unmix1.xyz, log_color) + params.unmix1.w, - dot(params.unmix2.xyz, log_color) + params.unmix2.w, + dot(params.unmix0.xyz, log_color) + excess + params.unmix0.w, + dot(params.unmix1.xyz, log_color) + excess + params.unmix1.w, + dot(params.unmix2.xyz, log_color) + excess + params.unmix2.w, ); if (params.post0.w > 0.5) { let unmixed = pow(vec3(10.0), log_color); diff --git a/negpy/features/process/fade.py b/negpy/features/process/fade.py index 26d6c838e..165913e6b 100644 --- a/negpy/features/process/fade.py +++ b/negpy/features/process/fade.py @@ -94,19 +94,37 @@ def unmix_with_fade(process: ProcessConfig, process_mode: Optional[str], white_s unmix = effective_crosstalk_matrix(process, process_mode) fade = fade_matrix(process) matrix = unmix if fade is None else (fade if unmix is None else fade @ unmix) - white = _fade_white(process, white_space) - if white is None: - return matrix - if process.fade_white_anchor: - anchor, target = white, np.full(3, _white_target(white)) - elif white_space and matrix is not None: - anchor = target = np.asarray(density_anchor(white, False), dtype=np.float64) - else: + terms = _white_terms(process, white_space, matrix is not None) + if terms is None: return matrix + anchor, target = terms linear = np.eye(3) if matrix is None else matrix return np.hstack([linear, (target - linear @ anchor)[:, None]]) +def fade_anchor(process: ProcessConfig, process_mode: Optional[str], white_space: str = "") -> Optional[tuple[float, float, float]]: + """The log point `unmix_with_fade` scales density about, when it has one. A pixel above it in every + channel is light past the film, which has no dye to restore: `unmix_log_image` passes that part + through as a neutral, so a blown highlight stays white.""" + acts = effective_crosstalk_matrix(process, process_mode) is not None or fade_matrix(process) is not None + terms = _white_terms(process, white_space, acts) + return None if terms is None else (float(terms[0][0]), float(terms[0][1]), float(terms[0][2])) + + +def _white_terms(process: ProcessConfig, white_space: str, density_acts: bool) -> Optional[tuple[np.ndarray, np.ndarray]]: + """(anchor, target) of the stored white's affine, or None when no white acts. *density_acts*: a + crosstalk or fade matrix applies.""" + white = _fade_white(process, white_space) + if white is None: + return None + if process.fade_white_anchor: + return white, np.full(3, _white_target(white)) + if white_space and density_acts: + level = np.asarray(density_anchor(white, False), dtype=np.float64) + return level, level + return None + + def density_anchor(white: Sequence[float], remove_stain: bool) -> tuple[float, float, float]: """The log point a camera RAW's fade scales density about: each channel's white with Remove Stain, else the white's cleanest channel on all three. A neutral point keeps a gray neutral when the white diff --git a/negpy/features/transparency/logic.py b/negpy/features/transparency/logic.py index a70235345..2a316e857 100644 --- a/negpy/features/transparency/logic.py +++ b/negpy/features/transparency/logic.py @@ -489,6 +489,7 @@ class DensityStage(NamedTuple): pre: Optional[np.ndarray] # linear 3x3 before the log unmix: Optional[np.ndarray] # log-density matrix, (3, 3) or affine (3, 4) post: Optional[np.ndarray] # linear 3x3 after the log-density matrix + anchor: Optional[tuple[float, float, float]] = None # `fade_anchor` in the stage's input log def _whole_matrix(process: ProcessConfig, cam_xyz: Optional[list], camera_wb: Optional[list]) -> Optional[np.ndarray]: @@ -531,17 +532,27 @@ def density_channels(process: ProcessConfig, cam_xyz: Optional[list], camera_wb: return DensityStage(None, _with_log_offset(None, offset), primaries) -def density_stage(process: ProcessConfig, cam_xyz: Optional[list], camera_wb: Optional[list], unmix: Optional[np.ndarray]) -> DensityStage: +def density_stage( + process: ProcessConfig, + cam_xyz: Optional[list], + camera_wb: Optional[list], + unmix: Optional[np.ndarray], + anchor: Optional[tuple[float, float, float]] = None, +) -> DensityStage: """The stage a render applies. With a log-density matrix *unmix* on a slide path the source stays in its own channels through it (`density_channels`), because the primaries mix the dye channels the - density corrections act on. Else the whole matrix applies before the log.""" + density corrections act on. Else the whole matrix applies before the log. *anchor* is the fade's + `fade_anchor`, in those channels.""" from negpy.features.process.path import RenderPath, render_path if unmix is None or render_path(process) is RenderPath.PRINT: return DensityStage(_whole_matrix(process, cam_xyz, camera_wb), unmix, None) channels = density_channels(process, cam_xyz, camera_wb) offset = None if channels.unmix is None else channels.unmix[:, 3] - return DensityStage(channels.pre, _with_log_offset(unmix, offset), channels.post) + if anchor is not None and offset is not None: + # The gains ride the matrix's offset, so the input log sits below the channels by them. + anchor = (float(anchor[0] - offset[0]), float(anchor[1] - offset[1]), float(anchor[2] - offset[2])) + return DensityStage(channels.pre, _with_log_offset(unmix, offset), channels.post, anchor) def density_stage_grid( @@ -559,7 +570,7 @@ def density_stage_grid( grid = log_grid() else: grid = prefilter_log_grid(linear if stage.pre is None else apply_camera_matrix(linear, stage.pre), roi, analysis_buffer) - return relog_matrix_image(unmix_log_image(grid, stage.unmix), stage.post) + return relog_matrix_image(unmix_log_image(grid, stage.unmix, stage.anchor), stage.post) def transfer_bounds(density_range: float = TRANSFER_DENSITY_RANGE) -> Tuple[Tuple[float, ...], Tuple[float, ...]]: diff --git a/negpy/features/transparency/processor.py b/negpy/features/transparency/processor.py index 66f027d44..c2cefab60 100644 --- a/negpy/features/transparency/processor.py +++ b/negpy/features/transparency/processor.py @@ -25,7 +25,7 @@ resolve_analysis_region, unmix_log_image, ) -from negpy.features.process.fade import fade_white_space, unmix_with_fade +from negpy.features.process.fade import fade_anchor, fade_white_space, unmix_with_fade from negpy.features.process.capture_color import apply_camera_matrix from negpy.features.process.models import ProcessConfig from negpy.features.process.path import RenderPath, render_path @@ -70,11 +70,13 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: # Honoured here too: a rig-calibrated matrix is a capture correction like Hue Trim, and # the mode gate keeps a negative's profile from touching a slide. Inert at the shipped # default, so the as-captured render is unperturbed. - unmix = unmix_with_fade(self.config, context.process_mode, fade_white_space(context.cam_xyz, context.camera_wb)) - stage = density_stage(self.config, context.cam_xyz, context.camera_wb, unmix) + white_space = fade_white_space(context.cam_xyz, context.camera_wb) + unmix = unmix_with_fade(self.config, context.process_mode, white_space) + anchor = fade_anchor(self.config, context.process_mode, white_space) + stage = density_stage(self.config, context.cam_xyz, context.camera_wb, unmix, anchor) linear = image if stage.pre is None else apply_camera_matrix(negpy_core.nan_to_num(image, epsilon, 1.0, epsilon), stage.pre) img_log = negpy_core.log_density_open(np.ascontiguousarray(linear, dtype=np.float32)) - img_log = relog_matrix_image(unmix_log_image(img_log, stage.unmix), stage.post) + img_log = relog_matrix_image(unmix_log_image(img_log, stage.unmix, stage.anchor), stage.post) floors, ceils = transfer_bounds() pre_trim_bounds = LogNegativeBounds(floors=floors, ceils=ceils) # White/Black Point manually deviate the fixed window, same technique as the measured @@ -94,14 +96,14 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: linear.shape, context.active_roi, self.config.analysis_buffer, self.config.analysis_rect, self.config.meter_rect ) log_grid = prefilter_log_grid(linear, an_roi, an_buffer) - prefiltered = relog_matrix_image(unmix_log_image(log_grid, stage.unmix), stage.post) + prefiltered = relog_matrix_image(unmix_log_image(log_grid, stage.unmix, stage.anchor), stage.post) if context.wants_fade_grid: # Fade Estimate meters this grid, rendered with the fade at identity, in the channels # the fade acts on: a camera's white-balanced channels or a Positive source's own. channels = stage if unmix is not None else density_channels(self.config, context.cam_xyz, context.camera_wb) if channels.pre is None and stage.pre is not None: log_grid = prefilter_log_grid(image, an_roi, an_buffer) - context.metrics["transfer_grid"] = unmix_log_image(log_grid, channels.unmix) + context.metrics["transfer_grid"] = unmix_log_image(log_grid, channels.unmix, channels.anchor) # Cast Removal's neutral axis is metered on the working-space log image the curve # consumes, since the camera matrix above would leave a meter reading a different diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index 100ab574d..5f424aada 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -94,7 +94,7 @@ wb_split_geometry, zone_geometry, ) -from negpy.features.process.fade import fade_white_space, fade_white_target, unmix_with_fade +from negpy.features.process.fade import fade_anchor, fade_white_space, fade_white_target, unmix_with_fade from negpy.features.exposure.bw_filter import collapse_weights, plane_weights, printed_exposure from negpy.features.process.models import ProcessMode, per_channel_point_offsets, pooled_neutral_axis from negpy.features.process.path import RenderPath, render_path @@ -323,6 +323,11 @@ def _update_analysis_cache(cache, key, bounds, anchor, textural, neutral, shadow ) +def _fade_anchor(settings: WorkspaceConfig, cam_xyz: Optional[list], camera_wb: Optional[list]) -> Optional[tuple[float, float, float]]: + """The fade's anchor for this source (`fade.fade_anchor`), as TransparencyBaseProcessor reads it.""" + return fade_anchor(settings.process, settings.process.process_mode, fade_white_space(cam_xyz, camera_wb)) + + class GPUEngine: """ Core GPU orchestration engine using WebGPU. @@ -395,7 +400,7 @@ def __init__(self) -> None: # alignment (exposure) occupies multiple aligned slots. self._uniform_sizes = { "geometry": 64, - "normalization": 208, + "normalization": 224, "exposure": 480, "transfer": 224, "clahe_u": 32, @@ -832,7 +837,9 @@ def process_to_texture( unmix_m = unmix_with_fade(settings.process, settings.process.process_mode, fade_white_space(cam_xyz, camera_wb)) # The transparency curve reads working space, so its meter must too: the same # camera matrices TransparencyBaseProcessor applies, on the grid. - slide_stage = density_stage(settings.process, cam_xyz, camera_wb, unmix_m) if transfer else None + slide_stage = ( + density_stage(settings.process, cam_xyz, camera_wb, unmix_m, _fade_anchor(settings, cam_xyz, camera_wb)) if transfer else None + ) # A drag renders without metrics and caches no densities; the release that reads them # back re-runs the grid once so the cache fills. # A source with no base is known as such, so it never re-enters the grid for it. @@ -1907,8 +1914,9 @@ def _upload_unified_uniforms( # Capture-side dye-unmix rows, resolved once per frame by the caller and shared with # NormalizationProcessor, and the source matrix around them (density_stage): the cam rows # before the log, read only on the transfer path, and the post rows after. Identity when - # off; a (3, 4) affine packs its offset in w, and the w lane of post0 turns post on. - cam, unmix, post = density_stage(settings.process, cam_xyz, camera_wb, unmix) + # off; a (3, 4) affine packs its offset in w, and the w lane of post0 turns post on. The fade + # anchor rides its own row, its w lane on when one acts. + cam, unmix, post, anchor = density_stage(settings.process, cam_xyz, camera_wb, unmix, _fade_anchor(settings, cam_xyz, camera_wb)) if unmix is None: unmix = np.eye(3) unmix_w = unmix[:, 3] if unmix.shape[1] == 4 else (0.0, 0.0, 0.0) @@ -1937,6 +1945,7 @@ def _upload_unified_uniforms( + struct.pack("ffff", float(post[0, 0]), float(post[0, 1]), float(post[0, 2]), post_on) + struct.pack("ffff", float(post[1, 0]), float(post[1, 1]), float(post[1, 2]), 0.0) + struct.pack("ffff", float(post[2, 0]), float(post[2, 1]), float(post[2, 2]), 0.0) + + struct.pack("ffff", *((0.0, 0.0, 0.0, 0.0) if anchor is None else (*anchor, 1.0))) ) from negpy.features.exposure.logic import ( @@ -3006,7 +3015,7 @@ def _meter_grid() -> np.ndarray: if not transfer: return _prefiltered() if transfer_grid is None: - slide_stage = density_stage(settings.process, cam_xyz, camera_wb, unmix_m) + slide_stage = density_stage(settings.process, cam_xyz, camera_wb, unmix_m, _fade_anchor(settings, cam_xyz, camera_wb)) transfer_grid = density_stage_grid(slide_stage, _analysis_img(), meter_roi, meter_buffer, _log_grid) return transfer_grid diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index fcea83bdd..dd2c133aa 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -154,6 +154,28 @@ pub fn unmix_log(img: &[f32], m: [[f32; 3]; 3], dst: &mut [f32]) { ); } +/// `unmix_log_image` with an anchor: each channel is capped at its anchor before the 3x3, and the +/// amount the pixel is above the anchor in every channel, if any, is added to each channel after. +/// Light past the film carries no dye, so it passes through the density matrix as a neutral. +pub fn unmix_log_anchored(img: &[f32], m: [[f32; 3]; 3], anchor: [f32; 3], dst: &mut [f32]) { + crate::par_zip( + dst.as_chunks_mut::<3>().0, + img.as_chunks::<3>().0, + move |d, p| { + let over = (p[0] - anchor[0]) + .min(p[1] - anchor[1]) + .min(p[2] - anchor[2]); + let excess = over.max(0.0); + let q = [ + p[0].min(anchor[0]), + p[1].min(anchor[1]), + p[2].min(anchor[2]), + ]; + *d = m.map(|r| q[0] * r[0] + q[1] * r[1] + q[2] * r[2] + excess); + }, + ); +} + /// `relog_matrix_image`: a 3x3 matrix on each pixel of an HxWx3 log image, in linear light. Each /// value goes to 10^v, each row is float32 products summed in channel order with no `mul_add`, and /// the log clamps below at 1e-6 as `log_density_open` does. @@ -1813,6 +1835,20 @@ pub fn diffusion_tail( mod tests { use super::*; + #[test] + fn unmix_log_anchored_passes_light_past_the_anchor_as_a_neutral() { + let m = [[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.5]]; + let anchor = [-0.4, -0.5, -0.6]; + let img = [-1.0, -1.0, -1.0, 0.0, 0.0, 0.0, -0.2, -0.8, 0.0]; + let mut out = [0f32; 9]; + unmix_log_anchored(&img, m, anchor, &mut out); + assert_eq!(&out[..3], &[-2.0, -1.0, -0.5]); + // Above the anchor in every channel by at least 0.4: capped, then 0.4 added to each. + assert_eq!(&out[3..6], &[-0.8 + 0.4, -0.5 + 0.4, -0.3 + 0.4]); + // Below the anchor in green: capped where above, nothing added. + assert_eq!(&out[6..], &[-0.8, -0.8, -0.3]); + } + #[test] fn unmix_log_keeps_negative_densities_and_sums_in_channel_order() { let m = [[1.0f32, 0.5, 0.0], [0.0, 1.0, 0.0], [-1e8, 1e8, -3.0]]; diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index 8078c11c4..53c7c5113 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -467,6 +467,35 @@ fn unmix_log<'py>( Ok(out) } +/// `unmix_log_image(img_log, matrix, anchor)` of an HxWx3 float32 log image. +#[pyfunction] +fn unmix_log_anchored<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + m: numpy::PyReadonlyArray2<'py, f32>, + anchor: [f32; 3], +) -> PyResult>> { + let shape = img.shape().to_vec(); + if shape.len() != 3 || shape[2] != 3 { + return Err(PyValueError::new_err( + "unmix_log_anchored needs an HxWx3 array", + )); + } + let m = super::sensor::matrix(&m)?; + let data = slice(&img)?; + let out = empty::(py, &shape, "float32")?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || { + analysis::unmix_log_anchored(data, m, anchor, dst) + }); + } + Ok(out) +} + /// `relog_matrix_image(img_log, matrix)` of an HxWx3 float32 log image. #[pyfunction] fn relog_matrix<'py>( @@ -832,6 +861,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(log_density_open, m)?)?; m.add_function(wrap_pyfunction!(unmix_log, m)?)?; m.add_function(wrap_pyfunction!(relog_matrix, m)?)?; + m.add_function(wrap_pyfunction!(unmix_log_anchored, m)?)?; m.add_function(wrap_pyfunction!(normalize_to_uint8, m)?)?; m.add_function(wrap_pyfunction!(detection_luma, m)?)?; m.add_function(wrap_pyfunction!(base_blocks, m)?)?; diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index d8885b06e..00d7e3f09 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -58,6 +58,11 @@ def _filled(fill): np.ascontiguousarray(_IMG3 * np.float32(-3.0)), np.array([[1.3, -0.2, -0.1], [-0.05, 1.4, -0.35], [0.0, -0.3, 1.3]], np.float32), ), + "unmix_log_anchored": lambda: negpy_core.unmix_log_anchored( + np.ascontiguousarray(_IMG3 * np.float32(-1.0)), + np.array([[1.3, -0.2, -0.1], [-0.05, 1.4, -0.35], [0.0, -0.3, 1.3]], np.float32), + [-0.4, -0.7, -0.55], + ), "relog_matrix": lambda: negpy_core.relog_matrix( np.ascontiguousarray(_IMG3 * np.float32(-3.0)), np.array([[0.7152, 0.2848, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0411, 0.9589]], np.float32), @@ -163,6 +168,7 @@ def _filled(fill): "morph_close_u8": "4be9a2976881ff83eb0b92a8ec484e048c61612324afbde73b6e0c89aa92865e", "to_log_density": "cc4d2086edf051826619f024a4f5d28c80d28528291d9df4610c1d3854144038", "unmix_log": "73edc03deb7e068718e77264608858efaec4b64776f64645da9f4aef54ec23b4", + "unmix_log_anchored": "4a90ef06a62ef6de0478a5c13912531f8f860f63a4d5370cf1e1f1c7678c4907", "relog_matrix": "e79e7bbb62aa68e9248c9b92d9875cbb5779b640a93b8f8e45a648b5009547be", "oetf_encode": "44508d28673c678fc0ec7b5262a06d0053ea6b4bed4bead7bc49556bc60eb070", "rgb_to_lab": "02c583bf1f5fe9723e2cb0a3e1f34759d6b0778d3644e8e2cff2251e756b68f2", diff --git a/tests/test_core_unmix.py b/tests/test_core_unmix.py index 402718645..61ed44bd0 100644 --- a/tests/test_core_unmix.py +++ b/tests/test_core_unmix.py @@ -58,3 +58,42 @@ def test_unmix_log_is_pinned(): digest(unmix_log_image(img, _M), unmix_log_image(img[:, ::-1], _M.T)) == "caa22ec667cea3f0c0c21fb28547fc1d4c8bac5ffffc405eefebd608899818ee" ) + + +_ANCHOR = (-0.4, -0.7, -0.55) + + +def _anchored_oracle(img, m, anchor): + a = np.asarray(anchor, np.float32) + excess = np.maximum(np.min(img - a, axis=-1, keepdims=True), np.float32(0.0)) + q = np.minimum(img, a) + m = m.astype(np.float32) + return np.stack([((q[..., 0] * m[k, 0] + q[..., 1] * m[k, 1]) + q[..., 2] * m[k, 2]) + excess[..., 0] for k in range(3)], axis=-1) + + +@pytest.mark.parametrize("shape", [(1, 1), (7, 5), (301, 457)]) +def test_the_anchored_unmix_matches_its_oracle(shape): + img = _log(shape[0], shape) * np.float32(0.3) + np.testing.assert_array_equal(unmix_log_image(img, _M, _ANCHOR), _anchored_oracle(img, _M, _ANCHOR)) + + +def test_the_anchored_unmix_is_the_unmix_below_the_anchor(): + img = _log(6) - np.float32(1.0) + np.testing.assert_array_equal(unmix_log_image(img, _M, _ANCHOR), unmix_log_image(img, _M)) + + +def test_light_past_the_anchor_passes_as_a_neutral(): + """A pixel above the anchor in every channel keeps that excess unscaled and equal in each channel.""" + img = np.full((1, 1, 3), 0.0, np.float32) + out = unmix_log_image(img, _M, _ANCHOR)[0, 0] + at_anchor = unmix_log_image(np.asarray(_ANCHOR, np.float32).reshape(1, 1, 3), _M)[0, 0] + np.testing.assert_allclose(out - at_anchor, 0.4, atol=1e-6) + + +@linux_pin +def test_unmix_log_anchored_is_pinned(): + img = _log(7) * np.float32(0.3) + assert ( + digest(unmix_log_image(img, _M, _ANCHOR), unmix_log_image(img[:, ::-1], _M.T, _ANCHOR)) + == "ca6f43af70981d41416e79f3da8a1f40278f00daa089b67ccf9021603398be2a" + ) diff --git a/tests/test_fade_white_space.py b/tests/test_fade_white_space.py index a8350f4d5..9803fa2e8 100644 --- a/tests/test_fade_white_space.py +++ b/tests/test_fade_white_space.py @@ -205,3 +205,42 @@ def render(prefer_gpu): cpu, gpu = render(False), render(True) assert float(np.mean(np.abs(cpu - gpu))) < 0.005 assert float(np.max(np.abs(cpu - gpu))) < 0.04 + + +@pytest.mark.parametrize("positive", [False, True]) +@pytest.mark.parametrize("remove_stain", [False, True]) +def test_a_reconstructed_highlight_stays_white_through_the_fade(positive, remove_stain): + """Highlight Reconstruction decodes a blown highlight as neutral at full scale. It is light past the + film, above the fade's anchor in every channel, so it passes the density matrix unscaled.""" + img = np.random.default_rng(4).uniform(0.03, 0.4, (40, 60, 3)).astype(np.float32) + img[:10, :20] = 1.0 + white = (-0.42, -0.45, -0.5) + config = _slide( + positive_source=positive, linear_raw=False, highlight_reconstruction=5, fade_white_anchor=remove_stain, **_FADE, **_white(white) + ) + ctx = PipelineContext(original_size=img.shape[:2], scale_factor=1.0, process_mode=ProcessMode.E6, cam_xyz=_CAMERA, camera_wb=_WB) + TransparencyBaseProcessor(config).process(img, ctx) + b = ctx.metrics["log_bounds"] + restored = ctx.metrics["normalized_log"] * (np.asarray(b.ceils) - np.asarray(b.floors)) + np.asarray(b.floors) + np.testing.assert_allclose(restored[:10, :20].reshape(-1, 3), 0.0, atol=1e-4) + # Below the anchor the fade still acts. + assert not np.allclose(restored[20:, 30:], np.log10(img[20:, 30:]), atol=1e-3) + + +def test_estimate_reads_the_grid_without_the_stored_white(monkeypatch): + """A stored white caps the render at its anchor; the grid a new white is read from must not be capped.""" + from negpy.desktop.workers import chart_fit + from negpy.services.rendering import image_processor + + seen = [] + + def run_pipeline(self, buf, settings, *args, **kwargs): + seen.append(settings.process) + return None, {"transfer_grid": np.zeros((2, 2, 3), np.float32)} + + monkeypatch.setattr(image_processor.ImageProcessor, "run_pipeline", run_pipeline) + cfg = replace(WorkspaceConfig(), process=_slide(fade_white_anchor=True, **_FADE, **_white())) + chart_fit.fade_estimate_grid(chart_fit.ChartSource(np.zeros((4, 4, 3), np.float32), cfg, "h", _CAMERA, _WB, (), 1)) + (process,) = seen + assert (process.fade_white_r, process.fade_white_g, process.fade_white_b) == (0.0, 0.0, 0.0) + assert not process.fade_white_anchor and process.fade_ratio_g == 1.0 diff --git a/tests/test_pipeline_parity.py b/tests/test_pipeline_parity.py index 5606b35c7..6d1c37cf3 100644 --- a/tests/test_pipeline_parity.py +++ b/tests/test_pipeline_parity.py @@ -419,6 +419,35 @@ def test_camera_raw_split_at_the_white_balance_fold(self, positive): ) _assert_mostly_close(cpu, self.gpu._readback_downsampled(tex), atol=2e-2, rtol=2e-2, max_violation_frac=0.01) + @pytest.mark.parametrize("remove_stain", [False, True]) + def test_reconstructed_highlight_through_the_fade(self, remove_stain): + # A blown highlight decoded neutral at full scale passes the fade's anchor unscaled on both engines. + from negpy.domain.interfaces import PipelineContext + from negpy.features.process.fade import fade_white_space + from negpy.kernel.system.config import render_scale_factor + + cam_xyz = [[0.6722, -0.0635, -0.0963], [-0.4287, 1.246, 0.2046], [-0.0908, 0.2268, 0.5762]] + wb = [2.0, 1.0, 1.5, 1.0] + img = self.img.copy() + img[20:44, 20:44] = 1.0 + base = _make_base_settings() + white = dict(fade_white_r=-0.3, fade_white_g=-0.25, fade_white_b=-0.35, fade_white_space=fade_white_space(cam_xyz, wb)) + fade = dict(fade_ratio_r=0.6, fade_ratio_g=1.4, fade_ratio_b=1.2, fade_white_anchor=remove_stain, **white) + p = replace(base.process, process_mode=ProcessMode.E6, positive_source=True, linear_raw=False, highlight_reconstruction=5, **fade) + s = replace(base, process=p) + h, w = img.shape[:2] + ctx = PipelineContext( + original_size=(h, w), scale_factor=render_scale_factor(img.shape), process_mode=ProcessMode.E6, cam_xyz=cam_xyz, camera_wb=wb + ) + cpu = self.cpu.process(img, s, f"parity_raw_highlight_{remove_stain}", ctx) + tex, _ = self.gpu.process_to_texture( + img, s, scale_factor=max(h, w) / 1024.0, apply_layout=False, readback_metrics=False, cam_xyz=cam_xyz, camera_wb=wb + ) + gpu = self.gpu._readback_downsampled(tex) + _assert_mostly_close(cpu, gpu, atol=2e-2, rtol=2e-2, max_violation_frac=0.01) + patch = np.asarray(gpu)[24:40, 24:40, :3].reshape(-1, 3) + assert float(np.ptp(patch.mean(axis=0))) < 0.01 + class TestLabParity: """CPU vs GPU parity for the lab color/sharpening shader.""" From 74254b4afdeb0a9e725850d27414cfe86375c4b1 Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:45:23 +0000 Subject: [PATCH 08/46] fix(fade): keep the fade name out of the negative package test_slide_separation requires that features/exposure does not name the fade. The unmix_log_image docstring and the shader comment now call the anchor the density anchor. --- negpy/features/exposure/normalization.py | 2 +- negpy/features/exposure/shaders/normalization.wgsl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index 4b1783d9c..0c57f36b3 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -193,7 +193,7 @@ def resolve_crosstalk_matrix(strength: float, matrix: Optional[tuple]) -> Option def unmix_log_image(img_log: ImageBuffer, matrix: Optional[np.ndarray], anchor: Optional[Sequence[float]] = None) -> ImageBuffer: """Apply the unmix matrix to a (H, W, 3) log-density image; identity when None. A (3, 4) matrix is an affine: its last column is added after the 3x3, as normalization.wgsl does. - With an *anchor* (`fade.fade_anchor`), each channel is capped at it before the 3x3, and the + With an *anchor*, each channel is capped at it before the 3x3, and the amount the pixel is above it in every channel passes through as a neutral.""" if matrix is None: return img_log diff --git a/negpy/features/exposure/shaders/normalization.wgsl b/negpy/features/exposure/shaders/normalization.wgsl index a6d044779..d4ba39110 100644 --- a/negpy/features/exposure/shaders/normalization.wgsl +++ b/negpy/features/exposure/shaders/normalization.wgsl @@ -26,7 +26,7 @@ struct NormUniforms { post0: vec4, post1: vec4, post2: vec4, - // The fade's anchor (fade_anchor), on when w is 1.0: each channel is capped at it before the unmix, + // The density anchor, on when w is 1.0: each channel is capped at it before the unmix, // and the amount the pixel is above it in every channel is added after, as a neutral. anchor: vec4, }; From 1cc856c1021b55ca3d57b0bc38935b271c9a808b Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:52:59 +0000 Subject: [PATCH 09/46] fix(fade): cap highlights at the white on a camera RAW only A scanner TIFF or JPEG with Remove Stain on capped each pixel above its white and passed the excess unrestored. The white of such a file is a percentile of its clear film, so the light above it is film. Now only a camera RAW, whose white sits below the decoder's full scale, has a cap. --- negpy/features/process/fade.py | 5 ++++- tests/test_fade_white_space.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/negpy/features/process/fade.py b/negpy/features/process/fade.py index 165913e6b..2c588c4e1 100644 --- a/negpy/features/process/fade.py +++ b/negpy/features/process/fade.py @@ -105,7 +105,10 @@ def unmix_with_fade(process: ProcessConfig, process_mode: Optional[str], white_s def fade_anchor(process: ProcessConfig, process_mode: Optional[str], white_space: str = "") -> Optional[tuple[float, float, float]]: """The log point `unmix_with_fade` scales density about, when it has one. A pixel above it in every channel is light past the film, which has no dye to restore: `unmix_log_image` passes that part - through as a neutral, so a blown highlight stays white.""" + through as a neutral, so a blown highlight stays white. A camera RAW only: any other source's + white is at full scale, with no light past it.""" + if not white_space: + return None acts = effective_crosstalk_matrix(process, process_mode) is not None or fade_matrix(process) is not None terms = _white_terms(process, white_space, acts) return None if terms is None else (float(terms[0][0]), float(terms[0][1]), float(terms[0][2])) diff --git a/tests/test_fade_white_space.py b/tests/test_fade_white_space.py index 9803fa2e8..777c2833b 100644 --- a/tests/test_fade_white_space.py +++ b/tests/test_fade_white_space.py @@ -15,6 +15,7 @@ from negpy.features.process.fade import ( EstimateMethod, estimate_fade, + fade_anchor, fade_white_space, fade_white_stale, fade_white_target, @@ -114,6 +115,20 @@ def test_a_file_without_a_camera_matrix_keeps_the_full_scale_zero(): np.testing.assert_array_equal(unmix_with_fade(process, ProcessMode.E6, ""), resolve_fade_matrix(1.0, 0.8, 0.6)) +def test_remove_stain_on_a_scanner_file_restores_its_highlights_too(): + """A scanner file's white is a percentile of its clear film: the light above it is film, not light + past the film, so the restoration acts on it.""" + img = np.random.default_rng(5).uniform(0.03, 1.0, (40, 60, 3)).astype(np.float32) + config = _slide(fade_white_anchor=True, **_FADE, **_white((-0.05, -0.08, -0.12), space="")) + assert fade_anchor(config, ProcessMode.E6, "") is None + ctx = PipelineContext(original_size=img.shape[:2], scale_factor=1.0, process_mode=ProcessMode.E6) + TransparencyBaseProcessor(config).process(img, ctx) + b = ctx.metrics["log_bounds"] + restored = ctx.metrics["normalized_log"] * (np.asarray(b.ceils) - np.asarray(b.floors)) + np.asarray(b.floors) + expected = unmix_log_image(np.log10(img), unmix_with_fade(config, ProcessMode.E6, "")) + np.testing.assert_allclose(restored, expected, atol=1e-4) + + def test_the_white_goes_stale_only_where_it_acts(): assert fade_white_stale(_slide(**_FADE), _SPACE) assert fade_white_stale(_slide(fade_white_anchor=True), _SPACE) From 2c83c6a210ba19ec2cfc252403181f3261cc637c Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:52:59 +0000 Subject: [PATCH 10/46] fix(rolls): keep a camera RAW's fade white on its frame The fade card pushed one frame's white to every frame of the roll. A camera RAW's white moves with each frame's exposure, so other frames scaled their density about the wrong level and took a cast. A stale roll white also came back on each open and was measured again. The roll now drops a white tagged with a decode space when it is set, and ignores one already stored. A scanner white still follows the roll. --- docs/PIPELINE.md | 2 +- negpy/services/assets/rolls.py | 21 ++++++++++++++++++--- tests/test_rolls.py | 24 ++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 37d24388c..e0a29c710 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -65,7 +65,7 @@ Pixel values are linear transmittance. The source corrections run in that domain White Anchor (`fade_white_anchor`, UI "Remove Stain") makes the matrix affine, $M(L - w) + t$. The stain $w$ is each channel's `WHITE_PCT` cell of the meter grid in log transmittance, without decoded zeros and cells at full scale in every channel; Estimate stores it as `fade_white_r/g/b`. It maps to the neutral $t = \min(\max_c w_c, -0.1)$ (`FADE_WHITE_HEADROOM`): the cleanest channel's own level, never within $0.1$ D of full scale. $F^{-1}$ acts on $L - w$ before $t$ is added, so the Estimate reads the grays on $\min(L - w, 0)$. The offset rides in the w lanes of the unmix rows on the GPU. - A camera RAW's clear film sits below the decoder's white by the exposure and the light, so $F^{-1}$ about $L = 0$ would scale that offset into a cast. On a RAW the matrix is always $M(L - a) + t$ (`density_anchor`). Remove Stain sets $a = w$ and the neutral $t$ above. Without it, $a = t$ is the neutral level $\max_c w_c$, so the white keeps its color: a slide's rebate is black, and with no clear film in the picture $w$ is the brightest picture color, which a per-channel anchor would add to every gray. Estimate stores $w$ with White Anchor off too and reads the grays on $\min(L - a, 0)$. A pixel above $a$ in every channel is light past the film, such as a highlight that Highlight Reconstruction decodes neutral at full scale. The matrix acts on $\min(L, a)$, and $e = \max(\min_c(L_c - a_c), 0)$ is added to every channel after it (`fade_anchor`, `unmix_log_anchored`, the shader's `anchor` row), so that light passes as a neutral. Estimate reads its grid with the stored white cleared. A JPEG or scanner TIFF has its white at full scale and keeps $L = 0$. `fade_white_space` tags $w$ with the space it was read in: the white-balanced camera channels, named by a hash of `camera_to_working_matrix(cam_xyz, camera_wb)` and a channel marker, "" with no camera matrix. A render whose tag differs ignores $w$, and the desktop measures it again on the open frame (`_refresh_stale_fade_white`), with no roll lock. + A camera RAW's clear film sits below the decoder's white by the exposure and the light, so $F^{-1}$ about $L = 0$ would scale that offset into a cast. On a RAW the matrix is always $M(L - a) + t$ (`density_anchor`). Remove Stain sets $a = w$ and the neutral $t$ above. Without it, $a = t$ is the neutral level $\max_c w_c$, so the white keeps its color: a slide's rebate is black, and with no clear film in the picture $w$ is the brightest picture color, which a per-channel anchor would add to every gray. Estimate stores $w$ with White Anchor off too and reads the grays on $\min(L - a, 0)$. A pixel above $a$ in every channel is light past the film, such as a highlight that Highlight Reconstruction decodes neutral at full scale. The matrix acts on $\min(L, a)$, and $e = \max(\min_c(L_c - a_c), 0)$ is added to every channel after it (`fade_anchor`, `unmix_log_anchored`, the shader's `anchor` row), so that light passes as a neutral. Estimate reads its grid with the stored white cleared. A JPEG or scanner TIFF has its white at full scale and keeps $L = 0$, with no cap. `fade_white_space` tags $w$ with the space it was read in: the white-balanced camera channels, named by a hash of `camera_to_working_matrix(cam_xyz, camera_wb)` and a channel marker, "" with no camera matrix. A render whose tag differs ignores $w$, and the desktop measures it again on the open frame (`_refresh_stale_fade_white`), with no roll lock. A roll keeps no camera RAW white, because it moves with each frame's exposure. * **Density above base**: `density_above_base` is (Dmin, Dmed, Dmax) above the clear base, luminance-weighted: the thin and dense bounds (before trims; the roll's under Roll Analysis) and the metered median, each minus the base. The base is the picked one, else the rebate reader's, unmixed like the meters. A pick is the open roll's (`rolls.roll_film_base`), or the frame's own with no roll open (`process.film_base`, unhashed); in an open roll the roll's wins, with none open the frame's. The preview carries it as `film_base`; export never reads it. The reader takes the flat 8-px blocks (std over mean under 0.03, unclipped) in the outer 15% of the film box (`film_and_picture` at `AUTOCROP_DETECT_RES`), groups them by brightness with a 0.04-decade gap, and takes the largest group of at least five. It finds none without a film box, with a base darker than the picture, or on the transfer path. The preview reads it once per source, on the flat-fielded, unmixed source before the defect repairs, and passes the read to both engines. * **Scan-clip warning**: `scan_clip_fractions` reports the per-channel fraction of source pixels at or above $0.99$ linear. On a negative this clips film base and shadows to $D=0$; nothing reconstructs it. diff --git a/negpy/services/assets/rolls.py b/negpy/services/assets/rolls.py index 72130faf0..229b4eda7 100644 --- a/negpy/services/assets/rolls.py +++ b/negpy/services/assets/rolls.py @@ -680,7 +680,8 @@ def virtual_rolls(repo: Any) -> List[tuple]: ), ), # A roll's slides usually faded alike, so one Estimate or hand-tuned survival serves them - # all; its own card, so a per-frame Estimate never locks the crosstalk. + # all; its own card, so a per-frame Estimate never locks the crosstalk. A camera RAW's white + # stays per frame (`_without_decode_white`). "fade": ( "process", ( @@ -754,12 +755,23 @@ def same_value(a: Any, b: Any) -> bool: return config_value(a) == config_value(b) +# The fade white of a camera RAW: a reading of one frame's decode, which moves with its exposure. +_DECODE_WHITE_FIELDS = ("fade_white_r", "fade_white_g", "fade_white_b", "fade_white_space") + + +def _without_decode_white(fields: Dict[str, Any]) -> Dict[str, Any]: + """*fields* without a camera RAW's fade white (a set `fade_white_space`), which no other frame can use.""" + if not fields.get("fade_white_space"): + return fields + return {k: v for k, v in fields.items() if k not in _DECODE_WHITE_FIELDS} + + def roll_defaults(repo: Any, roll_id: str) -> Dict[str, Any]: """The roll's own value for each field it has set at least once. A field absent here has no roll default yet -- the frame's own saved value is what is used, exactly as before roll defaults existed.""" entry = roll_for_id(repo, roll_id) - return dict(entry["defaults"]) if entry and entry.get("defaults") else {} + return _without_decode_white(dict(entry["defaults"])) if entry and entry.get("defaults") else {} def set_roll_defaults(repo: Any, roll_id: str, **fields: Any) -> None: @@ -772,7 +784,10 @@ def set_roll_defaults(repo: Any, roll_id: str, **fields: Any) -> None: if entry is None: return defaults = dict(entry.get("defaults", {})) - defaults.update(fields) + if fields.get("fade_white_space"): + # A camera RAW's white replaces the roll's white with none, so each frame keeps its own. + defaults = {k: v for k, v in defaults.items() if k not in _DECODE_WHITE_FIELDS} + defaults.update(_without_decode_white(fields)) entry["defaults"] = defaults _write(repo, store) diff --git a/tests/test_rolls.py b/tests/test_rolls.py index fee7284a6..bcd7e0ca8 100644 --- a/tests/test_rolls.py +++ b/tests/test_rolls.py @@ -1182,3 +1182,27 @@ def test_home_roll_lookup_answers_as_home_roll(tmp_path): for path in (str(folder / "1.tif"), "/elsewhere/2.tif", "/nowhere/3.tif", str(folder / "sub" / "4.tif")): assert lookup(path) == home_roll(repo, path), path assert lookup(str(folder)) == folder_id + + +def test_a_camera_raw_fade_white_stays_on_its_frame(): + """A RAW's white moves with each frame's exposure, so the roll keeps a scanner white only.""" + repo = _repo() + roll_id = create_virtual_roll(repo, "R", ["/a.tif"]) + scanner = dict(fade_white_r=-0.1, fade_white_g=-0.2, fade_white_b=-0.3, fade_white_space="") + set_roll_defaults(repo, roll_id, fade_ratio_g=2.0, **scanner) + assert roll_defaults(repo, roll_id) == dict(fade_ratio_g=2.0, **scanner) + raw = dict(fade_white_r=-0.4, fade_white_g=-0.5, fade_white_b=-0.6, fade_white_space="abc123") + set_roll_defaults(repo, roll_id, fade_ratio_g=1.5, **raw) + assert roll_defaults(repo, roll_id) == dict(fade_ratio_g=1.5) + frame = WorkspaceConfig(process=ProcessConfig(fade_white_r=-0.7, fade_white_g=-0.8, fade_white_b=-0.9, fade_white_space="abc123")) + resolved = resolve_roll_config(repo, roll_id, "h1", frame).process + assert (resolved.fade_ratio_g, resolved.fade_white_r, resolved.fade_white_space) == (1.5, -0.7, "abc123") + + +def test_a_stored_camera_raw_roll_white_is_not_read(): + repo = _repo() + roll_id = create_virtual_roll(repo, "R", ["/a.nef"]) + store = repo.get_global_setting(ROLLS_KEY) + store[roll_id]["defaults"] = dict(fade_ratio_r=0.5, fade_white_r=-0.4, fade_white_g=-0.5, fade_white_b=-0.6, fade_white_space="abc123") + repo.save_global_setting(ROLLS_KEY, store) + assert roll_defaults(repo, roll_id) == dict(fade_ratio_r=0.5) From ff9b0b22315560ac7797255fa29fec223c365ddc Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:29:32 +0000 Subject: [PATCH 11/46] fix(fade): name the white's space by the white-balance gains only The tag hashed the whole camera matrix, primaries included. The white is read in the white-balanced camera channels, before the primaries, so an Input ICC changed the tag and made a good white stale. The preview then measured it again, and export and thumbnails ignored it. --- docs/PIPELINE.md | 2 +- negpy/features/process/fade.py | 20 ++++++++++---------- tests/test_fade_white_space.py | 7 ++++++- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index e0a29c710..1eb2fa7b1 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -65,7 +65,7 @@ Pixel values are linear transmittance. The source corrections run in that domain White Anchor (`fade_white_anchor`, UI "Remove Stain") makes the matrix affine, $M(L - w) + t$. The stain $w$ is each channel's `WHITE_PCT` cell of the meter grid in log transmittance, without decoded zeros and cells at full scale in every channel; Estimate stores it as `fade_white_r/g/b`. It maps to the neutral $t = \min(\max_c w_c, -0.1)$ (`FADE_WHITE_HEADROOM`): the cleanest channel's own level, never within $0.1$ D of full scale. $F^{-1}$ acts on $L - w$ before $t$ is added, so the Estimate reads the grays on $\min(L - w, 0)$. The offset rides in the w lanes of the unmix rows on the GPU. - A camera RAW's clear film sits below the decoder's white by the exposure and the light, so $F^{-1}$ about $L = 0$ would scale that offset into a cast. On a RAW the matrix is always $M(L - a) + t$ (`density_anchor`). Remove Stain sets $a = w$ and the neutral $t$ above. Without it, $a = t$ is the neutral level $\max_c w_c$, so the white keeps its color: a slide's rebate is black, and with no clear film in the picture $w$ is the brightest picture color, which a per-channel anchor would add to every gray. Estimate stores $w$ with White Anchor off too and reads the grays on $\min(L - a, 0)$. A pixel above $a$ in every channel is light past the film, such as a highlight that Highlight Reconstruction decodes neutral at full scale. The matrix acts on $\min(L, a)$, and $e = \max(\min_c(L_c - a_c), 0)$ is added to every channel after it (`fade_anchor`, `unmix_log_anchored`, the shader's `anchor` row), so that light passes as a neutral. Estimate reads its grid with the stored white cleared. A JPEG or scanner TIFF has its white at full scale and keeps $L = 0$, with no cap. `fade_white_space` tags $w$ with the space it was read in: the white-balanced camera channels, named by a hash of `camera_to_working_matrix(cam_xyz, camera_wb)` and a channel marker, "" with no camera matrix. A render whose tag differs ignores $w$, and the desktop measures it again on the open frame (`_refresh_stale_fade_white`), with no roll lock. A roll keeps no camera RAW white, because it moves with each frame's exposure. + A camera RAW's clear film sits below the decoder's white by the exposure and the light, so $F^{-1}$ about $L = 0$ would scale that offset into a cast. On a RAW the matrix is always $M(L - a) + t$ (`density_anchor`). Remove Stain sets $a = w$ and the neutral $t$ above. Without it, $a = t$ is the neutral level $\max_c w_c$, so the white keeps its color: a slide's rebate is black, and with no clear film in the picture $w$ is the brightest picture color, which a per-channel anchor would add to every gray. Estimate stores $w$ with White Anchor off too and reads the grays on $\min(L - a, 0)$. A pixel above $a$ in every channel is light past the film, such as a highlight that Highlight Reconstruction decodes neutral at full scale. The matrix acts on $\min(L, a)$, and $e = \max(\min_c(L_c - a_c), 0)$ is added to every channel after it (`fade_anchor`, `unmix_log_anchored`, the shader's `anchor` row), so that light passes as a neutral. Estimate reads its grid with the stored white cleared. A JPEG or scanner TIFF has its white at full scale and keeps $L = 0$, with no cap. `fade_white_space` tags $w$ with the space it was read in: the white-balanced camera channels, named by a hash of the white-balance gains and a channel marker, so an Input ICC keeps it, "" with no camera matrix. A render whose tag differs ignores $w$, and the desktop measures it again on the open frame (`_refresh_stale_fade_white`), with no roll lock. A roll keeps no camera RAW white, because it moves with each frame's exposure. * **Density above base**: `density_above_base` is (Dmin, Dmed, Dmax) above the clear base, luminance-weighted: the thin and dense bounds (before trims; the roll's under Roll Analysis) and the metered median, each minus the base. The base is the picked one, else the rebate reader's, unmixed like the meters. A pick is the open roll's (`rolls.roll_film_base`), or the frame's own with no roll open (`process.film_base`, unhashed); in an open roll the roll's wins, with none open the frame's. The preview carries it as `film_base`; export never reads it. The reader takes the flat 8-px blocks (std over mean under 0.03, unclipped) in the outer 15% of the film box (`film_and_picture` at `AUTOCROP_DETECT_RES`), groups them by brightness with a 0.04-decade gap, and takes the largest group of at least five. It finds none without a film box, with a base darker than the picture, or on the transfer path. The preview reads it once per source, on the flat-fielded, unmixed source before the defect repairs, and passes the read to both engines. * **Scan-clip warning**: `scan_clip_fractions` reports the per-channel fraction of source pixels at or above $0.99$ linear. On a negative this clips film base and shadows to $D=0$; nothing reconstructs it. diff --git a/negpy/features/process/fade.py b/negpy/features/process/fade.py index 2c588c4e1..1ca1de367 100644 --- a/negpy/features/process/fade.py +++ b/negpy/features/process/fade.py @@ -15,7 +15,7 @@ effective_crosstalk_matrix, measure_neutral_axis_from_log, ) -from negpy.features.process.capture_color import camera_to_working_matrix, is_source_profile +from negpy.features.process.capture_color import camera_matrix_parts, is_source_profile from negpy.features.process.models import ProcessConfig from negpy.features.process.path import RenderPath, render_path @@ -68,20 +68,20 @@ def fade_matrix(process: ProcessConfig) -> Optional[np.ndarray]: def fade_white_space(cam_xyz: Optional[Sequence[Sequence[float]]], camera_wb: Optional[Sequence[float]]) -> str: """The space a camera RAW's log white is measured in: its white-balanced camera channels - (`density_channels`), named by its camera matrix with the as-shot white balance, which every RAW - decode applies at the decode or in the matrix. "" for a source with no camera matrix, whose white - is the file's own full scale.""" + (`density_channels`), named by the as-shot white balance, which every RAW decode applies at the + decode or in the matrix. The primaries after the white do not name it. "" for a source with no + camera matrix, whose white is the file's own full scale.""" if cam_xyz is None or is_source_profile(cam_xyz): return "" - matrix = camera_to_working_matrix(cam_xyz, camera_wb) - if matrix is None: + gains, primaries = camera_matrix_parts(cam_xyz, camera_wb) + if primaries is None: return "" - return hashlib.sha1(_WHITE_SPACE_CHANNELS + np.round(np.asarray(matrix, dtype=np.float64), 4).tobytes()).hexdigest()[:12] + named = b"" if gains is None else np.round(np.diag(gains).astype(np.float64), 4).tobytes() + return hashlib.sha1(_WHITE_SPACE_CHANNELS + named).hexdigest()[:12] -# Names the channels in `fade_white_space`, so a white read in working space (the camera matrix -# before the log) reads as stale and is measured again. -_WHITE_SPACE_CHANNELS = b"camera-channels" +# Names the channels in `fade_white_space`, so a tag hashed any other way reads as stale. +_WHITE_SPACE_CHANNELS = b"camera-gains" def unmix_with_fade(process: ProcessConfig, process_mode: Optional[str], white_space: str = "") -> Optional[np.ndarray]: diff --git a/tests/test_fade_white_space.py b/tests/test_fade_white_space.py index 777c2833b..49f1e992c 100644 --- a/tests/test_fade_white_space.py +++ b/tests/test_fade_white_space.py @@ -11,7 +11,7 @@ from negpy.domain.interfaces import PipelineContext from negpy.domain.models import WorkspaceConfig from negpy.features.exposure.normalization import prefilter_log_grid, unmix_log_image -from negpy.features.process.capture_color import source_cam_xyz +from negpy.features.process.capture_color import source_cam_xyz, wb_only_cam_xyz from negpy.features.process.fade import ( EstimateMethod, estimate_fade, @@ -51,6 +51,11 @@ def test_the_space_names_a_camera_and_its_white_balance(): assert fade_white_space(source_cam_xyz(np.eye(3)), None) == "" +def test_an_input_icc_keeps_the_space(): + """The white is read before the primaries, so a camera profile that swaps them leaves it valid.""" + assert fade_white_space(wb_only_cam_xyz(_CAMERA), _WB) == _SPACE + + def test_a_camera_raw_scales_density_above_a_neutral_level_at_its_white(): """Without Remove Stain the anchor is the white's cleanest channel on all three, so the white keeps its color.""" process = _slide(**_FADE, **_white()) From f7d33cc88518a5894d5774b307c35a7cf0ed2ca7 Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:29:32 +0000 Subject: [PATCH 12/46] fix(fade): clear the local bounds when a stale white is measured again The fade white is a bounds input. A white measured again on the open frame kept the cached bounds, which were read with the old white. Every other edit of the white clears them. --- negpy/desktop/controller.py | 1 + tests/test_fade_white_space.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 7870215b1..a48d6f48a 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -9958,6 +9958,7 @@ def _refresh_stale_fade_white(self) -> None: return r, g, b = measure_white(grid) or (0.0, 0.0, 0.0) refreshed = replace(process, fade_white_r=r, fade_white_g=g, fade_white_b=b, fade_white_space=space) + refreshed = replace(refreshed, **invalidate_local_bounds(refreshed)) # No undo step: undoing to the stale white would measure it again and drop the redo branch. self.session.update_config(replace(self.state.config, process=refreshed), persist=True, record_history=False) self.request_render() diff --git a/tests/test_fade_white_space.py b/tests/test_fade_white_space.py index 49f1e992c..6e7d9fdbd 100644 --- a/tests/test_fade_white_space.py +++ b/tests/test_fade_white_space.py @@ -180,7 +180,7 @@ def test_a_stale_white_is_measured_again_without_a_roll_lock_or_an_undo_step(mon applied = [] cfg = WorkspaceConfig() fake = SimpleNamespace( - state=SimpleNamespace(config=replace(cfg, process=_slide(**_FADE))), + state=SimpleNamespace(config=replace(cfg, process=_slide(**_FADE, local_floors=(-2.0, -2.0, -2.0)))), _effective_cam_xyz=lambda: (_CAMERA, _WB), chart_fit_source=lambda: object(), session=SimpleNamespace( @@ -192,7 +192,7 @@ def test_a_stale_white_is_measured_again_without_a_roll_lock_or_an_undo_step(mon AppController._refresh_stale_fade_white(fake) ((process, persist, record_history),) = applied assert persist and not record_history and rendered - assert process.fade_white_space == _SPACE + assert process.fade_white_space == _SPACE and process.local_floors == (0.0, 0.0, 0.0) assert process.fade_white_r == pytest.approx(-0.5, abs=0.01) and process.fade_white_b == pytest.approx(-0.6, abs=0.01) From af0cd2951f98d3e644dae0316fc557461424d116 Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:29:32 +0000 Subject: [PATCH 13/46] fix(fade): store a failed Estimate's camera RAW white without a roll lock A failed Estimate on a camera RAW stored the white through the roll's fade card, which locked the card away from the roll. The roll holds no camera RAW white, so the white now goes on the frame alone. --- negpy/desktop/controller.py | 5 +++++ negpy/desktop/view/sidebar/sensor.py | 4 +++- tests/test_sensor_sidebar.py | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index a48d6f48a..539314680 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -6577,6 +6577,11 @@ def set_roll_default(self, card_key: str, persist: bool = True, readback_metrics if persist: self._lock_roll_card(card_key) + def set_fade_white(self, **white) -> None: + """Store a camera RAW's fade white on the active frame. It is a reading of this frame's + decode, which no roll holds, so it locks no card.""" + self._edit_card("fade", "process", True, True, white) + def set_crop_settings(self, persist: bool = True, readback_metrics: bool = True, **changes) -> None: """Auto Crop's detection settings on the active frame. A change re-detects the crop through its detection key.""" diff --git a/negpy/desktop/view/sidebar/sensor.py b/negpy/desktop/view/sidebar/sensor.py index 681d9f7ee..724cf5f2e 100644 --- a/negpy/desktop/view/sidebar/sensor.py +++ b/negpy/desktop/view/sidebar/sensor.py @@ -567,7 +567,9 @@ def _run_estimate(self, white_anchor: bool) -> None: hint += " " + tr("No stain found to remove.") self._show_fade_hint(hint, stain) if failed: - if white: + if space and white: + self.controller.set_fade_white(**white) + elif white: self.controller.set_roll_default("fade", **white) return red = chosen.red if chosen.red is not None else conf.fade_ratio_r diff --git a/tests/test_sensor_sidebar.py b/tests/test_sensor_sidebar.py index 0fb05ebe3..178728ebc 100644 --- a/tests/test_sensor_sidebar.py +++ b/tests/test_sensor_sidebar.py @@ -335,6 +335,20 @@ def test_turning_on_remove_stain_with_no_stain_says_so(): assert "No stain found to remove." in w.fade_estimate_hint.text() +def test_a_failed_estimate_on_a_camera_raw_stores_the_white_without_a_roll_lock(): + from negpy.desktop.workers.chart_fit import ChartSource + + w, ctrl = _fade_sidebar() + camera = [[0.6722, -0.0635, -0.0963], [-0.4287, 1.246, 0.2046], [-0.0908, 0.2268, 0.5762]] + flat = np.full((32, 32, 3), 0.5, dtype=np.float32) + ctrl.chart_fit_source.return_value = ChartSource(flat, ctrl.state.config, "frame", camera, [2.0, 1.0, 1.5], (), 1) + + w._on_estimate_fade() + + ctrl.set_roll_default.assert_not_called() + assert ctrl.set_fade_white.call_args.kwargs["fade_white_space"] + + def test_remove_stain_ends_the_estimate_row(): w, _ctrl = _fade_sidebar() row = w.fade_estimate_row From ceed1f44200ff7eb6a0a18b10e37d5fa75d1823a Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:02:40 +0000 Subject: [PATCH 14/46] fix(fade): measure a stale camera RAW white in the render Only the open frame in the preview measured a stale or missing white, on the GUI thread. Export, thumbnails and batch renders skipped it and rendered without Remove Stain or the anchor until the frame was opened. Each render now measures the white on the Estimate grid before the density stage (resolve_fade_white, fade_white_grid). This applies to the CPU and GPU engines and to the tiled export's reference pass. The CPU exposure stage reads it too, for the Remove Stain target. The desktop stores the white from the render's metrics with no render, no undo step and no roll lock. With a crosstalk unmix U, the white is read after U, so the restoration now scales about U^-1 w. It scaled about w, in the wrong channels. Crosstalk alone takes no white: it keeps a neutral neutral, so a crosstalk-only frame renders as before. fade_density returns the matrix and the anchor from one pass, and the white's tag is computed once per render. --- docs/PIPELINE.md | 2 +- negpy/desktop/controller.py | 28 +++--- negpy/desktop/view/sidebar/sensor.py | 6 +- negpy/features/process/fade.py | 95 ++++++++++++++------ negpy/features/transparency/logic.py | 25 ++++++ negpy/features/transparency/processor.py | 39 +++++--- negpy/services/rendering/engine.py | 7 +- negpy/services/rendering/gpu_engine.py | 49 +++++++--- tests/test_fade_estimation.py | 5 +- tests/test_fade_white_space.py | 108 ++++++++++++++++------- tests/test_gpu_tiled_parity.py | 10 +++ tests/test_interactive_decoupling.py | 2 +- tests/test_navigate_back_memo.py | 2 +- tests/test_raw_density_order.py | 3 +- 14 files changed, 267 insertions(+), 114 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 1eb2fa7b1..86b6d226e 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -65,7 +65,7 @@ Pixel values are linear transmittance. The source corrections run in that domain White Anchor (`fade_white_anchor`, UI "Remove Stain") makes the matrix affine, $M(L - w) + t$. The stain $w$ is each channel's `WHITE_PCT` cell of the meter grid in log transmittance, without decoded zeros and cells at full scale in every channel; Estimate stores it as `fade_white_r/g/b`. It maps to the neutral $t = \min(\max_c w_c, -0.1)$ (`FADE_WHITE_HEADROOM`): the cleanest channel's own level, never within $0.1$ D of full scale. $F^{-1}$ acts on $L - w$ before $t$ is added, so the Estimate reads the grays on $\min(L - w, 0)$. The offset rides in the w lanes of the unmix rows on the GPU. - A camera RAW's clear film sits below the decoder's white by the exposure and the light, so $F^{-1}$ about $L = 0$ would scale that offset into a cast. On a RAW the matrix is always $M(L - a) + t$ (`density_anchor`). Remove Stain sets $a = w$ and the neutral $t$ above. Without it, $a = t$ is the neutral level $\max_c w_c$, so the white keeps its color: a slide's rebate is black, and with no clear film in the picture $w$ is the brightest picture color, which a per-channel anchor would add to every gray. Estimate stores $w$ with White Anchor off too and reads the grays on $\min(L - a, 0)$. A pixel above $a$ in every channel is light past the film, such as a highlight that Highlight Reconstruction decodes neutral at full scale. The matrix acts on $\min(L, a)$, and $e = \max(\min_c(L_c - a_c), 0)$ is added to every channel after it (`fade_anchor`, `unmix_log_anchored`, the shader's `anchor` row), so that light passes as a neutral. Estimate reads its grid with the stored white cleared. A JPEG or scanner TIFF has its white at full scale and keeps $L = 0$, with no cap. `fade_white_space` tags $w$ with the space it was read in: the white-balanced camera channels, named by a hash of the white-balance gains and a channel marker, so an Input ICC keeps it, "" with no camera matrix. A render whose tag differs ignores $w$, and the desktop measures it again on the open frame (`_refresh_stale_fade_white`), with no roll lock. A roll keeps no camera RAW white, because it moves with each frame's exposure. + A camera RAW's clear film sits below the decoder's white by the exposure and the light, so $F^{-1}$ about $L = 0$ would scale that offset into a cast. On a RAW the matrix is always $M(L - a) + t$ (`density_anchor`). $w$ is read after the crosstalk unmix $U$, so $a = U^{-1} w$; crosstalk alone keeps a neutral neutral and takes no $a$. Remove Stain sets $a = w$ and the neutral $t$ above. Without it, $a = t$ is the neutral level $\max_c w_c$, so the white keeps its color: a slide's rebate is black, and with no clear film in the picture $w$ is the brightest picture color, which a per-channel anchor would add to every gray. Estimate stores $w$ with White Anchor off too and reads the grays on $\min(L - a, 0)$. A pixel above $a$ in every channel is light past the film, such as a highlight that Highlight Reconstruction decodes neutral at full scale. The matrix acts on $\min(L, a)$, and $e = \max(\min_c(L_c - a_c), 0)$ is added to every channel after it (`fade_density`, `unmix_log_anchored`, the shader's `anchor` row), so that light passes as a neutral. Estimate reads its grid with the stored white cleared. A JPEG or scanner TIFF has its white at full scale and keeps $L = 0$, with no cap. `fade_white_space` tags $w$ with the space it was read in: the white-balanced camera channels, named by a hash of the white-balance gains and a channel marker, so an Input ICC keeps it, "" with no camera matrix. A render whose tag differs, or that has no $w$, measures $w$ on the Estimate grid before the density stage (`resolve_fade_white`), in both engines and in the tiled export's reference pass. The desktop stores it with no roll lock. A roll keeps no camera RAW white, because it moves with each frame's exposure. * **Density above base**: `density_above_base` is (Dmin, Dmed, Dmax) above the clear base, luminance-weighted: the thin and dense bounds (before trims; the roll's under Roll Analysis) and the metered median, each minus the base. The base is the picked one, else the rebate reader's, unmixed like the meters. A pick is the open roll's (`rolls.roll_film_base`), or the frame's own with no roll open (`process.film_base`, unhashed); in an open roll the roll's wins, with none open the frame's. The preview carries it as `film_base`; export never reads it. The reader takes the flat 8-px blocks (std over mean under 0.03, unclipped) in the outer 15% of the film box (`film_and_picture` at `AUTOCROP_DETECT_RES`), groups them by brightness with a 0.04-decade gap, and takes the largest group of at least five. It finds none without a film box, with a base darker than the picture, or on the transfer path. The preview reads it once per source, on the flat-fielded, unmixed source before the defect repairs, and passes the read to both engines. * **Scan-clip warning**: `scan_clip_fractions` reports the per-channel fraction of source pixels at or above $0.99$ linear. On a negative this clips film base and shadows to $D=0$; nothing reconstructs it. diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 539314680..1151a9f6f 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -9943,30 +9943,26 @@ def _on_render_finished(self, _result: Any, metrics: Dict[str, Any]) -> None: return if self._pending_render_task is None and not metrics.get("ephemeral") and not metrics.get("interactive"): - self._refresh_stale_fade_white() + self._store_measured_fade_white(metrics) self._dispatch_pending_render() AppController._continue_background_work(self) - def _refresh_stale_fade_white(self) -> None: - """Measure a camera RAW's fade white again once the stored one belongs to another decode - space (`fade_white_stale`). It is a reading of this frame's decode, so it locks no card.""" - from negpy.desktop.workers.chart_fit import fade_estimate_grid - from negpy.features.process.fade import fade_white_space, fade_white_stale, measure_white + def _store_measured_fade_white(self, metrics: Dict[str, Any]) -> None: + """Store the fade white a render measured for a stale one (`resolve_fade_white`), so the + next render reads it. It is a reading of this frame's decode, so it locks no card.""" + from negpy.features.process.fade import fade_white_space, fade_white_stale, with_fade_white + measured = metrics.get("fade_white") process = self.state.config.process - space = fade_white_space(*self._effective_cam_xyz()) - if not fade_white_stale(process, space): + if measured is None or measured[3] != fade_white_space(*self._effective_cam_xyz()): return - source = self.chart_fit_source() - grid = None if source is None else fade_estimate_grid(source) - if grid is None: + if not fade_white_stale(process, measured[3]): return - r, g, b = measure_white(grid) or (0.0, 0.0, 0.0) - refreshed = replace(process, fade_white_r=r, fade_white_g=g, fade_white_b=b, fade_white_space=space) + refreshed = with_fade_white(process, measured) refreshed = replace(refreshed, **invalidate_local_bounds(refreshed)) - # No undo step: undoing to the stale white would measure it again and drop the redo branch. - self.session.update_config(replace(self.state.config, process=refreshed), persist=True, record_history=False) - self.request_render() + # No render: the frame on screen already shows this white. No undo step: undoing to the + # stale white would measure it again and drop the redo branch. + self.session.update_config(replace(self.state.config, process=refreshed), persist=True, render=False, record_history=False) def _freeze_resolved_auto_crop(self, metrics: Dict[str, Any]) -> None: """Store the crop this render detected, so nothing detects it a second time. diff --git a/negpy/desktop/view/sidebar/sensor.py b/negpy/desktop/view/sidebar/sensor.py index 724cf5f2e..40aae638f 100644 --- a/negpy/desktop/view/sidebar/sensor.py +++ b/negpy/desktop/view/sidebar/sensor.py @@ -553,9 +553,9 @@ def _run_estimate(self, white_anchor: bool) -> None: estimates = estimate_fade(grid, white_anchor=anchor, camera_raw=bool(space)) chosen, other = estimates[method], estimates[next(m for m in EstimateMethod if m != method)] failed = bool(chosen.reason) and chosen.green == 1.0 and chosen.blue == 1.0 - # The white is stored with the survivals and never measured per render. A failed reading - # stores it only where it is plainly a stain on clear film. A camera RAW's render scales - # density above its white, so it stores the white with Remove Stain off too. + # The white is stored with the survivals; a render measures one only for a stale camera RAW + # white. A failed reading stores it only where it is plainly a stain on clear film. A camera + # RAW's render scales density above its white, so it stores the white with Remove Stain off too. white = {} if (anchor or space) and chosen.white is not None and (not failed or space or stain_suggested(chosen.white)): white = dict(zip(("fade_white_r", "fade_white_g", "fade_white_b"), chosen.white), fade_white_space=space) diff --git a/negpy/features/process/fade.py b/negpy/features/process/fade.py index 1ca1de367..ed8b276ac 100644 --- a/negpy/features/process/fade.py +++ b/negpy/features/process/fade.py @@ -2,9 +2,9 @@ of the dye survivals.""" import hashlib -from dataclasses import dataclass +from dataclasses import dataclass, replace from enum import StrEnum -from typing import Optional, Sequence +from typing import Callable, NamedTuple, Optional, Sequence import numpy as np @@ -84,45 +84,66 @@ def fade_white_space(cam_xyz: Optional[Sequence[Sequence[float]]], camera_wb: Op _WHITE_SPACE_CHANNELS = b"camera-gains" -def unmix_with_fade(process: ProcessConfig, process_mode: Optional[str], white_space: str = "") -> Optional[np.ndarray]: - """The log-density matrix a render applies: the crosstalk unmix, then the fade restoration of - the densities it recovers (`fade @ unmix`). None when neither applies. *white_space* is the - render's `fade_white_space`. A stored white makes it a (3, 4) affine `M (L - a) + t`, the last - column the log offset `t - M a`. Remove Stain maps each channel's white a = w to a neutral t. On a - camera RAW, whose white sits below full scale, Remove Stain off scales density above the neutral - level a = t of the white's cleanest channel (`density_anchor`), so the white keeps its color.""" +class FadeDensity(NamedTuple): + """The log-density matrix a render applies (`unmix_with_fade`) and the point it scales about + (`fade_anchor`).""" + + matrix: Optional[np.ndarray] + anchor: Optional[tuple[float, float, float]] + + +def fade_density(process: ProcessConfig, process_mode: Optional[str], white_space: str = "") -> FadeDensity: + """The crosstalk unmix U, then the fade restoration F of the densities it recovers (`F @ U`). + The matrix is None when neither applies. *white_space* is the render's `fade_white_space`. A + stored white w makes it a (3, 4) affine `F (U L - w) + t`, the last column the log offset. w is + read after U, so the affine scales about `a = U^-1 w`. Remove Stain maps each channel's white to + a neutral t. On a camera RAW, whose white sits below full scale, Remove Stain off scales density + above the neutral level w = t of the white's cleanest channel (`density_anchor`). + + The anchor is a camera RAW's a. A pixel above it in every channel is light past the film, with no + dye to restore: `unmix_log_image` passes that part through as a neutral, so a blown highlight + stays white. Any other source's white is at full scale, with no light past it.""" unmix = effective_crosstalk_matrix(process, process_mode) fade = fade_matrix(process) matrix = unmix if fade is None else (fade if unmix is None else fade @ unmix) - terms = _white_terms(process, white_space, matrix is not None) + terms = _white_terms(process, white_space, fade is not None) if terms is None: - return matrix - anchor, target = terms + return FadeDensity(matrix, None) + white, target = terms + a = white if unmix is None else _unmixed_point(unmix, white) linear = np.eye(3) if matrix is None else matrix - return np.hstack([linear, (target - linear @ anchor)[:, None]]) + affine = np.hstack([linear, (target - linear @ a)[:, None]]) + return FadeDensity(affine, (float(a[0]), float(a[1]), float(a[2])) if white_space else None) + + +def _unmixed_point(unmix: np.ndarray, point: np.ndarray) -> np.ndarray: + """The log point that *unmix* maps to *point*; *point* itself when *unmix* has no inverse.""" + try: + return np.linalg.solve(np.asarray(unmix, dtype=np.float64), point) + except np.linalg.LinAlgError: + return point + + +def unmix_with_fade(process: ProcessConfig, process_mode: Optional[str], white_space: str = "") -> Optional[np.ndarray]: + """`fade_density`'s matrix.""" + return fade_density(process, process_mode, white_space).matrix def fade_anchor(process: ProcessConfig, process_mode: Optional[str], white_space: str = "") -> Optional[tuple[float, float, float]]: - """The log point `unmix_with_fade` scales density about, when it has one. A pixel above it in every - channel is light past the film, which has no dye to restore: `unmix_log_image` passes that part - through as a neutral, so a blown highlight stays white. A camera RAW only: any other source's - white is at full scale, with no light past it.""" - if not white_space: - return None - acts = effective_crosstalk_matrix(process, process_mode) is not None or fade_matrix(process) is not None - terms = _white_terms(process, white_space, acts) - return None if terms is None else (float(terms[0][0]), float(terms[0][1]), float(terms[0][2])) + """`fade_density`'s anchor.""" + return fade_density(process, process_mode, white_space).anchor -def _white_terms(process: ProcessConfig, white_space: str, density_acts: bool) -> Optional[tuple[np.ndarray, np.ndarray]]: - """(anchor, target) of the stored white's affine, or None when no white acts. *density_acts*: a - crosstalk or fade matrix applies.""" +def _white_terms(process: ProcessConfig, white_space: str, fade_acts: bool) -> Optional[tuple[np.ndarray, np.ndarray]]: + """(white, target) of the stored white's affine, both after the crosstalk unmix, or None when no + white acts. *fade_acts*: Fade Restoration applies. Crosstalk alone keeps a neutral neutral, so a + camera RAW needs no anchor for it.""" white = _fade_white(process, white_space) if white is None: return None if process.fade_white_anchor: return white, np.full(3, _white_target(white)) - if white_space and density_acts: + if white_space and fade_acts: level = np.asarray(density_anchor(white, False), dtype=np.float64) return level, level return None @@ -160,12 +181,32 @@ def fade_white_target(process: ProcessConfig, white_space: str = "") -> Optional def fade_white_stale(process: ProcessConfig, white_space: str) -> bool: """Whether a camera RAW's fade needs its white measured again: Fade Restoration or Remove Stain - acts, and the stored white was measured in another space (or never) for this render.""" + acts, and the stored white was measured in another space (or never).""" if not white_space or render_path(process) is RenderPath.PRINT or process.fade_white_space == white_space: return False return process.fade_white_anchor or fade_matrix(process) is not None +#: A white a render measured for itself: (r, g, b, fade_white_space). +MeasuredWhite = tuple[float, float, float, str] + + +def resolve_fade_white(process: ProcessConfig, white_space: str, grid: Callable[[], ImageBuffer]) -> Optional[MeasuredWhite]: + """The white a render measures on the Estimate *grid* when the stored one is stale, else None. + Each engine measures it before the density stage, so a frame renders alike everywhere.""" + if not fade_white_stale(process, white_space): + return None + r, g, b = measure_white(grid()) or (0.0, 0.0, 0.0) + return (r, g, b, white_space) + + +def with_fade_white(process: ProcessConfig, white: Optional[MeasuredWhite]) -> ProcessConfig: + """*process* carrying a white `resolve_fade_white` measured; unchanged with none.""" + if white is None: + return process + return replace(process, fade_white_r=white[0], fade_white_g=white[1], fade_white_b=white[2], fade_white_space=white[3]) + + #: The return shape of `measure_neutral_axis_from_log`: midtone, shadow, highlight or None, confidence. NeutralAxisRefs = tuple[tuple[float, float, float], tuple[float, float, float], object, float] diff --git a/negpy/features/transparency/logic.py b/negpy/features/transparency/logic.py index 2a316e857..91f37e929 100644 --- a/negpy/features/transparency/logic.py +++ b/negpy/features/transparency/logic.py @@ -573,6 +573,31 @@ def density_stage_grid( return relog_matrix_image(unmix_log_image(grid, stage.unmix, stage.anchor), stage.post) +def fade_white_grid( + process: ProcessConfig, + process_mode: Optional[str], + cam_xyz: Optional[list], + camera_wb: Optional[list], + image: ImageBuffer, + roi: Optional[tuple[int, int, int, int]], + analysis_buffer: float, + log_grid: Optional[Callable[[], ImageBuffer]] = None, +) -> ImageBuffer: + """The grid Fade Estimate meters and the render reads its white on: *image*'s white-balanced + camera channels (a Positive source's own) after the crosstalk unmix, with no fade or white. + *log_grid* gives the prefiltered grid of *image* when the caller has it.""" + from negpy.features.exposure.normalization import effective_crosstalk_matrix, prefilter_log_grid, unmix_log_image + from negpy.features.process.capture_color import apply_camera_matrix + + crosstalk = effective_crosstalk_matrix(process, process_mode) + channels = density_channels(process, cam_xyz, camera_wb) if crosstalk is None else density_stage(process, cam_xyz, camera_wb, crosstalk) + if channels.pre is None and log_grid is not None: + grid = log_grid() + else: + grid = prefilter_log_grid(image if channels.pre is None else apply_camera_matrix(image, channels.pre), roi, analysis_buffer) + return unmix_log_image(grid, channels.unmix) + + def transfer_bounds(density_range: float = TRANSFER_DENSITY_RANGE) -> Tuple[Tuple[float, ...], Tuple[float, ...]]: """ The fixed (floors, ceils) the transparency path normalizes with — the decoder's diff --git a/negpy/features/transparency/processor.py b/negpy/features/transparency/processor.py index c2cefab60..c308f9548 100644 --- a/negpy/features/transparency/processor.py +++ b/negpy/features/transparency/processor.py @@ -25,14 +25,14 @@ resolve_analysis_region, unmix_log_image, ) -from negpy.features.process.fade import fade_anchor, fade_white_space, unmix_with_fade +from negpy.features.process.fade import fade_density, fade_white_space, resolve_fade_white, with_fade_white from negpy.features.process.capture_color import apply_camera_matrix from negpy.features.process.models import ProcessConfig from negpy.features.process.path import RenderPath, render_path from negpy.features.transparency.logic import ( TRANSFER_DENSITY_RANGE, apply_transfer_curve, - density_channels, + fade_white_grid, density_stage, measure_transfer_neutral_axis, transfer_assumed_anchor, @@ -67,12 +67,21 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: """ epsilon = 1e-6 image = np.ascontiguousarray(image, dtype=np.float32) + an_roi, an_buffer = resolve_analysis_region( + image.shape, context.active_roi, self.config.analysis_buffer, self.config.analysis_rect, self.config.meter_rect + ) + + def white_grid() -> ImageBuffer: + return fade_white_grid(self.config, context.process_mode, context.cam_xyz, context.camera_wb, image, an_roi, an_buffer) + + white_space = fade_white_space(context.cam_xyz, context.camera_wb) + measured = resolve_fade_white(self.config, white_space, white_grid) + if measured is not None: + context.metrics["fade_white"] = measured # Honoured here too: a rig-calibrated matrix is a capture correction like Hue Trim, and # the mode gate keeps a negative's profile from touching a slide. Inert at the shipped # default, so the as-captured render is unperturbed. - white_space = fade_white_space(context.cam_xyz, context.camera_wb) - unmix = unmix_with_fade(self.config, context.process_mode, white_space) - anchor = fade_anchor(self.config, context.process_mode, white_space) + unmix, anchor = fade_density(with_fade_white(self.config, measured), context.process_mode, white_space) stage = density_stage(self.config, context.cam_xyz, context.camera_wb, unmix, anchor) linear = image if stage.pre is None else apply_camera_matrix(negpy_core.nan_to_num(image, epsilon, 1.0, epsilon), stage.pre) img_log = negpy_core.log_density_open(np.ascontiguousarray(linear, dtype=np.float32)) @@ -92,18 +101,20 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: # Shared prefilter for the neutral axis and Auto Density/Grade's four meters -- # working-space (post camera matrix) and post-unmix, since that is what this curve # itself consumes. - an_roi, an_buffer = resolve_analysis_region( - linear.shape, context.active_roi, self.config.analysis_buffer, self.config.analysis_rect, self.config.meter_rect - ) log_grid = prefilter_log_grid(linear, an_roi, an_buffer) prefiltered = relog_matrix_image(unmix_log_image(log_grid, stage.unmix, stage.anchor), stage.post) if context.wants_fade_grid: - # Fade Estimate meters this grid, rendered with the fade at identity, in the channels - # the fade acts on: a camera's white-balanced channels or a Positive source's own. - channels = stage if unmix is not None else density_channels(self.config, context.cam_xyz, context.camera_wb) - if channels.pre is None and stage.pre is not None: - log_grid = prefilter_log_grid(image, an_roi, an_buffer) - context.metrics["transfer_grid"] = unmix_log_image(log_grid, channels.unmix, channels.anchor) + # *log_grid* is the prefiltered *image* itself when no matrix runs before the log. + context.metrics["transfer_grid"] = fade_white_grid( + self.config, + context.process_mode, + context.cam_xyz, + context.camera_wb, + image, + an_roi, + an_buffer, + (lambda: log_grid) if stage.pre is None else None, + ) # Cast Removal's neutral axis is metered on the working-space log image the curve # consumes, since the camera matrix above would leave a meter reading a different diff --git a/negpy/services/rendering/engine.py b/negpy/services/rendering/engine.py index baaca9014..dc8434180 100644 --- a/negpy/services/rendering/engine.py +++ b/negpy/services/rendering/engine.py @@ -1,3 +1,4 @@ +from dataclasses import replace from typing import Optional, Any, Callable, Tuple import numpy as np @@ -28,7 +29,7 @@ geometry_kwargs, normalized_roi, ) -from negpy.features.process.fade import fade_white_space, fade_white_target +from negpy.features.process.fade import fade_white_space, fade_white_target, with_fade_white from negpy.features.process.hue import apply_hue_trim from negpy.features.process.path import RenderPath, render_path from negpy.features.transparency.processor import TransferProcessor, TransparencyBaseProcessor @@ -339,7 +340,9 @@ def run_base(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: timing.lap("diffusion") def run_exposure(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: - img_out = exposure_processor(settings, fade_white_space(ctx.cam_xyz, ctx.camera_wb)).process(img_in, ctx) + # The base stage reports a white it measured for itself (`resolve_fade_white`). + measured = replace(settings, process=with_fade_white(settings.process, ctx.metrics.get("fade_white"))) + img_out = exposure_processor(measured, fade_white_space(ctx.cam_xyz, ctx.camera_wb)).process(img_in, ctx) # Rides this stage: it needs the print, and its own stage would re-run everything behind # it on a drag. Stays inside the flat intent below, being a capture fix, not a look. return apply_hue_trim(img_out, settings.process.hue_trim) diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index 5f424aada..bffaf60fd 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -3,6 +3,7 @@ import os import struct import time +from dataclasses import replace from typing import Any, Dict, Optional, Tuple import negpy_core @@ -80,6 +81,7 @@ from negpy.features.transparency.logic import ( density_stage, density_stage_grid, + fade_white_grid, TRANSFER_CONSTANTS, ZONE_BLACK_TAPER, TRANSFER_DENSITY_RANGE, @@ -94,7 +96,7 @@ wb_split_geometry, zone_geometry, ) -from negpy.features.process.fade import fade_anchor, fade_white_space, fade_white_target, unmix_with_fade +from negpy.features.process.fade import fade_density, fade_white_space, fade_white_target, resolve_fade_white, with_fade_white from negpy.features.exposure.bw_filter import collapse_weights, plane_weights, printed_exposure from negpy.features.process.models import ProcessMode, per_channel_point_offsets, pooled_neutral_axis from negpy.features.process.path import RenderPath, render_path @@ -323,11 +325,6 @@ def _update_analysis_cache(cache, key, bounds, anchor, textural, neutral, shadow ) -def _fade_anchor(settings: WorkspaceConfig, cam_xyz: Optional[list], camera_wb: Optional[list]) -> Optional[tuple[float, float, float]]: - """The fade's anchor for this source (`fade.fade_anchor`), as TransparencyBaseProcessor reads it.""" - return fade_anchor(settings.process, settings.process.process_mode, fade_white_space(cam_xyz, camera_wb)) - - class GPUEngine: """ Core GPU orchestration engine using WebGPU. @@ -786,6 +783,24 @@ def process_to_texture( y1, y2, x1, x2 = (0, h_rot, 0, w_rot) if full_frame and not tiling_mode else roi crop_w, crop_h = max(1, x2 - x1), max(1, y2 - y1) + def _white_grid() -> np.ndarray: + source, buffer = _build_analysis_source( + img, + settings.geometry, + roi, + settings.process.analysis_buffer, + settings.process.analysis_rect, + tiling_mode, + APP_CONFIG.preview_render_size, + meter_rect=None if crops_frame(settings.geometry) else settings.process.meter_rect, + ) + return fade_white_grid(settings.process, settings.process.process_mode, cam_xyz, camera_wb, source, None, buffer) + + # Before every key and matrix below, which read the white. A tile takes its export's white. + white_space = fade_white_space(cam_xyz, camera_wb) + measured_white = None if tiling_mode else resolve_fade_white(settings.process, white_space, _white_grid) + settings = replace(settings, process=with_fade_white(settings.process, measured_white)) + # Reuse the per-source meter across creative-slider previews: fill any missing # override from the cache so the needs_* gates below skip the analysis entirely. analysis_key = None @@ -834,12 +849,10 @@ def process_to_texture( sorted_grid = None scan_clip_fractions = None analysis_source = None - unmix_m = unmix_with_fade(settings.process, settings.process.process_mode, fade_white_space(cam_xyz, camera_wb)) + unmix_m, fade_anchor_m = fade_density(settings.process, settings.process.process_mode, white_space) # The transparency curve reads working space, so its meter must too: the same # camera matrices TransparencyBaseProcessor applies, on the grid. - slide_stage = ( - density_stage(settings.process, cam_xyz, camera_wb, unmix_m, _fade_anchor(settings, cam_xyz, camera_wb)) if transfer else None - ) + slide_stage = density_stage(settings.process, cam_xyz, camera_wb, unmix_m, fade_anchor_m) if transfer else None # A drag renders without metrics and caches no densities; the release that reads them # back re-runs the grid once so the cache fills. # A source with no base is known as such, so it never re-enters the grid for it. @@ -1146,6 +1159,8 @@ def _analyze_bounds() -> LogNegativeBounds: shadow_point=shadow_point, highlight_point=highlight_point, unmix=unmix_m, + fade_anchor=fade_anchor_m, + white_space=white_space, cam_xyz=cam_xyz, camera_wb=camera_wb, contrast_mask=mask_uniform, @@ -1748,6 +1763,8 @@ def _analyze_bounds() -> LogNegativeBounds: # Raw cast refs so the chart can re-solve the exact render curves. "neutral_axis_refs": neutral_axis_refs, } + if measured_white is not None: + metrics["fade_white"] = measured_white # A drag with no read leaves the base out, so the merged metrics keep the last frame's. if not transfer and film_base is None and source_base_log is NOT_READ: del metrics["film_base_density"] @@ -1865,6 +1882,8 @@ def _upload_unified_uniforms( shadow_point: Optional[float] = None, highlight_point: Optional[float] = None, unmix: Optional[np.ndarray] = None, + fade_anchor: Optional[Tuple[float, float, float]] = None, + white_space: str = "", cam_xyz: Optional[list] = None, camera_wb: Optional[list] = None, contrast_mask: Optional[Tuple[Any, ...]] = None, @@ -1916,7 +1935,7 @@ def _upload_unified_uniforms( # before the log, read only on the transfer path, and the post rows after. Identity when # off; a (3, 4) affine packs its offset in w, and the w lane of post0 turns post on. The fade # anchor rides its own row, its w lane on when one acts. - cam, unmix, post, anchor = density_stage(settings.process, cam_xyz, camera_wb, unmix, _fade_anchor(settings, cam_xyz, camera_wb)) + cam, unmix, post, anchor = density_stage(settings.process, cam_xyz, camera_wb, unmix, fade_anchor) if unmix is None: unmix = np.eye(3) unmix_w = unmix[:, 3] if unmix.shape[1] == 4 else (0.0, 0.0, 0.0) @@ -1993,7 +2012,7 @@ def _upload_unified_uniforms( LogNegativeBounds(adj_floors, adj_ceils), neutral_axis_refs, settings.exposure.cast_removal_strength, - fade_white_target(settings.process, fade_white_space(cam_xyz, camera_wb)), + fade_white_target(settings.process, white_space), ) # A finished positive skips the baseline gain, and the zone_taper.y lane below tells # the shader to skip display_rendering too. Matches transparency/logic.py. @@ -2913,6 +2932,8 @@ def _process_tiled( ) global_cdfs = self._readback_clahe_cdf() + # Every tile and global meter below takes the white the reference pass measured. + settings = replace(settings, process=with_fade_white(settings.process, metrics_ref.get("fade_white"))) if transient_tiles: normalized_log = metrics_ref.get("normalized_log") @@ -2966,7 +2987,7 @@ def _analysis_img() -> np.ndarray: # Unmixed like the non-tiled path, lazily: skipped when bounds are locked and no # auto refs, anchor or textural range need it. - unmix_m = unmix_with_fade(settings.process, settings.process.process_mode, fade_white_space(cam_xyz, camera_wb)) + unmix_m, fade_anchor_m = fade_density(settings.process, settings.process.process_mode, fade_white_space(cam_xyz, camera_wb)) log_grid_cache: Optional[np.ndarray] = None prefiltered_cache: Optional[np.ndarray] = None sorted_cache: Optional[np.ndarray] = None @@ -3015,7 +3036,7 @@ def _meter_grid() -> np.ndarray: if not transfer: return _prefiltered() if transfer_grid is None: - slide_stage = density_stage(settings.process, cam_xyz, camera_wb, unmix_m, _fade_anchor(settings, cam_xyz, camera_wb)) + slide_stage = density_stage(settings.process, cam_xyz, camera_wb, unmix_m, fade_anchor_m) transfer_grid = density_stage_grid(slide_stage, _analysis_img(), meter_roi, meter_buffer, _log_grid) return transfer_grid diff --git a/tests/test_fade_estimation.py b/tests/test_fade_estimation.py index 515862098..b20d6f4e4 100644 --- a/tests/test_fade_estimation.py +++ b/tests/test_fade_estimation.py @@ -150,7 +150,6 @@ def test_estimate_meters_the_render_grid_before_the_fade(): from dataclasses import replace from negpy.desktop.workers.chart_fit import ChartSource, fade_estimate_grid - from negpy.features.process.fade import resolve_fade_matrix from negpy.features.process.models import ProcessMode from negpy.kernel.system.config import DEFAULT_WORKSPACE_CONFIG from negpy.services.rendering.image_processor import ImageProcessor @@ -182,8 +181,8 @@ def test_estimate_meters_the_render_grid_before_the_fade(): _, metrics = ImageProcessor(use_gpu=False).run_pipeline( image, cfg, "fade-grid", render_size_ref=256.0, prefer_gpu=False, cache_stages=False, wants_fade_grid=True ) - fade = resolve_fade_matrix(1.0, ratio_g, ratio_b) - np.testing.assert_allclose(metrics["transfer_grid"], grid @ fade.T, atol=1e-5) + # The grid holds no fade, whatever the render's own. + np.testing.assert_allclose(metrics["transfer_grid"], grid, atol=1e-5) def _record_axis(monkeypatch, confidences): diff --git a/tests/test_fade_white_space.py b/tests/test_fade_white_space.py index 6e7d9fdbd..10f41efc3 100644 --- a/tests/test_fade_white_space.py +++ b/tests/test_fade_white_space.py @@ -10,12 +10,13 @@ from negpy.desktop.controller import AppController from negpy.domain.interfaces import PipelineContext from negpy.domain.models import WorkspaceConfig -from negpy.features.exposure.normalization import prefilter_log_grid, unmix_log_image +from negpy.features.exposure.normalization import effective_crosstalk_matrix, prefilter_log_grid, unmix_log_image from negpy.features.process.capture_color import source_cam_xyz, wb_only_cam_xyz from negpy.features.process.fade import ( EstimateMethod, estimate_fade, fade_anchor, + fade_density, fade_white_space, fade_white_stale, fade_white_target, @@ -153,59 +154,82 @@ def test_a_camera_raw_estimate_ignores_the_exposure(): assert (a.green, a.blue) == pytest.approx((b.green, b.blue), abs=1e-3) -def test_the_processor_reads_the_space_from_the_capture(): - """The CPU render tags the white with the context's camera, so a stale white falls back to no zero shift.""" - img = np.random.default_rng(1).uniform(0.05, 0.25, (32, 48, 3)).astype(np.float32) - - def restored(process): - ctx = PipelineContext( - original_size=img.shape[:2], scale_factor=1.0, process_mode=ProcessMode.E6, cam_xyz=_CAMERA, camera_wb=_WB, wants_uv_grid=False - ) - TransparencyBaseProcessor(process).process(img, ctx) - return ctx.metrics["normalized_log"] +def _restored(process, img): + ctx = PipelineContext( + original_size=img.shape[:2], scale_factor=1.0, process_mode=ProcessMode.E6, cam_xyz=_CAMERA, camera_wb=_WB, wants_uv_grid=False + ) + TransparencyBaseProcessor(process).process(img, ctx) + return ctx.metrics["normalized_log"], ctx - plain = restored(_slide(linear_raw=True, **_FADE)) - stale = restored(_slide(linear_raw=True, **_FADE, **_white(space="old"))) - tagged = restored(_slide(linear_raw=True, **_FADE, **_white())) - np.testing.assert_array_equal(stale, plain) - assert not np.allclose(tagged, plain) +@pytest.mark.parametrize("stored", ["", "old"]) +def test_a_render_measures_a_stale_white_for_itself(stored): + """Export and thumbnails read no desktop state, so the render measures the white it needs.""" + img = np.random.default_rng(1).uniform(0.05, 0.25, (32, 48, 3)).astype(np.float32) + stale, ctx = _restored(_slide(linear_raw=True, **_FADE, **_white(space=stored)), img) + r, g, b, space = ctx.metrics["fade_white"] + assert space == _SPACE + tagged, ctx = _restored(_slide(linear_raw=True, **_FADE, **_white((r, g, b))), img) + np.testing.assert_array_equal(stale, tagged) + assert "fade_white" not in ctx.metrics -def test_a_stale_white_is_measured_again_without_a_roll_lock_or_an_undo_step(monkeypatch): - import negpy.desktop.workers.chart_fit as chart_fit - grid = np.full((8, 8, 3), -0.7, dtype=np.float32) - grid[:2] = (-0.5, -0.45, -0.6) - monkeypatch.setattr(chart_fit, "fade_estimate_grid", lambda source: grid) +def test_a_measured_white_is_stored_without_a_roll_lock_a_render_or_an_undo_step(): applied = [] cfg = WorkspaceConfig() fake = SimpleNamespace( state=SimpleNamespace(config=replace(cfg, process=_slide(**_FADE, local_floors=(-2.0, -2.0, -2.0)))), _effective_cam_xyz=lambda: (_CAMERA, _WB), - chart_fit_source=lambda: object(), session=SimpleNamespace( - update_config=lambda config, persist, record_history: applied.append((config.process, persist, record_history)) + update_config=lambda config, persist, render, record_history: applied.append((config.process, persist, render, record_history)) ), - request_render=lambda: rendered.append(True), ) - rendered = [] - AppController._refresh_stale_fade_white(fake) - ((process, persist, record_history),) = applied - assert persist and not record_history and rendered + AppController._store_measured_fade_white(fake, {"fade_white": (-0.5, -0.45, -0.6, _SPACE)}) + ((process, persist, render, record_history),) = applied + assert persist and not render and not record_history assert process.fade_white_space == _SPACE and process.local_floors == (0.0, 0.0, 0.0) - assert process.fade_white_r == pytest.approx(-0.5, abs=0.01) and process.fade_white_b == pytest.approx(-0.6, abs=0.01) + assert (process.fade_white_r, process.fade_white_g, process.fade_white_b) == (-0.5, -0.45, -0.6) + # Another frame's white, or one already stored, is left alone. + AppController._store_measured_fade_white(fake, {"fade_white": (-0.5, -0.45, -0.6, "other")}) + fake.state.config = replace(cfg, process=process) + AppController._store_measured_fade_white(fake, {"fade_white": (-0.4, -0.4, -0.4, _SPACE)}) + assert len(applied) == 1 + + +def _crosstalk(**kw) -> ProcessConfig: + unmix = ((1.0, -0.12, -0.03), (-0.08, 1.0, -0.15), (-0.02, -0.1, 1.0)) + return _slide(crosstalk_matrix=unmix, crosstalk_strength=1.0, crosstalk_process=ProcessMode.E6, **kw) + + +def test_crosstalk_alone_takes_no_white(): + """Crosstalk keeps a neutral neutral, so it renders the same whatever white is stored.""" + assert not fade_white_stale(_crosstalk(), _SPACE) + assert fade_anchor(_crosstalk(**_white()), ProcessMode.E6, _SPACE) is None + + +@pytest.mark.parametrize("remove_stain", [False, True]) +def test_with_crosstalk_the_white_maps_to_its_target(remove_stain): + """w is read after the crosstalk unmix, so the restoration scales about the point the unmix maps to w.""" + process = _crosstalk(fade_white_anchor=remove_stain, **_FADE, **_white()) + matrix, anchor = fade_density(process, ProcessMode.E6, _SPACE) + unmix = effective_crosstalk_matrix(process, ProcessMode.E6) + white = np.asarray(_RAW_WHITE) if remove_stain else np.full(3, max(_RAW_WHITE)) + np.testing.assert_allclose(unmix @ np.asarray(anchor), white, atol=1e-9) + target = fade_white_target(process, _SPACE) if remove_stain else max(_RAW_WHITE) + np.testing.assert_allclose(matrix[:, :3] @ np.asarray(anchor) + matrix[:, 3], target, atol=1e-9) @pytest.mark.skipif(not GPUDevice.get().is_available, reason="GPU not available") +@pytest.mark.parametrize("stored", [_SPACE, "old"]) @pytest.mark.parametrize("anchor", [False, True]) -def test_gpu_matches_cpu_on_a_camera_raw(anchor): +def test_gpu_matches_cpu_on_a_camera_raw(anchor, stored): from negpy.services.rendering.image_processor import ImageProcessor processor = ImageProcessor() if processor.engine_gpu is None: pytest.skip("GPU engine not initialised") cfg = WorkspaceConfig() - settings = replace(cfg, process=_slide(linear_raw=True, fade_white_anchor=anchor, **_FADE, **_white())) + settings = replace(cfg, process=_slide(linear_raw=True, fade_white_anchor=anchor, **_FADE, **_white(space=stored))) img = np.random.default_rng(2).uniform(0.03, 0.25, (96, 128, 3)).astype(np.float32) def render(prefer_gpu): @@ -264,3 +288,25 @@ def run_pipeline(self, buf, settings, *args, **kwargs): (process,) = seen assert (process.fade_white_r, process.fade_white_g, process.fade_white_b) == (0.0, 0.0, 0.0) assert not process.fade_white_anchor and process.fade_ratio_g == 1.0 + + +def test_an_export_renders_a_stale_white_as_the_measured_one(): + """The exposure stage reads the white the base stage measured, as Remove Stain's target sets Cast Removal.""" + from negpy.services.rendering.image_processor import ImageProcessor + + # A faded gray card under a stained white, two stops below full scale. + img = (_synthetic_neutral_slide(0.8, 0.6, size=96) * np.float32((0.22, 0.25, 0.18))).astype(np.float32) + base = WorkspaceConfig() + stale = replace(base, process=_slide(linear_raw=True, fade_white_anchor=True, **_FADE, **_white(space="old"))) + stale = replace(stale, exposure=replace(stale.exposure, cast_removal_strength=1.0)) + + def render(settings): + return ImageProcessor(use_gpu=False).run_pipeline( + img.copy(), settings, "stale-export", render_size_ref=96.0, prefer_gpu=False, cache_stages=False, cam_xyz=_CAMERA, camera_wb=_WB + ) + + out, metrics = render(stale) + r, g, b, _space = metrics["fade_white"] + stored, _ = render(replace(stale, process=replace(stale.process, **_white((r, g, b))))) + np.testing.assert_array_equal(np.asarray(out), np.asarray(stored)) + assert metrics["neutral_axis_refs"] is not None diff --git a/tests/test_gpu_tiled_parity.py b/tests/test_gpu_tiled_parity.py index 4c9220442..325427eeb 100644 --- a/tests/test_gpu_tiled_parity.py +++ b/tests/test_gpu_tiled_parity.py @@ -11,6 +11,7 @@ from negpy.domain.models import WorkspaceConfig from negpy.features.local.models import LocalAdjustmentsConfig, LocalMask, MaskKey, MaskShape +from negpy.features.process.fade import fade_white_space from negpy.features.process.models import ProcessMode from negpy.infrastructure.gpu.device import GPUDevice from negpy.services.rendering.gpu_engine import GPUEngine @@ -245,6 +246,15 @@ def test_tiled_raw_slide_meters_like_the_preview(self): def test_tiled_positive_meters_like_the_preview(self): self._assert_parity(self._slide(positive_source=True), "Tiled export metered a Positive frame differently") + def test_tiled_export_measures_a_stale_fade_white_like_the_preview(self): + """No desktop state reaches an export, so the reference pass measures the white for every tile.""" + stale = self._slide(linear_raw=True, fade_ratio_g=0.8, fade_ratio_b=0.6, fade_white_anchor=True, fade_white_space="old") + self._assert_parity(stale, "Tiled export read a stale fade white differently") + unmeasured = replace(stale, process=replace(stale.process, fade_white_space=fade_white_space(_CAM_XYZ, _CAM_WB))) + tiled, _ = self.engine._process_tiled(self.img, stale, scale_factor=1.0, cam_xyz=_CAM_XYZ, camera_wb=_CAM_WB) + plain, _ = self.engine._process_tiled(self.img, unmeasured, scale_factor=1.0, cam_xyz=_CAM_XYZ, camera_wb=_CAM_WB) + self.assertGreater(float(np.abs(tiled - plain).mean()), 0.005) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_interactive_decoupling.py b/tests/test_interactive_decoupling.py index 4a15729bd..10be6a65f 100644 --- a/tests/test_interactive_decoupling.py +++ b/tests/test_interactive_decoupling.py @@ -85,7 +85,7 @@ def _render_finished_stub(self): _gpu_fallback_notified=True, _freeze_resolved_auto_crop=MagicMock(), _freeze_resolved_meter=MagicMock(), - _refresh_stale_fade_white=MagicMock(), + _store_measured_fade_white=MagicMock(), state=SimpleNamespace( config=object(), metrics_lock=MagicMock(__enter__=lambda s: None, __exit__=lambda s, *a: None), diff --git a/tests/test_navigate_back_memo.py b/tests/test_navigate_back_memo.py index 584a0fb0b..3f6a7cc22 100644 --- a/tests/test_navigate_back_memo.py +++ b/tests/test_navigate_back_memo.py @@ -41,7 +41,7 @@ def _stub(memo, **overrides): _gpu_fallback_notified=True, _freeze_resolved_auto_crop=MagicMock(), _freeze_resolved_meter=MagicMock(), - _refresh_stale_fade_white=MagicMock(), + _store_measured_fade_white=MagicMock(), state=SimpleNamespace( config=object(), metrics_lock=MagicMock(__enter__=lambda s: None, __exit__=lambda s, *a: None), diff --git a/tests/test_raw_density_order.py b/tests/test_raw_density_order.py index d9a259add..5deec9218 100644 --- a/tests/test_raw_density_order.py +++ b/tests/test_raw_density_order.py @@ -136,7 +136,8 @@ def test_remove_stain_leaves_a_white_border_neutral_and_converts_after(positive) @pytest.mark.parametrize("positive", _PATHS) def test_a_faded_gray_ramp_restores_to_neutral(positive): - config = _slide(positive, **_FADE) + # A white measured as none: the fade scales about L = 0, as the ramp was faded. + config = _slide(positive, fade_white_space=fade_white_space(_CAMERA, _WB), **_FADE) restore = unmix_with_fade(config, ProcessMode.E6) gray = np.repeat(np.linspace(-1.5, -0.05, 60, dtype=np.float64)[None, :, None], 3, axis=2) faded_log = gray @ np.linalg.inv(restore).T From a0ede0914f170c2778ae57012d2c17dc72150ca1 Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:35:21 +0000 Subject: [PATCH 15/46] fix(rolls): keep a camera RAW frame's own fade white against the roll The roll holds no camera RAW white, but the Fade card's lock check needed every card field in the roll. Any fade edit on a RAW frame then locked the card, even when its survivals matched the roll's. A roll's scanner white (space "") also replaced a RAW frame's measured white on each open. The render measured it again and the card locked as diverged. frame_roll_defaults now drops the roll's white for a frame that holds its own. The overlay, the lock check, the diverged-card scan and Reset to Roll use it, and the lock check does not need a white the roll does not hold. A scanner roll keeps its shared white. --- negpy/desktop/controller.py | 5 ++++- negpy/desktop/session.py | 2 +- negpy/services/assets/rolls.py | 16 ++++++++++++---- tests/test_controller.py | 22 ++++++++++++++++++++++ tests/test_rolls.py | 12 ++++++++++++ 5 files changed, 51 insertions(+), 6 deletions(-) diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 1151a9f6f..deffcaf31 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -6521,11 +6521,13 @@ def _lock_roll_card_for(self, asset: dict, file_hash: str, config: WorkspaceConf roll_id = rolls.frame_roll(self.session.repo, self.state.active_roll_id, asset) if roll_id is None: return - defaults = rolls.roll_defaults(self.session.repo, roll_id) + defaults = rolls.frame_roll_defaults(rolls.roll_defaults(self.session.repo, roll_id), config.process) # A field the roll has never set at all cannot "match" -- there is nothing yet # to differ from, and treating that as a match would hide a card's first-ever # edit from Apply until every one of its fields happened to get a roll default. + # A fade white the roll holds none of stays the frame's own (`frame_roll_defaults`). current = self._card_values(config, card_key) + current = {k: v for k, v in current.items() if k in defaults or k not in rolls.DECODE_WHITE_FIELDS} matches_roll = all(name in defaults and rolls.same_value(value, defaults[name]) for name, value in current.items()) diverged = not matches_roll file_hash = rolls.unforked_hash(file_hash) @@ -6793,6 +6795,7 @@ def revert_frame_to_roll(self) -> int: def _with_roll_card(self, config: WorkspaceConfig, card_key: str, defaults: dict) -> WorkspaceConfig: """*config* with *card_key* holding the roll's value for every field the roll has set, through the side effects a hand edit of that card carries.""" + defaults = rolls.frame_roll_defaults(defaults, config.process) values = {f: rolls.config_value(defaults[f]) for f in rolls.card_fields(card_key) if f in defaults} if card_key == "film": return with_film_fields(config, values) diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index 2f6b05cbc..75a8945ce 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -2089,7 +2089,7 @@ def _lock_diverged_cards(self, asset: dict, config: WorkspaceConfig) -> None: roll_id = rolls.frame_roll(self.repo, self.state.active_roll_id, asset) if roll_id is None: return - defaults = rolls.roll_defaults(self.repo, roll_id) + defaults = rolls.frame_roll_defaults(rolls.roll_defaults(self.repo, roll_id), config.process) base = unforked_hash(asset["hash"]) locked = rolls.frame_override_cards(self.repo, roll_id, base) for card_key, (section, names) in rolls.ROLL_DEFAULT_FIELDS.items(): diff --git a/negpy/services/assets/rolls.py b/negpy/services/assets/rolls.py index 229b4eda7..15deaa290 100644 --- a/negpy/services/assets/rolls.py +++ b/negpy/services/assets/rolls.py @@ -756,14 +756,22 @@ def same_value(a: Any, b: Any) -> bool: # The fade white of a camera RAW: a reading of one frame's decode, which moves with its exposure. -_DECODE_WHITE_FIELDS = ("fade_white_r", "fade_white_g", "fade_white_b", "fade_white_space") +DECODE_WHITE_FIELDS = ("fade_white_r", "fade_white_g", "fade_white_b", "fade_white_space") def _without_decode_white(fields: Dict[str, Any]) -> Dict[str, Any]: """*fields* without a camera RAW's fade white (a set `fade_white_space`), which no other frame can use.""" if not fields.get("fade_white_space"): return fields - return {k: v for k, v in fields.items() if k not in _DECODE_WHITE_FIELDS} + return {k: v for k, v in fields.items() if k not in DECODE_WHITE_FIELDS} + + +def frame_roll_defaults(defaults: Dict[str, Any], process: Any) -> Dict[str, Any]: + """*defaults* as they apply to a frame with *process*: a camera RAW frame that has measured + its own white keeps it over a roll's scanner white.""" + if not getattr(process, "fade_white_space", ""): + return defaults + return {k: v for k, v in defaults.items() if k not in DECODE_WHITE_FIELDS} def roll_defaults(repo: Any, roll_id: str) -> Dict[str, Any]: @@ -786,7 +794,7 @@ def set_roll_defaults(repo: Any, roll_id: str, **fields: Any) -> None: defaults = dict(entry.get("defaults", {})) if fields.get("fade_white_space"): # A camera RAW's white replaces the roll's white with none, so each frame keeps its own. - defaults = {k: v for k, v in defaults.items() if k not in _DECODE_WHITE_FIELDS} + defaults = {k: v for k, v in defaults.items() if k not in DECODE_WHITE_FIELDS} defaults.update(_without_decode_white(fields)) entry["defaults"] = defaults _write(repo, store) @@ -901,7 +909,7 @@ def resolve_roll_config(repo: Any, roll_id: Optional[str], file_hash: str, confi locked leaves *config* unchanged.""" if roll_id is None: return config - defaults = roll_defaults(repo, roll_id) + defaults = frame_roll_defaults(roll_defaults(repo, roll_id), config.process) if not defaults: return config locked_cards = frame_override_cards(repo, roll_id, file_hash) diff --git a/tests/test_controller.py b/tests/test_controller.py index cb5ff797e..057afce94 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1440,6 +1440,28 @@ def test_set_roll_default_matches_a_tuple_the_store_read_back_as_a_list(self): self.assertEqual(rolls.frame_override_cards(repo, roll_id, "h1"), set()) + def test_a_camera_raw_fade_card_matches_a_roll_that_holds_no_white(self): + """The roll keeps no camera RAW white, so the frame's own white is no divergence.""" + from dataclasses import replace + + from negpy.services.assets import rolls + + self._wire_repo_store() + repo = self.controller.session.repo + roll_id = rolls.create_virtual_roll(repo, "Velvia", []) + state = self.mock_session_manager.state + state.active_roll_id = roll_id + state.uploaded_files = [{"name": "a.nef", "path": "/a.nef", "hash": "h1"}] + state.current_file_hash = "h1" + white = dict(fade_white_r=-0.5, fade_white_g=-0.45, fade_white_b=-0.6, fade_white_space="abc123") + state.config = replace(WorkspaceConfig(), process=replace(WorkspaceConfig().process, fade_ratio_g=0.8, **white)) + rolls.set_roll_defaults(repo, roll_id, **self.controller._card_values(state.config, "fade")) + self.assertNotIn("fade_white_r", rolls.roll_defaults(repo, roll_id)) + + self.controller.set_roll_default("fade", fade_ratio_g=0.8) + + self.assertEqual(rolls.frame_override_cards(repo, roll_id, "h1"), set()) + def test_set_roll_default_writes_the_cards_own_config_section(self): """The Lens Correction card edits GeometryConfig, not ProcessConfig.""" from negpy.services.assets import rolls diff --git a/tests/test_rolls.py b/tests/test_rolls.py index bcd7e0ca8..d2a6e3685 100644 --- a/tests/test_rolls.py +++ b/tests/test_rolls.py @@ -1206,3 +1206,15 @@ def test_a_stored_camera_raw_roll_white_is_not_read(): store[roll_id]["defaults"] = dict(fade_ratio_r=0.5, fade_white_r=-0.4, fade_white_g=-0.5, fade_white_b=-0.6, fade_white_space="abc123") repo.save_global_setting(ROLLS_KEY, store) assert roll_defaults(repo, roll_id) == dict(fade_ratio_r=0.5) + + +def test_a_measured_camera_raw_frame_keeps_its_white_over_a_scanner_roll_white(): + """A roll's scanner white (space "") reaches a frame with none of its own, never a RAW frame's own reading.""" + repo = _repo() + roll_id = create_virtual_roll(repo, "R", ["/a.tif", "/b.nef"]) + set_roll_defaults(repo, roll_id, fade_ratio_g=0.8, fade_white_r=-0.1, fade_white_g=-0.2, fade_white_b=-0.3, fade_white_space="") + raw = WorkspaceConfig(process=ProcessConfig(fade_white_r=-0.7, fade_white_g=-0.8, fade_white_b=-0.9, fade_white_space="abc123")) + resolved = resolve_roll_config(repo, roll_id, "h1", raw).process + assert (resolved.fade_ratio_g, resolved.fade_white_r, resolved.fade_white_space) == (0.8, -0.7, "abc123") + unmeasured = resolve_roll_config(repo, roll_id, "h2", WorkspaceConfig()).process + assert (unmeasured.fade_white_r, unmeasured.fade_white_space) == (-0.1, "") From 2de7c0d554191fd98d673ceb61fecec227e7e01b Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:35:21 +0000 Subject: [PATCH 16/46] fix(fade): store the render's white with its bounds and tag the crosstalk The desktop stored a white the render measured without moving the render memo or the render identity, so the first navigate-back rendered again. A half frame could also get its own settings row. The white now joins the measured-bounds write, which handles all three. The white is read after the crosstalk unmix, so its tag now names the unmix (render_white_space). A crosstalk change makes the white stale, and the render measures it again. The GPU engine keeps the last white it measured for a frame, so drag ticks before the white is stored do not measure it again. fade_anchor had no caller left and is removed. --- docs/PIPELINE.md | 2 +- negpy/desktop/controller.py | 73 +++++++++++----------- negpy/desktop/view/sidebar/sensor.py | 4 +- negpy/features/process/fade.py | 41 ++++++------- negpy/features/transparency/logic.py | 6 +- negpy/features/transparency/processor.py | 4 +- negpy/services/rendering/engine.py | 6 +- negpy/services/rendering/gpu_engine.py | 36 +++++++++-- tests/test_fade_white_space.py | 78 ++++++++++++++++-------- tests/test_interactive_decoupling.py | 1 - tests/test_navigate_back_memo.py | 1 - 11 files changed, 151 insertions(+), 101 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 86b6d226e..1b15629e8 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -65,7 +65,7 @@ Pixel values are linear transmittance. The source corrections run in that domain White Anchor (`fade_white_anchor`, UI "Remove Stain") makes the matrix affine, $M(L - w) + t$. The stain $w$ is each channel's `WHITE_PCT` cell of the meter grid in log transmittance, without decoded zeros and cells at full scale in every channel; Estimate stores it as `fade_white_r/g/b`. It maps to the neutral $t = \min(\max_c w_c, -0.1)$ (`FADE_WHITE_HEADROOM`): the cleanest channel's own level, never within $0.1$ D of full scale. $F^{-1}$ acts on $L - w$ before $t$ is added, so the Estimate reads the grays on $\min(L - w, 0)$. The offset rides in the w lanes of the unmix rows on the GPU. - A camera RAW's clear film sits below the decoder's white by the exposure and the light, so $F^{-1}$ about $L = 0$ would scale that offset into a cast. On a RAW the matrix is always $M(L - a) + t$ (`density_anchor`). $w$ is read after the crosstalk unmix $U$, so $a = U^{-1} w$; crosstalk alone keeps a neutral neutral and takes no $a$. Remove Stain sets $a = w$ and the neutral $t$ above. Without it, $a = t$ is the neutral level $\max_c w_c$, so the white keeps its color: a slide's rebate is black, and with no clear film in the picture $w$ is the brightest picture color, which a per-channel anchor would add to every gray. Estimate stores $w$ with White Anchor off too and reads the grays on $\min(L - a, 0)$. A pixel above $a$ in every channel is light past the film, such as a highlight that Highlight Reconstruction decodes neutral at full scale. The matrix acts on $\min(L, a)$, and $e = \max(\min_c(L_c - a_c), 0)$ is added to every channel after it (`fade_density`, `unmix_log_anchored`, the shader's `anchor` row), so that light passes as a neutral. Estimate reads its grid with the stored white cleared. A JPEG or scanner TIFF has its white at full scale and keeps $L = 0$, with no cap. `fade_white_space` tags $w$ with the space it was read in: the white-balanced camera channels, named by a hash of the white-balance gains and a channel marker, so an Input ICC keeps it, "" with no camera matrix. A render whose tag differs, or that has no $w$, measures $w$ on the Estimate grid before the density stage (`resolve_fade_white`), in both engines and in the tiled export's reference pass. The desktop stores it with no roll lock. A roll keeps no camera RAW white, because it moves with each frame's exposure. + A camera RAW's clear film sits below the decoder's white by the exposure and the light, so $F^{-1}$ about $L = 0$ would scale that offset into a cast. On a RAW the matrix is always $M(L - a) + t$ (`density_anchor`). $w$ is read after the crosstalk unmix $U$, so $a = U^{-1} w$; crosstalk alone keeps a neutral neutral and takes no $a$. Remove Stain sets $a = w$ and the neutral $t$ above. Without it, $a = t$ is the neutral level $\max_c w_c$, so the white keeps its color: a slide's rebate is black, and with no clear film in the picture $w$ is the brightest picture color, which a per-channel anchor would add to every gray. Estimate stores $w$ with White Anchor off too and reads the grays on $\min(L - a, 0)$. A pixel above $a$ in every channel is light past the film, such as a highlight that Highlight Reconstruction decodes neutral at full scale. The matrix acts on $\min(L, a)$, and $e = \max(\min_c(L_c - a_c), 0)$ is added to every channel after it (`fade_density`, `unmix_log_anchored`, the shader's `anchor` row), so that light passes as a neutral. Estimate reads its grid with the stored white cleared. A JPEG or scanner TIFF has its white at full scale and keeps $L = 0$, with no cap. `fade_white_space` tags $w$ with the space it was read in: the white-balanced camera channels, after the crosstalk unmix, named by a hash of the white-balance gains, the unmix and a channel marker (`render_white_space`), so an Input ICC keeps it, "" with no camera matrix. A render whose tag differs, or that has no $w$, measures $w$ on the Estimate grid before the density stage (`resolve_fade_white`), in both engines and in the tiled export's reference pass. The desktop stores it with no roll lock. A roll keeps no camera RAW white, because it moves with each frame's exposure, and a RAW frame with its own white ignores a roll's scanner white. * **Density above base**: `density_above_base` is (Dmin, Dmed, Dmax) above the clear base, luminance-weighted: the thin and dense bounds (before trims; the roll's under Roll Analysis) and the metered median, each minus the base. The base is the picked one, else the rebate reader's, unmixed like the meters. A pick is the open roll's (`rolls.roll_film_base`), or the frame's own with no roll open (`process.film_base`, unhashed); in an open roll the roll's wins, with none open the frame's. The preview carries it as `film_base`; export never reads it. The reader takes the flat 8-px blocks (std over mean under 0.03, unclipped) in the outer 15% of the film box (`film_and_picture` at `AUTOCROP_DETECT_RES`), groups them by brightness with a 0.04-decade gap, and takes the largest group of at least five. It finds none without a film box, with a base darker than the picture, or on the transfer path. The preview reads it once per source, on the flat-fielded, unmixed source before the defect repairs, and passes the read to both engines. * **Scan-clip warning**: `scan_clip_fractions` reports the per-channel fraction of source pixels at or above $0.99$ linear. On a negative this clips film base and shadows to $D=0$; nothing reconstructs it. diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index deffcaf31..9fa67f732 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -3756,7 +3756,7 @@ def _handle_film_base_pick(self, nx: float, ny: float) -> None: unmix, so the roll keeps a raw base that every render unmixes like the rebate reader's. One click: the tool disarms, and leaving it re-renders the roll's frame above the base.""" from negpy.features.exposure.normalization import log_from_val - from negpy.features.process.fade import fade_white_space, unmix_with_fade + from negpy.features.process.fade import render_white_space, unmix_with_fade bounds = self.state.last_metrics.get("final_bounds") or self.state.last_metrics.get("log_bounds") patch = self._sample_normalized_patch(nx, ny, radius=4) @@ -3770,7 +3770,9 @@ def _handle_film_base_pick(self, nx: float, ny: float) -> None: return base = logs.mean(axis=0) unmix = unmix_with_fade( - self.state.config.process, self.state.config.process.process_mode, fade_white_space(*self._effective_cam_xyz()) + self.state.config.process, + self.state.config.process.process_mode, + render_white_space(self.state.config.process, self.state.config.process.process_mode, *self._effective_cam_xyz()), ) if unmix is not None: unmix = np.asarray(unmix, dtype=np.float64) @@ -9945,27 +9947,22 @@ def _on_render_finished(self, _result: Any, metrics: Dict[str, Any]) -> None: AppController._continue_background_work(self) return - if self._pending_render_task is None and not metrics.get("ephemeral") and not metrics.get("interactive"): - self._store_measured_fade_white(metrics) self._dispatch_pending_render() AppController._continue_background_work(self) - def _store_measured_fade_white(self, metrics: Dict[str, Any]) -> None: - """Store the fade white a render measured for a stale one (`resolve_fade_white`), so the - next render reads it. It is a reading of this frame's decode, so it locks no card.""" - from negpy.features.process.fade import fade_white_space, fade_white_stale, with_fade_white + def _measured_fade_white(self, metrics: Dict[str, Any]) -> Dict[str, Any]: + """The fade white fields a render measured for a stale one (`resolve_fade_white`), to store + with its bounds. A reading of this frame's decode, so it locks no card.""" + from negpy.features.process.fade import fade_white_stale, render_white_space measured = metrics.get("fade_white") process = self.state.config.process - if measured is None or measured[3] != fade_white_space(*self._effective_cam_xyz()): - return + cam_xyz, camera_wb = self._effective_cam_xyz() + if measured is None or measured[3] != render_white_space(process, process.process_mode, cam_xyz, camera_wb): + return {} if not fade_white_stale(process, measured[3]): - return - refreshed = with_fade_white(process, measured) - refreshed = replace(refreshed, **invalidate_local_bounds(refreshed)) - # No render: the frame on screen already shows this white. No undo step: undoing to the - # stale white would measure it again and drop the redo branch. - self.session.update_config(replace(self.state.config, process=refreshed), persist=True, render=False, record_history=False) + return {} + return dict(zip(("fade_white_r", "fade_white_g", "fade_white_b", "fade_white_space"), measured)) def _freeze_resolved_auto_crop(self, metrics: Dict[str, Any]) -> None: """Store the crop this render detected, so nothing detects it a second time. @@ -10086,35 +10083,33 @@ def _on_metrics_updated(self, metrics: Dict[str, Any]) -> None: # stacks edits. Skip only when both axes ride the roll baseline. proc = self.state.config.process bounds = metrics.get("log_bounds_base") or metrics.get("log_bounds") + changes = self._measured_fade_white(metrics) if bounds and not (proc.use_luma_average and proc.use_color_average): - changes = {} if not proc.lock_bounds and (bounds.floors != proc.local_floors or bounds.ceils != proc.local_ceils): changes["local_floors"] = bounds.floors changes["local_ceils"] = bounds.ceils - if changes: - before = self.state.config - new_process = replace(self.state.config.process, **changes) - self.session.update_config( - replace(self.state.config, process=new_process), - persist=self._may_persist_measured_bounds(), - render=False, - record_history=False, - ) - self._carry_render_identity(before) - # render=False: the displayed pixels already reflect these measured bounds. - # Move the frame's memo entry to the updated config's key so the first - # navigate-back after an initial render still hits. A GPU render is not filed - # until navigate-away, so its identity follows too. - self._render_memo.rekey( - src or self.state.current_file_hash or "", self._render_memo_key(), old_key=metrics.get("memo_key", "") + if changes: + before = self.state.config + new_process = replace(self.state.config.process, **changes) + self.session.update_config( + replace(self.state.config, process=new_process), + persist=self._may_persist_measured_bounds(), + render=False, + record_history=False, + ) + self._carry_render_identity(before) + # render=False: the displayed pixels already reflect these measured bounds and white. + # Move the frame's memo entry to the updated config's key so the first + # navigate-back after an initial render still hits. A GPU render is not filed + # until navigate-away, so its identity follows too. + self._render_memo.rekey(src or self.state.current_file_hash or "", self._render_memo_key(), old_key=metrics.get("memo_key", "")) + if self._last_render_identity is not None: + self._last_render_identity = ( + self._last_render_identity[0], + self._render_memo_key(), + self._last_render_identity[2], ) - if self._last_render_identity is not None: - self._last_render_identity = ( - self._last_render_identity[0], - self._render_memo_key(), - self._last_render_identity[2], - ) def _may_persist_measured_bounds(self) -> bool: """Whether an auto-measured bounds write may reach the database. diff --git a/negpy/desktop/view/sidebar/sensor.py b/negpy/desktop/view/sidebar/sensor.py index 40aae638f..fed9b7bb8 100644 --- a/negpy/desktop/view/sidebar/sensor.py +++ b/negpy/desktop/view/sidebar/sensor.py @@ -537,7 +537,7 @@ def _on_estimate_fade(self) -> None: def _run_estimate(self, white_anchor: bool) -> None: from negpy.desktop.workers.chart_fit import fade_estimate_grid - from negpy.features.process.fade import estimate_fade, fade_white_space + from negpy.features.process.fade import estimate_fade, render_white_space conf = self.state.config.process if render_path(conf) is RenderPath.PRINT: @@ -549,7 +549,7 @@ def _run_estimate(self, white_anchor: bool) -> None: return method = EstimateMethod(self.estimate_method_combo.currentData()) anchor = white_anchor - space = fade_white_space(source.cam_xyz, source.camera_wb) + space = render_white_space(conf, conf.process_mode, source.cam_xyz, source.camera_wb) estimates = estimate_fade(grid, white_anchor=anchor, camera_raw=bool(space)) chosen, other = estimates[method], estimates[next(m for m in EstimateMethod if m != method)] failed = bool(chosen.reason) and chosen.green == 1.0 and chosen.blue == 1.0 diff --git a/negpy/features/process/fade.py b/negpy/features/process/fade.py index ed8b276ac..3a699f88d 100644 --- a/negpy/features/process/fade.py +++ b/negpy/features/process/fade.py @@ -66,43 +66,47 @@ def fade_matrix(process: ProcessConfig) -> Optional[np.ndarray]: return resolve_fade_matrix(*ratios) -def fade_white_space(cam_xyz: Optional[Sequence[Sequence[float]]], camera_wb: Optional[Sequence[float]]) -> str: +def fade_white_space( + cam_xyz: Optional[Sequence[Sequence[float]]], camera_wb: Optional[Sequence[float]], crosstalk: Optional[np.ndarray] = None +) -> str: """The space a camera RAW's log white is measured in: its white-balanced camera channels - (`density_channels`), named by the as-shot white balance, which every RAW decode applies at the - decode or in the matrix. The primaries after the white do not name it. "" for a source with no - camera matrix, whose white is the file's own full scale.""" + (`density_channels`) after the *crosstalk* unmix, named by the as-shot white balance and the + unmix. The primaries after them do not name it. "" for a source with no camera matrix, whose + white is the file's own full scale.""" if cam_xyz is None or is_source_profile(cam_xyz): return "" gains, primaries = camera_matrix_parts(cam_xyz, camera_wb) if primaries is None: return "" named = b"" if gains is None else np.round(np.diag(gains).astype(np.float64), 4).tobytes() + if crosstalk is not None: + named += b"unmix" + np.round(np.asarray(crosstalk, dtype=np.float64), 4).tobytes() return hashlib.sha1(_WHITE_SPACE_CHANNELS + named).hexdigest()[:12] +def render_white_space( + process: ProcessConfig, process_mode: Optional[str], cam_xyz: Optional[Sequence[Sequence[float]]], camera_wb: Optional[Sequence[float]] +) -> str: + """`fade_white_space` of a render with *process*, its crosstalk unmix included.""" + return fade_white_space(cam_xyz, camera_wb, effective_crosstalk_matrix(process, process_mode)) + + # Names the channels in `fade_white_space`, so a tag hashed any other way reads as stale. _WHITE_SPACE_CHANNELS = b"camera-gains" class FadeDensity(NamedTuple): - """The log-density matrix a render applies (`unmix_with_fade`) and the point it scales about - (`fade_anchor`).""" + """`fade_density`'s result.""" matrix: Optional[np.ndarray] anchor: Optional[tuple[float, float, float]] def fade_density(process: ProcessConfig, process_mode: Optional[str], white_space: str = "") -> FadeDensity: - """The crosstalk unmix U, then the fade restoration F of the densities it recovers (`F @ U`). - The matrix is None when neither applies. *white_space* is the render's `fade_white_space`. A - stored white w makes it a (3, 4) affine `F (U L - w) + t`, the last column the log offset. w is - read after U, so the affine scales about `a = U^-1 w`. Remove Stain maps each channel's white to - a neutral t. On a camera RAW, whose white sits below full scale, Remove Stain off scales density - above the neutral level w = t of the white's cleanest channel (`density_anchor`). - - The anchor is a camera RAW's a. A pixel above it in every channel is light past the film, with no - dye to restore: `unmix_log_image` passes that part through as a neutral, so a blown highlight - stays white. Any other source's white is at full scale, with no light past it.""" + """The log-density matrix a render applies, `F @ U` (the fade after the crosstalk unmix), and + the anchor it scales about. A stored white w, read after U, makes the matrix the affine + `F (U L - w) + t` about `a = U^-1 w`. The anchor is a camera RAW's a, else None + (docs/PIPELINE.md, Fade restoration).""" unmix = effective_crosstalk_matrix(process, process_mode) fade = fade_matrix(process) matrix = unmix if fade is None else (fade if unmix is None else fade @ unmix) @@ -129,11 +133,6 @@ def unmix_with_fade(process: ProcessConfig, process_mode: Optional[str], white_s return fade_density(process, process_mode, white_space).matrix -def fade_anchor(process: ProcessConfig, process_mode: Optional[str], white_space: str = "") -> Optional[tuple[float, float, float]]: - """`fade_density`'s anchor.""" - return fade_density(process, process_mode, white_space).anchor - - def _white_terms(process: ProcessConfig, white_space: str, fade_acts: bool) -> Optional[tuple[np.ndarray, np.ndarray]]: """(white, target) of the stored white's affine, both after the crosstalk unmix, or None when no white acts. *fade_acts*: Fade Restoration applies. Crosstalk alone keeps a neutral neutral, so a diff --git a/negpy/features/transparency/logic.py b/negpy/features/transparency/logic.py index 91f37e929..519c459ff 100644 --- a/negpy/features/transparency/logic.py +++ b/negpy/features/transparency/logic.py @@ -489,7 +489,7 @@ class DensityStage(NamedTuple): pre: Optional[np.ndarray] # linear 3x3 before the log unmix: Optional[np.ndarray] # log-density matrix, (3, 3) or affine (3, 4) post: Optional[np.ndarray] # linear 3x3 after the log-density matrix - anchor: Optional[tuple[float, float, float]] = None # `fade_anchor` in the stage's input log + anchor: Optional[tuple[float, float, float]] = None # `fade_density`'s anchor in the stage's input log def _whole_matrix(process: ProcessConfig, cam_xyz: Optional[list], camera_wb: Optional[list]) -> Optional[np.ndarray]: @@ -541,8 +541,8 @@ def density_stage( ) -> DensityStage: """The stage a render applies. With a log-density matrix *unmix* on a slide path the source stays in its own channels through it (`density_channels`), because the primaries mix the dye channels the - density corrections act on. Else the whole matrix applies before the log. *anchor* is the fade's - `fade_anchor`, in those channels.""" + density corrections act on. Else the whole matrix applies before the log. *anchor* is + `fade_density`'s, in those channels.""" from negpy.features.process.path import RenderPath, render_path if unmix is None or render_path(process) is RenderPath.PRINT: diff --git a/negpy/features/transparency/processor.py b/negpy/features/transparency/processor.py index c308f9548..e3c25cb2e 100644 --- a/negpy/features/transparency/processor.py +++ b/negpy/features/transparency/processor.py @@ -25,7 +25,7 @@ resolve_analysis_region, unmix_log_image, ) -from negpy.features.process.fade import fade_density, fade_white_space, resolve_fade_white, with_fade_white +from negpy.features.process.fade import fade_density, render_white_space, resolve_fade_white, with_fade_white from negpy.features.process.capture_color import apply_camera_matrix from negpy.features.process.models import ProcessConfig from negpy.features.process.path import RenderPath, render_path @@ -74,7 +74,7 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: def white_grid() -> ImageBuffer: return fade_white_grid(self.config, context.process_mode, context.cam_xyz, context.camera_wb, image, an_roi, an_buffer) - white_space = fade_white_space(context.cam_xyz, context.camera_wb) + white_space = render_white_space(self.config, context.process_mode, context.cam_xyz, context.camera_wb) measured = resolve_fade_white(self.config, white_space, white_grid) if measured is not None: context.metrics["fade_white"] = measured diff --git a/negpy/services/rendering/engine.py b/negpy/services/rendering/engine.py index dc8434180..f9348747e 100644 --- a/negpy/services/rendering/engine.py +++ b/negpy/services/rendering/engine.py @@ -29,7 +29,7 @@ geometry_kwargs, normalized_roi, ) -from negpy.features.process.fade import fade_white_space, fade_white_target, with_fade_white +from negpy.features.process.fade import fade_white_target, render_white_space, with_fade_white from negpy.features.process.hue import apply_hue_trim from negpy.features.process.path import RenderPath, render_path from negpy.features.transparency.processor import TransferProcessor, TransparencyBaseProcessor @@ -342,7 +342,9 @@ def run_base(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: def run_exposure(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: # The base stage reports a white it measured for itself (`resolve_fade_white`). measured = replace(settings, process=with_fade_white(settings.process, ctx.metrics.get("fade_white"))) - img_out = exposure_processor(measured, fade_white_space(ctx.cam_xyz, ctx.camera_wb)).process(img_in, ctx) + img_out = exposure_processor( + measured, render_white_space(settings.process, ctx.process_mode, ctx.cam_xyz, ctx.camera_wb) + ).process(img_in, ctx) # Rides this stage: it needs the print, and its own stage would re-run everything behind # it on a drag. Stays inside the flat intent below, being a capture fix, not a look. return apply_hue_trim(img_out, settings.process.hue_trim) diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index bffaf60fd..814fe0e3e 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -96,7 +96,14 @@ wb_split_geometry, zone_geometry, ) -from negpy.features.process.fade import fade_density, fade_white_space, fade_white_target, resolve_fade_white, with_fade_white +from negpy.features.process.fade import ( + fade_density, + fade_white_stale, + fade_white_target, + render_white_space, + resolve_fade_white, + with_fade_white, +) from negpy.features.exposure.bw_filter import collapse_weights, plane_weights, printed_exposure from negpy.features.process.models import ProcessMode, per_channel_point_offsets, pooled_neutral_axis from negpy.features.process.path import RenderPath, render_path @@ -421,6 +428,8 @@ def __init__(self) -> None: self._clip_cache: Optional[tuple] = None # (key, prefiltered grid, clip fractions), keyed without the clip sliders. self._prefilter_cache: Optional[tuple] = None + # (key, white) of the last fade white measured for a stale one, so drag ticks reuse it. + self._fade_white_cache: Optional[Tuple[Any, Any]] = None self._last_settings: Optional[WorkspaceConfig] = None self._last_targets_rev: int = -1 self._last_scale_factor: float = 1.0 @@ -797,8 +806,23 @@ def _white_grid() -> np.ndarray: return fade_white_grid(settings.process, settings.process.process_mode, cam_xyz, camera_wb, source, None, buffer) # Before every key and matrix below, which read the white. A tile takes its export's white. - white_space = fade_white_space(cam_xyz, camera_wb) - measured_white = None if tiling_mode else resolve_fade_white(settings.process, white_space, _white_grid) + white_space = render_white_space(settings.process, settings.process.process_mode, cam_xyz, camera_wb) + proc = settings.process + white_key = ( + None + if analysis_source_hash is None or tiling_mode + else (analysis_source_hash, white_space, roi, settings.geometry, proc.analysis_buffer, proc.analysis_rect, proc.meter_rect) + + (proc.process_mode, proc.positive_source, proc.linear_raw, proc.highlight_reconstruction) + ) + measured_white = None + if not tiling_mode and fade_white_stale(proc, white_space): + cached = self._fade_white_cache + if white_key is not None and cached is not None and cached[0] == white_key: + measured_white = cached[1] + else: + measured_white = resolve_fade_white(proc, white_space, _white_grid) + if white_key is not None: + self._fade_white_cache = (white_key, measured_white) settings = replace(settings, process=with_fade_white(settings.process, measured_white)) # Reuse the per-source meter across creative-slider previews: fill any missing @@ -2987,7 +3011,11 @@ def _analysis_img() -> np.ndarray: # Unmixed like the non-tiled path, lazily: skipped when bounds are locked and no # auto refs, anchor or textural range need it. - unmix_m, fade_anchor_m = fade_density(settings.process, settings.process.process_mode, fade_white_space(cam_xyz, camera_wb)) + unmix_m, fade_anchor_m = fade_density( + settings.process, + settings.process.process_mode, + render_white_space(settings.process, settings.process.process_mode, cam_xyz, camera_wb), + ) log_grid_cache: Optional[np.ndarray] = None prefiltered_cache: Optional[np.ndarray] = None sorted_cache: Optional[np.ndarray] = None diff --git a/tests/test_fade_white_space.py b/tests/test_fade_white_space.py index 10f41efc3..66555262c 100644 --- a/tests/test_fade_white_space.py +++ b/tests/test_fade_white_space.py @@ -15,11 +15,11 @@ from negpy.features.process.fade import ( EstimateMethod, estimate_fade, - fade_anchor, fade_density, fade_white_space, fade_white_stale, fade_white_target, + render_white_space, resolve_fade_matrix, unmix_with_fade, ) @@ -126,7 +126,7 @@ def test_remove_stain_on_a_scanner_file_restores_its_highlights_too(): past the film, so the restoration acts on it.""" img = np.random.default_rng(5).uniform(0.03, 1.0, (40, 60, 3)).astype(np.float32) config = _slide(fade_white_anchor=True, **_FADE, **_white((-0.05, -0.08, -0.12), space="")) - assert fade_anchor(config, ProcessMode.E6, "") is None + assert fade_density(config, ProcessMode.E6, "").anchor is None ctx = PipelineContext(original_size=img.shape[:2], scale_factor=1.0, process_mode=ProcessMode.E6) TransparencyBaseProcessor(config).process(img, ctx) b = ctx.metrics["log_bounds"] @@ -174,26 +174,16 @@ def test_a_render_measures_a_stale_white_for_itself(stored): assert "fade_white" not in ctx.metrics -def test_a_measured_white_is_stored_without_a_roll_lock_a_render_or_an_undo_step(): - applied = [] +def test_a_measured_white_is_stored_with_the_bounds(): + """The render's white joins the measured-bounds write: no render, no undo step, no roll lock.""" cfg = WorkspaceConfig() - fake = SimpleNamespace( - state=SimpleNamespace(config=replace(cfg, process=_slide(**_FADE, local_floors=(-2.0, -2.0, -2.0)))), - _effective_cam_xyz=lambda: (_CAMERA, _WB), - session=SimpleNamespace( - update_config=lambda config, persist, render, record_history: applied.append((config.process, persist, render, record_history)) - ), - ) - AppController._store_measured_fade_white(fake, {"fade_white": (-0.5, -0.45, -0.6, _SPACE)}) - ((process, persist, render, record_history),) = applied - assert persist and not render and not record_history - assert process.fade_white_space == _SPACE and process.local_floors == (0.0, 0.0, 0.0) - assert (process.fade_white_r, process.fade_white_g, process.fade_white_b) == (-0.5, -0.45, -0.6) + fake = SimpleNamespace(state=SimpleNamespace(config=replace(cfg, process=_slide(**_FADE))), _effective_cam_xyz=lambda: (_CAMERA, _WB)) + measured = (-0.5, -0.45, -0.6, _SPACE) + assert AppController._measured_fade_white(fake, {"fade_white": measured}) == dict(_white(measured[:3])) # Another frame's white, or one already stored, is left alone. - AppController._store_measured_fade_white(fake, {"fade_white": (-0.5, -0.45, -0.6, "other")}) - fake.state.config = replace(cfg, process=process) - AppController._store_measured_fade_white(fake, {"fade_white": (-0.4, -0.4, -0.4, _SPACE)}) - assert len(applied) == 1 + assert AppController._measured_fade_white(fake, {"fade_white": (-0.5, -0.45, -0.6, "other")}) == {} + fake.state.config = replace(cfg, process=_slide(**_FADE, **_white())) + assert AppController._measured_fade_white(fake, {"fade_white": measured}) == {} def _crosstalk(**kw) -> ProcessConfig: @@ -201,21 +191,36 @@ def _crosstalk(**kw) -> ProcessConfig: return _slide(crosstalk_matrix=unmix, crosstalk_strength=1.0, crosstalk_process=ProcessMode.E6, **kw) +def _crosstalk_space(process: ProcessConfig) -> str: + return render_white_space(process, ProcessMode.E6, _CAMERA, _WB) + + def test_crosstalk_alone_takes_no_white(): """Crosstalk keeps a neutral neutral, so it renders the same whatever white is stored.""" - assert not fade_white_stale(_crosstalk(), _SPACE) - assert fade_anchor(_crosstalk(**_white()), ProcessMode.E6, _SPACE) is None + space = _crosstalk_space(_crosstalk()) + assert not fade_white_stale(_crosstalk(), space) + assert fade_density(_crosstalk(**_white(space=space)), ProcessMode.E6, space).anchor is None + + +def test_a_crosstalk_change_makes_the_white_stale(): + """w is read after the unmix, so a new unmix names a new space.""" + space = _crosstalk_space(_crosstalk()) + assert space not in ("", _SPACE) + stronger = _crosstalk(**_FADE, **_white(space=space)) + stronger = replace(stronger, crosstalk_strength=0.5) + assert fade_white_stale(stronger, _crosstalk_space(stronger)) @pytest.mark.parametrize("remove_stain", [False, True]) def test_with_crosstalk_the_white_maps_to_its_target(remove_stain): """w is read after the crosstalk unmix, so the restoration scales about the point the unmix maps to w.""" - process = _crosstalk(fade_white_anchor=remove_stain, **_FADE, **_white()) - matrix, anchor = fade_density(process, ProcessMode.E6, _SPACE) + space = _crosstalk_space(_crosstalk()) + process = _crosstalk(fade_white_anchor=remove_stain, **_FADE, **_white(space=space)) + matrix, anchor = fade_density(process, ProcessMode.E6, space) unmix = effective_crosstalk_matrix(process, ProcessMode.E6) white = np.asarray(_RAW_WHITE) if remove_stain else np.full(3, max(_RAW_WHITE)) np.testing.assert_allclose(unmix @ np.asarray(anchor), white, atol=1e-9) - target = fade_white_target(process, _SPACE) if remove_stain else max(_RAW_WHITE) + target = fade_white_target(process, space) if remove_stain else max(_RAW_WHITE) np.testing.assert_allclose(matrix[:, :3] @ np.asarray(anchor) + matrix[:, 3], target, atol=1e-9) @@ -310,3 +315,26 @@ def render(settings): stored, _ = render(replace(stale, process=replace(stale.process, **_white((r, g, b))))) np.testing.assert_array_equal(np.asarray(out), np.asarray(stored)) assert metrics["neutral_axis_refs"] is not None + + +@pytest.mark.skipif(not GPUDevice.get().is_available, reason="GPU not available") +def test_the_gpu_measures_a_stale_white_once_per_frame(monkeypatch): + """Drag ticks render with the white still stale until a settled render stores it.""" + import negpy.services.rendering.gpu_engine as gpu_engine + + calls = [] + real = gpu_engine.resolve_fade_white + monkeypatch.setattr(gpu_engine, "resolve_fade_white", lambda *a: calls.append(1) or real(*a)) + engine = gpu_engine.GPUEngine() + try: + img = np.random.default_rng(2).uniform(0.03, 0.25, (64, 96, 3)).astype(np.float32) + settings = replace(WorkspaceConfig(), process=_slide(linear_raw=True, **_FADE, **_white(space="old"))) + for strength in (0.0, 0.5): + moved = replace(settings, exposure=replace(settings.exposure, cast_removal_strength=strength)) + _, metrics = engine.process_to_texture( + img, moved, scale_factor=1.0, apply_layout=False, cam_xyz=_CAMERA, camera_wb=_WB, analysis_source_hash="frame" + ) + assert metrics["fade_white"][3] == _SPACE + assert len(calls) == 1 + finally: + engine.destroy_all() diff --git a/tests/test_interactive_decoupling.py b/tests/test_interactive_decoupling.py index 10be6a65f..ecc129a73 100644 --- a/tests/test_interactive_decoupling.py +++ b/tests/test_interactive_decoupling.py @@ -85,7 +85,6 @@ def _render_finished_stub(self): _gpu_fallback_notified=True, _freeze_resolved_auto_crop=MagicMock(), _freeze_resolved_meter=MagicMock(), - _store_measured_fade_white=MagicMock(), state=SimpleNamespace( config=object(), metrics_lock=MagicMock(__enter__=lambda s: None, __exit__=lambda s, *a: None), diff --git a/tests/test_navigate_back_memo.py b/tests/test_navigate_back_memo.py index 3f6a7cc22..217e3bfcf 100644 --- a/tests/test_navigate_back_memo.py +++ b/tests/test_navigate_back_memo.py @@ -41,7 +41,6 @@ def _stub(memo, **overrides): _gpu_fallback_notified=True, _freeze_resolved_auto_crop=MagicMock(), _freeze_resolved_meter=MagicMock(), - _store_measured_fade_white=MagicMock(), state=SimpleNamespace( config=object(), metrics_lock=MagicMock(__enter__=lambda s: None, __exit__=lambda s, *a: None), From 41d1eb027a54c5fe4534a358059d81cfaeb79bc4 Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:25:11 +0000 Subject: [PATCH 17/46] perf(core): run relog_matrix in runs that vectorize relog_matrix took 10^v in float64 one pixel at a time, which kept the loop scalar. It now works in runs of 256 pixels, one channel at a time, with the same arithmetic in the same order, so the pins do not move. A 24 MP frame on 4 cores: 0.45 s to 0.12 s. Every camera RAW slide with a density correction, and every Positive source profile, runs it. --- negpy_core/src/analysis.rs | 46 +++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index dd2c133aa..3a07c58a3 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -180,16 +180,42 @@ pub fn unmix_log_anchored(img: &[f32], m: [[f32; 3]; 3], anchor: [f32; 3], dst: /// value goes to 10^v, each row is float32 products summed in channel order with no `mul_add`, and /// the log clamps below at 1e-6 as `log_density_open` does. pub fn relog_matrix(img: &[f32], m: [[f32; 3]; 3], dst: &mut [f32]) { - crate::par_zip( - dst.as_chunks_mut::<3>().0, - img.as_chunks::<3>().0, - move |d, p| { - let lin = p.map(|v| crate::mathf::exp10_f64(v as f64) as f32); - *d = m.map(|r| { - crate::mathf::log10f((lin[0] * r[0] + lin[1] * r[1] + lin[2] * r[2]).max(1e-6)) - }); - }, - ); + const BLOCK: usize = 1 << 14; + dst.as_chunks_mut::<3>() + .0 + .par_chunks_mut(BLOCK) + .zip(img.as_chunks::<3>().0.par_chunks(BLOCK)) + .for_each(move |(d, p)| { + for (d, p) in d.chunks_mut(RELOG_RUN).zip(p.chunks(RELOG_RUN)) { + relog_run(p, m, d); + } + }); +} + +/// Pixels per `relog_run`: each step runs as a flat loop over a run, one channel at a time, so it +/// vectorizes. +const RELOG_RUN: usize = 256; + +#[multiversion(targets("x86_64+avx2+fma", "aarch64+neon"))] +fn relog_run(px: &[[f32; 3]], m: [[f32; 3]; 3], out: &mut [[f32; 3]]) { + let n = px.len(); + let mut lin = [[0f32; RELOG_RUN]; 3]; + for (c, lin) in lin.iter_mut().enumerate() { + for (l, p) in lin[..n].iter_mut().zip(px) { + *l = crate::mathf::exp10_f64(p[c] as f64) as f32; + } + } + let mut row = [0f32; RELOG_RUN]; + for (k, r) in m.iter().enumerate() { + for (i, v) in row[..n].iter_mut().enumerate() { + *v = crate::mathf::log10f( + (lin[0][i] * r[0] + lin[1][i] * r[1] + lin[2][i] * r[2]).max(1e-6), + ); + } + for (o, v) in out.iter_mut().zip(&row[..n]) { + o[k] = *v; + } + } } /// `normalize_log_image`: `(v - floor) / (ceil - floor)` per channel of an HxWx3 log image, From e6189ab3bd0609029200f4a3610deaa871649297 Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:25:11 +0000 Subject: [PATCH 18/46] perf(fade): add the density offset in the anchored kernel and share the image grid A camera RAW's density matrix carries its white-balance gains as a log offset, so every faded RAW frame took a full-image NumPy add after the unmix. unmix_log_anchored now adds the offset in the same pass, with the same float32 add, so the render keeps its bits. A render that measures a stale white prefiltered the full image a second time. The white and the meters now share one prefiltered grid. The slide base stage of a 24 MP frame on 4 cores, against the base branch: fade 0.44 s to 0.52 s, fade with Remove Stain 0.65 s to 0.57 s, a stale white 0.59 s, an unfaded frame unchanged at 0.42 s. --- negpy/features/exposure/normalization.py | 10 ++++----- negpy/features/transparency/processor.py | 27 ++++++++++++------------ negpy_core/src/analysis.rs | 19 +++++++++++++---- negpy_core/src/py/analysis.rs | 5 +++-- tests/test_core_same_bits.py | 3 ++- 5 files changed, 39 insertions(+), 25 deletions(-) diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index 0c57f36b3..0e9080407 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -200,11 +200,11 @@ def unmix_log_image(img_log: ImageBuffer, matrix: Optional[np.ndarray], anchor: m = np.ascontiguousarray(matrix, dtype=np.float32) img = np.ascontiguousarray(img_log, dtype=np.float32) lin = np.ascontiguousarray(m[:, :3]) - if anchor is None: - out = negpy_core.unmix_log(img, lin) - else: - out = negpy_core.unmix_log_anchored(img, lin, [float(v) for v in anchor]) - # A (3, 4) matrix is Remove Stain's affine: the last column is a log offset added after the 3x3. + if anchor is not None: + offset = [float(v) for v in m[:, 3]] if m.shape[1] == 4 else [0.0, 0.0, 0.0] + return negpy_core.unmix_log_anchored(img, lin, [float(v) for v in anchor], offset) + out = negpy_core.unmix_log(img, lin) + # A (3, 4) matrix is an affine: the last column is a log offset added after the 3x3. return out + m[:, 3] if m.shape[1] == 4 else out diff --git a/negpy/features/transparency/processor.py b/negpy/features/transparency/processor.py index e3c25cb2e..175b25181 100644 --- a/negpy/features/transparency/processor.py +++ b/negpy/features/transparency/processor.py @@ -71,8 +71,19 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: image.shape, context.active_roi, self.config.analysis_buffer, self.config.analysis_rect, self.config.meter_rect ) + image_grid: Optional[ImageBuffer] = None + + def image_log_grid() -> ImageBuffer: + """The prefiltered *image*, built once: the white and, with no matrix before the log, the meters read it.""" + nonlocal image_grid + if image_grid is None: + image_grid = prefilter_log_grid(image, an_roi, an_buffer) + return image_grid + def white_grid() -> ImageBuffer: - return fade_white_grid(self.config, context.process_mode, context.cam_xyz, context.camera_wb, image, an_roi, an_buffer) + return fade_white_grid( + self.config, context.process_mode, context.cam_xyz, context.camera_wb, image, an_roi, an_buffer, image_log_grid + ) white_space = render_white_space(self.config, context.process_mode, context.cam_xyz, context.camera_wb) measured = resolve_fade_white(self.config, white_space, white_grid) @@ -101,20 +112,10 @@ def white_grid() -> ImageBuffer: # Shared prefilter for the neutral axis and Auto Density/Grade's four meters -- # working-space (post camera matrix) and post-unmix, since that is what this curve # itself consumes. - log_grid = prefilter_log_grid(linear, an_roi, an_buffer) + log_grid = image_log_grid() if stage.pre is None else prefilter_log_grid(linear, an_roi, an_buffer) prefiltered = relog_matrix_image(unmix_log_image(log_grid, stage.unmix, stage.anchor), stage.post) if context.wants_fade_grid: - # *log_grid* is the prefiltered *image* itself when no matrix runs before the log. - context.metrics["transfer_grid"] = fade_white_grid( - self.config, - context.process_mode, - context.cam_xyz, - context.camera_wb, - image, - an_roi, - an_buffer, - (lambda: log_grid) if stage.pre is None else None, - ) + context.metrics["transfer_grid"] = white_grid() # Cast Removal's neutral axis is metered on the working-space log image the curve # consumes, since the camera matrix above would leave a meter reading a different diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index 3a07c58a3..48c000ae3 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -156,8 +156,15 @@ pub fn unmix_log(img: &[f32], m: [[f32; 3]; 3], dst: &mut [f32]) { /// `unmix_log_image` with an anchor: each channel is capped at its anchor before the 3x3, and the /// amount the pixel is above the anchor in every channel, if any, is added to each channel after. -/// Light past the film carries no dye, so it passes through the density matrix as a neutral. -pub fn unmix_log_anchored(img: &[f32], m: [[f32; 3]; 3], anchor: [f32; 3], dst: &mut [f32]) { +/// Light past the film carries no dye, so it passes through the density matrix as a neutral. Each +/// channel then takes its `offset`, the affine's last column, as `unmix_log_image` adds it after. +pub fn unmix_log_anchored( + img: &[f32], + m: [[f32; 3]; 3], + anchor: [f32; 3], + offset: [f32; 3], + dst: &mut [f32], +) { crate::par_zip( dst.as_chunks_mut::<3>().0, img.as_chunks::<3>().0, @@ -171,7 +178,8 @@ pub fn unmix_log_anchored(img: &[f32], m: [[f32; 3]; 3], anchor: [f32; 3], dst: p[1].min(anchor[1]), p[2].min(anchor[2]), ]; - *d = m.map(|r| q[0] * r[0] + q[1] * r[1] + q[2] * r[2] + excess); + let r = m.map(|r| q[0] * r[0] + q[1] * r[1] + q[2] * r[2] + excess); + *d = [r[0] + offset[0], r[1] + offset[1], r[2] + offset[2]]; }, ); } @@ -1867,12 +1875,15 @@ mod tests { let anchor = [-0.4, -0.5, -0.6]; let img = [-1.0, -1.0, -1.0, 0.0, 0.0, 0.0, -0.2, -0.8, 0.0]; let mut out = [0f32; 9]; - unmix_log_anchored(&img, m, anchor, &mut out); + unmix_log_anchored(&img, m, anchor, [0.0; 3], &mut out); assert_eq!(&out[..3], &[-2.0, -1.0, -0.5]); // Above the anchor in every channel by at least 0.4: capped, then 0.4 added to each. assert_eq!(&out[3..6], &[-0.8 + 0.4, -0.5 + 0.4, -0.3 + 0.4]); // Below the anchor in green: capped where above, nothing added. assert_eq!(&out[6..], &[-0.8, -0.8, -0.3]); + // The offset joins each channel last. + unmix_log_anchored(&img, m, anchor, [0.25, -0.5, 1.0], &mut out); + assert_eq!(&out[..3], &[-2.0 + 0.25, -1.0 - 0.5, -0.5 + 1.0]); } #[test] diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index 53c7c5113..253ca562e 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -467,13 +467,14 @@ fn unmix_log<'py>( Ok(out) } -/// `unmix_log_image(img_log, matrix, anchor)` of an HxWx3 float32 log image. +/// `unmix_log_image(img_log, matrix, anchor)` of an HxWx3 float32 log image, with the affine's offset. #[pyfunction] fn unmix_log_anchored<'py>( py: Python<'py>, img: PyReadonlyArrayDyn<'py, f32>, m: numpy::PyReadonlyArray2<'py, f32>, anchor: [f32; 3], + offset: [f32; 3], ) -> PyResult>> { let shape = img.shape().to_vec(); if shape.len() != 3 || shape[2] != 3 { @@ -490,7 +491,7 @@ fn unmix_log_anchored<'py>( .as_slice_mut() .map_err(|e| PyValueError::new_err(e.to_string()))?; super::detach(py, dst.len(), || { - analysis::unmix_log_anchored(data, m, anchor, dst) + analysis::unmix_log_anchored(data, m, anchor, offset, dst) }); } Ok(out) diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index 00d7e3f09..d0f9ad7f9 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -62,6 +62,7 @@ def _filled(fill): np.ascontiguousarray(_IMG3 * np.float32(-1.0)), np.array([[1.3, -0.2, -0.1], [-0.05, 1.4, -0.35], [0.0, -0.3, 1.3]], np.float32), [-0.4, -0.7, -0.55], + [0.12, -0.3, 0.05], ), "relog_matrix": lambda: negpy_core.relog_matrix( np.ascontiguousarray(_IMG3 * np.float32(-3.0)), @@ -168,7 +169,7 @@ def _filled(fill): "morph_close_u8": "4be9a2976881ff83eb0b92a8ec484e048c61612324afbde73b6e0c89aa92865e", "to_log_density": "cc4d2086edf051826619f024a4f5d28c80d28528291d9df4610c1d3854144038", "unmix_log": "73edc03deb7e068718e77264608858efaec4b64776f64645da9f4aef54ec23b4", - "unmix_log_anchored": "4a90ef06a62ef6de0478a5c13912531f8f860f63a4d5370cf1e1f1c7678c4907", + "unmix_log_anchored": "0d1d92c5fdf62796f89d844d8c03bd2c8fc9cd8da35f11f44b9fa9cbeb1507f4", "relog_matrix": "e79e7bbb62aa68e9248c9b92d9875cbb5779b640a93b8f8e45a648b5009547be", "oetf_encode": "44508d28673c678fc0ec7b5262a06d0053ea6b4bed4bead7bc49556bc60eb070", "rgb_to_lab": "02c583bf1f5fe9723e2cb0a3e1f34759d6b0778d3644e8e2cff2251e756b68f2", From 89e6bd9a9b2e5b4d5e383441ec305fa625bdf5ca Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:31:21 +0100 Subject: [PATCH 19/46] fix(fade): keep a camera RAW frame's own white on Paste, Apply and presets The roll keeps a camera RAW's fade white on its frame, but Paste, the Film Strip's Apply, presets and sticky settings copied the whole Fade Restoration row. Two frames from one camera with the same white balance have the same white tag, so the target took the source's white as its own anchor and rendered with a cast. apply_selected_fields now leaves a white with a camera tag on the target, as rolls._without_decode_white does for a roll. The survivals and Remove Stain still copy, and a scanner white still copies. --- negpy/desktop/settings_catalog.py | 8 +++-- tests/test_settings_catalog_presets.py | 41 +++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py index f0e64b824..8e584200b 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -24,7 +24,7 @@ from negpy.features.process.models import SOURCE_ENCODING_LABELS, SourceEncoding, invalidate_local_bounds, with_film_fields from negpy.services.assets.flatfield import FlatFieldProfiles from negpy.services.assets.presets import preset_fields -from negpy.services.assets.rolls import card_fields +from negpy.services.assets.rolls import DECODE_WHITE_FIELDS, card_fields class SettingRow: @@ -632,7 +632,7 @@ def catalog_sections(cfg: WorkspaceConfig) -> list[tuple[str, list[tuple[Setting def apply_selected_fields(source: WorkspaceConfig, target: WorkspaceConfig, rows: Iterable[SettingRow]) -> WorkspaceConfig: """Overlay only the chosen rows' fields from source onto target (one replace per section). Fields not listed — per-frame bounds, dust spots, heal strokes, - masks — stay the target's own.""" + masks — stay the target's own, and so does a camera RAW's fade white.""" rows = list(rows) by_section: dict[str, dict] = {} for row in rows: @@ -640,6 +640,10 @@ def apply_selected_fields(source: WorkspaceConfig, target: WorkspaceConfig, rows changes = by_section.setdefault(row.section, {}) for f in row.fields: changes[f] = getattr(src_section, f) + process = by_section.get("process", {}) + if process.get("fade_white_space"): + # A camera RAW's white moves with each frame's exposure, as on a roll (rolls._without_decode_white). + by_section["process"] = {k: v for k, v in process.items() if k not in DECODE_WHITE_FIELDS} out = target # Film Mode and Positive carry their own defaults; a field chosen alongside still wins below. out = with_film_fields(out, by_section.get("process", {})) diff --git a/tests/test_settings_catalog_presets.py b/tests/test_settings_catalog_presets.py index 126f4e9c7..01b8cf0bb 100644 --- a/tests/test_settings_catalog_presets.py +++ b/tests/test_settings_catalog_presets.py @@ -5,7 +5,14 @@ import pytest -from negpy.desktop.settings_catalog import all_rows, apply_selected_fields, preset_summary, roll_card_rows, selected_flat_dict +from negpy.desktop.settings_catalog import ( + all_rows, + apply_selected_fields, + preset_config, + preset_summary, + roll_card_rows, + selected_flat_dict, +) from negpy.domain.models import WorkspaceConfig from negpy.features.process.models import DemosaicMode, ProcessMode from negpy.services.assets.rolls import card_fields @@ -125,3 +132,35 @@ def test_preset_summary_lists_a_deliberately_default_value(): def test_preset_summary_empty_for_no_data(): assert preset_summary({}) == "" + + +def _faded(white: tuple, space: str) -> WorkspaceConfig: + base = WorkspaceConfig() + r, g, b = white + process = replace( + base.process, fade_ratio_g=0.8, fade_ratio_b=0.6, fade_white_r=r, fade_white_g=g, fade_white_b=b, fade_white_space=space + ) + return replace(base, process=process) + + +def test_a_pasted_fade_keeps_the_targets_camera_raw_white(): + """A camera RAW's white moves with each frame's exposure, so Paste and Apply copy only the survivals.""" + source = _faded((-0.6, -0.55, -0.65), "cam") + target = _faded((-0.3, -0.25, -0.35), "cam") + out = apply_selected_fields(source, target, [_row("Fade Restoration")]).process + assert (out.fade_ratio_g, out.fade_ratio_b) == (0.8, 0.6) + assert (out.fade_white_r, out.fade_white_g, out.fade_white_b, out.fade_white_space) == (-0.3, -0.25, -0.35, "cam") + + +def test_a_fade_preset_keeps_the_targets_camera_raw_white(): + data = selected_flat_dict(_faded((-0.6, -0.55, -0.65), "cam"), [_row("Fade Restoration")]) + target = replace(WorkspaceConfig(), process=replace(WorkspaceConfig().process, fade_white_r=-0.3, fade_white_space="cam")) + out = apply_selected_fields(preset_config(data), target, [_row("Fade Restoration")]).process + assert out.fade_ratio_b == 0.6 + assert (out.fade_white_r, out.fade_white_space) == (-0.3, "cam") + + +def test_a_pasted_scanner_white_copies(): + """A scanner white is the file's full scale, the same on every frame, as on a roll.""" + out = apply_selected_fields(_faded((-0.05, -0.1, -0.3), ""), WorkspaceConfig(), [_row("Fade Restoration")]).process + assert (out.fade_white_r, out.fade_white_g, out.fade_white_b) == (-0.05, -0.1, -0.3) From 2e6801de4fc472c511ed275a7bab549d9a79a124 Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:43:24 +0000 Subject: [PATCH 20/46] fix(fade): name Highlight Reconstruction in the white's tag Highlight Reconstruction rebuilds the clipped pixels that a camera RAW's white is read from. The tag named only the white-balance gains and the crosstalk unmix, so a stored white stayed in use after the level changed. The tag now hashes the effective highlight mode, and a white stored with the old tag reads as stale. A negative now takes the empty tag. --- negpy/features/process/fade.py | 22 ++++++++++++++++------ tests/test_fade_white_space.py | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/negpy/features/process/fade.py b/negpy/features/process/fade.py index c8f7968d7..4f8b6064d 100644 --- a/negpy/features/process/fade.py +++ b/negpy/features/process/fade.py @@ -16,6 +16,7 @@ measure_neutral_axis_from_log, ) from negpy.features.process.capture_color import camera_matrix_parts, is_source_profile +from negpy.features.process.logic import effective_highlight_reconstruction from negpy.features.process.models import ProcessConfig from negpy.features.process.path import RenderPath, render_path @@ -67,12 +68,16 @@ def fade_matrix(process: ProcessConfig) -> Optional[np.ndarray]: def fade_white_space( - cam_xyz: Optional[Sequence[Sequence[float]]], camera_wb: Optional[Sequence[float]], crosstalk: Optional[np.ndarray] = None + cam_xyz: Optional[Sequence[Sequence[float]]], + camera_wb: Optional[Sequence[float]], + crosstalk: Optional[np.ndarray] = None, + highlight_mode: int = 0, ) -> str: """The space a camera RAW's log white is measured in: its white-balanced camera channels - (`density_channels`) after the *crosstalk* unmix, named by the as-shot white balance and the - unmix. The primaries after them do not name it. "" for a source with no camera matrix, whose - white is the file's own full scale.""" + (`density_channels`) after the *crosstalk* unmix, named by the as-shot white balance, the unmix + and the decode's *highlight_mode*, which rebuilds the clipped pixels the white is read from. The + primaries after them do not name it. "" for a source with no camera matrix, whose white is the + file's own full scale.""" if cam_xyz is None or is_source_profile(cam_xyz): return "" gains, primaries = camera_matrix_parts(cam_xyz, camera_wb) @@ -81,14 +86,19 @@ def fade_white_space( named = b"" if gains is None else np.round(np.diag(gains).astype(np.float64), 4).tobytes() if crosstalk is not None: named += b"unmix" + np.round(np.asarray(crosstalk, dtype=np.float64), 4).tobytes() + named += b"highlight" + int(highlight_mode).to_bytes(1, "little") return hashlib.sha1(_WHITE_SPACE_CHANNELS + named).hexdigest()[:12] def render_white_space( process: ProcessConfig, process_mode: Optional[str], cam_xyz: Optional[Sequence[Sequence[float]]], camera_wb: Optional[Sequence[float]] ) -> str: - """`fade_white_space` of a render with *process*, its crosstalk unmix included.""" - return fade_white_space(cam_xyz, camera_wb, effective_crosstalk_matrix(process, process_mode)) + """`fade_white_space` of a render with *process*: its crosstalk unmix and highlight mode included, + "" on a negative, which takes no fade.""" + if render_path(process) is RenderPath.PRINT: + return "" + crosstalk = effective_crosstalk_matrix(process, process_mode) + return fade_white_space(cam_xyz, camera_wb, crosstalk, effective_highlight_reconstruction(process)) # Names the channels in `fade_white_space`, so a tag hashed any other way reads as stale. diff --git a/tests/test_fade_white_space.py b/tests/test_fade_white_space.py index 66555262c..d7b3f9f88 100644 --- a/tests/test_fade_white_space.py +++ b/tests/test_fade_white_space.py @@ -338,3 +338,18 @@ def test_the_gpu_measures_a_stale_white_once_per_frame(monkeypatch): assert len(calls) == 1 finally: engine.destroy_all() + + +@pytest.mark.parametrize("level", [2, 5]) +def test_highlight_reconstruction_makes_a_stored_white_stale(level): + """Highlight Reconstruction rebuilds the clipped pixels the white is read from, so a new level measures it again.""" + process = _slide(**_FADE, **_white(space=render_white_space(_slide(), ProcessMode.E6, _CAMERA, _WB))) + assert not fade_white_stale(process, render_white_space(process, ProcessMode.E6, _CAMERA, _WB)) + rebuilt = replace(process, highlight_reconstruction=level) + assert fade_white_stale(rebuilt, render_white_space(rebuilt, ProcessMode.E6, _CAMERA, _WB)) + + +def test_a_negative_and_a_scanner_source_take_no_white_space(): + negative = ProcessConfig(process_mode=ProcessMode.C41, highlight_reconstruction=5) + assert render_white_space(negative, ProcessMode.C41, _CAMERA, _WB) == "" + assert render_white_space(_slide(highlight_reconstruction=5), ProcessMode.E6, None, None) == "" From 21db3ba13975e66f29dcc1db1e901d96d1f0c896 Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:43:53 +0000 Subject: [PATCH 21/46] fix(rolls): lock a scanner frame's Fade card on its own white The lock check ignored the white fields that the roll did not hold on every frame. A scanner frame's own Remove Stain white then matched a roll with no white, so the card stayed unlocked and its Roll button had nothing to push. The check now ignores those fields only on a camera RAW frame, whose white stays its own. --- negpy/desktop/controller.py | 5 +++-- tests/test_controller.py | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 1e92a2222..085d1bd97 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -6564,9 +6564,10 @@ def _lock_roll_card_for(self, asset: dict, file_hash: str, config: WorkspaceConf # A field the roll has never set at all cannot "match" -- there is nothing yet # to differ from, and treating that as a match would hide a card's first-ever # edit from Apply until every one of its fields happened to get a roll default. - # A fade white the roll holds none of stays the frame's own (`frame_roll_defaults`). + # A camera RAW's fade white stays the frame's own (`frame_roll_defaults`). current = self._card_values(config, card_key) - current = {k: v for k, v in current.items() if k in defaults or k not in rolls.DECODE_WHITE_FIELDS} + if config.process.fade_white_space: + current = {k: v for k, v in current.items() if k in defaults or k not in rolls.DECODE_WHITE_FIELDS} matches_roll = all(name in defaults and rolls.same_value(value, defaults[name]) for name, value in current.items()) diverged = not matches_roll file_hash = rolls.unforked_hash(file_hash) diff --git a/tests/test_controller.py b/tests/test_controller.py index 057afce94..6929c3785 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1462,6 +1462,30 @@ def test_a_camera_raw_fade_card_matches_a_roll_that_holds_no_white(self): self.assertEqual(rolls.frame_override_cards(repo, roll_id, "h1"), set()) + def test_a_scanner_frames_own_white_locks_the_fade_card_on_a_roll_with_no_white(self): + """A scanner white is the roll's to share, so a frame's own white is a divergence; a camera RAW's is not.""" + from dataclasses import replace + + from negpy.services.assets import rolls + + self._wire_repo_store() + repo = self.controller.session.repo + roll_id = rolls.create_virtual_roll(repo, "Velvia", []) + state = self.mock_session_manager.state + state.active_roll_id = roll_id + state.uploaded_files = [{"name": "a.tif", "path": "/a.tif", "hash": "h1"}] + state.current_file_hash = "h1" + rolls.set_roll_defaults(repo, roll_id, fade_ratio_r=1.0, fade_ratio_g=0.8, fade_ratio_b=1.0, fade_white_anchor=True) + white = dict(fade_white_r=-0.05, fade_white_g=-0.1, fade_white_b=-0.3) + for space, locked in (("", {"fade"}), ("abc123", set())): + rolls.set_frame_override(repo, roll_id, "h1", "fade", False) + process = replace(WorkspaceConfig().process, fade_ratio_g=0.8, fade_white_anchor=True, fade_white_space=space) + state.config = replace(WorkspaceConfig(), process=process) + + self.controller.set_roll_default("fade", **white) + + self.assertEqual(rolls.frame_override_cards(repo, roll_id, "h1"), locked, space) + def test_set_roll_default_writes_the_cards_own_config_section(self): """The Lens Correction card edits GeometryConfig, not ProcessConfig.""" from negpy.services.assets import rolls From b651b66e3bb213d5bf730f92a764507d0a643d52 Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:44:49 +0000 Subject: [PATCH 22/46] fix(rolls): keep a roll's scanner white when a camera RAW pushes the Fade card A push that held a camera RAW white deleted the roll's white fields. The scanner frames on a mixed roll then lost the scanner white they share. The push now leaves out only the RAW white. A RAW frame still ignores the roll's scanner white, through its tag and frame_roll_defaults. Updates test_a_camera_raw_fade_white_stays_on_its_frame in tests/test_rolls.py, which asserted the deletion: the roll now keeps its scanner white and takes only the pushed survivals. --- negpy/services/assets/rolls.py | 3 --- tests/test_rolls.py | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/negpy/services/assets/rolls.py b/negpy/services/assets/rolls.py index 15deaa290..ef320e015 100644 --- a/negpy/services/assets/rolls.py +++ b/negpy/services/assets/rolls.py @@ -792,9 +792,6 @@ def set_roll_defaults(repo: Any, roll_id: str, **fields: Any) -> None: if entry is None: return defaults = dict(entry.get("defaults", {})) - if fields.get("fade_white_space"): - # A camera RAW's white replaces the roll's white with none, so each frame keeps its own. - defaults = {k: v for k, v in defaults.items() if k not in DECODE_WHITE_FIELDS} defaults.update(_without_decode_white(fields)) entry["defaults"] = defaults _write(repo, store) diff --git a/tests/test_rolls.py b/tests/test_rolls.py index d2a6e3685..55cccaa3d 100644 --- a/tests/test_rolls.py +++ b/tests/test_rolls.py @@ -1185,7 +1185,7 @@ def test_home_roll_lookup_answers_as_home_roll(tmp_path): def test_a_camera_raw_fade_white_stays_on_its_frame(): - """A RAW's white moves with each frame's exposure, so the roll keeps a scanner white only.""" + """A RAW's white moves with each frame's exposure, so its push keeps the roll's scanner white and stores only the survivals.""" repo = _repo() roll_id = create_virtual_roll(repo, "R", ["/a.tif"]) scanner = dict(fade_white_r=-0.1, fade_white_g=-0.2, fade_white_b=-0.3, fade_white_space="") @@ -1193,7 +1193,7 @@ def test_a_camera_raw_fade_white_stays_on_its_frame(): assert roll_defaults(repo, roll_id) == dict(fade_ratio_g=2.0, **scanner) raw = dict(fade_white_r=-0.4, fade_white_g=-0.5, fade_white_b=-0.6, fade_white_space="abc123") set_roll_defaults(repo, roll_id, fade_ratio_g=1.5, **raw) - assert roll_defaults(repo, roll_id) == dict(fade_ratio_g=1.5) + assert roll_defaults(repo, roll_id) == dict(fade_ratio_g=1.5, **scanner) frame = WorkspaceConfig(process=ProcessConfig(fade_white_r=-0.7, fade_white_g=-0.8, fade_white_b=-0.9, fade_white_space="abc123")) resolved = resolve_roll_config(repo, roll_id, "h1", frame).process assert (resolved.fade_ratio_g, resolved.fade_white_r, resolved.fade_white_space) == (1.5, -0.7, "abc123") From 367ce5686fc09afac8e7c8f799a190c328351a25 Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:45:18 +0000 Subject: [PATCH 23/46] refactor(fade): stop zeroing the stored white for the Estimate grid The Estimate grid is fade_white_grid, which never applies the white. With the survivals at 1.0 and Remove Stain off, no white acts in that render. The zeroed white fields and their comment are removed. test_estimate_reads_the_grid_without_the_stored_white in tests/test_fade_white_space.py asserted the zeroed fields. It now renders the grid with two different stored whites and expects equal grids. --- negpy/desktop/workers/chart_fit.py | 12 +----------- tests/test_fade_white_space.py | 22 +++++++++------------- 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/negpy/desktop/workers/chart_fit.py b/negpy/desktop/workers/chart_fit.py index 93b43e476..0b93119d9 100644 --- a/negpy/desktop/workers/chart_fit.py +++ b/negpy/desktop/workers/chart_fit.py @@ -118,17 +118,7 @@ def run(self) -> None: def fade_estimate_grid(source: ChartSource) -> Optional[np.ndarray]: """The transparency meter's grid for the open frame, rendered on the CPU with the fade at identity and Remove Stain off, so Estimate never reads its own correction. None on a negative.""" - # The stored white is zeroed too: on a camera RAW it would cap the grid the new white is read from. - process = replace( - source.config.process, - fade_ratio_r=1.0, - fade_ratio_g=1.0, - fade_ratio_b=1.0, - fade_white_anchor=False, - fade_white_r=0.0, - fade_white_g=0.0, - fade_white_b=0.0, - ) + process = replace(source.config.process, fade_ratio_r=1.0, fade_ratio_g=1.0, fade_ratio_b=1.0, fade_white_anchor=False) buf = np.asarray(source.buffer, dtype=np.float32) _, metrics = ImageProcessor(use_gpu=False).run_pipeline( buf, diff --git a/tests/test_fade_white_space.py b/tests/test_fade_white_space.py index d7b3f9f88..126aa12b5 100644 --- a/tests/test_fade_white_space.py +++ b/tests/test_fade_white_space.py @@ -276,23 +276,19 @@ def test_a_reconstructed_highlight_stays_white_through_the_fade(positive, remove assert not np.allclose(restored[20:, 30:], np.log10(img[20:, 30:]), atol=1e-3) -def test_estimate_reads_the_grid_without_the_stored_white(monkeypatch): - """A stored white caps the render at its anchor; the grid a new white is read from must not be capped.""" +def test_estimate_reads_the_grid_without_the_stored_white(): + """The grid a new white is read from takes no fade and no white, so a stored white does not move it.""" from negpy.desktop.workers import chart_fit - from negpy.services.rendering import image_processor - seen = [] + img = (_synthetic_neutral_slide(0.8, 0.6, size=96) * np.float32((0.22, 0.25, 0.18))).astype(np.float32) - def run_pipeline(self, buf, settings, *args, **kwargs): - seen.append(settings.process) - return None, {"transfer_grid": np.zeros((2, 2, 3), np.float32)} + def grid(**white): + cfg = replace(WorkspaceConfig(), process=_slide(linear_raw=True, fade_white_anchor=True, **_FADE, **white)) + return chart_fit.fade_estimate_grid(chart_fit.ChartSource(img, cfg, "h", _CAMERA, _WB, (), 1)) - monkeypatch.setattr(image_processor.ImageProcessor, "run_pipeline", run_pipeline) - cfg = replace(WorkspaceConfig(), process=_slide(fade_white_anchor=True, **_FADE, **_white())) - chart_fit.fade_estimate_grid(chart_fit.ChartSource(np.zeros((4, 4, 3), np.float32), cfg, "h", _CAMERA, _WB, (), 1)) - (process,) = seen - assert (process.fade_white_r, process.fade_white_g, process.fade_white_b) == (0.0, 0.0, 0.0) - assert not process.fade_white_anchor and process.fade_ratio_g == 1.0 + stored = grid(**_white(white=(-0.9, -0.95, -1.0))) + assert stored is not None + np.testing.assert_array_equal(stored, grid(**_white(white=(0.0, 0.0, 0.0)))) def test_an_export_renders_a_stale_white_as_the_measured_one(): From af134edff0d0c4c26fdc31c11335f28a7ce676f2 Mon Sep 17 00:00:00 2001 From: Akinyemi Paul Odulate Date: Mon, 2 Nov 2026 21:26:12 +0000 Subject: [PATCH 24/46] Blend capture: R, G and B in turn inside one Bulb exposure Blend is a third capture mode beside Trichrome and Single Exposure, as in Lento's Dock Connect. The camera holds Bulb and the light shows red, green and blue in turn, each for its Trichrome calibration time, so a frame is one RAW. - Shutter release: USB bulb through gphoto2, or Cable through the Scanlight's 3.5mm jack (H2D_SHUTTER_PULSE, at most 2.55 s). - A Blend calibration saves a sensor crosstalk profile by default, and the frame's roll takes it with Linear RAW. - A strip holds Bulb from frame to frame and moves the film while the camera writes the file. On an A7 IV with the Lento Dock 35 a frame went from about 9 s to about 4.5 s. - The Dock switches the light without waiting for its reply, so each channel gets its time. Waited writes made a 200 ms red last 312 to 703 ms; the no-wait writes held it to within 0.5%. - The light stays dark 0.35 s after the Bulb press: the A7 IV opens its shutter by 0.3 s. scripts/bulb_lead_probe.py measures another body. - The Dock takes host control when NegPy connects, so it finds frame 0 of a strip inserted after that. Under AUTO it reported idx=-1. - The Dock keep-alive reaches the dock while a command waits for a slow move, and an unplugged dock logs one warning, not a traceback per second. - The preset list names each preset's capture mode, and the Film Strip marks Blend and Single Exposure frames. On ten frames of one strip, Blend with its sensor profile matched Trichrome within 1 to 2.6% RMS in R/G and B/G after a per-channel gain; without the profile the error was two to three times larger. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CiaS7pstGdewkrwmf2fset --- docs/CAMERA_SCANNING.md | 69 +++++- docs/PIPELINE.md | 2 +- docs/USER_GUIDE.md | 8 +- negpy/desktop/controller.py | 8 +- negpy/desktop/session.py | 26 ++- .../view/sidebar/calibration_window.py | 25 ++- negpy/desktop/view/sidebar/files.py | 22 +- negpy/desktop/view/sidebar/scanlight.py | 170 ++++++++++++-- negpy/desktop/workers/capture_worker.py | 119 ++++++++-- negpy/infrastructure/capture/base.py | 42 +++- negpy/infrastructure/capture/gphoto.py | 211 ++++++++++++++++-- negpy/infrastructure/capture/lento_dock.py | 43 +++- negpy/infrastructure/capture/protocol.py | 24 +- negpy/infrastructure/capture/scanlight.py | 5 + negpy/infrastructure/capture/settings.py | 5 + negpy/infrastructure/simulated/gphoto.py | 69 ++++-- negpy/infrastructure/simulated/scanlight.py | 42 +++- negpy/services/assets/capture_modes.py | 49 ++++ negpy/services/capture/calibration.py | 14 +- negpy/services/capture/presets.py | 1 + negpy/services/capture/service.py | 168 +++++++++++++- negpy/translations/USER_GUIDE.de.md | 8 +- negpy/translations/USER_GUIDE.en.md | 8 +- negpy/translations/USER_GUIDE.ja.md | 8 +- negpy/translations/USER_GUIDE.ko.md | 8 +- negpy/translations/USER_GUIDE.zh_CN.md | 8 +- negpy/translations/de.json | 20 +- negpy/translations/ja.json | 20 +- negpy/translations/ko.json | 20 +- negpy/translations/zh_CN.json | 20 +- scripts/bulb_lead_probe.py | 133 +++++++++++ tests/test_capture_calibration.py | 12 + tests/test_capture_controller.py | 16 ++ tests/test_capture_gphoto.py | 180 ++++++++++++++- tests/test_capture_modes.py | 46 ++++ tests/test_capture_presets.py | 9 + tests/test_capture_protocol.py | 12 + tests/test_capture_service.py | 192 +++++++++++++++- tests/test_capture_worker.py | 206 +++++++++++++++++ tests/test_file_browser_widget.py | 22 ++ tests/test_lento_dock_driver.py | 67 ++++++ tests/test_scan_roll.py | 13 ++ tests/test_scanlight_driver.py | 8 +- tests/test_scanlight_sidebar.py | 152 ++++++++++++- tests/test_simulated_hardware.py | 41 +++- 45 files changed, 2195 insertions(+), 156 deletions(-) create mode 100644 negpy/services/assets/capture_modes.py create mode 100644 scripts/bulb_lead_probe.py create mode 100644 tests/test_capture_modes.py diff --git a/docs/CAMERA_SCANNING.md b/docs/CAMERA_SCANNING.md index 718c731a3..982d175cb 100644 --- a/docs/CAMERA_SCANNING.md +++ b/docs/CAMERA_SCANNING.md @@ -19,6 +19,13 @@ together for one exposure: one shutter actuation per frame and no registration. imports as an ordinary RAW, and its roll is recorded with Trichrome Mode off. The channels overlap on the sensor, so pair it with a [sensor calibration](#sensor-calibration) profile. +**Blend scanning.** A Blend preset shoots one Bulb exposure per frame. The +shutter opens in the dark, the light shows red, then green, then blue, and the shutter +closes in the dark. Each channel gets the light of its Trichrome exposure, so the frame +needs no registration and has no channel offset. It imports as one RAW, as a Single +Exposure frame does. Each LED still reaches every sensor channel, so Blend needs a +[sensor calibration](#sensor-calibration) profile. See [Blend](#blend). + ## What you need | | | @@ -140,7 +147,7 @@ aperture. The scan sets that exposure on the body before every frame. The dropdo own presets and the built-ins you have added from **+** › **Built-in Presets**; a built-in stays in the list until the trash removes it. To build a preset by hand, press **+** beside PRESET and pick **Set by Hand** to unlock the sliders and the exposure steppers. Set them and **Capture -mode** (Trichrome or Single Exposure); Scan uses them as they stand, and the save (floppy) +mode** (Trichrome, Single Exposure or Blend); Scan uses them as they stand, and the save (floppy) button keeps them as a preset. Only the white-light presets use the White channel, because the Scanlight cannot light it together with the red, green and blue LEDs. The white-light presets differ only in film type: AUTO classifies each capture, **B&W Negative**, **Color Negative** and **Slide** fix it, for the import and for Meter. **White @@ -149,8 +156,50 @@ need no calibration either: full LED power at the camera's shutter, which Meter a **+ IR** preset ends each frame with an infrared exposure at **IR Shutter**. The **Infrared** toggle beside IR Shutter does the same for any preset. +### Blend + +Blend is the third **Capture mode**, beside Trichrome and Single Exposure. Pick it in the +calibration window, or by hand with **Set by Hand**. Blend calibrates as Trichrome does, so a +selected Trichrome or Blend preset can switch between the two. A Single Exposure preset cannot, +because its levels are for three LEDs lit together. Set the camera to RAW. + +A Blend calibration, with **Create Sensor Crosstalk Profile** on (the default), also saves a +sensor crosstalk profile under the preset's name. It reads every sensor channel in the last +shot of each LED, near the exposure target. Each Blend frame's roll takes that profile, with +Linear RAW. + +A Blend preset with no profile of its own (one set by hand, or a Trichrome preset switched to +Blend) shows **Sensor profile** instead, and Scan waits until you pick one. The vials button +opens **Calibrate Sensor**, where **Capture from Camera…** makes a profile from the bare light. + +**Shutter release** sets what holds the shutter open: + +- **USB.** NegPy sets the shutter to Bulb and presses the camera's bulb control over the + USB cable: `eosremoterelease` on Canon, the `bulb` toggle on Nikon and Sony. A body + that has neither cannot use USB. +- **Cable.** A release cable goes from the Scanlight's 3.5mm jack to the camera. The light + holds the shutter line for the whole exposure, which the firmware limits to 2.55 s. NegPy + sets Bulb over USB if the body lists it. Otherwise, set Bulb on the camera. The camera + stays on USB, because the file still comes in over USB. + +Each channel stays lit for the preset's shutter time at the preset's level. NegPy times +the channels, so a channel shorter than 0.2 s is dimmed and lit longer, with the same +light. All three levels change by one factor, so the channels keep their balance. A level +never goes below 40, the calibration's floor. With Cable, a long exposure is brightened to +fit the pulse; if it still does not fit, the scan stops and asks you to open the aperture +or raise the ISO and calibrate again. The LEDs are not fully linear, so a dimmed or +brightened Blend can expose a little differently from the Trichrome scan of the same +preset. After the exposure, the body goes back to its shutter step. A whole-strip scan keeps the +camera in Bulb from frame to frame, and puts the shutter back before the Flat Field shots. A built-in +**Trichrome** preset uses the camera's shutter step as the time, so meter first. + +The light stays dark for 0.35 s after the Bulb press, because a camera opens its shutter +some time after the press. A body that opens later loses the start of the red light. To +measure your body's lag, close NegPy, leave a frame in the light and run +`uv run python scripts/bulb_lead_probe.py`. It prints the shortest safe lead. + **Scan.** Pick an output folder and a preset, then press **Scan** for each frame. Files go -to an auto-numbered per-roll subfolder and are imported and merged automatically. A Single Exposure preset writes one file per frame. **Retake** shoots the current frame again +to an auto-numbered per-roll subfolder and are imported and merged automatically. A Single Exposure or Blend scan writes one file per frame. **Retake** shoots the current frame again without advancing the counter. **Capture Delay** holds both for up to 10 s before the first exposure, so a table that shakes when you let go of the camera can settle; **Stop** ends the wait. **Delay between exposures** adds a pause between the red, green and blue captures, for @@ -199,8 +248,9 @@ described above. stays in the `flatfield/references` folder, outside the roll. A strip that stops before its end gets no flat. The flat never comes first: after the dock moves the film out of the window and back, it does not advance. -- Insert the strip after the light status shows the dock and before you open Live View. The - dock then finds the first frame itself. Opening the dock's port resets the dock, so it does +- Insert the strip after the light status shows the dock and before you open Live View. NegPy + takes control of the dock when it connects, and the dock finds the first frame only of a strip + that goes in under that control. Opening the dock's port resets the dock, so it does not know the position of a strip that was in it before. - While NegPy uses the dock, the dock's S button works as Scan: it starts a scan, or stops one. The scan starts at the press. The dock also makes a 3-second exposure of its own, and the @@ -249,11 +299,12 @@ assigns it on every scan. You assign a Calibration-panel profile yourself, once | Tethered camera and Scanlight, manual preset | [C](#c-a-manual-preset) | Calibration panel | You | | Files shot without NegPy | [D](#d-files-not-scanned-with-negpy) | Calibration panel | You | -All four apply to Single Exposure frames only. A Trichrome preset needs no profile. +All four apply to Single Exposure and Blend frames only. A Trichrome scan needs no profile. A +calibrated Blend preset makes its own profile, as in workflow A. #### A. The preset makes the profile -The default, for calibrated Single Exposure presets. +The default, for calibrated Single Exposure and Blend presets. Once per film stock: @@ -261,7 +312,7 @@ Once per film stock: dropdown and pick **Calibrate on the Film Base…**. The calibration window opens with its own live view. 2. Set the ISO and the aperture you scan with, click the clear film base and name the preset after the stock. -3. Pick **Single Exposure**, leave **Create Sensor Crosstalk Profile** on and press **Calibrate & Save**. +3. Pick **Single Exposure** or **Blend**, leave **Create Sensor Crosstalk Profile** on and press **Calibrate & Save**. For every roll of that stock: @@ -358,6 +409,8 @@ LED at a time). Its remaining color error is film-dye crosstalk, which the densi | Live view is black | The body dropped out of PC Remote, or the lens cap is on. | Power-cycle the camera. | | The scan window opens with **"no live view"** instead of a preview | libgphoto2's entry for this body has no preview capability (Sony a6000), or it is connected in MTP mode, where no body has it. | If the message names MTP, set the USB mode to **PC Remote** and reconnect. If not, scanning works, but you set framing and focus on the camera, and calibration is not available. | | Capture says the camera returned JPEG instead of RAW | The image-quality setting is JPEG, or RAW+JPEG selected the processed file. | Set image quality to **RAW only**, then retry. | +| A Blend scan says the camera did not go to Bulb | The drive mode is continuous (a Sony body refuses Bulb in any burst mode), or the mode dial is not on M. | Set the drive mode to single shooting and the mode dial to M, then scan again. | +| A Blend scan says the camera sent no RAW file | The body is not in Bulb, it is set to JPEG, or the release cable does not reach it. | Set Bulb and RAW only. With **Cable**, check that the cable is in the Scanlight's 3.5mm jack and that the shutter fires from the light. | | The aperture stepper is grayed out | The lens has no electronic diaphragm. | Set the aperture on the lens. | | A setting snaps back to its old value | Property writes are asynchronous. | NegPy polls until the value lands and logs a warning if it never does. Then the setting is not writable in the body's current mode. Try **M**. | | The Scanlight is not detected | Wrong USB-C port. | Only one of the two ports carries data; the other is power only. Use the data port. | @@ -367,6 +420,8 @@ LED at a time). Its remaining color error is film-dye crosstalk, which the densi ## Notes and limitations - **USB only.** libgphoto2 can reach some cameras over the network, but not Sony bodies. +- **Blend is untested on a real body.** The USB bulb controls follow libgphoto2's names for + Canon, Nikon and Sony, and the simulated rig covers both releases. - **Only Sony bodies are tested.** NegPy looks up every control and assumes no vendor. `iso` and `shutterspeed` have the same names everywhere. The aperture is `f-number` on Sony and Panasonic, and `aperture` on Canon, Nikon, Fujifilm, Olympus and Sigma. The RAW diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index e11e4e291..ef58a15e4 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -32,7 +32,7 @@ Pixel values are linear transmittance. The source corrections run in that domain * **Flat-field** (`negpy.features.flatfield`): a per-channel gain $\text{mean}(\text{blur})/\text{blur}$ from a blank reference, computed on a 256 px copy with $\sigma$ = 1/64 of the long side, clamped to $[0.25, 4]$. The blur and mean are a normalized convolution over the lit area (pixels above 0.35 of the 95th-percentile luminance), so a dark border does not overcorrect the frame beside it. The gain is stored in sensor layout and turned by each frame's decode orientation. It is baked once into a profile (an `.npz` in `APP_CONFIG.flatfield_dir`, keyed by an opaque id, with a 512 px float16 copy of the reference for the self-check: Check Flat Field shows that copy times the gain, each channel over its lit-area median, ±10% to black and white, or 1/gain over ±15% without the copy); the edit stores only the id. The render resolves it through `set_gain_provider` (wired to `services/assets/flatfield.py`); the profile id and a gain content token fold into the source hash. * **Embedded lens correction** (`negpy.features.lens`): the camera's own distortion and lateral CA (DNG `WarpRectilinear`, Sony ARW; Panasonic RW2 distortion only, after darktable's reader), applied at decode after flat-field and before the unmix, so neither engine needs a shader. Composites and RGB+IR sources skip it. A correction that reads past the frame edge is scaled to fill (`fill_scale` ≤ 1). -* **Sensor crosstalk unmix** (`sensor_matrix`, `features/process/sensor.py`): for single-exposure narrowband camera scans, whose CFA passbands overlap the light's bands. The matrix is calibrated from three bare-light exposures (columns normalized to a unit diagonal), inverted, and applied to the linear capture. Where the film passes almost none of a band, the subtraction falls below grain and calibration error, which the log turns into maximum density. `sensor_unmix` picks the handling (default `two_scale`): +* **Sensor crosstalk unmix** (`sensor_matrix`, `features/process/sensor.py`): for one-file narrowband camera scans (Single Exposure and Blend), whose CFA passbands overlap the light's bands. The matrix is calibrated from three bare-light exposures (columns normalized to a unit diagonal), inverted, and applied to the linear capture. Where the film passes almost none of a band, the subtraction falls below grain and calibration error, which the log turns into maximum density. `sensor_unmix` picks the handling (default `two_scale`): * `linear` clips at zero. * `density` applies $C = \mathrm{diag}(1/(M b))\,M\,\mathrm{diag}(b)$, the unmix linearized at the film base $b$ (rows sum to 1), to $\ln x - \ln b$, holding each channel at $10^{-3} b_c$ or above. * `two_scale` is the clipped linear unmix while the own-channel gain $M_{cc}\bar x_c / (M\bar x)_c$ stays below 1.15 $C_{cc}$, blending by a smoothstep (full at 1.5 $C_{cc}$) toward $\mathrm{SF}(M\bar x)\,\exp\!\big(C(\ln x - \ln \bar x)\big)$. $\bar x$ is the capture blurred by $\sigma = $ long edge / 1200, and $\mathrm{SF}$ the smooth maximum $\tfrac12\big(u + f + \sqrt{(u - f)^2 + f^2}\big)$ with $f = 0.15\,M_{cc}\bar x_c$. A channel whose detail $\ln(x_c/\bar x_c)$ sits $\ln 64$ below both others is a dead photosite and takes their mean. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 93c082ef0..0c5540ee7 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -162,7 +162,7 @@ Terms combine: `film:portra iso:>=400 -rejected:`. Metadata terms match only fra Right-click a frame, or use the shortcuts, to **Keep** it (a check) or **Reject** it (a cross; the frame dims and batch exports skip it). Your files never change. -A film mark in the top-right corner means the frame prints on the roll average; it is amber when the frame's color is far from that average. A mark in the bottom-left corner means the frame is built from several files (a stitch, HDR merge, Trichrome or half frame); hover it for the name. An exclamation mark means the file did not open (click to retry); a small amber dot means the thumbnail is out of date. +A film mark in the top-right corner means the frame prints on the roll average; it is amber when the frame's color is far from that average. A mark in the bottom-left corner means the frame is built from several files (a stitch, HDR merge, Trichrome or half frame), or a Blend or Single Exposure capture shot it; hover it for the name. An exclamation mark means the file did not open (click to retry); a small amber dot means the thumbnail is out of date. The right-click menu also copies, pastes and resets settings, applies them to other frames, reanalyzes frames ([§10.5](#105-metering-negative--positive)), puts the selection on the roll average or back on its own measurement, shows the file in its folder, and moves files to the Trash (the edit stays). @@ -789,8 +789,10 @@ Shown while a Scanlight is connected. A preset holds the light levels and the ca * **Calibrate on the Film Base…**: place the rectangle on clear film and run it. NegPy finds the light levels and the shutter. * **Set by Hand**: set the sliders yourself, then save them as a preset. With no camera connected, it saves no exposure, and a scan uses the camera's own. * **Red**, **Green**, **Blue**, **White**: the light levels (0 to 255). **Light On** turns the light on to frame the film, or off. -* **Capture mode**: **Trichrome** takes one exposure per color and joins them; **Single Exposure** takes one exposure with all three colors on. -* **Create Sensor Crosstalk Profile** (Single Exposure): also saves a sensor crosstalk profile for this preset (see Calibration, §10.4). +* **Capture mode**: **Trichrome** takes one exposure per color and joins them; **Single Exposure** takes one exposure with all three colors on. **Blend** holds the camera open in Bulb and turns on red, green and blue in turn, for one file. A Trichrome or Blend preset can switch between the two. +* **Shutter release** (Blend): what holds the shutter open. **USB** uses the camera's own bulb control. **Cable** uses a release cable in the Scanlight's 3.5mm jack, for exposures up to 2.55 s. +* **Sensor profile** (Blend preset with no profile of its own): the sensor crosstalk profile for its scans. Scan waits for one. The vials button makes one. +* **Create Sensor Crosstalk Profile** (Single Exposure or Blend): also saves a sensor crosstalk profile that this preset's scans use (see Calibration, §10.4). * **ISO**, **Shutter**, **Aperture**: the preset's exposure. A change holds until you pick the preset again. The gauge button meters. * **IR Shutter** and **Infrared** (lights with infrared LEDs): adds an infrared exposure to each scan for dust removal, saved as an `_IR` file. It needs a camera that sees infrared (full-spectrum or converted) and manual focus. Color film only. * **Channel Delay**: a pause between the red, green and blue exposures, for cameras that need it. diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index ba4fbb340..c95c45c81 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -110,6 +110,7 @@ preset_from_export_config, resolve_preset_export, ) +from negpy.services.assets.capture_modes import BLEND, SINGLE, record_capture_mode from negpy.services.assets.composites import forget_composite, frame_count, part_paths, restore_maps, saved_composites, source_files from negpy.services.assets.triplets import forget_triplets, saved_triplets from negpy.services.assets import rolls @@ -7652,7 +7653,7 @@ def _on_capture_flat(self, path: str) -> None: def _on_capture_finished(self, paths: list) -> None: """Feed the captured frame(s) into NegPy. An RGB triplet → RGB-Scan, C-41; a single-capture - RGB frame → a single RAW, C-41; a white-light slide → E-6/positive; a normal camera scan → + or Blend RGB frame → a single RAW, C-41; a white-light slide → E-6/positive; a normal camera scan → a single RAW, process left to NegPy.""" self.capture_finished.emit(paths) if not paths: @@ -7662,8 +7663,11 @@ def _on_capture_finished(self, paths: list) -> None: main = [p for p in paths if not os.path.splitext(os.path.basename(p))[0].lower().endswith("_ir")] or list(paths) white = bool(req is not None and req.white_mode) rgb = bool(req is not None and getattr(req, "rgb_mode", True)) - single = bool(req is not None and getattr(req, "single_capture", False)) + # A Blend frame is one RAW lit by all three LEDs, so it enters as a single capture does. + single = bool(req is not None and (getattr(req, "single_capture", False) or getattr(req, "blend_capture", False))) channels = tuple(getattr(req, "channels", ("R", "G", "B")) or ()) if req is not None else ("R", "G", "B") + if rgb and not white and single: + record_capture_mode(self.session.repo, main[0], BLEND if getattr(req, "blend_capture", False) else SINGLE) if rgb and not white and not single and channels != ("R", "G", "B"): # A subset of the frame's exposures. Once the frame's three visible files exist it # re-enters discovery as one triplet, with its saved record dropped first: a re-shot diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index 0e35e64f2..8d9e0f99d 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -42,6 +42,7 @@ from negpy.infrastructure.storage.repository import StorageRepository from negpy.kernel.system.config import APP_CONFIG, DEFAULT_WORKSPACE_CONFIG from negpy.kernel.system.text import count_of +from negpy.services.assets.capture_modes import BLEND, SINGLE, capture_modes from negpy.services.assets.composites import remember_composites from negpy.services.assets.triplets import remember_triplets from negpy.services.assets.flatfield import FlatFieldProfiles @@ -452,7 +453,7 @@ def composite_kind(asset: Dict[str, Any]) -> str: def composite_summary(asset: Dict[str, Any]) -> str: - """Tooltip text naming what a frame is built from. Empty for a plain frame.""" + """Tooltip text naming what a frame is built from, or how a capture shot it. Empty for a plain frame.""" kind = composite_kind(asset) if kind == "stitch": return _stitch_summary(asset) @@ -464,6 +465,10 @@ def composite_summary(asset: Dict[str, Any]) -> str: return tr("Half-frame split ({value} of 2)", value=int(asset["half"])) if kind == "diptych": return tr("Diptych: both halves, each with its own edit") + if asset.get("capture") == BLEND: + return tr("Blend capture") + if asset.get("capture") == SINGLE: + return tr("Single Exposure capture") return "" @@ -1612,6 +1617,21 @@ def _stamp_averages(self, hashes: Optional[Collection[str]] = None) -> bool: f.pop("average", None) return changed + def _stamp_capture_modes(self) -> None: + """Marks each loaded single-file frame that a Blend or Single Exposure capture shot, ``capture``.""" + modes = capture_modes(self.repo, [f["path"] for f in self.state.uploaded_files if f.get("path")]) + for f in self.state.uploaded_files: + mode = modes.get(f.get("path", "")) + if mode: + f["capture"] = mode + else: + f.pop("capture", None) + + def refresh_capture_marks(self) -> None: + self._stamp_capture_modes() + self.asset_model.refresh() + self.files_changed.emit() + def refresh_average_marks(self, hashes: Optional[Collection[str]] = None) -> None: if self._stamp_averages(hashes): self.asset_model.refresh() @@ -1621,6 +1641,7 @@ def refresh_scene_marks(self) -> None: """Re-reads the active roll's scenes onto the loaded frames after a scene edit.""" self._stamp_scenes() self._stamp_averages() + self._stamp_capture_modes() self.asset_model.refresh() self.files_changed.emit() @@ -2422,6 +2443,7 @@ def add_files(self, file_paths: List[str], validated_info: Optional[List[Dict]] f["excluded"] = m == "excluded" self._stamp_scenes() self._stamp_averages() + self._stamp_capture_modes() self.asset_model.refresh() self.files_changed.emit() @@ -2470,6 +2492,7 @@ def replace_assets(self, replacements: Dict[int, dict]) -> None: self.state.uploaded_files[i] = {**asset, "keeper": m == "keeper", "excluded": m == "excluded"} self._stamp_scenes() self._stamp_averages() + self._stamp_capture_modes() self.asset_model.refresh() self.files_changed.emit() self._persist_session() @@ -2497,6 +2520,7 @@ def shifted(idx: int) -> int: self.state.selected_indices = [shifted(i) for i in self.state.selected_indices] self._stamp_scenes() self._stamp_averages() + self._stamp_capture_modes() self.asset_model.refresh() self.files_changed.emit() self._persist_session() diff --git a/negpy/desktop/view/sidebar/calibration_window.py b/negpy/desktop/view/sidebar/calibration_window.py index 1142b7d21..31147f854 100644 --- a/negpy/desktop/view/sidebar/calibration_window.py +++ b/negpy/desktop/view/sidebar/calibration_window.py @@ -27,10 +27,11 @@ from negpy.desktop.view.widgets.dialog_geometry import remember_dialog_geometry from negpy.desktop.view.widgets.floating_panel import float_over_app -# The index is the preset's `single_capture` flag. -CAPTURE_MODES = (tr("Trichrome"), tr("Single Exposure")) +# The Capture mode index. Blend solves as Trichrome does, then shoots the three LEDs in one Bulb exposure. +SINGLE_MODE, BLEND_MODE = 1, 2 +CAPTURE_MODES = (tr("Trichrome"), tr("Single Exposure"), tr("Blend")) CAPTURE_MODE_TOOLTIP = tr( - "Trichrome merges one exposure per LED, and Single Exposure takes one exposure with red, green and blue lit together." + "Trichrome takes one exposure per LED, Single Exposure lights all three together, and Blend lights each LED in turn in one Bulb exposure." ) @@ -39,9 +40,13 @@ "Click a patch of clear film base: the gap between frames, the leader or a blank frame. Name the stock, then " "Calibrate & Save. The shutter is solved here, so set the ISO and aperture you scan with." ) -_SENSOR_PROFILE_OFF_TOOLTIP = tr("Single Exposure only: a Trichrome triplet reads each LED alone and has no sensor mixing to unmix.") +_SENSOR_PROFILE_OFF_TOOLTIP = tr( + "Single Exposure and Blend only: a Trichrome triplet reads each LED alone and has no sensor mixing to unmix." +) -SENSOR_PROFILE_TOOLTIP = tr("Single Exposure only: also save a sensor crosstalk profile for this preset, from the same exposures.") +SENSOR_PROFILE_TOOLTIP = tr( + "Single Exposure and Blend only: also save a sensor crosstalk profile for this preset, from the same exposures." +) def _reserve_checked_width(btn: QPushButton) -> None: @@ -138,14 +143,14 @@ def _on_sensor_profile_clicked(self, checked: bool) -> None: self._sensor_profile_wanted = checked def _sync_sensor_profile(self) -> None: - single = self.capture_btn.currentIndex() == 1 - self.sensor_profile_btn.setChecked(single and self._sensor_profile_wanted) - self.sensor_profile_btn.setEnabled(single and not self._running) - self.sensor_profile_btn.setToolTip(SENSOR_PROFILE_TOOLTIP if single else _SENSOR_PROFILE_OFF_TOOLTIP) + profiled = self.capture_btn.currentIndex() in (SINGLE_MODE, BLEND_MODE) + self.sensor_profile_btn.setChecked(profiled and self._sensor_profile_wanted) + self.sensor_profile_btn.setEnabled(profiled and not self._running) + self.sensor_profile_btn.setToolTip(SENSOR_PROFILE_TOOLTIP if profiled else _SENSOR_PROFILE_OFF_TOOLTIP) def wants_sensor_profile(self) -> bool: """Whether a finished run saves a sensor crosstalk profile with the preset.""" - return self.capture_btn.currentIndex() == 1 and self._sensor_profile_wanted + return self.capture_btn.currentIndex() in (SINGLE_MODE, BLEND_MODE) and self._sensor_profile_wanted def set_inputs_locked(self, locked: bool) -> None: """Freeze the calibration inputs while a run is in progress: film-stock name, capture mode, diff --git a/negpy/desktop/view/sidebar/files.py b/negpy/desktop/view/sidebar/files.py index 5c9e78d4e..8c8de2535 100644 --- a/negpy/desktop/view/sidebar/files.py +++ b/negpy/desktop/view/sidebar/files.py @@ -119,7 +119,7 @@ class _ThumbnailDelegate(QStyledItemDelegate): """Scales each cached thumbnail into its cell; a frame not selected or under the pointer is dimmed. Triage marks are small bottom-right badges: check = keeper, cross + heavy dim = rejected; the bottom-left badge says the frame was built from several files - (stitch, HDR merge, RGB triplet, half-frame split). Top-right says the frame prints on the + (stitch, HDR merge, RGB triplet, half-frame split) or shot by a Blend or Single Exposure capture. Top-right says the frame prints on the roll average, amber when it is far from the roll color. Top-left holds the decode-failure badge, else a small dot saying the bitmap shown predates a settings change (a bulk apply reaches the file before a render reaches its thumbnail).""" @@ -257,7 +257,8 @@ def _draw_average_badge(self, painter: QPainter, img_rect: QRect, outlier: bool) icon.paint(painter, QRect(cx - 6, cy - 6, 12, 12)) def _draw_composite_badge(self, painter: QPainter, img_rect: QRect, kind: str, half: int, split_axis: str = "x") -> None: - """Bottom-left mark: this frame was assembled from more than one file. + """Bottom-left mark: this frame was assembled from more than one file, or a Blend or + Single Exposure capture shot it. One glyph per kind, so a merge is told from a stitch without opening the menu. The chip carries a faint ring because a flat dark disc vanishes on a dense frame.""" @@ -282,6 +283,21 @@ def _draw_composite_badge(self, painter: QPainter, img_rect: QRect, kind: str, h painter.setBrush(QColor(color)) painter.drawEllipse(QRect(cx + dx - 2, cy - 2, 4, 4)) painter.setBrush(Qt.BrushStyle.NoBrush) + elif kind == "blend": # the three LEDs in turn in one exposure: overlapping, mixed as light + painter.setPen(Qt.PenStyle.NoPen) + painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Plus) + for dx, color in ((-3, THEME.channel_red), (0, THEME.channel_green), (3, THEME.channel_blue)): + painter.setBrush(QColor(color)) + painter.drawEllipse(QRect(cx + dx - 3, cy - 3, 6, 6)) + painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver) + painter.setBrush(Qt.BrushStyle.NoBrush) + elif kind == "single": # the three LEDs lit together: one disc of three sectors + painter.setPen(Qt.PenStyle.NoPen) + disc = QRect(cx - 5, cy - 5, 10, 10) + for start, color in ((90, THEME.channel_red), (210, THEME.channel_green), (330, THEME.channel_blue)): + painter.setBrush(QColor(color)) + painter.drawPie(disc, start * 16, 120 * 16) + painter.setBrush(Qt.BrushStyle.NoBrush) elif kind == "half": # a split frame, this asset's own half filled painter.drawRect(QRect(cx - 6, cy - 4, 12, 8)) panes = self._half_badge_panes(cx, cy, split_axis) @@ -323,7 +339,7 @@ def _paint_placeholder(self, painter: QPainter, option: QStyleOptionViewItem, fi def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None: file_info = index.data(Qt.ItemDataRole.UserRole) or {} failed = bool(file_info.get("decode_failed")) - kind = composite_kind(file_info) + kind = composite_kind(file_info) or file_info.get("capture", "") icon = index.data(Qt.ItemDataRole.DecorationRole) if icon is None or icon.isNull(): diff --git a/negpy/desktop/view/sidebar/scanlight.py b/negpy/desktop/view/sidebar/scanlight.py index 0e9b7fe29..87686fd23 100644 --- a/negpy/desktop/view/sidebar/scanlight.py +++ b/negpy/desktop/view/sidebar/scanlight.py @@ -32,7 +32,13 @@ ) from negpy.desktop.view.confirm import confirm_unmetered_scan -from negpy.desktop.view.sidebar.calibration_window import CAPTURE_MODE_TOOLTIP, CAPTURE_MODES, CalibrationWindow +from negpy.desktop.view.sidebar.calibration_window import ( + BLEND_MODE, + CAPTURE_MODE_TOOLTIP, + CAPTURE_MODES, + SINGLE_MODE, + CalibrationWindow, +) from negpy.desktop.view.sidebar.live_view_window import LiveViewWindow, SettingStepper from negpy.desktop.view.sidebar.roi_image import zebra_image from negpy.desktop.view.sidebar.scan_output import ScanOutputPanel @@ -53,10 +59,11 @@ wrap_tooltip, ) from negpy.desktop.view.styles.theme import THEME -from negpy.desktop.view.widgets.sliders import CompactSlider, LogSlider, SliderGroup from negpy.desktop.view.widgets.segmented_choice import SegmentedChoice +from negpy.desktop.view.widgets.sliders import CompactSlider, LogSlider, SliderGroup from negpy.desktop.view.widgets.step_combo import StepCombo from negpy.infrastructure import simulated +from negpy.infrastructure.capture.base import BulbRelease from negpy.infrastructure.capture.gphoto import default_settings_path from negpy.infrastructure.capture.settings import ScanlightSettings, WhiteCaptureMode from negpy.features.exposure.analysis import output_histogram @@ -75,6 +82,8 @@ from negpy.services.capture.presets import PresetStore, ScanlightPreset, framing_levels _CAPTURE_BAR = tr("Capturing… %p%") # the status strip's bar while a scan runs +_BULB_RELEASE_TOOLTIP = tr("What holds the shutter open in Bulb: the camera's USB cable, or a release cable on the Scanlight.") +_BLEND_SENSOR_TOOLTIP = tr("Sensor crosstalk profile for Blend scans: removes the color leak between the camera's channels.") _CHANNEL_COLORS = {"R": THEME.channel_red_text, "G": THEME.channel_green_text, "B": THEME.channel_blue_text, "W": THEME.text_secondary} # One source for the over/under advice, shown on both surfaces of an aborted calibration: @@ -181,6 +190,19 @@ def _rgb_array(pixmap: QPixmap) -> np.ndarray: return rows[:, : 3 * image.width()].reshape(image.height(), image.width(), 3).copy() +def _preset_label(name: str, preset: Optional[ScanlightPreset]) -> str: + """A user preset's row: its name and the capture mode it was calibrated for.""" + if preset is None: + return name + if preset.blend_capture: + mode = CAPTURE_MODES[BLEND_MODE] + elif preset.single_capture or preset.w_level: + mode = CAPTURE_MODES[SINGLE_MODE] + else: + mode = CAPTURE_MODES[0] + return f"{name} · {mode}" + + class ScanlightSidebar(QWidget): """Trichromatic RGB-scan capture panel.""" @@ -471,10 +493,31 @@ def _init_ui(self) -> None: ) _exp.addLayout(header_row(section_subheader(tr("CAMERA EXPOSURE")), self.meter_btn)) self.capture_btn = StepCombo.of(CAPTURE_MODES, CAPTURE_MODE_TOOLTIP) - self.capture_btn.setEnabled(False) # the preset's mode, editable only while building a manual preset + # The preset's mode. A hand-set recipe picks any; a Trichrome or Blend preset switches between the two. + self.capture_btn.setEnabled(False) self.capture_mode_row = QWidget() # a white-light preset has one exposure, so the row hides there self.capture_mode_row.setObjectName("collapsible_content_body") - self.capture_mode_row.setLayout(field_row(tr("Capture mode"), self.capture_btn)) + _mode = QVBoxLayout(self.capture_mode_row) + _mode.setContentsMargins(0, 0, 0, 0) + _mode.setSpacing(THEME.space_xs) + _mode.addLayout(field_row(tr("Capture mode"), self.capture_btn)) + self.bulb_release_btn = SegmentedChoice( + (("fa5b.usb", tr("USB")), ("fa5s.plug", tr("Cable"))), + _BULB_RELEASE_TOOLTIP, + data=(BulbRelease.USB.value, BulbRelease.CABLE.value), + ) + self.bulb_release_btn.setCurrentIndex(self.bulb_release_btn.findData(self._settings.bulb_release.value)) + # A Blend frame is one exposure under all three LEDs, so the sensor leak needs a profile. A + # calibrated Blend preset brings its own; this row asks for one when the preset has none. + self.blend_sensor_combo = StepCombo.of((), _BLEND_SENSOR_TOOLTIP) + self.blend_sensor_btn = icon_button("fa5s.vials", tr("Make a profile from three shots of the bare light: red, green and blue.")) + self._reload_blend_sensor_profiles() + self.blend_sensor_row = QWidget() + self.blend_sensor_row.setObjectName("collapsible_content_body") + self.blend_sensor_row.setLayout(field_row(tr("Sensor profile"), self.blend_sensor_combo, self.blend_sensor_btn)) + self.bulb_release_rail = SliderGroup(field_row(tr("Shutter release"), self.bulb_release_btn), self.blend_sensor_row) + self.bulb_release_rail.setVisible(False) # shown while Blend is the capture mode + _mode.addWidget(self.bulb_release_rail) _exp.addWidget(self.capture_mode_row) self.iso_stepper = SettingStepper() self.shutter_stepper = SettingStepper(shutter=True) @@ -569,7 +612,10 @@ def _connect_signals(self) -> None: self.scan_btn.clicked.connect(self._on_scan) self.retake_btn.clicked.connect(self._on_retake) self.preset_combo.activated.connect(self._on_preset_selected) - self.capture_btn.currentIndexChanged.connect(lambda _i: self._push_light()) + self.capture_btn.currentIndexChanged.connect(lambda _i: self._on_capture_mode_picked()) + self.bulb_release_btn.currentChanged.connect(lambda _i: self._on_bulb_release_picked()) + self.blend_sensor_combo.activated.connect(lambda _i: self._on_blend_sensor_picked()) + self.blend_sensor_btn.clicked.connect(self._open_sensor_calibration) self.preset_save_btn.clicked.connect(self._on_preset_save) self.preset_del_btn.clicked.connect(self._on_preset_delete) # Typing an invalid path greys Scan immediately (with the reason), not on the next click. @@ -692,7 +738,7 @@ def _reload_presets(self, select: str = "") -> None: if adopted and names: self.preset_combo.add_header(tr("Your presets")) for name in names: - self.preset_combo.addItem(name, name) + self.preset_combo.addItem(_preset_label(name, self._presets.get(name)), name) idx = self.preset_combo.findData(select) if select else -1 # A placeholder keeps Qt from selecting the first item by itself: -1 shows it, for a # hand-set recipe; otherwise the first item, “Select preset”. @@ -711,13 +757,80 @@ def _set_slider(self, slider, value: int) -> None: sliders itself, and the sliders reflect the *preset*, not the live LED level.""" slider.setValue(value) - def _set_capture_mode(self, single: bool) -> None: + def _set_capture_mode(self, single: bool, blend: bool = False) -> None: """Point the Capture mode button and the settings at a preset's mode, without the push a user's own pick triggers.""" + blend = blend and not single self.capture_btn.blockSignals(True) - self.capture_btn.setCurrentIndex(int(single)) + self.capture_btn.setCurrentIndex(SINGLE_MODE if single else (BLEND_MODE if blend else 0)) self.capture_btn.blockSignals(False) - self._settings = replace(self._settings, single_capture=single) + self._settings = replace(self._settings, single_capture=single, blend_capture=blend) + + def _blend_active(self) -> bool: + """The scan shoots one Bulb exposure with the LEDs lit in turn.""" + s = self._settings + return self._rgb_mode and not s.white_mode and not s.single_capture and s.blend_capture + + def _triplet_active(self) -> bool: + """The scan shoots one exposure per LED.""" + s = self._settings + return self._rgb_mode and not s.white_mode and not s.single_capture and not s.blend_capture + + def _on_capture_mode_picked(self) -> None: + self._reload_blend_sensor_profiles() # the Sensor panel saves profiles too + self._update_settings_from_ui() + self._push_light() + self._refresh_preset_hint() + self._apply_gating() + + def _on_bulb_release_picked(self) -> None: + self._settings = replace(self._settings, bulb_release=BulbRelease(self.bulb_release_btn.currentData())) + self._save_settings() + + def _reload_blend_sensor_profiles(self) -> None: + """List the saved profiles under a placeholder, and select the Blend one if it still exists.""" + names = SensorProfiles.list_profiles()[1:] # "None" leaves the leak in, which Blend refuses + self.blend_sensor_combo.blockSignals(True) + self.blend_sensor_combo.set_choices([tr("Select a profile"), *names], ["", *names]) + self.blend_sensor_combo.setCurrentIndex(max(0, self.blend_sensor_combo.findData(self._settings.blend_sensor_profile))) + self.blend_sensor_combo.blockSignals(False) + + def _on_blend_sensor_picked(self) -> None: + self._settings = replace(self._settings, blend_sensor_profile=self.blend_sensor_combo.currentData() or "") + self._save_settings() + self._apply_gating() + + def _open_sensor_calibration(self) -> None: + from negpy.desktop.view.widgets.file_dialogs import last_open_folder + from negpy.desktop.view.widgets.sensor_calibration_dialog import SensorCalibrationDialog + + repo = self.controller.session.repo + dlg = SensorCalibrationDialog(parent=self, start_dir=last_open_folder(repo), repo=repo, controller=self.controller) + dlg.profile_saved.connect(self._on_blend_sensor_saved) + dlg.exec() + + def _on_blend_sensor_saved(self, name: str) -> None: + self._settings = replace(self._settings, blend_sensor_profile=name) + self._save_settings() + self._reload_blend_sensor_profiles() + self._apply_gating() + + def _profile_on_disk(self, name: str) -> bool: + """Whether `name` is a saved sensor profile, as the Blend list last read the folder.""" + combo = getattr(self, "blend_sensor_combo", None) # gating first runs before the RGB section exists + return bool(name) and combo is not None and combo.findData(name) > 0 + + def _preset_sensor_profile(self) -> str: + """The selected preset's own sensor profile, "" when it has none or the file is gone.""" + preset = self._presets.get(self.preset_combo.currentData()) if self._preset_selected() and not self._manual_mode else None + name = getattr(preset, "sensor_profile", "") or "" + return name if self._profile_on_disk(name) else "" + + def _blend_profile(self) -> str: + """The profile a Blend frame's roll takes: the preset's own, else the one picked in the Blend row.""" + return self._preset_sensor_profile() or ( + self._settings.blend_sensor_profile if self._profile_on_disk(self._settings.blend_sensor_profile) else "" + ) def _show_lone(self, stepper, label: str) -> None: """Make a stepper display one fixed value (a preset's baked setting): no options to step @@ -752,7 +865,8 @@ def _apply_preset(self, preset) -> None: ): self._set_slider(slider, value) self._show_lone(self.shutter_stepper, preset.shutter_r) # one shared shutter (r/g/b are equal) - self._set_capture_mode(preset.single_capture) + self._set_capture_mode(preset.single_capture, preset.blend_capture) + self._reload_blend_sensor_profiles() # the preset's profile may be newer than the list self._settings = replace( self._settings, white_mode=False, @@ -902,6 +1016,8 @@ def _refresh_preset_hint(self) -> None: ) elif self._preset_selected() and getattr(self._presets.get(name), "single_capture", False): text = tr("Single exposure with red, green and blue lit together.") + elif self._blend_active(): + text = tr("Blend lights red, green and blue in turn inside one Bulb exposure.") else: text = "" if self._settings.infrared and (text or self._preset_selected()): @@ -944,6 +1060,7 @@ def _save_current_as_preset(self, name: str, sensor_profile: str = "") -> None: iso=s.iso, aperture=s.aperture, single_capture=s.single_capture, + blend_capture=s.blend_capture, sensor_profile=sensor_profile, ), ) @@ -1135,7 +1252,8 @@ def _on_calibrate_new_preset(self, name: str) -> None: shutter_candidates=candidates, start_levels=start_levels, start_shutter=start_shutter, - single_capture=self.calib_window.capture_btn.currentIndex() == 1, + single_capture=self.calib_window.capture_btn.currentIndex() == SINGLE_MODE, + measure_sensor=self.calib_window.capture_btn.currentIndex() == BLEND_MODE and self.calib_window.wants_sensor_profile(), ) ) @@ -1816,7 +1934,7 @@ def _apply_builtin_trichrome(self) -> None: for slider, value in ((self.r_slider, 255), (self.g_slider, 255), (self.b_slider, 255), (self.w_slider, 0)): self._set_slider(slider, value) self._show_lone(self.shutter_stepper, "") - self._set_capture_mode(False) + self._set_capture_mode(False, self._settings.blend_capture) # Blend or not stays the operator's pick self._settings = replace(self._settings, white_mode=False, shutter_r="", shutter_g="", shutter_b="", shutter_w="") self._apply_preset_exposure("", "") # The exposure is the operator's: the steppers fill from the body once, then drive it. @@ -1852,7 +1970,7 @@ def _on_calibration_finished(self, result) -> None: self._set_slider(self.w_slider, 0) shutter = shutters[0] # one shared shutter (all three are equal) self._show_lone(self.shutter_stepper, shutter) - self._set_capture_mode(result.single_capture) + self._set_capture_mode(result.single_capture, self.calib_window.capture_btn.currentIndex() == BLEND_MODE) self._settings = replace( self._settings, white_mode=False, shutter_r=shutter, shutter_g=shutter, shutter_b=shutter, shutter_w=shutter ) @@ -1874,6 +1992,7 @@ def _on_calibration_finished(self, result) -> None: if result.sensor_matrix is not None: SensorProfiles.save(name, list(result.sensor_matrix)) sensor_profile = name + self._reload_blend_sensor_profiles() # the gate reads the new profile from this list status = tr("Saved preset “{name}” and its sensor crosstalk profile", name=name) else: status = tr( @@ -2023,7 +2142,8 @@ def _start_capture(self, retake: bool) -> None: preset = ( self._presets.get(self.preset_combo.currentData()) if single and self._preset_selected() and not self._manual_mode else None ) - triplet = rgb and not s.white_mode and not single + blend = self._blend_active() + triplet = self._triplet_active() # Scan shoots the whole triplet; Retake shoots the exposures checked in Live View. channels = self.lv_window.selected_rgb() if triplet and retake else ("R", "G", "B") if triplet and retake and not channels and not (s.infrared and self._light_has_ir): @@ -2063,7 +2183,9 @@ def _start_capture(self, retake: bool) -> None: aperture=s.aperture if rgb and not s.white_mode else "", as_roll=self.output.as_roll(), single_capture=single, - sensor_profile=preset.sensor_profile if preset is not None else "", + blend_capture=blend, + bulb_release=s.bulb_release, + sensor_profile=self._blend_profile() if blend else (preset.sensor_profile if preset is not None else ""), channels=channels, shutter_ir=shutter_ir, infrared=s.infrared and rgb and self._light_has_ir, @@ -2328,6 +2450,8 @@ def _missing_requirements(self) -> list[str]: m.append(tr("connect the Scanlight")) if not (self._preset_selected() or self._manual_mode): m.append(tr("select or create a preset")) + if self._blend_active() and not self._blend_profile(): + m.append(tr("pick or make a sensor profile for Blend")) folder = self.output.folder() if not folder: m.append(tr("choose an output folder")) @@ -2347,7 +2471,7 @@ def _apply_gating(self) -> None: self.lv_btn.setEnabled((self._camera_verified and not self._calibrating_preset) or self.lv_btn.isChecked()) self.scan_btn.setEnabled(can_scan or self._scanning) # Which exposures Retake shoots: R, G and B for a triplet preset, IR with infrared LEDs. - triplet = self._rgb_mode and not self._settings.white_mode and not self._settings.single_capture + triplet = self._triplet_active() can_retake = can_scan and (not triplet or bool(self.lv_window.selected_rgb()) or (self._settings.infrared and self._light_has_ir)) self.retake_btn.setEnabled(can_retake and not self._scanning) self.eject_btn.setEnabled(not self._scanning) @@ -2415,9 +2539,16 @@ def _refresh_preset_ui(self) -> None: return # first call lands during __init__, before the RGB section is built editable = self._manual_mode exposure_editable = (editable or self._free_exposure) and self._camera_verified - self.capture_btn.setEnabled(editable) + # Trichrome and Blend shoot one calibration, so either preset switches between them. + trichrome_preset = ( + self._preset_selected() and self._rgb_mode and not self._settings.white_mode and not self._settings.single_capture + ) + self.capture_btn.setEnabled(editable or trichrome_preset) + self.capture_btn.set_item_enabled(SINGLE_MODE, editable) self.capture_mode_row.setVisible(not self._settings.white_mode) - self.inter_exposure_delay_slider.setEnabled(not self._settings.single_capture) + self.bulb_release_rail.setVisible(self._blend_active()) + self.blend_sensor_row.setVisible(self._blend_active() and not self._preset_sensor_profile()) + self.inter_exposure_delay_slider.setEnabled(not self._settings.single_capture and not self._blend_active()) for slider in (self.r_slider, self.g_slider, self.b_slider): slider.setEnabled(editable) self.w_slider.setEnabled(False) # the Scanlight can't light white with RGB → a manual RGB preset keeps W off @@ -2537,7 +2668,8 @@ def _update_settings_from_ui(self) -> None: w_level=int(self.w_slider.value()), inter_exposure_delay_ms=int(self.inter_exposure_delay_slider.value()), capture_delay_s=float(self.capture_delay_slider.value()), - single_capture=self.capture_btn.currentIndex() == 1, + single_capture=self.capture_btn.currentIndex() == SINGLE_MODE, + blend_capture=self.capture_btn.currentIndex() == BLEND_MODE, shutter_r=shutter, shutter_g=shutter, shutter_b=shutter, diff --git a/negpy/desktop/workers/capture_worker.py b/negpy/desktop/workers/capture_worker.py index 2efa95146..e5925b463 100644 --- a/negpy/desktop/workers/capture_worker.py +++ b/negpy/desktop/workers/capture_worker.py @@ -11,11 +11,11 @@ import threading import time from dataclasses import dataclass, field, replace -from typing import Optional +from typing import Callable, Optional from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot -from negpy.infrastructure.capture.base import CAPTURE_ORDER, CaptureSettings, Channel +from negpy.infrastructure.capture.base import CAPTURE_ORDER, BulbRelease, CaptureSettings, Channel from negpy.infrastructure.capture.gphoto import ( CameraCapabilities, CameraClaimedError, @@ -93,6 +93,9 @@ class CaptureRequest: aperture: str = "" as_roll: bool = False single_capture: bool = False # RGB preset shot as one exposure with all three LEDs lit + blend_capture: bool = False # triplet preset shot as one Bulb exposure, the LEDs lit in turn + bulb_release: BulbRelease = BulbRelease.USB # what holds the Blend exposure's shutter open + keep_bulb: bool = False # a strip frame: leave the camera in Bulb for the next Blend frame sensor_profile: str = "" # the single-capture preset's sensor profile, for the frame's roll channels: tuple[str, ...] = ("R", "G", "B") # the visible exposures to shoot; a subset shoots alone, into its own files shutter_ir: str = "" # the infrared exposure's own shutter; "" = the body's current @@ -134,6 +137,7 @@ class CalibrationRequest: start_levels: tuple[int, int, int] = REFERENCE_LEVELS start_shutter: str = REFERENCE_SHUTTER single_capture: bool = False # solve for R, G and B lit together in one exposure + measure_sensor: bool = False # a triplet run also measures a sensor unmix, for a Blend preset # The central half of the frame, the region a sensor profile built from files measures. @@ -167,6 +171,21 @@ def _setting_label(info) -> str: return next((str(o.get("label", "")) for o in info.get("options", []) if o.get("raw") == info.get("cur")), "") +def _blend_seconds(camera, shutter: str) -> float: + """The true time of the Trichrome shutter, which each Blend channel gets as its light. A built-in + Trichrome preset names none, so the body's current step stands in.""" + info = camera.read_settings().get("shutter", {}) if hasattr(camera, "read_settings") else {} + ladder = scan_ladder(str(o.get("label", "")) for o in info.get("options", [])) if isinstance(info, dict) else () + label = shutter.strip() or _setting_label(info) + try: + seconds = true_seconds(label, ladder) + except (TypeError, ValueError): + seconds = 0.0 + if seconds <= 0: + raise CaptureError(tr("Blend needs a shutter time. Set the camera's shutter to a time, not Bulb, then scan again.")) + return seconds + + def _shutters_or_none(shutters: tuple[str, str, str]): """UI shutter strings → capture-settings form (None = leave the camera as-is).""" if not any(s.strip() for s in shutters): @@ -223,6 +242,8 @@ def __init__(self) -> None: # single PTP claim, and GphotoCamera serialises the three internally. Held open across # frames, closed on error and at shutdown. self._camera: Optional[GphotoCamera] = None + # Set by a Blend strip: runs once a frame's shutter closes, to move the film while the camera finishes. + self._after_shutter: Optional[Callable[[], None]] = None self._model = "" # True after an open failed because another program holds the USB claim; drives the # "in use by another app" camera status. Cleared by the next open attempt that does @@ -307,6 +328,12 @@ def _ensure_light(self, port: str) -> Light: self._light = None self._light = open_light(port or None) self._light_port = port + take_control = getattr(self._light, "take_control", None) + if take_control is not None: + try: + take_control() + except Exception: + logger.exception("could not put the light under host control") if hasattr(self._light, "on_scan_button"): self._light.on_scan_button = self.scan_button_pressed.emit return self._light @@ -443,6 +470,22 @@ def _with_ir(paths: list) -> list: self._report_clipping(path) return True + if req.blend_capture: # R, G and B in turn inside one Bulb exposure, imported as a single RAW + self.status.emit(tr("Capturing Blend exposure…")) + path = service.capture_blend( + settings, + seconds=_blend_seconds(camera, req.shutters[0]), + release=BulbRelease(req.bulb_release), + cancel=self._cancel, + on_channel=self.channel.emit, + end_light=visible_end, + keep_bulb=req.keep_bulb, + on_closed=self._after_shutter, + ) + self._finish_frame(req, _with_ir([path])) + self._report_clipping(path) + return True + if tuple(req.channels) != ("R", "G", "B"): # a subset of the visible exposures, each shot alone paths = [] last = [ch for ch in CAPTURE_ORDER if ch.letter in req.channels][-1:] @@ -561,20 +604,39 @@ def _run_strip(self, req: CaptureRequest) -> None: The dock cannot advance after it moves the film out of the window and back, so the flat never comes first.""" scanned, at_end = 0, False - for frame_number in range(req.frame_number, req.frame_number + _STRIP_FRAME_LIMIT): - if self._cancel.is_set(): - self.cancelled.emit() - return - one = replace(req, frame_number=frame_number, whole_strip=False, strip_flat=False, advance_film=False) - if not self.run_capture(one): - return - scanned += 1 - moved = self._advance_film(req.port, eject=False) - if moved is None: - break - if moved: - at_end = True - break + try: + for frame_number in range(req.frame_number, req.frame_number + _STRIP_FRAME_LIMIT): + if self._cancel.is_set(): + self.cancelled.emit() + return + one = replace(req, frame_number=frame_number, whole_strip=False, strip_flat=False, advance_film=False, keep_bulb=True) + early: dict = {} + if req.blend_capture: + self._after_shutter = lambda: self._start_advance(req.port, early) + try: + captured = self.run_capture(one) + finally: + self._after_shutter = None + if "thread" in early: + early["thread"].join() + if not captured: + if "thread" in early: + self.status.emit( + tr( + "Frame {frame} failed after the film moved on. Move the film back one frame, then click Retake.", + frame=frame_number, + ) + ) + return + scanned += 1 + moved = early["moved"] if "thread" in early else self._advance_film(req.port, eject=False) + if moved is None: + break + if moved: + at_end = True + break + finally: + self._release_bulb() # before the flat, which meters from the camera's own shutter step flat_error = "" if at_end and self._eject(self._ensure_light(req.port)) and req.strip_flat and not self._cancel.is_set(): try: @@ -587,6 +649,28 @@ def _run_strip(self, req: CaptureRequest) -> None: if flat_error: self.status.emit(flat_error) + def _start_advance(self, port: str, result: dict) -> None: + """Move the film on a thread of its own once a Blend frame's shutter has closed, so the move + overlaps the camera's processing of the shot. `result` gets the thread and the move's outcome.""" + + def _run() -> None: + result["moved"] = self._advance_film(port, eject=False) + + thread = threading.Thread(target=_run, name="film-advance", daemon=True) + result["thread"] = thread + thread.start() + + def _release_bulb(self) -> None: + """Put back the shutter step a Blend strip held at Bulb. A failure is logged, never raised: + the next plain capture and the session's close put it back as well.""" + release = getattr(self._camera, "release_bulb", None) + if release is None: + return + try: + release() + except Exception: # noqa: BLE001 + logger.exception("could not put the camera's shutter back after the strip") + def _capture_flat(self, req: CaptureRequest) -> str: """Shoot the empty dock at the frame's LED levels and a shutter that puts the brightest channel in range. The file goes beside the Flat Field profiles, never into the roll.""" @@ -659,7 +743,7 @@ def _cleanup_partial_frame(self, req: CaptureRequest) -> None: Skipped for white-light captures (a single frame is already complete, not a triplet) and for retakes (the frame existed complete; overwriting leaves three files in place). """ - if req.white_mode or req.single_capture or req.is_retake or not req.rgb_mode: + if req.white_mode or req.single_capture or req.blend_capture or req.is_retake or not req.rgb_mode: return # single-file captures (white / single RGB / normal) aren't triplets → nothing to keep aligned prefix = f"{req.roll_name}_Frame{req.frame_number:03d}_" # trailing _ → only channel files try: @@ -910,6 +994,7 @@ def run_calibration(self, req: CalibrationRequest) -> None: progress=self.calibration_progress.emit, cancel=self._cancel, single_capture=req.single_capture, + measure_sensor=req.measure_sensor, ) self.calibration_finished.emit(result) except CalibrationExposureError as e: diff --git a/negpy/infrastructure/capture/base.py b/negpy/infrastructure/capture/base.py index fba718685..99bb3b2d9 100644 --- a/negpy/infrastructure/capture/base.py +++ b/negpy/infrastructure/capture/base.py @@ -3,8 +3,8 @@ from __future__ import annotations from dataclasses import dataclass -from enum import Enum -from typing import Optional, Protocol, runtime_checkable +from enum import Enum, StrEnum +from typing import Callable, Optional, Protocol, runtime_checkable class Channel(Enum): @@ -58,6 +58,44 @@ def capture(self, out_path: str, shutter: Optional[str] = None, iso: Optional[st def close(self) -> None: ... +class BulbRelease(StrEnum): + """What holds a Blend exposure's shutter open: the camera's own bulb control over USB, or a + release cable on the Scanlight's 3.5mm jack.""" + + USB = "usb" + CABLE = "cable" + + @classmethod + def _missing_(cls, value: object) -> "BulbRelease": + return cls.USB + + +@runtime_checkable +class ShutterRelease(Protocol): + """A light with a shutter-release output: holds the camera's shutter line for `seconds`.""" + + def shutter_pulse(self, seconds: float) -> None: ... + + +@runtime_checkable +class BulbCamera(Protocol): + """A camera that takes one Bulb exposure: the shutter opens, `expose` runs, the shutter closes, + and the RAW is downloaded to beside `out_path`. With `usb_release` False the camera only sets + Bulb and waits for the file, because `expose` triggers the shutter by cable.""" + + def capture_bulb( + self, + out_path: str, + expose: Callable[[], None], + *, + usb_release: bool = True, + iso: Optional[str] = None, + aperture: Optional[str] = None, + keep_bulb: bool = False, + on_closed: Optional[Callable[[], None]] = None, + ) -> str: ... + + @dataclass(frozen=True) class CaptureSettings: """Everything needed to capture one R/G/B triplet. diff --git a/negpy/infrastructure/capture/gphoto.py b/negpy/infrastructure/capture/gphoto.py index 2989b8179..748185f99 100644 --- a/negpy/infrastructure/capture/gphoto.py +++ b/negpy/infrastructure/capture/gphoto.py @@ -132,6 +132,37 @@ class _Magnifier: # permanently discarding highlight and color information. _CAMERA_RAW_EXTENSIONS = frozenset(SUPPORTED_RAW_EXTENSIONS - SUPPORTED_JPEG_EXTENSIONS - SUPPORTED_TIFF_EXTENSIONS) +#: The bulb button over USB as (property, press, release), tried in order. Canon names it a +#: remote release; Nikon and Sony publish a `bulb` toggle. +_BULB_RELEASES: tuple[tuple[str, Any, Any], ...] = ( + ("eosremoterelease", "Press Full", "Release Full"), + ("bulb", 1, 0), +) +#: A Bulb exposure's RAW arrives as a file-added event once the shutter closes. Long-exposure noise +#: reduction can hold it for as long again as the exposure. +_BULB_FILE_TIMEOUT_S = 30.0 +_BULB_EVENT_WAIT_MS = 500 + + +def _require_raw(name: str) -> None: + suffix = os.path.splitext(name)[1] + if suffix.lower() not in _CAMERA_RAW_EXTENSIONS: + shown = suffix or "(no extension)" + raise GphotoError(f"camera returned {shown}, not a RAW file; set the camera to RAW-only image quality and retry") + + +def _write_raw(out_path: str, camera_name: str, data: bytes) -> str: + """Write `data` beside `out_path` under the camera's own suffix, and return the path.""" + suffix = os.path.splitext(camera_name)[1] + if suffix: + out_path = os.path.splitext(out_path)[0] + suffix + os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) + tmp = f"{out_path}.part" + with open(tmp, "wb") as handle: + handle.write(data) + os.replace(tmp, out_path) + return out_path + #: The catch-all entry unlisted PTP bodies fall back to. It claims every operation, so "not #: in the database" must never be reported as unsupported on its own. @@ -349,6 +380,7 @@ def _reset_body_state(self) -> None: self._magnifier_stalls = False self._aim_warned = False self._names: dict[str, Optional[str]] = {} # settings key → this body's property name + self._bulb_held: Optional[tuple[str, str, Optional[str]]] = None # (shutter property, Bulb label, step to restore) self._position = (_GRID_W // 2, _GRID_H // 2) # ----- session --------------------------------------------------------------- @@ -432,6 +464,10 @@ def close(self) -> None: if prev is not None and prev.is_alive(): prev.join(timeout=3.0) # let a post-shot drain finish before the handle goes away with self._lock: + try: + self.release_bulb() # the body must not stay in Bulb after NegPy lets go of it + except Exception as exc: # noqa: BLE001 — teardown must not raise + logger.warning("gphoto2: could not restore the shutter: %s", exc) self._alive = False if self._camera is not None: try: @@ -771,6 +807,7 @@ def capture(self, out_path: str, shutter: Optional[str] = None, iso: Optional[st with self._lock: camera = self._require() t0 = time.perf_counter() + self.release_bulb() # a plain shot at a held Bulb would expose for as long as the shutter stays open if shutter: name = self._property("shutter") if name is None or not self._set_verified(name, shutter): @@ -786,21 +823,9 @@ def capture(self, out_path: str, shutter: Optional[str] = None, iso: Optional[st try: path = camera.capture(self._gp.GP_CAPTURE_IMAGE) t_shot = time.perf_counter() - suffix = os.path.splitext(path.name)[1] - if suffix.lower() not in _CAMERA_RAW_EXTENSIONS: - shown = suffix or "(no extension)" - raise GphotoError(f"camera returned {shown}, not a RAW file; set the camera to RAW-only image quality and retry") - camera_file = camera.file_get(path.folder, path.name, self._gp.GP_FILE_TYPE_NORMAL) - data = bytes(memoryview(camera_file.get_data_and_size())) + _require_raw(path.name) + data = self._download(camera, path) t_download = time.perf_counter() - try: - # Delete the downloaded shot from the body. On a self-recycling RAM - # target this is redundant at worst, but a body without a capture target - # keeps every tethered object until it refuses to capture at all - # (issue #658). - camera.file_delete(path.folder, path.name) - except self._gp.GPhoto2Error as exc: - logger.debug("gphoto2: could not delete %s off the camera: %s", path.name, exc) except BaseException: # A failed shot drains inline, still under the lock: the error must leave a # clean queue behind before the preview or a retry touches the session. @@ -816,14 +841,7 @@ def capture(self, out_path: str, shutter: Optional[str] = None, iso: Optional[st if not drained_async: self._busy.clear() - suffix = os.path.splitext(path.name)[1] - if suffix: - out_path = os.path.splitext(out_path)[0] + suffix - os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) - tmp = f"{out_path}.part" - with open(tmp, "wb") as handle: - handle.write(data) - os.replace(tmp, out_path) + out_path = _write_raw(out_path, path.name, data) logger.info( "gphoto2 captured %s (%.1f MB): assert %.0f ms, shot %.0f ms, download %.0f ms (%.0f MB/s)", os.path.basename(out_path), @@ -835,6 +853,155 @@ def capture(self, out_path: str, shutter: Optional[str] = None, iso: Optional[st ) return out_path + def capture_bulb( + self, + out_path: str, + expose: Callable[[], None], + *, + usb_release: bool = True, + iso: Optional[str] = None, + aperture: Optional[str] = None, + keep_bulb: bool = False, + on_closed: Optional[Callable[[], None]] = None, + ) -> str: + """One Bulb exposure: set Bulb, open the shutter, run `expose`, close it, then download the + RAW the body reports and return where it landed. With `usb_release` False, `expose` opens + the shutter itself by cable, so only Bulb is set. The body's shutter step is put back after, + unless `keep_bulb` holds Bulb for the next frame: `release_bulb`, `capture` and `close` put it back. + `on_closed` runs once the shutter has closed, before the body hands over the file; it must not block. + """ + prev = self._post_shot + if prev is not None and prev.is_alive(): + prev.join() + self._busy.set() + drained_async = False + try: + with self._lock: + camera = self._require() + held, self._bulb_held = self._bulb_held, None + if held is not None: + name, bulb, before = held + else: + name = self._property("shutter") + before = _safe_value(self._gp, camera.get_single_config(name)) if name is not None else None + bulb = self._bulb_choice(camera, name) + if bulb is None and usb_release: + raise GphotoError("this camera offers no Bulb shutter over USB; set Bulb on the camera and use a release cable") + release = self._bulb_release(camera) if usb_release else None + if usb_release and release is None: + raise GphotoError("this camera has no bulb release over USB; use a release cable on the Scanlight") + for prop, value in (("iso", iso), ("aperture", aperture)): + if value: + prop_name = self._property(prop) + if prop_name is None or not self._set_verified(prop_name, value): + logger.warning("gphoto2: could not lock %s to %r for the scan; using the current setting", prop, value) + if bulb is None: + logger.info("gphoto2: this body lists no Bulb step; the camera must be set to Bulb by hand") + elif held is None and not self._set_verified(name, bulb): + # A body in a continuous drive mode refuses Bulb (a Sony one does), which no write can change. + raise GphotoError( + f"the camera did not go to {bulb!r}; set its drive mode to single shooting and its mode dial to M, then scan again" + ) + kept = False + try: + try: + if release is not None: + self._write_action(camera, release[0], release[1]) + expose() + finally: + if release is not None: + self._write_action(camera, release[0], release[2]) + if on_closed is not None: + on_closed() + path = self._wait_for_raw(camera) + data = self._download(camera, path) + if keep_bulb and name is not None and bulb is not None: + self._bulb_held, kept = (name, bulb, before), True + except BaseException: + self._drain_events() + raise + finally: + if not kept: + self._restore_shutter(name, bulb, before) + except self._gp.GPhoto2Error as exc: + raise GphotoError(f"bulb capture failed: {exc}") from exc + else: + self._drain_owed.set() + self._finish_shot_async() + drained_async = True + finally: + if not drained_async: + self._busy.clear() + out_path = _write_raw(out_path, path.name, data) + logger.info("gphoto2 captured bulb exposure %s (%.1f MB)", os.path.basename(out_path), len(data) / 1e6) + return out_path + + def release_bulb(self) -> None: + """Put back the shutter step a `keep_bulb` capture held at Bulb. Safe to call at any time.""" + with self._lock: + held, self._bulb_held = self._bulb_held, None + if held is not None and self._camera is not None and self._alive: + self._restore_shutter(*held) + + def _restore_shutter(self, name: Optional[str], bulb: Optional[str], before: Optional[str]) -> None: + """Live view and the next plain shot keep the operator's step, not Bulb.""" + if name is not None and bulb is not None and before and before != bulb: + self._set_verified(name, before) + + def _bulb_choice(self, camera: Any, name: Optional[str]) -> Optional[str]: + """This body's label for the Bulb shutter step, or None when it lists none.""" + if name is None: + return None + return next((c for c in _choices(self._gp, camera.get_single_config(name)) if c.strip().lower() == "bulb"), None) + + def _bulb_release(self, camera: Any) -> Optional[tuple[str, Any, Any]]: + """(property, press, release) for this body's bulb button over USB, or None.""" + for spec in _BULB_RELEASES: + try: + camera.get_single_config(spec[0]) + except self._gp.GPhoto2Error: + continue + return spec + return None + + def _write_action(self, camera: Any, name: str, value: Any) -> None: + """Write a button-like property (a toggle or a remote release) with no read-back: it has no + state to settle on.""" + widget = camera.get_single_config(name) + widget.set_value(value) + camera.set_single_config(name, widget) + + def _wait_for_raw(self, camera: Any) -> Any: + """The camera path of the RAW a Bulb exposure adds. A JPEG the body adds beside it is + deleted off the body; no RAW before the timeout is an error.""" + deadline = time.monotonic() + _BULB_FILE_TIMEOUT_S + while time.monotonic() < deadline: + kind, data = camera.wait_for_event(_BULB_EVENT_WAIT_MS) + if kind != self._gp.GP_EVENT_FILE_ADDED: + continue + if os.path.splitext(data.name)[1].lower() in _CAMERA_RAW_EXTENSIONS: + return data + try: + camera.file_delete(data.folder, data.name) + except self._gp.GPhoto2Error as exc: + logger.debug("gphoto2: could not delete %s off the camera: %s", data.name, exc) + raise GphotoError( + "the camera sent no RAW file after the Bulb exposure; check that it is in Bulb, set to RAW, " + "and that the release cable reaches it" + ) + + def _download(self, camera: Any, path: Any) -> bytes: + """The file's bytes, deleted off the body after the download. On a self-recycling RAM target + the delete is redundant at worst, but a body without a capture target keeps every tethered + object until it refuses to capture at all (issue #658).""" + camera_file = camera.file_get(path.folder, path.name, self._gp.GP_FILE_TYPE_NORMAL) + data = bytes(memoryview(camera_file.get_data_and_size())) + try: + camera.file_delete(path.folder, path.name) + except self._gp.GPhoto2Error as exc: + logger.debug("gphoto2: could not delete %s off the camera: %s", path.name, exc) + return data + def _finish_shot_async(self) -> None: """Drain the shot's leftover events off the caller's thread. diff --git a/negpy/infrastructure/capture/lento_dock.py b/negpy/infrastructure/capture/lento_dock.py index 84d16cfb9..bb33effee 100644 --- a/negpy/infrastructure/capture/lento_dock.py +++ b/negpy/infrastructure/capture/lento_dock.py @@ -49,6 +49,7 @@ EXPOSE_WAIT_S = 5.0 # the dock's own exposure on an S press lasts 3 s EJECT_TIMEOUT_S = 30.0 HOLD_INTERVAL_S = 30.0 +KEEPALIVE_TICK_S = 1.0 _KV = re.compile(r"(\w+)=(\S+)") @@ -149,7 +150,11 @@ def __init__( self._port = port self._serial = serial.Serial(port, baudrate=baudrate, timeout=read_timeout_s) + # One command waits for its replies at a time; the write lock guards only the write, + # so the keep-alive still reaches the dock while a command waits for a slow move. + self._command_lock = threading.Lock() self._write_lock = threading.Lock() + self._link_error: Optional[BaseException] = None self._state: dict[str, str] = {} self._replies: queue.Queue[str] = queue.Queue() self._booted = threading.Event() @@ -185,7 +190,7 @@ def last_temp_c(self) -> Optional[float]: return None def is_connected(self) -> bool: - return self._reader.is_alive() and self._reader_error is None + return self._reader.is_alive() and self._reader_error is None and self._link_error is None def describe(self) -> tuple[str, bool, bool]: """(status text, has a white channel, has an infrared channel) for the Scan panel.""" @@ -214,6 +219,23 @@ def set_color(self, r: int = 0, g: int = 0, b: int = 0, w: int = 0, ir: int = 0, self._last_hold = time.monotonic() self._light_on = True + def set_color_now(self, r: int = 0, g: int = 0, b: int = 0) -> None: + """Switch the panel without waiting for the dock's reply, for a light that NegPy times itself. + The reply time varies, so a light switched by waited commands stays lit for a varying extra time. + A call with the hold not yet confirmed confirms it first, so make the first call in the dark.""" + duties = [to_duty(v) for v in (r, g, b)] + if not (self._continuous and self._light_on): + self._enter_continuous() + self._command("CONTHOLD 1", r"^(CONTHOLD|ERR)") + self._last_hold = time.monotonic() + self._light_on = True # the keep-alive refreshes the hold from here on + self._send(f"CONTRGB {duties[0]} {duties[1]} {duties[2]}") + + def take_control(self) -> None: + """Put the dock under host control now. The dock finds the first frame of a strip only when + the strip goes in while the dock is in SEMI mode.""" + self._enter_continuous() + def off(self) -> None: if self._continuous: self._command("CONTRGB 0 0 0", r"^(CONTRGB|ERR)") @@ -312,14 +334,15 @@ def _command(self, cmd: str, expect: str, timeout: float = REPLY_TIMEOUT_S, then timeout)` in `then` waits for a later line in turn, and the last line matched is returned.""" if self._reader_error is not None: raise ConnectionError(f"Lento Dock reader stopped: {self._reader_error}") - with self._write_lock: + if self._link_error is not None: + raise ConnectionError(f"Lento Dock link lost: {self._link_error}") + with self._command_lock: while not self._replies.empty(): try: self._replies.get_nowait() except queue.Empty: break - self._serial.write((cmd + "\r\n").encode("ascii")) - self._last_tx = time.monotonic() + self._send(cmd) self._in_command = True try: line = self._await(cmd, expect, timeout) @@ -351,7 +374,11 @@ def _await(self, cmd: str, expect: str, timeout: float) -> str: return line def _keepalive_loop(self) -> None: - while not self._stop.wait(1.0): + """Ends when the link does: a dock that is gone gets no more writes, and `is_connected` + turns false so the caller connects again.""" + while not self._stop.wait(KEEPALIVE_TICK_S): + if not self._reader.is_alive() or self._reader_error is not None: + return try: now = time.monotonic() if self._light_on and now - self._last_hold >= HOLD_INTERVAL_S: @@ -359,8 +386,10 @@ def _keepalive_loop(self) -> None: self._last_hold = now elif now - self._last_tx >= PING_INTERVAL_S: self._send("PING") - except Exception: - logger.debug("Lento Dock keep-alive failed", exc_info=True) + except Exception as exc: + self._link_error = exc + logger.warning("Lento Dock link lost: %s", exc) + return def _reader_loop(self) -> None: buf = b"" diff --git a/negpy/infrastructure/capture/protocol.py b/negpy/infrastructure/capture/protocol.py index b3605dda4..32cd3eddc 100644 --- a/negpy/infrastructure/capture/protocol.py +++ b/negpy/infrastructure/capture/protocol.py @@ -2,8 +2,8 @@ Vendored from jackw01's Scanlight automation firmware protocol via rohanpandula's TriRGB `scanlightctl` (MIT). This is the subset NegPy speaks, byte-for-byte faithful -to the firmware; the device offers more (a 3.5mm shutter trigger, NVM defaults, bus -telemetry) that nothing here needs. The whole Scanlight family (v2, v4, Big Scanlight) +to the firmware; the device offers more (NVM defaults, bus telemetry, the focus line) +that nothing here needs. The whole Scanlight family (v2, v4, Big Scanlight) shares this exact wire protocol — same 0xFE framing, same packet headers, same SET_COLOR layout — and all enumerate as the stock Pico CDC device, so this one codec drives any of them. They differ only in the reported hardware ID (see @@ -23,12 +23,19 @@ from __future__ import annotations +import math + START_BYTE = 0xFE -# Host-to-device headers. The firmware also defines 1 = GET_DEFAULT_RGB and -# 3 = SHUTTER_PULSE, which NegPy never sends. +# Host-to-device headers. The firmware also defines 1 = GET_DEFAULT_RGB, which NegPy never sends. H2D_SET_COLOR = 0 H2D_GET_FW_VERSION = 2 +H2D_SHUTTER_PULSE = 3 + +# The pulse length byte counts 10 ms units, so the firmware holds the 3.5mm jack's shutter line +# closed for at most 2.55 s. +SHUTTER_PULSE_UNIT_S = 0.01 +MAX_SHUTTER_PULSE_S = 255 * SHUTTER_PULSE_UNIT_S # Device-to-host headers (2 = VBUS and 4 = DEFAULT_RGB arrive but are dropped) D2H_LED_TEMP = 1 @@ -62,6 +69,15 @@ def encode_set_color(r: int, g: int, b: int, w: int = 0, ir: int = 0, save: bool return encode_packet(H2D_SET_COLOR, payload) +def encode_shutter_pulse(seconds: float) -> bytes: + """Build a PKT_H2D_SHUTTER_PULSE packet that holds the shutter line for `seconds`, rounded up + to the firmware's 10 ms unit. A camera in Bulb keeps its shutter open for the pulse.""" + units = math.ceil(round(seconds / SHUTTER_PULSE_UNIT_S, 6)) + if not 1 <= units <= 255: + raise ProtocolError(f"shutter pulse out of range 0.01-{MAX_SHUTTER_PULSE_S:.2f} s: {seconds}") + return encode_packet(H2D_SHUTTER_PULSE, bytes([units])) + + def decode_led_temp(data: bytes) -> float: """LED_TEMP payload → degrees C (32-bit signed millidegrees, big-endian).""" if len(data) < 4: diff --git a/negpy/infrastructure/capture/scanlight.py b/negpy/infrastructure/capture/scanlight.py index 914e5c3c6..e01c69090 100644 --- a/negpy/infrastructure/capture/scanlight.py +++ b/negpy/infrastructure/capture/scanlight.py @@ -145,6 +145,11 @@ def describe(self) -> tuple[str, bool, bool]: fw, hw = self.get_fw_version() return f"{proto.describe_hardware(hw)} (fw{fw})", proto.has_white_channel(hw), proto.has_ir_channel(hw) + def shutter_pulse(self, seconds: float) -> None: + """Close the 3.5mm jack's shutter line for `seconds` (at most 2.55 s), timed by the firmware. + A camera in Bulb on a release cable holds its shutter open for the pulse.""" + self._serial.write(proto.encode_shutter_pulse(seconds)) + def get_fw_version(self, timeout: float = 2.0) -> tuple[int, int]: """Request (firmware_id, hardware_id). Raises TimeoutError on no reply.""" return self._request(proto.H2D_GET_FW_VERSION, proto.D2H_FW_VERSION, proto.decode_fw_version, timeout) diff --git a/negpy/infrastructure/capture/settings.py b/negpy/infrastructure/capture/settings.py index a65ce149b..158fae770 100644 --- a/negpy/infrastructure/capture/settings.py +++ b/negpy/infrastructure/capture/settings.py @@ -6,6 +6,7 @@ from enum import StrEnum from negpy.features.process.models import ProcessMode +from negpy.infrastructure.capture.base import BulbRelease class WhiteCaptureMode(StrEnum): @@ -46,6 +47,9 @@ class ScanlightSettings: 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) single_capture: bool = False # the RGB preset lights R, G and B together for one exposure + blend_capture: bool = False # the preset lights R, G and B in turn inside one Bulb exposure + bulb_release: BulbRelease = BulbRelease.USB # what holds a Blend exposure's shutter open + blend_sensor_profile: str = "" # the sensor crosstalk profile a Blend frame's roll takes; Blend needs one inter_exposure_delay_ms: int = 0 white_process_mode: WhiteCaptureMode = WhiteCaptureMode.AUTO port: str = "" # Scanlight serial port ("" = autodetect); the camera needs no address @@ -58,6 +62,7 @@ class ScanlightSettings: 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)) + object.__setattr__(self, "bulb_release", BulbRelease(self.bulb_release)) # A saved list reads back as a list; anything else in the file is not a list of names. adopted = self.adopted_presets if isinstance(self.adopted_presets, (list, tuple)) else () object.__setattr__(self, "adopted_presets", tuple(n for n in adopted if isinstance(n, str))) diff --git a/negpy/infrastructure/simulated/gphoto.py b/negpy/infrastructure/simulated/gphoto.py index ee56ec435..1b2c1e0ab 100644 --- a/negpy/infrastructure/simulated/gphoto.py +++ b/negpy/infrastructure/simulated/gphoto.py @@ -1,10 +1,12 @@ """A python-gphoto2 stand-in: one body that exposes the simulated film under the simulated Scanlight. A still lit white or by the triplet's first channel advances the film; the other two channels stay on it. +A Bulb exposure, opened over USB or by the light's shutter pulse, adds up the light it saw and is one frame. """ import functools import io +import time from typing import Optional import numpy as np @@ -13,7 +15,7 @@ from negpy.infrastructure.capture.base import CAPTURE_ORDER from negpy.infrastructure.simulated.images import film -from negpy.infrastructure.simulated.scanlight import current_color, current_ir +from negpy.infrastructure.simulated.scanlight import current_color, current_ir, light_dose, take_pulse MODEL = "Simulated Camera" _SENSOR_HW = (2000, 3000) # a smaller frame fails the capture path's minimum RAW size @@ -28,7 +30,7 @@ _WHITE_SHARE = 0.5 _SHUTTERS = ( "1/250", "1/200", "1/160", "1/125", "1/100", "1/80", "1/60", "1/50", "1/40", "1/30", "1/25", "1/20", "1/15", - "1/13", "1/10", "1/8", "1/6", "1/5", "1/4", "1/3", "0.4", "1/2", "0.6", "0.8", "1", "1.3", "1.6", "2", + "1/13", "1/10", "1/8", "1/6", "1/5", "1/4", "1/3", "0.4", "1/2", "0.6", "0.8", "1", "1.3", "1.6", "2", "Bulb", ) # fmt: skip # DNGVersion, UniqueCameraModel, CFARepeatPatternDim, CFAPattern (RGGB), BlackLevel, WhiteLevel, ColorMatrix1, AsShotNeutral. _DNG_TAGS = [ @@ -49,7 +51,10 @@ def _seconds(label: str) -> float: num, _, den = label.partition("/") - return float(num) / float(den) if den else float(num) + try: + return float(num) / float(den) if den else float(num) + except ValueError: + return 1.0 # Bulb: a plain capture at Bulb exposes for a second class GPhoto2Error(Exception): @@ -143,6 +148,8 @@ def get_single_config(self, name: str) -> _Widget: def set_single_config(self, name: str, widget: _Widget) -> None: widget.value = widget.pending + if name == "bulb": + self._gp.bulb_switch(bool(int(widget.value or 0))) def capture(self, _kind: int) -> _Path: self._gp.pending_raw = self._gp.still_dng() @@ -158,7 +165,10 @@ def file_delete(self, _folder: str, _name: str) -> None: def capture_preview(self) -> _Data: return _Data(self._gp.preview_jpeg()) - def wait_for_event(self, _ms: int) -> tuple[int, None]: + def wait_for_event(self, _ms: int) -> tuple[int, object]: + self._gp.collect_pulse() + if self._gp.events: + return self._gp.events.pop(0) if self._gp.shot_events: self._gp.shot_events -= 1 return SimGphoto.GP_EVENT_CAPTURE_COMPLETE, None @@ -168,7 +178,7 @@ def wait_for_event(self, _ms: int) -> tuple[int, None]: class SimGphoto: GP_WIDGET_TEXT, GP_WIDGET_RADIO, GP_WIDGET_MENU = 2, 5, 6 GP_CAPTURE_IMAGE, GP_FILE_TYPE_NORMAL = 0, 1 - GP_EVENT_TIMEOUT, GP_EVENT_CAPTURE_COMPLETE = 0, 3 + GP_EVENT_TIMEOUT, GP_EVENT_FILE_ADDED, GP_EVENT_CAPTURE_COMPLETE = 0, 2, 3 GP_OPERATION_CAPTURE_PREVIEW, GP_OPERATION_CONFIG = 8, 16 GPhoto2Error = GPhoto2Error @@ -180,26 +190,54 @@ def __init__(self) -> None: "f-number": _Widget("f-number", None, [], readonly=True), "capturetarget": _Widget("capturetarget", "card", ["card", "sdram"]), "focusmagnifier": _Widget("focusmagnifier", "Off,320,240", ["Off", "1", "6.9", "13.7"]), + "bulb": _Widget("bulb", "0", []), } self.pending_raw = b"" self.shot_events = 0 + self.events: list[tuple[int, object]] = [] + self._bulb_open: Optional[float] = None self._rng = np.random.default_rng(0) self._frame = 0 self.Camera = lambda: _Camera(self) self.Camera.autodetect = lambda: _CameraList([(MODEL, "usb:sim")]) - def _signal(self, transmittance: np.ndarray, ir_transmittance: np.ndarray) -> np.ndarray: - """Each sensor channel's exposure, as a fraction of full scale, through `transmittance`.""" - r, g, b, w = current_color() + def _signal(self, transmittance: np.ndarray, ir_transmittance: np.ndarray, dose: Optional[tuple] = None) -> np.ndarray: + """Each sensor channel's exposure, as a fraction of full scale, through `transmittance`. + `dose` is a Bulb exposure's level-seconds per channel, in place of the light as lit now.""" + if dose is None: + (r, g, b, w), ir, seconds = current_color(), current_ir(), _seconds(self.props["shutterspeed"].value or "1") + else: + (r, g, b, w, ir), seconds = dose, 1.0 led = np.array([r, g, b], np.float32) lit = led + _CROSSTALK * (led.sum() - led) + _WHITE_SHARE * w iso = self.props["iso"].value or "" - gain = _seconds(self.props["shutterspeed"].value or "1") * (int(iso) / 100 if iso.isdigit() else 1.0) + gain = seconds * (int(iso) / 100 if iso.isdigit() else 1.0) signal = transmittance * (_RESPONSE * lit * gain) - if current_ir(): - signal = signal + (ir_transmittance * (_IR_RESPONSE * current_ir() * gain))[..., None] + if ir: + signal = signal + (ir_transmittance * (_IR_RESPONSE * ir * gain))[..., None] return signal + def bulb_switch(self, on: bool) -> None: + """The USB bulb button: pressed opens the shutter, released closes it and adds the file.""" + if self.props["shutterspeed"].value != "Bulb": + return + if on: + self._bulb_open = time.monotonic() + elif self._bulb_open is not None: + self._finish_bulb(self._bulb_open, time.monotonic()) + self._bulb_open = None + + def collect_pulse(self) -> None: + """A shutter pulse from the light's 3.5mm jack exposes a body in Bulb for the pulse.""" + pulse = take_pulse() + if pulse is not None and self.props["shutterspeed"].value == "Bulb": + self._finish_bulb(*pulse) + + def _finish_bulb(self, t0: float, t1: float) -> None: + self.pending_raw = self.still_dng(light_dose(t0, t1)) + self.events.append((self.GP_EVENT_FILE_ADDED, _Path("capt0001.DNG"))) + self.events.append((self.GP_EVENT_CAPTURE_COMPLETE, None)) + def _advance_film(self) -> None: r, g, b, w = current_color() if current_ir() and not any((r, g, b, w)): @@ -207,9 +245,12 @@ def _advance_film(self) -> None: if w > max(r, g, b) or int(np.argmax((r, g, b))) == _FIRST_CHANNEL: self._frame += 1 - def still_dng(self) -> bytes: - self._advance_film() - quad = self._signal(*_film(_SENSOR_HW[0] // 2, _SENSOR_HW[1] // 2, self._frame)) + def still_dng(self, dose: Optional[tuple] = None) -> bytes: + if dose is None: + self._advance_film() + else: + self._frame += 1 # a Bulb exposure is a whole frame + quad = self._signal(*_film(_SENSOR_HW[0] // 2, _SENSOR_HW[1] // 2, self._frame), dose) cfa = np.empty(_SENSOR_HW, np.float32) cfa[0::2, 0::2], cfa[0::2, 1::2], cfa[1::2, 0::2], cfa[1::2, 1::2] = quad[..., 0], quad[..., 1], quad[..., 1], quad[..., 2] counts = cfa * (_WHITE - _BLACK) diff --git a/negpy/infrastructure/simulated/scanlight.py b/negpy/infrastructure/simulated/scanlight.py index b11140c4f..a40e94c4a 100644 --- a/negpy/infrastructure/simulated/scanlight.py +++ b/negpy/infrastructure/simulated/scanlight.py @@ -1,16 +1,23 @@ -"""A Scanlight serial port stand-in: answers the firmware query and streams LED temperature.""" +"""A Scanlight serial port stand-in: answers the firmware query, streams LED temperature, and keeps +the light's history so a simulated Bulb exposure can add up the light it saw.""" import math import queue import time +from typing import Optional from negpy.infrastructure.capture import protocol as proto _FIRMWARE_ID = 7 _HARDWARE_ID = 0 # Big Scanlight: white and infrared channels _TELEMETRY_INTERVAL_S = 0.2 +_HISTORY_S = 60.0 # longer than any Bulb exposure _color = [0, 0, 0, 0, 0] +# (monotonic time, (r, g, b, w, ir)) at each change, oldest first. +_history: list[tuple[float, tuple[int, ...]]] = [(0.0, (0, 0, 0, 0, 0))] +# (start, end) of the last shutter pulse on the 3.5mm jack, None once a camera took it. +_pulse: list[Optional[tuple[float, float]]] = [None] def current_color() -> tuple[int, int, int, int]: @@ -22,6 +29,34 @@ def current_ir() -> int: return _color[4] if len(_color) > 4 else 0 +def light_dose(t0: float, t1: float) -> tuple[float, ...]: + """Level times seconds per channel (r, g, b, w, ir) between `t0` and `t1`.""" + dose = [0.0] * 5 + for i, (start, levels) in enumerate(_history): + end = _history[i + 1][0] if i + 1 < len(_history) else t1 + span = min(end, t1) - max(start, t0) + if span > 0: + dose = [d + level * span for d, level in zip(dose, levels)] + return tuple(dose) + + +def take_pulse() -> Optional[tuple[float, float]]: + """The last shutter pulse once it has ended, consumed so one pulse makes one exposure.""" + pulse = _pulse[0] + if pulse is None or pulse[1] > time.monotonic(): + return None + _pulse[0] = None + return pulse + + +def _record(levels: tuple[int, ...]) -> None: + now = time.monotonic() + _color[:] = levels + _history.append((now, tuple(levels))) + while len(_history) > 1 and _history[1][0] < now - _HISTORY_S: + del _history[0] + + class SimSerial: port = "sim" @@ -32,7 +67,10 @@ def __init__(self) -> None: def write(self, data: bytes) -> None: if len(data) >= 8 and data[0] == proto.START_BYTE and data[1] == proto.H2D_SET_COLOR: - _color[:] = data[3:8] + _record(tuple(data[3:8])) + elif len(data) >= 4 and data[0] == proto.START_BYTE and data[1] == proto.H2D_SHUTTER_PULSE: + now = time.monotonic() + _pulse[0] = (now, now + data[3] * proto.SHUTTER_PULSE_UNIT_S) elif len(data) >= 2 and data[0] == proto.START_BYTE and data[1] == proto.H2D_GET_FW_VERSION: word = (_HARDWARE_ID << 16) | _FIRMWARE_ID self._replies.put(proto.encode_packet(proto.D2H_FW_VERSION, word.to_bytes(4, "big"))) diff --git a/negpy/services/assets/capture_modes.py b/negpy/services/assets/capture_modes.py new file mode 100644 index 000000000..ae25d55d9 --- /dev/null +++ b/negpy/services/assets/capture_modes.py @@ -0,0 +1,49 @@ +"""How NegPy shot a single-file frame: a Blend or a Single Exposure capture. + +Nothing in the RAW records it, so the capture writes it here, one global setting per folder +(file name -> mode), and the Film Strip reads it back for the frame's mark. A Trichrome frame +needs no entry: its three files make it a triplet. +""" + +import os +from typing import Any, Dict, Iterable + +BLEND = "blend" +SINGLE = "single" +MODES = (BLEND, SINGLE) + +_KEY_PREFIX = "capture_modes:" + + +def _key(folder: str) -> str: + return _KEY_PREFIX + os.path.normpath(folder) + + +def _folder_modes(repo: Any, folder: str) -> Dict[str, str]: + saved = repo.get_global_setting(_key(folder), default=None) + return dict(saved) if isinstance(saved, dict) else {} + + +def record_capture_mode(repo: Any, path: str, mode: str) -> None: + if mode not in MODES: + raise ValueError(f"unknown capture mode: {mode!r}") + folder, name = os.path.split(path) + modes = _folder_modes(repo, folder) + if modes.get(name) != mode: + modes[name] = mode + repo.save_global_setting(_key(folder), modes) + + +def capture_modes(repo: Any, paths: Iterable[str]) -> Dict[str, str]: + """{path: mode} for each of `paths` that a capture recorded.""" + by_folder: Dict[str, list] = {} + for path in paths: + by_folder.setdefault(os.path.dirname(path), []).append(path) + found: Dict[str, str] = {} + for folder, members in by_folder.items(): + modes = _folder_modes(repo, folder) + for path in members: + mode = modes.get(os.path.basename(path)) + if mode in MODES: + found[path] = mode + return found diff --git a/negpy/services/capture/calibration.py b/negpy/services/capture/calibration.py index 0c4031438..9863d9d2b 100644 --- a/negpy/services/capture/calibration.py +++ b/negpy/services/capture/calibration.py @@ -406,8 +406,8 @@ class CalibrationResult: channels: dict[str, ChannelCalibration] spread_stops: float = 0.0 # measured k spread in stops (confirms the shared-shutter assumption) single_capture: bool = False # the levels are for R, G and B lit together in one exposure - # Sensor unmix from a single-capture run's probes (9 floats, row-major); None when they - # could not give one. + # Sensor unmix from a single-capture or sensor-measuring run (9 floats, row-major); None when + # the shots could not give one. sensor_matrix: Optional[tuple[float, ...]] = None @property @@ -522,14 +522,21 @@ def calibrate( progress: Optional[ProgressCb] = None, cancel=None, single_capture: bool = False, + measure_sensor: bool = False, ) -> CalibrationResult: + """`measure_sensor` (a triplet run) also reads every sensor channel in each LED's last shot, near target, + for a sensor unmix: a Blend exposure lights all three LEDs into one file.""" candidates, start_shutter, _report, _check_cancel = _run_setup(candidates, start_shutter, progress, cancel) T = target_signal(target_fraction) + last_means: dict[int, np.ndarray] = {} def _shoot(i: int, ch, level: int, shutter: str) -> tuple[float, float, float]: """Light channel `ch` at `level`, capture at `shutter`, meter → (base p99.9, linearity_clip, plateau_clip). The demosaiced check joins the linearity budget: its ceiling is itself scaled from the calibrated linearity limit via user_sat.""" + if measure_sensor: + signals, linearity, plateau, last_means[i] = self._meter_rgb(ch.rgb(level), shutter, roi, scratch_path, clip_channels=(i,)) + return float(signals[i]), float(linearity[i]), float(plateau[i]) self._light.set_color(*ch.rgb(level)) self._sleep(self._settle_s) img, written = self._capture(scratch_path, shutter=shutter) @@ -568,7 +575,8 @@ def _shoot(i: int, ch, level: int, shutter: str) -> tuple[float, float, float]: channels[ch.letter] = self._verify_channel(i, ch, levels[ch.letter], shutter, T, _shoot) _report(1.0, "Calibration done") - return CalibrationResult(channels=channels, spread_stops=spread) + sensor = _sensor_matrix(np.column_stack([last_means[i] for i in range(3)])) if measure_sensor else None + return CalibrationResult(channels=channels, spread_stops=spread, sensor_matrix=sensor) finally: try: self._light.off() diff --git a/negpy/services/capture/presets.py b/negpy/services/capture/presets.py index b8134506d..0de32f670 100644 --- a/negpy/services/capture/presets.py +++ b/negpy/services/capture/presets.py @@ -54,6 +54,7 @@ class ScanlightPreset: iso: str = "" # camera ISO label baked at calibration (e.g. "100"); "" = not captured aperture: str = "" # aperture label (e.g. "f/8"); "" for a manual lens (no electronic aperture) single_capture: bool = False # one exposure with R, G and B lit together, not a triplet + blend_capture: bool = False # a triplet's levels and shutter, shot as one Bulb exposure with the LEDs in turn sensor_profile: str = "" # sensor profile measured with a single-capture preset; "" = none diff --git a/negpy/services/capture/service.py b/negpy/services/capture/service.py index 1a5398aa3..fd3b31251 100644 --- a/negpy/services/capture/service.py +++ b/negpy/services/capture/service.py @@ -2,28 +2,36 @@ Sets the Scanlight to one narrowband channel, fires the camera, repeats for red, green, blue — then turns the light off. The three RAWs are handed to NegPy's -RGB-Scan mode, which aligns/merges/inverts them. Hardware is injected, so the -whole flow is unit-testable with fakes. +RGB-Scan mode, which aligns/merges/inverts them. A Blend capture lights the same +three channels in turn inside one Bulb exposure, into one RAW. Hardware is injected, +so the whole flow is unit-testable with fakes. """ from __future__ import annotations +import math import os import shutil import tempfile import threading import time -from typing import Callable, Optional +from dataclasses import dataclass +from typing import Callable, Optional, cast from negpy.infrastructure.capture.base import ( CAPTURE_ORDER, + BulbCamera, + BulbRelease, Camera, CaptureResult, CaptureSettings, Channel, LightSource, + ShutterRelease, ) +from negpy.infrastructure.capture.protocol import MAX_SHUTTER_PULSE_S from negpy.kernel.system.logging import get_logger +from negpy.services.capture.calibration import PWM_MAX, PWM_MIN logger = get_logger(__name__) @@ -36,10 +44,64 @@ LightLevels = tuple[int, int, int, int] # r, g, b, w +#: The host times each Blend channel with a sleep, so a channel shorter than this takes a timing +#: error of a few percent. The plan dims the LEDs to make a short channel longer. +BLEND_MIN_CHANNEL_S = 0.2 +#: Dark time inside the Bulb exposure before the first LED, so the shutter's lag after the press +#: never shortens the first channel. +BULB_LEAD_S = 0.35 +#: Dark time after the last LED, by cable only: the light's timer ends the pulse, so the host's +#: latency must fit inside it. Over USB the light is off before the release, so a slow close loses nothing. +BULB_TAIL_S = 0.2 + + class CaptureError(RuntimeError): """Raised when a triplet capture cannot complete.""" +@dataclass(frozen=True) +class BlendStep: + """One channel of a Blend exposure: its LED level and how long it stays lit.""" + + channel: Channel + level: int + seconds: float + + +def blend_plan(levels: tuple[int, int, int], seconds: float, *, max_light_s: Optional[float] = None) -> tuple[BlendStep, ...]: + """Each channel's LED level and time for one Blend exposure. Each channel gets the light of its + Trichrome exposure (level times `seconds`). One factor scales every level, so the channels keep + their balance. The factor stays 1 unless a channel would be too short to time, or the channels + together would last longer than `max_light_s`.""" + lit = [(ch, level) for ch, level in zip(CAPTURE_ORDER, levels) if level > 0] + if not lit or seconds <= 0: + raise CaptureError("a Blend exposure needs a shutter time and at least one lit channel") + low = min(level for _, level in lit) + high = max(level for _, level in lit) + floor = min(PWM_MIN, low) / low # dims no channel under the calibration's LED floor + if max_light_s is not None: + floor = max(floor, len(lit) * seconds / max_light_s) + ceiling = PWM_MAX / high + if floor > ceiling: + raise CaptureError( + f"the Blend exposure needs {len(lit) * seconds * high / PWM_MAX:.1f} s of light, more than the " + f"release cable holds ({max_light_s:.1f} s); open the aperture or raise the ISO, then calibrate again" + ) + factor = min(max(min(1.0, seconds / BLEND_MIN_CHANNEL_S), floor), ceiling) + steps = [] + for ch, level in lit: + # Under a pulse limit, a level rounds up: a brighter step is a shorter one, so the plan still fits. + scaled = max(1, min(PWM_MAX, math.ceil(level * factor - 1e-9) if max_light_s is not None else round(level * factor))) + steps.append(BlendStep(ch, scaled, seconds * level / scaled)) # exact against the rounding + if max_light_s is not None and sum(s.seconds for s in steps) > max_light_s + 1e-9: + raise CaptureError(f"the Blend exposure is longer than the release cable holds ({max_light_s:.1f} s)") + if min(s.seconds for s in steps) < BLEND_MIN_CHANNEL_S: + logger.warning( + "blend: %.3f s per channel is short to time; close the aperture or lower the ISO for longer channels", steps[0].seconds + ) + return tuple(steps) + + def verify_raw_size(path: str, min_bytes: int, max_bytes: int) -> None: """Raise CaptureError if the captured RAW is missing or implausibly sized.""" try: @@ -64,13 +126,18 @@ def _capture_validated_single( cancel: Optional[threading.Event] = None, iso: Optional[str] = None, aperture: Optional[str] = None, + shoot: Optional[Callable[[str], str]] = None, ) -> str: - """Capture beside the destination and replace it only after size validation.""" + """Capture beside the destination and replace it only after size validation. `shoot` takes the + staged path and returns the file's, in place of one plain `camera.capture`.""" output_folder = os.path.dirname(final_stem) staging_dir = tempfile.mkdtemp(prefix=f".{os.path.basename(final_stem)}-", suffix=".capture", dir=output_folder) try: staged_stem = os.path.join(staging_dir, os.path.basename(final_stem)) - staged_path = camera.capture(staged_stem + _RAW_SUFFIX, shutter=shutter, iso=iso, aperture=aperture) + if shoot is not None: + staged_path = shoot(staged_stem + _RAW_SUFFIX) + else: + staged_path = camera.capture(staged_stem + _RAW_SUFFIX, shutter=shutter, iso=iso, aperture=aperture) verify_raw_size(staged_path, min_raw_bytes, max_raw_bytes) final_path = os.path.join(output_folder, os.path.basename(staged_path)) if cancel is not None and cancel.is_set(): @@ -281,6 +348,97 @@ def capture_rgb_single( finally: self._release_light(ok, end_light) + def capture_blend( + self, + settings: CaptureSettings, + *, + seconds: float, + release: BulbRelease = BulbRelease.USB, + cancel: Optional[threading.Event] = None, + on_channel: Optional[Callable[[str], None]] = None, + end_light: Optional[LightLevels] = None, + keep_bulb: bool = False, + on_closed: Optional[Callable[[], None]] = None, + ) -> str: + """One Bulb exposure with red, green and blue lit in turn (one file, no R/G/B split). + `keep_bulb` leaves the camera in Bulb for the next frame of a strip. `on_closed` runs once the + shutter has closed and the light is off, while the camera still processes the shot. + `seconds` is the Trichrome shutter's true time; see `blend_plan`. The shutter opens and + closes in the dark. A Stop between channels ends the light and discards the file.""" + if not isinstance(self._camera, BulbCamera): + raise CaptureError("this camera cannot take a Bulb exposure") + camera = cast(BulbCamera, self._camera) + cable = release is BulbRelease.CABLE + if cable and not isinstance(self._light, ShutterRelease): + raise CaptureError("this light has no shutter release output for a cable") + pulse = cast(ShutterRelease, self._light) if cable else None + budget = MAX_SHUTTER_PULSE_S - BULB_LEAD_S - BULB_TAIL_S if cable else None + plan = blend_plan(settings.levels, seconds, max_light_s=budget) + light_s = sum(step.seconds for step in plan) + os.makedirs(settings.output_folder, exist_ok=True) + + # A light whose set_color waits for a reply switches through set_color_now, so each color + # is lit for the planned time and not for the reply's as well. + now = getattr(self._light, "set_color_now", None) + + def _switch(r: int = 0, g: int = 0, b: int = 0) -> None: + if now is not None: + now(r=r, g=g, b=b) + elif r or g or b: + self._light.set_color(r=r, g=g, b=b) + else: + self._light.off() + + def _expose() -> None: + _switch() # dark, and any confirmation the light needs, before the timed part + if pulse is not None: + pulse.shutter_pulse(BULB_LEAD_S + light_s + BULB_TAIL_S) + self._sleep(BULB_LEAD_S) + for step in plan: + if cancel is not None and cancel.is_set(): + break + if on_channel is not None: + on_channel(step.channel.letter) + _switch(*step.channel.rgb(step.level)) + self._sleep(step.seconds) + _switch() + if pulse is not None: + self._sleep(BULB_TAIL_S) + + def _shoot(path: str) -> str: + return camera.capture_bulb( + path, + _expose, + usb_release=not cable, + iso=settings.iso, + aperture=settings.aperture, + keep_bulb=keep_bulb, + on_closed=on_closed, + ) + + ok = False + try: + stem = os.path.join(settings.output_folder, f"{settings.roll_name}_Frame{settings.frame_number:03d}") + logger.info( + "capturing blend frame → %s (%s release): %s", + stem, + release.value, + ", ".join(f"{s.channel.letter} {s.level} for {s.seconds:.3f} s" for s in plan), + ) + path = _capture_validated_single( + self._camera, + final_stem=stem, + shutter=None, + min_raw_bytes=settings.min_raw_bytes, + max_raw_bytes=settings.max_raw_bytes, + cancel=cancel, + shoot=_shoot, + ) + ok = True + return path + finally: + self._release_light(ok, end_light) + def capture_channel( self, *, diff --git a/negpy/translations/USER_GUIDE.de.md b/negpy/translations/USER_GUIDE.de.md index 1bbc29b3b..e1fd71017 100644 --- a/negpy/translations/USER_GUIDE.de.md +++ b/negpy/translations/USER_GUIDE.de.md @@ -576,7 +576,7 @@ Scannt mit einer Kamera auf einem Reprostativ, per USB im Modus **PC Remote** ve * **Film** (mit einem Lento Dock): was das Dock beim Klick auf Scan tut. **Nächstes Bild** transportiert den Film nach jedem Scan um ein Bild. **Ganzer Streifen** scannt jedes Bild und wirft den Streifen dann aus; Stopp beendet den Lauf. Neu aufnehmen bewegt den Film nie. **Auswerfen** transportiert den Streifen heraus. Die Taste S des Docks wirkt wie Scan. * **Flat Field aufnehmen**: nimmt nach dem Auswerfen eines ganzen Streifens das leere Dock auf und speichert es als Flat-Field-Profil der Rolle. - + ### Vorgabe Sichtbar, solange ein Scanlight verbunden ist. Eine Vorgabe enthält die Lichtwerte und die Kamerabelichtung für eine Filmart. @@ -586,8 +586,10 @@ Sichtbar, solange ein Scanlight verbunden ist. Eine Vorgabe enthält die Lichtwe * **Auf der Filmbasis kalibrieren…**: Setze das Rechteck auf klaren Film und starte die Kalibrierung. NegPy ermittelt die Lichtwerte und die Verschlusszeit. * **Von Hand einstellen**: Stelle die Regler selbst ein und speichere sie als Vorgabe. Ohne verbundene Kamera speichert sie keine Belichtung, und ein Scan nutzt die eigene der Kamera. * **Rot**, **Grün**, **Blau**, **Weiß**: die Lichtwerte (0 bis 255). **Licht an** schaltet das Licht ein, um den Film einzurichten, oder aus. -* **Aufnahmemodus**: **Trichrom** macht eine Belichtung pro Farbe und fügt sie zusammen. **Einzelbelichtung** macht eine Belichtung mit allen drei Farben an. -* **Sensor-Farbvermischungsprofil erstellen** (Einzelbelichtung): speichert zusätzlich ein Sensor-Farbvermischungsprofil für diese Vorgabe (siehe Kalibrierung, §10.4). +* **Aufnahmemodus**: **Trichrom** macht eine Belichtung pro Farbe und fügt sie zusammen. **Einzelbelichtung** macht eine Belichtung mit allen drei Farben an. **Mischen** hält die Kamera in Bulb offen und schaltet Rot, Grün und Blau nacheinander ein, für eine Datei. Ein Trichrom- oder Misch-Preset kann zwischen beiden wechseln. +* **Auslösung** (Mischen): was den Verschluss offen hält. **USB** nutzt die Bulb-Steuerung der Kamera. **Kabel** nutzt ein Fernauslöserkabel in der 3,5-mm-Buchse des Scanlight, für Belichtungen bis 2,55 s. +* **Sensorprofil** (Misch-Vorgabe ohne eigenes Profil): das Sensor-Farbvermischungsprofil für ihre Scans. Scannen wartet auf eines. Die Schaltfläche mit den Röhrchen erstellt eines. +* **Sensor-Farbvermischungsprofil erstellen** (Einzelbelichtung oder Mischen): speichert zusätzlich ein Sensor-Farbvermischungsprofil, das die Scans dieser Vorgabe nutzen (siehe Kalibrierung, §10.4). * **ISO**, **Verschlusszeit**, **Blende**: die Belichtung der Vorgabe. Eine Änderung gilt, bis du die Vorgabe erneut wählst. Die Messtaste misst. * **IR-Verschlusszeit** und **Infrarot** (Lichter mit Infrarot-LEDs): fügt jedem Scan eine Infrarotbelichtung für die Staubentfernung hinzu, gespeichert als `_IR`-Datei. Es braucht eine Kamera, die Infrarot sieht (Vollspektrum oder umgebaut), und manuellen Fokus. Nur für Farbfilm. * **Kanalverzögerung**: eine Pause zwischen den Belichtungen für Rot, Grün und Blau, für Kameras, die sie brauchen. diff --git a/negpy/translations/USER_GUIDE.en.md b/negpy/translations/USER_GUIDE.en.md index 93c082ef0..0c5540ee7 100644 --- a/negpy/translations/USER_GUIDE.en.md +++ b/negpy/translations/USER_GUIDE.en.md @@ -162,7 +162,7 @@ Terms combine: `film:portra iso:>=400 -rejected:`. Metadata terms match only fra Right-click a frame, or use the shortcuts, to **Keep** it (a check) or **Reject** it (a cross; the frame dims and batch exports skip it). Your files never change. -A film mark in the top-right corner means the frame prints on the roll average; it is amber when the frame's color is far from that average. A mark in the bottom-left corner means the frame is built from several files (a stitch, HDR merge, Trichrome or half frame); hover it for the name. An exclamation mark means the file did not open (click to retry); a small amber dot means the thumbnail is out of date. +A film mark in the top-right corner means the frame prints on the roll average; it is amber when the frame's color is far from that average. A mark in the bottom-left corner means the frame is built from several files (a stitch, HDR merge, Trichrome or half frame), or a Blend or Single Exposure capture shot it; hover it for the name. An exclamation mark means the file did not open (click to retry); a small amber dot means the thumbnail is out of date. The right-click menu also copies, pastes and resets settings, applies them to other frames, reanalyzes frames ([§10.5](#105-metering-negative--positive)), puts the selection on the roll average or back on its own measurement, shows the file in its folder, and moves files to the Trash (the edit stays). @@ -789,8 +789,10 @@ Shown while a Scanlight is connected. A preset holds the light levels and the ca * **Calibrate on the Film Base…**: place the rectangle on clear film and run it. NegPy finds the light levels and the shutter. * **Set by Hand**: set the sliders yourself, then save them as a preset. With no camera connected, it saves no exposure, and a scan uses the camera's own. * **Red**, **Green**, **Blue**, **White**: the light levels (0 to 255). **Light On** turns the light on to frame the film, or off. -* **Capture mode**: **Trichrome** takes one exposure per color and joins them; **Single Exposure** takes one exposure with all three colors on. -* **Create Sensor Crosstalk Profile** (Single Exposure): also saves a sensor crosstalk profile for this preset (see Calibration, §10.4). +* **Capture mode**: **Trichrome** takes one exposure per color and joins them; **Single Exposure** takes one exposure with all three colors on. **Blend** holds the camera open in Bulb and turns on red, green and blue in turn, for one file. A Trichrome or Blend preset can switch between the two. +* **Shutter release** (Blend): what holds the shutter open. **USB** uses the camera's own bulb control. **Cable** uses a release cable in the Scanlight's 3.5mm jack, for exposures up to 2.55 s. +* **Sensor profile** (Blend preset with no profile of its own): the sensor crosstalk profile for its scans. Scan waits for one. The vials button makes one. +* **Create Sensor Crosstalk Profile** (Single Exposure or Blend): also saves a sensor crosstalk profile that this preset's scans use (see Calibration, §10.4). * **ISO**, **Shutter**, **Aperture**: the preset's exposure. A change holds until you pick the preset again. The gauge button meters. * **IR Shutter** and **Infrared** (lights with infrared LEDs): adds an infrared exposure to each scan for dust removal, saved as an `_IR` file. It needs a camera that sees infrared (full-spectrum or converted) and manual focus. Color film only. * **Channel Delay**: a pause between the red, green and blue exposures, for cameras that need it. diff --git a/negpy/translations/USER_GUIDE.ja.md b/negpy/translations/USER_GUIDE.ja.md index 2dc0f8e3b..d33f129d3 100644 --- a/negpy/translations/USER_GUIDE.ja.md +++ b/negpy/translations/USER_GUIDE.ja.md @@ -580,7 +580,7 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 - **フィルム**(Lento Dock 使用時):「スキャン」をクリックしたときの Dock の動作です。**次のフレーム** は各スキャンの後にフィルムを 1 フレーム送ります。**ストリップ全体** はすべてのフレームをスキャンしてからストリップをイジェクトします。「停止」で終了します。リテイクではフィルムは動きません。**イジェクト** はストリップを送り出します。Dock の S ボタンは「スキャン」と同じ働きをします。 - **フラットフィールドを撮影**:ストリップ全体のイジェクト後に空の Dock を撮影し、ロールのフラットフィールド プロファイルとして保存します。 - + ## プリセット Scanlight の接続中に表示されます。プリセットは、一種類のフィルム用の光の強さとカメラの露出を保持します。 @@ -590,8 +590,10 @@ Scanlight の接続中に表示されます。プリセットは、一種類の - **フィルムベースでキャリブレーション…**:四角形を透明なフィルムに置いて実行すると、NegPy が光の強さとシャッターを求めます。 - **手動で設定**:スライダーを自分で設定し、プリセットとして保存します。カメラを接続していないときは露出を保存せず、スキャンはカメラ自身の露出を使います。 - **赤**、**緑**、**青**、**ホワイト**:光の強さ(0〜255)。**ライトオン**でフレーミング用に点灯、または消灯します。 -- **キャプチャモード**:**トリクローム**は色ごとに 1 回ずつ撮って合わせ、**単一露光**は 3 色を同時に点けて 1 回撮ります。 -- **センサークロストークプロファイルの作成**(単一露光):このプリセット用のセンサークロストークプロファイルも保存します(キャリブレーション、§10.4 を参照)。 +- **キャプチャモード**:**トリクローム**は色ごとに 1 回ずつ撮って合わせ、**単一露光**は 3 色を同時に点けて 1 回撮ります。**ブレンド**はカメラをバルブで開けたまま赤・緑・青を順に点け、1 つのファイルにします。トリクロームまたはブレンドのプリセットは両者を切り替えられます。 +- **シャッターレリーズ**(ブレンド):シャッターを開けておく方法です。**USB** はカメラ自身のバルブ操作を使います。**ケーブル** は Scanlight の 3.5mm ジャックにつないだレリーズケーブルを使い、2.55 秒までの露光に対応します。 +- **センサープロファイル**(自身のプロファイルを持たないブレンドプリセット):そのスキャン用のセンサークロストークプロファイルです。選ぶまでスキャンはできません。試験管のボタンで作成できます。 +- **センサークロストークプロファイルの作成**(単一露光またはブレンド):このプリセット用のセンサークロストークプロファイルも保存し、以後のスキャンで使います(キャリブレーション、§10.4 を参照)。 - **ISO**、**シャッター**、**絞り**:プリセットの露出。変更はプリセットを選び直すまで有効です。メーターのボタンで測光します。 - **IR シャッター**と**赤外線**(赤外線 LED のあるライト):ほこり除去用に各スキャンに赤外線露光を加え、`_IR` ファイルとして保存します。赤外線を写せるカメラ(フルスペクトルまたは改造機)と手動フォーカスが必要です。カラーフィルムのみ。 - **チャネル遅延**:必要なカメラ向けに、赤・緑・青の露光の間に入れる待ち時間。 diff --git a/negpy/translations/USER_GUIDE.ko.md b/negpy/translations/USER_GUIDE.ko.md index 9d07e2207..9be78d89d 100644 --- a/negpy/translations/USER_GUIDE.ko.md +++ b/negpy/translations/USER_GUIDE.ko.md @@ -580,7 +580,7 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 - **필름**(Lento Dock 사용 시): 스캔을 클릭할 때 Dock이 하는 동작입니다. **다음 프레임**은 매 스캔 뒤 필름을 한 프레임 이송합니다. **스트립 전체**는 모든 프레임을 스캔한 뒤 스트립을 꺼냅니다. 중지로 끝냅니다. 재촬영은 필름을 움직이지 않습니다. **꺼내기**는 스트립을 밖으로 이송합니다. Dock의 S 버튼은 스캔과 같이 작동합니다. - **플랫 필드 촬영**: 스트립 전체를 꺼낸 뒤 빈 Dock을 촬영해 롤의 플랫 필드 프로필로 저장합니다. - + ## 사전 설정 Scanlight가 연결되어 있을 때 보입니다. 사전 설정은 한 종류의 필름에 맞는 광원 세기와 카메라 노출을 담습니다. @@ -590,8 +590,10 @@ Scanlight가 연결되어 있을 때 보입니다. 사전 설정은 한 종류 - **필름 베이스에서 보정…**: 사각형을 투명한 필름에 놓고 실행하면 NegPy가 광원 세기와 셔터를 찾습니다. - **수동 설정**: 슬라이더를 직접 정한 뒤 사전 설정으로 저장합니다. 카메라를 연결하지 않으면 노출을 저장하지 않으며, 스캔은 카메라 자체 노출을 씁니다. - **빨강**, **초록**, **파랑**, **화이트**: 광원 세기(0~255)입니다. **점등**은 프레이밍을 위해 광원을 켜거나 끕니다. -- **캡처 모드**: **삼색**은 색마다 한 번씩 찍어 합치고, **단일 노출**은 세 색을 함께 켜고 한 번 찍습니다. -- **센서 누화 프로필 생성**(단일 노출): 이 사전 설정용 센서 누화 프로필도 저장합니다(보정, §10.4 참고). +- **캡처 모드**: **삼색**은 색마다 한 번씩 찍어 합치고, **단일 노출**은 세 색을 함께 켜고 한 번 찍습니다. **블렌드**는 카메라를 벌브로 열어 둔 채 빨강, 초록, 파랑을 차례로 켜서 파일 하나로 만듭니다. 삼색 또는 블렌드 사전 설정은 둘 사이를 바꿀 수 있습니다. +- **셔터 릴리즈** (블렌드): 셔터를 열어 두는 방법입니다. **USB**는 카메라 자체의 벌브 제어를 씁니다. **케이블**은 Scanlight의 3.5mm 잭에 연결한 릴리즈 케이블을 쓰며, 2.55초까지의 노출에 씁니다. +- **센서 프로필** (자체 프로필이 없는 블렌드 사전 설정): 그 스캔에 쓸 센서 누화 프로필입니다. 하나를 고를 때까지 스캔할 수 없습니다. 시험관 버튼으로 만들 수 있습니다. +- **센서 누화 프로필 생성**(단일 노출 또는 블렌드): 이 사전 설정용 센서 누화 프로필도 저장하고, 이후 스캔에 씁니다(보정, §10.4 참고). - **ISO**, **셔터**, **조리개**: 사전 설정의 노출입니다. 바꾼 값은 사전 설정을 다시 고를 때까지 유지됩니다. 측정기 버튼은 측광합니다. - **IR 셔터**와 **적외선**(적외선 LED가 있는 광원): 먼지 제거를 위해 매 스캔에 적외선 노출을 더해 `_IR` 파일로 저장합니다. 적외선을 찍을 수 있는 카메라(풀 스펙트럼 또는 개조)와 수동 초점이 필요합니다. 컬러 필름 전용입니다. - **채널 지연**: 필요한 카메라를 위해 빨강, 초록, 파랑 노출 사이에 두는 쉬는 시간입니다. diff --git a/negpy/translations/USER_GUIDE.zh_CN.md b/negpy/translations/USER_GUIDE.zh_CN.md index de3cb672e..2a3898bf5 100644 --- a/negpy/translations/USER_GUIDE.zh_CN.md +++ b/negpy/translations/USER_GUIDE.zh_CN.md @@ -580,7 +580,7 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 - **胶片**(使用 Lento Dock 时):点击“扫描”时 Dock 的动作。**下一帧** 在每次扫描后将胶片移动一帧。**整条** 扫描每一帧,然后退出胶片条;点击“停止”结束。重拍不会移动胶片。**退片** 将胶片条移出。Dock 的 S 按钮与“扫描”作用相同。 - **拍摄平场**:整条胶片退出后拍摄空的 Dock,并保存为该胶卷的平场配置。 - + ## 预设 连接 Scanlight 时显示。预设保存适合某类胶片的光源强度和相机曝光。 @@ -590,8 +590,10 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 - **在片基上校准…**:把矩形放在透明胶片上运行,NegPy 会求出光源强度和快门。 - **手动设置**:自己设置滑块,然后保存为预设。未连接相机时,不保存曝光,扫描使用相机自身的曝光。 - **红色**、**绿色**、**蓝色**、**白色**:光源强度(0 到 255)。**开灯**点亮光源以便取景,或将其关闭。 -- **采集模式**:**三色扫描**每种颜色各拍一次再合并;**单次曝光**三色同时点亮拍一次。 -- **创建传感器串扰配置**(单次曝光):同时为此预设保存传感器串扰配置(见校准,§10.4)。 +- **采集模式**:**三色扫描**每种颜色各拍一次再合并;**单次曝光**三色同时点亮拍一次。**混合**让相机在 B 门下保持打开,依次点亮红、绿、蓝,得到一个文件。三色扫描或混合预设可在两者之间切换。 +- **快门释放**(混合):保持快门打开的方式。**USB** 使用相机自身的 B 门控制。**快门线** 使用接在 Scanlight 3.5mm 插孔上的快门线,曝光最长 2.55 秒。 +- **传感器配置**(没有自带配置的混合预设):其扫描使用的传感器串扰配置。选好之前无法扫描。试管按钮可创建一个。 +- **创建传感器串扰配置**(单次曝光或混合):同时为此预设保存传感器串扰配置,之后的扫描会使用它(见校准,§10.4)。 - **ISO**、**快门**、**光圈**:预设的曝光。更改会保持到你再次选择该预设。仪表按钮用于测光。 - **IR 快门**和**红外**(带红外 LED 的光源):每次扫描追加一次红外曝光用于除尘,保存为 `_IR` 文件。需要能拍红外的相机(全光谱或改装)和手动对焦。仅限彩色胶片。 - **通道延时**:为需要的相机在红、绿、蓝曝光之间暂停。 diff --git a/negpy/translations/de.json b/negpy/translations/de.json index 8227071cf..7c21113cb 100644 --- a/negpy/translations/de.json +++ b/negpy/translations/de.json @@ -284,6 +284,9 @@ "Bleach up": "Bleichen erhöhen", "Bleach ↑/↓": "Bleichen ↑/↓", "Blend": "Mischen", + "Blend capture": "Mischen-Aufnahme", + "Blend lights red, green and blue in turn inside one Bulb exposure.": "Mischen lässt Rot, Grün und Blau nacheinander in einer Bulb-Belichtung leuchten.", + "Blend needs a shutter time. Set the camera's shutter to a time, not Bulb, then scan again.": "Mischen braucht eine Verschlusszeit. Stelle an der Kamera eine Verschlusszeit ein, nicht Bulb, und scanne dann erneut.", "Blue": "Blau", "Blue LED level": "Pegel der blauen LED", "Border Bottom Weight": "Randgewichtung unten", @@ -327,6 +330,7 @@ "CLONE · {num_clones}": "KLONEN · {num_clones}", "COLOR": "FARBE", "COLOR MANAGEMENT": "FARBMANAGEMENT", + "Cable": "Kabel", "Cached thumbnails": "Zwischengespeicherte Vorschaubilder", "Calibrate && Save": "Kalibrieren && Speichern", "Calibrate Sensor": "Sensor kalibrieren", @@ -374,6 +378,7 @@ "Captured frame {frame}. Inverting in NegPy…": "Bild {frame} aufgenommen. Invertierung in NegPy…", "Captured the {channels} exposure of frame {frame}": "Belichtung {channels} von Bild {frame} aufgenommen", "Captures a short and a long exposure and fuses them for more dynamic range, without stacking.": "Nimmt eine kurze und eine lange Belichtung auf und verschmilzt sie für mehr Dynamikumfang, ohne Stacking.", + "Capturing Blend exposure…": "Mischbelichtung wird aufgenommen…", "Capturing IR channel…": "IR-Kanal wird aufgenommen…", "Capturing R / G / B…": "R / G / B werden aufgenommen…", "Capturing in {seconds} s…": "Aufnahme in {seconds} s…", @@ -1062,6 +1067,7 @@ "Frame size": "Bildgröße", "Frame {frame} at {value:+.1f} mm: corrects this frame alone, on top of Offset and Drift.": "Bild {frame} bei {value:+.1f} mm: korrigiert nur dieses Bild, zusätzlich zu Versatz und Drift.", "Frame {frame} clipped on {share:.0%} of the picture. Lower the exposure, then click Retake.": "Bild {frame} ist auf {share:.0%} des Bildes beschnitten. Verringere die Belichtung und klicke dann auf Neu aufnehmen.", + "Frame {frame} failed after the film moved on. Move the film back one frame, then click Retake.": "Bild {frame} ist fehlgeschlagen, nachdem der Film weitergelaufen ist. Fahre den Film ein Bild zurück und klicke dann auf Neu aufnehmen.", "Frame {slot} failed. Continuing…": "Bild {slot} ist fehlgeschlagen. Es geht weiter…", "Frames": "Bilder", "Frames per Row": "Bilder pro Zeile", @@ -2289,6 +2295,7 @@ "Select Images": "Bilder wählen", "Select Output Folder": "Ausgabeordner wählen", "Select Ratio…": "Verhältnis wählen…", + "Select a profile": "Profil wählen", "Select an exposure to shoot again in Live View: R, G, B or IR": "Wähle eine Belichtung, die in der Live-Ansicht neu aufgenommen wird: R, G, B oder IR", "Select frames in the Film Strip, then right-click › Scene › Group as Scene…": "Wähle Bilder im Filmstreifen, dann Rechtsklick › Szene › Als Szene gruppieren…", "Select gear or enter process metadata to see a preview.": "Wähle Ausrüstung oder gib Prozess-Metadaten ein, um eine Vorschau zu sehen.", @@ -2311,7 +2318,9 @@ "Sensitizer": "Sensibilisator", "Sensor Correction": "Sensorkorrektur", "Sensor Crosstalk Profile": "Sensor-Farbvermischungsprofil", + "Sensor crosstalk profile for Blend scans: removes the color leak between the camera's channels.": "Sensor-Farbvermischungsprofil für Misch-Scans: entfernt die Farbvermischung zwischen den Kanälen der Kamera.", "Sensor crosstalk profile: removes the color leak between channels in a single exposure under narrowband light; run Roll Analysis again after a change.": "Sensor-Farbvermischungsprofil: entfernt die Farbvermischung zwischen den Kanälen bei einer Einzelbelichtung unter Schmalbandlicht. Führe nach einer Änderung die Rollenanalyse erneut aus.", + "Sensor profile": "Sensorprofil", "Separation Damping": "Trennungsdämpfung", "Separation Damping down": "Trennungsdämpfung verringern", "Separation Damping up": "Trennungsdämpfung erhöhen", @@ -2442,13 +2451,15 @@ "Shuffle the order on each run; the order set here is used only when this is off": "Reihenfolge bei jedem Durchlauf mischen. Die hier festgelegte Reihenfolge gilt nur, wenn dies aus ist", "Shutter": "Verschluss", "Shutter for the infrared exposure, set by hand and usually much slower than the visible exposure.": "Verschlusszeit für die Infrarot-Belichtung, manuell festgelegt und meist viel länger als bei der sichtbaren Belichtung.", + "Shutter release": "Auslösung", "Shutter speed": "Verschlusszeit", "Sidecar": "Sidecar-Datei", "Simulate Ink Black": "Tintenschwarz simulieren", "Simulate Paper White": "Papierweiß simulieren", "Single Exposure": "Einzelbelichtung", - "Single Exposure only: a Trichrome triplet reads each LED alone and has no sensor mixing to unmix.": "Nur Einzelbelichtung: Ein Trichrome-Triplett liest jede LED einzeln und hat keine Sensormischung, die zu entmischen wäre.", - "Single Exposure only: also save a sensor crosstalk profile for this preset, from the same exposures.": "Nur Einzelbelichtung: Speichert aus denselben Belichtungen auch ein Sensor-Farbvermischungsprofil für diese Vorgabe.", + "Single Exposure and Blend only: a Trichrome triplet reads each LED alone and has no sensor mixing to unmix.": "Nur Einzelbelichtung und Mischen: Ein Trichrome-Triplett liest jede LED einzeln und hat keine Sensormischung, die zu entmischen wäre.", + "Single Exposure and Blend only: also save a sensor crosstalk profile for this preset, from the same exposures.": "Nur Einzelbelichtung und Mischen: Speichert aus denselben Belichtungen auch ein Sensor-Farbvermischungsprofil für diese Vorgabe.", + "Single Exposure capture": "Einzelbelichtungs-Aufnahme", "Single exposure with red, green and blue lit together.": "Einzelbelichtung mit gleichzeitig leuchtendem Rot, Grün und Blau.", "Single white-light exposure.": "Einzelbelichtung mit Weißlicht.", "Single-Pass": "Einzeldurchgang", @@ -2798,7 +2809,7 @@ "Trichrome": "Trichrome", "Trichrome + IR, Color Negative": "Trichrome + IR, Farbnegativ", "Trichrome Mode": "Trichrome-Modus", - "Trichrome merges one exposure per LED, and Single Exposure takes one exposure with red, green and blue lit together.": "Trichrome fügt eine Belichtung pro LED zusammen. Einzelbelichtung nimmt eine Belichtung mit gemeinsam leuchtendem Rot, Grün und Blau auf.", + "Trichrome takes one exposure per LED, Single Exposure lights all three together, and Blend lights each LED in turn in one Bulb exposure.": "Trichrome nimmt eine Belichtung pro LED auf, Einzelbelichtung lässt alle drei gemeinsam leuchten, und Mischen lässt jede LED nacheinander in einer Bulb-Belichtung leuchten.", "Trichrome triplet": "Trichrome-Triplet", "Trichrome, Color Negative": "Trichrome, Farbnegativ", "Tritanopia (blue-yellow)": "Tritanopie (Blau-Gelb)", @@ -2827,6 +2838,7 @@ "Type a search first, e.g. film:portra": "Gib zuerst eine Suche ein, zum Beispiel film:portra", "Type a slider, card or action, or another editor's word for it: contrast, white balance.": "Gib einen Regler, eine Karte oder eine Aktion ein, oder den Begriff eines anderen Editors dafür: Kontrast, Weißabgleich.", "UI scale": "Skalierung der Oberfläche", + "USB": "USB", "USE AVERAGE": "MITTELWERT NUTZEN", "UV exposure time: more makes the print bluer, and less makes it paler.": "UV-Belichtungszeit: Mehr macht den Abzug blauer, weniger macht ihn blasser.", "Unavailable: {reason}": "Nicht verfügbar: {reason}", @@ -2925,6 +2937,7 @@ "WebP Method": "WebP-Methode", "WebP Quality": "WebP-Qualität", "Welcome to NegPy": "Willkommen bei NegPy", + "What holds the shutter open in Bulb: the camera's USB cable, or a release cable on the Scanlight.": "Was den Verschluss bei Bulb offen hält: das USB-Kabel der Kamera oder ein Fernauslöserkabel am Scanlight.", "What is on the film, because silver and Kodachrome block infrared": "Was auf dem Film ist, denn Silber und Kodachrome blockieren Infrarot", "What the Lento Dock does with the film when you click Scan.": "Was das Lento Dock mit dem Film macht, wenn du auf Scan klickst.", "What the canvas shows: the print, a before/after split, or a peek.": "Was die Arbeitsfläche zeigt: den Abzug, eine Vorher-nachher-Teilung oder einen Blick auf das Original.", @@ -3086,6 +3099,7 @@ "open the aperture (e.g. f/5.6) or raise the ISO, then recalibrate": "Blende öffnen (z. B. f/5.6) oder ISO erhöhen, dann neu kalibrieren", "over-exposed": "überbelichtet", "photo": "Foto|Fotos", + "pick or make a sensor profile for Blend": "wähle oder erstelle ein Sensorprofil für Mischen", "positive": "Positiv", "preset": "Vorgabe|Vorgaben", "preview res": "Vorschauauflösung", diff --git a/negpy/translations/ja.json b/negpy/translations/ja.json index 68a1a6ae3..1534e0306 100644 --- a/negpy/translations/ja.json +++ b/negpy/translations/ja.json @@ -284,6 +284,9 @@ "Bleach up": "漂白アップ", "Bleach ↑/↓": "ブリーチ ↑/↓", "Blend": "ブレンド", + "Blend capture": "ブレンド撮影", + "Blend lights red, green and blue in turn inside one Bulb exposure.": "ブレンドは 1 回のバルブ露光の中で赤・緑・青を順に点灯します。", + "Blend needs a shutter time. Set the camera's shutter to a time, not Bulb, then scan again.": "ブレンドにはシャッター速度が必要です。カメラのシャッターをバルブ以外の速度に設定してから、もう一度スキャンしてください。", "Blue": "青", "Blue LED level": "青色LEDレベル", "Border Bottom Weight": "余白のボトムウェイト", @@ -327,6 +330,7 @@ "CLONE · {num_clones}": "クローン · {num_clones}", "COLOR": "カラー", "COLOR MANAGEMENT": "カラーマネジメント", + "Cable": "ケーブル", "Cached thumbnails": "キャッシュされたサムネイル", "Calibrate && Save": "調整して保存", "Calibrate Sensor": "センサーの校正", @@ -374,6 +378,7 @@ "Captured frame {frame}. Inverting in NegPy…": "キャプチャされたフレーム {frame} — NegPy で反転しています…", "Captured the {channels} exposure of frame {frame}": "フレーム {frame} の {channels} 露出を撮影しました", "Captures a short and a long exposure and fuses them for more dynamic range, without stacking.": "短い露出と長い露出を撮影して合成し、スタッキングなしでダイナミックレンジを広げます。", + "Capturing Blend exposure…": "ブレンド露光をキャプチャ中…", "Capturing IR channel…": "IR チャンネルを撮影中…", "Capturing R / G / B…": "R/G/Bをキャプチャ中…", "Capturing in {seconds} s…": "{seconds} s 後に撮影…", @@ -1062,6 +1067,7 @@ "Frame size": "フレームサイズ", "Frame {frame} at {value:+.1f} mm: corrects this frame alone, on top of Offset and Drift.": "フレーム {frame} ({value:+.1f} mm): オフセットとドリフトに加えて、このフレームだけを補正します。", "Frame {frame} clipped on {share:.0%} of the picture. Lower the exposure, then click Retake.": "フレーム {frame} は画面の {share:.0%} がクリッピングしています。露出を下げてから「リテイク」をクリックしてください。", + "Frame {frame} failed after the film moved on. Move the film back one frame, then click Retake.": "フィルムが進んだ後にフレーム {frame} が失敗しました。フィルムを 1 コマ戻してから、リテイクをクリックしてください。", "Frame {slot} failed. Continuing…": "フレーム {slot} が失敗しました — 継続中…", "Frames": "フレーム", "Frames per Row": "1 行のフレーム数", @@ -2289,6 +2295,7 @@ "Select Images": "画像の選択", "Select Output Folder": "出力フォルダーの選択", "Select Ratio…": "比率を選択…", + "Select a profile": "プロファイルを選択", "Select an exposure to shoot again in Live View: R, G, B or IR": "ライブビューで撮り直す露出を選択してください:R、G、B または IR", "Select frames in the Film Strip, then right-click › Scene › Group as Scene…": "フィルム ストリップでフレームを選択し、右クリック › シーン › シーンとしてグループ化…", "Select gear or enter process metadata to see a preview.": "機材を選ぶか現像のメタデータを入力すると、プレビューを表示します。", @@ -2311,7 +2318,9 @@ "Sensitizer": "感光剤", "Sensor Correction": "センサー補正", "Sensor Crosstalk Profile": "センサークロストークプロファイル", + "Sensor crosstalk profile for Blend scans: removes the color leak between the camera's channels.": "ブレンドスキャン用のセンサークロストークプロファイル:カメラのチャンネル間の色漏れを取り除きます。", "Sensor crosstalk profile: removes the color leak between channels in a single exposure under narrowband light; run Roll Analysis again after a change.": "センサークロストークプロファイル: 狭帯域光下の単一露光で、チャンネル間の色漏れを取り除きます。変更後はロール解析をやり直してください。", + "Sensor profile": "センサープロファイル", "Separation Damping": "分離減衰", "Separation Damping down": "分離 ダンピングダウン", "Separation Damping up": "分離ダンピングアップ", @@ -2442,13 +2451,15 @@ "Shuffle the order on each run; the order set here is used only when this is off": "実行ごとに順序をシャッフルします。ここで設定した順序は、これがオフの場合にのみ使用されます", "Shutter": "シャッター", "Shutter for the infrared exposure, set by hand and usually much slower than the visible exposure.": "赤外線露光のシャッター。手動で設定し、ふつうは可視光の露光よりかなり遅くなります。", + "Shutter release": "シャッターレリーズ", "Shutter speed": "シャッタースピード", "Sidecar": "サイドカー", "Simulate Ink Black": "インクブラックをシミュレート", "Simulate Paper White": "ペーパーホワイトをシミュレート", "Single Exposure": "単一露光", - "Single Exposure only: a Trichrome triplet reads each LED alone and has no sensor mixing to unmix.": "単一露光のみ:トリクロームのトリプレットは各 LED を単独で読むため、分離すべきセンサーの混色がありません。", - "Single Exposure only: also save a sensor crosstalk profile for this preset, from the same exposures.": "単一露光のみ: 同じ露光から、このプリセット用のセンサークロストークプロファイルも保存します。", + "Single Exposure and Blend only: a Trichrome triplet reads each LED alone and has no sensor mixing to unmix.": "単一露光とブレンドのみ:トリクロームのトリプレットは各 LED を単独で読むため、分離すべきセンサーの混色がありません。", + "Single Exposure and Blend only: also save a sensor crosstalk profile for this preset, from the same exposures.": "単一露光とブレンドのみ: 同じ露光から、このプリセット用のセンサークロストークプロファイルも保存します。", + "Single Exposure capture": "単一露光撮影", "Single exposure with red, green and blue lit together.": "赤、緑、青が同時に点灯する単一露光。", "Single white-light exposure.": "白色光露出 1 回。", "Single-Pass": "シングルパス", @@ -2798,7 +2809,7 @@ "Trichrome": "トリクローム", "Trichrome + IR, Color Negative": "Trichrome + IR、カラーネガ", "Trichrome Mode": "トリクロームモード", - "Trichrome merges one exposure per LED, and Single Exposure takes one exposure with red, green and blue lit together.": "トリクロームは LED ごとに 1 回ずつの露光を合成し、単一露光は赤・緑・青を同時に点灯して 1 回撮影します。", + "Trichrome takes one exposure per LED, Single Exposure lights all three together, and Blend lights each LED in turn in one Bulb exposure.": "トリクロームは LED ごとに 1 回ずつ露光し、単一露光は 3 色を同時に点灯し、ブレンドは 1 回のバルブ露光の中で各 LED を順に点灯します。", "Trichrome triplet": "トリクロームトリプレット", "Trichrome, Color Negative": "Trichrome、カラーネガ", "Tritanopia (blue-yellow)": "トリタノピア(青黄)", @@ -2827,6 +2838,7 @@ "Type a search first, e.g. film:portra": "先に検索条件を入力してください。例:film:portra", "Type a slider, card or action, or another editor's word for it: contrast, white balance.": "スライダー、カード、操作、または他の編集ソフトでの呼び名(コントラスト、ホワイトバランスなど)を入力します。", "UI scale": "UIスケール", + "USB": "USB", "USE AVERAGE": "平均を使用", "UV exposure time: more makes the print bluer, and less makes it paler.": "UV 露光時間: 多いほど青く、少ないほど淡くなります。", "Unavailable: {reason}": "利用不可: {reason}", @@ -2925,6 +2937,7 @@ "WebP Method": "WebP メソッド", "WebP Quality": "WebP の品質", "Welcome to NegPy": "NegPy へようこそ", + "What holds the shutter open in Bulb: the camera's USB cable, or a release cable on the Scanlight.": "バルブでシャッターを開けておく方法:カメラの USB ケーブル、または Scanlight につないだレリーズケーブル。", "What is on the film, because silver and Kodachrome block infrared": "フィルム上の像の種類 (銀とコダクロームは赤外線を遮るため)", "What the Lento Dock does with the film when you click Scan.": "「スキャン」をクリックしたときに Lento Dock がフィルムをどう動かすかを選びます。", "What the canvas shows: the print, a before/after split, or a peek.": "キャンバスに表示するもの:プリント、ビフォー/アフター、またはのぞき見。", @@ -3086,6 +3099,7 @@ "open the aperture (e.g. f/5.6) or raise the ISO, then recalibrate": "絞りを開けるか(f/5.6 など)、ISO を上げてから再調整してください。", "over-exposed": "露出オーバー", "photo": "枚", + "pick or make a sensor profile for Blend": "ブレンド用のセンサープロファイルを選択または作成する", "positive": "ポジ", "preset": "プリセット", "preview res": "プレビュー解像度", diff --git a/negpy/translations/ko.json b/negpy/translations/ko.json index 7a4422a57..9f3c559d3 100644 --- a/negpy/translations/ko.json +++ b/negpy/translations/ko.json @@ -284,6 +284,9 @@ "Bleach up": "표백제 증가", "Bleach ↑/↓": "표백제 ↑/↓", "Blend": "블렌드", + "Blend capture": "블렌드 촬영", + "Blend lights red, green and blue in turn inside one Bulb exposure.": "블렌드는 한 번의 벌브 노출 안에서 빨강, 초록, 파랑을 차례로 켭니다.", + "Blend needs a shutter time. Set the camera's shutter to a time, not Bulb, then scan again.": "블렌드에는 셔터 시간이 필요합니다. 카메라 셔터를 벌브가 아닌 시간으로 맞춘 다음 다시 스캔하세요.", "Blue": "파랑", "Blue LED level": "파란색 LED 레벨", "Border Bottom Weight": "테두리 하단 무게", @@ -327,6 +330,7 @@ "CLONE · {num_clones}": "클론 · {num_clones}", "COLOR": "색", "COLOR MANAGEMENT": "색 관리", + "Cable": "케이블", "Cached thumbnails": "캐시된 썸네일", "Calibrate && Save": "교정 및 저장", "Calibrate Sensor": "센서 교정", @@ -374,6 +378,7 @@ "Captured frame {frame}. Inverting in NegPy…": "캡처된 프레임 {frame} — NegPy에서 반전 중…", "Captured the {channels} exposure of frame {frame}": "프레임 {frame}의 {channels} 노출을 촬영했습니다", "Captures a short and a long exposure and fuses them for more dynamic range, without stacking.": "짧은 노출과 긴 노출을 촬영하고 합쳐, 스태킹 없이 다이내믹 레인지를 넓힙니다.", + "Capturing Blend exposure…": "블렌드 노출 캡처 중…", "Capturing IR channel…": "IR 채널 촬영 중…", "Capturing R / G / B…": "R/G/B 캡처…", "Capturing in {seconds} s…": "{seconds} s 후 촬영…", @@ -1062,6 +1067,7 @@ "Frame size": "프레임 크기", "Frame {frame} at {value:+.1f} mm: corrects this frame alone, on top of Offset and Drift.": "프레임 {frame}, {value:+.1f} mm: 오프셋과 드리프트 위에 이 프레임만 보정합니다.", "Frame {frame} clipped on {share:.0%} of the picture. Lower the exposure, then click Retake.": "프레임 {frame}의 화면 중 {share:.0%}가 클리핑되었습니다. 노출을 낮춘 다음 재촬영을 클릭하세요.", + "Frame {frame} failed after the film moved on. Move the film back one frame, then click Retake.": "필름이 넘어간 뒤 프레임 {frame}이(가) 실패했습니다. 필름을 한 프레임 뒤로 돌린 다음 재촬영을 클릭하세요.", "Frame {slot} failed. Continuing…": "프레임 {slot} 실패 — 계속…", "Frames": "프레임", "Frames per Row": "한 줄 프레임 수", @@ -2289,6 +2295,7 @@ "Select Images": "이미지 선택", "Select Output Folder": "출력 폴더 선택", "Select Ratio…": "비율 선택…", + "Select a profile": "프로필 선택", "Select an exposure to shoot again in Live View: R, G, B or IR": "라이브 뷰에서 다시 찍을 노출을 선택하세요: R, G, B 또는 IR", "Select frames in the Film Strip, then right-click › Scene › Group as Scene…": "필름 스트립에서 프레임을 선택한 다음 오른쪽 클릭 › 장면 › 장면으로 그룹화…를 고르세요.", "Select gear or enter process metadata to see a preview.": "미리보기를 보려면 장비를 고르거나 프로세스 메타데이터를 입력하세요.", @@ -2311,7 +2318,9 @@ "Sensitizer": "감광제", "Sensor Correction": "센서 교정", "Sensor Crosstalk Profile": "센서 누화 프로필", + "Sensor crosstalk profile for Blend scans: removes the color leak between the camera's channels.": "블렌드 스캔용 센서 누화 프로필: 카메라 채널 사이의 색 번짐을 없앱니다.", "Sensor crosstalk profile: removes the color leak between channels in a single exposure under narrowband light; run Roll Analysis again after a change.": "센서 누화 프로필: 협대역 광원 아래 단일 노출에서 채널 간 색 누출을 없앱니다. 바꾼 뒤에는 롤 분석을 다시 실행하세요.", + "Sensor profile": "센서 프로필", "Separation Damping": "분리 감쇠", "Separation Damping down": "분리 감쇠 감소", "Separation Damping up": "분리 감쇠 증가", @@ -2442,13 +2451,15 @@ "Shuffle the order on each run; the order set here is used only when this is off": "각 실행마다 순서를 섞습니다. 여기에 설정된 순서는 이 기능이 꺼져 있을 때만 사용됩니다.", "Shutter": "셔터", "Shutter for the infrared exposure, set by hand and usually much slower than the visible exposure.": "적외선 노출의 셔터로, 직접 정하며 보통 가시광 노출보다 훨씬 느립니다.", + "Shutter release": "셔터 릴리즈", "Shutter speed": "셔터 속도", "Sidecar": "사이드카", "Simulate Ink Black": "잉크 검정 시뮬레이션", "Simulate Paper White": "종이 흰색 시뮬레이션", "Single Exposure": "단일 노출", - "Single Exposure only: a Trichrome triplet reads each LED alone and has no sensor mixing to unmix.": "단일 노출 전용: 삼색 트리플릿은 각 LED를 따로 읽으므로 분리할 센서 혼합이 없습니다.", - "Single Exposure only: also save a sensor crosstalk profile for this preset, from the same exposures.": "단일 노출 전용: 같은 노출로 이 사전 설정용 센서 누화 프로필도 저장합니다.", + "Single Exposure and Blend only: a Trichrome triplet reads each LED alone and has no sensor mixing to unmix.": "단일 노출과 블렌드 전용: 삼색 트리플릿은 각 LED를 따로 읽으므로 분리할 센서 혼합이 없습니다.", + "Single Exposure and Blend only: also save a sensor crosstalk profile for this preset, from the same exposures.": "단일 노출과 블렌드 전용: 같은 노출로 이 사전 설정용 센서 누화 프로필도 저장합니다.", + "Single Exposure capture": "단일 노출 촬영", "Single exposure with red, green and blue lit together.": "빨간색, 녹색, 파란색이 함께 켜진 단일 노출입니다.", "Single white-light exposure.": "백색광 노출 한 번.", "Single-Pass": "단일 패스", @@ -2798,7 +2809,7 @@ "Trichrome": "삼색", "Trichrome + IR, Color Negative": "삼색 + IR, 컬러 네거티브", "Trichrome Mode": "삼색 모드", - "Trichrome merges one exposure per LED, and Single Exposure takes one exposure with red, green and blue lit together.": "삼색은 LED마다 한 번씩 찍은 노출을 합치고, 단일 노출은 빨강, 초록, 파랑을 함께 켜고 한 번 노출합니다.", + "Trichrome takes one exposure per LED, Single Exposure lights all three together, and Blend lights each LED in turn in one Bulb exposure.": "삼색은 LED마다 한 번씩 노출하고, 단일 노출은 세 색을 함께 켜며, 블렌드는 한 번의 벌브 노출 안에서 각 LED를 차례로 켭니다.", "Trichrome triplet": "삼색 트리플릿", "Trichrome, Color Negative": "삼색, 컬러 네거티브", "Tritanopia (blue-yellow)": "제3색맹(청황)", @@ -2827,6 +2838,7 @@ "Type a search first, e.g. film:portra": "먼저 검색 조건을 입력하세요. 예: film:portra", "Type a slider, card or action, or another editor's word for it: contrast, white balance.": "슬라이더, 카드, 동작 이름이나 다른 편집 앱에서 쓰는 말(대비, 화이트 밸런스 등)을 입력하세요.", "UI scale": "UI 배율", + "USB": "USB", "USE AVERAGE": "평균 사용", "UV exposure time: more makes the print bluer, and less makes it paler.": "UV 노출 시간: 많을수록 더 파랗고, 적을수록 더 옅어집니다.", "Unavailable: {reason}": "사용할 수 없음: {reason}", @@ -2925,6 +2937,7 @@ "WebP Method": "WebP 방법", "WebP Quality": "WebP 품질", "Welcome to NegPy": "NegPy에 오신 것을 환영합니다", + "What holds the shutter open in Bulb: the camera's USB cable, or a release cable on the Scanlight.": "벌브에서 셔터를 열어 두는 방법: 카메라의 USB 케이블 또는 Scanlight에 연결한 릴리즈 케이블.", "What is on the film, because silver and Kodachrome block infrared": "필름의 종류로, 은과 코다크롬은 적외선을 막습니다", "What the Lento Dock does with the film when you click Scan.": "스캔을 클릭할 때 Lento Dock이 필름을 어떻게 처리할지 정합니다.", "What the canvas shows: the print, a before/after split, or a peek.": "캔버스에 보여줄 것: 인화, 전/후 분할, 또는 미리보기입니다.", @@ -3086,6 +3099,7 @@ "open the aperture (e.g. f/5.6) or raise the ISO, then recalibrate": "조리개(예: f/5.6)를 열거나 ISO를 높인 다음 다시 보정하세요.", "over-exposed": "과다 노출", "photo": "장", + "pick or make a sensor profile for Blend": "블렌드용 센서 프로필 선택 또는 만들기", "positive": "포지티브", "preset": "사전 설정", "preview res": "미리보기 해상도", diff --git a/negpy/translations/zh_CN.json b/negpy/translations/zh_CN.json index e4de0558d..31be73fb4 100644 --- a/negpy/translations/zh_CN.json +++ b/negpy/translations/zh_CN.json @@ -284,6 +284,9 @@ "Bleach up": "增大漂白", "Bleach ↑/↓": "漂白 ↑/↓", "Blend": "混合", + "Blend capture": "混合拍摄", + "Blend lights red, green and blue in turn inside one Bulb exposure.": "混合在一次 B 门曝光内依次点亮红、绿、蓝。", + "Blend needs a shutter time. Set the camera's shutter to a time, not Bulb, then scan again.": "混合需要快门时间。请把相机快门设为具体时间而非 B 门,然后重新扫描。", "Blue": "蓝色", "Blue LED level": "蓝色 LED 强度", "Border Bottom Weight": "边框底边加宽", @@ -327,6 +330,7 @@ "CLONE · {num_clones}": "仿制 · {num_clones}", "COLOR": "色彩", "COLOR MANAGEMENT": "色彩管理", + "Cable": "快门线", "Cached thumbnails": "缩略图缓存", "Calibrate && Save": "校准并保存", "Calibrate Sensor": "校准传感器", @@ -374,6 +378,7 @@ "Captured frame {frame}. Inverting in NegPy…": "已拍摄第 {frame} 帧,正在 NegPy 中反转…", "Captured the {channels} exposure of frame {frame}": "已拍摄第 {frame} 帧的 {channels} 曝光", "Captures a short and a long exposure and fuses them for more dynamic range, without stacking.": "拍摄一次短曝光和一次长曝光,并将其融合以扩展动态范围,不进行叠加。", + "Capturing Blend exposure…": "正在拍摄混合曝光…", "Capturing IR channel…": "正在拍摄 IR 通道…", "Capturing R / G / B…": "正在拍摄 R / G / B…", "Capturing in {seconds} s…": "将在 {seconds} s 后拍摄…", @@ -1062,6 +1067,7 @@ "Frame size": "画面尺寸", "Frame {frame} at {value:+.1f} mm: corrects this frame alone, on top of Offset and Drift.": "第 {frame} 帧位于 {value:+.1f} mm:在偏移和漂移基础上,仅校正此帧。", "Frame {frame} clipped on {share:.0%} of the picture. Lower the exposure, then click Retake.": "第 {frame} 帧画面有 {share:.0%} 截断。请降低曝光,然后点击“重拍”。", + "Frame {frame} failed after the film moved on. Move the film back one frame, then click Retake.": "胶片前进后第 {frame} 帧失败。请把胶片退回一帧,然后点击重拍。", "Frame {slot} failed. Continuing…": "第 {slot} 帧失败,继续处理…", "Frames": "画面", "Frames per Row": "每行帧数", @@ -2289,6 +2295,7 @@ "Select Images": "选择图像", "Select Output Folder": "选择输出文件夹", "Select Ratio…": "选择比例…", + "Select a profile": "选择配置", "Select an exposure to shoot again in Live View: R, G, B or IR": "请在实时取景中选择要重新拍摄的曝光:R、G、B 或 IR", "Select frames in the Film Strip, then right-click › Scene › Group as Scene…": "在胶片条中选择画面,然后右键 › 场景 › 分组为场景…", "Select gear or enter process metadata to see a preview.": "选择器材或输入工艺元数据以查看预览。", @@ -2311,7 +2318,9 @@ "Sensitizer": "感光剂", "Sensor Correction": "传感器校正", "Sensor Crosstalk Profile": "传感器串扰配置", + "Sensor crosstalk profile for Blend scans: removes the color leak between the camera's channels.": "混合扫描的传感器串扰配置:去除相机各通道之间的串色。", "Sensor crosstalk profile: removes the color leak between channels in a single exposure under narrowband light; run Roll Analysis again after a change.": "传感器串扰配置:去除窄带光下单次曝光中通道之间的串色;更改后请重新运行胶卷分析。", + "Sensor profile": "传感器配置", "Separation Damping": "分离抑制", "Separation Damping down": "减小分离抑制", "Separation Damping up": "增大分离抑制", @@ -2442,13 +2451,15 @@ "Shuffle the order on each run; the order set here is used only when this is off": "每次运行随机排列顺序,关闭时使用此处顺序", "Shutter": "快门", "Shutter for the infrared exposure, set by hand and usually much slower than the visible exposure.": "红外曝光的快门,需手动设置,通常比可见光曝光慢得多。", + "Shutter release": "快门释放", "Shutter speed": "快门速度", "Sidecar": "附属文件", "Simulate Ink Black": "模拟墨黑", "Simulate Paper White": "模拟纸白", "Single Exposure": "单次曝光", - "Single Exposure only: a Trichrome triplet reads each LED alone and has no sensor mixing to unmix.": "仅限单次曝光:三色扫描的三色组逐个读取每个 LED,没有需要解混的传感器串扰。", - "Single Exposure only: also save a sensor crosstalk profile for this preset, from the same exposures.": "仅限单次曝光:同时用相同的曝光为此预设保存传感器串扰配置。", + "Single Exposure and Blend only: a Trichrome triplet reads each LED alone and has no sensor mixing to unmix.": "仅限单次曝光和混合:三色扫描的三色组逐个读取每个 LED,没有需要解混的传感器串扰。", + "Single Exposure and Blend only: also save a sensor crosstalk profile for this preset, from the same exposures.": "仅限单次曝光和混合:同时用相同的曝光为此预设保存传感器串扰配置。", + "Single Exposure capture": "单次曝光拍摄", "Single exposure with red, green and blue lit together.": "红、绿、蓝同时点亮的单次曝光。", "Single white-light exposure.": "单次白光曝光。", "Single-Pass": "单次扫描", @@ -2798,7 +2809,7 @@ "Trichrome": "三色扫描", "Trichrome + IR, Color Negative": "三色扫描 + IR,彩色负片", "Trichrome Mode": "三色扫描模式", - "Trichrome merges one exposure per LED, and Single Exposure takes one exposure with red, green and blue lit together.": "三色扫描逐个 LED 曝光后合并,单次曝光同时点亮红绿蓝并曝光一次。", + "Trichrome takes one exposure per LED, Single Exposure lights all three together, and Blend lights each LED in turn in one Bulb exposure.": "三色扫描逐个 LED 曝光,单次曝光同时点亮三色,混合在一次 B 门曝光内依次点亮各个 LED。", "Trichrome triplet": "三色组", "Trichrome, Color Negative": "三色扫描,彩色负片", "Tritanopia (blue-yellow)": "蓝黄色盲", @@ -2827,6 +2838,7 @@ "Type a search first, e.g. film:portra": "请先输入搜索内容,例如 film:portra", "Type a slider, card or action, or another editor's word for it: contrast, white balance.": "输入滑块、卡片或操作名称,也可使用反差、白平衡等常用词。", "UI scale": "界面缩放", + "USB": "USB", "USE AVERAGE": "使用平均值", "UV exposure time: more makes the print bluer, and less makes it paler.": "紫外曝光时间:越多越蓝,越少越浅。", "Unavailable: {reason}": "不可用:{reason}", @@ -2925,6 +2937,7 @@ "WebP Method": "WebP 编码方法", "WebP Quality": "WebP 质量", "Welcome to NegPy": "欢迎使用 NegPy", + "What holds the shutter open in Bulb: the camera's USB cable, or a release cable on the Scanlight.": "B 门下保持快门打开的方式:相机的 USB 线,或接在 Scanlight 上的快门线。", "What is on the film, because silver and Kodachrome block infrared": "胶片上的记录类型,因为银影像和 Kodachrome 会阻挡红外", "What the Lento Dock does with the film when you click Scan.": "点击“扫描”时 Lento Dock 如何处理胶片。", "What the canvas shows: the print, a before/after split, or a peek.": "画布显示的内容:照片、前后对比或快速查看。", @@ -3086,6 +3099,7 @@ "open the aperture (e.g. f/5.6) or raise the ISO, then recalibrate": "开大光圈(例如 f/5.6)或提高 ISO,再重新校准", "over-exposed": "过曝", "photo": "张照片", + "pick or make a sensor profile for Blend": "为混合选择或创建传感器配置", "positive": "正片", "preset": "个预设", "preview res": "预览分辨率", diff --git a/scripts/bulb_lead_probe.py b/scripts/bulb_lead_probe.py new file mode 100644 index 000000000..a8fea1353 --- /dev/null +++ b/scripts/bulb_lead_probe.py @@ -0,0 +1,133 @@ +"""Measure how soon the camera's shutter is open after NegPy presses Bulb over USB. + +Shoots red-only Blend exposures with a shorter dark lead each time and compares each red level with the +longest lead's. A lead shorter than the shutter's lag cuts the start of the red light, and the level drops. +Close NegPy first: the camera and the light take one program at a time. Leave the film still. + + uv run python scripts/bulb_lead_probe.py +""" + +from __future__ import annotations + +import argparse +import os +import statistics +import tempfile +import time + +import numpy as np + +import negpy.services.capture.service as service +from negpy.infrastructure.capture.base import CaptureSettings +from negpy.infrastructure.capture.gphoto import GphotoCamera +from negpy.infrastructure.capture.lights import open_light +from negpy.infrastructure.capture.raw_demosaic import linear_demosaic + +LEADS = (0.3, 0.2, 0.15, 0.1, 0.075, 0.05, 0.025, 0.0) +SECONDS = 0.2 # the red time: short, so a few ms of lag show as a clear drop +TARGET = 0.4 # red level as a share of full scale, clear of clipping and noise +TOLERANCE = 0.01 # a 1 % drop at 0.2 s is 2 ms of lost light +MARGIN_S = 0.05 + + +def _red(path: str) -> float: + img = linear_demosaic(path, half_size=True) + h, w = img.shape[:2] + return float(img[h // 4 : 3 * h // 4, w // 4 : 3 * w // 4, 0].mean()) / 65535.0 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--repeats", type=int, default=3, help="exposures per lead") + parser.add_argument("--level", type=int, default=120, help="the first red level; the probe then aims at 40 %% of full scale") + args = parser.parse_args() + + camera = GphotoCamera() + camera.open() + light = open_light() + # Time every light command, sent and returned, so a varying light time (the light's reply + # latency) shows apart from the shutter's lag. + calls: list[tuple[str, float, float]] = [] + for name in ("set_color", "set_color_now", "off"): + original = getattr(light, name, None) + if original is None: + continue + + def _timed(*a, _original=original, _name=name, **k): + start = time.perf_counter() + try: + return _original(*a, **k) + finally: + lit = _name != "off" and (any(a) or any(k.values())) + calls.append(("on" if lit else "off", start, time.perf_counter())) + + setattr(light, name, _timed) + on_times: list[float] = [] + on_latency: list[float] = [] + off_latency: list[float] = [] + folder = tempfile.mkdtemp(prefix="negpy-bulb-lead-") + capture = service.CaptureService(light, camera) + count = 0 + + def shoot(lead: float, level: int) -> float: + nonlocal count + count += 1 + service.BULB_LEAD_S = lead + settings = CaptureSettings(roll_name="probe", frame_number=count, output_folder=folder, levels=(level, 0, 0)) + del calls[:] + path = capture.capture_blend(settings, seconds=SECONDS, keep_bulb=True) + ons = [c for c in calls if c[0] == "on"] + if ons: + on = ons[0] + off = next((c for c in calls if c[0] == "off" and c[1] >= on[2]), None) + on_latency.append(on[2] - on[1]) + if off is not None: + off_latency.append(off[2] - off[1]) + # The light is lit from somewhere inside the on command to somewhere inside the off command. + on_times.append(off[1] - on[1]) + try: + return _red(path) + finally: + os.remove(path) + + try: + first = shoot(LEADS[0], args.level) + if first < 0.005: + raise SystemExit("The red light reads almost nothing. Open the aperture or raise the ISO, then run the probe again.") + level = int(np.clip(round(args.level * TARGET / first), 10, 255)) + print(f"Red at level {level} for {SECONDS} s, {args.repeats} exposures per lead.\n") + results = {lead: [shoot(lead, level) for _ in range(args.repeats)] for lead in LEADS} + base = statistics.mean(results[LEADS[0]]) + if base > 0.9: + print("Warning: the red level is near clipping, so a drop can hide. Close the aperture one stop and run again.\n") + print(" lead (s) red / longest lead") + safe = None + for lead in LEADS: + ratios = [value / base for value in results[lead]] + passed = min(ratios) >= 1.0 - TOLERANCE + print(f" {lead:5.3f} " + " ".join(f"{r:6.3f}" for r in ratios) + ("" if passed else " <- light lost")) + if passed and (safe is None or safe == LEADS[LEADS.index(lead) - 1]): + safe = lead + if on_times: + + def ms(xs: list[float]) -> str: + return f"min {min(xs) * 1000:6.1f} mean {statistics.mean(xs) * 1000:6.1f} max {max(xs) * 1000:6.1f} ms" + + print(f"\nLight command reply time, on: {ms(on_latency)}") + print(f"Light command reply time, off: {ms(off_latency)}") + print(f"Red lit, on sent to off sent: {ms(on_times)} (asked for {SECONDS * 1000:.0f} ms)") + if safe is None: + print("\nEven the longest lead varies by more than 1 %. Check that the film and the light are still, then run again.") + else: + print(f"\nThe shutter is open by a lead of {safe:.3f} s. With a margin, use a lead of {safe + MARGIN_S:.3f} s.") + finally: + try: + camera.release_bulb() + light.off() + finally: + light.close() + camera.close() + + +if __name__ == "__main__": + main() diff --git a/tests/test_capture_calibration.py b/tests/test_capture_calibration.py index 6b60456e0..881e28eef 100644 --- a/tests/test_capture_calibration.py +++ b/tests/test_capture_calibration.py @@ -708,3 +708,15 @@ def test_shutter_display_of_one_label(label, shown): from negpy.services.capture.calibration import shutter_display assert shutter_display(label) == shown + + +def test_a_sensor_measuring_triplet_run_solves_as_trichrome_and_measures_the_unmix(): + plain = _calibrate(_service(FakeLight(), FakeCamera(), crosstalk=0.15)) + light, cam = FakeLight(), FakeCamera() + result = _service(light, cam, crosstalk=0.15).calibrate(Roi(0, 0, 1, 1), "/tmp/_negpy_cal.raw", measure_sensor=True) + assert (result.levels, result.shutters, result.single_capture) == (plain.levels, plain.shutters, False) + levels = np.array([120.0, 60.0, 30.0]) + k = np.array([K[c] for c in "RGB"]) + mixed = k * (levels + 0.15 * (levels.sum() - levels)) # a Blend frame: each LED alone, added up on the sensor + unmixed = np.array(result.sensor_matrix).reshape(3, 3) @ mixed + assert unmixed == pytest.approx(k * levels, rel=1e-6) diff --git a/tests/test_capture_controller.py b/tests/test_capture_controller.py index 78617e9c0..d68172a74 100644 --- a/tests/test_capture_controller.py +++ b/tests/test_capture_controller.py @@ -11,6 +11,8 @@ """ import os + +import pytest from dataclasses import replace from types import MethodType, SimpleNamespace from unittest.mock import MagicMock @@ -272,3 +274,17 @@ def test_an_infrared_exposure_rides_beside_the_triplet(tmp_path): controller.request_asset_discovery.assert_called_once_with(paths, restore_triplets={paths[0]: [paths[1], paths[2]]}) assert controller._pending_scanned_file == paths[0] assert os.path.normcase(os.path.abspath(paths[0])) in controller._pending_capture_imports + + +@pytest.mark.parametrize( + "req_kw,mode", + [({"blend_capture": True}, "blend"), ({"single_capture": True}, "single"), ({}, None)], +) +def test_a_blend_or_single_capture_records_its_mode_for_the_film_strip(monkeypatch, req_kw, mode): + import negpy.desktop.controller as controller_module + + recorded = [] + monkeypatch.setattr(controller_module, "record_capture_mode", lambda _repo, path, m: recorded.append((path, m))) + paths = ["frame.ARW"] if mode else ["r.ARW", "g.ARW", "b.ARW"] + _run(paths, rgb_mode=True, white_mode=False, **req_kw) + assert recorded == ([("frame.ARW", mode)] if mode else []) diff --git a/tests/test_capture_gphoto.py b/tests/test_capture_gphoto.py index ffadf388e..ee42337c7 100644 --- a/tests/test_capture_gphoto.py +++ b/tests/test_capture_gphoto.py @@ -153,6 +153,8 @@ def capture_preview(self): def wait_for_event(self, ms): self._fake.event_waits.append(ms) + if self._fake.events: + return self._fake.events.pop(0) if self._fake.undrained: self._fake.undrained = False if self._fake.late_events: @@ -189,7 +191,7 @@ def __init__(self, model, operations): class FakeGP: GP_WIDGET_RADIO, GP_WIDGET_MENU, GP_WIDGET_TEXT = 5, 6, 2 GP_CAPTURE_IMAGE, GP_FILE_TYPE_NORMAL = 0, 1 - GP_EVENT_TIMEOUT, GP_EVENT_CAPTURE_COMPLETE = 0, 3 + GP_EVENT_TIMEOUT, GP_EVENT_FILE_ADDED, GP_EVENT_CAPTURE_COMPLETE = 0, 2, 3 GP_OPERATION_CAPTURE_PREVIEW, GP_OPERATION_CONFIG = 8, 16 GPhoto2Error = _Err @@ -217,6 +219,7 @@ def __init__( self.stored_objects = 0 # tethered shots the body still holds (deletes drain this) self.object_capacity = None # int → refuse captures when full, as the X-T5 does (#658) self.deletes = 0 + self.events: list = [] # queued (kind, data) the body reports before anything else self.reject_deletes = False # a body whose RAM target refuses the delete self.opened = False self.undrained = False @@ -1062,3 +1065,178 @@ def test_a_body_known_to_stall_never_engages_its_magnifier(): assert len(fake.writes) == writes assert len(reasons) == 1 camera.close() + + +# ---- Bulb ------------------------------------------------------------------- + + +def _bulb_body(fake, release="bulb"): + """A body with a Bulb step and a USB bulb release: Sony and Nikon's toggle, or Canon's remote release.""" + fake.props["shutterspeed"].choices = ["Bulb", "1/2", "1/5", "1/10"] + if release == "bulb": + fake.props["bulb"] = _Widget(fake, "bulb", 0, ["0", "1"]) + elif release == "canon": + fake.props["eosremoterelease"] = _Widget(fake, "eosremoterelease", "None", ["None", "Press Full", "Release Full"]) + + +def _file_after_release(fake, *names): + """The body adds its files once the shutter closes.""" + + def _expose(): + fake.events.extend((FakeGP.GP_EVENT_FILE_ADDED, _Path(n)) for n in names) + + return _expose + + +def test_bulb_presses_and_releases_around_the_exposure(cam, fake, tmp_path): + _bulb_body(fake) + order = [] + + def _expose(): + order.append(("expose", fake.writes[-1])) + _file_after_release(fake, "capt0002.ARW")() + + out = cam.capture_bulb(str(tmp_path / "f.raw"), _expose, iso="100") + cam._post_shot.join() + + assert out == str(tmp_path / "f.ARW") and (tmp_path / "f.ARW").read_bytes() == b"RAWDATA" * 3 + assert order == [("expose", ("bulb", 1))] + assert ("shutterspeed", "Bulb") in fake.writes and ("iso", "100") in fake.writes + assert fake.writes.index(("bulb", 1)) < fake.writes.index(("bulb", 0)) + assert fake.writes[-1] == ("shutterspeed", "1/5") # the operator's step comes back + assert fake.deletes == 1 + + +def test_bulb_uses_canons_remote_release(cam, fake, tmp_path): + _bulb_body(fake, release="canon") + cam.capture_bulb(str(tmp_path / "f.raw"), _file_after_release(fake, "capt0002.CR3")) + assert ("eosremoterelease", "Press Full") in fake.writes and ("eosremoterelease", "Release Full") in fake.writes + + +def test_bulb_by_cable_sets_bulb_and_presses_nothing(cam, fake, tmp_path): + _bulb_body(fake) + cam.capture_bulb(str(tmp_path / "f.raw"), _file_after_release(fake, "capt0002.ARW"), usb_release=False) + assert ("shutterspeed", "Bulb") in fake.writes + assert not [w for w in fake.writes if w[0] == "bulb"] + + +def test_bulb_by_cable_runs_on_a_body_set_to_bulb_by_hand(cam, fake, tmp_path): + # No Bulb step over USB: a dial-set body is left as it is, and the cable opens it. + out = cam.capture_bulb(str(tmp_path / "f.raw"), _file_after_release(fake, "capt0002.ARW"), usb_release=False) + assert out.endswith(".ARW") and not [w for w in fake.writes if w[0] == "shutterspeed"] + + +def test_bulb_over_usb_refuses_a_body_without_bulb(cam, fake, tmp_path): + ran = [] + with pytest.raises(GphotoError, match="no Bulb shutter"): + cam.capture_bulb(str(tmp_path / "f.raw"), lambda: ran.append(1)) + assert ran == [] + + +def test_bulb_over_usb_refuses_a_body_without_a_release(cam, fake, tmp_path): + _bulb_body(fake, release=None) + with pytest.raises(GphotoError, match="no bulb release"): + cam.capture_bulb(str(tmp_path / "f.raw"), lambda: None) + assert ("shutterspeed", "Bulb") not in fake.writes # refused before the body leaves its step + + +def test_bulb_skips_and_deletes_a_jpeg_beside_the_raw(cam, fake, tmp_path): + _bulb_body(fake) + out = cam.capture_bulb(str(tmp_path / "f.raw"), _file_after_release(fake, "capt0002.JPG", "capt0002.ARW")) + assert out.endswith(".ARW") and fake.deletes == 2 + + +def test_bulb_without_a_raw_times_out(cam, fake, tmp_path, monkeypatch): + _bulb_body(fake) + monkeypatch.setattr("negpy.infrastructure.capture.gphoto._BULB_FILE_TIMEOUT_S", 0.0) + with pytest.raises(GphotoError, match="no RAW"): + cam.capture_bulb(str(tmp_path / "f.raw"), lambda: None) + + +def test_bulb_releases_the_button_when_the_exposure_fails(cam, fake, tmp_path): + _bulb_body(fake) + + def _fail(): + raise RuntimeError("light unplugged") + + with pytest.raises(RuntimeError): + cam.capture_bulb(str(tmp_path / "f.raw"), _fail) + assert fake.writes.index(("bulb", 1)) < fake.writes.index(("bulb", 0)) + assert fake.writes[-1] == ("shutterspeed", "1/5") + assert not cam._busy.is_set() + + +def test_keep_bulb_holds_bulb_for_the_next_frame_and_release_restores_it(cam, fake, tmp_path): + _bulb_body(fake) + cam.capture_bulb(str(tmp_path / "f1.raw"), _file_after_release(fake, "capt0002.ARW"), keep_bulb=True) + assert fake.writes[-1] == ("bulb", 0) # no restore yet: the body stays in Bulb + before = len([w for w in fake.writes if w == ("shutterspeed", "Bulb")]) + + cam.capture_bulb(str(tmp_path / "f2.raw"), _file_after_release(fake, "capt0003.ARW"), keep_bulb=True) + assert len([w for w in fake.writes if w == ("shutterspeed", "Bulb")]) == before # not written again + + cam.release_bulb() + assert fake.writes[-1] == ("shutterspeed", "1/5") + cam.release_bulb() # nothing held: no write + assert fake.writes[-1] == ("shutterspeed", "1/5") + + +def test_a_plain_capture_restores_a_held_bulb_first(cam, fake, tmp_path): + _bulb_body(fake) + cam.capture_bulb(str(tmp_path / "f1.raw"), _file_after_release(fake, "capt0002.ARW"), keep_bulb=True) + cam._post_shot.join() + cam.capture(str(tmp_path / "flat.ARW")) + assert ("shutterspeed", "1/5") in fake.writes[fake.writes.index(("bulb", 0)) :] + + +def test_close_restores_a_held_bulb(fake, tmp_path): + _bulb_body(fake) + camera = GphotoCamera(gp_module=fake, jpeg_path=str(tmp_path / "l.jpg"), settings_path=str(tmp_path / "l.json")) + camera.open() + camera.capture_bulb(str(tmp_path / "f1.raw"), _file_after_release(fake, "capt0002.ARW"), keep_bulb=True) + camera.close() + assert fake.writes[-1] == ("shutterspeed", "1/5") + + +def test_a_failed_held_frame_restores_the_shutter(cam, fake, tmp_path): + _bulb_body(fake) + cam.capture_bulb(str(tmp_path / "f1.raw"), _file_after_release(fake, "capt0002.ARW"), keep_bulb=True) + + def _fail(): + raise RuntimeError("light unplugged") + + with pytest.raises(RuntimeError): + cam.capture_bulb(str(tmp_path / "f2.raw"), _fail, keep_bulb=True) + assert fake.writes[-1] == ("shutterspeed", "1/5") + + +def test_on_closed_runs_after_the_release_and_before_the_download(cam, fake, tmp_path): + _bulb_body(fake) + at_close = [] + cam.capture_bulb( + str(tmp_path / "f.raw"), + _file_after_release(fake, "capt0002.ARW"), + on_closed=lambda: at_close.append((fake.writes[-1], fake.deletes)), + ) + assert at_close == [(("bulb", 0), 0)] # the button is up, and nothing is downloaded yet + + +def test_on_closed_does_not_run_when_the_exposure_fails(cam, fake, tmp_path): + _bulb_body(fake) + called = [] + + def _fail(): + raise RuntimeError("light unplugged") + + with pytest.raises(RuntimeError): + cam.capture_bulb(str(tmp_path / "f.raw"), _fail, on_closed=lambda: called.append(1)) + assert called == [] + + +def test_a_body_that_refuses_bulb_says_to_check_the_drive_mode(cam, fake, tmp_path, monkeypatch): + _bulb_body(fake) + fake._settle_writes = False # a Sony in continuous drive keeps its step, whatever is written + clock = iter((0.0, 4.0)) + monkeypatch.setattr("negpy.infrastructure.capture.gphoto.time.monotonic", lambda: next(clock, 4.0)) + with pytest.raises(GphotoError, match="drive mode to single shooting"): + cam.capture_bulb(str(tmp_path / "f.raw"), lambda: None) diff --git a/tests/test_capture_modes.py b/tests/test_capture_modes.py new file mode 100644 index 000000000..78f99e8eb --- /dev/null +++ b/tests/test_capture_modes.py @@ -0,0 +1,46 @@ +"""The per-folder record of Blend and Single Exposure captures.""" + +import pytest + +from negpy.services.assets.capture_modes import BLEND, SINGLE, capture_modes, record_capture_mode + + +class _Repo: + def __init__(self): + self.settings: dict = {} + self.saves = 0 + + def get_global_setting(self, key, default=None): + return self.settings.get(key, default) + + def save_global_setting(self, key, value): + self.settings[key] = value + self.saves += 1 + + +def test_a_recorded_capture_reads_back_by_path(): + repo = _Repo() + record_capture_mode(repo, "/scans/roll/Roll_Frame001.ARW", BLEND) + record_capture_mode(repo, "/scans/roll/Roll_Frame002.ARW", SINGLE) + record_capture_mode(repo, "/scans/other/Other_Frame001.ARW", SINGLE) + paths = ["/scans/roll/Roll_Frame001.ARW", "/scans/roll/Roll_Frame002.ARW", "/scans/roll/Roll_Frame003.ARW", "/scans/other/Other_Frame001.ARW"] + assert capture_modes(repo, paths) == { + "/scans/roll/Roll_Frame001.ARW": BLEND, + "/scans/roll/Roll_Frame002.ARW": SINGLE, + "/scans/other/Other_Frame001.ARW": SINGLE, + } + assert len(repo.settings) == 2 # one setting per folder + + +def test_a_retake_replaces_the_mode_and_an_unchanged_one_writes_nothing(): + repo = _Repo() + record_capture_mode(repo, "/r/F1.ARW", SINGLE) + record_capture_mode(repo, "/r/F1.ARW", SINGLE) + assert repo.saves == 1 + record_capture_mode(repo, "/r/F1.ARW", BLEND) + assert capture_modes(repo, ["/r/F1.ARW"]) == {"/r/F1.ARW": BLEND} + + +def test_an_unknown_mode_is_refused(): + with pytest.raises(ValueError): + record_capture_mode(_Repo(), "/r/F1.ARW", "trichrome") diff --git a/tests/test_capture_presets.py b/tests/test_capture_presets.py index de59ba6ab..c412ba2b0 100644 --- a/tests/test_capture_presets.py +++ b/tests/test_capture_presets.py @@ -110,3 +110,12 @@ def test_rgb_only_framing_keeps_dark_channels_dark(): def test_white_capable_framing_has_no_floor(): assert framing_levels(180, 185, 155) == (22, 23, 19) + + +def test_blend_capture_round_trips_and_defaults_off(): + repo = FakeRepo() + repo.save_global_setting("scanlight_presets", {"Old": {"r_level": 200, "shutter_r": "1/5"}}) + store = PresetStore(repo) + assert not store.get("Old").blend_capture # a preset saved before Blend is a triplet + store.save("Blend", ScanlightPreset(r_level=120, shutter_r="1/5", blend_capture=True)) + assert store.get("Blend").blend_capture and not store.get("Blend").single_capture diff --git a/tests/test_capture_protocol.py b/tests/test_capture_protocol.py index 46b636ad0..2e8293334 100644 --- a/tests/test_capture_protocol.py +++ b/tests/test_capture_protocol.py @@ -89,3 +89,15 @@ def test_channel_light_keywords(): assert Channel.IR.light(255) == {"ir": 255} with pytest.raises(ValueError): Channel.IR.rgb(255) + + +def test_encode_shutter_pulse_counts_ten_millisecond_units(): + assert proto.encode_shutter_pulse(1.5) == bytes([0xFE, proto.H2D_SHUTTER_PULSE, 1, 150]) + assert proto.encode_shutter_pulse(0.123)[-1] == 13 # rounded up, so the shutter outlasts the light + assert proto.encode_shutter_pulse(proto.MAX_SHUTTER_PULSE_S)[-1] == 255 + + +@pytest.mark.parametrize("bad", [0.0, 2.56, 10.0]) +def test_encode_shutter_pulse_rejects_what_the_firmware_cannot_hold(bad): + with pytest.raises(proto.ProtocolError): + proto.encode_shutter_pulse(bad) diff --git a/tests/test_capture_service.py b/tests/test_capture_service.py index acc6e2719..2a604583e 100644 --- a/tests/test_capture_service.py +++ b/tests/test_capture_service.py @@ -5,9 +5,10 @@ import pytest -from negpy.infrastructure.capture.base import CaptureSettings, Channel +from negpy.infrastructure.capture.base import BulbRelease, CaptureSettings, Channel +from negpy.infrastructure.capture.protocol import encode_shutter_pulse from negpy.infrastructure.capture.settings import ScanlightSettings -from negpy.services.capture.service import CaptureError, CaptureService +from negpy.services.capture.service import CaptureError, CaptureService, blend_plan class FakeLight: @@ -298,3 +299,190 @@ def test_single_exposures_leave_the_end_light_lit(tmp_path, shoot): assert not light.off_called assert light.colors[-1] == (0, 0, 0, 120) + + +# ---- Blend: one Bulb exposure with R, G and B lit in turn ---------------------------------- + + +class FakeReleaseLight(FakeLight): + """A light with the 3.5mm shutter output.""" + + def __init__(self): + super().__init__() + self.pulses: list[float] = [] + + def shutter_pulse(self, seconds): + self.pulses.append(seconds) + self.colors.append(("pulse", seconds)) + + +class FakeBulbCamera(FakeCamera): + """Runs the exposure callback inside the Bulb shot, as the shutter would hold open.""" + + def __init__(self, light, file_size=21 * 1024 * 1024): + super().__init__(file_size) + self.light = light + self.bulbs: list[dict] = [] + + def capture_bulb(self, out_path, expose, *, usb_release=True, iso=None, aperture=None, keep_bulb=False, on_closed=None): + start = len(self.light.colors) + expose() + if on_closed is not None: + on_closed() + self.bulbs.append({"usb_release": usb_release, "iso": iso, "aperture": aperture, "lit": self.light.colors[start:]}) + self.kept = keep_bulb + out_path = os.path.splitext(out_path)[0] + ".ARW" + with open(out_path, "wb") as f: + f.write(b"\0" * self.file_size) + return out_path + + +def _blend(tmp_path, light, cam, waits, **kw): + svc = CaptureService(light, cam, sleep=waits.append) + return svc.capture_blend(_settings(tmp_path, iso="100", aperture="8", **kw.pop("settings", {})), **kw) + + +def test_blend_lights_each_channel_alone_in_one_bulb_exposure(tmp_path): + light = FakeLight() + cam, waits = FakeBulbCamera(light), [] + path = _blend(tmp_path, light, cam, waits, seconds=0.5) + + assert os.path.basename(path) == "Roll001_Frame001.ARW" and os.path.exists(path) + (bulb,) = cam.bulbs + assert bulb == { + "usb_release": True, + "iso": "100", + "aperture": "8", + "lit": [(0, 0, 0, 0), (200, 0, 0, 0), (0, 180, 0, 0), (0, 0, 255, 0), (0, 0, 0, 0)], + } + # Dark lead, then one on-time per channel at the Trichrome shutter. Over USB the release follows + # the light-off at once: a slow shutter close cannot cut a channel. + assert waits == pytest.approx([0.35, 0.5, 0.5, 0.5]) + + +def test_blend_dims_the_leds_to_lengthen_a_short_channel(tmp_path): + light = FakeLight() + cam, waits = FakeBulbCamera(light), [] + _blend(tmp_path, light, cam, waits, seconds=0.05) + + levels = [max(c) for c in cam.bulbs[0]["lit"] if any(c)] + times = waits[1:4] + assert levels == [50, 45, 64] + assert times == pytest.approx([0.2, 0.2, 0.05 * 255 / 64]) + # Each channel keeps its Trichrome light: level times seconds. + assert [lv * t for lv, t in zip(levels, times)] == pytest.approx([200 * 0.05, 180 * 0.05, 255 * 0.05]) + + +def test_blend_dims_no_led_under_the_calibration_floor(tmp_path): + light = FakeLight() + cam, waits = FakeBulbCamera(light), [] + _blend(tmp_path, light, cam, waits, seconds=0.02) + + # The dimmest LED stops at 40, so the channels stay shorter than 0.2 s. + assert [max(c) for c in cam.bulbs[0]["lit"] if any(c)] == [44, 40, 57] + + +def test_blend_by_cable_pulses_the_shutter_and_leaves_the_camera_to_wait(tmp_path): + light = FakeReleaseLight() + cam, waits = FakeBulbCamera(light), [] + _blend(tmp_path, light, cam, waits, seconds=0.4, release=BulbRelease.CABLE) + + assert cam.bulbs[0]["usb_release"] is False + assert light.pulses == pytest.approx([0.35 + 3 * 0.4 + 0.2]) + assert light.colors.index(("pulse", light.pulses[0])) < light.colors.index((200, 0, 0, 0)) + + +def test_blend_by_cable_brightens_the_leds_to_fit_the_pulse(tmp_path): + light = FakeReleaseLight() + cam, waits = FakeBulbCamera(light), [] + _blend(tmp_path, light, cam, waits, seconds=1.0, settings={"levels": (100, 90, 120)}, release=BulbRelease.CABLE) + + assert sum(waits[1:4]) <= 2.55 - 0.35 - 0.2 + 1e-9 + assert light.pulses[0] <= 2.55 + 1e-9 + assert encode_shutter_pulse(light.pulses[0])[-1] == 255 # the light's own encoder takes it, at its limit + + +def test_blend_by_cable_refuses_an_exposure_longer_than_the_pulse(tmp_path): + light = FakeReleaseLight() + cam = FakeBulbCamera(light) + with pytest.raises(CaptureError, match="release cable"): + _blend(tmp_path, light, cam, [], seconds=2.0, release=BulbRelease.CABLE) + assert cam.bulbs == [] and light.pulses == [] + + +def test_blend_by_cable_needs_a_light_with_a_shutter_output(tmp_path): + light = FakeLight() + with pytest.raises(CaptureError, match="shutter release"): + _blend(tmp_path, light, FakeBulbCamera(light), [], seconds=0.5, release=BulbRelease.CABLE) + + +def test_blend_needs_a_bulb_camera(tmp_path): + with pytest.raises(CaptureError, match="Bulb"): + _blend(tmp_path, FakeLight(), FakeCamera(), [], seconds=0.5) + + +def test_blend_reports_each_channel_and_leaves_the_end_light_lit(tmp_path): + light = FakeLight() + seen = [] + _blend(tmp_path, light, FakeBulbCamera(light), [], seconds=0.5, on_channel=seen.append, end_light=(10, 10, 10, 0)) + assert seen == ["R", "G", "B"] + assert light.colors[-1] == (10, 10, 10, 0) + + +def test_blend_stop_between_channels_ends_the_light_and_keeps_no_file(tmp_path): + light = FakeLight() + cancel = threading.Event() + + def _stop_after_red(letter): + if letter == "R": + cancel.set() + + with pytest.raises(CaptureError, match="cancelled"): + _blend(tmp_path, light, FakeBulbCamera(light), [], seconds=0.5, cancel=cancel, on_channel=_stop_after_red) + assert (0, 180, 0, 0) not in light.colors + assert light.colors[-1] == (0, 0, 0, 0) + assert not [n for n in os.listdir(tmp_path) if n.endswith(".ARW")] + + +def test_blend_plan_skips_an_unlit_channel(): + plan = blend_plan((200, 0, 255), 0.5) + assert [step.channel for step in plan] == [Channel.RED, Channel.BLUE] + + +def test_blend_passes_keep_bulb_to_the_camera(tmp_path): + light = FakeLight() + cam = FakeBulbCamera(light) + _blend(tmp_path, light, cam, [], seconds=0.5, keep_bulb=True) + assert cam.kept is True + + +def test_blend_calls_on_closed_after_the_light_is_off(tmp_path): + light = FakeLight() + seen = [] + _blend(tmp_path, light, FakeBulbCamera(light), [], seconds=0.5, on_closed=lambda: seen.append(light.colors[-1])) + assert seen == [(0, 0, 0, 0)] + + +def test_blend_by_cable_keeps_the_dark_tail_inside_the_pulse(tmp_path): + light = FakeReleaseLight() + waits = [] + _blend(tmp_path, light, FakeBulbCamera(light), waits, seconds=0.4, release=BulbRelease.CABLE) + assert waits[-1] == pytest.approx(0.2) + + +class FakeTimedLight(FakeLight): + """A light whose set_color waits for a reply, so a timed exposure must switch through set_color_now.""" + + def __init__(self): + super().__init__() + self.now: list[tuple[int, int, int]] = [] + + def set_color_now(self, r=0, g=0, b=0): + self.now.append((r, g, b)) + + +def test_blend_switches_a_waiting_light_through_set_color_now(tmp_path): + light = FakeTimedLight() + _blend(tmp_path, light, FakeBulbCamera(light), [], seconds=0.5) + assert light.now == [(0, 0, 0), (200, 0, 0), (0, 180, 0), (0, 0, 255), (0, 0, 0)] + assert light.colors and all(c == (0, 0, 0, 0) for c in light.colors) # off() only for the end-of-frame release diff --git a/tests/test_capture_worker.py b/tests/test_capture_worker.py index 0b8265a03..ef3b09588 100644 --- a/tests/test_capture_worker.py +++ b/tests/test_capture_worker.py @@ -1,6 +1,7 @@ """CaptureWorker cancellation behavior at the camera/service boundary.""" import os +import time from pathlib import Path from types import SimpleNamespace @@ -1213,3 +1214,208 @@ def test_the_lights_scan_button_reaches_the_worker_signal(monkeypatch): worker._ensure_light("") dock.on_scan_button() assert pressed == [1] + + +class _BulbCamera: + """A body at `current` in its own ladder that takes Bulb exposures.""" + + def __init__(self, current="1/2"): + self.ladder = ["1/4", "1/2", "1", "Bulb"] + self.current = current + self.bulbs = [] + self.kept = [] + self.released = 0 + self.closed_calls = 0 + self.dock = None + self.moved_before_file = [] + + def release_bulb(self): + self.released += 1 + + def is_open(self): + return True + + def close(self): + pass + + def read_settings(self): + return {"shutter": {"cur": self.ladder.index(self.current), "options": [{"label": s, "raw": i} for i, s in enumerate(self.ladder)]}} + + def capture_bulb(self, out_path, expose, *, usb_release=True, iso=None, aperture=None, keep_bulb=False, on_closed=None): + expose() + self.bulbs.append(usb_release) + self.kept.append(keep_bulb) + if on_closed is not None: + on_closed() + self.closed_calls += 1 + if self.dock is not None: # the camera is still busy: the film moves meanwhile + deadline = time.monotonic() + 2.0 + while self.dock.events.count("advance") < self.closed_calls and time.monotonic() < deadline: + time.sleep(0.01) + self.moved_before_file.append(self.dock.events.count("advance") == self.closed_calls) + path = os.path.splitext(out_path)[0] + ".ARW" + with open(path, "wb") as f: + f.write(b"\0" * (9 * 1024 * 1024)) + return path + + +def _blend_worker(monkeypatch, camera, waits): + import negpy.desktop.workers.capture_worker as cw + + worker = CaptureWorker() + monkeypatch.setattr(worker, "_acquire_camera", lambda: camera) + monkeypatch.setattr(worker, "_ensure_light", lambda _port: FakeLight()) + monkeypatch.setattr(worker, "_report_clipping", lambda _path: None) + real = cw.CaptureService + monkeypatch.setattr(cw, "CaptureService", lambda light, cam: real(light, cam, sleep=waits.append)) + return worker + + +@pytest.mark.parametrize(("shutters", "seconds"), [(("1/4", "1/4", "1/4"), 0.25), (("", "", ""), 0.5)]) +def test_blend_lights_each_channel_for_the_preset_shutter_or_the_bodys(tmp_path, monkeypatch, shutters, seconds): + camera, waits = _BulbCamera(), [] + worker = _blend_worker(monkeypatch, camera, waits) + channels = [] + worker.channel.connect(channels.append) + + finished, errors, _ = _run( + worker, roll_name="Roll", frame_number=3, output_folder=str(tmp_path), levels=(255, 255, 255), shutters=shutters, blend_capture=True + ) + + assert errors == [] and finished == [[str(tmp_path / "Roll_Frame003.ARW")]] + assert waits[1:4] == pytest.approx([seconds] * 3) and channels == ["R", "G", "B"] + assert camera.bulbs == [True] + + +def test_blend_on_a_body_left_at_bulb_asks_for_a_shutter_time(tmp_path, monkeypatch): + camera = _BulbCamera(current="Bulb") + worker = _blend_worker(monkeypatch, camera, []) + + finished, errors, _ = _run( + worker, roll_name="Roll", frame_number=1, output_folder=str(tmp_path), levels=(255, 255, 255), blend_capture=True + ) + + assert finished == [] and camera.bulbs == [] + assert errors and "shutter time" in errors[0] + + +def test_a_whole_strip_in_blend_shoots_one_bulb_exposure_per_frame_then_ejects(tmp_path, monkeypatch): + import negpy.desktop.workers.capture_worker as cw + + real = cw.CaptureService + monkeypatch.setattr(cw, "CaptureService", lambda light, cam: real(light, cam, sleep=lambda _s: None)) + dock, camera = StripDock(frames=3), _BulbCamera() + + seen, _ = _run_strip(tmp_path, monkeypatch, dock, camera=camera, single_capture=False, blend_capture=True) + + # The advance overlaps the camera's processing, so it may land before or after the frame's report. + assert sorted(dock.events[:-1]) == ["advance"] * 3 + ["frame"] * 3 and dock.events[-1] == "eject" + assert camera.bulbs == [True] * 3 + assert seen["strip"] == [(3, True)] and seen["error"] == [] + + +def test_advance_film_after_a_blend_scan_moves_the_film(tmp_path, monkeypatch): + camera, waits = _BulbCamera(), [] + worker = _blend_worker(monkeypatch, camera, waits) + dock = StripDock(frames=3) + monkeypatch.setattr(worker, "_ensure_light", lambda _port: dock) + + assert worker.run_capture( + CaptureRequest(roll_name="Roll", frame_number=1, output_folder=str(tmp_path), levels=(255, 255, 255), blend_capture=True, advance_film=True) + ) + assert dock.events == ["advance"] + + +def test_a_blend_strip_holds_bulb_between_frames_and_restores_it_before_the_flat(tmp_path, monkeypatch): + import negpy.desktop.workers.capture_worker as cw + + camera, order = _BulbCamera(), [] + worker = _blend_worker(monkeypatch, camera, []) + worker._camera = camera # the held session the strip releases + dock = StripDock(frames=2) + monkeypatch.setattr(worker, "_ensure_light", lambda _port: dock) + monkeypatch.setattr(camera, "release_bulb", lambda: order.append("release")) + monkeypatch.setattr(worker, "_capture_flat", lambda _req: order.append("flat") or str(tmp_path / "flat.ARW")) + monkeypatch.setattr(cw.APP_CONFIG, "flatfield_dir", str(tmp_path / "ff"), raising=False) + + worker.run_capture( + CaptureRequest( + roll_name="Roll", frame_number=1, output_folder=str(tmp_path), levels=(255, 255, 255), shutters=("1/2",) * 3, + blend_capture=True, whole_strip=True, strip_flat=True, + ) + ) + + assert camera.kept == [True, True] # every strip frame leaves Bulb set for the next + assert order == ["release", "flat"] # the flat meters from the restored shutter step + + +def test_a_single_blend_scan_puts_the_shutter_back(tmp_path, monkeypatch): + camera = _BulbCamera() + worker = _blend_worker(monkeypatch, camera, []) + worker.run_capture(CaptureRequest(roll_name="Roll", frame_number=1, output_folder=str(tmp_path), levels=(255, 255, 255), blend_capture=True)) + assert camera.kept == [False] + + +def test_a_blend_strip_moves_the_film_while_the_camera_finishes_the_shot(tmp_path, monkeypatch): + import negpy.desktop.workers.capture_worker as cw + + real = cw.CaptureService + monkeypatch.setattr(cw, "CaptureService", lambda light, cam: real(light, cam, sleep=lambda _s: None)) + dock, camera = StripDock(frames=3), _BulbCamera() + camera.dock = dock + + seen, _ = _run_strip(tmp_path, monkeypatch, dock, camera=camera, single_capture=False, blend_capture=True) + + assert camera.moved_before_file == [True, True, True] # each advance ran before the camera handed over the file + assert dock.events.count("advance") == 3 # one move per frame: the loop does not move the film again + assert seen["strip"] == [(3, True)] + + +def test_a_blend_frame_that_fails_after_the_film_moved_says_how_to_retake_it(tmp_path, monkeypatch): + import negpy.desktop.workers.capture_worker as cw + + real = cw.CaptureService + monkeypatch.setattr(cw, "CaptureService", lambda light, cam: real(light, cam, sleep=lambda _s: None)) + + class _FailsAfterClose(_BulbCamera): + def capture_bulb(self, out_path, expose, **kw): + expose() + kw["on_closed"]() + raise RuntimeError("the camera sent no RAW file") + + dock = StripDock(frames=3) + worker = CaptureWorker() + monkeypatch.setattr(worker, "_acquire_camera", lambda: _FailsAfterClose()) + monkeypatch.setattr(worker, "_ensure_light", lambda _port: dock) + monkeypatch.setattr(worker, "_report_clipping", lambda _path: None) + statuses, errors = [], [] + worker.status.connect(statuses.append) + worker.error.connect(errors.append) + + worker.run_capture( + CaptureRequest( + roll_name="Roll", frame_number=4, output_folder=str(tmp_path), levels=(255, 255, 255), shutters=("1/2",) * 3, + blend_capture=True, whole_strip=True, + ) + ) + + assert errors and dock.events.count("advance") == 1 + assert any("Frame 4 failed after the film moved on" in s for s in statuses) + + +def test_a_new_light_connection_takes_host_control(monkeypatch): + class DockLight: + controlled = 0 + + def take_control(self): + self.controlled += 1 + + def is_connected(self): + return True + + light = DockLight() + monkeypatch.setattr("negpy.desktop.workers.capture_worker.open_light", lambda _port: light) + worker = CaptureWorker() + assert worker._ensure_light("/dev/cu.usbserial-10") is light + assert worker._ensure_light("/dev/cu.usbserial-10") is light + assert light.controlled == 1 diff --git a/tests/test_file_browser_widget.py b/tests/test_file_browser_widget.py index 8adcb5e44..5e67a7f20 100644 --- a/tests/test_file_browser_widget.py +++ b/tests/test_file_browser_widget.py @@ -1423,3 +1423,25 @@ def test_context_menu_reanalyze_follows_the_selection(browser, session): session.state.selected_indices = [0, 2] assert "Reanalyze 2 Frames" in _action_labels(browser.build_frame_menu()) + + +def test_blend_and_single_captures_draw_their_own_glyphs(qapp): + assets = _composite_assets() + blend = {**assets["plain"], "capture": "blend"} + single = {**assets["plain"], "capture": "single"} + assert composite_kind(blend) == "" and composite_kind(single) == "" # a capture mark is not a composite + corners = [_badge_corner(_render(a)) for a in (assets["plain"], assets["rgb"], blend, single)] + for i, a in enumerate(corners): + for b in corners[i + 1 :]: + assert a != b + assert composite_summary(blend) == "Blend capture" + assert composite_summary(single) == "Single Exposure capture" + + +def test_recorded_captures_mark_their_frames(session): + session.repo.get_global_setting.side_effect = lambda key, default=None: ( + {"IMG_0001.cr2": "blend", "IMG_0002.cr2": "single"} if key.startswith("capture_modes:") else default + ) + session.state.uploaded_files[2]["capture"] = "blend" # a stale mark: nothing records scan.tif + session.refresh_capture_marks() + assert [f.get("capture") for f in session.state.uploaded_files] == ["blend", "single", None, None] diff --git a/tests/test_lento_dock_driver.py b/tests/test_lento_dock_driver.py index 1f69631ea..367ee940b 100644 --- a/tests/test_lento_dock_driver.py +++ b/tests/test_lento_dock_driver.py @@ -110,6 +110,14 @@ def test_close_without_light_leaves_the_mode_alone(sim): assert not any(line.startswith(("MODE", "EXPMODE")) for line in sim.lines) +def test_take_control_enters_host_control_at_once(sim): + with LentoDock(serial_obj=sim) as dock: + dock.take_control() + assert (sim.mode, sim.expmode) == ("SEMI", "CONT") + assert sim.lit == (0, 0, 0) + assert (sim.mode, sim.expmode) == ("AUTO", "BRGB") + + def test_a_refused_command_raises(dock, sim): sim._answer = lambda line: sim._say("ERR") # the dock refuses everything from now on with pytest.raises(ValueError): @@ -269,3 +277,62 @@ def test_the_s_button_calls_the_scan_handler(sim): assert pressed == [1] # at the press, not at the end of the dock's exposure assert dock.advance_frame() == 1 # the film waits for the dock's exposure to end assert pressed == [1] + + +def test_set_color_now_confirms_the_hold_once_then_writes_without_waiting(dock, sim, monkeypatch): + dock.set_color_now() # the first call, in the dark: continuous mode and the hold, both confirmed + assert sim.expmode == "CONT" and sim.lines.count("CONTHOLD 1") == 1 + + def _no_waiting(*_a, **_k): + raise AssertionError("a timed switch must not wait for the dock's reply") + + monkeypatch.setattr(dock, "_await", _no_waiting) + dock.set_color_now(r=255) + dock.set_color_now(g=128) + dock.set_color_now() + deadline = time.monotonic() + 1.0 + while sim.lines[-1] != "CONTRGB 0 0 0" and time.monotonic() < deadline: + time.sleep(0.01) + assert sim.lines[-3:] == ["CONTRGB 1000 0 0", "CONTRGB 0 502 0", "CONTRGB 0 0 0"] + assert sim.lines.count("CONTHOLD 1") == 1 + + +def test_set_color_now_confirms_the_hold_again_after_off(dock, sim): + dock.set_color_now(r=255) + dock.off() + dock.set_color_now() + assert sim.lines.count("CONTHOLD 1") == 2 + + +def test_the_keep_alive_reaches_the_dock_while_a_command_waits(sim, monkeypatch): + monkeypatch.setattr(lento_dock, "KEEPALIVE_TICK_S", 0.02) + monkeypatch.setattr(lento_dock, "PING_INTERVAL_S", 0.1) + with LentoDock(serial_obj=sim) as dock: + sent = len(sim.lines) + with pytest.raises(TimeoutError): + dock._command("SLOW", r"^NEVER", timeout=0.5) + assert "PING" in sim.lines[sent:] + + +class _UnpluggedSerial(SimDockSerial): + unplugged = False + + def write(self, data: bytes) -> None: + if self.unplugged: + raise OSError(6, "Device not configured") + super().write(data) + + +def test_an_unplugged_dock_ends_the_keep_alive_with_one_warning(monkeypatch, caplog): + monkeypatch.setattr(lento_dock, "KEEPALIVE_TICK_S", 0.02) + monkeypatch.setattr(lento_dock, "PING_INTERVAL_S", 0.0) + sim = _UnpluggedSerial() + dock = LentoDock(serial_obj=sim) + sim.unplugged = True + dock._keepalive.join(timeout=2.0) + assert not dock._keepalive.is_alive() + assert not dock.is_connected() + assert sum("link lost" in r.getMessage() for r in caplog.records) == 1 + with pytest.raises(ConnectionError): + dock.set_color(255, 0, 0) + dock.close() diff --git a/tests/test_scan_roll.py b/tests/test_scan_roll.py index aeb3eba78..9d5792335 100644 --- a/tests/test_scan_roll.py +++ b/tests/test_scan_roll.py @@ -223,3 +223,16 @@ def test_a_triplet_capture_never_takes_a_sensor_profile(monkeypatch): AppController._on_capture_finished(c, ["/hot/R1/r.ARW", "/hot/R1/g.ARW", "/hot/R1/b.ARW"]) assert "sensor_profile" not in roll_defaults(c.session.repo, folder_roll_id_for_path(c.session.repo, "/hot/R1")) + + +def test_a_blend_capture_gives_its_roll_the_blend_sensor_profile_and_no_triplet(monkeypatch): + _profiles(monkeypatch, Rig=_UNMIX) + c = _controller(capture_req=_rgb_req("R1", blend_capture=True, sensor_profile="Rig")) + + AppController._on_capture_finished(c, ["/hot/R1/R1_Frame001.ARW"]) + + roll_id = folder_roll_id_for_path(c.session.repo, "/hot/R1") + defaults = roll_defaults(c.session.repo, roll_id) + assert defaults["sensor_profile"] == "Rig" and defaults["linear_raw"] is True + assert c._store["rgbscan_mode_by_roll"] == {roll_id: False} + assert c.request_asset_discovery.call_args.kwargs["restore_triplets"] is None diff --git a/tests/test_scanlight_driver.py b/tests/test_scanlight_driver.py index 768ced53f..205ca8446 100644 --- a/tests/test_scanlight_driver.py +++ b/tests/test_scanlight_driver.py @@ -12,7 +12,7 @@ import pytest from negpy.infrastructure.capture import protocol as proto -from negpy.infrastructure.capture.base import LightSource +from negpy.infrastructure.capture.base import LightSource, ShutterRelease from negpy.infrastructure.capture.scanlight import Scanlight @@ -108,3 +108,9 @@ def test_describe_names_the_model_and_its_channels(light): word = (0 << 16) | 9 # firmware 9 on a Big Scanlight: white and infrared threading.Timer(0.05, light.serial.feed, args=(proto.D2H_FW_VERSION, word.to_bytes(4, "big"))).start() assert light.describe() == ("Big Scanlight (fw9)", True, True) + + +def test_shutter_pulse_writes_one_pulse_packet(light): + light.shutter_pulse(2.0) + assert bytes(light.serial.written) == proto.encode_shutter_pulse(2.0) + assert isinstance(light, ShutterRelease) diff --git a/tests/test_scanlight_sidebar.py b/tests/test_scanlight_sidebar.py index 1755416c3..095015b29 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 _BUILTIN_PRESETS, ScanlightSidebar +from negpy.infrastructure.capture.base import BulbRelease from negpy.infrastructure.capture.settings import ScanlightSettings, WhiteCaptureMode from negpy.services.capture.presets import ScanlightPreset @@ -2196,6 +2197,139 @@ def test_manual_preset_can_be_marked_single_capture(tmp_path, monkeypatch): assert saved["preset"].single_capture +def test_a_trichrome_preset_scans_as_blend_through_the_picked_release(tmp_path, monkeypatch): + w = _sidebar() + _select_stored(w, monkeypatch, _rgb_preset(iso="100", aperture="f/8")) + assert w.capture_btn.isEnabled() and not w.capture_btn.is_item_enabled(1) # Single Exposure has its own calibration + assert w.bulb_release_rail.isHidden() + + w.capture_btn.setCurrentIndex(2) + assert w._settings.blend_capture and not w._settings.single_capture + assert not w.bulb_release_rail.isHidden() and "Bulb" in w.preset_hint.text() + assert not w.inter_exposure_delay_slider.isEnabled() # one exposure has no channel gap + w.bulb_release_btn.setCurrentIndex(w.bulb_release_btn.findData("cable")) + assert w._settings.bulb_release is BulbRelease.CABLE + + w.output.folder_edit.setText(str(tmp_path)) + w.output.roll_edit.setText("Roll001") + w._start_capture(retake=False) + req = w.controller.start_capture.call_args[0][0] + assert req.blend_capture and not req.single_capture and req.bulb_release is BulbRelease.CABLE + assert (req.levels, req.shutters[0], req.iso, req.aperture) == ((200, 100, 90), "1/5", "100", "f/8") + + +def test_each_preset_brings_back_its_own_capture_mode(monkeypatch): + w = _sidebar() + _select_stored(w, monkeypatch, _rgb_preset(blend_capture=True)) + assert w.capture_btn.currentIndex() == 2 and w._settings.blend_capture and w.capture_btn.isEnabled() + _select_stored(w, monkeypatch, _rgb_preset(single_capture=True)) + assert w.capture_btn.currentIndex() == 1 and not w.capture_btn.isEnabled() and not w._settings.blend_capture + _select_stored(w, monkeypatch, _rgb_preset()) + assert w.capture_btn.currentIndex() == 0 and not w._settings.blend_capture + + +def test_blend_is_a_capture_mode_beside_trichrome_and_single_exposure(): + w = _sidebar() + for combo in (w.capture_btn, w.calib_window.capture_btn): + assert [combo.itemText(i) for i in range(combo.count())] == ["Trichrome", "Single Exposure", "Blend"] + + +def test_a_blend_calibration_solves_a_triplet_and_saves_a_blend_preset(monkeypatch): + w = _sidebar() + w._camera_verified = True + w._light_verified = True + w.calib_window.image._set_crosshair(0.5, 0.5) + monkeypatch.setattr(w, "_settings_json", lambda: {"shutter": {"options": [{"label": "1/250"}, {"label": "1/60"}]}}) + w.calib_window.capture_btn.setCurrentIndex(2) + btn = w.calib_window.sensor_profile_btn + assert btn.isEnabled() and btn.isChecked() # on by default, as for Single Exposure + + w._on_calibrate_new_preset("Portra 400") + req = w.controller.start_calibration.call_args[0][0] + assert not req.single_capture and req.measure_sensor + + saves = _profile_saves(monkeypatch) + preset = _calibrate(w, monkeypatch, sensor_matrix=_UNMIX) + assert preset.blend_capture and not preset.single_capture + assert saves == [("Portra 400", _UNMIX)] and preset.sensor_profile == "Portra 400" + assert w.capture_btn.currentIndex() == 2 + + +def _blend_profiles(w, monkeypatch, *names): + import negpy.desktop.view.sidebar.scanlight as sl + + monkeypatch.setattr(sl.SensorProfiles, "list_profiles", staticmethod(lambda: ["None", *names])) + w._reload_blend_sensor_profiles() + + +def test_blend_asks_for_a_sensor_profile_before_it_can_scan(tmp_path, monkeypatch): + w = _sidebar() + _blend_profiles(w, monkeypatch, "Rig A", "Rig B") + _select_stored(w, monkeypatch, _rgb_preset()) + gate = "pick or make a sensor profile for Blend" + assert gate not in w._missing_requirements() # a Trichrome scan needs no profile + + w.capture_btn.setCurrentIndex(2) + assert gate in w._missing_requirements() + assert [w.blend_sensor_combo.itemData(i) for i in range(w.blend_sensor_combo.count())] == ["", "Rig A", "Rig B"] + + w.blend_sensor_combo.setCurrentIndex(2) + w._on_blend_sensor_picked() + assert gate not in w._missing_requirements() and w._settings.blend_sensor_profile == "Rig B" + + w.output.folder_edit.setText(str(tmp_path)) + w.output.roll_edit.setText("Roll001") + w._start_capture(retake=False) + assert w.controller.start_capture.call_args[0][0].sensor_profile == "Rig B" + + +def test_a_deleted_blend_profile_asks_again(monkeypatch): + w = _sidebar() + w._settings = replace(w._settings, blend_sensor_profile="Gone") + _blend_profiles(w, monkeypatch, "Rig A") + _select_stored(w, monkeypatch, _rgb_preset(blend_capture=True)) + assert w.blend_sensor_combo.currentIndex() == 0 + assert "pick or make a sensor profile for Blend" in w._missing_requirements() + + +def test_a_blend_preset_with_its_own_profile_scans_with_it_and_asks_for_nothing(tmp_path, monkeypatch): + w = _sidebar() + _blend_profiles(w, monkeypatch, "Portra 400", "Rig A") + _select_stored(w, monkeypatch, _rgb_preset(blend_capture=True, sensor_profile="Portra 400")) + assert "pick or make a sensor profile for Blend" not in w._missing_requirements() + assert not w.bulb_release_rail.isHidden() and w.blend_sensor_row.isHidden() + + w.output.folder_edit.setText(str(tmp_path)) + w.output.roll_edit.setText("Roll001") + w._start_capture(retake=False) + assert w.controller.start_capture.call_args[0][0].sensor_profile == "Portra 400" + + +def test_a_blend_preset_without_a_profile_shows_the_picker(): + w = _sidebar() + w._settings = replace(w._settings, blend_capture=True) + w._apply_gating() + assert not w.blend_sensor_row.isHidden() + + +def test_sensor_profile_toggle_is_on_by_default_for_blend(): + w = _sidebar() + w.calib_window.capture_btn.setCurrentIndex(2) + assert w.calib_window.sensor_profile_btn.isChecked() and w.calib_window.wants_sensor_profile() + + +def test_a_profile_made_from_the_blend_row_is_selected(monkeypatch): + w = _sidebar() + _blend_profiles(w, monkeypatch, "Fresh") + w._on_blend_sensor_saved("Fresh") + assert w.blend_sensor_combo.currentData() == "Fresh" and w._settings.blend_sensor_profile == "Fresh" + + +def test_settings_restore_the_bulb_release_and_fall_back_to_usb(): + assert ScanlightSettings(bulb_release="cable").bulb_release is BulbRelease.CABLE + assert ScanlightSettings(bulb_release="laser").bulb_release is BulbRelease.USB + + _UNMIX = (1.0, -0.1, 0.0, -0.1, 1.0, -0.3, 0.0, -0.3, 1.0) @@ -2485,8 +2619,8 @@ def test_adopted_presets_persist_and_an_older_settings_dict_loads_clean(monkeypa assert w._settings.adopted_presets == ("Trichrome, Color Negative", "A built-in this build no longer has") # a tuple again assert _rows(w) == ["Select preset", "Trichrome, Color Negative"] # an unknown name is not a row - ctrl.session.repo.get_global_setting.side_effect = ( - lambda k, default=None: {"white_mode": True, "w_level": 255} if k == "scanlight_settings" else default + ctrl.session.repo.get_global_setting.side_effect = lambda k, default=None: ( + {"white_mode": True, "w_level": 255} if k == "scanlight_settings" else default ) w = ScanlightSidebar(ctrl) # a user from before the list existed assert w._settings.adopted_presets == () and _rows(w) == ["Click + to add a preset"] @@ -3013,3 +3147,17 @@ def test_the_docks_scan_button_works_as_scan(monkeypatch): w._scanning = True w._on_light_scan_button() w.controller.cancel_capture.assert_called_once() + + +def test_a_user_preset_row_names_its_capture_mode(monkeypatch): + w = _sidebar(adopt=False) + presets = { + "Portra 400": _rgb_preset(), + "Gold 200": _rgb_preset(single_capture=True), + "Ektar 100": _rgb_preset(blend_capture=True), + } + monkeypatch.setattr(w._presets, "names", lambda: sorted(presets)) + monkeypatch.setattr(w._presets, "get", presets.get) + w._reload_presets(select="Ektar 100") + assert _rows(w) == ["Select preset", "Ektar 100 · Blend", "Gold 200 · Single Exposure", "Portra 400 · Trichrome"] + assert w.preset_combo.currentData() == "Ektar 100" diff --git a/tests/test_simulated_hardware.py b/tests/test_simulated_hardware.py index c17d69015..686fc4cbd 100644 --- a/tests/test_simulated_hardware.py +++ b/tests/test_simulated_hardware.py @@ -8,7 +8,7 @@ from negpy.features.rgbscan.logic import classify_channel, group_triplets, probe_frame from negpy.infrastructure.capture import gphoto -from negpy.infrastructure.capture.base import CaptureSettings +from negpy.infrastructure.capture.base import BulbRelease, CaptureSettings from negpy.infrastructure.capture.gphoto import GphotoCamera from negpy.infrastructure.capture.raw_demosaic import linear_demosaic from negpy.infrastructure.capture.scanlight import Scanlight @@ -208,3 +208,42 @@ def test_flag_registers_the_simulated_backend_as_default(monkeypatch): monkeypatch.delenv("NEGPY_SIMULATE_HARDWARE") importlib.reload(registry) assert "sim" not in registry.BACKENDS + + +def test_light_dose_adds_up_level_seconds_per_channel(): + t = time.monotonic() + scanlight._history[:] = [(t, (0, 0, 0, 0, 0)), (t + 1.0, (200, 0, 0, 0, 0)), (t + 1.5, (0, 100, 0, 0, 0)), (t + 2.5, (0, 0, 0, 0, 0))] + try: + assert scanlight.light_dose(t + 0.5, t + 3.0) == pytest.approx((100.0, 100.0, 0.0, 0.0, 0.0)) + finally: + scanlight._history[:] = [(0.0, (0, 0, 0, 0, 0))] + + +@pytest.mark.parametrize("release", [BulbRelease.USB, BulbRelease.CABLE]) +def test_a_blend_exposure_matches_the_three_leds_lit_together_for_the_same_time(camera, tmp_path, sim_env, release): + light = Scanlight() + try: + light.set_color(200, 100, 50) + together = camera.capture(str(tmp_path / "together.raw"), shutter="1/5") + service = CaptureService(light, camera) + settings = CaptureSettings(roll_name="t", frame_number=1, output_folder=str(tmp_path), levels=(200, 100, 50)) + blend = service.capture_blend(settings, seconds=0.2, release=release) + finally: + light.close() + + assert blend.endswith("t_Frame001.DNG") + # The simulated light is linear, so each LED alone for 0.2 s adds up to all three for 0.2 s. + assert _base_signal(blend) == pytest.approx(_base_signal(together), rel=0.1) + + +def test_a_blend_calibration_measures_the_sensor_leak_on_the_simulated_rig(camera, tmp_path, sim_env): + light = Scanlight() + try: + service = CalibrationService(light, camera, lambda p: linear_demosaic(p, half_size=True), settle_s=0.0) + result = service.calibrate(_REBATE, str(tmp_path / "cal"), measure_sensor=True) + finally: + light.close() + assert not result.single_capture + mixing = np.linalg.inv(np.array(result.sensor_matrix).reshape(3, 3)) + leaks = mixing[~np.eye(3, dtype=bool)] + assert np.diag(mixing) == pytest.approx([1.0] * 3) and np.all((leaks > 0) & (leaks < 0.2)) From 5b72de9d86e049cbf37f45e4185c8516f7e25ba3 Mon Sep 17 00:00:00 2001 From: packetscrub <49302503+packetscrub@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:39:45 -0800 Subject: [PATCH 25/46] fix(scanners): detect SANE film scanners by their device type The avision backend gives its Minolta film scanners one source, "Normal", which maps to no film mode. These scanners have no RGBI mode and no negative-film invert option, so NegPy skipped them with "no recognised film sources". SANE reports the device type "film scanner" for these models. Film detection now reads that type from get_devices() before the other signals. It also works through saned, because the net backend passes the type on. Co-Authored-By: Claude Opus 5.5 --- negpy/infrastructure/scanners/sane_backend.py | 21 +++++++++++-------- tests/scanners/test_capabilities.py | 9 ++++++++ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/negpy/infrastructure/scanners/sane_backend.py b/negpy/infrastructure/scanners/sane_backend.py index 818bba47d..53b17b832 100644 --- a/negpy/infrastructure/scanners/sane_backend.py +++ b/negpy/infrastructure/scanners/sane_backend.py @@ -108,12 +108,15 @@ def _mode_has_rgbi(opt) -> bool: return any(str(v).strip().lower() == "rgbi" for v in constraint) -def _infer_film_scanner(opt, device_id: str) -> bool: - """Heuristically decide a device is a dedicated film scanner lacking a `source` option. +def _infer_film_scanner(opt, device_id: str, device_type: str = "") -> bool: + """Heuristically decide a device is a dedicated film scanner lacking a film `source`. - Signals: an RGBI mode, an `invert` option described as negative-film correction, or a - known film-scanner backend prefix. Kept narrow to avoid matching plain flatbeds. + Signals: the SANE device type "film scanner", an RGBI mode, an `invert` option described + as negative-film correction, or a known film-scanner backend prefix. Kept narrow to avoid + matching plain flatbeds. """ + if device_type.strip().lower() == "film scanner": + return True if _mode_has_rgbi(opt): return True invert = opt["invert"] if "invert" in opt else None @@ -645,10 +648,10 @@ def _require_writable_option(opt, option_name: str, absent_message: str) -> None raise RuntimeError(f"Requested SANE option {option_name!r} is not settable") -def _caps_from_options(opt, device_id: str = "") -> ScannerCapabilities: +def _caps_from_options(opt, device_id: str = "", device_type: str = "") -> ScannerCapabilities: """Build ScannerCapabilities from a SANE option map. Pure — no `sane` import.""" sources = _detect_explicit_sources(opt) - if not sources and _infer_film_scanner(opt, device_id): + if not sources and _infer_film_scanner(opt, device_id, device_type): # Dedicated film scanner with no `source` option (pieusb). `sources` is only a detection # and UI gate, never applied to the device, so populate it to unblock scanning. sources = (ScanMode.NEGATIVE, ScanMode.POSITIVE, ScanMode.TRANSPARENCY) @@ -945,7 +948,7 @@ def list_devices(self) -> list[ScannerDevice]: continue try: dev = self._sane.open(raw[0]) - caps = self._detect_caps(dev, raw[0]) + caps = self._detect_caps(dev, raw[0], raw[3] if len(raw) > 3 else "") dev.close() if caps.sources: devices.append( @@ -1470,7 +1473,7 @@ def _set_pieusb_flags(self, dev, capture_ir) -> None: except Exception as e: logger.warning(f"Could not set SANE pieusb option {name}={val}: {e}") - def _detect_caps(self, dev, device_id: str = "") -> ScannerCapabilities: + def _detect_caps(self, dev, device_id: str = "", device_type: str = "") -> ScannerCapabilities: """Read dev.opt to build ScannerCapabilities. Switches to the transparency source first, when the device has one, so geometry @@ -1491,7 +1494,7 @@ def _detect_caps(self, dev, device_id: str = "") -> ScannerCapabilities: opt = dev.opt if hasattr(dev, "opt") else {} except Exception as e: logger.warning(f"Could not switch {device_id!r} to {transparency_source!r} for capability probe: {e}") - return _caps_from_options(opt, device_id) + return _caps_from_options(opt, device_id, device_type) @staticmethod def _ir_strategy(dev, device_id) -> str | None: diff --git a/tests/scanners/test_capabilities.py b/tests/scanners/test_capabilities.py index 21320e814..c8e4d9b32 100644 --- a/tests/scanners/test_capabilities.py +++ b/tests/scanners/test_capabilities.py @@ -166,6 +166,15 @@ def test_film_inferred_without_pieusb_id(self) -> None: caps = _caps_from_options(_pieusb_opt(), "othervendor:libusb:001:001") assert caps.sources + def test_film_inferred_from_sane_device_type(self) -> None: + # avision film scanners offer only a "Normal" source but report the SANE type. + opt = { + "source": FakeOption(constraint=["Normal"]), + "mode": FakeOption(constraint=["Color", "Gray"]), + } + assert _caps_from_options(opt, "avision:libusb:001:013", "film scanner").sources + assert not _caps_from_options(opt, "avision:libusb:001:013", "flatbed scanner").sources + def test_roll_adapter_frame_range_is_reported_as_capacity(self) -> None: caps = _caps_from_options( { From 0c1f7182514094c54bc36e4167f9a87c738c9076 Mon Sep 17 00:00:00 2001 From: packetscrub <49302503+packetscrub@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:17:09 -0800 Subject: [PATCH 26/46] fix(scanners): set the color depth by mode name on avision avision has no depth option. Its depth is part of the mode name: "Color" is 8-bit, and "12bit Color" and "16bit Color" both deliver 16-bit samples. Film models that follow the vendor driver offer no "Color" mode, so setting it failed with "Invalid argument". On other avision scanners, a 16-bit scan delivered 8-bit data. When a backend has no depth option, the offered depths come from the color mode names, and a color scan picks the mode for its depth. Backends with a depth option keep "Color". Co-Authored-By: Claude Opus 5.5 --- negpy/infrastructure/scanners/sane_backend.py | 39 ++++++++++++++++++- tests/scanners/test_capabilities.py | 18 +++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/negpy/infrastructure/scanners/sane_backend.py b/negpy/infrastructure/scanners/sane_backend.py index 53b17b832..bbee16ba0 100644 --- a/negpy/infrastructure/scanners/sane_backend.py +++ b/negpy/infrastructure/scanners/sane_backend.py @@ -1,4 +1,5 @@ import math +import re import subprocess import sys import threading @@ -237,7 +238,7 @@ def _resolve_resampled_resolutions(opt) -> dict[int, int]: def _detect_depths(opt) -> tuple[int, ...]: if "depth" not in opt: - return (8, 16) + return tuple(sorted(_color_modes_by_depth(opt))) or (8, 16) constraint = opt["depth"].constraint if not isinstance(constraint, list): return (8, 16) @@ -450,6 +451,39 @@ def _sane_container_depth(requested_depth: int) -> int: return 8 if requested_depth <= 8 else 16 +_DEPTH_COLOR_MODE = re.compile(r"(\d+)\s*bit\s+colou?r") + + +def _color_modes_by_depth(opt) -> dict[int, str]: + """Color modes keyed by sample container, for a backend that sets depth by mode name. + + avision has no `depth` option: it offers "Color" (8-bit) and "12bit Color" or "16bit Color" + (both 16-bit containers), and some film models leave out "Color". Empty with a `depth` option. + """ + if "depth" in opt or "mode" not in opt: + return {} + constraint = opt["mode"].constraint + if not isinstance(constraint, (list, tuple)): + return {} + modes: dict[int, tuple[int, str]] = {} + for value in constraint: + name = str(value) + lowered = name.strip().lower() + match = _DEPTH_COLOR_MODE.fullmatch(lowered) + bits = 8 if lowered == "color" else int(match.group(1)) if match else 0 + if bits < 8: + continue + container = _sane_container_depth(bits) + if container not in modes or bits > modes[container][0]: + modes[container] = (bits, name) + return {container: name for container, (_, name) in modes.items()} + + +def _resolve_color_mode(opt, depth: int) -> str: + """The `mode` value for a color scan at `depth`: "Color" unless the depth is in the mode name.""" + return _color_modes_by_depth(opt).get(_sane_container_depth(depth), "Color") + + def _option_is_usable(option) -> bool: """Fail-closed capability probe: is the option active and settable?""" for method_name in ("is_active", "is_settable"): @@ -1144,7 +1178,8 @@ def _scan_on_device( # options the device has. Set after source/film_type above, since those can reset # depth/resolution to backend defaults on this device. if hasattr(dev, "opt") and "mode" in dev.opt: - dev.mode = "RGBI" if (ir_strategy == "rgbi" or ir_strategy == "rgbi-hw3") else "Color" + rgbi = ir_strategy == "rgbi" or ir_strategy == "rgbi-hw3" + dev.mode = "RGBI" if rgbi else _resolve_color_mode(dev.opt, params.depth) dev.depth = params.depth # `resolution` alone is sometimes an artificially narrow convenience subset of what diff --git a/tests/scanners/test_capabilities.py b/tests/scanners/test_capabilities.py index c8e4d9b32..1fa570bb8 100644 --- a/tests/scanners/test_capabilities.py +++ b/tests/scanners/test_capabilities.py @@ -10,6 +10,7 @@ _SOURCE_MAP, _caps_from_options, _detect_auto_exposure, + _resolve_color_mode, _split_rgbi, ) @@ -175,6 +176,23 @@ def test_film_inferred_from_sane_device_type(self) -> None: assert _caps_from_options(opt, "avision:libusb:001:013", "film scanner").sources assert not _caps_from_options(opt, "avision:libusb:001:013", "flatbed scanner").sources + def test_depth_from_mode_name_without_color_mode(self) -> None: + # avision film scanners with vendor options offer no 8-bit "Color" mode. + opt = {"mode": FakeOption(constraint=["Lineart", "Dithered", "Gray", "16bit Gray", "16bit Color"])} + assert _caps_from_options(opt, "avision:libusb:001:013", "film scanner").supported_depths == (16,) + assert _resolve_color_mode(opt, 16) == "16bit Color" + + def test_depth_from_mode_name_with_color_mode(self) -> None: + opt = {"mode": FakeOption(constraint=["Gray", "12bit Gray", "Color", "12bit Color"])} + assert _caps_from_options(opt).supported_depths == (8, 16) + assert _resolve_color_mode(opt, 8) == "Color" + assert _resolve_color_mode(opt, 16) == "12bit Color" + + def test_depth_option_keeps_the_color_mode(self) -> None: + opt = _pieusb_opt() + opt["mode"] = FakeOption(constraint=["Color", "16bit Color"]) + assert _resolve_color_mode(opt, 16) == "Color" + def test_roll_adapter_frame_range_is_reported_as_capacity(self) -> None: caps = _caps_from_options( { From eb5b7e567f651e3469f43633a0c374c0acaa49ac Mon Sep 17 00:00:00 2001 From: Akinyemi Paul Odulate Date: Tue, 3 Nov 2026 18:49:57 +0000 Subject: [PATCH 27/46] Lento Dock: follow the published serial API Changes from the DOCK Serial API reference (fw 1.0.239, contract 1.15.0): - Scan with Next Frame or Whole Strip feeds a strip that waits at the entrance (st=IDLE, entrance=1) with INSERT. In SEMI the dock feeds a strip only on INSERT, and a port open resets it. The strip no longer has to go in between connect and Live View. - A command the dock answers with BUSY goes again every 250 ms until the dock's own exposure ends. - CONTRGB and CONTHOLD accept a bare OK, which the reference gives as their reply. - FRAMEAT ERR ends the strip, like ROLLEND. Refs #1375 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CiaS7mdcgupjy3zYtMa4Su --- docs/CAMERA_SCANNING.md | 6 +- negpy/desktop/workers/capture_worker.py | 17 ++++++ negpy/infrastructure/capture/lento_dock.py | 59 ++++++++++++++------ negpy/infrastructure/simulated/lento_dock.py | 36 ++++++++++-- negpy/translations/de.json | 2 + negpy/translations/ja.json | 2 + negpy/translations/ko.json | 2 + negpy/translations/zh_CN.json | 2 + tests/test_capture_worker.py | 32 +++++++++++ tests/test_lento_dock_driver.py | 40 +++++++++++++ 10 files changed, 173 insertions(+), 25 deletions(-) diff --git a/docs/CAMERA_SCANNING.md b/docs/CAMERA_SCANNING.md index 718c731a3..945513144 100644 --- a/docs/CAMERA_SCANNING.md +++ b/docs/CAMERA_SCANNING.md @@ -199,9 +199,9 @@ described above. stays in the `flatfield/references` folder, outside the roll. A strip that stops before its end gets no flat. The flat never comes first: after the dock moves the film out of the window and back, it does not advance. -- Insert the strip after the light status shows the dock and before you open Live View. The - dock then finds the first frame itself. Opening the dock's port resets the dock, so it does - not know the position of a strip that was in it before. +- Put the strip in the dock's entrance. With **Next Frame** or **Whole Strip**, Scan feeds a + strip that waits there to its first frame (`INSERT`). This also covers a strip that was in + the dock when NegPy opened its port, because opening the port resets the dock. - While NegPy uses the dock, the dock's S button works as Scan: it starts a scan, or stops one. The scan starts at the press. The dock also makes a 3-second exposure of its own, and the film moves only after it ends. An arrow button nudges the film on a tap and moves it while you hold it. NegPy diff --git a/negpy/desktop/workers/capture_worker.py b/negpy/desktop/workers/capture_worker.py index 2efa95146..731d7a638 100644 --- a/negpy/desktop/workers/capture_worker.py +++ b/negpy/desktop/workers/capture_worker.py @@ -332,6 +332,8 @@ def set_light(self, r: int, g: int, b: int, w: int, port: str) -> None: def run_capture(self, req: CaptureRequest) -> bool: """Capture one frame, or a whole strip. True when the frame is on disk; a failure or Stop has emitted `error` or `cancelled`.""" + if (req.whole_strip or req.advance_film) and not req.is_retake and not self._feed_strip(req.port): + return False if req.whole_strip: self._run_strip(req) return False @@ -505,6 +507,21 @@ def _finish_frame(self, req: CaptureRequest, paths: list) -> None: if req.advance_film and not req.is_retake: self._advance_film(req.port) + def _feed_strip(self, port: str) -> bool: + """Feed a strip that waits at the light's entrance to its first frame. False when the feed + failed and `error` has been emitted.""" + try: + light = self._ensure_light(port) + if not getattr(light, "strip_waiting", lambda: False)(): + return True + self.status.emit(tr("Feeding the strip to the first frame…")) + light.insert_strip() + except Exception as exc: # noqa: BLE001 + logger.exception("strip feed failed") + self.error.emit(tr("The dock did not feed the strip: {error}. Insert it again.", error=exc)) + return False + return True + def _advance_film(self, port: str, eject: bool = True) -> Optional[bool]: """Move the light's film one frame. True at the end of the strip, False when the next frame is in place, None when the light has no film transport or the move failed. With `eject`, diff --git a/negpy/infrastructure/capture/lento_dock.py b/negpy/infrastructure/capture/lento_dock.py index 84d16cfb9..c4327743e 100644 --- a/negpy/infrastructure/capture/lento_dock.py +++ b/negpy/infrastructure/capture/lento_dock.py @@ -47,6 +47,7 @@ #: The motor moves one frame and checks the frame edge within this time. ADVANCE_TIMEOUT_S = 15.0 EXPOSE_WAIT_S = 5.0 # the dock's own exposure on an S press lasts 3 s +BUSY_RETRY_S = 0.25 # the API's retry interval for a BUSY reply EJECT_TIMEOUT_S = 30.0 HOLD_INTERVAL_S = 30.0 @@ -208,15 +209,15 @@ def set_color(self, r: int = 0, g: int = 0, b: int = 0, w: int = 0, ir: int = 0, self.off() return self._enter_continuous() - self._command(f"CONTRGB {duties[0]} {duties[1]} {duties[2]}", r"^(CONTRGB|ERR)") + self._command(f"CONTRGB {duties[0]} {duties[1]} {duties[2]}", r"^(OK|CONTRGB|ERR)") if not self._light_on: - self._command("CONTHOLD 1", r"^(CONTHOLD|ERR)") + self._command("CONTHOLD 1", r"^(OK|CONTHOLD|ERR)") self._last_hold = time.monotonic() self._light_on = True def off(self) -> None: if self._continuous: - self._command("CONTRGB 0 0 0", r"^(CONTRGB|ERR)") + self._command("CONTRGB 0 0 0", r"^(OK|CONTRGB|ERR)") self._light_on = False def advance_frame(self) -> Optional[int]: @@ -241,6 +242,23 @@ def advance_frame(self) -> Optional[int]: return None return int(parse_kv(self._last_frameat).get("idx", "-1")) + def strip_waiting(self) -> bool: + """True when a strip sits at the entrance and the dock has not fed it: in SEMI the dock + feeds a strip only on INSERT, and a reset forgets one it had fed.""" + self._command("STATE?", r"^STATE\b") + return self._state.get("st") == "IDLE" and self._state.get("entrance") == "1" + + def insert_strip(self) -> None: + """Feed the strip at the entrance and align its first frame in the window.""" + self._enter_continuous() + line = self._command("INSERT", r"^(OK|ERR)", then=((r"^(FRAMEAT|INSERT ERR)\b", ADVANCE_TIMEOUT_S),)) + if not line.startswith("FRAMEAT") or line.startswith("FRAMEAT ERR"): + raise RuntimeError(f"the dock reported {line!r}") + deadline = time.monotonic() + REPLY_TIMEOUT_S + while self._state.get("st") != "READY" and time.monotonic() < deadline: + time.sleep(0.05) + self._light_on = False # the next set_color holds the light again + def eject(self) -> None: """Move the strip out of the dock. The last frame stops in the window, short of the exit.""" self._enter_continuous() @@ -294,8 +312,8 @@ def _enter_continuous(self) -> None: def _restore(self) -> None: if not self._continuous: return - self._command("CONTRGB 0 0 0", r"^(CONTRGB|ERR)") - self._command("CONTHOLD 0", r"^(CONTHOLD|ERR)") + self._command("CONTRGB 0 0 0", r"^(OK|CONTRGB|ERR)") + self._command("CONTHOLD 0", r"^(OK|CONTHOLD|ERR)") expmode = self._saved.get("EXPMODE", "BRGB") self._command(f"EXPMODE {'BRGB' if expmode == 'CONT' else expmode}", r"^(EXPMODE=|ERR)") mode = self._saved.get("MODE", "AUTO") @@ -309,20 +327,29 @@ def _send(self, cmd: str) -> None: def _command(self, cmd: str, expect: str, timeout: float = REPLY_TIMEOUT_S, then: tuple[tuple[str, float], ...] = ()) -> str: """Send `cmd` and return the first reply line that matches `expect`. Each `(pattern, - timeout)` in `then` waits for a later line in turn, and the last line matched is returned.""" + timeout)` in `then` waits for a later line in turn, and the last line matched is returned. + The dock answers BUSY during its own exposure; the command goes again until that ends.""" if self._reader_error is not None: raise ConnectionError(f"Lento Dock reader stopped: {self._reader_error}") + first = re.compile(rf"(?:{expect})|^BUSY\b") with self._write_lock: - while not self._replies.empty(): - try: - self._replies.get_nowait() - except queue.Empty: - break - self._serial.write((cmd + "\r\n").encode("ascii")) - self._last_tx = time.monotonic() self._in_command = True try: - line = self._await(cmd, expect, timeout) + busy_until = time.monotonic() + EXPOSE_WAIT_S + while True: + while not self._replies.empty(): + try: + self._replies.get_nowait() + except queue.Empty: + break + self._serial.write((cmd + "\r\n").encode("ascii")) + self._last_tx = time.monotonic() + line = self._await(cmd, first.pattern, timeout) + if not line.startswith("BUSY"): + break + if time.monotonic() > busy_until: + raise TimeoutError(f"The dock stayed busy after {cmd!r}") + time.sleep(BUSY_RETRY_S) for pattern, wait in then: line = self._await(cmd, pattern, wait) finally: @@ -389,9 +416,9 @@ def _wait_out_exposure(self) -> None: def _dispatch(self, line: str) -> None: logger.debug("Lento Dock: %s", line) - if line.startswith("FRAMEAT"): + if line.startswith("FRAMEAT") and not line.startswith("FRAMEAT ERR"): self._last_frameat = line - elif line.startswith(("END_OF_ROLL", "ROLLEND")): + elif line.startswith(("END_OF_ROLL", "ROLLEND", "FRAMEAT ERR")): self._last_frameat = "" if line.startswith("STATE"): was = self._state.get("st") diff --git a/negpy/infrastructure/simulated/lento_dock.py b/negpy/infrastructure/simulated/lento_dock.py index abe1f6aa6..7e62190b9 100644 --- a/negpy/infrastructure/simulated/lento_dock.py +++ b/negpy/infrastructure/simulated/lento_dock.py @@ -11,7 +11,9 @@ class SimDockSerial: port = "sim-dock" - def __init__(self, *, saved_mode: str = "AUTO", saved_expmode: str = "BRGB", frames: int = 6) -> None: + def __init__( + self, *, saved_mode: str = "AUTO", saved_expmode: str = "BRGB", frames: int = 6, fed: bool = True, ok_replies: bool = False + ) -> None: self.is_open = True self.lines: list[str] = [] # every command received, in order self.mode = saved_mode @@ -21,7 +23,9 @@ def __init__(self, *, saved_mode: str = "AUTO", saved_expmode: str = "BRGB", fra self.duty = (0, 0, 0) self.frames = frames # frames on the strip; the first one is in the window self.frame = 0 # the frame in the window, counted from the first - self.st = "READY" if frames else "IDLE" + self.st = "READY" if frames and fed else "IDLE" # an unfed strip waits at the entrance for INSERT + self.ok_replies = ok_replies # firmware 1.0.239 answers CONTRGB and CONTHOLD with a bare OK + self.busy = 0 # the next commands answered BUSY, as during the dock's own exposure self._out: queue.Queue[bytes] = queue.Queue() self._buf = b"" self._say("BOOT fw=1.0.224 model=35 reason=nrst film=0 entrance=0 cal=persisted") @@ -45,7 +49,7 @@ def film(self) -> int: def _state(self) -> str: return ( f"STATE link=1 seq=1 mode={self.mode} st={self.st} film={self.film} frame={self.frame - 1}/{self.frame} " - f"busy=0 expmode={self.expmode} shutter=JACK" + f"entrance={int(self.frames > 0)} busy=0 expmode={self.expmode} shutter=JACK" ) def press_scan(self, hold_s: float = 0.0) -> None: @@ -64,7 +68,7 @@ def done() -> None: done() def _frame_next(self) -> None: - if self.mode != "SEMI" or self.frames == 0 or self.st == "EXPOSE": + if self.mode != "SEMI" or self.frames == 0 or self.st != "READY": self._say("ERR STATE") return self._say("OK") @@ -89,8 +93,26 @@ def write(self, data: bytes) -> None: self.lines.append(line) self._answer(line) + def _insert(self) -> None: + if self.st != "IDLE": + self._say("ERR STATE") + return + self._say("OK") + if self.frames == 0: + self._say("INSERT ERR no_border") + return + self.st = "ALIGN" + self._say(self._state()) + self.st = "READY" + self._say("FRAMEAT idx=-1 count=0") + self._say(self._state()) + def _answer(self, line: str) -> None: cmd, _, arg = line.partition(" ") + if self.busy and cmd not in ("STATE?", "PING"): + self.busy -= 1 + self._say("BUSY") + return if cmd == "IDENT": self._say(_IDENT) elif cmd == "VER": @@ -108,9 +130,11 @@ def _answer(self, line: str) -> None: self._say(f"IDLE={arg}") elif cmd == "CONTHOLD" and arg in ("0", "1"): self.hold = int(arg) - self._say(f"CONTHOLD {arg}") + self._say("OK" if self.ok_replies else f"CONTHOLD {arg}") elif line == "FRAME NEXT": self._frame_next() + elif line == "INSERT": + self._insert() elif line == "EJECT": self._say("OK") self.frames = self.frame = 0 @@ -121,7 +145,7 @@ def _answer(self, line: str) -> None: parts = arg.split() if len(parts) == 3 and all(p.isdigit() and int(p) <= 1000 for p in parts): self.duty = (int(parts[0]), int(parts[1]), int(parts[2])) - self._say(f"CONTRGB {arg}") + self._say("OK" if self.ok_replies else f"CONTRGB {arg}") else: self._say("ERR") else: diff --git a/negpy/translations/de.json b/negpy/translations/de.json index 8227071cf..48a1c09ae 100644 --- a/negpy/translations/de.json +++ b/negpy/translations/de.json @@ -927,6 +927,7 @@ "Favorites": "Favoriten", "Favorites tab": "Tab Favoriten", "Feather": "Weichzeichnung", + "Feeding the strip to the first frame…": "Streifen wird zum ersten Bild eingezogen…", "Fetch the model once; it is cached for every later search": "Modell einmal laden. Es wird für jede spätere Suche zwischengespeichert", "File": "Datei", "File Already Exists": "Datei existiert bereits", @@ -2606,6 +2607,7 @@ "The developer that the film was processed in.": "Der Entwickler, in dem der Film entwickelt wurde.", "The developer the film was processed in": "Der Entwickler, in dem der Film entwickelt wurde", "The developer this process uses": "Der Entwickler, den dieser Prozess nutzt", + "The dock did not feed the strip: {error}. Insert it again.": "Das Dock hat den Streifen nicht eingezogen: {error}. Lege ihn erneut ein.", "The dodge and burn map and the print recipe": "Die Abwedeln-und-Nachbelichten-Karte und das Abzugsrezept", "The edit, as it prints": "Die Bearbeitung, wie sie gedruckt wird", "The exposure of the original shot, if you know it. Click the lock to type free text such as 1/125s f/2.8 ISO 400 -1/3ev.": "Die Belichtung der Originalaufnahme, falls du sie kennst. Klicke auf das Schloss, um freien Text wie 1/125s f/2.8 ISO 400 -1/3ev einzugeben.", diff --git a/negpy/translations/ja.json b/negpy/translations/ja.json index 68a1a6ae3..4b7d5658d 100644 --- a/negpy/translations/ja.json +++ b/negpy/translations/ja.json @@ -927,6 +927,7 @@ "Favorites": "お気に入り", "Favorites tab": "お気に入りタブ", "Feather": "フェザー", + "Feeding the strip to the first frame…": "ストリップを最初のフレームまで送っています…", "Fetch the model once; it is cached for every later search": "モデルを一度取得します。以降の検索のためにキャッシュされます", "File": "ファイル", "File Already Exists": "ファイルはすでに存在します", @@ -2606,6 +2607,7 @@ "The developer that the film was processed in.": "フィルムの現像に使った現像液。", "The developer the film was processed in": "フィルムの現像に使った現像液", "The developer this process uses": "このプロセスで使う現像液", + "The dock did not feed the strip: {error}. Insert it again.": "Dock がストリップを送り込めませんでした: {error}。もう一度挿入してください。", "The dodge and burn map and the print recipe": "覆い焼き・焼き込みマップとプリントレシピ", "The edit, as it prints": "プリント時の編集内容", "The exposure of the original shot, if you know it. Click the lock to type free text such as 1/125s f/2.8 ISO 400 -1/3ev.": "分かっていれば、元の撮影時の露出。鍵をクリックして、1/125s f/2.8 ISO 400 -1/3ev のように自由に記述します。", diff --git a/negpy/translations/ko.json b/negpy/translations/ko.json index 7a4422a57..6c03ae7d0 100644 --- a/negpy/translations/ko.json +++ b/negpy/translations/ko.json @@ -927,6 +927,7 @@ "Favorites": "즐겨찾기", "Favorites tab": "즐겨찾기 탭", "Feather": "페더", + "Feeding the strip to the first frame…": "스트립을 첫 프레임까지 넣는 중…", "Fetch the model once; it is cached for every later search": "모델을 한 번만 받으면 이후 모든 검색에 캐시가 쓰입니다.", "File": "파일", "File Already Exists": "파일이 이미 존재합니다", @@ -2606,6 +2607,7 @@ "The developer that the film was processed in.": "필름을 현상한 현상액입니다.", "The developer the film was processed in": "필름을 현상한 현상액", "The developer this process uses": "이 프로세스가 쓰는 현상액", + "The dock did not feed the strip: {error}. Insert it again.": "Dock이 스트립을 넣지 못했습니다: {error}. 다시 넣으세요.", "The dodge and burn map and the print recipe": "닷징·버닝 맵과 인화 레시피", "The edit, as it prints": "편집 내용이 인화되는 대로", "The exposure of the original shot, if you know it. Click the lock to type free text such as 1/125s f/2.8 ISO 400 -1/3ev.": "알고 있다면 원래 촬영의 노출입니다. 자물쇠를 클릭해 1/125s f/2.8 ISO 400 -1/3ev 같은 자유 문구를 입력하세요.", diff --git a/negpy/translations/zh_CN.json b/negpy/translations/zh_CN.json index e4de0558d..637aacdd5 100644 --- a/negpy/translations/zh_CN.json +++ b/negpy/translations/zh_CN.json @@ -927,6 +927,7 @@ "Favorites": "收藏", "Favorites tab": "收藏选项卡", "Feather": "羽化", + "Feeding the strip to the first frame…": "正在将胶片条送到第一帧…", "Fetch the model once; it is cached for every later search": "模型只需下载一次,之后搜索使用缓存", "File": "文件", "File Already Exists": "文件已存在", @@ -2606,6 +2607,7 @@ "The developer that the film was processed in.": "冲洗胶片所用的显影液。", "The developer the film was processed in": "冲洗胶片所用的显影液", "The developer this process uses": "此工艺使用的显影液", + "The dock did not feed the strip: {error}. Insert it again.": "Dock 未能送入胶片条:{error}。请重新插入。", "The dodge and burn map and the print recipe": "局部加减光图与印相配方", "The edit, as it prints": "编辑后的印相效果", "The exposure of the original shot, if you know it. Click the lock to type free text such as 1/125s f/2.8 ISO 400 -1/3ev.": "原始拍摄的曝光(如果知道)。点击锁可输入自由文本,如 1/125s f/2.8 ISO 400 -1/3ev。", diff --git a/tests/test_capture_worker.py b/tests/test_capture_worker.py index 0b8265a03..ff4403c87 100644 --- a/tests/test_capture_worker.py +++ b/tests/test_capture_worker.py @@ -1213,3 +1213,35 @@ def test_the_lights_scan_button_reaches_the_worker_signal(monkeypatch): worker._ensure_light("") dock.on_scan_button() assert pressed == [1] + + +class EntranceDock(StripDock): + """A strip that waits at the entrance until the worker feeds it.""" + + def __init__(self, frames: int) -> None: + super().__init__(frames) + self.waiting = True + + def strip_waiting(self) -> bool: + return self.waiting + + def insert_strip(self) -> None: + self.events.append("insert") + self.waiting = False + + +def test_a_whole_strip_feeds_a_waiting_strip_first(tmp_path, monkeypatch): + dock = EntranceDock(frames=2) + seen, _ = _run_strip(tmp_path, monkeypatch, dock) + assert dock.events == ["insert", "frame", "advance", "frame", "advance", "eject"] + assert seen["strip"] == [(2, True)] + + +def test_a_failed_feed_stops_before_the_first_frame(tmp_path, monkeypatch): + class StuckEntrance(EntranceDock): + def insert_strip(self) -> None: + raise RuntimeError("the dock reported 'INSERT ERR no_border'") + + dock = StuckEntrance(frames=2) + seen, _ = _run_strip(tmp_path, monkeypatch, dock) + assert seen["error"] and seen["finished"] == [] and seen["strip"] == [] diff --git a/tests/test_lento_dock_driver.py b/tests/test_lento_dock_driver.py index 1f69631ea..6d106b8be 100644 --- a/tests/test_lento_dock_driver.py +++ b/tests/test_lento_dock_driver.py @@ -269,3 +269,43 @@ def test_the_s_button_calls_the_scan_handler(sim): assert pressed == [1] # at the press, not at the end of the dock's exposure assert dock.advance_frame() == 1 # the film waits for the dock's exposure to end assert pressed == [1] + + +def test_a_strip_at_the_entrance_is_fed_to_its_first_frame(): + sim = SimDockSerial(frames=4, fed=False) + with LentoDock(serial_obj=sim) as dock: + assert dock.strip_waiting() + dock.insert_strip() + assert sim.st == "READY" and not dock.strip_waiting() + assert dock.advance_frame() == 0 + + +def test_a_failed_feed_raises(): + sim = SimDockSerial(frames=0) + sim.frames = 0 + with LentoDock(serial_obj=sim) as dock: + with pytest.raises(RuntimeError, match="INSERT ERR"): + dock.insert_strip() + + +def test_firmware_that_answers_ok_still_lights_the_panel(): + sim = SimDockSerial(ok_replies=True) + with LentoDock(serial_obj=sim) as dock: + dock.set_color(200, 110, 40) + assert sim.lit == tuple(to_duty(v) for v in (200, 110, 40)) + + +def test_a_busy_dock_gets_the_command_again(sim): + with LentoDock(serial_obj=sim) as dock: + dock._enter_continuous() + sim.busy = 2 # the dock's own exposure is running + dock.set_color(200, 110, 40) + assert sim.lines.count("CONTRGB 784 431 157") == 3 + assert sim.lit == tuple(to_duty(v) for v in (200, 110, 40)) + + +def test_frameat_err_is_the_end_of_the_strip(sim): + with LentoDock(serial_obj=sim) as dock: + dock._enter_continuous() + sim._frame_next = lambda: (sim._say("OK"), sim._say("FRAMEAT ERR no_edge"), sim._say(sim._state())) + assert dock.advance_frame() is None From b0c60ee1f69c4525d2196046a57cc8890408956d Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:36:38 +0100 Subject: [PATCH 28/46] fix(fade): stop a camera RAW's white at full scale With Highlight Recovery off, green clips and the white-balance gains lift red and blue past full scale in the same cells. The white read that clip color as the film's, so Remove Stain turned the frame green (white +0.104, 0, +0.102 on a clipped sky). Each channel now stops at full scale, as the decode with Recovery does, and the white is the same at both levels. --- docs/PIPELINE.md | 2 +- negpy/features/process/fade.py | 6 ++++-- tests/test_fade_white_anchor.py | 12 ++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index cfe00a313..666ba4aad 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -64,7 +64,7 @@ Pixel values are linear transmittance. The source corrections run in that domain Estimate (`features/process/fade.py`) runs on request, never in a render. It renders the frame on the CPU with $F$ at identity and White Anchor off, and reads the transparency meter grid (`transfer_grid`, made only on request through `PipelineContext.wants_fade_grid`: bakes, geometry, crop and unmix, in the channels the density matrix acts on, `density_channels`). It meters the grays with `measure_neutral_axis_from_log` against the frame's own bounds: first with the foliage and sky sectors excluded, against bounds from each channel's extreme cells with decoded zeros dropped, at confidence `MIN_CONFIDENCE` ($0.2$) or more. Else it reads against bounds from the `ROBUST_EXTREME_PCT` ($0.1$) percentiles, with the sectors excluded, then not, at `ROBUST_MIN_CONFIDENCE` ($0.4$). MIDTONE reads the midtone densities and also sets $a_r$; SPREAD reads each channel's midtone-to-shadow spread. Ratios clamp to $[0.2, 5]$, and the hint shows the unclamped value. The hint also shows the other method's reading: a large gap is a cast that a fade does not explain. A near-monochrome slide meters as faded. - White Anchor (`fade_white_anchor`, UI "Remove Stain") makes the matrix affine, $M(L - w) + t$. The stain $w$ is each channel's `WHITE_PCT` cell of the meter grid in log transmittance, without decoded zeros and cells at full scale in every channel; Estimate stores it as `fade_white_r/g/b`. It maps to the neutral $t = \min(\max_c w_c, -0.1)$ (`FADE_WHITE_HEADROOM`): the cleanest channel's own level, never within $0.1$ D of full scale. $F^{-1}$ acts on $L - w$ before $t$ is added, so the Estimate reads the grays on $\min(L - w, 0)$. The offset rides in the w lanes of the unmix rows on the GPU. + White Anchor (`fade_white_anchor`, UI "Remove Stain") makes the matrix affine, $M(L - w) + t$. The stain $w$ is each channel's `WHITE_PCT` cell of the meter grid in log transmittance, each channel capped at full scale (a camera RAW's white-balance gains lift a channel past it where another clipped), without decoded zeros and cells at full scale in every channel; Estimate stores it as `fade_white_r/g/b`. It maps to the neutral $t = \min(\max_c w_c, -0.1)$ (`FADE_WHITE_HEADROOM`): the cleanest channel's own level, never within $0.1$ D of full scale. $F^{-1}$ acts on $L - w$ before $t$ is added, so the Estimate reads the grays on $\min(L - w, 0)$. The offset rides in the w lanes of the unmix rows on the GPU. A camera RAW's clear film sits below the decoder's white by the exposure and the light, so $F^{-1}$ about $L = 0$ would scale that offset into a cast. On a RAW the matrix is always $M(L - a) + t$ (`density_anchor`). $w$ is read after the crosstalk unmix $U$, so $a = U^{-1} w$; crosstalk alone keeps a neutral neutral and takes no $a$. Remove Stain sets $a = w$ and the neutral $t$ above. Without it, $a = t$ is the neutral level $\max_c w_c$, so the white keeps its color: a slide's rebate is black, and with no clear film in the picture $w$ is the brightest picture color, which a per-channel anchor would add to every gray. Estimate stores $w$ with White Anchor off too and reads the grays on $\min(L - a, 0)$. A pixel above $a$ in every channel is light past the film, such as a highlight that Highlight Reconstruction decodes neutral at full scale. The matrix acts on $\min(L, a)$, and $e = \max(\min_c(L_c - a_c), 0)$ is added to every channel after it (`fade_density`, `unmix_log_anchored`, the shader's `anchor` row), so that light passes as a neutral. Estimate reads its grid with the stored white cleared. A JPEG or scanner TIFF has its white at full scale and keeps $L = 0$, with no cap. `fade_white_space` tags $w$ with the space it was read in: the white-balanced camera channels, after the crosstalk unmix, named by a hash of the white-balance gains, the unmix and a channel marker (`render_white_space`), so an Input ICC keeps it, "" with no camera matrix. A render whose tag differs, or that has no $w$, measures $w$ on the Estimate grid before the density stage (`resolve_fade_white`), in both engines and in the tiled export's reference pass. The desktop stores it with no roll lock. A roll keeps no camera RAW white, because it moves with each frame's exposure, and a RAW frame with its own white ignores a roll's scanner white. * **Density above base**: `density_above_base` is (Dmin, Dmed, Dmax) above the clear base, luminance-weighted: the thin and dense bounds (before trims; the roll's under Roll Analysis) and the metered median, each minus the base. The base is the picked one, else the rebate reader's, unmixed like the meters. A pick is the open roll's (`rolls.roll_film_base`), or the frame's own with no roll open (`process.film_base`, unhashed); in an open roll the roll's wins, with none open the frame's. The preview carries it as `film_base`; export never reads it. The reader takes the flat 8-px blocks (std over mean under 0.03, unclipped) in the outer 15% of the film box (`film_and_picture` at `AUTOCROP_DETECT_RES`), groups them by brightness with a 0.04-decade gap, and takes the largest group of at least five. It finds none without a film box, with a base darker than the picture, or on the transfer path. The preview reads it once per source, on the flat-fielded, unmixed source before the defect repairs, and passes the read to both engines. diff --git a/negpy/features/process/fade.py b/negpy/features/process/fade.py index 4f8b6064d..96a98a2a7 100644 --- a/negpy/features/process/fade.py +++ b/negpy/features/process/fade.py @@ -269,8 +269,10 @@ def _pct(p: float) -> list: def measure_white(grid: ImageBuffer) -> Optional[tuple[float, float, float]]: """Each channel's white: the log transmittance of its brightest cells in the analysis grid, or None with no cell left. A cell at full scale in every channel is light past the film (a mount gap, - a leak or a blown specular), and a decoded zero is no reading, so neither counts.""" - cells = grid.reshape(-1, 3) + a leak or a blown specular), and a decoded zero is no reading, so neither counts. A camera RAW's + white-balance gains lift a channel past full scale where another channel clipped, so a channel + stops at full scale, as the decode with Highlight Recovery does.""" + cells = np.minimum(grid.reshape(-1, 3), 0.0) cells = cells[(cells > CLIPPED_LOG).all(axis=1) & (cells < 0.0).any(axis=1)] if not len(cells): return None diff --git a/tests/test_fade_white_anchor.py b/tests/test_fade_white_anchor.py index 7843b0c97..197236659 100644 --- a/tests/test_fade_white_anchor.py +++ b/tests/test_fade_white_anchor.py @@ -202,6 +202,18 @@ def test_the_white_skips_cells_clipped_in_every_channel(): assert measure_white(leak) != (0.0, 0.0, 0.0) +def test_the_white_stops_at_full_scale_where_the_gains_lift_a_clipped_cell(): + """Recovery Off: green clips at full scale and the white-balance gains lift red and blue past it.""" + _clean, faded = _stained_slide() + grid = prefilter_log_grid(faded, None, 0.0) + clipped = grid.copy() + clipped[: max(1, clipped.shape[0] // 4)] = (0.15, 0.0, 0.10) + clipped[: max(1, clipped.shape[0] // 4), ::2, 2] = -0.01 + white = measure_white(clipped) + assert white is not None and max(white) <= 0.0 + np.testing.assert_allclose(white, (0.0, 0.0, 0.0), atol=0.02) + + def test_no_white_without_a_film_cell(): assert measure_white(np.zeros((8, 8, 3), np.float32)) is None assert measure_white(np.full((8, 8, 3), -6.0, np.float32)) is None From 855405362d34b8963ecbaa1d640670fcfa89acea Mon Sep 17 00:00:00 2001 From: red_prs Date: Wed, 4 Nov 2026 11:33:31 +0900 Subject: [PATCH 29/46] feat(files): add Frame No. sort and contact-sheet order Film Strip Sort and the contact-sheet Order gain a Frame No. option. It orders by the metadata Roll, then the Frame No., the file name a tie. The name, date and scene orders are unchanged. Adds the label to the four catalogs and updates the frames and contact_sheet guide panels (en, de, ja, ko, zh_CN). Co-Authored-By: Claude Code --- docs/USER_GUIDE.md | 4 ++-- negpy/desktop/session.py | 13 +++++++++++-- negpy/desktop/view/sidebar/files.py | 8 ++++++-- negpy/desktop/view/widgets/contact_sheet_dialog.py | 9 ++++++--- negpy/services/assets/search.py | 2 ++ negpy/services/export/contact_sheet_layout.py | 2 +- negpy/services/export/contact_sheet_roll.py | 11 +++++++++++ negpy/translations/USER_GUIDE.de.md | 8 ++++---- negpy/translations/USER_GUIDE.en.md | 4 ++-- negpy/translations/USER_GUIDE.ja.md | 8 ++++---- negpy/translations/USER_GUIDE.ko.md | 8 ++++---- negpy/translations/USER_GUIDE.zh_CN.md | 8 ++++---- negpy/translations/de.json | 3 ++- negpy/translations/ja.json | 3 ++- negpy/translations/ko.json | 3 ++- negpy/translations/zh_CN.json | 3 ++- 16 files changed, 65 insertions(+), 32 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 1def3f736..a8af242bd 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -134,7 +134,7 @@ The toolbar's **Roll** row holds: * The roll's name and its count of frames, keepers and rejects. * The **filter box**: type part of a file name, or terms such as `film:portra iso:>=400` (below). The search button, or **Enter**, searches the whole library. -* **Sort**: by name, date or scene. +* **Sort**: by name, date, scene, or by roll and frame number. * **Sheet filter**: all frames, keepers only, hide rejected, or unmarked only. The button is highlighted while a filter hides frames. * **Roll Settings** (the tag): gear and film details for this frame, the selection or the roll. * **Apply**: copies chosen settings of this frame to the selection or the roll. @@ -711,7 +711,7 @@ On the **Alongside Exports** tab. A contact sheet shows the whole roll as film s * **Contact Sheet…**: opens the sheet. Drag an edge of the paper to resize it. * **Film format**: 35mm, 35mm Half Frame or 120 (with **Frame size** for 120). * **Paper**, **Width**, **Height** and **DPI**: the sheet's size and resolution. The default paper holds a 36-exposure roll. -* **Order**: by file name, by date, or by scene with a new strip or row for each scene. +* **Order**: by file name, by date, by roll and frame number, or by scene with a new strip or row for each scene. * **Print**: **As Edited**, or **Straight Proof**: the whole roll at one exposure and normal contrast, so you see which negatives are thin or dense. * **Pick Frames**: click a frame to leave it out or put it back. Rejects start out. * **Edge Print**: the film maker's markings along the edges, such as the film name, frame numbers and the colored lines a stock prints by its perforations. diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index 5ef618228..698b46064 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -522,7 +522,7 @@ def __init__(self, state: AppState, facts_provider: Optional[Any] = None): # Returns {asset hash: facts}. Without one, plain queries see file facts only # (name, ext, date), which is all a model built outside a session can know. self._facts_provider = facts_provider - self._sort_order = "name" # "name" | "date" | "scene" + self._sort_order = "name" # "name" | "date" | "scene" | "frame" self._sort_descending = False self._filter_text: str = "" self._filter_regex: bool = False @@ -557,6 +557,9 @@ def _rebuild_indices(self) -> None: order = self.effective_sort_order if order == "date": indices.sort(key=lambda i: _asset_mtime(files[i]), reverse=self._sort_descending) + elif order == "frame": + facts = self._facts_provider() if self._facts_provider else {} + indices.sort(key=lambda i: self._frame_rank(files[i], facts), reverse=self._sort_descending) else: indices.sort(key=lambda i: files[i]["name"].lower(), reverse=self._sort_descending) if order == "scene": @@ -638,7 +641,7 @@ def filter_text(self) -> str: return self._filter_text def set_sort_order(self, order: str) -> None: - if order not in ("name", "date", "scene"): + if order not in ("name", "date", "scene", "frame"): order = "name" self._sort_order = order self._apply_reindex() @@ -666,6 +669,12 @@ def _scene_rank(self, file_info: dict) -> tuple: return (1, 0) return (0, -scene[0] if self._sort_descending else scene[0]) + def _frame_rank(self, file_info: dict, facts: dict) -> tuple: + """Frame No. sort: the frame's metadata Roll, then its Frame No., the name a tie.""" + f = facts.get(file_info["hash"]) or {} + num = f.get("frame") + return (f.get("capture_roll", ""), num if num is not None else 0, file_info["name"].lower()) + def scene_runs(self) -> list[tuple[Optional[int], int, int]]: """``(scene ordinal or None, first row, last row)`` for each scene's block of display rows, frames in no scene as the last block. Empty unless sorted by Scene.""" diff --git a/negpy/desktop/view/sidebar/files.py b/negpy/desktop/view/sidebar/files.py index 58c868093..0b83f79cb 100644 --- a/negpy/desktop/view/sidebar/files.py +++ b/negpy/desktop/view/sidebar/files.py @@ -1503,9 +1503,12 @@ def _init_ui(self) -> None: # The frames' own order; the Library's roll list has a Sort of its own. self.sort_btn = SortButton( - (("name", tr("Name")), ("date", tr("Date")), ("scene", tr("Scene"))), tr("Sort the frames in the Film Strip") + (("name", tr("Name")), ("date", tr("Date")), ("scene", tr("Scene")), ("frame", tr("Frame No."))), + tr("Sort the frames in the Film Strip"), + ) + self.act_sort_name, self.act_sort_date, self.act_sort_scene, self.act_sort_frame = ( + self.sort_btn.order_action(k) for k in ("name", "date", "scene", "frame") ) - self.act_sort_name, self.act_sort_date, self.act_sort_scene = (self.sort_btn.order_action(k) for k in ("name", "date", "scene")) self.act_sort_asc, self.act_sort_desc = self.sort_btn.ascending_action, self.sort_btn.descending_action self.act_sort_scene.setVisible(False) self.sort_btn.order_selected.connect(self._apply_sort_order) @@ -2075,6 +2078,7 @@ def _sync_sort_menu(self) -> None: self.act_sort_name.setChecked(order == "name") self.act_sort_date.setChecked(order == "date") self.act_sort_scene.setChecked(order == "scene") + self.act_sort_frame.setChecked(order == "frame") def _apply_sheet_filter(self, mode: str, save: bool = True) -> None: model = self.session.asset_model diff --git a/negpy/desktop/view/widgets/contact_sheet_dialog.py b/negpy/desktop/view/widgets/contact_sheet_dialog.py index cfe9f5491..621b041a9 100644 --- a/negpy/desktop/view/widgets/contact_sheet_dialog.py +++ b/negpy/desktop/view/widgets/contact_sheet_dialog.py @@ -53,6 +53,7 @@ SheetLook, StraightProof, creation_order, + frame_order, print_aspect, scene_breaks, scene_of, @@ -66,8 +67,8 @@ _CUSTOM_PAPER = len(ILFORD_PAPERS) _PRINTS = (tr("As Edited"), tr("Straight Proof")) _AS_EDITED, _STRAIGHT_PROOF = 0, 1 -_ORDERS = (mark("Name"), mark("Date"), mark("Scene")) -_BY_NAME, _BY_DATE, _BY_SCENE = 0, 1, 2 +_ORDERS = (mark("Name"), mark("Date"), mark("Scene"), mark("Frame No.")) +_BY_NAME, _BY_DATE, _BY_SCENE, _BY_FRAME = 0, 1, 2, 3 _EDITED_TILES, _PROOF_TILES, _SCENE_PROOF_TILES = 0, 1, 2 _CANVAS_PAD = 28.0 _TILE_REPAINT_MS = 120 @@ -415,7 +416,7 @@ def _build_controls(self, settings: ContactSheetSettings) -> QWidget: self.order_btn = StepCombo.of( [tr(order) for order in _ORDERS], - tr("Lay the frames out by file name, in the order they were taken, or with a new strip for each scene."), + tr("Lay the frames out by file name, by the order they were taken, by roll and frame number, or by scene."), ) self.order_btn.set_item_enabled(_BY_SCENE, self._has_scenes) if not self._has_scenes: @@ -651,6 +652,8 @@ def _order_indices(self, order: int) -> list[int]: return scene_order(self._frames) if order == _BY_DATE: return creation_order(self._frames) + if order == _BY_FRAME: + return frame_order(self._frames) return list(range(len(self._frames))) def _on_order_changed(self, index: int) -> None: diff --git a/negpy/services/assets/search.py b/negpy/services/assets/search.py index 8feaf29c0..c04ff3f70 100644 --- a/negpy/services/assets/search.py +++ b/negpy/services/assets/search.py @@ -166,6 +166,8 @@ def facts_for(asset: dict, config: Any = None) -> dict[str, Any]: "format": (meta.format_other if meta.format == "Other" else meta.format).casefold(), "scanning": meta.scanning.casefold(), "roll": " ".join(p for p in (meta.capture_roll, config.process.roll_name or "") if p).casefold(), + # Raw Roll value, not in FIELDS: the Frame No. sort orders by it, not by the combined search term. + "capture_roll": meta.capture_roll.casefold(), "frame": meta.capture_frame, "iso": meta.film_iso, "push": meta.push_pull, diff --git a/negpy/services/export/contact_sheet_layout.py b/negpy/services/export/contact_sheet_layout.py index d9f1899c8..ed6d8ab0f 100644 --- a/negpy/services/export/contact_sheet_layout.py +++ b/negpy/services/export/contact_sheet_layout.py @@ -328,7 +328,7 @@ def frame_turns(upright_aspect: Optional[float], geometry: FilmGeometry, rotatio return 3 -SHEET_ORDERS = ("name", "date", "scene") +SHEET_ORDERS = ("name", "date", "scene", "frame") @dataclass(frozen=True) diff --git a/negpy/services/export/contact_sheet_roll.py b/negpy/services/export/contact_sheet_roll.py index e6962b426..c041bd512 100644 --- a/negpy/services/export/contact_sheet_roll.py +++ b/negpy/services/export/contact_sheet_roll.py @@ -204,6 +204,17 @@ def key(index: int) -> tuple[Any, ...]: return sorted(range(len(frames)), key=key) +def frame_order(frames: Sequence[SheetFrame]) -> list[int]: + """Indices by the frame's Roll and Frame No. from its metadata, the name a tie.""" + + def key(index: int) -> tuple[Any, ...]: + meta = frames[index].config.metadata + num = meta.capture_frame + return (meta.capture_roll.casefold(), num if num is not None else 0, frames[index].name.lower()) + + return sorted(range(len(frames)), key=key) + + def _crop_fractions(config: WorkspaceConfig) -> tuple[float, float]: rect = config.geometry.crop_rect if not rect: diff --git a/negpy/translations/USER_GUIDE.de.md b/negpy/translations/USER_GUIDE.de.md index 98c43977d..8d7825062 100644 --- a/negpy/translations/USER_GUIDE.de.md +++ b/negpy/translations/USER_GUIDE.de.md @@ -18,7 +18,7 @@ Eine Ordnerrolle enthält nur die Dateien ihres Ordners. Eine Datei, die du aus Um die Bilder im Filmstreifen als Rolle zu speichern, nutze den Filmstreifen. **In Ordnerrolle speichern** macht aus Bildern, die alle aus einem Ordner stammen, die Rolle dieses Ordners. Seine anderen Dateien erscheinen als entfernt. **Als virtuelle Rolle speichern…** behält genau diese Bilder, aus beliebigen Ordnern. In Ordnerrolle speichern macht auch aus einer virtuellen Rolle eines Ordners die Rolle dieses Ordners. Die neue Rolle startet mit den Einstellungen, den Kartensperren und den nur in dieser Rolle gemachten Bearbeitungen der geöffneten Rolle. Ein Bild hat in jeder Rolle, die es enthält, dieselbe Bearbeitung. Rechtsklick → **In dieser Rolle unabhängig bearbeiten** gibt ihm in einer Rolle eine eigene Bearbeitung. - + ### Filmstreifen Der **Filmstreifen** unter der Arbeitsfläche zeigt die geöffneten Bilder in einer Reihe. Ziehe seinen oberen Rand, um die Bilder zu vergrößern. @@ -27,7 +27,7 @@ Die Zeile **Rolle** der Werkzeugleiste enthält: * Den Namen der Rolle und die Anzahl der Bilder, der behaltenen und der verworfenen Bilder. * Das **Filterfeld**: Gib einen Teil eines Dateinamens ein oder Begriffe wie `film:portra iso:>=400` (siehe unten). Die Suchschaltfläche oder **Enter** durchsucht die ganze Bibliothek. -* **Sortieren**: nach Name, Datum oder Szene. +* **Sortieren**: nach Name, Datum, Szene oder nach Rolle und Bildnummer. * **Bogenfilter**: alle Bilder, nur behaltene, verworfene ausblenden oder nur unmarkierte. Die Schaltfläche ist hervorgehoben, solange ein Filter Bilder ausblendet. * **Rolleneinstellungen** (das Etikett): Ausrüstung und Filmdetails für dieses Bild, die Auswahl oder die Rolle. * **Anwenden**: kopiert gewählte Einstellungen dieses Bildes auf die Auswahl oder die Rolle. @@ -505,7 +505,7 @@ Zusätzliche Dateien neben dem Abzug. Jede hat links einen Schalter und rechts e * **Auto-Dichte / Gradation**: **Beim Export festschreiben** fixiert die automatische Helligkeit und den Kontrast jedes exportierten Bildes, damit sie sich später nicht ändern. **In Rolle festschreiben** tut das jetzt für jedes sichtbare Bild. * **Bearbeitungs-Sidecar-Dateien**: **Beim Export speichern** schreibt neben jede Quelle eine `.negpy`-Datei mit der Bearbeitung. **Sidecar-Dateien exportieren** schreibt sie für alle sichtbaren Bilder. Rechtsklick auf ein Bild → **Bearbeitung aus Sidecar-Datei laden…** liest eine zurück. - + ### Kontaktbogen Auf dem Tab **Neben dem Export**. Ein Kontaktbogen zeigt die ganze Rolle als Filmstreifen auf einem Blatt Papier, wie ein Kontaktabzug in der Dunkelkammer. @@ -514,7 +514,7 @@ Auf dem Tab **Neben dem Export**. Ein Kontaktbogen zeigt die ganze Rolle als Fil * **Kontaktbogen…**: öffnet den Bogen. Ziehe an einer Kante des Papiers, um die Größe zu ändern. * **Filmformat**: 35mm, 35mm Half Frame oder 120 (bei 120 mit **Bildgröße**). * **Papier**, **Breite**, **Höhe** und **DPI**: Größe und Auflösung des Bogens. Das Standardpapier fasst eine Rolle mit 36 Aufnahmen. -* **Reihenfolge**: nach Dateiname, nach Datum oder nach Szene, mit einem neuen Streifen oder einer neuen Zeile für jede Szene. +* **Reihenfolge**: nach Dateiname, nach Datum, nach Rolle und Bildnummer oder nach Szene, mit einem neuen Streifen oder einer neuen Zeile für jede Szene. * **Abzug**: **Wie bearbeitet** oder **Reiner Probeabzug**: die ganze Rolle mit einer Belichtung und normalem Kontrast. So siehst du, welche Negative dünn oder dicht sind. * **Bilder wählen**: Klicke auf ein Bild, um es auszulassen oder wieder aufzunehmen. Verworfene Bilder sind zu Beginn ausgelassen. * **Randaufdruck**: die Markierungen des Filmherstellers am Rand, zum Beispiel Filmname, Bildnummern und die farbigen Linien, die ein Film neben seinen Perforationen druckt. diff --git a/negpy/translations/USER_GUIDE.en.md b/negpy/translations/USER_GUIDE.en.md index 1def3f736..a8af242bd 100644 --- a/negpy/translations/USER_GUIDE.en.md +++ b/negpy/translations/USER_GUIDE.en.md @@ -134,7 +134,7 @@ The toolbar's **Roll** row holds: * The roll's name and its count of frames, keepers and rejects. * The **filter box**: type part of a file name, or terms such as `film:portra iso:>=400` (below). The search button, or **Enter**, searches the whole library. -* **Sort**: by name, date or scene. +* **Sort**: by name, date, scene, or by roll and frame number. * **Sheet filter**: all frames, keepers only, hide rejected, or unmarked only. The button is highlighted while a filter hides frames. * **Roll Settings** (the tag): gear and film details for this frame, the selection or the roll. * **Apply**: copies chosen settings of this frame to the selection or the roll. @@ -711,7 +711,7 @@ On the **Alongside Exports** tab. A contact sheet shows the whole roll as film s * **Contact Sheet…**: opens the sheet. Drag an edge of the paper to resize it. * **Film format**: 35mm, 35mm Half Frame or 120 (with **Frame size** for 120). * **Paper**, **Width**, **Height** and **DPI**: the sheet's size and resolution. The default paper holds a 36-exposure roll. -* **Order**: by file name, by date, or by scene with a new strip or row for each scene. +* **Order**: by file name, by date, by roll and frame number, or by scene with a new strip or row for each scene. * **Print**: **As Edited**, or **Straight Proof**: the whole roll at one exposure and normal contrast, so you see which negatives are thin or dense. * **Pick Frames**: click a frame to leave it out or put it back. Rejects start out. * **Edge Print**: the film maker's markings along the edges, such as the film name, frame numbers and the colored lines a stock prints by its perforations. diff --git a/negpy/translations/USER_GUIDE.ja.md b/negpy/translations/USER_GUIDE.ja.md index b301b54d9..7ae5e8d08 100644 --- a/negpy/translations/USER_GUIDE.ja.md +++ b/negpy/translations/USER_GUIDE.ja.md @@ -6,7 +6,7 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 ロールを読み込み、フィルムモードとスキャン装置を選びます。光源・レンズ補正、トリミング、ロール解析を行ってから、各フレームの濃度、コントラスト、色補正、修復を調整し、最後に書き出します。カード見出しのフレーム/ロールで適用範囲を選び、ロール設定への復帰で共有値を戻せます。個々のトリミング位置と回転はフレームに保持されます。 - + ## フィルムストリップ キャンバスの下の**フィルムストリップ**は、開いているフレームを1列に表示します。上端をドラッグすると、フレームが大きくなります。 @@ -15,7 +15,7 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 - ロール名と、フレーム数、キーパー数、不採用数。 - **絞り込みボックス**:ファイル名の一部、または `film:portra iso:>=400` のような条件(下記)を入力します。検索ボタンまたは **Enter** でライブラリ全体を検索します。 -- **並べ替え**:名前、日付、シーン。 +- **並べ替え**:名前、日付、シーン、またはロールとフレーム番号。 - **シートフィルター**:すべてのフレーム、キーパーのみ、拒否されたものを非表示、マークなしのみ。フィルターでフレームが隠れている間、ボタンが強調表示されます。 - **ロール設定**(タグのアイコン):このフレーム、選択、またはロールの機材とフィルムの情報。 - **適用**:このフレームの選んだ設定を、選択またはロールにコピーします。 @@ -509,7 +509,7 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 - **自動濃度/グレード**:**書き出し時に確定**は、書き出した各フレームの自動の明るさとコントラストを固定し、後で変わらないようにします。**ロールに確定**は表示中のすべてのフレームで今すぐ固定します。 - **編集サイドカー**:**書き出し時に保存**は各元ファイルの横に編集を含む `.negpy` ファイルを書きます。**サイドカーのエクスポート**は表示中のすべてのフレームで書きます。フレームを右クリック →**サイドカーから編集をロード…**で読み戻します。 - + ## コンタクトシート **書き出しと一緒に**タブにあります。コンタクトシートは、暗室のコンタクトプリントのように、ロール全体をフィルムの帯として 1 枚の紙に並べます。 @@ -518,7 +518,7 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 - **コンタクトシート…**:シートを開きます。紙の端をドラッグしてサイズを変えます。 - **フィルムフォーマット**:35mm、35mm ハーフサイズ、120(120 は**フレームサイズ**も設定)。 - **印画紙**、**幅**、**高さ**、**DPI**:シートのサイズと解像度。既定の紙で 36 枚撮りのロールが収まります。 -- **順序**:ファイル名順、日付順、またはシーンごとに新しい帯か行を始めるシーン順。 +- **順序**:ファイル名順、日付順、ロールとフレーム番号順、またはシーンごとに新しい帯か行を始めるシーン順。 - **プリント**:**編集済み**、または**ストレートプルーフ**:ロール全体を一つの露光と標準のコントラストでプリントし、どのネガが薄いか濃いかを見られます。 - **フレームの選択**:フレームをクリックして外したり戻したりします。不採用のフレームは最初から外れています。 - **エッジプリント**:フィルム名やフレーム番号、パーフォレーションの片側にある色付きラインなど、フィルムの縁のメーカーの印字。 diff --git a/negpy/translations/USER_GUIDE.ko.md b/negpy/translations/USER_GUIDE.ko.md index 7f52b1aea..e9e3dc6a7 100644 --- a/negpy/translations/USER_GUIDE.ko.md +++ b/negpy/translations/USER_GUIDE.ko.md @@ -6,7 +6,7 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 롤을 불러와 필름 모드와 스캔 장치를 선택합니다. 광원과 렌즈를 보정하고 프레임 자르기와 롤 분석을 수행한 다음, 각 프레임의 농도·대비·색 보정·복구를 조정하여 내보냅니다. 카드 제목의 프레임/롤 버튼으로 적용 범위를 정하고 롤 설정으로 되돌려 공유 값을 복원할 수 있습니다. 자르기 위치와 회전은 프레임별로 유지됩니다. - + ## 필름 스트립 캔버스 아래의 **필름 스트립**은 열린 프레임을 한 줄로 보여줍니다. 위쪽 가장자리를 드래그하면 프레임이 커집니다. @@ -15,7 +15,7 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 - 롤 이름과 프레임, 키퍼, 거부 수. - **필터 상자**: 파일 이름 일부나 `film:portra iso:>=400` 같은 조건(아래)을 입력합니다. 검색 버튼이나 **Enter**는 라이브러리 전체를 검색합니다. -- **정렬**: 이름, 날짜, 장면. +- **정렬**: 이름, 날짜, 장면, 또는 롤과 프레임 번호. - **시트 필터**: 모든 프레임, 키퍼 전용, 거부됨 숨기기, 표시되지 않은 것만. 필터가 프레임을 숨기는 동안 버튼이 강조됩니다. - **롤 설정**(태그 아이콘): 이 프레임, 선택 항목 또는 롤의 장비와 필름 정보. - **적용**: 이 프레임의 선택한 설정을 선택 항목이나 롤에 복사합니다. @@ -509,7 +509,7 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 - **자동 농도 / 등급**: **내보낼 때 고정**은 내보낸 각 프레임의 자동 밝기와 대비를 고정해 나중에 바뀌지 않게 합니다. **롤에 고정**은 보이는 모든 프레임에 지금 적용합니다. - **편집 사이드카**: **내보내기 시 저장**은 각 원본 옆에 편집을 담은 `.negpy` 파일을 씁니다. **사이드카 내보내기**는 보이는 모든 프레임에 씁니다. 프레임 오른쪽 클릭 → **사이드카에서 편집 로드…**로 다시 읽습니다. - + ## 컨택트 시트 **내보내기와 함께** 탭에 있습니다. 컨택트 시트는 암실의 밀착 인화처럼 롤 전체를 필름 띠로 종이 한 장에 보여줍니다. @@ -518,7 +518,7 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 - **컨택트 시트…**: 시트를 엽니다. 종이 가장자리를 드래그해 크기를 바꿉니다. - **필름 규격**: 35mm, 35mm 하프 프레임, 120(120은 **프레임 크기**도 설정). - **인화지**, **폭**, **높이**, **DPI**: 시트의 크기와 해상도입니다. 기본 용지에 36컷 롤이 다 들어갑니다. -- **순서**: 파일 이름순, 날짜순, 또는 장면마다 새 띠나 줄을 시작하는 장면순. +- **순서**: 파일 이름순, 날짜순, 롤과 프레임 번호순, 또는 장면마다 새 띠나 줄을 시작하는 장면순. - **인화**: **편집됨**, 또는 **스트레이트 프루프**: 롤 전체를 한 노출과 보통 대비로 인화해 어느 네거티브가 옅거나 짙은지 보여줍니다. - **프레임 선택**: 프레임을 클릭해 빼거나 다시 넣습니다. 거부한 프레임은 처음부터 빠져 있습니다. - **가장자리 인화**: 필름 이름, 프레임 번호, 퍼포레이션 한쪽의 색선 같은 필름 가장자리의 제조사 표시입니다. diff --git a/negpy/translations/USER_GUIDE.zh_CN.md b/negpy/translations/USER_GUIDE.zh_CN.md index f89da1f33..7c55af274 100644 --- a/negpy/translations/USER_GUIDE.zh_CN.md +++ b/negpy/translations/USER_GUIDE.zh_CN.md @@ -6,7 +6,7 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 载入胶卷后,选择胶片模式与扫描装置,校正光源和镜头,裁切画面并分析胶卷,再调整单帧密度、反差、滤色与修复,最后导出。卡片标题的画面/胶卷按钮控制应用范围,恢复胶卷设置可拉回共享值。单帧裁切位置与旋转保留在画面上。 - + ## 胶片条 画布下方的**胶片条**把打开的帧排成一行。拖动其上边缘可放大帧。 @@ -15,7 +15,7 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 - 胶卷名称,以及帧数、保留数和拒绝数。 - **筛选框**:输入文件名的一部分,或 `film:portra iso:>=400` 这样的条件(见下)。搜索按钮或 **Enter** 会搜索整个图库。 -- **排序**:按名称、日期或场景。 +- **排序**:按名称、日期、场景,或按胶卷和帧号。 - **画面筛选**:所有画面、仅保留画面、隐藏拒绝画面或仅未标记画面。筛选隐藏画面时,按钮会高亮。 - **胶卷设置**(标签图标):此帧、所选帧或胶卷的器材和胶片信息。 - **应用**:把此帧选定的设置复制到所选帧或整卷。 @@ -509,7 +509,7 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 - **自动密度 / 反差**:**导出时固化**会固定每个导出帧的自动亮度和反差,使其以后不再变化。**固化到胶卷**立即对所有可见帧执行。 - **编辑附属文件**:**导出时保存**在每个源文件旁写入含编辑内容的 `.negpy` 文件。**导出附属文件**为所有可见帧写入。右键点击某帧 → **从附属文件加载编辑…**可读回。 - + ## 接触印相 位于**随导出**选项卡。接触印相像暗房印样一样,把整卷以胶片条的形式排在一张纸上。 @@ -518,7 +518,7 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 - **接触印相…**:打开印样。拖动纸张边缘可改变大小。 - **胶片画幅**:35mm、35mm 半格或 120(120 还需设置**画面尺寸**)。 - **相纸**、**宽度**、**高度**和 **DPI**:印样的尺寸和分辨率。默认相纸可容纳 36 张一卷。 -- **顺序**:按文件名、按日期,或按场景(每个场景另起一条或一行)。 +- **顺序**:按文件名、按日期、按胶卷和帧号,或按场景(每个场景另起一条或一行)。 - **印相**:**按编辑结果**,或**直接样片**:整卷以同一曝光和常规反差印出,便于看出哪些底片偏薄或偏厚。 - **选择画面**:点击某帧可将其排除或放回。拒绝的帧一开始就被排除。 - **边缘印相**:胶片边缘的厂商标记,如胶片名称、帧编号和齿孔一侧的彩色线。 diff --git a/negpy/translations/de.json b/negpy/translations/de.json index 9935e4173..b24ef1551 100644 --- a/negpy/translations/de.json +++ b/negpy/translations/de.json @@ -1085,6 +1085,7 @@ "Frame Counter": "Bildzähler", "Frame Info": "Bildinfo", "Frame Info: {value}": "Bildinfo: {value}", + "Frame No.": "Bildnummer", "Frame info on the canvas: off, basic or full": "Bildinfo auf der Arbeitsfläche: aus, einfach oder vollständig", "Frame length on the loaded film; Auto where the holder fixes it": "Bildlänge auf dem geladenen Film; Auto, wo der Halter sie festlegt", "Frame or Roll": "Bild oder Rolle", @@ -1375,7 +1376,7 @@ "Later edits never delete a work print; this is the only way to delete one.": "Spätere Bearbeitungen löschen nie einen Arbeitsabzug. Nur so lässt er sich löschen.", "Latitude and longitude, or a pasted OpenStreetMap or Google Maps link.": "Breiten- und Längengrad oder ein eingefügter OpenStreetMap- oder Google-Maps-Link.", "Lay every visible frame out as film strips on photographic paper, then export the sheet": "Alle sichtbaren Bilder als Filmstreifen auf Fotopapier anordnen und den Bogen exportieren", - "Lay the frames out by file name, in the order they were taken, or with a new strip for each scene.": "Ordnet die Bilder nach Dateiname, nach Aufnahmereihenfolge oder mit einem neuen Streifen für jede Szene an.", + "Lay the frames out by file name, by the order they were taken, by roll and frame number, or by scene.": "Ordnen Sie die Bilder nach Dateiname, Aufnahmereihenfolge, Rolle und Bildnummer oder Szene an.", "Leave at the default unless you know why you need to change it.": "Belasse den Standardwert, außer du weißt, warum du ihn ändern musst.", "Leave frames in the rejected state out of the run; the numbers fill the frames that remain.": "Verworfene Bilder bleiben im Durchlauf unberücksichtigt. Die Nummern füllen die übrigen Bilder.", "Leave the current view (peek, split, strip, zone overlay) or cancel the active tool; Crop and Draw Region undo their changes": "Die aktuelle Ansicht (Vorschau, Teilung, Streifen, Zonenüberlagerung) verlassen oder das aktive Werkzeug abbrechen. Zuschnitt und Bereich zeichnen machen ihre Änderungen rückgängig", diff --git a/negpy/translations/ja.json b/negpy/translations/ja.json index 3fefc0c81..92428b51a 100644 --- a/negpy/translations/ja.json +++ b/negpy/translations/ja.json @@ -1085,6 +1085,7 @@ "Frame Counter": "フレーム番号", "Frame Info": "フレーム情報", "Frame Info: {value}": "フレーム情報: {value}", + "Frame No.": "フレーム番号", "Frame info on the canvas: off, basic or full": "キャンバス上のフレーム情報: オフ、ベーシック、またはフル", "Frame length on the loaded film; Auto where the holder fixes it": "装填したフィルムのフレーム長。ホルダーで決まる場合は自動", "Frame or Roll": "フレームまたはロール", @@ -1375,7 +1376,7 @@ "Later edits never delete a work print; this is the only way to delete one.": "後の編集でワークプリントが消えることはありません。削除できるのはここだけです。", "Latitude and longitude, or a pasted OpenStreetMap or Google Maps link.": "緯度と経度、または貼り付けた OpenStreetMap か Google マップのリンク。", "Lay every visible frame out as film strips on photographic paper, then export the sheet": "表示されているすべてのフレームをフィルム ストリップとして印画紙に配置し、シートをエクスポートします。", - "Lay the frames out by file name, in the order they were taken, or with a new strip for each scene.": "フレームをファイル名順か撮影した順に並べるか、シーンごとに新しいストリップを始めます。", + "Lay the frames out by file name, by the order they were taken, by roll and frame number, or by scene.": "フレームをファイル名、撮影順、ロールとフレーム番号、シーンで並べます。", "Leave at the default unless you know why you need to change it.": "変更する必要がある理由がわからない場合は、デフォルトのままにしておきます。", "Leave frames in the rejected state out of the run; the numbers fill the frames that remain.": "リジェクト済みのフレームを除外し、残りに連番を付けます。", "Leave the current view (peek, split, strip, zone overlay) or cancel the active tool; Crop and Draw Region undo their changes": "現在のビュー(のぞき見、分割、ストリップ、ゾーンオーバーレイ)を終了するか、アクティブなツールをキャンセルします。トリミングと範囲を描画は変更を元に戻します", diff --git a/negpy/translations/ko.json b/negpy/translations/ko.json index ca1e39fe5..7daa7d4b7 100644 --- a/negpy/translations/ko.json +++ b/negpy/translations/ko.json @@ -1085,6 +1085,7 @@ "Frame Counter": "프레임 번호", "Frame Info": "프레임 정보", "Frame Info: {value}": "프레임 정보: {value}", + "Frame No.": "프레임 번호", "Frame info on the canvas: off, basic or full": "캔버스의 프레임 정보: 꺼짐, 기본 또는 전체", "Frame length on the loaded film; Auto where the holder fixes it": "로드된 필름의 프레임 길이입니다. 홀더가 정하는 경우 자동", "Frame or Roll": "프레임 또는 롤", @@ -1375,7 +1376,7 @@ "Later edits never delete a work print; this is the only way to delete one.": "이후의 편집으로 워크 프린트가 지워지지 않습니다. 삭제하는 방법은 이것뿐입니다.", "Latitude and longitude, or a pasted OpenStreetMap or Google Maps link.": "위도와 경도, 또는 붙여 넣은 OpenStreetMap이나 Google Maps 링크.", "Lay every visible frame out as film strips on photographic paper, then export the sheet": "눈에 보이는 모든 프레임을 인화지 위에 필름 스트립으로 배치한 다음 시트를 내보냅니다.", - "Lay the frames out by file name, in the order they were taken, or with a new strip for each scene.": "프레임을 파일 이름순이나 찍은 순서대로 놓거나, 장면마다 새 스트립을 시작합니다.", + "Lay the frames out by file name, by the order they were taken, by roll and frame number, or by scene.": "프레임을 파일 이름, 촬영 순서, 롤과 프레임 번호, 장면으로 배치합니다.", "Leave at the default unless you know why you need to change it.": "변경해야 하는 이유를 알지 못하는 한 기본값을 그대로 유지합니다.", "Leave frames in the rejected state out of the run; the numbers fill the frames that remain.": "거부된 프레임은 제외하고 남은 프레임에 연속 번호를 지정합니다.", "Leave the current view (peek, split, strip, zone overlay) or cancel the active tool; Crop and Draw Region undo their changes": "현재 보기(피크, 분할, 스트립, 존 오버레이)를 종료하거나 활성 도구를 취소합니다. 자르기와 영역 그리기는 변경을 되돌립니다.", diff --git a/negpy/translations/zh_CN.json b/negpy/translations/zh_CN.json index dfc0acb03..d0230056d 100644 --- a/negpy/translations/zh_CN.json +++ b/negpy/translations/zh_CN.json @@ -1085,6 +1085,7 @@ "Frame Counter": "画面计数", "Frame Info": "画面信息", "Frame Info: {value}": "画面信息:{value}", + "Frame No.": "帧号", "Frame info on the canvas: off, basic or full": "画布画面信息:关闭、基本或完整", "Frame length on the loaded film; Auto where the holder fixes it": "已载入胶片的画幅长度;片夹固定时选择自动", "Frame or Roll": "画面或胶卷", @@ -1375,7 +1376,7 @@ "Later edits never delete a work print; this is the only way to delete one.": "之后的编辑不会删除工作样片;只有这里能删除它。", "Latitude and longitude, or a pasted OpenStreetMap or Google Maps link.": "经纬度,或粘贴的 OpenStreetMap 或 Google 地图链接。", "Lay every visible frame out as film strips on photographic paper, then export the sheet": "将所有可见画面按胶片条排列在相纸上并导出", - "Lay the frames out by file name, in the order they were taken, or with a new strip for each scene.": "按文件名或拍摄顺序排列画面,或为每个场景另起一条。", + "Lay the frames out by file name, by the order they were taken, by roll and frame number, or by scene.": "将帧按文件名、拍摄顺序、卷和帧号或场景排列。", "Leave at the default unless you know why you need to change it.": "除非明确需要更改,请保留默认值。", "Leave frames in the rejected state out of the run; the numbers fill the frames that remain.": "跳过已拒绝的画面,其余画面连续编号。", "Leave the current view (peek, split, strip, zone overlay) or cancel the active tool; Crop and Draw Region undo their changes": "退出当前查看模式,或取消活动工具;裁切和绘制区域会撤销其更改", From c94ab9e6d0b3c1aade8e33adc0bf4bf6f4036db5 Mon Sep 17 00:00:00 2001 From: Akinyemi Paul Odulate Date: Wed, 4 Nov 2026 11:31:26 +0000 Subject: [PATCH 30/46] Lento Dock: take SCANREQ as the S button Firmware 1.0.238 no longer starts a scan from the S button in NegPy. The serial API reference lists SCANREQ and SCANSTOP as the events for a requested or stopped scan cycle, so either one, outside a NegPy command, now works as Scan. One press that reports both a request and an exposure counts once. Refs #1375 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CiaS7mdcgupjy3zYtMa4Su --- negpy/infrastructure/capture/lento_dock.py | 22 +++++++++++++++----- negpy/infrastructure/simulated/lento_dock.py | 4 ++++ tests/test_lento_dock_driver.py | 14 +++++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/negpy/infrastructure/capture/lento_dock.py b/negpy/infrastructure/capture/lento_dock.py index c4327743e..06d1c3d78 100644 --- a/negpy/infrastructure/capture/lento_dock.py +++ b/negpy/infrastructure/capture/lento_dock.py @@ -48,6 +48,7 @@ ADVANCE_TIMEOUT_S = 15.0 EXPOSE_WAIT_S = 5.0 # the dock's own exposure on an S press lasts 3 s BUSY_RETRY_S = 0.25 # the API's retry interval for a BUSY reply +SCAN_BUTTON_DEBOUNCE_S = 1.0 EJECT_TIMEOUT_S = 30.0 HOLD_INTERVAL_S = 30.0 @@ -158,6 +159,7 @@ def __init__( self._continuous = False self._last_frameat = "" self._in_command = False + self._last_press = -SCAN_BUTTON_DEBOUNCE_S self.on_scan_button: Optional[Callable[[], None]] = None # called on the reader thread self._saved: dict[str, str] = {} self._last_tx = time.monotonic() @@ -414,6 +416,17 @@ def _wait_out_exposure(self) -> None: while self._state.get("st") == "EXPOSE" and time.monotonic() < deadline: time.sleep(0.05) + def _scan_button(self) -> None: + """One S press can report both a scan request and an exposure: the second report is the same press.""" + now = time.monotonic() + if self.on_scan_button is None or now - self._last_press < SCAN_BUTTON_DEBOUNCE_S: + return + self._last_press = now + try: + self.on_scan_button() + except Exception: + logger.exception("Lento Dock scan button handler failed") + def _dispatch(self, line: str) -> None: logger.debug("Lento Dock: %s", line) if line.startswith("FRAMEAT") and not line.startswith("FRAMEAT ERR"): @@ -425,11 +438,10 @@ def _dispatch(self, line: str) -> None: self._state = parse_kv(line) # NegPy starts no exposure of the dock's own: one that begins outside a NegPy command is # the S button. The dock accepts light commands during it. - if self._state.get("st") == "EXPOSE" and was != "EXPOSE" and not self._in_command and self.on_scan_button: - try: - self.on_scan_button() - except Exception: - logger.exception("Lento Dock scan button handler failed") + if self._state.get("st") == "EXPOSE" and was != "EXPOSE" and not self._in_command: + self._scan_button() + elif line.startswith(("SCANREQ", "SCANSTOP")) and not self._in_command: + self._scan_button() # firmware 1.0.238 and later report the S button as a scan request elif line.startswith("BOOT"): self._booted.set() self._replies.put(line) diff --git a/negpy/infrastructure/simulated/lento_dock.py b/negpy/infrastructure/simulated/lento_dock.py index 7e62190b9..d6898429b 100644 --- a/negpy/infrastructure/simulated/lento_dock.py +++ b/negpy/infrastructure/simulated/lento_dock.py @@ -52,6 +52,10 @@ def _state(self) -> str: f"entrance={int(self.frames > 0)} busy=0 expmode={self.expmode} shutter=JACK" ) + def request_scan(self) -> None: + """The S button on firmware that reports it as a scan request.""" + self._say("SCANREQ") + def press_scan(self, hold_s: float = 0.0) -> None: """The S button in host mode: the dock holds its own exposure for `hold_s`, then reports it.""" self.st = "EXPOSE" diff --git a/tests/test_lento_dock_driver.py b/tests/test_lento_dock_driver.py index 6d106b8be..1629b890e 100644 --- a/tests/test_lento_dock_driver.py +++ b/tests/test_lento_dock_driver.py @@ -309,3 +309,17 @@ def test_frameat_err_is_the_end_of_the_strip(sim): dock._enter_continuous() sim._frame_next = lambda: (sim._say("OK"), sim._say("FRAMEAT ERR no_edge"), sim._say(sim._state())) assert dock.advance_frame() is None + + +def test_a_scan_request_is_the_s_button_and_counts_once(sim): + pressed = [] + with LentoDock(serial_obj=sim) as dock: + dock.on_scan_button = lambda: pressed.append(1) + sim.request_scan() + sim.press_scan() # the same press may also start the dock's exposure + for _ in range(50): + if pressed: + break + time.sleep(0.01) + time.sleep(0.1) + assert pressed == [1] From d5c19efc35a659812b8d2834fe549cb434df721d Mon Sep 17 00:00:00 2001 From: Akinyemi Paul Odulate Date: Wed, 4 Nov 2026 11:54:30 +0000 Subject: [PATCH 31/46] Lento Dock: let the dock align a new strip on its own On firmware 1.0.238 the dock aligns an inserted strip to its first frame in SEMI (AUTOALIGN), and INSERT on an aligned strip moves it one frame. The feed now waits for an alignment in progress, gives a strip that just went in time to start one, and sends INSERT only to a strip still at the entrance. On the same firmware, S with a frame in the window still gives st=EXPOSE, now with a 1 s exposure; with no strip aligned it seeks a frame instead. Refs #1375 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CiaS7mdcgupjy3zYtMa4Su --- docs/CAMERA_SCANNING.md | 12 +++++++----- negpy/infrastructure/capture/lento_dock.py | 12 ++++++++++-- tests/test_lento_dock_driver.py | 20 +++++++++++++++++++- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/docs/CAMERA_SCANNING.md b/docs/CAMERA_SCANNING.md index 945513144..8578d8231 100644 --- a/docs/CAMERA_SCANNING.md +++ b/docs/CAMERA_SCANNING.md @@ -199,12 +199,14 @@ described above. stays in the `flatfield/references` folder, outside the roll. A strip that stops before its end gets no flat. The flat never comes first: after the dock moves the film out of the window and back, it does not advance. -- Put the strip in the dock's entrance. With **Next Frame** or **Whole Strip**, Scan feeds a - strip that waits there to its first frame (`INSERT`). This also covers a strip that was in - the dock when NegPy opened its port, because opening the port resets the dock. +- Put the strip in the dock's entrance. Firmware 1.0.238 and later align it to the first frame + on their own. With **Next Frame** or **Whole Strip**, Scan feeds a strip that still waits at + the entrance (`INSERT`), such as one that was in the dock when NegPy opened its port, because + opening the port resets the dock. - While NegPy uses the dock, the dock's S button works as Scan: it starts a scan, or stops one. - The scan starts at the press. The dock also makes a 3-second exposure of its own, and the - film moves only after it ends. An arrow button nudges the film on a tap and moves it while you hold it. NegPy + S acts only while a frame is in the window: with no strip aligned, the dock looks for the next + frame instead. The scan starts at the press. The dock also makes a short exposure of its own, + and the film moves only after it ends. An arrow button nudges the film on a tap and moves it while you hold it. NegPy leaves the dock's button mode (`BTNMODE`) as you set it. - When NegPy releases the dock, it puts back the dock's saved light mode and control mode. diff --git a/negpy/infrastructure/capture/lento_dock.py b/negpy/infrastructure/capture/lento_dock.py index 06d1c3d78..f00a31c4a 100644 --- a/negpy/infrastructure/capture/lento_dock.py +++ b/negpy/infrastructure/capture/lento_dock.py @@ -49,6 +49,7 @@ EXPOSE_WAIT_S = 5.0 # the dock's own exposure on an S press lasts 3 s BUSY_RETRY_S = 0.25 # the API's retry interval for a BUSY reply SCAN_BUTTON_DEBOUNCE_S = 1.0 +AUTOALIGN_START_S = 1.5 # an inserted strip starts to align within this time EJECT_TIMEOUT_S = 30.0 HOLD_INTERVAL_S = 30.0 @@ -245,9 +246,16 @@ def advance_frame(self) -> Optional[int]: return int(parse_kv(self._last_frameat).get("idx", "-1")) def strip_waiting(self) -> bool: - """True when a strip sits at the entrance and the dock has not fed it: in SEMI the dock - feeds a strip only on INSERT, and a reset forgets one it had fed.""" + """True when a strip sits at the entrance and the dock has not fed it, as after a reset. + Firmware 1.0.238 and later align an inserted strip on their own, so a strip that just went + in gets a moment to start, and an alignment in progress is waited out: INSERT on an aligned + strip moves it one frame.""" self._command("STATE?", r"^STATE\b") + if self._state.get("st") == "IDLE" and self._state.get("entrance") == "1": + time.sleep(AUTOALIGN_START_S) + deadline = time.monotonic() + ADVANCE_TIMEOUT_S + while self._state.get("st") in ("SEEK", "ALIGN") and time.monotonic() < deadline: + time.sleep(0.05) return self._state.get("st") == "IDLE" and self._state.get("entrance") == "1" def insert_strip(self) -> None: diff --git a/tests/test_lento_dock_driver.py b/tests/test_lento_dock_driver.py index 1629b890e..afff042b5 100644 --- a/tests/test_lento_dock_driver.py +++ b/tests/test_lento_dock_driver.py @@ -271,7 +271,8 @@ def test_the_s_button_calls_the_scan_handler(sim): assert pressed == [1] -def test_a_strip_at_the_entrance_is_fed_to_its_first_frame(): +def test_a_strip_at_the_entrance_is_fed_to_its_first_frame(monkeypatch): + monkeypatch.setattr(lento_dock, "AUTOALIGN_START_S", 0.0) sim = SimDockSerial(frames=4, fed=False) with LentoDock(serial_obj=sim) as dock: assert dock.strip_waiting() @@ -323,3 +324,20 @@ def test_a_scan_request_is_the_s_button_and_counts_once(sim): time.sleep(0.01) time.sleep(0.1) assert pressed == [1] + + +def test_a_strip_the_dock_aligns_itself_is_not_fed_again(monkeypatch): + import threading + + monkeypatch.setattr(lento_dock, "AUTOALIGN_START_S", 0.0) + sim = SimDockSerial(frames=4) + sim.st = "SEEK" # the dock aligns a strip that just went in + + def aligned(): + sim.st = "READY" + sim._say(sim._state()) + + with LentoDock(serial_obj=sim) as dock: + threading.Timer(0.3, aligned).start() + assert not dock.strip_waiting() + assert "INSERT" not in sim.lines From 813300f4e1b09c4baad774e77558bc965cd42166 Mon Sep 17 00:00:00 2001 From: Akinyemi Paul Odulate Date: Wed, 4 Nov 2026 12:05:17 +0000 Subject: [PATCH 32/46] Lento Dock: log the dock's state changes and S presses The terminal log now shows each state change the dock makes on its own, each S press the driver detects, and an S press the Camera panel ignores because Scan is not ready. This finds where an S press stops. Refs #1375 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CiaS7mdcgupjy3zYtMa4Su --- negpy/desktop/view/sidebar/scanlight.py | 5 +++++ negpy/infrastructure/capture/lento_dock.py | 3 +++ 2 files changed, 8 insertions(+) diff --git a/negpy/desktop/view/sidebar/scanlight.py b/negpy/desktop/view/sidebar/scanlight.py index 0e9b7fe29..45ec3d56a 100644 --- a/negpy/desktop/view/sidebar/scanlight.py +++ b/negpy/desktop/view/sidebar/scanlight.py @@ -7,6 +7,7 @@ """ from negpy.kernel.system.i18n import mark, tr +from negpy.kernel.system.logging import get_logger from negpy.kernel.system.text import count_of import json @@ -119,6 +120,8 @@ class _Builtin: # The white-light built-ins and the process each imports as. _BUILTIN_WHITE_PRESETS = {name: b.process for name, b in _BUILTIN_PRESETS.items() if b.white} +logger = get_logger(__name__) + def _builtin_tip(b: _Builtin) -> str: if b.white: @@ -1932,6 +1935,8 @@ def _on_light_scan_button(self) -> None: """The Lento Dock's S button works as Scan: it starts a scan, or stops one.""" if self.scan_btn.isEnabled(): self._on_scan() + else: + logger.info("S button ignored: Scan is not ready (%s)", self.scan_btn.toolTip()) def _on_retake(self) -> None: if not self._scanning: diff --git a/negpy/infrastructure/capture/lento_dock.py b/negpy/infrastructure/capture/lento_dock.py index f00a31c4a..25172265b 100644 --- a/negpy/infrastructure/capture/lento_dock.py +++ b/negpy/infrastructure/capture/lento_dock.py @@ -430,6 +430,7 @@ def _scan_button(self) -> None: if self.on_scan_button is None or now - self._last_press < SCAN_BUTTON_DEBOUNCE_S: return self._last_press = now + logger.info("Lento Dock S button") try: self.on_scan_button() except Exception: @@ -444,6 +445,8 @@ def _dispatch(self, line: str) -> None: if line.startswith("STATE"): was = self._state.get("st") self._state = parse_kv(line) + if self._state.get("st") != was and not self._in_command: + logger.info("Lento Dock state %s -> %s", was, self._state.get("st")) # NegPy starts no exposure of the dock's own: one that begins outside a NegPy command is # the S button. The dock accepts light commands during it. if self._state.get("st") == "EXPOSE" and was != "EXPOSE" and not self._in_command: From d900f3376f1162def38b5102165345372cbde0f6 Mon Sep 17 00:00:00 2001 From: Akinyemi Paul Odulate Date: Wed, 4 Nov 2026 12:05:34 +0000 Subject: [PATCH 33/46] Open a fresh Blend frame as its capture imported it A Blend or Single Exposure frame got C-41, the narrowband decode and the Pakon print stock only from the capture's pending import, which applies when the frame opens in the capture session. A frame first opened in a later session started from the sticky settings and autodetect, which reads narrowband RGB as B&W. A trichrome has no such gap: its triplet decides its decode on every open. A fresh frame with a recorded Blend or Single capture mode now takes the same settings on open. A saved edit keeps its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CiaS7mdcgupjy3zYtMa4Su --- negpy/desktop/session.py | 25 ++++++++++++++++++++++++- tests/test_capture_modes.py | 14 ++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index 8d9e0f99d..6648e8040 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -32,7 +32,14 @@ from negpy.domain.models import PROOF_INTENT_LABELS, ExportPreset, ProofIntent, WorkspaceConfig from negpy.features.exposure.models import apply_targets from negpy.features.geometry.logic import flip_geometry_and_analysis, has_manual_crop, rotate_geometry_and_analysis -from negpy.features.process.models import invalidate_local_bounds, mode_aware_exposure_reset, with_process_mode +from negpy.features.process.models import ( + SCAN_SETUP_PAPERS, + ProcessMode, + invalidate_local_bounds, + mode_aware_exposure_reset, + scan_setup_values, + with_process_mode, +) from negpy.features.rgbscan.models import RgbScanConfig, is_rgb_triplet from negpy.features.hdr.logic import resolve_anchor, seed_shadow_density from negpy.features.hdr.models import ANCHOR_EV_UNSET, HdrConfig, hdr_frame_paths @@ -832,6 +839,21 @@ def resolve_asset_process_mode(params: WorkspaceConfig, asset: dict) -> Workspac return with_process_mode(params, str(mode)) +def resolve_asset_capture(params: WorkspaceConfig, asset: dict) -> WorkspaceConfig: + """Open a fresh Blend or Single Exposure frame as its capture imported it: C-41, with the + narrowband rig's decode and print stock. The import applies these only to a frame opened in + the capture session; a trichrome needs no such record, because its triplet decides its decode. + Applied only when the frame has no saved edit, so a later change on it still wins.""" + if asset.get("capture") not in (BLEND, SINGLE): + return params + linear_raw, paper = scan_setup_values("camera", "narrowband") + params = with_process_mode(params, ProcessMode.C41) + params = replace(params, process=replace(params.process, linear_raw=linear_raw)) + if params.exposure.paper_profile in SCAN_SETUP_PAPERS: + params = replace(params, exposure=replace(params.exposure, paper_profile=paper)) + return params + + def resolve_asset_hdr_seed(params: WorkspaceConfig, asset: dict) -> WorkspaceConfig: """Open a fresh merge with its recovered shadow range already dialled in. @@ -1383,6 +1405,7 @@ def _asset_defaults(config: WorkspaceConfig, asset: dict) -> WorkspaceConfig: sticky settings, a reset from bare defaults. """ config = resolve_asset_process_mode(config, asset) + config = resolve_asset_capture(config, asset) config = resolve_asset_hdr_seed(config, asset) return resolve_asset_hdr(resolve_asset_stitch(resolve_asset_rgbscan(config, asset), asset), asset) diff --git a/tests/test_capture_modes.py b/tests/test_capture_modes.py index 78f99e8eb..75bc017aa 100644 --- a/tests/test_capture_modes.py +++ b/tests/test_capture_modes.py @@ -44,3 +44,17 @@ def test_a_retake_replaces_the_mode_and_an_unchanged_one_writes_nothing(): def test_an_unknown_mode_is_refused(): with pytest.raises(ValueError): record_capture_mode(_Repo(), "/r/F1.ARW", "trichrome") + + +def test_a_fresh_blend_frame_opens_as_its_capture_imported_it(): + from negpy.desktop.session import resolve_asset_capture + from negpy.features.process.models import ProcessMode, scan_setup_values + from negpy.kernel.system.config import DEFAULT_WORKSPACE_CONFIG + + linear_raw, paper = scan_setup_values("camera", "narrowband") + for mode in (BLEND, SINGLE): + cfg = resolve_asset_capture(DEFAULT_WORKSPACE_CONFIG, {"capture": mode}) + assert cfg.process.process_mode == ProcessMode.C41 + assert cfg.process.linear_raw is linear_raw + assert cfg.exposure.paper_profile == paper + assert resolve_asset_capture(DEFAULT_WORKSPACE_CONFIG, {}) is DEFAULT_WORKSPACE_CONFIG From 69599390a55410df19bd107bcbf3f0ed02926c1d Mon Sep 17 00:00:00 2001 From: Akinyemi Paul Odulate Date: Wed, 4 Nov 2026 12:23:35 +0000 Subject: [PATCH 34/46] Lento Dock: read the S button with BTN? On firmware 1.0.238, S does what the dock's state says: with a frame in the window it starts the dock's own exposure, with a strip at the entrance it aligns the strip, and a hold ejects. In NegPy no S press reached the Camera panel. BTN? reports shutter=1 while S is held, so NegPy now asks for the buttons every 100 ms while it holds the dock. A tap works as Scan when S is let go; a hold of 1 s or more stays the dock's own. Firmware that does not answer BTN? keeps the old signal, the dock's own exposure. Refs #1375 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CiaS7mdcgupjy3zYtMa4Su --- docs/CAMERA_SCANNING.md | 12 +++--- negpy/infrastructure/capture/lento_dock.py | 39 ++++++++++++++++++-- negpy/infrastructure/simulated/lento_dock.py | 4 ++ tests/test_lento_dock_driver.py | 36 ++++++++++++++++++ 4 files changed, 82 insertions(+), 9 deletions(-) diff --git a/docs/CAMERA_SCANNING.md b/docs/CAMERA_SCANNING.md index 8578d8231..17bc9cdee 100644 --- a/docs/CAMERA_SCANNING.md +++ b/docs/CAMERA_SCANNING.md @@ -203,11 +203,13 @@ described above. on their own. With **Next Frame** or **Whole Strip**, Scan feeds a strip that still waits at the entrance (`INSERT`), such as one that was in the dock when NegPy opened its port, because opening the port resets the dock. -- While NegPy uses the dock, the dock's S button works as Scan: it starts a scan, or stops one. - S acts only while a frame is in the window: with no strip aligned, the dock looks for the next - frame instead. The scan starts at the press. The dock also makes a short exposure of its own, - and the film moves only after it ends. An arrow button nudges the film on a tap and moves it while you hold it. NegPy - leaves the dock's button mode (`BTNMODE`) as you set it. +- While NegPy uses the dock, a tap of the dock's S button works as Scan: it starts a scan, or + stops one. NegPy reads the button (`BTN?`) and acts when you let go. The dock also acts on S + itself: with a frame in the window it makes a short exposure of its own, and the film moves + only after that ends. With a strip at the entrance, a tap lines the strip up first. A hold of + S is left to the dock, which ejects the strip on firmware 1.0.238. +- An arrow button nudges the film on a tap and moves it while you hold it. NegPy leaves the + dock's button mode (`BTNMODE`) as you set it. - When NegPy releases the dock, it puts back the dock's saved light mode and control mode. The dock's own pulsed light stays with Dock Connect. diff --git a/negpy/infrastructure/capture/lento_dock.py b/negpy/infrastructure/capture/lento_dock.py index 25172265b..3935c6951 100644 --- a/negpy/infrastructure/capture/lento_dock.py +++ b/negpy/infrastructure/capture/lento_dock.py @@ -49,6 +49,8 @@ EXPOSE_WAIT_S = 5.0 # the dock's own exposure on an S press lasts 3 s BUSY_RETRY_S = 0.25 # the API's retry interval for a BUSY reply SCAN_BUTTON_DEBOUNCE_S = 1.0 +BUTTON_POLL_S = 0.1 # a tap of S lasts longer than this +SCAN_BUTTON_HOLD_S = 1.0 # S held this long is the dock's own action, not Scan AUTOALIGN_START_S = 1.5 # an inserted strip starts to align within this time EJECT_TIMEOUT_S = 30.0 HOLD_INTERVAL_S = 30.0 @@ -161,6 +163,8 @@ def __init__( self._last_frameat = "" self._in_command = False self._last_press = -SCAN_BUTTON_DEBOUNCE_S + self._btn_seen = False # the dock answers BTN?, so the S button is read directly + self._shutter_down_at: Optional[float] = None self.on_scan_button: Optional[Callable[[], None]] = None # called on the reader thread self._saved: dict[str, str] = {} self._last_tx = time.monotonic() @@ -176,6 +180,8 @@ def __init__( self.info = self._identify() self._keepalive = threading.Thread(target=self._keepalive_loop, name="lento-dock-keepalive", daemon=True) self._keepalive.start() + self._buttons = threading.Thread(target=self._button_loop, name="lento-dock-buttons", daemon=True) + self._buttons.start() # ----- public API (the Scanlight driver's) ----- @@ -283,7 +289,7 @@ def close(self) -> None: except Exception: logger.exception("could not restore the Lento Dock's mode") self._stop.set() - for thread in (self._reader, getattr(self, "_keepalive", None)): + for thread in (self._reader, getattr(self, "_keepalive", None), getattr(self, "_buttons", None)): if thread is not None and thread.is_alive() and thread is not threading.current_thread(): thread.join(timeout=1.0) try: @@ -387,6 +393,28 @@ def _await(self, cmd: str, expect: str, timeout: float) -> str: raise ValueError(f"Lento Dock refused {cmd!r}: {line}") return line + def _button_loop(self) -> None: + """Ask for the button state while NegPy holds the dock. The reply comes to the reader.""" + while not self._stop.wait(BUTTON_POLL_S): + if self._continuous and self.on_scan_button is not None and self._reader_error is None: + try: + self._send("BTN?") + except Exception: + logger.debug("Lento Dock button poll failed", exc_info=True) + + def _on_buttons(self, line: str) -> None: + """A tap of S works as Scan, on release. A hold is the dock's own (firmware 1.0.238 ejects on it).""" + self._btn_seen = True + down = parse_kv(line).get("shutter") == "1" + now = time.monotonic() + if down and self._shutter_down_at is None: + self._shutter_down_at = now + elif not down and self._shutter_down_at is not None: + held = now - self._shutter_down_at + self._shutter_down_at = None + if held < SCAN_BUTTON_HOLD_S: + self._scan_button() + def _keepalive_loop(self) -> None: while not self._stop.wait(1.0): try: @@ -449,9 +477,12 @@ def _dispatch(self, line: str) -> None: logger.info("Lento Dock state %s -> %s", was, self._state.get("st")) # NegPy starts no exposure of the dock's own: one that begins outside a NegPy command is # the S button. The dock accepts light commands during it. - if self._state.get("st") == "EXPOSE" and was != "EXPOSE" and not self._in_command: - self._scan_button() - elif line.startswith(("SCANREQ", "SCANSTOP")) and not self._in_command: + if self._state.get("st") == "EXPOSE" and was != "EXPOSE" and not self._in_command and not self._btn_seen: + self._scan_button() # firmware with no BTN?: the dock's own exposure is the press + elif line.startswith("BTN "): + self._on_buttons(line) + return # no command waits for a poll's reply + elif line.startswith(("SCANREQ", "SCANSTOP")) and not self._in_command and not self._btn_seen: self._scan_button() # firmware 1.0.238 and later report the S button as a scan request elif line.startswith("BOOT"): self._booted.set() diff --git a/negpy/infrastructure/simulated/lento_dock.py b/negpy/infrastructure/simulated/lento_dock.py index d6898429b..6a3350ed6 100644 --- a/negpy/infrastructure/simulated/lento_dock.py +++ b/negpy/infrastructure/simulated/lento_dock.py @@ -25,6 +25,8 @@ def __init__( self.frame = 0 # the frame in the window, counted from the first self.st = "READY" if frames and fed else "IDLE" # an unfed strip waits at the entrance for INSERT self.ok_replies = ok_replies # firmware 1.0.239 answers CONTRGB and CONTHOLD with a bare OK + self.shutter = 0 # the S button, held down + self.answers_btn = True # firmware before the published API may not know BTN? self.busy = 0 # the next commands answered BUSY, as during the dock's own exposure self._out: queue.Queue[bytes] = queue.Queue() self._buf = b"" @@ -135,6 +137,8 @@ def _answer(self, line: str) -> None: elif cmd == "CONTHOLD" and arg in ("0", "1"): self.hold = int(arg) self._say("OK" if self.ok_replies else f"CONTHOLD {arg}") + elif line == "BTN?" and self.answers_btn: + self._say(f"BTN adv=0 back=0 shutter={self.shutter}") elif line == "FRAME NEXT": self._frame_next() elif line == "INSERT": diff --git a/tests/test_lento_dock_driver.py b/tests/test_lento_dock_driver.py index afff042b5..86ffff43b 100644 --- a/tests/test_lento_dock_driver.py +++ b/tests/test_lento_dock_driver.py @@ -256,6 +256,7 @@ def test_the_light_holds_again_after_an_eject(sim): def test_the_s_button_calls_the_scan_handler(sim): + sim.answers_btn = False # firmware that reports S only through its own exposure pressed = [] with LentoDock(serial_obj=sim) as dock: dock.on_scan_button = lambda: pressed.append(1) @@ -313,6 +314,7 @@ def test_frameat_err_is_the_end_of_the_strip(sim): def test_a_scan_request_is_the_s_button_and_counts_once(sim): + sim.answers_btn = False # firmware that reports S only through its own exposure pressed = [] with LentoDock(serial_obj=sim) as dock: dock.on_scan_button = lambda: pressed.append(1) @@ -341,3 +343,37 @@ def aligned(): threading.Timer(0.3, aligned).start() assert not dock.strip_waiting() assert "INSERT" not in sim.lines + + +def _wait_for(cond, timeout=2.0): + deadline = time.monotonic() + timeout + while not cond() and time.monotonic() < deadline: + time.sleep(0.01) + + +def test_a_tap_of_s_read_through_btn_works_as_scan(sim): + pressed = [] + with LentoDock(serial_obj=sim) as dock: + dock.on_scan_button = lambda: pressed.append(1) + dock.set_color(200, 110, 40) # NegPy holds the dock: the buttons are read + _wait_for(lambda: "BTN?" in sim.lines) + sim.shutter = 1 + time.sleep(0.3) + assert pressed == [] # a press counts on release, so a hold can stay the dock's own + sim.shutter = 0 + _wait_for(lambda: pressed) + assert pressed == [1] + + +def test_a_held_s_is_left_to_the_dock(sim, monkeypatch): + monkeypatch.setattr(lento_dock, "SCAN_BUTTON_HOLD_S", 0.3) + pressed = [] + with LentoDock(serial_obj=sim) as dock: + dock.on_scan_button = lambda: pressed.append(1) + dock.set_color(200, 110, 40) + _wait_for(lambda: "BTN?" in sim.lines) + sim.shutter = 1 + time.sleep(0.5) + sim.shutter = 0 + time.sleep(0.3) + assert pressed == [] From 0364e0955a15a53fca74ffefb3a658c2d786135b Mon Sep 17 00:00:00 2001 From: Akinyemi Paul Odulate Date: Wed, 4 Nov 2026 12:29:50 +0000 Subject: [PATCH 35/46] Lento Dock: the arrow buttons jog while NegPy holds the dock On firmware 1.0.238 a tap of an arrow moved a whole frame. While NegPy holds the dock it now sets BTNMODE JOG, so a tap nudges the film and a hold moves it, and it puts the saved mode back on release. Next Frame and Whole Strip move whole frames. Refs #1375 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CiaS7mdcgupjy3zYtMa4Su --- docs/CAMERA_SCANNING.md | 5 +++-- negpy/infrastructure/capture/lento_dock.py | 14 ++++++++++++++ negpy/infrastructure/simulated/lento_dock.py | 8 ++++++++ tests/test_lento_dock_driver.py | 8 ++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/CAMERA_SCANNING.md b/docs/CAMERA_SCANNING.md index 17bc9cdee..19855d960 100644 --- a/docs/CAMERA_SCANNING.md +++ b/docs/CAMERA_SCANNING.md @@ -208,8 +208,9 @@ described above. itself: with a frame in the window it makes a short exposure of its own, and the film moves only after that ends. With a strip at the entrance, a tap lines the strip up first. A hold of S is left to the dock, which ejects the strip on firmware 1.0.238. -- An arrow button nudges the film on a tap and moves it while you hold it. NegPy leaves the - dock's button mode (`BTNMODE`) as you set it. +- While NegPy uses the dock, an arrow button nudges the film on a tap and moves it while you + hold it (`BTNMODE JOG`), because Next Frame and Whole Strip move whole frames. NegPy puts the + dock's own button mode back when it releases the dock. - When NegPy releases the dock, it puts back the dock's saved light mode and control mode. The dock's own pulsed light stays with Dock Connect. diff --git a/negpy/infrastructure/capture/lento_dock.py b/negpy/infrastructure/capture/lento_dock.py index 3935c6951..316e28aa3 100644 --- a/negpy/infrastructure/capture/lento_dock.py +++ b/negpy/infrastructure/capture/lento_dock.py @@ -324,6 +324,18 @@ def _enter_continuous(self) -> None: self._command("IDLE AUTO", r"^(IDLE=|ERR)") self._command("EXPMODE CONT", r"^(EXPMODE=|ERR)") self._continuous = True + self._set_buttons_to_jog() + + def _set_buttons_to_jog(self) -> None: + """The arrow buttons nudge the film while NegPy holds the dock: NegPy moves whole frames + itself. Firmware that refuses BTNMODE keeps its buttons as they are.""" + try: + mode = parse_kv(self._command("BTNMODE", r"^(BTNMODE=|ERR)")).get("BTNMODE", "") + if mode and mode != "JOG": + self._command("BTNMODE JOG", r"^(BTNMODE=|ERR)") + self._saved["BTNMODE"] = mode + except (TimeoutError, ValueError): + logger.info("Lento Dock keeps its button mode") def _restore(self) -> None: if not self._continuous: @@ -334,6 +346,8 @@ def _restore(self) -> None: self._command(f"EXPMODE {'BRGB' if expmode == 'CONT' else expmode}", r"^(EXPMODE=|ERR)") mode = self._saved.get("MODE", "AUTO") self._command(f"MODE {'AUTO' if mode == 'SEMI' else mode}", r"^(MODE=|ERR)") + if self._saved.get("BTNMODE"): + self._command(f"BTNMODE {self._saved['BTNMODE']}", r"^(BTNMODE=|ERR)") self._continuous = self._light_on = False def _send(self, cmd: str) -> None: diff --git a/negpy/infrastructure/simulated/lento_dock.py b/negpy/infrastructure/simulated/lento_dock.py index 6a3350ed6..a5d66f4cc 100644 --- a/negpy/infrastructure/simulated/lento_dock.py +++ b/negpy/infrastructure/simulated/lento_dock.py @@ -25,6 +25,7 @@ def __init__( self.frame = 0 # the frame in the window, counted from the first self.st = "READY" if frames and fed else "IDLE" # an unfed strip waits at the entrance for INSERT self.ok_replies = ok_replies # firmware 1.0.239 answers CONTRGB and CONTHOLD with a bare OK + self.btnmode = "BOTH" self.shutter = 0 # the S button, held down self.answers_btn = True # firmware before the published API may not know BTN? self.busy = 0 # the next commands answered BUSY, as during the dock's own exposure @@ -137,6 +138,13 @@ def _answer(self, line: str) -> None: elif cmd == "CONTHOLD" and arg in ("0", "1"): self.hold = int(arg) self._say("OK" if self.ok_replies else f"CONTHOLD {arg}") + elif cmd == "BTNMODE": + if arg in ("BOTH", "JOG", "FRAME"): + self.btnmode = arg + elif arg: + self._say("ERR") + return + self._say(f"BTNMODE={self.btnmode} step=0.5") elif line == "BTN?" and self.answers_btn: self._say(f"BTN adv=0 back=0 shutter={self.shutter}") elif line == "FRAME NEXT": diff --git a/tests/test_lento_dock_driver.py b/tests/test_lento_dock_driver.py index 86ffff43b..28fee0cc9 100644 --- a/tests/test_lento_dock_driver.py +++ b/tests/test_lento_dock_driver.py @@ -377,3 +377,11 @@ def test_a_held_s_is_left_to_the_dock(sim, monkeypatch): sim.shutter = 0 time.sleep(0.3) assert pressed == [] + + +def test_the_arrows_jog_while_negpy_holds_the_dock(sim): + sim.btnmode = "FRAME" + with LentoDock(serial_obj=sim) as dock: + dock.set_color(200, 110, 40) + assert sim.btnmode == "JOG" + assert sim.btnmode == "FRAME" # the user's setting comes back From c2ed272650cc27791f9691d2172eecf5d2a31e26 Mon Sep 17 00:00:00 2001 From: Akinyemi Paul Odulate Date: Wed, 4 Nov 2026 14:15:20 +0000 Subject: [PATCH 36/46] Lento Dock: keep the dock awake, and reconnect a silent one After a whole strip ejected, the dock answered nothing three minutes later: STATE? and CONTRGB timed out with no line from the dock, and NegPy kept the dead link. The dock sleeps after 90 s without work (SLEEP, default 90). While NegPy holds the dock it now sets SLEEP 0 and puts the saved timeout back on release. A command that times out with no line from the dock for that long marks the link silent, so the next use opens the port again, which resets the dock. Refs #1375 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CiaS7mdcgupjy3zYtMa4Su --- docs/CAMERA_SCANNING.md | 5 ++++- negpy/infrastructure/capture/lento_dock.py | 23 +++++++++++++++++++- negpy/infrastructure/simulated/lento_dock.py | 11 ++++++++++ tests/test_lento_dock_driver.py | 18 +++++++++++++++ 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/docs/CAMERA_SCANNING.md b/docs/CAMERA_SCANNING.md index 19855d960..e90e2db9e 100644 --- a/docs/CAMERA_SCANNING.md +++ b/docs/CAMERA_SCANNING.md @@ -211,7 +211,10 @@ described above. - While NegPy uses the dock, an arrow button nudges the film on a tap and moves it while you hold it (`BTNMODE JOG`), because Next Frame and Whole Strip move whole frames. NegPy puts the dock's own button mode back when it releases the dock. -- When NegPy releases the dock, it puts back the dock's saved light mode and control mode. +- While NegPy uses the dock, the dock's auto-sleep is off (`SLEEP 0`), because a sleeping dock + answers nothing. If the dock goes silent anyway, NegPy opens its port again on the next use. +- When NegPy releases the dock, it puts back the dock's saved light mode, control mode, button + mode and sleep timeout. The dock's own pulsed light stays with Dock Connect. diff --git a/negpy/infrastructure/capture/lento_dock.py b/negpy/infrastructure/capture/lento_dock.py index 316e28aa3..386525e3a 100644 --- a/negpy/infrastructure/capture/lento_dock.py +++ b/negpy/infrastructure/capture/lento_dock.py @@ -164,6 +164,8 @@ def __init__( self._in_command = False self._last_press = -SCAN_BUTTON_DEBOUNCE_S self._btn_seen = False # the dock answers BTN?, so the S button is read directly + self._last_rx = time.monotonic() + self._silent = False self._shutter_down_at: Optional[float] = None self.on_scan_button: Optional[Callable[[], None]] = None # called on the reader thread self._saved: dict[str, str] = {} @@ -195,7 +197,8 @@ def last_temp_c(self) -> Optional[float]: return None def is_connected(self) -> bool: - return self._reader.is_alive() and self._reader_error is None + """False once the dock has gone silent: the caller opens the port again, which resets it.""" + return self._reader.is_alive() and self._reader_error is None and not self._silent def describe(self) -> tuple[str, bool, bool]: """(status text, has a white channel, has an infrared channel) for the Scan panel.""" @@ -325,6 +328,18 @@ def _enter_continuous(self) -> None: self._command("EXPMODE CONT", r"^(EXPMODE=|ERR)") self._continuous = True self._set_buttons_to_jog() + self._keep_awake() + + def _keep_awake(self) -> None: + """The dock sleeps after 90 s without work, and a sleeping dock answers nothing. NegPy turns + the auto-sleep off while it holds the dock and puts the saved timeout back on release.""" + try: + sec = parse_kv(self._command("SLEEP", r"^(SLEEP\b|ERR)")).get("sec", "") + if sec and sec != "0": + self._command("SLEEP 0", r"^(SLEEP\b|ERR)") + self._saved["SLEEP"] = sec + except (TimeoutError, ValueError): + logger.info("Lento Dock keeps its sleep timeout") def _set_buttons_to_jog(self) -> None: """The arrow buttons nudge the film while NegPy holds the dock: NegPy moves whole frames @@ -348,6 +363,8 @@ def _restore(self) -> None: self._command(f"MODE {'AUTO' if mode == 'SEMI' else mode}", r"^(MODE=|ERR)") if self._saved.get("BTNMODE"): self._command(f"BTNMODE {self._saved['BTNMODE']}", r"^(BTNMODE=|ERR)") + if self._saved.get("SLEEP"): + self._command(f"SLEEP {self._saved['SLEEP']}", r"^(SLEEP\b|ERR)") self._continuous = self._light_on = False def _send(self, cmd: str) -> None: @@ -394,6 +411,9 @@ def _await(self, cmd: str, expect: str, timeout: float) -> str: left = deadline - time.monotonic() if left <= 0: heard = "; ".join(seen[-8:]) or "nothing" + if time.monotonic() - self._last_rx >= timeout: + self._silent = True + logger.warning("Lento Dock went silent; NegPy opens its port again on the next use") raise TimeoutError(f"No reply to {cmd!r} within {timeout}s (the dock sent: {heard})") try: line = self._replies.get(timeout=left) @@ -480,6 +500,7 @@ def _scan_button(self) -> None: def _dispatch(self, line: str) -> None: logger.debug("Lento Dock: %s", line) + self._last_rx = time.monotonic() if line.startswith("FRAMEAT") and not line.startswith("FRAMEAT ERR"): self._last_frameat = line elif line.startswith(("END_OF_ROLL", "ROLLEND", "FRAMEAT ERR")): diff --git a/negpy/infrastructure/simulated/lento_dock.py b/negpy/infrastructure/simulated/lento_dock.py index a5d66f4cc..58f10b0dd 100644 --- a/negpy/infrastructure/simulated/lento_dock.py +++ b/negpy/infrastructure/simulated/lento_dock.py @@ -26,6 +26,8 @@ def __init__( self.st = "READY" if frames and fed else "IDLE" # an unfed strip waits at the entrance for INSERT self.ok_replies = ok_replies # firmware 1.0.239 answers CONTRGB and CONTHOLD with a bare OK self.btnmode = "BOTH" + self.sleep_s = 90 + self.asleep = False # a sleeping dock answers nothing self.shutter = 0 # the S button, held down self.answers_btn = True # firmware before the published API may not know BTN? self.busy = 0 # the next commands answered BUSY, as during the dock's own exposure @@ -116,6 +118,8 @@ def _insert(self) -> None: def _answer(self, line: str) -> None: cmd, _, arg = line.partition(" ") + if self.asleep: + return if self.busy and cmd not in ("STATE?", "PING"): self.busy -= 1 self._say("BUSY") @@ -138,6 +142,13 @@ def _answer(self, line: str) -> None: elif cmd == "CONTHOLD" and arg in ("0", "1"): self.hold = int(arg) self._say("OK" if self.ok_replies else f"CONTHOLD {arg}") + elif cmd == "SLEEP": + if arg.isdigit(): + self.sleep_s = int(arg) + elif arg: + self._say("ERR") + return + self._say(f"SLEEP sec={self.sleep_s}") elif cmd == "BTNMODE": if arg in ("BOTH", "JOG", "FRAME"): self.btnmode = arg diff --git a/tests/test_lento_dock_driver.py b/tests/test_lento_dock_driver.py index 28fee0cc9..8d8c43142 100644 --- a/tests/test_lento_dock_driver.py +++ b/tests/test_lento_dock_driver.py @@ -385,3 +385,21 @@ def test_the_arrows_jog_while_negpy_holds_the_dock(sim): dock.set_color(200, 110, 40) assert sim.btnmode == "JOG" assert sim.btnmode == "FRAME" # the user's setting comes back + + +def test_the_dock_stays_awake_while_negpy_holds_it(sim): + with LentoDock(serial_obj=sim) as dock: + dock.set_color(200, 110, 40) + assert sim.sleep_s == 0 + assert sim.sleep_s == 90 # the user's timeout comes back + + +def test_a_silent_dock_reads_as_disconnected(sim): + with LentoDock(serial_obj=sim) as dock: + dock.set_color(200, 110, 40) + sim.asleep = True + time.sleep(0.3) # the button polls go unanswered too + with pytest.raises(TimeoutError): + dock.set_color(100, 100, 100) + assert not dock.is_connected() + sim.asleep = False From 0f1d6390ccb8ee5f6d42d284aee14ff15079c2ee Mon Sep 17 00:00:00 2001 From: Akinyemi Paul Odulate Date: Wed, 4 Nov 2026 15:25:06 +0000 Subject: [PATCH 37/46] Offer the white-light presets with the Lento Dock describe() reported no white channel, so the Scan panel hid the white-light presets and white mode for the dock. set_color already lights R, G and B together at one level for white, so the dock now reports white. It still reports no infrared. Refs #1375 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CiaS7mdcgupjy3zYtMa4Su --- docs/CAMERA_SCANNING.md | 4 ++-- negpy/infrastructure/capture/lento_dock.py | 5 +++-- tests/test_capture_lights.py | 2 +- tests/test_lento_dock_driver.py | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/CAMERA_SCANNING.md b/docs/CAMERA_SCANNING.md index 87e694cbd..5a139eaf1 100644 --- a/docs/CAMERA_SCANNING.md +++ b/docs/CAMERA_SCANNING.md @@ -181,8 +181,8 @@ described above. - Close Dock Connect before you open the Scan tab. Only one program can use the dock's port. - NegPy uses a Scanlight first. To scan with the dock, disconnect the Scanlight. -- The dock has no white or infrared light. A white-light preset lights red, green and blue - together at the same level. +- The dock has no white LED and no infrared light. NegPy still offers the white-light presets + and lights red, green and blue together at the same level for them. - NegPy does not move the film. While NegPy uses the dock, its buttons jog the film in small steps. Jog each frame into the window before you scan it. - When NegPy releases the dock, it puts back the dock's saved light mode and control mode. diff --git a/negpy/infrastructure/capture/lento_dock.py b/negpy/infrastructure/capture/lento_dock.py index 287615655..6076dc0a6 100644 --- a/negpy/infrastructure/capture/lento_dock.py +++ b/negpy/infrastructure/capture/lento_dock.py @@ -181,11 +181,12 @@ def is_connected(self) -> bool: return self._reader.is_alive() and self._reader_error is None def describe(self) -> tuple[str, bool, bool]: - """(status text, has a white channel, has an infrared channel) for the Scan panel.""" + """(status text, has a white channel, has an infrared channel) for the Scan panel. White is + R, G and B lit together (see `set_color`), so the white-light presets work on the dock.""" info = self.info or self._identify() if info is None: raise ConnectionError("Lento Dock does not answer IDENT") - return f"{info.name} (fw {info.firmware})", False, False + return f"{info.name} (fw {info.firmware})", True, False def set_color(self, r: int = 0, g: int = 0, b: int = 0, w: int = 0, ir: int = 0, save: bool = False) -> None: """Hold the panel at R, G, B. The continuous light has no white or infrared channel: diff --git a/tests/test_capture_lights.py b/tests/test_capture_lights.py index 22d6cbfe2..218012a86 100644 --- a/tests/test_capture_lights.py +++ b/tests/test_capture_lights.py @@ -59,6 +59,6 @@ def test_the_simulator_can_stand_in_for_a_dock(monkeypatch): light = lights.open_light() try: assert isinstance(light, LentoDock) - assert light.describe() == ("Lento Dock 35 (fw 1.0.224)", False, False) + assert light.describe() == ("Lento Dock 35 (fw 1.0.224)", True, False) finally: light.close() diff --git a/tests/test_lento_dock_driver.py b/tests/test_lento_dock_driver.py index e5dc9324d..a88095f2d 100644 --- a/tests/test_lento_dock_driver.py +++ b/tests/test_lento_dock_driver.py @@ -26,10 +26,10 @@ def test_constructing_the_driver_needs_no_hardware(dock): assert dock.info is not None and dock.info.model == "35" -def test_describe_reports_rgb_only(dock): +def test_describe_offers_white_and_no_infrared(dock): detail, has_white, has_ir = dock.describe() assert detail == "Lento Dock 35 (fw 1.0.224)" - assert (has_white, has_ir) == (False, False) + assert (has_white, has_ir) == (True, False) def test_levels_scale_to_the_docks_duty(): From 933aedde18dfb44257dd02987b4e03758e9f12e6 Mon Sep 17 00:00:00 2001 From: jboneng Date: Wed, 4 Nov 2026 20:45:37 +0100 Subject: [PATCH 38/46] =?UTF-8?q?feat(i18n):=20add=20Norwegian=20Bokm?= =?UTF-8?q?=C3=A5l=20translation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Norwegian Bokmål (nb) as a UI language. - nb.json catalog with all 3327 messages - USER_GUIDE.nb.md with all 44 panel guides - System Default resolves "no" and "nn" system languages to Bokmål - Count forms keep "singular|plural" nouns, as for German - Qt standard buttons and file dialogs use the Nynorsk catalog (qtbase_nn), because Qt ships no Bokmål one - Norwegian search synonyms in the command palette - Norwegian Bokmål in the README and the user guide language lists - Tests for language resolution, count forms, the export ratio combo and the panel guides --- README.md | 4 +- docs/USER_GUIDE.md | 2 +- negpy/desktop/view/widgets/command_palette.py | 5 + negpy/kernel/system/i18n.py | 9 +- negpy/kernel/system/text.py | 2 +- negpy/translations/USER_GUIDE.en.md | 2 +- negpy/translations/USER_GUIDE.nb.md | 627 ++++ negpy/translations/nb.json | 3329 +++++++++++++++++ tests/test_i18n.py | 6 +- tests/test_sequential_frame.py | 6 +- tests/test_step_combo.py | 2 +- 11 files changed, 3983 insertions(+), 11 deletions(-) create mode 100644 negpy/translations/USER_GUIDE.nb.md create mode 100644 negpy/translations/nb.json diff --git a/README.md b/README.md index f5ac4bda8..0daff0ee4 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](#getting-started) [![Film](https://img.shields.io/badge/film-C41%20%7C%20B%26W%20%7C%20E6-red?logo=googlephotos&logoColor=white)](docs/PIPELINE.md) - [![Languages](https://img.shields.io/badge/languages-EN%20%7C%20日本語%20%7C%20한국어%20%7C%20中文%20%7C%20DE%20%7C%20VI-informational)](docs/USER_GUIDE.md) + [![Languages](https://img.shields.io/badge/languages-EN%20%7C%20日本語%20%7C%20한국어%20%7C%20中文%20%7C%20DE%20%7C%20VI%20%7C%20NB-informational)](docs/USER_GUIDE.md) [![Python](https://img.shields.io/badge/python-3.13%2B-blue)](pyproject.toml) [![Rust](https://img.shields.io/badge/rust-negpy__core-orange?logo=rust)](negpy_core) @@ -30,7 +30,7 @@ The [User Guide](docs/USER_GUIDE.md) covers every panel and control (the ⓘ on ## Languages -The desktop interface supports English, Simplified Chinese (简体中文), Japanese (日本語), Korean (한국어), German (Deutsch) and Vietnamese (Tiếng Việt). The panel guides are translated into all of them except Vietnamese. Choose a language in **Preferences → Interface → Language**, then restart NegPy. **System Default** follows your system language and uses English for unsupported languages. Missing panel translations fall back to English. See [CONTRIBUTING.md](CONTRIBUTING.md#translations) to update or add a translation. +The desktop interface supports English, Simplified Chinese (简体中文), Japanese (日本語), Korean (한국어), German (Deutsch), Vietnamese (Tiếng Việt) and Norwegian Bokmål (Norsk bokmål). The panel guides are translated into all of them except Vietnamese. Choose a language in **Preferences → Interface → Language**, then restart NegPy. **System Default** follows your system language and uses English for unsupported languages. Missing panel translations fall back to English. See [CONTRIBUTING.md](CONTRIBUTING.md#translations) to update or add a translation. ## Features diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e8e98f61a..9c725cd63 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -837,7 +837,7 @@ Shows the whole strip, to place and pick the frames before the scan. **Apply Fra ### Interface -* **Language**: English, Simplified Chinese, Japanese, Korean, German or Vietnamese, after a restart. +* **Language**: English, Simplified Chinese, Japanese, Korean, German, Vietnamese or Norwegian Bokmål, after a restart. * **UI scale**: 80% to 120%, after a restart. * **Canvas background**: from black to white. Mid gray is best for judging a print. * **Sticky Zoom**: keeps the zoom when you change frames. **Reverse Scroll Zoom**: scroll up zooms out. diff --git a/negpy/desktop/view/widgets/command_palette.py b/negpy/desktop/view/widgets/command_palette.py index b3d4d0439..06b8b23f9 100644 --- a/negpy/desktop/view/widgets/command_palette.py +++ b/negpy/desktop/view/widgets/command_palette.py @@ -63,6 +63,11 @@ "cân bằng trắng": ("Filtration", "Temperature"), "phối cảnh": ("Tilt", "Swing"), "xóa bụi": ("Retouch",), + "lysstyrke": ("Print Density",), + "metning": ("Chroma", "Dye Separation"), + "hvitbalanse": ("Filtration", "Temperature"), + "perspektiv": ("Tilt", "Swing"), + "støvfjerning": ("Retouch",), "exposure": ("Print Density",), "brightness": ("Print Density",), "contrast": ("ISO-R Grade", "Contrast Mask"), diff --git a/negpy/kernel/system/i18n.py b/negpy/kernel/system/i18n.py index e32306007..565845368 100644 --- a/negpy/kernel/system/i18n.py +++ b/negpy/kernel/system/i18n.py @@ -25,6 +25,7 @@ def mark(source: str) -> str: ("ko", "한국어"), ("de", "Deutsch"), ("vi", "Tiếng Việt"), + ("nb", "Norsk bokmål"), ) _language = "en" _messages: dict[str, str] = {} @@ -44,6 +45,9 @@ def resolve_language(choice: str, system_languages: list[str] | None = None) -> if tag in {"zh", "zh_cn", "zh_sg", "zh_hans", "zh_hans_cn", "zh_hans_sg"}: return "zh_CN" base = tag.split("_", 1)[0] + if base in {"no", "nn"}: + # Systems tag Norwegian as macrolanguage "no" or as Nynorsk; Bokmål is the one catalog. + base = "nb" supported = {locale.lower(): locale for locale, _ in LANGUAGES if locale != "system"} return supported.get(tag, supported.get(base, "en")) @@ -95,6 +99,9 @@ def install_qt_translations(app: "QCoreApplication") -> None: if _language == "en": return translator = QTranslator(app) - if translator.load(f"qtbase_{_language}", QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)): + directory = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath) + # Qt ships no Bokmål catalog. Its Nynorsk one is close enough for the standard buttons and file dialogs. + names = (f"qtbase_{_language}", "qtbase_nn") if _language == "nb" else (f"qtbase_{_language}",) + if any(translator.load(name, directory) for name in names): app.installTranslator(translator) _qt_translator = translator diff --git a/negpy/kernel/system/text.py b/negpy/kernel/system/text.py index d538be670..0fda7f7b5 100644 --- a/negpy/kernel/system/text.py +++ b/negpy/kernel/system/text.py @@ -11,7 +11,7 @@ def plural(count: int, singular: str, plural_form: str = "") -> str: # These languages use one noun form for all counts, without an English plural suffix. if language() in {"zh_CN", "ja", "ko", "vi"}: return tr(singular) - if language() == "de": + if language() in {"de", "nb"}: # The catalog value of a noun holds both forms as "Singular|Plural". one, _, many = tr(singular).partition("|") return one if abs(count) == 1 else (tr(plural_form) if plural_form else many or one) diff --git a/negpy/translations/USER_GUIDE.en.md b/negpy/translations/USER_GUIDE.en.md index e8e98f61a..9c725cd63 100644 --- a/negpy/translations/USER_GUIDE.en.md +++ b/negpy/translations/USER_GUIDE.en.md @@ -837,7 +837,7 @@ Shows the whole strip, to place and pick the frames before the scan. **Apply Fra ### Interface -* **Language**: English, Simplified Chinese, Japanese, Korean, German or Vietnamese, after a restart. +* **Language**: English, Simplified Chinese, Japanese, Korean, German, Vietnamese or Norwegian Bokmål, after a restart. * **UI scale**: 80% to 120%, after a restart. * **Canvas background**: from black to white. Mid gray is best for judging a print. * **Sticky Zoom**: keeps the zoom when you change frames. **Reverse Scroll Zoom**: scroll up zooms out. diff --git a/negpy/translations/USER_GUIDE.nb.md b/negpy/translations/USER_GUIDE.nb.md new file mode 100644 index 000000000..8098117b8 --- /dev/null +++ b/negpy/translations/USER_GUIDE.nb.md @@ -0,0 +1,627 @@ +# NegPy brukerveiledning + +NegPy gjør skanninger av filmnegativer og dias til ferdige bilder. Originalfilene dine endres aldri. NegPy lagrer redigeringene dine i en lokal database. Velg språk under Innstillinger → Grensesnitt → Språk, og start NegPy på nytt. Systemstandard følger systemspråket og bruker engelsk når NegPy ikke har det språket. Når et panel mangler oversettelse, viser NegPy den engelske teksten. + + +### Bibliotek + +Fanen **Bibliotek** (`Ctrl+L`) viser **rullene** dine. En rull er en navngitt gruppe bilder, vanligvis én film: en **mapperull** eller en **virtuell rull**. Importen husker mappen. NegPy flytter, gir nytt navn til eller sletter aldri filene dine. + +* **+**: **Importer mappe som rull…** gjør en mappe til én rull og åpner den. **Importer undermapper som ruller…** gjør hver undermappe med bilder til en rull. +* **Dobbeltklikk** en rull (eller trykk Enter) for å åpne den i filmstripen. +* **⋮** eller høyreklikk en rull: **Åpne**, **Last ut alle…** (den åpne rullen), **Rullanalyse**, **Gi nytt navn…**, **Lagre i mapperull**, **Gjenopprett fjernede bilder…** og **Slett…**. Gjenopprett fjernede bilder henter tilbake bildene som Fjern fra rull tok ut av en mapperull. Linjen under navnet på rullen teller dem. Slett beholder filene og de delte redigeringene deres. Rullens innstillinger, grunnlag, filmbase, scener, kortlåser og redigeringene som bare ble gjort i den rullen, går tapt. Oppdater legger ikke til en slettet mapperull igjen. +* **Filtrer ruller…** og **Sorter** hjelper deg å finne en rull. **⋮ → Oppdater** finner nye undermapper og teller bildene på nytt. **⋮ → Søkefiltre…** viser mappenavn som hoppes over (først `export`). + +En rull der mappen er borte, viser **mappen mangler**. Et mappenavn som samsvarer med utstyret ditt ("penf" for "Pen F"), åpner Rullinnstillinger ferdig utfylt. + +En mapperull inneholder bare filene i sin egen mappe. En fil du legger til fra en annen mappe, blir værende bare i denne økten. + +Bruk filmstripen for å lagre bildene i filmstripen som en rull. **Lagre i mapperull** gjør bilder som alle kommer fra én mappe, til rullen for den mappen. De andre filene i mappen vises som fjernet. **Lagre som virtuell rull…** beholder nøyaktig disse bildene, fra hvilke som helst mapper. Lagre i mapperull gjør også en virtuell rull fra én mappe til rullen for den mappen. Den nye rullen starter med den åpne rullens innstillinger, kortlåser og redigeringene som bare ble gjort i den rullen. Et bilde har én redigering i hver rull som inneholder det. Høyreklikk → **Rediger uavhengig i denne rullen** gir det en egen redigering i én rull. + + +### Filmstripe + +**Filmstripen** under lerretet viser de åpne bildene på én rad. Dra den øvre kanten for å gjøre bildene større. + +Raden **Rull** på verktøylinjen inneholder: + +* Navnet på rullen og antallet bilder, beholdte og forkastede. +* **Filterfeltet**: skriv en del av et filnavn, eller uttrykk som `film:portra iso:>=400` (se nedenfor). Søkeknappen, eller **Enter**, søker i hele biblioteket. +* **Sorter**: etter navn, dato eller scene. +* **Arkfilter**: alle bilder, bare beholdte, skjul forkastede, eller bare umerkede. Knappen er uthevet mens et filter skjuler bilder. +* **Rullinnstillinger** (merkelappen): detaljer om utstyr og film for dette bildet, utvalget eller rullen. +* **Bruk**: kopierer valgte innstillinger fra dette bildet til utvalget eller rullen. +* **⋮**: **Legg til filer…**, **Legg til mappe…**, **Lagre i mapperull**, **Lagre som virtuell rull…**, **Oppdater miniatyrbilder**, **Aktiv mappe** (laster inn nye filer som en skanner lagrer i mappen til det nyeste bildet), **Last ut…**, **Last ut alle…** (tømmer filmstripen; rullen blir værende i biblioteket), **Tilbakestill bilder til standard…**, **Gjenopprett fjernede bilder…** og **Veiledning for filmstripen…**. + +Rutenettknappen åpner **lysbordet** (`Shift+G`): hele rullen i rader, for sortering. Et klikk velger et bilde, og et dobbeltklikk eller `Enter` åpner det. Verktøylinjen virker på de valgte bildene: **Behold**, **Forkast**, roter og speilvend, **Autobeskjær** og **Eksporter**, og ⋯ inneholder resten av høyreklikkmenyen. Glidebryteren **Bilder per rad**, `+` og `-`, eller `Ctrl` med rullehjulet endrer bildestørrelsen. `Esc` går tilbake, og det gjør også et lerretsverktøy eller en titt. + +#### Filtrering + +Et vanlig ord samsvarer med filnavnet. `field:value`-uttrykk samsvarer med bildedata, og en innledende `-` utelukker: + +| Uttrykk | Finner | +|---|---| +| `film:portra` | filmtype som inneholder "portra" | +| `camera:"Nikon F3"` | anførselstegn for en verdi med mellomrom | +| `iso:>=400` | tall tar også `>`, `>=`, `<`, `<=` | +| `date:2024-03` · `shot:>=1998` | fildato · opptaksdato | +| `place:tokyo` · `lens:` · `roll:` | andre metadatafelt | +| `scene:beach` | bilder i en scene | +| `keeper:` · `rejected:` · `edited:` | merkede eller redigerte bilder | + +Uttrykk kan kombineres: `film:portra iso:>=400 -rejected:`. Metadatauttrykk samsvarer bare med bilder der du har fylt ut metadataene. **Søk etter betydning** (slå det på i Innstillinger → Ytelse) finner bilder etter en beskrivelse, for eksempel "en hund på en strand". + + +## 3. Analyseavlesning + +Denne avlesningen beskriver det gjeldende bildet mens du redigerer. + +#### Diagram + +Velg en diagramvisning: + +* **Kurve**: papirets kurve, fra negativet (bortover) til kopien (oppover). Brattere betyr mer kontrast. Det grå histogrammet er negativet, det fargede er kopien. +* **Bølgeform**: lysheten i kopien over bildet. +* **Parade**: rød, grønn og blå side om side; et grått motiv ligger i samme høyde i alle tre. **Paradeoverlegg** legger de tre i ett plott. +* **Vektorskop**: fargene i kopien rundt et midtpunkt. En klynge utenfor midten er et fargestikk. + +**Log** viser små områder, som klipping. + +#### Klipping + +Trekanter i de øvre hjørnene advarer når skygger blir helt svarte (venstre) eller høylys helt hvite (høyre). **Klippeoverlegg** (knappen med halvsirkelen) markerer disse delene på bildet. **Soneutheving** (markørknappen) viser bare sonen under pekeren på bildet. + +#### Soner og soneplassering + +**Gråkilen** kopierer 21 grå trinn; trinn som smelter sammen i svart eller hvitt, er tapte toner. **Sonestripen** viser hvor mye av bildet som havner i hver sone, fra 0 (svart) gjennom V (mellomgrå) til X (hvit). Dobbeltklikk den (eller trykk `Shift+Z`) for **soneoverlegget**, som farger bildet etter sone. + +Klikk en sone på stripen, og så et punkt på bildet, så angir NegPy kopien slik at punktet kopieres i den sonen. Én nål angir Kopitetthet; en andre angir også ISO-R-gradering. **Plasser soner** (eller Enter) bruker resultatet; `Esc` avbryter. + +#### Avlesninger + +Folden **Avlesninger** inneholder tall om skanningen. En gul verdi er en advarsel; hold pekeren over en rad for detaljer. + +**Filmbase** (trådkorset): klikk den klare filmen mellom bildene for å angi basen avlesningene måler fra. + +* **Negativ**: tetthetsomfanget til negativet (Δ). Hold pekeren over for flatt, normalt eller kontrastrikt. +* **Eksponering**: lyst (positiv) eller mørkt (negativ) bilde, i blendertrinn. +* **Klipping**: andel av kopien som er helt svart, og så helt hvit. +* **Skanneklipping**: andel av hver kanal ved skannerens maksimum. Ingen redigering retter det; eksponer skanningen lavere. +* **Reparer**: andel av bildet som støvfjerningen skrev om. +* **Fargeomfang**: andel som prøveskriveren ikke kan gjengi, under prøvetrykk på skjerm. + + +### 4.1 Beskjæring + +Velg den delen av bildet du vil beholde, selv eller med autobeskjæring. + +* **Tegn**: dra en beskjæring på bildet. Dra et hjørne for å endre størrelsen, innsiden for å flytte den, eller utenfor boksen for å rotere. `Enter` eller **Ferdig** beholder beskjæringen, og `Esc` setter tilbake beskjæringen du hadde før. **×** fjerner en beskjæring du har tegnet. Tegning slår av autobeskjæring. +* **Autobeskjær** (også på linjen i fanen Geometri): finner kantene i bildet og beskjærer etter dem. +* **Forhold**: formen på beskjæringen, for eksempel `3:2` eller `1:1`, dreid for å passe bildet. **Fri** bruker formen NegPy finner, og du kan dra én side om gangen. **Oppdag** velger den nærmeste standardformen. +* **Veiledning**: komposisjonslinjer, for eksempel tredjedeler, mens du tegner. Roterknappen dreier dem. +* **Modus** (folden Innstillinger for autobeskjæring): **Bare bildet** beskjærer til bildet. **Filmkant** beholder hele filmen med kanten og hullene. +* **Beskjæringsforskyvning** og **Justering av filmkant** (folden under Modus): flytter beskjæringen innenfor (positiv) eller utenfor (negativ) den funne kanten, og angir hvor langt den skjærer inn i filmkanten (Bare bildet). 0 % stopper ved filmkanten og 100 % ved bildekanten; over 100 % skjærer inn i bildet for å fjerne en hvit kant. +* **Rett opp først**: retter bildet etter filmkantene før autobeskjæring beskjærer det. +* **Beskjær alle bilder** (Bare bildet): beskjærer alle bilder sammen. Tydelige bilder hjelper utydelige, og beskjæringer du har tegnet, blir stående. + +Autobeskjæring krever at skannerlyset eller holderen er den lyseste delen av skanningen. Den lar et bilde være i fred når bildet er like lyst som lyset, eller når neste bilde fyller en stor del av én side. Beskjær et slikt bilde selv, eller bruk **Filmkant** og juster innover. + + +### 4.2 Rett opp + +Drei bildet og sett det i vater. + +* **Roter** og **Speilvend**: drei bildet en kvart omdreining, eller speilvend det. Verktøylinjen har de samme knappene. +* **Vater**: tegn en linje langs horisonten eller en loddrett kant, så roteres bildet til det er i vater. +* **Rett mot film** (også på linjen i fanen Geometri): retter bildet inn etter kantene på filmen. Den ser bort fra innholdet i bildet, så et bilde tatt på skrå blir stående på skrå. +* **Finrotasjon**: dreier bildet litt. Positiv er med klokken. +* **Tilt** og **Sving** (fold): rett opp en bygning fotografert nedenfra (Tilt) eller en vegg fotografert fra siden (Sving). **Tegn linjer** angir begge ut fra linjer langs de fire filmkantene. +* **Beskjær tomme hjørner**: beskjærer bort de tomme hjørnene som rotasjon, Tilt og Sving etterlater. + +Beskjær før en stor Tilt eller Sving, ellers kan filmkanten komme til syne og gjøre kopien mørkere. Objektivforvrengning rettes i **Optikk** ([§10.3](#103-optics)). + + +### 5.1 Filtrering: fargebalanse + +Korrigerer fargen i kopien, slik fargefiltrene i et forstørrelsesapparat i mørkerommet gjør. **Global / Skygger / Høylys** bruker fargekontrollene på hele bildet, eller mest på de mørke eller lyse delene. + +* **Velg WB**: klikk på noe i bildet som skal være grått, så korrigerer NegPy fargen slik at det blir grått. +* **Temperatur**: gjør bildet varmere eller kaldere. +* **Cyan / Magenta / Gul**: forskyver fargen mellom cyan og rødt, magenta og grønt, eller gult og blått. +* **Fargering** (`Shift+F`): viser bildet som et rutenett av litt ulike farger. Klikk den som ser riktig ut. `Esc` lukker den. +* **Fjerning av fargestikk**: fjerner et fargestikk som er ulikt i de mørke og lyse delene av bildet. Den starter på 0,5 (halvparten) for fargenegativer og på 0 for dias. Blader, himmel og vann regnes ikke som grått, så de beholder fargen. +* **Filter**: et farget filter, som på kameraobjektivet for svart-hvitt-film. Det vises bare for et fargenegativ som kopieres i S/H, og erstatter kontrollene ovenfor. Hvert filter gjør fargene motsatt av sin egen mørkere. Gul #8, Dyp gul #15, Oransje #21 og Rød #25 gjør en blå himmel mørkere, trinn for trinn. Rød gjør også blader mørke og hud blek. Gulgrønn #11 og Grønn #58 gjør blader lysere og hud mørkere. Ingen gir utseendet til en vanlig svart-hvitt-film. + + +### 5.2 Tone: lyshet og kontrast + +Hvor lys eller mørk kopien er, og hvor mye kontrast den har. + +* **Kopitetthet**: hvor lys eller mørk kopien er (eksponeringstiden i mørkerommet). Lavere er lysere. +* **ISO-R-gradering**: kontrast (papirgraderingen i mørkerommet). Et lavere tall gir mer kontrast; omtrent 110 er normalt. +* **Auto** (tryllestaven, også på linjen i fanen Eksponering): **Autotetthet** og **Autogradering** angir lyshet og kontrast for hvert bilde, så en rull kopieres jevnt. En glidebryter viser da den automatiske verdien: flytt den for å justere, og dobbeltklikk den for å gå tilbake. Høyreklikk tryllestaven for å slå begge på eller av. Auto-menyen har også: + * **Angi mål…**: lysheten og kontrasten Auto sikter mot. + * **Bak inn autotetthet/-gradering**: beholder de automatiske verdiene for dette bildet og slår av Auto for det. + * **Hold svart**: når du gjør skyggene lysere, forblir de dypeste svarttonene svarte. +* **Prøvestripe** (`Shift+T`): et rutenett av versjoner med ulik lyshet og kontrast. Klikk den du liker. `Esc` lukker den. +* **Skyggetetthet** / **Høylystetthet** (folden under Kopitetthet): gjør bare skyggene eller høylysene mørkere eller lysere. +* **Skyggegradering** / **Høylysgradering** (folden under ISO-R-gradering): kontrast bare i skyggene eller høylysene. +* **Kopieringshjelpemidler** (fold): + * **Forbelysning**: henter tilbake detaljer i de lyseste områdene. + * **Diffusjon**: en myk glød; mørke områder sprer seg inn i lyse. + * **Kontrastmaske**: åpner store mørke områder og holder tilbake store lyse, og beholder fine detaljer. I mørkerommet får en kopist dette med en myk, uskarp maske lagt på negativet. I digital kopiering får Fujifilm Frontier-skannere en lignende virkning med HyperTone. Områder langt fra mellomgrå endres mest, så et bilde med lav kontrast endres lite. +* **Global / R / G / B**: R, G eller B bruker graderingene på bare én farge, for et fargestikk som er ulikt i skygger og høylys. + +**Hvitpunkt** og **Svartpunkt** er på kortet **Lysmåling** i fanen Rull ([§10.5](#105-metering-negative--positive)). + + +### 5.3 Papirrespons: papiret + +Papiret bildet kopieres på, og hvordan det gjør lys om til toner fra svart til hvitt. + +* **Kopipapir**: papiret. Et ekte papir angir kontrastkurven og fargene ut fra produsentens data. **Digital (Adobe RGB)**, standarden, er et ideelt papir. **Digital (Pakon)** har fargene til en Pakon-laboratorieskanner. **Digital (Frontier)** har fargene og kontrastkurven til en laboratorieskanning fra Fujifilm Frontier. +* **Prøvestripe for papir** (den stripete knappen): kopierer bildet på hvert papir. Klikk et papir for å se det stort, bla med piltastene, og trykk **Behold** for å bruke det. +* **Fargestoffseparasjon**: fargestyrke i kopien. 1 er uendret. **Separasjonsdemping** legger mer av endringen på matte farger og mindre på sterke. **Kroma** i fanen Utseende endrer i stedet alle farger likt. +* **Papirhvitt**: viser hvitfargen til ekte papir, litt mørkere enn rent hvitt. +* **Papirsvart**: viser svarttonen til ekte papir, litt lysere enn rent svart. +* **Kurveform** (fold): + * **Snap**: mellomtonekontrast. Svart og hvitt flytter seg ikke. + * **Tå**: former skyggene. Positiv løfter dem, negativ gjør dem dypere. **Tåbredde** angir hvor langt inn i mellomtonene den når. + * **Skulder**: former høylysene. Positiv mykner dem, negativ gjør dem lysere og kan miste detaljer. **Skulderbredde** angir hvor langt den når. +* **Global / R / G / B**: R, G eller B bruker kurvekontrollene på ett fargelag. + + +### 5.4 Skygging og etterbelysning: gjør områder lysere eller mørkere + +Tegn en form, og gjør så bare det området lysere (skygging) eller mørkere (etterbelysning). Et dias bruker ikke dette kortet, men maskene blir stående. + +* **Tegn**: klikk for å plassere punkter. Dobbeltklikk, trykk Enter eller klikk nær starten for å lukke formen. Dra et punkt for å flytte det, klikk en "+" på en kant for å legge til et, og høyreklikk et punkt for å slette det. +* **Oval**: dra ut en oval. Håndtakene flytter, strekker og dreier den. +* **Kort**: en etterbelysning som toner ut, som et kort som flyttes over papiret. Dra fra kanten med full styrke (heltrukket linje) til der den slutter (stiplet linje). +* **Maskeliste**: klikk en maske for å velge den. Ikonene slår den på eller av, inverterer den (virker overalt unntatt innenfor den), viser omrisset eller sletter den. Glidebryterne nedenfor endrer den valgte masken. +* **Etterbelysning**: positiv gjør området mørkere, negativ gjør det lysere. Enheten er blendertrinn: +1 dobler lyset. +* **Mykning**: hvor myk kanten er. +* **Gradering**: kontrast innenfor masken. Negativ gir mer kontrast. +* **Forbelysning**: henter tilbake detaljer i lyse områder innenfor masken, som Forbelysning på kortet Tone. +* **Tonegrense**: **Høylys** eller **Skygger** får masken til å virke bare på toner lysere eller mørkere enn **Tonesone** (0 er svart, V mellomgrå, X hvit). En etterbelysning av himmelen stopper da ved horisonten. **Tonemykhet** angir hvor gradvis den starter. + +**Kopieringsnotater** (`Shift+N`) markerer maskene på bildet med numre og verdier, slik notatene til en kopist gjør. + + +### 6.1 Lab: farge og detaljer + +Justeringene en laboratorieskanner gjør etter kopien. + +* **Kroma**: fargestyrke. 1 er uendret, 0 er svart-hvitt. +* **Hudbeskyttelse**: hindrer at hudtoner får for sterk farge. +* **Kromastøyreduksjon**: fjerner fargede prikker. Filmkornet blir stående. Den er på 2,5 for fargefilm. +* **Skarphet**: gjør detaljer skarpere uten fargede kanter. Folden inneholder **Metode** (Uskarp maske gjør kantene skarpere; Dekonvolusjon opphever skannerens uskarphet), **Radius** (størrelsen på detaljene, i eksporterte piksler; vurder den i **Eksportstørrelse** eller med **Kornfokuserer**) og **Maskering** (gjør bare kanter skarpere, så himmel og hud holder seg jevne). +* **Automaskering** (tryllestaven ved siden av Metode, også i fanehodet): angir Maskering ut fra kornet i hvert bilde. Maskering viser verdien som brukes, og en dragning flytter den opp eller ned derfra. En eksport måler kornet på nytt i sin egen størrelse. +* **CLAHE**: lokal kontrast, for å få frem detaljer. Sterke verdier kan se unaturlige ut. +* **Glød** (folden Effekter): en myk glød rundt lyse områder, som den myke fokusen i en minilab-skanner. +* **Halasjon** (folden Effekter): en rød glød rundt lyse områder, slik noen filmer gir. + + +### 6.2 Alternative prosesser + +Historiske kopieringsprosesser for S/H-negativer. Velg **Ingen**, **Lith**, **Cyanotypi** eller **Sabattier**, så vises bare kontrollene for den prosessen. + +#### Lith + +Kremaktige, varme høylys og harde, dype svarttoner. Kopipapiret under Papirrespons angir fargen. Bare selen- og gulltonerne virker på en lith-kopi. + +* **Eksponering**: mer gir varmere, mer fargerike høylys og mykere toner. +* **Snappunkt**: når kopien tas ut av fremkalleren. Høyere gir dypere, kaldere svarttoner. +* **Brådhet**: hvor brått skyggene blir svarte. + +#### Cyanotypi + +En blå kopi med grønne høylys. Tonere virker ikke på den; bruk Bleking og Tannin. + +* **Sensibilisator**: **Klassisk** gir en lys blåfarge med grønne høylys. **Ny** gir dypere, renere blåtoner. +* **Eksponering**: mer gjør kopien blåere. +* **Eksponeringsskala**: kontrast. En kortere skala gir mer kontrast. +* **Bleking**: fjerner blått, høylysene først. +* **Tannin**: gjør blekede områder brune. Bruk Bleking først for en helt brun tone. + +#### Sabattier + +Et lysglimt under fremkallingen. De lyse tonene snus mot grått, og tynne lyse linjer (Mackie-linjer) dukker opp langs kantene mellom mørkt og lyst. + +* **Styrke**: lengden på lysglimtet. Over 1 snus de lyse tonene. +* **Ny eksponering**: hvor reverseringen starter. Mørkere toner blir som de er. +* **Agitasjon**: mindre agitasjon gir bredere Mackie-linjer. + + +### 6.3 Toning + +Tonere er kjemiske bad som endrer fargen på en S/H-kopi. Delt toning legger farge til enhver kopi. + +**Kjemisk toning** (S/H-negativer) virker i rekkefølgen som vises: + +* **Selen**: dypere, kaldere svarttoner. +* **Sepia**: varm brun, fra høylysene og nedover. +* **Gull**: blåsvart. Over sepia gir den oransjerøde høylys. +* **Jernblå**: blå skygger og marineblå svarttoner. +* **Kobber**: rosa til teglrødt. Svarttonene blir svakere. +* **Vanadium**: grønne mellomtoner og høylys. Svarttonene forblir svarte. + +På en lith-kopi virker bare Selen og Gull. På en cyanotypi virker ingen toner. + +**Delt toning** (alle kopier): **Skyggestyrke** og **Høylysstyrke** legger farge til skyggene og høylysene. **Skyggetone** og **Høylystone** velger fargen, og vises når styrken er over 0. + + +### 7.1 Retusj: støv, hår, riper + +Fjerner støv, hår og riper. Hver reparasjon bygges opp fra den rene filmen rundt den. + +* **Overlegg**: **Merket** viser hva støvfjerningen fant, **IR** skanningens infrarøde kanal. +* **Optisk fjerning** (også på linjen i fanen Etterarbeid): finner støv og hår selv. **Flekkterskel** og **Hårterskel**: lavere finner flere, men kan fjerne ekte detaljer; 1 er av. **Størrelse**: det største støvet som fjernes. For å beskytte et område høyredrar du over det eller høyreklikker et merke; pekerknappen hopper over høyreklikkmenyen. +* **IR-fjerning** (skanninger med en infrarød kanal, også på linjen i fanen Etterarbeid): finner støv i infrarødt, der bildet ikke vises. **IR-terskel**: lavere finner flere. S/H og Kodachrome hoppes over: bildet vises også i infrarødt. Kanalen kommer fra TIFF- og DNG-skanninger med fire kanaler eller en `_IR.tif`-fil ved siden av skanningen. +* **Lege**: klikk eller mal over støv. Bare merker som skiller seg ut, repareres, så mal romslig. +* **Ripe**: klikk punkter langs en ripe eller et hår, og trykk så Enter. +* **Linje**: klikk én gang på en lang, rett ripe, så finner og reparerer NegPy hele linjen. **Linjefølsomhet**: lavere finner svakere linjer. +* **Penselstørrelse**: også `Alt`+rulling på bildet. +* **Klon**: kopierer film fra et annet område over en feil som Lege ikke kan rette. Det første klikket (eller `Alt`-klikk) velger kilden; mal så. **Angi kilde** velger en ny. **Tilpass tone** henter lysheten og fargen rundt feilen. **Styrke** angir hvor mye den dekker, **Mykning** hvor myk kanten er. +* **Angre siste** og **Fjern alle** fjerner manuelle reparasjoner; høyreklikk en på bildet for å slette den. + + +### 7.2 Etterbehandling: vignett, negativholder, kant + +Hvordan kopien presenteres. De andre glidebryterne for hver effekt vises når den første glidebryteren ikke er 0. + +* **Vignett**: **Etterbelysning** gjør kantene mørkere (positiv) eller lysere (negativ). **Størrelse** angir hvor langt den når. **Rundhet** går fra rund (0) til formen på kopien (1). +* **Filet negativholder**: **Bredde** tegner en grov svart ramme rundt bildet, slik en negativholder i mørkerommet som er filt bredere, gjør. **Ruhet**, **Lysspredning** (en grå glød utenfor rammen) og **Hjørner** former den. +* **Kant**: **Bredde** legger et passepartout rundt kopien. **Vekt nederst** gjør den nedre kanten bredere, som på et passepartout til innramming. **Farge**: **Papirhvitt** tilpasser seg papiret, **Egendefinert** bruker en farge du velger. + + +### Forhåndsinnstillinger + +Lagrede innstillinger du kan bruke på andre bilder. + +* **Bruk** (eller dobbeltklikk en forhåndsinnstilling): bruker den valgte forhåndsinnstillingen på dette bildet, de valgte bildene eller hele rullen. +* **Lagre…**: velg hvilke av de gjeldende innstillingene som skal beholdes som en ny forhåndsinnstilling. En forhåndsinnstilling kan inneholde innstillingene for Beskjæring og Optikk med flatfeltprofilen, men aldri beskjæringen, rotasjonen eller speilvendingen av ett bilde. +* **Penn** og **Papirkurv**: rediger eller slett den valgte forhåndsinnstillingen. + + +### Arbeidskopier + +En **arbeidskopi** er en versjon av dette bildet som du beholder under et navn, som prøvekopiene på veien mot den ferdige. + +* **Lagre arbeidskopi…** (`Ctrl+Shift+S`) beholder den gjeldende redigeringen. +* **Klikk** en arbeidskopi for å bruke den. `Ctrl+Z` går tilbake. +* **⋮** eller høyreklikk: **Sammenlign med denne versjonen**, **Eksporter denne versjonen…**, **Gi nytt navn…** eller **Slett…**. **Sammenlign med denne versjonen** åpner **Før / etter** med arbeidskopien til venstre. +* **Last inn metadata** (av i starten): gjenoppretter også metadataene som er lagret med arbeidskopien. + + +### Redigeringshistorikk + +Hver endring du har gjort, den nyeste øverst (de siste 100). **Nå** markerer den gjeldende tilstanden; etter en angring vises senere trinn som **Angret** til neste redigering. Klikk et trinn for å gå tilbake til det. **⋮** eller høyreklikk → **Eksporter denne versjonen…** eksporterer en tidligere tilstand. **Sammenlign med dette trinnet** åpner **Før / etter** med det trinnet til venstre, på den gjeldende beskjæringen din. Trykk `\` for å lukke den. + + +### 10.1 Oppsett + +Hva filmen er, og hvordan du skannet den. Disse valgene bestemmer hvilke kort og kontroller som vises. + +* **Farge / S/H / Dias**: fargenegativ (C-41), en svart-hvitt-kopi, eller dias (E-6 og lignende). S/H kopierer et S/H-negativ eller et fargenegativ i svart-hvitt. For et fargenegativ gir kortet Filtrering da et kontrastfilter. +* **Positiv** (bare Dias): for en fil som allerede er et positiv, for eksempel en skannet kopi eller en eksport fra en annen app. NegPy hopper da over invertering og lysmåling. Et dias som ikke er en kamera-RAW, for eksempel en TIFF, JPEG, JXL eller en skanning fra Pakon, Noritsu, Coolscan eller Flextight, starter som Positiv. Slå det av for å gi en falmet skanning diasets tonekurve. En JXL lastes inn som lineær. +* **Koding** (positiv TIFF): hvordan verdiene koder lys. **Auto** leser filens ICC-profil, og ellers lineær over 8 bit og sRGB ved 8 bit. **Lineær** er lineære utdata fra en skanner, for eksempel Nikon Scan med fargestyring av. **2.2** er gamma 2,2 i Adobe RGB-primærfarger. Auto hopper over skannerens egen kalibreringsprofil og leser filen som umerket. Velg Lineær eller 2.2 for å passe skannerprogramvaren. +* **Kamera / Filmskanner**: hva du skannet med. NegPy leser det fra hver fil (en kamera-RAW er et kamera) til du velger noe. Valget ditt gjelder for hvert bilde og angir skanneren i fanen Skann. +* **Hvitt lys / Smalbåndslys** (ikke for et S/H-negativ): lyset bak negativet. Hvitt lys er en lysboks eller LED-plate. Smalbånd er et lys med rent rødt, grønt og blått, for eksempel en Scanlight. + +Skannevalgene er én innstilling for hele appen. Listen under dem viser hvordan en RAW-fil dekodes og hvilket kopipapir som brukes. + +Et kamera viser kortene RAW-dekoding og Optikk. Et kamera med smalbåndslys viser også Trichrome og Kalibrering. En endring oppdaterer hvert åpent bilde (Ctrl+Z angrer den per bilde), men et kopipapir du har valgt selv, blir stående. En kameraskanning angir dekodingen og kopipapiret for hvert bilde ut fra sitt eget lys. + + +### 10.2 RAW-dekoding + +Hvordan en kamera-RAW-fil gjøres om til piksler. Vises for et kamera. + +* **Kamera-WB / Lineær RAW**: Kamera-WB bruker kameraets hvitbalanse. Lineær RAW dekoder nøytralt, for smalbåndslys. Oppsett angir det for deg. +* **Forhåndsvisning** / **Eksport**: demosaikkmetoden, som fyller inn fargene hver sensorpiksel ikke kan se. **Auto** er raskt på skjermen og bruker **AHD** ved eksport. **VNG** er jevn og **PPG** rask. **DCB** og **DHT** beholder flere fine detaljer, men kan gjøre korn om til mønstre. **AAHD** mykner kanter. Bare metodene i din LibRaw-versjon vises. +* **Gjenoppretting** (dias): gjenoppbygger utbrente høylys i en kamera-RAW. **Blanding** passer sol og himmel. **Rekonstruer** er sterkere, men kan gi feil farge. + + +### 10.3 Optikk + +Korreksjoner for objektivet og lyset, de samme for hvert bilde du skanner med dette oppsettet. Vises for et kamera. + +#### Objektivkorreksjon + +* **Innebygd**: bruker filens egen objektivkorreksjon (DNG, Sony ARW, Panasonic RW2). **Forvrengning** retter opp buede linjer og **CA** fjerner fargekanter. Angi det før du beskjærer eller retusjerer. +* **Forvrengningskorreksjon**: retter opp linjer som buer utover (positiv) eller innover (negativ). Bruk filmkanten som en rett linje å gå etter. + +#### Flatfeltkorreksjon: jevn ut lyset + +Fjerner ujevnt lys, for eksempel mørkere hjørner, med et bilde av den bare lyskilden. + +* **Profil**: **+** lagrer et bilde av det bare lyset som en navngitt profil. Papirkurvknappen sletter den, og hvert bilde som bruker den, mister korreksjonen sin. +* **Bruk flatfelt**: bruker den valgte profilen på denne rullen. +* **Kontroller flatfelt**: viser hvor godt profilen jevner ut sitt eget bilde. Jevnt grått er bra; et bånd eller en flekk viser hvor den avviker. + +Et bilde beholder sin egen profil med mindre det følger rullen, så hvert lys og filmformat kan ha én. Et nytt bilde, eller et bilde der profilen mangler, får den siste profilen du valgte. + + +### 10.4 Kalibrering: farger fra skanneoppsettet ditt + +Korrigerer fargefeil som kommer fra lyset, kameraet og filmen din. + +**Sensorkrysstaleprofil** (én eksponering under smalbåndslys, lineær RAW): fjerner fargen som lekker mellom sensorens kanaler. Kjør **Rullanalyse** på nytt etter en endring. + +* **Profil**: sensorkrysstaleprofilen. Dine egne `.toml`-profiler legges i `/NegPy/sensor/`. +* **Metode**: **To skalaer** (standard) fjerner lekkasjen og hindrer at svært sterke farger blir prikkete. **Lineær** er nøyaktig, men sterke farger kan bli prikkete. **Tetthet** gir ingen prikker, men sterke farger blir litt mattere. +* **Kalibrer**: lager en profil fra tre bilder av det bare lyset (rødt, grønt og blått), fra filer eller et tilkoblet kamera (se CAMERA_SCANNING.md). + +**Krysstale** (fargefilm): fargestoffene i filmen, lyset ditt og sensoren din blander fargene litt, og en matrise skiller dem igjen. Kjør **Rullanalyse** på nytt etter en endring. + +* **Matrise**: matrisene for denne filmtypen. De som er merket *(omtrentlig)*, kommer fra datablader for film og er et utgangspunkt. Dine egne `.toml`-filer legges i `/NegPy/crosstalk/` (se CROSSTALK.md). Glidebryterknappen åpner redigeringsverktøyet; **+** lager en ny matrise. NegPy har ingen medfølgende matrise for dias. +* **Styrke**: hvor mye av matrisen som brukes. +* Fargeprøveknappen åpner **Mål fra kart** (fargenegativer): den måler en matrise fra et ColorChecker 24 eller SpyderCheckr 24 fotografert på filmen. Dra de fire prikkene til hjørnefeltene. + +**Justering av fargetone** (kamera med smalbåndslys): dreier alle farger rundt fargehjulet, for et lys som gjør gult oransje eller grønt olivenfarget. Behold 0 for hvitt lys. Den virker også på dias, der sensorkrysstaleprofilen ikke gjør det. + +#### 10.4.1 Fargegjenoppretting: falmede dias + +Gir et falmet dias fargelagene tilbake. + +* **Rød overlevelse**: hvor mye rødt fargestoff som er igjen. Senk den til kontrasten kommer tilbake. +* **Grønn overlevelse** / **Blå overlevelse**: det samme for grønt og blått, mot rødt. +* **Fjern flekk**: gjør gulnede klare områder hvite, og kjører Beregn. Prøv det når Beregn gjør ikonet sitt gult. +* **Beregn**: angir glidebryterne ut fra de grå tonene i dette bildet. **Mellomtone** passer et sterkt falmet dias. **Gråområde** passer en mild falming med dype skygger. + + +### 10.5 Lysmåling: negativ → positiv + +For å gjøre et negativ om til et positiv måler NegPy de mørkeste og lyseste delene (svart- og hvitpunktet) og fargen på filmen. Disse kontrollene angir hva den måler. + +* **Mål alene**: tar dette bildet, eller hvert valgte bilde, ut av gjennomsnittet for rullen eller scenen og måler det for seg, fra den gjeldende beskjæringen. +* **Tegn område**: tegn et område, så måler NegPy bare det området. Dobbeltklikk innenfor eller trykk `Enter` for å bekrefte, og `Esc` setter tilbake området du hadde før. **×** fjerner det. +* **Analysebuffer**: ser bort fra en kant rundt bildet ved måling, så filmkanter og holdere ikke teller med. +* **Klipping**: hvor mye av kopien som er helt svart eller helt hvit. +* **Lås grenser**: beholder svart- og hvitpunktet for dette bildet, selv når beskjæringen eller rullanalysen endres. +* **Luma-områdeklipping** (fold): hvor stramt svart og hvitt settes. Positiv gir mer kontrast, for tette eller tåkete negativer. Negativ beholder flere detaljer i skygger og høylys. +* **Fargeklipping** (fold): hvor stramt fargebalansen settes. +* **Dmin** / **Dmax** (folden Velg selv): for et bilde som NegPy måler dårlig. Klikk **Dmin**, og så filmbasen eller den dypeste skyggen, for å angi svartpunktet. Klikk **Dmax**, og så det lyseste høylyset, for å angi hvitpunktet. Hver **×** fjerner sitt eget punkt. Begge er av mens bildet kopieres etter rullens gjennomsnitt. +* **Hvitpunkt** / **Svartpunkt** (fold): flytter det målte hvit- og svartpunktet. Et positivt hvitpunkt gjør kopien lysere; et positivt svartpunkt løfter svarttonene. **R / G / B** flytter bare én farge. + +Et **dias** trenger ingen invertering: tonekontrollene former bildet direkte. Autotetthet og autogradering starter av; slå dem på for et falmet dias. Med Lightroom-begreper er Eksponering lik Kopitetthet (lavere er lysere), Kontrast lik ISO-R-gradering, og Skygger og Høylys lik Skyggetetthet og Høylystetthet. + + +### 10.6 Rullanalyse + +Måler hele rullen én gang og deler resultatet, så bildene fra én film kopieres likt. NegPy måler rullen og hver scene etter hvert som bildene kommer inn, og på nytt etter en endring av beskjæring eller lysmåling. Ingen bilder bruker et grunnlag før du slår på et gjennomsnitt. Når ingen rull er åpen, er kortet nedtonet: åpne eller lagre en rull først. + +* **Analyser på nytt**: måler hvert synlig bilde utenfor en scene, lagrer gjennomsnittet som rullens grunnlag og slår på gjennomsnittene for rullen og de nye bildene. Bilder som er langt fra resten, beholder sine egne. +* **Bruk dette bildet**: gjør dette bildets egen måling til rullens grunnlag; Fargestikk slås av. +* **Bruk gjennomsnitt**: høyreklikk en av de tre for å slå hvert gjennomsnitt på eller av. **Luma** bruker rullens svart- og hvitpunkt, **Farge** rullens fargebalanse, og **Fargestikk** (fargenegativer) rullens gråbalanse for fjerning av fargestikk. Med Fargestikk på beholder et bilde tatt under et annet lys noe av det lyset, som på en Frontier-kopi. Et magentastikk blir værende, et varmt eller grønt stikk blir delvis værende, og et blått stikk forsvinner. Med Luma og Farge på kopieres rullen jevnt; med begge av måles hvert bilde for seg. +* **Grunnlag**: viser hvor grunnlaget kom fra (rull, scene eller bilde). Den advarer når det ikke finnes noe, når det er utdatert, eller når fargen i dette bildet er langt fra gjennomsnittet. +* **Ruller**: velg en annen rulls lagrede grunnlag. En rull med hake har et. +* **Scener**: grupper av bilder med sitt eget grunnlag, for eksempel ett motiv under ett lys ([Scener](#scenes)). **Analyser** måler scenen, **Velg** velger bildene i den og **Slett** fjerner gruppen. +* **Oppdag scener**: finner gruppene av bilder under et annet lys enn rullen, i hvilken som helst bilderekkefølge, og viser dem på lysbordet. Hver gruppe venter under **Funnet**. For å endre en gruppe drar du bilder til en annen gruppe, eller til et bilde uten scene for å utelate dem; høyreklikk › **Scene** gjør det samme. Gi gruppen et navn, og **Godta** den som en scene med eget grunnlag, eller **Forkast** den. + + +### 10.7 Bildesammensetning + +Hvordan filer blir til bilder. Hver rull beholder sine egne innstillinger her. + +#### Trichrome + +For et kamera med smalbåndslys som tar tre bilder av hvert bilde: rødt, grønt og blått. + +* **Trichrome-modus**: slår de tre opptakene sammen til ett fargebilde. Opptakene grupperes etter opptakstid (eller filnavn), så ta de tre opptakene av hvert bilde rett etter hverandre. Opptak som ikke grupperes, blir værende hver for seg. +* **Slå sammen eksponeringer (Trichrome)** (gruppeikonet, eller høyreklikk): velg de tre opptakene av et bilde for å slå dem sammen. NegPy finner hvilket opptak som er rødt, grønt og blått. +* **Rediger Trichrome…**: velg de tre opptakene av det gjeldende bildet selv. **Juster kanaler** retter dem inn mot hverandre for å fjerne fargekanter. **Del opp Trichrome** (ikonet for å dele opp gruppen, eller høyreklikk) skiller dem igjen. +* **Slå sammen bilde til TIFF-negativ…** (høyreklikk): lagrer en triplett eller en sammensying som én TIFF ved siden av den første kildefilen, med redigeringen. Et alternativ flytter kildefilene til papirkurven. + +#### Halvformat + +**Halvformatmodus** deler hver skanning i to bilder, for halvformatkameraer. Hver halvdel har sin egen redigering. Hver rull beholder sin egen innstilling. Når ingen rull er åpen, gjelder den for bilder som ikke er i noen rull, til **Last ut alle**, og **Åpne forrige økt på nytt** beholder den. Den er nedtonet mens et bilde fra en rull er lastet inn, fordi det bildet følger sin egen rull. + +* **Juster…**: dra den hvite boksen for å beskjære og den oransje linjen for å angi delingen. **Delingsretning** og **Snittykkelse** angir mellomrommet, **Oppdag automatisk** finner dem igjen, og **Bruk** lagrer til dette bildet, utvalget eller alle bilder. +* **Oppdag alle**: finner delingene i hver skanning på nytt. +* **Del opp** (på et delt bilde): gjør det til ett bilde igjen og sletter redigeringene av begge halvdelene. + +Høyreklikk en halvdel for **Juster delingen for dette bildet…** og **Tilbakestill delingen til rullens standard**. Når du slår av halvformat, blir redigerte halvdeler stående side om side som et **diptyk**; slå det på igjen for å redigere dem. + + +### Analogt utstyr + +* **Kamera / Objektiv / Filmtype**: velg fra ditt eget utstyr (fanen **Mitt utstyr**). **Annet…** åpner hele den innebygde katalogen. +* **Tøm**: tømmer alle tre. +* **Utled fra mappenavnet**: fyller ut tomme felt (kamera, film, ISO, dato) ut fra mappenavnet. + + +### Opptak + +* **Dato**: når du tok bildet, som `1998`, `1998-07`, `1998-07-14` eller `1998-07-14 16:30`. +* **Sted**: nålknappen åpner et kart. Du kan også lime inn koordinater eller en kartlenke. Kartet bruker OpenStreetMap. + + +### Prosess + +Hvordan filmen ble fremkalt. + +* **Lagret prosess**: en oppskrift fra Mitt utstyr som fyller ut feltene nedenfor. Når du skriver over et felt, kobles det fra. +* **Filmformat**: filmstørrelsen, for eksempel `35mm`, `120` eller `4×5`. +* **Fremkaller** og **Fortynning**: for eksempel `D-76` og `1+1`. +* **Push / Pull**: fra `Push +3` til `Pull -3`. +* **Tid** og **Temperatur (°C)**: tid som `9:30` eller i minutter. +* **Tøm**: tømmer prosessfeltene. + + +### Skanning + +* **Lagret oppsett**: et skanneoppsett fra Mitt utstyr. +* **Skanning**: hvordan du skannet, eller notater. +* **Rull / Bilde**: rullnavnet og bildenummeret. Filnavn kan bruke dem (`{{ roll }}`, `{{ frame }}`). +* **Sekvens…**: nummererer de valgte bildene, eller hele rullen, i filmrekkefølge. Angi start, trinn og retning; **Hopp over forkastede bilder** utelater forkastede. +* **Tøm**: tømmer oppsettet og notatet. + + +### Eksponering + +Lukkertid, blender, ISO og kameraets eksponeringskompensasjon (EV) for det opprinnelige bildet, hvis du vet dem. Klikk låsen for å skrive fritekst som `1/125s f/2.8 ISO 400 -1/3ev`. En kompensasjon er blendertrinn foran `ev`, og summer som `1+2/3ev` legges sammen. + + +### Forhåndsvisning av metadata + +Viser hva som legges i den eksporterte filen. Skjoldknappen, **Beskytt opprinnelige metadata**, eksporterer i stedet kildefilens egne metadata uendret. **Beskrivelse…** velger feltene som utgjør bildebeskrivelsen (kamera, objektiv, film og ISO i starten). + +Kamera, objektiv og film legges i de vanlige EXIF-feltene, og skanneren din i NegPys egne XMP-felt. En stjernevurdering angitt i kameraet blir med. + + +### Mitt utstyr + +Utstyrslisten din, som brukes av hver utstyrsvelger. **Kategori** velger **Kameraer**, **Objektiver**, **Filmtyper**, **Prosesser** (fremkallingsoppskrifter) eller **Skanneoppsett**. Den lagres i `~/NegPy/gear/`. + +* **+**: kopier et element fra den innebygde katalogen, eller **Legg til egendefinert** for å skrive inn et selv. +* **Katalog**: vis også de innebygde modellene. +* **Kopier / Papirkurv**: dupliser eller slett det valgte elementet. Innebygde elementer kan ikke slettes. + + +### Forhåndsinnstillinger + +Lagrede sett med metadata, for eksempel ett kamera, ett objektiv og én film. De lagres i `~/NegPy/presets/metadata/`. + +* **Bruk på dette bildet**: skriver feltene fra forhåndsinnstillingen til dette bildet; andre felt blir stående. +* **+**: lagrer metadataene for det gjeldende bildet under et navn. +* **⋮** eller høyreklikk en forhåndsinnstilling: bruk, dupliser, rediger eller slett den. +* **Detaljer**: rediger feltene i forhåndsinnstillingen. **Notater** er fritekst. + +En filmtype angir formatet, så angi et format som `6×7` etter filmtypen. + + +### Forhåndsinnstillinger + +Lagrede eksportoppskrifter: format, størrelse, farge, mappe og filnavn. Slå på dem du vil ha, så skriver **Eksportforhåndsinnstillinger** hvert bilde én gang per forhåndsinnstilling. **Administrer** redigerer dem. Forhåndsinnstillinger virker ikke med lineært resultat. + + +### Ved siden av eksportene + +Ekstra filer ved siden av kopien. Hver har en bryter til venstre og en engangshandling til høyre. + +* **Kopieringsnotater**: en merket kopi av bildet med skygging og etterbelysning og kopiinnstillinger, slik en mørkeromskopist tar vare på. **Forhåndsvisning** viser den (`Shift+N`); **Eksporter notater** lagrer den. +* **Autotetthet / -gradering**: **Bak inn ved eksport** fastsetter den automatiske lysheten og kontrasten for hvert eksporterte bilde, så de ikke endres senere. **Bak inn i rullen** gjør det for hvert synlig bilde nå. +* **Redigeringssidefiler**: **Lagre ved eksport** skriver en `.negpy`-fil med redigeringen ved siden av hver kilde. **Eksporter sidefiler** skriver dem for alle synlige bilder. Høyreklikk et bilde → **Last inn redigering fra sidefil…** leser en tilbake. + + +### Kontaktark + +På fanen **Ved siden av eksportene**. Et kontaktark viser hele rullen som filmstriper på ett papirark, slik en kontaktkopi i mørkerommet gjør. + +* **Bane**: mappen for arkene. Tom bruker eksportmappen. +* **Kontaktark…**: åpner arket. Dra en kant av papiret for å endre størrelsen. +* **Filmformat**: 35 mm, 35 mm halvformat eller 120 (med **Bildestørrelse** for 120). +* **Papir**, **Bredde**, **Høyde** og **DPI**: størrelsen og oppløsningen til arket. Standardpapiret har plass til en rull med 36 bilder. +* **Rekkefølge**: etter filnavn, etter dato, eller etter scene med en ny stripe eller rad for hver scene. +* **Kopi**: **Som redigert**, eller **Rett prøve**: hele rullen med én eksponering og normal kontrast, så du ser hvilke negativer som er tynne eller tette. +* **Velg bilder**: klikk et bilde for å utelate det eller sette det tilbake. Forkastede er utelatt i starten. +* **Kantpåskrift**: produsentens merking langs kantene, for eksempel filmnavnet, bildenumrene og de fargede linjene en filmtype trykker ved perforeringen. +* **Rulletikett**: rullnavnet, filmen, kameraet og datoen over stripene. Skriv din egen tekst i **Etikettekst** for å skrive den i stedet. +* **Hvitt papir**: kopierer på hvitt, noe som sparer blekk på en hjemmeskriver. +* **Filmkanter**: av kopierer bildene stående, hvert i sin egen form, i rader som på lysbordet. Arket har da ingen perforering og ingen **Kantpåskrift**. + +Arkene lagres som `contact_sheet.jpg` (`contact_sheet_1of2.jpg` og videre for en lang rull). + + +### Prøvetrykk på skjerm + +Fanen **Prøve** viser på skjermen hvordan bildet vil se ut når det skrives ut på en gitt skriver og et gitt papir. Den endrer bare forhåndsvisningen, aldri eksporten. Papir kan ikke vise farger like klare som en skjerm, så et riktig prøvetrykk ser mattere ut. + +* **Prøvetrykk på skjerm** (`Shift+P`): slår prøvetrykket på eller av. Mens det er på, viser bildeinfoen over bildet **Prøve** og profilen. +* **Forhåndsinnstilling**: et lagret oppsett for skriver og papir. +* **Profil**: skriver- eller papirprofilen. Den følger eksportprofilen til du velger en. +* **Hensikt**: **Relativ kolorimetrisk** holder farger som kan gjengis, nøyaktige og klipper resten. **Perseptuell** komprimerer alle farger, noe som passer svært fargerike bilder. **Metning** er for diagrammer og grafikk. +* **Svartpunktkompensasjon**: tilordner den mørkeste tonen til papirets svart. +* **Simuler papirhvitt** og **Simuler blekksvart**: viser papirets virkelige hvitt og svart. +* **Advarsel om fargeomfang**: viser farger skriveren ikke kan gjengi, som grått. +* **Skjerm**: profilen til skjermen din, funnet automatisk. + + +### Skanner + +* **Skanner**: **Filmskanner** for en egen filmskanner (kortene Enhet, Film og kvalitet og Innramming), eller **Kamera** for et kamera på et reprostativ (kortene Kamera og Forhåndsinnstilling). + + +### Enhet + +* **Motor**: **SANE** (Linux og macOS), **Nikon Coolscan (nkscan)** eller **pyOpticfilm (Plustek)**. Kontrollene følger det skanneren rapporterer. +* **Enhet**: skanneren; pilene oppdaterer listen. Utmatingsmenyen har **Mat ut nå** og, på en stripemater, **Mat ut når ferdig**, som returnerer stripen etter en satsvis kjøring. +* **Feilsøkingslogg** (Coolscan): skriver `nkscan.log` i NegPy-mappen. For en feilrapport angir du **Trace**, gjentar problemet og legger ved filen. + +NegPy holder macOS og Windows våkne mens den skanner. På Linux slår du av automatisk hvilemodus før en lang rull. + +**Plustek**: OpticFilm 8200i SE, 8100 V2, 8300i SE, 7600i v1 og 7600i v2 virker; for andre modeller kan du prøve SANE. På Windows setter du først opp USB-driveren ([PLUSTEK_WINDOWS.md](PLUSTEK_WINDOWS.md)). **Coolscan**: trenger ikke SANE. På Linux krever USB en udev-regel for leverandøren `04b0`. + + +### Film og kvalitet + +* **Film**: fargenegativ, S/H-negativ, dias eller Kodachrome. Den styrer bildeoppdaging, lysmåling, og om infrarødt tilbys. +* **Filmformat**: bildelengden (135, 66, 645 og andre). **Auto** virker der holderen bestemmer den. +* **Oppløsning** og **Bitdybde**: skanningens dpi og bit per kanal. +* **Skannemodus** (Plustek): **Flerpassering** tar gjennomsnittet av flere skanninger for mindre støy; **Adaptiv multieksponering** kombinerer en kort og en lang eksponering for større omfang, med den lange eksponeringen angitt ut fra hvert bilde; **Fast multieksponering** bruker skannerens ene lange eksponering på hvert bilde, slik SilverFast gjør (8200i SE, 8100 V2 og 8300i SE). 7600i v2 tilbyr adaptiv multieksponering, men ikke flerpassering. +* **Prøver** (Coolscan): leser hver linje flere ganger, for mindre støy i skyggene. Tregere. +* **Infrarød**: **IR** skanner en infrarød kanal, for støvfjerning på kortet Retusj. **ICE** (Coolscan) fjerner i stedet støvet i skanneren. En skanner uten ICE viser én **IR**-bryter. Ikke for S/H eller Kodachrome. +* **Superfin** (Coolscan): skanner én linje per passering. Tregere. +* **Autofokus**, **Autoeksponering** og **Eksponering**: fokus og eksponering på skanneren, før skanningen. + + +### Innramming + +* **Bilder**: `1-6`, `1,2,5`, eller tomt for alle. Stripeforhåndsvisningen fyller det ut. +* **Forhåndsvis stripe…** (stripemater): forhåndsviser hvert bilde, for å plassere og velge bildene. **Forhåndsvis…** (manuell holder): angir ett beskjæringsvindu. **Tøm** tilbakestiller det. **Oppdag bilder** finner bildene på nytt etter at filmen har flyttet seg. +* **Mål bilde…** (Coolscan): måler eksponeringen på ett bilde og bruker den for hver senere skanning, så alle bildene i en rull samsvarer. **Lås opp** avslutter det. Mål på nytt for hver rull. +* **Forhåndsskann…** (Plustek): en rask forhåndsvisning av hele området. Dra en beskjæring, så leser neste skanning bare det området. + + +### Kamera + +Skanner med et kamera på et reprostativ, koblet til med USB i **PC Remote**-modus (macOS og Linux; det krever `python-gphoto2`). Med en NegPy **Scanlight** styrer NegPy også lyset. CAMERA_SCANNING.md forklarer oppsettet. + +* **Kamera** / **Lys**: tilkoblingsstatusen. Lyset blir gult når det går varmt. På Linux krever Scanlight at brukeren din er i gruppen `dialout`. +* **Direktevisning**: et levende bilde fra kameraet. Klikk for å plassere fokusluppen, og fokuser til **Fokusmåler** viser **på topp**. Angi ISO, lukkertid og blender her eller på kameraet. Når den lukkes, går Scanlight tilbake til slik den var før. +* **Histogram** og **Sebraer**: histogrammet for bildet, og striper på deler som er i ferd med å klippes, i klippefargene som Fargesyn angir. +* **Måler**: angir lukkertiden slik at den lyseste delen som ikke må klippes (den klare filmen i et negativ, høylysene i et dias), havner like under klipping. Mål før første skanning. Uten en kalibrert forhåndsinnstilling bruker en skanning eksponeringen som er angitt på kameraet, og NegPy spør før den skanner uten lysmåling. +* **Skann** tar neste bilde og importerer det; **Ta på nytt** (gjør om-pilen) tar det siste bildet igjen. Når en skanning klipper, forteller statuslinjen hvor mye av bildet som ble klippet. I vinduet Direktevisning skanner `Space` eller `S`, og `R` tar på nytt. Med en Scanlight velger **R**, **G**, **B** og **IR** hvilke eksponeringer Ta på nytt gjentar. +* **Opptaksforsinkelse**: venter før den første eksponeringen, så et ustøtt bord rekker å falle til ro. +* **La lyset være på** (pæren): lar Scanlight lyse etter hver skanning, så du kan ramme inn neste bilde. +* **Film** (med en Lento Dock): hva holderen gjør når du klikker Skann. **Neste bilde** flytter filmen ett bilde etter hver skanning. **Hele stripen** skanner hvert bilde og mater så ut stripen; Stopp avslutter det. Ta på nytt flytter aldri filmen. **Mat ut** flytter stripen ut. S-knappen på holderen virker som Skann. +* **Ta flatfelt**: når en hel stripe er matet ut, fotograferes den tomme holderen og lagres som rullens flatfeltprofil. + + +### Forhåndsinnstilling + +Vises mens en Scanlight er tilkoblet. En forhåndsinnstilling inneholder lysnivåene og kameraeksponeringen for én type film. + +* **Forhåndsinnstilling**: lyset og eksponeringen for hver skanning. **+** legger til en på tre måter: + * **Innebygde forhåndsinnstillinger**: klare til bruk, uten kalibrering: **Hvitt lys**, **Hvitt lys + IR**, **Trichrome** og **Trichrome + IR**. De bruker kameraets eksponering, som måleren angir. Hvitt lys krever en Scanlight med hvit LED (v4, Big Scanlight). + * **Kalibrer på filmbasen…**: plasser rektangelet på klar film og kjør den. NegPy finner lysnivåene og lukkertiden. + * **Angi selv**: angi glidebryterne selv, og lagre dem så som en forhåndsinnstilling. Uten tilkoblet kamera lagrer den ingen eksponering, og en skanning bruker kameraets egen. +* **Rød**, **Grønn**, **Blå**, **Hvit**: lysnivåene (0 til 255). **Lys på** slår på lyset for å ramme inn filmen, eller av. +* **Opptaksmodus**: **Trichrome** tar én eksponering per farge og slår dem sammen; **Én eksponering** tar én eksponering med alle tre fargene på. +* **Lag sensorkrysstaleprofil** (Én eksponering): lagrer også en sensorkrysstaleprofil for denne forhåndsinnstillingen (se Kalibrering, §10.4). +* **ISO**, **Lukker**, **Blender**: eksponeringen for forhåndsinnstillingen. En endring gjelder til du velger forhåndsinnstillingen igjen. Måleknappen måler. +* **IR-lukker** og **Infrarød** (lys med infrarøde LED-er): legger til en infrarød eksponering i hver skanning for støvfjerning, lagret som en `_IR`-fil. Det krever et kamera som ser infrarødt (fullspekter eller konvertert) og manuell fokus. Bare fargefilm. +* **Kanalforsinkelse**: en pause mellom den røde, grønne og blå eksponeringen, for kameraer som trenger det. + + +### Utdata + +Felles for begge skannerne. + +* **Mappe**: hvor skanninger lagres. +* **Skann som rull**: mappen åpnes som en rull, og bildene lastes inn etter hvert som de lagres. +* **Mappe som rull**: skanninger legges i selve mappen. Av skriver inn i en undermappe **Rull**, og et nytt rullnavn starter en ny rull. **+** ved siden av Rull går til neste navn (`Roll001` til `Roll002`). +* **Format** og **Filnavn** (filmskanner): TIFF, eller TIFF (mono) for S/H, og filnavnmønsteret (`{{ date }}`, `{{ seq }}`). + + +### Stripeforhåndsvisning + +Viser hele stripen, for å plassere og velge bildene før skanningen. **Bruk innramming** beholder innrammingen og **Skann** skanner. **Stopp forhåndsvisning** avslutter en forhåndsvisning tidlig. + +* **Beskjær**: dra på et bilde. **Fjern beskjæringer** fjerner alle beskjæringer. +* **Forskyvning**: flytter hvert bilde langs filmen for å rette det inn etter bildene. Den hvite boksen viser bildet NegPy fant. +* **Avdrift**: en forskyvning som vokser fra bilde til bilde. +* Glidebryteren under hver flis flytter bare det bildet. +* **Størrelse** angir flisstørrelsen. **Alle**, **Ingen** og bryteren for hvert bilde velger bilder. **Forhåndsvis bilde** skanner ett bilde på nytt. diff --git a/negpy/translations/nb.json b/negpy/translations/nb.json new file mode 100644 index 000000000..7f112d5a8 --- /dev/null +++ b/negpy/translations/nb.json @@ -0,0 +1,3329 @@ +{ + " (base unreadable)": " (grunnlaget kan ikke leses)", + " Ref": " Ref", + " Set in override.toml.": " Angitt i override.toml.", + " frames": " bilder", + " photos": " fotoer", + " · click to place a tone here": " · klikk for å plassere en tone her", + "\"{value}\" is assigned to both \"{description}\" and \"{value1}\".": "\"{value}\" er tilordnet både \"{description}\" og \"{value1}\".", + "+{value} more": "+{value} til", + "100%": "100%", + "16× (default)": "16× (standard)", + "2.2": "2.2", + "2.2: the samples carry a 2.2 gamma in Adobe RGB primaries.": "2.2: prøvene har gamma 2,2 i Adobe RGB-primærfarger.", + "4× (default)": "4× (standard)", + "; the preset scans at {label} until you pick it again": "; forhåndsinnstillingen skanner med {label} til du velger den igjen", + "Disabled for Slides/Reversal Film
{text}": "Deaktivert for dias/reversalfilm
{text}", + "Draw: drag a corner to resize the crop, the inside to move it, or outside the box to rotate. Auto Crop finds the edges by itself. {value} changes the composition guide.": "Tegn: dra et hjørne for å endre størrelsen på beskjæringen, innsiden for å flytte den, eller utenfor boksen for å rotere. Autobeskjær finner kantene selv. {value} endrer komposisjonshjelpen.", + "ISO-R Grade is contrast, like the grade of darkroom paper. A lower number gives more contrast, and about 110 is normal. Auto Grade sets it for each frame.": "ISO-R-gradering er kontrast, som graderingen på mørkeromspapir. Et lavere tall gir mer kontrast, og omtrent 110 er normalt. Autogradering angir den for hvert bilde.", + "Level: draw a line along the horizon, and the picture turns to level it. Tilt and Swing fix buildings that lean. Square to Film lines the frame up with the film edges.": "Vater: tegn en linje langs horisonten, så roteres bildet til det er i vater. Tilt og Sving retter opp bygninger som lener. Rett mot film retter bildet inn etter filmkantene.", + "NegPy {value}
Film negatives printed through a virtual darkroom.
github.com/{value1}": "NegPy {value}
Filmnegativer kopiert gjennom et virtuelt mørkerom.
github.com/{value1}", + "Optical Removal and IR Removal find dust by themselves. Heal paints dust away, Scratch and Line repair scratches, and Clone copies film over a defect.": "Optisk fjerning og IR-fjerning finner støv selv. Lege maler bort støv, Riper og Linje reparerer riper, og Klon kopierer film over en feil.", + "Print Density sets how light or dark the print is, like the exposure time under an enlarger. Lower is lighter. Auto Density sets it for each frame; your change adjusts from there.": "Kopitetthet angir hvor lys eller mørk kopien er, som eksponeringstiden under et forstørrelsesapparat. Lavere er lysere. Autotetthet angir den for hvert bilde; endringen din justerer derfra.", + "Saved “{name}”: measured green↔blue leakage {gb:.0f}% / {bg:.0f}%.": "Lagret «{name}»: målt grønn↔blå lekkasje {gb:.0f}% / {bg:.0f}%.", + "Saved “{value}”. Mean color ΔE00 (hue and chroma) over the 24 patches: {value1:.1f} without the matrix, {value2:.1f} with it; {value3:.1f} and {value4:.1f} with lightness. Per patch, on the frame: {value5} within {value6:g}, {value7} within {value8:g}, {value9} beyond. Farthest patch: {value10}, {value11:.1f}. Re-run Roll Analysis on the roll's other frames. The matrix holds over the frame's current sensor profile: measure again if you change it.": "Lagret «{value}». Gjennomsnittlig farge-ΔE00 (fargetone og kroma) over de 24 feltene: {value1:.1f} uten matrisen, {value2:.1f} med den; {value3:.1f} og {value4:.1f} med lyshet. Per felt, på bildet: {value5} innenfor {value6:g}, {value7} innenfor {value8:g}, {value9} utenfor. Fjerneste felt: {value10}, {value11:.1f}. Kjør rullanalyse på nytt for rullens andre bilder. Matrisen gjelder over bildets nåværende sensorprofil: mål på nytt hvis du endrer den.", + "Soft Proof ({value}) shows on screen how the picture will look on a given printer and paper.": "Prøvetrykk på skjerm ({value}) viser på skjermen hvordan bildet vil se ut på en gitt skriver og et gitt papir.", + "Toning changes the color of a B&W print, as darkroom toners do; Split Toning tints any print. Alternative Processes gives lith, cyanotype and Sabattier looks.": "Toning endrer fargen på en svart-hvitt-kopi, slik mørkeromstonere gjør; Delt toning farger enhver kopi. Alternative prosesser gir lith-, cyanotypi- og Sabattier-utseende.", + "Vignette darkens the edges, Filed Carrier adds a rough black frame, and Border puts a mat around the print.": "Vignett gjør kantene mørkere, Filet negativholder legger til en grov svart ramme, og Kant legger et passepartout rundt kopien.", + "{body}": "{body}", + "◌ {ordinal}": "◌ {ordinal}", + "● {ordinal} {value1} · {value2}{tick}": "● {ordinal} {value1} · {value2}{tick}", + "A": "A", + "A roll is a group of frames, usually one film. + imports a folder as a roll. NegPy never moves or changes your files.": "En rull er en gruppe bilder, vanligvis én film. + importerer en mappe som en rull. NegPy flytter eller endrer aldri filene dine.", + "A test strip prints the frame as a grid of brightness and contrast steps. Click the one you like.": "En prøvestripe kopierer bildet som et rutenett av trinn i lyshet og kontrast. Klikk på det du liker.", + "A Scanlight or RGB LED light with pure red, green and blue.": "Et Scanlight- eller RGB-LED-lys med rent rødt, grønt og blått.", + "A Trichrome triplet has no sensor leak to remove, so the profile is skipped and your choice is kept.": "En Trichrome-triplett har ingen sensorlekkasje å fjerne, så profilen hoppes over og valget ditt beholdes.", + "A calibration is running. Wait for it to finish.": "En kalibrering pågår. Vent til den er ferdig.", + "A camera RAW, so a camera.": "En kamera-RAW, altså et kamera.", + "A click selects a frame, and a double-click or Enter opens it": "Et klikk velger et bilde, og et dobbeltklikk eller Enter åpner det", + "A file for red, green or blue does not exist": "En fil for rød, grønn eller blå finnes ikke", + "A file is already part of a stitch, an HDR merge, a diptych or a half frame": "En fil er allerede del av en sammensying, en HDR-sammenslåing, et diptyk eller et halvformat", + "A file you have already edited keeps its own look; only export and metadata settings reach it.": "En fil du allerede har redigert, beholder sitt eget utseende; bare eksport- og metadatainnstillinger når den.", + "A folder roll holds a folder of scans, and a virtual roll holds frames you saved.": "En mapperull er en mappe med skanninger. En virtuell rull er lagrede bilder.", + "A format the list does not carry, written as you type it": "Et format listen ikke har, skrevet slik du taster det", + "A frame changed during the merge. Select the three frames again.": "Et bilde ble endret under sammenslåingen. Velg de tre bildene på nytt.", + "A higher level is slower and makes a smaller lossless file.": "Et høyere nivå er tregere og gir en mindre tapsfri fil.", + "A horizontal cut: the two halves sit stacked, as a rotated scan lays them.": "Et vannrett snitt: de to halvdelene ligger stablet, slik en rotert skanning legger dem.", + "A large library takes a while to regenerate.": "Et stort bibliotek tar en stund å generere på nytt.", + "A lightbox or LED panel with white light.": "En lysboks eller LED-plate med hvitt lys.", + "A matrix shows only for the film type that it was saved for, so open the editor to make one.": "En matrise vises bare for filmtypen den ble lagret for, så åpne redigeringsverktøyet for å lage en.", + "A merged bracket already recovers highlights from its shorter exposures, so unmerge the frame to use this.": "En sammenslått eksponeringsserie gjenvinner allerede høylys fra de kortere eksponeringene, så del opp bildet for å bruke dette.", + "A merged bracket sets its brightness with Render Exposure, so unmerge the frame to use Auto.": "En sammenslått eksponeringsserie angir lysheten med Gjengivelseseksponering, så del opp bildet for å bruke Auto.", + "A note on how the film was digitized.": "Et notat om hvordan filmen ble digitalisert.", + "A one-time download ({MODEL_DOWNLOAD_SIZE}) lets the Film Strip search by plain-language description.": "En engangsnedlasting ({MODEL_DOWNLOAD_SIZE}) lar filmstripen søke med en beskrivelse i vanlig språk.", + "A preset name cannot contain / \\ : * ? \" < > | or start or end with a dot.": "Et navn på en forhåndsinnstilling kan ikke inneholde / \\ : * ? \" < > | eller begynne eller slutte med punktum.", + "A preset named '{name}' already exists. Replace it?": "En forhåndsinnstilling med navnet '{name}' finnes allerede. Vil du erstatte den?", + "A profile named '{stem}' replaces NegPy's own {value} profile everywhere, instead of appearing as a separate choice. Import anyway?": "En profil med navnet '{stem}' erstatter NegPys egen {value}-profil overalt, i stedet for å vises som et eget valg. Vil du importere likevel?", + "A red glow around bright areas, as some films give.": "En rød glød rundt lyse områder, slik noen filmer gir.", + "A roll name cannot contain / \\ : * ? \" < > | or start or end with a dot.": "Et rullnavn kan ikke inneholde / \\ : * ? \" < > | eller begynne eller slutte med punktum.", + "A saved development recipe that fills Developer and Push / Pull": "En lagret fremkallingsoppskrift som fyller ut Fremkaller og Push / Pull", + "A saved development recipe that fills Developer and Push / Pull.": "En lagret fremkallingsoppskrift som fyller ut Fremkaller og Push / Pull.", + "A saved development recipe, which fills Developer and Push / Pull.": "En lagret fremkallingsoppskrift, som fyller ut Fremkaller og Push / Pull.", + "A saved digitizing setup that fills Scanning": "Et lagret digitaliseringsoppsett som fyller ut Skanning", + "A saved digitizing setup that fills Scanning.": "Et lagret digitaliseringsoppsett som fyller ut Skanning.", + "A saved digitizing setup, which fills Scanning.": "Et lagret digitaliseringsoppsett, som fyller ut Skanning.", + "A saved printer and paper setup, where None shows the export target without paper simulation.": "Et lagret oppsett for skriver og papir, der Ingen viser eksportmålet uten papirsimulering.", + "A scan is running. Wait for it to finish.": "En skanning pågår. Vent til den er ferdig.", + "A sensor crosstalk profile needs Linear RAW, so the profile is skipped while it is off and your choice is kept.": "En sensorkrysstaleprofil krever lineær RAW, så profilen hoppes over mens den er av, og valget ditt beholdes.", + "A short guided walk through the panels": "En kort veiledet tur gjennom panelene", + "A slide does not use dodge and burn, but the frame keeps its masks.": "Et dias bruker ikke skygging og etterbelysning, men bildet beholder maskene sine.", + "A soft glow around bright areas, like the soft focus of a minilab scanner.": "En myk glød rundt lyse områder, som den myke fokusen i en minilab-skanner.", + "A soft gray glow just outside the black frame. 0 is off.": "En myk grå glød rett utenfor den svarte rammen. 0 er av.", + "A stitch also bakes in its flat field and sensor correction; the TIFF cannot be unstitched.": "En sammensying baker også inn flatfeltet og sensorkorreksjonen; TIFF-filen kan ikke deles opp igjen.", + "A vertical cut: the two halves sit side by side.": "Et loddrett snitt: de to halvdelene ligger side om side.", + "A virtual roll loses its list of frames too.": "En virtuell rull mister også listen over bilder.", + "A white lamp, as in Plustek, Epson and most flatbed scanners.": "En hvit lampe, som i Plustek, Epson og de fleste flatskannere.", + "AFTER": "ETTER", + "ANALYSIS": "ANALYSE", + "AUTO DENSITY / GRADE": "AUTOTETTHET / GRADERING", + "AVAILABLE": "TILGJENGELIG", + "Abort": "Avbryt", + "Aborted": "Avbrutt", + "Aborting…": "Avbryter…", + "About NegPy": "Om NegPy", + "About NegPy: version and project page": "Om NegPy: versjon og prosjektside", + "About NegPy…": "Om NegPy…", + "Abruptness": "Brådhet", + "Absolute path": "Absolutt bane", + "Accept All": "Godta alle", + "Accept: make this group a scene and give its frames their own baseline.": "Godta: gjør denne gruppen til en scene og gi bildene i den sitt eget grunnlag.", + "Accessibility": "Tilgjengelighet", + "Achromatopsia (no color)": "Akromatopsi (ingen farger)", + "Action": "Handling", + "Actions": "Handlinger", + "Active backend: {value}": "Aktiv motor: {value}", + "Adams zone overlay": "Adams-soneoverlegg", + "Adaptation Strength": "Tilpasningsstyrke", + "Adaptive Multi-Exposure": "Adaptiv multieksponering", + "Adaptive Multi-Pass": "Adaptiv flerpassering", + "Add": "Legg til", + "Add Custom": "Legg til egendefinert", + "Add Files…": "Legg til filer…", + "Add Folder…": "Legg til mappe…", + "Add a blank entry to fill in by hand, for gear the catalog does not carry": "Legg til en tom oppføring som du fyller ut selv, for utstyr katalogen ikke har", + "Add a camera you own: pick one from the built-in list, or enter your own": "Legg til et kamera du eier: velg ett fra den innebygde listen, eller skriv inn ditt eget", + "Add a development process: pick one from the built-in list, or enter your own": "Legg til en fremkallingsprosess: velg en fra den innebygde listen, eller skriv inn din egen", + "Add a film stock you use: pick one from the built-in list, or enter your own": "Legg til en filmtype du bruker: velg en fra den innebygde listen, eller skriv inn din egen", + "Add a lens you own: pick one from the built-in list, or enter your own": "Legg til et objektiv du eier: velg ett fra den innebygde listen, eller skriv inn ditt eget", + "Add a library folder first": "Legg til en biblioteksmappe først", + "Add a preset: calibrate on the film base, set one by hand, or pick a built-in": "Legg til en forhåndsinnstilling: kalibrer på filmbasen, angi en selv, eller velg en innebygd", + "Add a scan of the bare light source to enable.": "Legg til en skanning av den bare lyskilden for å aktivere.", + "Add a scan setup: pick one from the built-in list, or enter your own": "Legg til et skanneoppsett: velg ett fra den innebygde listen, eller skriv inn ditt eget", + "Add frames, the Hot Folder, and start, close or reset the roll": "Legg til bilder, aktiv mappe, og start, lukk eller tilbakestill rullen", + "Add item": "Legg til element", + "Add print or flat master preset": "Legg til forhåndsinnstilling for kopi eller flat master", + "Add the picked entry to your own gear": "Legg den valgte oppføringen til i ditt eget utstyr", + "Add to {value}": "Legg til i {value}", + "Add {singular}": "Legg til {singular}", + "Add {value} to {name}": "Legg {value} til i {name}", + "Add, edit and remove export presets": "Legg til, rediger og fjern eksportforhåndsinnstillinger", + "Added {value} to “{name}”. Loaded {value1} from other folders for this session only. Use Save as Virtual Roll… to keep them in a new roll.": "La til {value} i «{name}». Lastet inn {value1} fra andre mapper bare for denne økten. Bruk Lagre som virtuell rull… for å beholde dem i en ny rull.", + "Adds local contrast to bring out detail, but strong values can look unnatural.": "Legger til lokal kontrast for å få frem detaljer, men sterke verdier kan se unaturlige ut.", + "Adds progressively more (or less) offset per frame position, for a strip whose gaps creep.": "Legger til gradvis mer (eller mindre) forskyvning per bildeposisjon, for en stripe der mellomrommene sniker seg.", + "Adds the frame's place in the strip, film, camera and lens, the proof and the export size": "Legger til bildets plass i stripen, film, kamera og objektiv, prøven og eksportstørrelsen", + "Adjust Split for This Frame…": "Juster delingen for dette bildet…", + "Adjust…": "Juster…", + "Advance After Marking": "Gå videre etter merking", + "Advance to the next frame after marking": "Gå videre til neste bilde etter merking", + "After the strip ejects, shoot the empty dock and use it as the roll's Flat Field profile.": "Når stripen er matet ut, fotograferer du den tomme holderen og bruker den som rullens flatfeltprofil.", + "After you use a control with the mouse, show its keyboard shortcut once per session": "Etter at du har brukt en kontroll med musen, vis hurtigtasten én gang per økt", + "Agitation": "Agitasjon", + "Agitation down": "Agitasjon ned", + "Agitation up": "Agitasjon opp", + "Agitation ↑/↓": "Agitasjon ↑/↓", + "Align Channels": "Juster kanaler", + "All": "Alle", + "All Frames": "Alle bilder", + "All Off": "Alle av", + "All On": "Alle på", + "All thumbnails are up to date": "Alle miniatyrbilder er oppdatert", + "Alongside Exports": "Ved siden av eksportene", + "Also restore the metadata saved with a work print when it loads.": "Gjenopprett også metadataene som er lagret med en arbeidskopi når den lastes inn.", + "Also write a .negpy edit file beside each source on export.": "Skriv også en .negpy-redigeringsfil ved siden av hver kilde ved eksport.", + "Alt Process": "Alt. prosess", + "Alternative Processes": "Alternative prosesser", + "Alternative printing process": "Alternativ kopieringsprosess", + "Always Load Without Asking": "Last alltid inn uten å spørre", + "Always Overwrite Without Asking": "Overskriv alltid uten å spørre", + "Amount applied per shortcut press": "Mengde per tastetrykk", + "Analog Gear": "Analogt utstyr", + "Analysis": "Analyse", + "Analysis Buffer": "Analysebuffer", + "Analysis Buffer ↑/↓": "Analysebuffer ↑/↓", + "Analysis Panel Guide…": "Veiledning for analysepanelet…", + "Analysis buffer down": "Analysebuffer ned", + "Analysis buffer up": "Analysebuffer opp", + "Analysis chart": "Analysediagram", + "Analysis panel guide": "Veiledning for analysepanelet", + "Analyze Scene…": "Analyser scene…", + "Analyze every scene of the roll": "Analyser hver scene i rullen", + "Analyzing roll": "Analyserer rullen", + "Analyzing scene “{name}”": "Analyserer scenen «{name}»", + "Analyzing scenes": "Analyserer scener", + "Analyzing {current}/{total}: {name} [{marker}]…": "Analyserer {current}/{total}: {name} [{marker}]…", + "Another app is using the camera": "En annen app bruker kameraet", + "Another program is using the camera. On macOS a background app that watches for cameras, a cloud sync client for example, holds it through the system camera daemon. Quit that app or unplug the cable. NegPy reconnects by itself.": "Et annet program bruker kameraet. På macOS holder en bakgrunnsapp som ser etter kameraer, for eksempel en skysynkroniseringsklient, det via systemets kameratjeneste. Avslutt den appen eller koble fra kabelen. NegPy kobler til igjen av seg selv.", + "Aperture": "Blender", + "Aperture (needs an electronically controlled lens)": "Blender (krever et elektronisk styrt objektiv)", + "App": "App", + "App preferences": "Appinnstillinger", + "Applied \"{name}\"'s baseline": "Brukte grunnlaget til \"{name}\"", + "Applied to the roll: {value}": "Brukt på rullen: {value}", + "Applied: none": "Brukt: ingen", + "Applied: {value}": "Brukt: {value}", + "Apply": "Bruk", + "Apply Crop": "Bruk beskjæring", + "Apply Flat Field: even out the light on this roll with the selected profile.": "Bruk flatfelt: jevn ut lyset på denne rullen med den valgte profilen.", + "Apply Framing": "Bruk innramming", + "Apply IR-based dust and scratch correction": "Bruk IR-basert korreksjon av støv og riper", + "Apply Persistent Settings to a file with no saved edit, instead of starting at default settings": "Bruk faste innstillinger på en fil uten lagret redigering, i stedet for å starte med standardinnstillinger", + "Apply Preset": "Bruk forhåndsinnstilling", + "Apply Settings": "Bruk innstillinger", + "Apply Settings…": "Bruk innstillinger…", + "Apply Window": "Bruk vindu", + "Apply on Top": "Bruk oppå", + "Apply settings from the current frame to selected frames or the whole roll": "Bruk innstillinger fra det gjeldende bildet på valgte bilder eller hele rullen", + "Apply the Flat Field gain correction": "Bruk flatfeltets forsterkningskorreksjon", + "Apply the color controls to the whole picture, or mostly to its dark or bright parts.": "Bruk fargekontrollene på hele bildet, eller mest på de mørke eller lyse delene.", + "Apply the copied frame's own metered bounds": "Bruk det kopierte bildets egne målte grenser", + "Apply the lens correction set in Optics: embedded Distortion and CA, and Distortion Correction": "Bruk objektivkorreksjonen angitt i Optikk: innebygd forvrengning og CA, og forvrengningskorreksjon", + "Apply the selected metadata preset to this frame": "Bruk den valgte metadataforhåndsinnstillingen på dette bildet", + "Apply the selected preset": "Bruk den valgte forhåndsinnstillingen", + "Apply the selected preset to the current image (or double-click a preset)": "Bruk den valgte forhåndsinnstillingen på det gjeldende bildet (eller dobbeltklikk på en forhåndsinnstilling)", + "Apply the sensor crosstalk unmixing matrix": "Bruk matrisen for å fjerne sensorkrysstale", + "Apply the {value} values below": "Bruk {value}-verdiene nedenfor", + "Apply these averages to this frame, as one edit": "Bruk disse gjennomsnittene på dette bildet, som én redigering", + "Apply this tab to the roll…": "Bruk denne fanen på rullen…", + "Apply to All": "Bruk på alle", + "Apply to All Frames": "Bruk på alle bilder", + "Apply to Current": "Bruk på gjeldende", + "Apply to Current Frame": "Bruk på gjeldende bilde", + "Apply to Frames": "Bruk på bilder", + "Apply to Frames: copy every {title} setting to the selected frames or the whole roll…": "Bruk på bilder: kopier hver {title}-innstilling til de valgte bildene eller hele rullen…", + "Apply to Frames: copy this card to the selected frames or the whole roll…": "Bruk på bilder: kopier dette kortet til de valgte bildene eller hele rullen…", + "Apply to Roll": "Bruk på rull", + "Apply to Selected": "Bruk på valgte", + "Apply to Selected Frames": "Bruk på valgte bilder", + "Apply to This Frame": "Bruk på dette bildet", + "Armature": "Armatur", + "As Edited": "Som redigert", + "As detected": "Som oppdaget", + "As detected ({profile})": "Som oppdaget ({profile})", + "As opened": "Som åpnet", + "Ascending": "Stigende", + "Assembled from {others} and {last}.": "Satt sammen av {others} og {last}.", + "Assign sequential capture frame numbers to the selected frames or the whole roll": "Tildel fortløpende bildenumre til de valgte bildene eller hele rullen", + "Assign sequential frame numbers": "Tildel fortløpende bildenumre", + "Assigned {count} sequential frame numbers": "Tildelte {count} fortløpende bildenumre", + "Auto": "Auto", + "Auto Crop": "Autobeskjær", + "Auto Crop Settings": "Innstillinger for autobeskjæring", + "Auto Crop aborted; no crops were saved": "Autobeskjæring avbrutt; ingen beskjæringer ble lagret", + "Auto Crop failed: {message}": "Autobeskjæring mislyktes: {message}", + "Auto Crop kept {value}; nothing to analyze": "Autobeskjæring beholdt {value}; ingenting å analysere", + "Auto Crop the selected frames; crops you drew stay": "Autobeskjær de valgte bildene; beskjæringer du har tegnet, blir stående", + "Auto Crop {current}/{total}: {name}": "Autobeskjæring {current}/{total}: {name}", + "Auto Crop: find the edges of the picture and crop to them.": "Autobeskjær: finn kantene i bildet og beskjær etter dem.", + "Auto Crop: saved {saved}, kept {preserved}, unchanged {unresolved}": "Autobeskjæring: lagret {saved}, beholdt {preserved}, uendret {unresolved}", + "Auto Crop: saved {saved}, kept {preserved}, unchanged {unresolved}, failed {failed}": "Autobeskjæring: lagret {saved}, beholdt {preserved}, uendret {unresolved}, mislyktes {failed}", + "Auto Density": "Autotetthet", + "Auto Density and Grade": "Autotetthet og -gradering", + "Auto Density and Grade Targets": "Mål for autotetthet og -gradering", + "Auto Grade": "Autogradering", + "Auto Masking": "Automaskering", + "Auto Masking: sets Masking from the grain of each frame, so grain stays smooth.": "Automaskering: angir maskering ut fra kornet i hvert bilde, så kornet holder seg jevnt.", + "Auto cropping {value}…": "Autobeskjærer {value}…", + "Auto size down": "Autostørrelse ned", + "Auto size up": "Autostørrelse opp", + "Auto-crop all frames": "Autobeskjær alle bilder", + "Auto-cropping frames": "Autobeskjærer bilder", + "Auto-detect": "Oppdag automatisk", + "Auto-detected the split on {value} {frame_word}": "Oppdaget delingen automatisk på {value} {frame_word}", + "Auto-detecting the split on {value} {frame_word}…": "Oppdager delingen automatisk på {value} {frame_word}…", + "Auto-detection failed. Select your monitor's color space above.": "Automatisk oppdaging mislyktes. Velg fargerommet til skjermen ovenfor.", + "Auto-exposure": "Autoeksponering", + "Auto-exposure not supported by this device": "Denne enheten støtter ikke autoeksponering", + "Auto: let NegPy set the brightness and contrast of each frame; right-click turns both on or off.": "Auto: la NegPy angi lysheten og kontrasten for hvert bilde; høyreklikk slår begge på eller av.", + "Auto: the file's profile or color tag; untagged, linear above 8 bits and sRGB at 8 bits.": "Auto: filens profil eller fargemerke; umerket er lineær over 8 bit og sRGB ved 8 bit.", + "Auto: {value}, from the grain of this view.": "Auto: {value}, ut fra kornet i denne visningen.", + "Autofocus": "Autofokus", + "Autofocus before scanning (film is rarely perfectly flat)": "Autofokuser før skanning (film er sjelden helt flat)", + "B": "B", + "B&W": "S/H", + "B&W Negative": "S/H-negativ", + "B&W Slide": "S/H-dias", + "B&W: a black-and-white print, from a B&W negative or a color negative.": "S/H: en svart-hvitt-kopi, fra et S/H-negativ eller et fargenegativ.", + "BEFORE": "FØR", + "BORDER": "KANT", + "Back (←)": "Tilbake (←)", + "Back to every paper": "Tilbake til alle papirer", + "Backend": "Motor", + "Bake Auto Density and Grade on a frame before you change the targets to keep its density and grade fixed.": "Bak inn autotetthet og -gradering på et bilde før du endrer målene, for å holde tettheten og graderingen fast.", + "Bake Auto Density/Grade": "Bak inn autotetthet/-gradering", + "Bake on Export": "Bak inn ved eksport", + "Bake to Roll": "Bak inn i rullen", + "Base Relative: line up each channel at its film base, so the channels compare at one height.": "Baserelativ: still hver kanal inn ved filmbasen, så kanalene sammenlignes i samme høyde.", + "Base Relative: the scan Parade's density above each channel's film base": "Baserelativ: skanneparadens tetthet over hver kanals filmbase", + "Baseline from an older analysis: run Reanalyze": "Grunnlag fra en eldre analyse: kjør Analyser på nytt", + "Baseline from an older analysis: run Scene Analysis": "Grunnlag fra en eldre analyse: kjør sceneanalyse", + "Baseline: {value}": "Grunnlag: {value}", + "Baseline: {value}, from an older analysis": "Grunnlag: {value}, fra en eldre analyse", + "Baseline: {value}. This frame's color is far from the average, so the average can shift its color": "Grunnlag: {value}. Fargen i dette bildet er langt fra gjennomsnittet, så gjennomsnittet kan forskyve fargen", + "Basic": "Grunnleggende", + "Basics": "Grunnleggende", + "Batch": "Satsvis", + "Batch complete: {value}": "Satsvis kjøring fullført: {value}", + "Before / After": "Før / etter", + "Before / After: split against the default conversion; drag the divider": "Før / etter: delt visning mot standardkonverteringen; dra skillelinjen", + "Before and After": "Før og etter", + "Before/after split against the default conversion; hold to see the default alone": "Før/etter delt mot standardkonverteringen; hold for å se bare standarden", + "Below HQ the preview is enlarged: the framing is right, but fine detail is not.": "Under HQ er forhåndsvisningen forstørret: innrammingen er riktig, men fine detaljer er det ikke.", + "Bit Depth": "Bitdybde", + "Bit depth": "Bitdybde", + "Bits per channel in the scanned file": "Bit per kanal i den skannede filen", + "Black": "Svart", + "Black Point": "Svartpunkt", + "Black Point Compensation": "Svartpunktkompensasjon", + "Black Point Trim": "Justering av svartpunkt", + "Black Point ↑/↓": "Svartpunkt ↑/↓", + "Black and white film only": "Bare svart-hvitt-film", + "Black point down": "Svartpunkt ned", + "Black point up": "Svartpunkt opp", + "Bleach": "Bleking", + "Bleach down": "Bleking ned", + "Bleach up": "Bleking opp", + "Bleach ↑/↓": "Bleking ↑/↓", + "Blend": "Blanding", + "Blue": "Blå", + "Blue LED level": "Nivå for blå LED", + "Blue Survival": "Blå overlevelse", + "Blue read {raw}, outside {lo} to {hi}, so it is set to {clamped}.": "Blå leste {raw}, utenfor {lo} til {hi}, så den settes til {clamped}.", + "Border Bottom Weight": "Vekt for nedre kant", + "Border Color": "Kantfarge", + "Border Paper White": "Papirhvitt for kanten", + "Border Width": "Kantbredde", + "Border width down": "Kantbredde ned", + "Border width up": "Kantbredde opp", + "Border width ↑/↓": "Kantbredde ↑/↓", + "Border width. 0 is no border.": "Kantbredde. 0 er ingen kant.", + "Both halves' edits are deleted. Splitting the scan again starts from defaults.": "Redigeringene av begge halvdelene slettes. Deles skanningen på nytt, starter den fra standardverdiene.", + "Bottom": "Nederst", + "Bottom Weight": "Vekt nederst", + "Bracket Middle (Auto)": "Midten av serien (auto)", + "Bracket frames differ in size. They must come from the same capture setup.": "Bildene i eksponeringsserien har ulik størrelse. De må komme fra samme opptaksoppsett.", + "Bring All to Front": "Hent alle frem", + "Bring back the frames that Remove from Roll took out of this roll": "Hent tilbake bildene som Fjern fra rull tok ut av denne rullen", + "Bring back the {value} removed from “{roll_name}”?": "Vil du hente tilbake {value} som ble fjernet fra «{roll_name}»?", + "Brings back detail in bright areas inside the mask, like Preflash.": "Henter tilbake detaljer i lyse områder innenfor masken, som forbelysning.", + "Browse…": "Bla gjennom…", + "Brush Size": "Penselstørrelse", + "Brush Size {size} px": "Penselstørrelse {size} px", + "Brush Size ↑/↓": "Penselstørrelse ↑/↓", + "Brush size down": "Penselstørrelse ned", + "Brush size up": "Penselstørrelse opp", + "Brush size, which Alt+scroll on the picture also changes.": "Penselstørrelse, som Alt+rulling på bildet også endrer.", + "Build the matrix from the three exposures and save it as a sensor profile.": "Bygg matrisen fra de tre eksponeringene og lagre den som en sensorprofil.", + "Built-in": "Innebygd", + "Built-in Presets": "Innebygde forhåndsinnstillinger", + "Built-in gear cannot be removed, only duplicated": "Innebygd utstyr kan ikke fjernes, bare dupliseres", + "Bundled matrix, read-only. Make an editable copy to change it.": "Medfølgende matrise, skrivebeskyttet. Lag en redigerbar kopi for å endre den.", + "Burn": "Etterbelysning", + "CA": "CA", + "CAMERA EXPOSURE": "KAMERAEKSPONERING", + "CHEMICAL TONING": "KJEMISK TONING", + "CLAHE": "CLAHE", + "CLAHE down": "CLAHE ned", + "CLAHE up": "CLAHE opp", + "CLAHE ↑/↓": "CLAHE ↑/↓", + "CLONE · 0": "KLON · 0", + "CLONE · {num_clones}": "KLON · {num_clones}", + "COLOR": "FARGE", + "COLOR MANAGEMENT": "FARGESTYRING", + "Cached thumbnails": "Bufrede miniatyrbilder", + "Calibrate && Save": "Kalibrer && lagre", + "Calibrate Sensor": "Kalibrer sensor", + "Calibrate on the Film Base…": "Kalibrer på filmbasen…", + "Calibrating a new preset. See the pop-up.": "Kalibrerer en ny forhåndsinnstilling. Se popup-vinduet.", + "Calibrating… %p%": "Kalibrerer… %p%", + "Calibration": "Kalibrering", + "Calibration Stopped": "Kalibreringen stoppet", + "Calibration canceled": "Kalibreringen ble avbrutt", + "Calibration failed: {msg}": "Kalibreringen mislyktes: {msg}", + "Calibration: {e}": "Kalibrering: {e}", + "Camera": "Kamera", + "Camera (USB)": "Kamera (USB)", + "Camera (in use)": "Kamera (i bruk)", + "Camera Live View": "Kamera direktevisning", + "Camera WB": "Kamera-WB", + "Camera WB uses the camera's white balance, and Linear RAW decodes neutral, for narrowband light.": "Kamera-WB bruker kameraets hvitbalanse, og lineær RAW dekoder nøytralt, for smalbåndslys.", + "Camera connected (USB)": "Kamera tilkoblet (USB)", + "Camera control failed: {exc}. Reconnect and try again.": "Kamerastyring mislyktes: {exc}. Koble til igjen og prøv på nytt.", + "Camera disconnected": "Kamera frakoblet", + "Camera make": "Kameramerke", + "Camera model": "Kameramodell", + "Camera scanning needs python-gphoto2 (`pip install gphoto2`), which has no Windows build. See docs/CAMERA_SCANNING.md.": "Kameraskanning krever python-gphoto2 (`pip install gphoto2`), som ikke finnes for Windows. Se docs/CAMERA_SCANNING.md.", + "Camera, lens and film details go into every export, so photo apps show your film gear. Set them once for the roll.": "Detaljer om kamera, objektiv og film legges i hver eksport, så fotoapper viser filmutstyret ditt. Angi dem én gang for rullen.", + "Camera: {model} (USB)": "Kamera: {model} (USB)", + "Cameras": "Kameraer", + "Cameras, lenses, film stocks and processes you own. Catalog shows the full shipped list.": "Kameraer, objektiver, filmtyper og prosesser du eier. Katalog viser hele den medfølgende listen.", + "Cancel": "Avbryt", + "Cancel Thumbnail Update": "Avbryt oppdatering av miniatyrbilder", + "Cancel Thumbnail Update: stop the background refresh in progress": "Avbryt oppdatering av miniatyrbilder: stopp oppdateringen som pågår i bakgrunnen", + "Cannot scan yet: {value}": "Kan ikke skanne ennå: {value}", + "Cannot start {requested} while {_active_batch_title} is running": "Kan ikke starte {requested} mens {_active_batch_title} kjører", + "Canvas background": "Lerretsbakgrunn", + "Canvas tools edit one half: turn on Half Frame Mode to edit it": "Lerretsverktøy redigerer én halvdel: slå på halvformatmodus for å redigere den", + "Capture": "Opptak", + "Capture Date": "Opptaksdato", + "Capture Delay": "Opptaksforsinkelse", + "Capture Delay: wait this long after Scan or Retake before the shot, so a shaky table can settle.": "Opptaksforsinkelse: vent så lenge etter Skann eller Ta på nytt før bildet tas, så et ustøtt bord rekker å falle til ro.", + "Capture Location": "Opptakssted", + "Capture canceled": "Opptaket ble avbrutt", + "Capture from Camera…": "Ta opp fra kamera…", + "Capture mode": "Opptaksmodus", + "Capture the next frame into the roll, or stop the capture": "Ta opp neste bilde i rullen, eller stopp opptaket", + "Capture this frame, or stop the capture": "Ta opp dette bildet, eller stopp opptaket", + "Captured frame {frame}. Inverting in NegPy…": "Tok opp bilde {frame}. Inverterer i NegPy…", + "Captured the {channels} exposure of frame {frame}": "Tok {channels}-eksponeringen av bilde {frame}", + "Captures a short and a long exposure and fuses them for more dynamic range, without stacking.": "Tar en kort og en lang eksponering og slår dem sammen for større dynamikkomfang, uten stabling.", + "Captures a short and a long exposure at the scanner's fixed long exposure, as SilverFast does, and fuses them for more dynamic range.": "Tar en kort og en lang eksponering med skannerens faste lange eksponering, slik SilverFast gjør, og slår dem sammen for større dynamikkomfang.", + "Capturing IR channel…": "Tar opp IR-kanalen…", + "Capturing R / G / B…": "Tar opp R / G / B…", + "Capturing in {seconds} s…": "Tar opp om {seconds} s…", + "Capturing white frame…": "Tar opp hvitt bilde…", + "Capturing {channel} channel…": "Tar opp {channel}-kanalen…", + "Capturing {letter}… %p%": "Tar opp {letter}… %p%", + "Capturing {value} channel…": "Tar opp {value}-kanalen…", + "Capturing…": "Tar opp…", + "Capturing… %p%": "Tar opp… %p%", + "Card": "Kort", + "Card Edge": "Kortkant", + "Card Edge: a burn that fades out, dragged from the full-strength edge to where it ends.": "Kortkant: en etterbelysning som toner ut, dratt fra kanten med full styrke til der den slutter.", + "Carrier Corners": "Hjørner på negativholderen", + "Carrier Flare": "Lysspredning fra negativholderen", + "Carrier Roughness": "Ruhet på negativholderen", + "Carrier Width": "Bredde på negativholderen", + "Carry Settings Between Frames": "Overfør innstillinger mellom bilder", + "Carry settings between frames (apply Persistent Settings to a new file)": "Overfør innstillinger mellom bilder (bruk faste innstillinger på en ny fil)", + "Cast": "Fargestikk", + "Cast Removal": "Fjerning av fargestikk", + "Cast Removal: removes a color cast that differs between the dark and bright parts of the picture. 0 is off.": "Fjerning av fargestikk: fjerner et fargestikk som er ulikt i de mørke og lyse delene av bildet. 0 er av.", + "Cast turns off, because one frame has no shared gray balance.": "Fargestikk slås av, fordi ett bilde ikke har noen felles gråbalanse.", + "Catalog": "Katalog", + "Category": "Kategori", + "Centered on the scan file's coordinates.": "Sentrert på koordinatene i skannefilen.", + "Change the film mode, then set it back.": "Endre filmmodus, og sett den så tilbake.", + "Change the keys for each action": "Endre tastene for hver handling", + "Changing the targets affects every frame, including ones not yet baked.": "Endring av målene påvirker hvert bilde, også de som ikke er bakt inn ennå.", + "Channel Delay": "Kanalforsinkelse", + "Chart": "Diagram", + "Check Flat Field '{name}': {low:+.1%} to {high:+.1%}, color ±{color:.1%}, reference clipped. Gray is even; full white or black is {EVENNESS_RANGE:.0%} off": "Kontroller flatfelt «{name}»: {low:+.1%} til {high:+.1%}, farge ±{color:.1%}, referansen er klippet. Grått er jevnt; helt hvitt eller svart er {EVENNESS_RANGE:.0%} avvik", + "Check Flat Field '{name}': {low:+.1%} to {high:+.1%}, color ±{color:.1%}. Gray is even; full white or black is {EVENNESS_RANGE:.0%} off": "Kontroller flatfelt «{name}»: {low:+.1%} til {high:+.1%}, farge ±{color:.1%}. Grått er jevnt; helt hvitt eller svart er {EVENNESS_RANGE:.0%} avvik", + "Check Flat Field (how well the profile corrects its reference)": "Kontroller flatfelt (hvor godt profilen korrigerer referansen sin)", + "Check Flat Field: show how well the profile evens out its own reference, where even gray is good.": "Kontroller flatfelt: vis hvor godt profilen jevner ut sin egen referanse, der jevnt grått er bra.", + "Check R, G, B or IR in Live View to pick what Retake shoots again": "Kryss av for R, G, B eller IR i direktevisningen for å velge hva Ta på nytt tar igjen", + "Check each group on the Light Table. To change one, drag frames onto another group or right-click › Scene.": "Kontroller hver gruppe på lysbordet. For å endre en, dra bilder til en annen gruppe eller høyreklikk › Scene.", + "Check for Updates": "Se etter oppdateringer", + "Check for Updates…": "Se etter oppdateringer…", + "Check for updates": "Se etter oppdateringer", + "Choose Export Folder": "Velg eksportmappe", + "Choose a Flat Field profile to check": "Velg en flatfeltprofil som skal kontrolleres", + "Choose a capture…": "Velg et opptak…", + "Choose contact sheet output folder": "Velg utdatamappe for kontaktark", + "Choose export folder": "Velg eksportmappe", + "Choose the buttons on the canvas bar": "Velg knappene på lerretslinjen", + "Choose the exposure file for this color.": "Velg eksponeringsfilen for denne fargen.", + "Choose the output folder": "Velg utdatamappen", + "Choose what the Export Presets button does": "Velg hva knappen Eksportforhåndsinnstillinger gjør", + "Choose what the Export button does": "Velg hva knappen Eksporter gjør", + "Choose which edits carry onto the next file you open": "Velg hvilke redigeringer som følger med til neste fil du åpner", + "Choose which fields join into EXIF ImageDescription": "Velg hvilke felt som slås sammen til EXIF ImageDescription", + "Choose which settings carry to the next file": "Velg hvilke innstillinger som følger med til neste fil", + "Choose which sliders appear here, and in what order": "Velg hvilke glidebrytere som vises her, og i hvilken rekkefølge", + "Chroma": "Kroma", + "Chroma Denoise": "Kromastøyreduksjon", + "Chroma Denoise ↑/↓": "Kromastøyreduksjon ↑/↓", + "Chroma down": "Kroma ned", + "Chroma up": "Kroma opp", + "Chroma ↑/↓": "Kroma ↑/↓", + "City": "By", + "Classic (Herschel)": "Klassisk (Herschel)", + "Classic gives a light blue with green highlights, and New gives deeper, cleaner blues and more range.": "Klassisk gir en lys blåfarge med grønne høylys, og Ny gir dypere, renere blåtoner og større omfang.", + "Clear": "Tøm", + "Clear All Clones": "Fjern alle kloner", + "Clear All Clones…": "Fjern alle kloner…", + "Clear All Heals": "Fjern alle legeinger", + "Clear All Heals…": "Fjern alle legeinger…", + "Clear All: remove all clone strokes": "Fjern alle: fjern alle klonestrøk", + "Clear All: remove all manual heals and keep the automatic dust removal.": "Fjern alle: fjern alle manuelle legeinger og behold den automatiske støvfjerningen.", + "Clear Crop": "Fjern beskjæring", + "Clear Crops": "Fjern beskjæringer", + "Clear Dmax: measure the white point automatically again.": "Fjern Dmax: mål hvitpunktet automatisk på nytt.", + "Clear Dmin: measure the black point automatically again.": "Fjern Dmin: mål svartpunktet automatisk på nytt.", + "Clear Film Base: forget the film base this frame uses.": "Fjern filmbase: glem filmbasen dette bildet bruker.", + "Clear Library": "Tøm biblioteket", + "Clear Library…": "Tøm biblioteket…", + "Clear Region: measure with the Analysis Buffer again.": "Fjern område: mål med analysebufferen på nytt.", + "Clear Saved Edits": "Fjern lagrede redigeringer", + "Clear Saved Edits…": "Fjern lagrede redigeringer…", + "Clear Thumbnails": "Fjern miniatyrbilder", + "Clear Thumbnails…": "Fjern miniatyrbilder…", + "Clear the camera, lens and film stock selections": "Fjern valgene av kamera, objektiv og filmtype", + "Clear the capture place": "Fjern opptaksstedet", + "Clear the crop you drew.": "Fjern beskjæringen du tegnet.", + "Clear the frame's camera, lens and film stock": "Fjern bildets kamera, objektiv og filmtype", + "Clear the frame's development process": "Fjern bildets fremkallingsprosess", + "Clear the frame's scan setup": "Fjern bildets skanneoppsett", + "Clear the saved process and the developer, dilution, push, time and temperature it fills": "Fjern den lagrede prosessen og fremkalleren, fortynningen, push, tiden og temperaturen den fyller ut", + "Clear the saved scan setup and its scanning note": "Fjern det lagrede skanneoppsettet og skannenotatet", + "Clearing only affects this app's database and its thumbnail cache. Source files are never touched. If you export .negpy sidecars, those still exist next to your images and can restore an edit when that image is reloaded.": "Tømming påvirker bare databasen til denne appen og hurtigbufferen for miniatyrbilder. Kildefilene blir aldri rørt. Hvis du eksporterer .negpy-sidefiler, finnes de fortsatt ved siden av bildene dine og kan gjenopprette en redigering når bildet lastes inn på nytt.", + "Click + to add a preset": "Klikk + for å legge til en forhåndsinnstilling", + "Click Draw and drag a new rectangle on the picture. Enter confirms.": "Klikk Tegn og dra et nytt rektangel på bildet. Enter bekrefter.", + "Click Flat or Linear to change the output intent.": "Klikk Flat eller Lineær for å endre utdatahensikten.", + "Click Pick WB, then a gray area in the picture, or drag a filter slider.": "Klikk Velg WB, og så et grått område i bildet, eller dra en filterglidebryter.", + "Click a mask to select it, or use its icons to turn it on or off, show its outline or delete it.": "Klikk en maske for å velge den, eller bruk ikonene for å slå den på eller av, vise omrisset eller slette den.", + "Click a neutral gray; the selected region's CMY is solved so it prints neutral": "Klikk et nøytralt grått; CMY for det valgte området løses slik at det kopieres nøytralt", + "Click a paper to see it on the whole frame": "Klikk et papir for å se det på hele bildet", + "Click a patch of clear film base: the gap between frames, the leader or a blank frame. Name the stock, then Calibrate & Save. The shutter is solved here, so set the ISO and aperture you scan with.": "Klikk et felt med klar filmbase: mellomrommet mellom bilder, starten av filmen eller et tomt bilde. Gi filmtypen et navn, og velg så Kalibrer og lagre. Lukkeren løses her, så angi ISO-en og blenderen du skanner med.", + "Click a preset to see what it stores, or right-click it for more": "Klikk en forhåndsinnstilling for å se hva den lagrer, eller høyreklikk den for mer", + "Click a step to go back to it; ⋮ or right-click exports it. The last {count} edits are kept.": "Klikk et trinn for å gå tilbake til det; ⋮ eller høyreklikk eksporterer det. De siste {count} redigeringene beholdes.", + "Click a step to jump to it (last 100 edits kept).": "Klikk et trinn for å hoppe til det (de siste 100 redigeringene beholdes).", + "Click and type to search for the film stock of the original capture.": "Klikk og skriv for å søke etter filmtypen i det opprinnelige opptaket.", + "Click and type to search for the lens used on the film camera.": "Klikk og skriv for å søke etter objektivet som ble brukt på filmkameraet.", + "Click and type to search for the original film camera body.": "Klikk og skriv for å søke etter det opprinnelige filmkamerahuset.", + "Click and type to search the built-in {value} catalog": "Klikk og skriv for å søke i den innebygde {value}-katalogen", + "Click and type to search the saved sets of metadata values.": "Klikk og skriv for å søke i de lagrede settene med metadataverdier.", + "Click once on the line; the whole line is traced and repaired": "Klikk én gang på linjen; hele linjen spores og repareres", + "Click points along the scratch; Backspace deletes the last point": "Klikk punkter langs ripen; Backspace sletter det siste punktet", + "Click something in the picture that should be gray, and the color is corrected to make it gray.": "Klikk på noe i bildet som skal være grått, så korrigeres fargen slik at det blir grått.", + "Click the clear film base (crosshair) first": "Klikk den klare filmbasen (trådkors) først", + "Click the clear rebate to set the roll's film base": "Klikk den klare filmkanten for å angi rullens filmbase", + "Click the densest spot of the negative: the brightest highlight": "Klikk det tetteste punktet på negativet: det lyseste høylyset", + "Click the photo to pick the area to copy from": "Klikk på bildet for å velge området det skal kopieres fra", + "Click the photo to pick the area to copy from first": "Klikk på bildet for å velge området det skal kopieres fra først", + "Click the photo to pick the area to copy from.": "Klikk på bildet for å velge området det skal kopieres fra.", + "Click the photo to pin a spot; drag a pin to move it": "Klikk på bildet for å feste et punkt; dra en nål for å flytte den", + "Click the photo to place zone {value}": "Klikk på bildet for å plassere sone {value}", + "Click the thinnest spot of the negative: the film base or the deepest shadow": "Klikk det tynneste punktet på negativet: filmbasen eller den dypeste skyggen", + "Click to enable or disable this mask's effect on the render": "Klikk for å slå denne maskens virkning på gjengivelsen på eller av", + "Click to place vertices; a click near the start closes the mask": "Klikk for å plassere hjørnepunkter; et klikk nær starten lukker masken", + "Clipping": "Klipping", + "Clipping Overlay": "Klippeoverlegg", + "Clipping Overlay: mark the parts of the picture that print pure black or pure white.": "Klippeoverlegg: merk delene av bildet som kopieres helt svart eller helt hvitt.", + "Clipping Overlay: paper black and paper white on the frame": "Klippeoverlegg: papirsvart og papirhvitt på bildet", + "Clone": "Klon", + "Clone: Alt-click to pick an area, then copy its film over a defect.": "Klon: Alt-klikk for å velge et område, og kopier så filmen over en feil.", + "Close": "Lukk", + "Close the Paper Test Strip": "Lukk prøvestripen for papir", + "Close the Zone Overlay": "Lukk soneoverlegget", + "Close the reference": "Lukk referansen", + "Close without adding anything": "Lukk uten å legge til noe", + "Collapse every card on this tab": "Fold sammen hvert kort på denne fanen", + "Collapse or expand the Film Strip": "Fold sammen eller ut filmstripen", + "Color": "Farge", + "Color Balance": "Fargebalanse", + "Color Clip": "Fargeklipping", + "Color Clip ↑/↓": "Fargeklipping ↑/↓", + "Color Negative": "Fargenegativ", + "Color Negative (C-41)": "Fargenegativ (C-41)", + "Color Negative (C-41): orange-masked negative": "Fargenegativ (C-41): negativ med oransje maske", + "Color Slide": "Fargedias", + "Color added to the highlights.": "Farge lagt til i høylysene.", + "Color added to the shadows.": "Farge lagt til i skyggene.", + "Color negatives only: use the roll's gray balance for Cast Removal, so frames under one light match.": "Bare fargenegativer: bruk rullens gråbalanse for fjerning av fargestikk, så bilder under samme lys stemmer overens.", + "Color or black and white, negative or slide": "Farge eller svart-hvitt, negativ eller dias", + "Color range clip down": "Fargeområdeklipping ned", + "Color range clip up": "Fargeområdeklipping opp", + "Color ring-around (M/Y filtration)": "Fargeringen (M/Y-filtrering)", + "Color strength in the print, through the paper's dyes. 1 is unchanged.": "Fargestyrke i kopien, gjennom papirets fargestoffer. 1 er uendret.", + "Color strength. 1 is unchanged, 0 is black and white.": "Fargestyrke. 1 er uendret, 0 er svart-hvitt.", + "Color type": "Fargetype", + "Color vision": "Fargesyn", + "Colors for the dust overlay, the Dodge & Burn masks and Printing Notes. The color-blind choices use the Okabe–Ito palette.": "Farger for støvoverlegget, maskene for skygging og etterbelysning og kopieringsnotater. Valgene for fargeblinde bruker Okabe–Ito-paletten.", + "Combines the short and long exposure fusion with multi-pass stacking for the most range and least noise, the slowest option.": "Kombinerer sammenslåingen av kort og lang eksponering med flerpasseringsstabling for størst omfang og minst støy, det tregeste valget.", + "Commit the solved print (Enter)": "Bekreft den løste kopien (Enter)", + "Compare": "Sammenlign", + "Compare With This Step": "Sammenlign med dette trinnet", + "Compare With This Version": "Sammenlign med denne versjonen", + "Composition guide shown in the crop tool": "Komposisjonshjelp som vises i beskjæringsverktøyet", + "Compression": "Komprimering", + "Compute and Save": "Beregn og lagre", + "Computing…": "Beregner…", + "Confirm Scratch Enter": "Bekreft ripe Enter", + "Connect the Scanlight to use it.": "Koble til Scanlight for å bruke det.", + "Connect the camera and the Scanlight to use it.": "Koble til kameraet og Scanlight for å bruke det.", + "Connect the camera by USB, in PC Remote mode. NegPy finds it by itself.": "Koble til kameraet med USB, i PC Remote-modus. NegPy finner det selv.", + "Connect the camera to set the exposure.": "Koble til kameraet for å angi eksponeringen.", + "Connect the camera to use it.": "Koble til kameraet for å bruke det.", + "Connecting to camera…": "Kobler til kameraet…", + "Contact Sheet": "Kontaktark", + "Contact Sheet…": "Kontaktark…", + "Contact sheet": "Kontaktark", + "Contact sheet saved to {folder}": "Kontaktark lagret i {folder}", + "Contact sheet saved to {folder}, {failed} failed": "Kontaktark lagret i {folder}, {failed} mislyktes", + "Contact sheet: {reason}": "Kontaktark: {reason}", + "Contrast Mask": "Kontrastmaske", + "Contrast Mask down": "Kontrastmaske ned", + "Contrast Mask up": "Kontrastmaske opp", + "Contrast Mask ↑/↓": "Kontrastmaske ↑/↓", + "Contrast Mask: a soft darkroom mask that opens up large dark areas and holds back large bright ones, modeled on the Fujifilm Frontier's digital interpretation.": "Kontrastmaske: en myk mørkeromsmaske som åpner store mørke områder og holder tilbake store lyse, etter modell av den digitale tolkningen i Fujifilm Frontier.", + "Contrast Target": "Kontrastmål", + "Contrast in the highlights only.": "Kontrast bare i høylysene.", + "Contrast in the shadows only.": "Kontrast bare i skyggene.", + "Contrast inside the mask, where a negative value gives more contrast.": "Kontrast innenfor masken, der en negativ verdi gir mer kontrast.", + "Contrast of this color layer only, for a color cast that differs between shadows and highlights.": "Kontrast bare i dette fargelaget, for et fargestikk som er ulikt i skygger og høylys.", + "Contrast: a lower number gives more contrast, and about 110 is a normal print.": "Kontrast: et lavere tall gir mer kontrast, og omtrent 110 er en normal kopi.", + "Contrast: a shorter scale gives more contrast but can lose detail in shadows and highlights.": "Kontrast: en kortere skala gir mer kontrast, men kan miste detaljer i skygger og høylys.", + "Controls": "Kontroller", + "Coordinates": "Koordinater", + "Coordinates not recognized": "Koordinatene ble ikke gjenkjent", + "Copied frame's bounds": "Det kopierte bildets grenser", + "Copied frame's bounds (locked)": "Det kopierte bildets grenser (låst)", + "Copper": "Kobber", + "Copper toner, B&W negatives only: pink to brick-red tones, with weaker blacks.": "Kobbertoner, bare S/H-negativer: rosa til teglrøde toner, med svakere svarttoner.", + "Copy Card Settings": "Kopier kortinnstillinger", + "Copy Settings": "Kopier innstillinger", + "Copy Settings + Bounds": "Kopier innstillinger + grenser", + "Copy settings": "Kopier innstillinger", + "Copy settings (with bounds)": "Kopier innstillinger (med grenser)", + "Copy settings plus the metering/normalization bounds": "Kopier innstillinger pluss grensene for lysmåling/normalisering", + "Copy this card alone; Paste then offers only its settings": "Kopier bare dette kortet; Lim inn tilbyr da bare innstillingene fra det", + "Copy this frame's settings to the clipboard": "Kopier innstillingene for dette bildet til utklippstavlen", + "Corners": "Hjørner", + "Correct the color, like the filters of an enlarger. Click Pick WB, then something that should be gray, or drag a color slider. {value} opens a grid of color choices.": "Korriger fargen, som filtrene i et forstørrelsesapparat. Klikk Velg WB, og så noe som skal være grått, eller dra en fargeglidebryter. {value} åpner et rutenett med fargevalg.", + "Corrections": "Korreksjoner", + "Could not build the matrix: {exc}": "Kunne ikke bygge matrisen: {exc}", + "Could not capture: {msg}": "Kunne ikke ta opp: {msg}", + "Could not clear the database:\n{exc}": "Kunne ikke tømme databasen:\n{exc}", + "Could not clear the library:\n{exc}": "Kunne ikke tømme biblioteket:\n{exc}", + "Could not clear the thumbnail cache:\n{exc}": "Kunne ikke tømme hurtigbufferen for miniatyrbilder:\n{exc}", + "Could not display": "Kunne ikke vises", + "Could not display frame {slot}: {e}": "Kunne ikke vise bilde {slot}: {e}", + "Could not display preview: {e}": "Kunne ikke vise forhåndsvisningen: {e}", + "Could not load preview: {e}": "Kunne ikke laste inn forhåndsvisningen: {e}", + "Could not measure the chart: {value}": "Kunne ikke måle kartet: {value}", + "Could not read the database.": "Kunne ikke lese databasen.", + "Could not rename the folder on disk. Check that no other folder has that name and that you may rename it.": "Kunne ikke gi mappen nytt navn på disken. Kontroller at ingen annen mappe har det navnet, og at du har lov til å gi den nytt navn.", + "Could not reset the database:\n{exc}": "Kunne ikke tilbakestille databasen:\n{exc}", + "Could not start the installer: {exc}": "Kunne ikke starte installasjonsprogrammet: {exc}", + "Could not tell red, green and blue apart. Use Edit Trichrome… to pick them by hand.": "Kunne ikke skille rød, grønn og blå fra hverandre. Bruk Rediger Trichrome… for å velge dem selv.", + "Could not write {path}": "Kunne ikke skrive {path}", + "Count up or down from the start number": "Tell opp eller ned fra startnummeret", + "Country": "Land", + "Create Sensor Crosstalk Profile": "Lag sensorkrysstaleprofil", + "Crop": "Beskjær", + "Crop All Frames": "Beskjær alle bilder", + "Crop Empty Corners": "Beskjær tomme hjørner", + "Crop Empty Corners: crop away the empty corners that rotation, Tilt and Swing leave.": "Beskjær tomme hjørner: beskjær bort de tomme hjørnene som rotasjon, Tilt og Sving etterlater.", + "Crop Mode": "Beskjæringsmodus", + "Crop Offset": "Beskjæringsforskyvning", + "Crop Offset ↑/↓": "Beskjæringsforskyvning ↑/↓", + "Crop Ratio": "Beskjæringsforhold", + "Crop all frames together: clear frames help unclear ones, and crops that you drew stay.": "Beskjær alle bilder sammen: tydelige bilder hjelper utydelige, og beskjæringer du har tegnet, blir stående.", + "Crop is locked while Auto Crop runs": "Beskjæringen er låst mens autobeskjæring kjører", + "Crop offset down": "Beskjæringsforskyvning ned", + "Crop offset up": "Beskjæringsforskyvning opp", + "Crop status: 0 of {total} files are cropped.": "Beskjæringsstatus: 0 av {total} filer er beskåret.", + "Crop status: all {total} files are cropped.": "Beskjæringsstatus: alle {total} filer er beskåret.", + "Crop status: {cropped} of {total} files are cropped.": "Beskjæringsstatus: {cropped} av {total} filer er beskåret.", + "Crop to the picture only, or keep the whole film with its edges and holes.": "Beskjær bare til bildet, eller behold hele filmen med kantene og hullene.", + "Crop: {value:.1f} × {value1:.1f} mm": "Beskjæring: {value:.1f} × {value1:.1f} mm", + "Crosstalk": "Krysstale", + "Crosstalk Matrices": "Krysstalematriser", + "Crosstalk down": "Krysstale ned", + "Crosstalk matrix: separates colors that the film's dyes, your light and your sensor mix together.": "Krysstalematrise: skiller farger som filmens fargestoffer, lyset ditt og sensoren din blander sammen.", + "Crosstalk up": "Krysstale opp", + "Crosstalk ↑/↓": "Krysstale ↑/↓", + "Current Frame": "Gjeldende bilde", + "Current keyboard shortcuts and slider step sizes. Search by name or press a shortcut to filter results, then choose or press Enter. Open Customize to change bindings.": "Gjeldende hurtigtaster og trinnstørrelser for glidebrytere. Søk etter navn eller trykk en hurtigtast for å filtrere resultatene, og velg eller trykk Enter. Åpne Tilpass for å endre tastebindinger.", + "Curve": "Kurve", + "Curve Shape": "Kurveform", + "Curve: the paper curve over the scan's and the print's histograms": "Kurve: papirkurven over histogrammene for skanningen og kopien", + "Custom": "Egendefinert", + "Custom Routing": "Egendefinert ruting", + "Customize Shortcuts": "Tilpass hurtigtaster", + "Customize Shortcuts…": "Tilpass hurtigtaster…", + "Customize…": "Tilpass…", + "Cut Thickness": "Snittykkelse", + "Cyan": "Cyan", + "Cyan down": "Cyan ned", + "Cyan up": "Cyan opp", + "Cyan ↑/↓": "Cyan ↑/↓", + "Cyanotype": "Cyanotypi", + "Cyanotype Exposure": "Cyanotypieksponering", + "Cyanotype Exposure ↑/↓": "Cyanotypieksponering ↑/↓", + "Cyanotype exposure down": "Cyanotypieksponering ned", + "Cyanotype exposure up": "Cyanotypieksponering opp", + "Cyanotype printing on/off": "Cyanotypikopiering på/av", + "Cyanotype: a blue print with green highlights, made in UV light, toned with Bleach and Tannin in place of toners.": "Cyanotypi: en blå kopi med grønne høylys, laget i UV-lys, tonet med Bleking og Tannin i stedet for tonere.", + "Cyanotype: no silver for a toner to act on. Use Bleach and Tannin.": "Cyanotypi: ikke noe sølv som en toner kan virke på. Bruk Bleking og Tannin.", + "DEMOSAIC": "DEMOSAIKK", + "DESTINATION": "MÅL", + "DETAIL": "DETALJ", + "DETAILS": "DETALJER", + "DPI": "DPI", + "Dark Gray": "Mørkegrå", + "Darkens (positive) or lightens (negative) only the highlights, and Auto Grade can set it too.": "Gjør bare høylysene mørkere (positiv) eller lysere (negativ), og autogradering kan også angi den.", + "Darkens (positive) or lightens (negative) only the shadows, and Auto Grade can set it too.": "Gjør bare skyggene mørkere (positiv) eller lysere (negativ), og autogradering kan også angi den.", + "Darkens (positive) or lightens (negative) the area in the mask, in stops.": "Gjør området i masken mørkere (positiv) eller lysere (negativ), i blendertrinn.", + "Darkens (positive) or lightens (negative) the edges of the print. 0 is off.": "Gjør kantene av kopien mørkere (positiv) eller lysere (negativ). 0 er av.", + "Date": "Dato", + "Debug log": "Feilsøkingslogg", + "Decodes the RAW linear, with no white balance.": "Dekoder RAW-filen lineært, uten hvitbalanse.", + "Decodes the RAW with the camera white balance.": "Dekoder RAW-filen med kameraets hvitbalanse.", + "Deconvolution": "Dekonvolusjon", + "Deep Yellow #15": "Dyp gul #15", + "Default": "Standard", + "Delete": "Slett", + "Delete Mask": "Slett maske", + "Delete Scene…": "Slett scene…", + "Delete This Clone": "Slett denne klonen", + "Delete This Heal": "Slett denne legeingen", + "Delete all saved per-image edits, their undo history, and keep/reject marks?\n\nReloading an image will start from defaults. Export presets and rig profiles are kept.": "Vil du slette alle lagrede redigeringer per bilde, angrehistorikken deres og behold/forkast-merkene?\n\nNår et bilde lastes inn på nytt, starter det fra standardverdiene. Eksportforhåndsinnstillinger og riggprofiler beholdes.", + "Delete every cached thumbnail?\n\nThumbnails for the images currently loaded are rebuilt straight away.": "Vil du slette alle bufrede miniatyrbilder?\n\nMiniatyrbildene for bildene som er lastet inn nå, bygges opp igjen med en gang.", + "Delete preset": "Slett forhåndsinnstilling", + "Delete the cached Film Strip thumbnails, which render again as images load": "Slett de bufrede miniatyrbildene i filmstripen, som gjengis på nytt når bildene lastes inn", + "Delete the saved edits, undo history and keep/reject marks of every image": "Slett de lagrede redigeringene, angrehistorikken og behold/forkast-merkene for hvert bilde", + "Delete the selected preset": "Slett den valgte forhåndsinnstillingen", + "Delete the selected profile": "Slett den valgte profilen", + "Delete the {value} “{name}”?": "Vil du slette {value} «{name}»?", + "Delete these {value} {kinds}?": "Vil du slette disse {value} {kinds}?", + "Delete this dodge/burn mask?": "Vil du slette denne masken for skygging/etterbelysning?", + "Delete this mask": "Slett denne masken", + "Delete {kind}": "Slett {kind}", + "Delete {value} Rolls…": "Slett {value} ruller…", + "Delete {value} {kinds}": "Slett {value} {kinds}", + "Delete “{value}”: its frames keep their edits and baseline.": "Slett «{value}»: bildene beholder redigeringene og grunnlaget sitt.", + "Deleted preset “{name}”": "Slettet forhåndsinnstillingen «{name}»", + "Delete…": "Slett…", + "Demosaic Export": "Demosaikk ved eksport", + "Demosaic Preview": "Demosaikk ved forhåndsvisning", + "Denoise down": "Støyreduksjon ned", + "Denoise up": "Støyreduksjon opp", + "Density down": "Tetthet ned", + "Density up": "Tetthet opp", + "Density × grade test strip": "Prøvestripe for tetthet × gradering", + "Descending": "Synkende", + "Describe the photo to find…": "Beskriv bildet du vil finne…", + "Description Fields": "Beskrivelsesfelt", + "Description…": "Beskrivelse…", + "Detect": "Oppdag", + "Detect All": "Oppdag alle", + "Detect Frames": "Oppdag bilder", + "Detect Scenes": "Oppdag scener", + "Detect Scenes (find groups of frames under another light)": "Oppdag scener (finn grupper av bilder under et annet lys)", + "Detect Scenes: find the groups of frames under another light than the roll and show them on the Light Table.": "Oppdag scener: finn gruppene av bilder under et annet lys enn rullen og vis dem på lysbordet.", + "Detect closest aspect ratio from the film frame": "Finn nærmeste sideforhold ut fra filmbildet", + "Detected: {desc}": "Oppdaget: {desc}", + "Detecting scanners…": "Ser etter skannere…", + "Detecting scenes": "Oppdager scener", + "Developer": "Fremkaller", + "Developer temperature in degrees Celsius": "Fremkallertemperatur i grader Celsius", + "Developer the film was processed in.": "Fremkalleren filmen ble behandlet i.", + "Development temperature": "Fremkallingstemperatur", + "Development temperature, in °C.": "Fremkallingstemperatur, i °C.", + "Development time": "Fremkallingstid", + "Development time, as mm:ss or plain minutes": "Fremkallingstid, som mm:ss eller bare minutter", + "Development time, as mm:ss or plain minutes.": "Fremkallingstid, som mm:ss eller bare minutter.", + "Device": "Enhet", + "Diagonal Method": "Diagonalmetode", + "Diagonals": "Diagonaler", + "Difference": "Differanse", + "Diffusion": "Diffusjon", + "Diffusion down": "Diffusjon ned", + "Diffusion up": "Diffusjon opp", + "Diffusion ↑/↓": "Diffusjon ↑/↓", + "Diffusion: a soft glow that spreads dark areas into bright ones and softens edges, off at 0.": "Diffusjon: en myk glød som sprer mørke områder inn i lyse og mykner kanter, av ved 0.", + "Digital": "Digital", + "Dilution": "Fortynning", + "Diptych unsplit. The halves' edits are deleted.": "Diptyket er delt opp igjen. Redigeringene av halvdelene slettes.", + "Diptych: both halves, each with its own edit": "Diptyk: begge halvdelene, hver med sin egen redigering", + "Diptych: the edits belong to the halves. Turn Half Frame Mode on to edit either one.": "Diptyk: redigeringene hører til halvdelene. Slå på halvformatmodus for å redigere en av dem.", + "Discard All": "Forkast alle", + "Discard all edits and return this frame to its default look": "Forkast alle redigeringer og gi dette bildet standardutseendet tilbake", + "Discard: forget this group, and its frames stay with the roll baseline.": "Forkast: glem denne gruppen, så blir bildene værende med rullens grunnlag.", + "Discovery Filters": "Søkefiltre", + "Discovery Filters: folder names that importing subfolders and Refresh skip": "Søkefiltre: mappenavn som import av undermapper og Oppdater hopper over", + "Discovery Filters…": "Søkefiltre…", + "Display": "Skjerm", + "Display name": "Visningsnavn", + "Dissolve them first: Unstitch, Unmerge Exposures, Unsplit Diptych, or turn off Trichrome Mode or Half Frame Mode on the Roll tab. Then move their files to the Trash.": "Løs dem opp først: Del opp sammensying, Del opp eksponeringer, Del opp diptyk, eller slå av Trichrome-modus eller halvformatmodus på fanen Rull. Flytt så filene deres til papirkurven.", + "Distance": "Avstand", + "Distortion": "Forvrengning", + "Distortion Correction": "Forvrengningskorreksjon", + "Distortion and CA are off in Optics": "Forvrengning og CA er av i Optikk", + "Dmax": "Dmax", + "Dmax set for this frame": "Dmax angitt for dette bildet", + "Dmax: Densest Spot": "Dmax: tetteste punkt", + "Dmax: click the brightest highlight to set this frame's white point by hand.": "Dmax: klikk det lyseste høylyset for å angi hvitpunktet for dette bildet selv.", + "Dmin": "Dmin", + "Dmin set for this frame": "Dmin angitt for dette bildet", + "Dmin: Thinnest Spot": "Dmin: tynneste punkt", + "Dmin: click the film base or the deepest shadow to set this frame's black point by hand.": "Dmin: klikk filmbasen eller den dypeste skyggen for å angi svartpunktet for dette bildet selv.", + "Do Not Ask Again": "Ikke spør igjen", + "Do Not Show Again": "Ikke vis igjen", + "Dock controls panel to right": "Fest kontrollpanelet til høyre", + "Dock the controls panel and expand the Film Strip": "Fest kontrollpanelet og utvid filmstripen", + "Dodge": "Skygging", + "Dodge & Burn": "Skygging og etterbelysning", + "Dodge and Burn": "Skygging og etterbelysning", + "Done": "Ferdig", + "Double-click to reset": "Dobbeltklikk for å tilbakestille", + "Double-click: Zone Overlay on or off": "Dobbeltklikk: soneoverlegg på eller av", + "Download": "Last ned", + "Download failed: {exc}": "Nedlastingen mislyktes: {exc}", + "Download the Model": "Last ned modellen", + "Downloading {asset_name}: {value} of {value1}": "Laster ned {asset_name}: {value} av {value1}", + "Downloading {asset_name}…": "Laster ned {asset_name}…", + "Downloading: {value} of {value1}": "Laster ned: {value} av {value1}", + "Downloading…": "Laster ned…", + "Downloads a small model ({MODEL_DOWNLOAD_SIZE}) the first time this is turned on.": "Laster ned en liten modell ({MODEL_DOWNLOAD_SIZE}) første gang dette slås på.", + "Drag ISO-R Grade.": "Dra ISO-R-gradering.", + "Drag Print Density and watch the print.": "Dra Kopitetthet og se på kopien.", + "Drag Shadow Strength, then turn Shadow Hue.": "Dra Skyggestyrke, og drei så Skyggetone.", + "Drag Vignette Burn, or give the print a Border.": "Dra Vignett-etterbelysning, eller gi kopien en kant.", + "Drag a corner or an edge; drag outside the box to redraw it": "Dra et hjørne eller en kant; dra utenfor boksen for å tegne den på nytt", + "Drag a line along each converging edge; four lines solve Tilt and Swing": "Dra en linje langs hver konvergerende kant; fire linjer løser Tilt og Sving", + "Drag any Lab slider and watch the print.": "Dra en hvilken som helst Lab-glidebryter og se på kopien.", + "Drag from the full-exposure edge to where it fades out": "Dra fra kanten med full eksponering til der den toner ut", + "Drag one of the four zone sliders.": "Dra en av de fire soneglidebryterne.", + "Drag out an oval": "Dra ut en oval", + "Drag outside the box to redraw the crop": "Dra utenfor boksen for å tegne beskjæringen på nytt", + "Drag the area the meters read": "Dra området som målerne leser", + "Drag the rectangle to set the scan crop": "Dra rektangelet for å angi skannebeskjæringen", + "Drag the white rectangle to crop. Drag the orange line to set the split. Use Cut Thickness to widen the cut band (the physical black separator).": "Dra det hvite rektangelet for å beskjære. Dra den oransje linjen for å angi delingen. Bruk Snittykkelse for å gjøre snittbåndet bredere (den fysiske svarte skillelisten).", + "Drag to float this panel": "Dra for å gjøre dette panelet flytende", + "Drag to reorder. A favorite is the same control as in its own card.": "Dra for å endre rekkefølgen. En favoritt er samme kontroll som i sitt eget kort.", + "Drag to reorder. What does not fit the toolbar hides, geometry first and navigation last; the ⋯ menu holds every action.": "Dra for å endre rekkefølgen. Det som ikke får plass på verktøylinjen, skjules, geometri først og navigering sist; ⋯-menyen inneholder alle handlinger.", + "Draw": "Tegn", + "Draw Lines": "Tegn linjer", + "Draw Mask": "Tegn maske", + "Draw Mask: click to place points, then double-click or press Enter to close the shape.": "Tegn maske: klikk for å plassere punkter, og dobbeltklikk eller trykk Enter for å lukke formen.", + "Draw Region": "Tegn område", + "Draw a line along a horizon or an edge to level the frame": "Tegn en linje langs en horisont eller en kant for å sette bildet i vater", + "Draw a line along a horizon or edge to level the frame": "Tegn en linje langs en horisont eller kant for å sette bildet i vater", + "Draw a line along each edge of the film, and Tilt and Swing make them square.": "Tegn en linje langs hver kant av filmen, så retter Tilt og Sving dem opp.", + "Draw a mask on the picture with any of the three tools, then drag its Burn or Grade.": "Tegn en maske på bildet med et av de tre verktøyene, og dra så Etterbelysning eller Gradering for den.", + "Draw a region on the picture for NegPy to measure, then double-click inside it to confirm.": "Tegn et område på bildet som NegPy skal måle, og dobbeltklikk innenfor det for å bekrefte.", + "Draw the crop on the picture.": "Tegn beskjæringen på bildet.", + "Draw: drag a crop on the picture.": "Tegn: dra en beskjæring på bildet.", + "Drift": "Avdrift", + "Drop this roll's own edit for this frame?": "Vil du fjerne denne rullens egen redigering for dette bildet?", + "Duplicate": "Dupliser", + "Duplicate Shortcut": "Duplisert hurtigtast", + "Duplicate preset": "Dupliser forhåndsinnstilling", + "Dust and Scratches": "Støv og riper", + "Dye Separation": "Fargestoffseparasjon", + "Dye Separation Trim": "Justering av fargestoffseparasjon", + "Dye Separation down": "Fargestoffseparasjon ned", + "Dye Separation of this color layer only.": "Fargestoffseparasjon bare for dette fargelaget.", + "Dye Separation up": "Fargestoffseparasjon opp", + "Dye Separation ↑/↓": "Fargestoffseparasjon ↑/↓", + "EDIT SIDECARS": "REDIGERINGSSIDEFILER", + "EMBEDDED": "INNEBYGD", + "EXIF camera/lens": "EXIF kamera/objektiv", + "EXIF capture tags": "EXIF-opptaksmerker", + "EXPORT SIZE": "EKSPORTSTØRRELSE", + "EXPORT SIZE · RENDERING": "EKSPORTSTØRRELSE · GJENGIVELSE", + "Each Roll tab card has a Frame / Roll pair. It follows the roll until you change it on one frame. Roll gives the roll this frame's value. A Frame tab card copies with Apply to Frames.": "Hvert kort på fanen Rull har et Bilde / Rull-par. Det følger rullen til du endrer det på ett bilde. Rull gir rullen dette bildets verdi. Et kort på fanen Bilde kopieres med Bruk på bilder.", + "Each Trichrome exposure holds one color only, so camera white balance has nothing to correct.": "Hver Trichrome-eksponering inneholder bare én farge, så kameraets hvitbalanse har ingenting å korrigere.", + "Each channel's density takes in a share of the other two. A negative value removes that channel's leak. Every channel keeps its own density, because each row is normalized to sum to 1. Strength on the Calibration card applies the matrix.": "Tettheten i hver kanal tar inn en andel av de to andre. En negativ verdi fjerner lekkasjen i den kanalen. Hver kanal beholder sin egen tetthet, fordi hver rad normaliseres til summen 1. Styrke på kortet Kalibrering bruker matrisen.", + "Each frame becomes a linear TIFF negative beside its primary source and keeps its edit. The demosaic is baked in.": "Hvert bilde blir et lineært TIFF-negativ ved siden av primærkilden og beholder redigeringen sin. Demosaikken bakes inn.", + "Each frame is metered on its own. Meter a mid-strip frame to lock the roll.": "Hvert bilde lysmåles for seg. Mål et bilde midt på stripen for å låse rullen.", + "Each frame's reset is a normal undo step, so you can revert it after.": "Tilbakestillingen av hvert bilde er et vanlig angretrinn, så du kan gjøre den om etterpå.", + "Each of these frames follows its own roll's Half Frame. Open the roll to change it.": "Hvert av disse bildene følger halvformatet til sin egen rull. Åpne rullen for å endre det.", + "Easel swing down": "Sving for forstørrelsesramme ned", + "Easel swing up": "Sving for forstørrelsesramme opp", + "Easel tilt down": "Tilt for forstørrelsesramme ned", + "Easel tilt up": "Tilt for forstørrelsesramme opp", + "Edge Print": "Kantpåskrift", + "Edge softness for the selected mask": "Kantmykhet for den valgte masken", + "Edit": "Rediger", + "Edit Favorites": "Rediger favoritter", + "Edit History": "Redigeringshistorikk", + "Edit Independently in This Roll": "Rediger uavhengig i denne rullen", + "Edit Metadata Preset": "Rediger metadataforhåndsinnstilling", + "Edit Preset": "Rediger forhåndsinnstilling", + "Edit Preset…": "Rediger forhåndsinnstilling…", + "Edit Toolbar": "Rediger verktøylinje", + "Edit Toolbar…": "Rediger verktøylinje…", + "Edit Trichrome": "Rediger Trichrome", + "Edit Trichrome does not work on a stitch, an HDR merge, a diptych or a half frame": "Rediger Trichrome virker ikke på en sammensying, en HDR-sammenslåing, et diptyk eller et halvformat", + "Edit Trichrome…": "Rediger Trichrome…", + "Edit Trichrome…: pick this frame's three exposures by hand.": "Rediger Trichrome…: velg de tre eksponeringene for dette bildet selv.", + "Edit the folder names library discovery skips": "Rediger mappenavnene som biblioteksøket hopper over", + "Edit the selected preset: rename it or change which settings it stores": "Rediger den valgte forhåndsinnstillingen: gi den nytt navn eller endre hvilke innstillinger den lagrer", + "Edit the toolbar (which controls it shows, in what order)": "Rediger verktøylinjen (hvilke kontroller den viser, og i hvilken rekkefølge)", + "Effects": "Effekter", + "Effort": "Innsats", + "Eject Now": "Mat ut nå", + "Eject When Done": "Mat ut når ferdig", + "Eject failed: {msg}": "Utmatingen mislyktes: {msg}", + "Eject now, or eject the strip when a batch is done": "Mat ut nå, eller mat ut stripen når en satsvis kjøring er ferdig", + "Eject the film (Lento Dock)": "Mat ut filmen (Lento Dock)", + "Eject: move the strip out of the dock.": "Mat ut: flytt stripen ut av holderen.", + "Ejecting film…": "Mater ut filmen…", + "Ejecting the film…": "Mater ut filmen…", + "Embedded": "Innebygd", + "Embedded CA": "Innebygd CA", + "Embedded Distortion": "Innebygd forvrengning", + "Embedded lens correction is unavailable for RGB+IR sources.": "Innebygd objektivkorreksjon er ikke tilgjengelig for RGB+IR-kilder.", + "Embedded lens correction is unavailable for composites.": "Innebygd objektivkorreksjon er ikke tilgjengelig for sammensatte bilder.", + "Empty camera, lens and film stock": "Tøm kamera, objektiv og filmtype", + "Empty the saved process and everything it fills; Format stays": "Tøm den lagrede prosessen og alt den fyller ut; Format blir stående", + "Empty the saved setup and the scanning note; Roll and Frame stay": "Tøm det lagrede oppsettet og skannenotatet; Rull og Bilde blir stående", + "Enable at least one export preset in the Export panel.": "Slå på minst én eksportforhåndsinnstilling i eksportpanelet.", + "Enabled": "Aktivert", + "Encode without loss, and use Quality to set the compression effort.": "Kod uten tap, og bruk Kvalitet til å angi komprimeringsinnsatsen.", + "Encode without loss, or turn this off to set the loss with Distance.": "Kod uten tap, eller slå dette av for å angi tapet med Avstand.", + "Encoder effort: higher = slower, smaller file": "Kodingsinnsats: høyere = tregere, mindre fil", + "Encoding": "Koding", + "Encoding: how the file's samples encode light.": "Koding: hvordan filens verdier koder lys.", + "End of the strip. Insert the next strip.": "Slutten av stripen. Sett inn neste stripe.", + "Enter a film-stock name first": "Skriv inn et filmtypenavn først", + "Error: {msg}": "Feil: {msg}", + "Error: {text}": "Feil: {text}", + "Estimate": "Beregn", + "Estimate Fade Restoration from the grays in this frame": "Beregn fargegjenoppretting ut fra de grå tonene i dette bildet", + "Estimated: red {r}, green {g}, blue {b}.": "Beregnet: rød {r}, grønn {g}, blå {b}.", + "Even the fastest shutter with the LEDs at their minimum still clips the film base.": "Selv den raskeste lukkeren med LED-ene på minimum klipper fortsatt filmbasen.", + "Even the slowest shutter with the LEDs at their maximum stays below the exposure target.": "Selv den langsomste lukkeren med LED-ene på maksimum holder seg under eksponeringsmålet.", + "Every change is a step in History; click one to go back. Work prints ({value}) are versions you keep under a name.": "Hver endring er et trinn i Historikk; klikk ett for å gå tilbake. Arbeidskopier ({value}) er versjoner du beholder under et navn.", + "Every choice is lossless, and ZIP usually makes the smallest file.": "Hvert valg er tapsfritt, og ZIP gir vanligvis den minste filen.", + "Every frame using it loses its correction, and the profile cannot be recovered.": "Hvert bilde som bruker den, mister korreksjonen sin, og profilen kan ikke gjenopprettes.", + "Every loaded frame is read and thumbnailed again. Your saved edits stay.": "Hvert innlastet bilde leses og får miniatyrbilde på nytt. De lagrede redigeringene dine blir stående.", + "Every slider, card and action is one search away. Type what you want in your own words, and NegPy opens the control.": "Hver glidebryter, hvert kort og hver handling er ett søk unna. Skriv det du vil med dine egne ord, så åpner NegPy kontrollen.", + "Exclude from Optical Removal": "Utelat fra optisk fjerning", + "Expand every card on this tab": "Utvid hvert kort på denne fanen", + "Expand or collapse this tab's cards": "Utvid eller fold sammen kortene på denne fanen", + "Expansion": "Utvidelse", + "Export": "Eksporter", + "Export Current Frame": "Eksporter gjeldende bilde", + "Export Frame": "Eksporter bilde", + "Export Linear Output": "Eksporter lineært resultat", + "Export Notes": "Eksporter notater", + "Export Presets": "Eksportforhåndsinnstillinger", + "Export Presets (Roll)": "Eksportforhåndsinnstillinger (rull)", + "Export Presets (Selected)": "Eksportforhåndsinnstillinger (valgte)", + "Export Profile": "Eksportprofil", + "Export Roll": "Eksporter rull", + "Export Selected": "Eksporter valgte", + "Export Selected Frames": "Eksporter valgte bilder", + "Export Sidecars": "Eksporter sidefiler", + "Export Size": "Eksportstørrelse", + "Export This Version…": "Eksporter denne versjonen…", + "Export complete in {value:.2f}s": "Eksport fullført på {value:.2f} s", + "Export complete in {value:.2f}s, {failed} failed": "Eksport fullført på {value:.2f} s, {failed} mislyktes", + "Export every visible frame using the settings below": "Eksporter hvert synlig bilde med innstillingene nedenfor", + "Export every visible frame with every enabled preset": "Eksporter hvert synlig bilde med hver aktiverte forhåndsinnstilling", + "Export failed for {full_name}: {e}": "Eksporten mislyktes for {full_name}: {e}", + "Export folder": "Eksportmappe", + "Export in smaller pieces, which is slower but stops export crashes on a GPU with little memory.": "Eksporter i mindre deler, noe som er tregere, men hindrer at eksporten krasjer på en GPU med lite minne.", + "Export presets": "Eksportforhåndsinnstillinger", + "Export profile": "Eksportprofil", + "Export skipped for {value}: it would overwrite the source file. Change the filename pattern or destination.": "Eksport hoppet over for {value}: den ville overskrevet kildefilen. Endre filnavnmønsteret eller målet.", + "Export tab": "Eksportfanen", + "Export the current frame with every enabled preset": "Eksporter gjeldende bilde med hver aktiverte forhåndsinnstilling", + "Export the current frame with the settings below": "Eksporter gjeldende bilde med innstillingene nedenfor", + "Export the picture as you see it, a flat master for editing in another app, or the raw scan data.": "Eksporter bildet slik du ser det, en flat master for redigering i en annen app, eller rådataene fra skanningen.", + "Export the selected filmstrip frames using the settings below": "Eksporter de valgte bildene i filmstripen med innstillingene nedenfor", + "Export the selected filmstrip frames with every enabled preset": "Eksporter de valgte bildene i filmstripen med hver aktiverte forhåndsinnstilling", + "Export the selected frames with the Export tab's settings, skipping rejected frames": "Eksporter de valgte bildene med innstillingene på eksportfanen, og hopp over forkastede bilder", + "Export with this preset": "Eksporter med denne forhåndsinnstillingen", + "Export {value} px": "Eksporter {value} px", + "Export {value} through {value1} ({value2})?": "Vil du eksportere {value} via {value1} ({value2})?", + "Export {value}?": "Vil du eksportere {value}?", + "Exporting": "Eksporterer", + "Exporting Linear Output": "Eksporterer lineært resultat", + "Exports a flat 16-bit master at full size. Pick Print or Pixels below to make it smaller.": "Eksporterer en flat 16-biters master i full størrelse. Velg Kopi eller Piksler nedenfor for å gjøre den mindre.", + "Exports the raw scan data as a 16-bit file, before NegPy's processing and color management.": "Eksporterer rådataene fra skanningen som en 16-biters fil, før NegPys behandling og fargestyring.", + "Exposure": "Eksponering", + "Exposure Lock": "Eksponeringslås", + "Exposure Scale": "Eksponeringsskala", + "Exposure Scale ↑/↓": "Eksponeringsskala ↑/↓", + "Exposure lock": "Eksponeringslås", + "Exposure locked from frame {frame}": "Eksponering låst fra bilde {frame}", + "Exposure scale longer": "Lengre eksponeringsskala", + "Exposure scale shorter": "Kortere eksponeringsskala", + "Exposure tab": "Eksponeringsfanen", + "Exposure: Filtration, Tone, Paper Response, Dodge & Burn": "Eksponering: filtrering, tone, papirrespons, skygging og etterbelysning", + "Extra exposure: more gives warmer, more colorful highlights and softer tones.": "Ekstra eksponering: mer gir varmere, mer fargerike høylys og mykere toner.", + "FAVORITES": "FAVORITTER", + "FILED CARRIER": "FILET NEGATIVHOLDER", + "FLAT FIELD": "FLATFELT", + "FLAT FIELD CORRECTION": "FLATFELTKORREKSJON", + "FLAT SCAN": "FLAT SKANNING", + "FORMAT": "FORMAT", + "FOUND": "FUNNET", + "Fade Restoration": "Fargegjenoppretting", + "Fade Restoration: brings back the colors and contrast of a slide whose dyes have faded with age.": "Fargegjenoppretting: henter tilbake fargene og kontrasten i et dias der fargestoffene har falmet med alderen.", + "Failed to load file: {message}": "Kunne ikke laste inn filen: {message}", + "Far from the roll color": "Langt fra rullens farge", + "Far from the scene color": "Langt fra scenens farge", + "Favorites": "Favoritter", + "Favorites tab": "Favorittfanen", + "Feather": "Mykning", + "Fetch the model once; it is cached for every later search": "Hent modellen én gang; den bufres for alle senere søk", + "File": "Fil", + "File Already Exists": "Filen finnes allerede", + "File name pattern, such as {{ original_name }}_{{ frame_padded }}, with fields like {{ roll }}, {{ film }} and {{ date }}.": "Filnavnmønster, for eksempel {{ original_name }}_{{ frame_padded }}, med felt som {{ roll }}, {{ film }} og {{ date }}.", + "File name, scan size and film mode": "Filnavn, skannestørrelse og filmmodus", + "Filed Carrier: a rough black frame around the picture, as from a filed negative carrier. 0 is off.": "Filet negativholder: en grov svart ramme rundt bildet, som fra en filet negativholder. 0 er av.", + "Filename": "Filnavn", + "Filename Pattern": "Filnavnmønster", + "Filename Pattern…": "Filnavnmønster…", + "Filename pattern must include the sequence number ({{ seq }})": "Filnavnmønsteret må inneholde sekvensnummeret ({{ seq }})", + "Files Already Exist": "Filene finnes allerede", + "Fill camera, film stock, ISO and capture date from this roll's folder name": "Fyll ut kamera, filmtype, ISO og opptaksdato fra mappenavnet til denne rullen", + "Fill these fields from the preset, and turn on the groups it stores": "Fyll ut disse feltene fra forhåndsinnstillingen, og slå på gruppene den lagrer", + "Film": "Film", + "Film & Quality": "Film og kvalitet", + "Film Base": "Filmbase", + "Film Base: click the clear film between frames to set the base these readouts measure from, for the open roll or this frame.": "Filmbase: klikk den klare filmen mellom bildene for å angi basen disse avlesningene måler fra, for den åpne rullen eller dette bildet.", + "Film Base: click the clear rebate once the frame has rendered": "Filmbase: klikk den klare filmkanten når bildet er gjengitt", + "Film Base: that spot is not flat film; click the clear rebate": "Filmbase: det punktet er ikke flat film; klikk den klare filmkanten", + "Film Base: this frame's roll is not in the library": "Filmbase: rullen til dette bildet er ikke i biblioteket", + "Film Edges": "Filmkanter", + "Film ISO": "Film-ISO", + "Film Mode": "Filmmodus", + "Film Scanner": "Filmskanner", + "Film Stock": "Filmtype", + "Film Stocks": "Filmtyper", + "Film Strip": "Filmstripe", + "Film Strip Guide…": "Veiledning for filmstripen…", + "Film base set for the roll": "Filmbase angitt for rullen", + "Film base set for this frame": "Filmbase angitt for dette bildet", + "Film edge": "Filmkant", + "Film ejected": "Filmen er matet ut", + "Film ejected. Frame selection cleared.": "Filmen er matet ut. Bildevalget er fjernet.", + "Film format": "Filmformat", + "Film format written to the frame's metadata": "Filmformat skrevet til bildets metadata", + "Film format, read from the frames' Format metadata and the roll's Half Frame mode": "Filmformat, lest fra bildenes Format-metadata og rullens halvformatmodus", + "Film loaded": "Film lastet inn", + "Film manufacturer": "Filmprodusent", + "Film process": "Filmprosess", + "Film removed": "Filmen er fjernet", + "Film removed. Frame selection cleared.": "Filmen er fjernet. Bildevalget er fjernet.", + "Film stock": "Filmtype", + "Film stock name:": "Navn på filmtype:", + "Film type": "Filmart", + "Film: Next Frame (Lento Dock)": "Film: neste bilde (Lento Dock)", + "Film: Off (Lento Dock)": "Film: av (Lento Dock)", + "Film: Whole Strip (Lento Dock)": "Film: hele stripen (Lento Dock)", + "Filter": "Filter", + "Filter rolls…": "Filtrer ruller…", + "Filter the frames: a word matches the file name, and terms such as film:portra iso:>=400 -rejected: match frame data.": "Filtrer bildene: et ord samsvarer med filnavnet, og uttrykk som film:portra iso:>=400 -rejected: samsvarer med bildedata.", + "Filter the list below by name": "Filtrer listen nedenfor etter navn", + "Filter: a colored filter, as on the camera lens. It darkens the colors opposite to its own.": "Filter: et farget filter, som på kameraobjektivet. Det gjør fargene motsatt av sin egen mørkere.", + "Filter: name, film:portra, iso:>=400…": "Filter: navn, film:portra, iso:>=400…", + "Filtration": "Filtrering", + "Find Anything": "Finn hva som helst", + "Find Control or Action…": "Finn kontroll eller handling…", + "Find a control, card or action": "Finn en kontroll, et kort eller en handling", + "Find a control, card or action…": "Finn en kontroll, et kort eller en handling…", + "Find any slider, card or action by name, and open it": "Finn en hvilken som helst glidebryter, et kort eller en handling etter navn, og åpne den", + "Find new rolls in imported folders, and recount the frames of each roll.": "Finn nye ruller i importerte mapper, og tell bildene i hver rull på nytt.", + "Find the crop and the split again on every loaded scan.": "Finn beskjæringen og delingen på nytt i hver innlastet skanning.", + "Find the film edges and the gap between the two frames.": "Finn filmkantene og mellomrommet mellom de to bildene.", + "Find the frames on the strip and preview each one.": "Finn bildene på stripen og forhåndsvis hvert av dem.", + "Finding the frames on the strip…": "Finner bildene på stripen…", + "Fine Rotation": "Finrotasjon", + "Fine Rotation {value:+.2f}°": "Finrotasjon {value:+.2f}°", + "Fine Rotation ↑/↓": "Finrotasjon ↑/↓", + "Fine rotation clockwise": "Finrotasjon med klokken", + "Fine rotation counter-clockwise": "Finrotasjon mot klokken", + "Finish": "Etterarbeid", + "Finish Pick Better": "Fullfør Velg bedre", + "Finish Pick Better and close the window": "Fullfør Velg bedre og lukk vinduet", + "Finish tab": "Etterarbeidsfanen", + "Finish the line or shape in progress and leave the tool, keeping its changes": "Fullfør linjen eller formen som pågår, og forlat verktøyet med endringene beholdt", + "Finish: Retouch, Finishing": "Etterarbeid: retusj, etterbehandling", + "Finishing": "Etterbehandling", + "Finishing as a lab scanner does it. Chroma sets color strength, Sharpening sharpens detail, and Glow and Halation add a soft or a red glow around bright areas.": "Etterbehandling slik en laboratorieskanner gjør det. Kroma angir fargestyrken, Skarphet gjør detaljer skarpere, og Glød og Halasjon legger til en myk eller en rød glød rundt lyse områder.", + "First file": "Første fil", + "First pick the paper, as a darkroom printer does. Each real paper has its own contrast and colors. Digital (Adobe RGB), the default, is an ideal paper.": "Velg først papiret, slik en mørkeromskopist gjør. Hvert ekte papir har sin egen kontrast og sine egne farger. Digital (Adobe RGB), standarden, er et ideelt papir.", + "Fit the matrix to the marked chart and save it as a Measured profile.": "Tilpass matrisen til det merkede kartet og lagre den som en målt profil.", + "Fit to Window": "Tilpass til vinduet", + "Fit to window": "Tilpass til vinduet", + "Fix Auto Density and Auto Grade of each exported frame, so they do not change later.": "Fastsett autotetthet og autogradering for hvert eksporterte bilde, så de ikke endres senere.", + "Fixed Multi-Exposure": "Fast multieksponering", + "Flare": "Lysspredning", + "Flash": "Forbelysning", + "Flat": "Flat", + "Flat Field": "Flatfelt", + "Flat Field '{name}' was saved before the check: showing the light it corrects, darker where there is less light. Save the profile again to check it": "Flatfeltet «{name}» ble lagret før kontrollen: viser lyset det korrigerer, mørkere der det er mindre lys. Lagre profilen på nytt for å kontrollere den", + "Flat Field Profile": "Flatfeltprofil", + "Flat Field check: how evenly the Flat Field profile from Optics corrects the light": "Flatfeltkontroll: hvor jevnt flatfeltprofilen fra Optikk korrigerer lyset", + "Flat Field profile '{name}' saved, but it corrects its own reference only to ±{spread:.0%}: look for a carrier edge, dust or a hot spot in the reference": "Flatfeltprofilen «{name}» er lagret, men den korrigerer sin egen referanse bare til ±{spread:.0%}: se etter en kant fra negativholderen, støv eller et lyst punkt i referansen", + "Flat Field profile '{name}' saved, but the reference is clipped: shoot it darker to correct the falloff": "Flatfeltprofilen «{name}» er lagret, men referansen er klippet: ta den mørkere for å korrigere lysfallet", + "Flat Field profile '{name}' saved, even to ±{spread:.1%}": "Flatfeltprofilen «{name}» er lagret, jevn til ±{spread:.1%}", + "Flat Field: could not read that reference image": "Flatfelt: kunne ikke lese det referansebildet", + "Flat Field: {error}": "Flatfelt: {error}", + "Flat Master": "Flat master", + "Flat Master Preset": "Forhåndsinnstilling for flat master", + "Flat Scan": "Flat skanning", + "Flat: a flat master for editing in another app (16-bit TIFF or lossless JPEG XL).": "Flat: en flat master for redigering i en annen app (16-biters TIFF eller tapsfri JPEG XL).", + "Flip Horizontal": "Speilvend vannrett", + "Flip Vertical": "Speilvend loddrett", + "Flip horizontal": "Speilvend vannrett", + "Flip the picture left to right.": "Speilvend bildet fra venstre til høyre.", + "Flip the picture top to bottom.": "Speilvend bildet fra topp til bunn.", + "Flip vertical": "Speilvend loddrett", + "Flipped {total} {frame_word}": "Speilvendte {total} {frame_word}", + "Focal length": "Brennvidde", + "Focus meter: at peak": "Fokusmåler: på topp", + "Focus meter: no reading": "Fokusmåler: ingen avlesning", + "Focus meter: {value}% of peak": "Fokusmåler: {value}% av toppen", + "Focus the Film Strip search box": "Fokuser søkefeltet i filmstripen", + "Folder": "Mappe", + "Folder Roll": "Mapperull", + "Folder as Roll": "Mappe som rull", + "Folder is no longer on disk": "Mappen finnes ikke lenger på disken", + "Folder not found on disk: {path}": "Fant ikke mappen på disken: {path}", + "Folder roll: {path}": "Mapperull: {path}", + "For consistent masters across a roll, lock one baseline for every frame.": "For ensartede mastere over en rull, lås ett grunnlag for hvert bilde.", + "Forget every group: their frames stay with the roll baseline.": "Glem hver gruppe: bildene blir værende med rullens grunnlag.", + "Forget every roll in your library?\n\nOnly the roll records are cleared. The folders, your images and their edits stay as they are.": "Vil du glemme hver rull i biblioteket ditt?\n\nBare rullpostene fjernes. Mappene, bildene dine og redigeringene deres blir som de er.", + "Forget the rolls in your library, but keep the folders and their files": "Glem rullene i biblioteket, men behold mappene og filene deres", + "Format": "Format", + "Found {value}": "Fant {value}", + "Found {value}, removed {value1}": "Fant {value}, fjernet {value1}", + "Frame": "Bilde", + "Frame Assembly": "Bildesammensetning", + "Frame Counter": "Bildeteller", + "Frame Info": "Bildeinfo", + "Frame Info: {value}": "Bildeinfo: {value}", + "Frame info on the canvas: off, basic or full": "Bildeinfo på lerretet: av, grunnleggende eller full", + "Frame length on the loaded film; Auto where the holder fixes it": "Bildelengde på den innlastede filmen; Auto der holderen bestemmer den", + "Frame or Roll": "Bilde eller rull", + "Frame size": "Bildestørrelse", + "Frame {frame} at {value:+.1f} mm: corrects this frame alone, on top of Offset and Drift.": "Bilde {frame} ved {value:+.1f} mm: korrigerer bare dette bildet, i tillegg til Forskyvning og Avdrift.", + "Frame {frame} clipped on {share:.0%} of the picture. Lower the exposure, then click Retake.": "Bilde {frame} er klippet på {share:.0%} av bildet. Senk eksponeringen, og klikk så Ta på nytt.", + "Frame {slot} failed. Continuing…": "Bilde {slot} mislyktes. Fortsetter…", + "Frames": "Bilder", + "Frames per Row": "Bilder per rad", + "Frames to scan, such as 1-6 or 1,2,5, or empty for every frame.": "Bilder som skal skannes, for eksempel 1-6 eller 1,2,5, eller tomt for alle bilder.", + "Frames without a saved edit: {unopened} of {value}. They export with the current settings and may not match their thumbnails. Export anyway?": "Bilder uten lagret redigering: {unopened} av {value}. De eksporteres med de gjeldende innstillingene og samsvarer kanskje ikke med miniatyrbildene. Vil du eksportere likevel?", + "Frames: cannot read that": "Bilder: kan ikke lese det", + "Frames: the holder has {value}": "Bilder: holderen har {value}", + "Framing": "Innramming", + "Free": "Fri", + "Freeze Auto Density and Auto Grade into fixed values for every visible frame now": "Frys autotetthet og autogradering til faste verdier for hvert synlig bilde nå", + "From \"{source_name}\"": "Fra \"{source_name}\"", + "From spec sheets (approx)": "Fra datablader (omtrentlig)", + "From this frame": "Fra dette bildet", + "From {value}": "Fra {value}", + "Froze Auto Density/Grade for {value}": "Frøs autotetthet/-gradering for {value}", + "Full": "Full", + "Full frame": "Fullt bilde", + "Full frame. Click the image to magnify.": "Fullt bilde. Klikk på bildet for å forstørre.", + "Full frame{offset_txt}": "Fullt bilde{offset_txt}", + "Full-size previews kept in memory. Each uses hundreds of MB; keeping the last one makes going back instant.": "Forhåndsvisninger i full størrelse som holdes i minnet. Hver bruker flere hundre MB; å beholde den siste gjør det umiddelbart å gå tilbake.", + "GPU Acceleration": "GPU-akselerasjon", + "GPU acceleration failed. Using the CPU.": "GPU-akselerasjonen mislyktes. Bruker CPU-en.", + "GPU texture cap": "Teksturgrense for GPU", + "GPU viewport failed to start ({gpu_viewport_failed}); the display runs on the CPU.": "GPU-visningen kunne ikke starte ({gpu_viewport_failed}); skjermen kjører på CPU-en.", + "GPU viewport failed to start, so the display runs on the CPU ({gpu_viewport_failed})": "GPU-visningen kunne ikke starte, så skjermen kjører på CPU-en ({gpu_viewport_failed})", + "Gamut": "Fargeomfang", + "Gamut Warning": "Advarsel om fargeomfang", + "Geometry": "Geometri", + "Geometry tab": "Geometrifanen", + "Give other frames this frame's metering bounds, and nothing else": "Gi andre bilder dette bildets lysmålingsgrenser, og ingenting annet", + "Give the roll every {title} card this frame changed": "Gi rullen hvert {title}-kort dette bildet har endret", + "Give the roll this frame's {title}…": "Gi rullen dette bildets {title}…", + "Global": "Global", + "Global changes all colors, and R, G or B changes one color layer.": "Global endrer alle farger, og R, G eller B endrer ett fargelag.", + "Global changes all colors, and R, G or B changes one color, for a cast that differs in shadows and highlights.": "Global endrer alle farger, og R, G eller B endrer én farge, for et fargestikk som er ulikt i skygger og høylys.", + "Glow": "Glød", + "Glow down": "Glød ned", + "Glow up": "Glød opp", + "Glow ↑/↓": "Glød ↑/↓", + "Go back to the whole scan, split in the middle with no cut band.": "Gå tilbake til hele skanningen, delt på midten uten snittbånd.", + "Go to the next chapter of the tour, or to its last step": "Gå til neste kapittel i omvisningen, eller til det siste trinnet", + "Gold": "Gull", + "Gold toner, B&W negatives only: blue-black tones, or orange-red highlights over sepia.": "Gulltoner, bare S/H-negativer: blåsvarte toner, eller oransjerøde høylys over sepia.", + "Golden Spiral": "Gyllen spiral", + "Golden Triangles": "Gylne trekanter", + "Grade": "Gradering", + "Grade Trim": "Justering av gradering", + "Grade down": "Gradering ned", + "Grade needs two different tones, but the outer pins read the same tone.": "Gradering krever to ulike toner, men de ytre nålene leser samme tone.", + "Grade up": "Gradering opp", + "Grain Focuser": "Kornfokuserer", + "Grain focuser loupe": "Kornfokuserer-lupe", + "Grain focuser: shows the exported pixels at the pointer at their true size, with a sharpness number to compare areas.": "Kornfokuserer: viser de eksporterte pikslene ved pekeren i sann størrelse, med et skarphetstall for å sammenligne områder.", + "Gray Range": "Gråområde", + "Gray Range: from how far the grays reach into the shadows.": "Gråområde: fra hvor langt de grå tonene når inn i skyggene.", + "Grayscale": "Gråtoner", + "Green": "Grønn", + "Green #58": "Grønn #58", + "Green LED level": "Nivå for grønn LED", + "Green Survival": "Grønn overlevelse", + "Green read {raw}, outside {lo} to {hi}, so it is set to {clamped}.": "Grønn leste {raw}, utenfor {lo} til {hi}, så den settes til {clamped}.", + "Grid": "Rutenett", + "Group as Scene": "Grupper som scene", + "Group as Scene…": "Grupper som scene…", + "Guide": "Veiledning", + "HDR": "HDR", + "HDR merge": "HDR-sammenslåing", + "HDR merge failed: {e}": "HDR-sammenslåingen mislyktes: {e}", + "HDR merge of a Trichrome triplet is not supported": "HDR-sammenslåing av en Trichrome-triplett støttes ikke", + "HDR merge of a half-frame asset is not supported": "HDR-sammenslåing av et halvformatbilde støttes ikke", + "HDR merge of a stitched frame is not supported": "HDR-sammenslåing av et sammensydd bilde støttes ikke", + "HDR merge of {value}": "HDR-sammenslåing av {value}", + "HIGHLIGHTS": "HØYLYS", + "HQ Preview": "HQ-forhåndsvisning", + "HQ buffers": "HQ-buffere", + "Hair Threshold": "Hårterskel", + "Hair Threshold ↑/↓": "Hårterskel ↑/↓", + "Hair threshold down": "Hårterskel ned", + "Hair threshold up": "Hårterskel opp", + "Halation": "Halasjon", + "Halation down": "Halasjon ned", + "Halation up": "Halasjon opp", + "Halation ↑/↓": "Halasjon ↑/↓", + "Half Frame": "Halvformat", + "Half Frame Mode": "Halvformatmodus", + "Half Frame Split and Crop": "Deling og beskjæring av halvformat", + "Half-frame split ({value} of 2)": "Halvformatdeling ({value} av 2)", + "Hand-set recipe (unsaved)": "Manuelt angitt oppskrift (ikke lagret)", + "Hashing files": "Beregner hash for filer", + "Hashing {current}/{total}: {name}": "Beregner hash {current}/{total}: {name}", + "Hatch the clipped pixels in the Clipping Overlay colors": "Skraver de klippede pikslene i fargene til klippeoverlegget", + "Heal": "Lege", + "Heal: click or paint over dust to remove it, and clean film under the brush stays as it is.": "Lege: klikk eller mal over støv for å fjerne det, og ren film under penselen blir som den er.", + "Height": "Høyde", + "Help": "Hjelp", + "Hide Rejected": "Skjul forkastede", + "Hide the controls panel and collapse the Film Strip, or bring both back": "Skjul kontrollpanelet og fold sammen filmstripen, eller hent begge tilbake", + "Hide {names}": "Skjul {names}", + "Hides the Raw Decode and Optics cards.": "Skjuler kortene RAW-dekoding og Optikk.", + "Highlight Hold": "Hold høylys", + "Highlight Hue": "Høylystone", + "Highlight Recovery": "Høylysgjenoppretting", + "Highlight Strength": "Høylysstyrke", + "Highlight hue down": "Høylystone ned", + "Highlight hue up": "Høylystone opp", + "Highlight hue ↑/↓": "Høylystone ↑/↓", + "Highlight strength down": "Høylysstyrke ned", + "Highlight strength up": "Høylysstyrke opp", + "Highlight strength ↑/↓": "Høylysstyrke ↑/↓", + "Highlights": "Høylys", + "Highlights CMY": "Høylys CMY", + "Highlights Density": "Høylystetthet", + "Highlights Density ↑/↓": "Høylystetthet ↑/↓", + "Highlights Grade": "Høylysgradering", + "Highlights Grade Trim": "Justering av høylysgradering", + "Highlights Grade ↑/↓": "Høylysgradering ↑/↓", + "Highlights density down": "Høylystetthet ned", + "Highlights density up": "Høylystetthet opp", + "Highlights grade down": "Høylysgradering ned", + "Highlights grade up": "Høylysgradering opp", + "History": "Historikk", + "History and Work Prints": "Historikk og arbeidskopier", + "History tab": "Historikkfanen", + "Hold Black": "Hold svart", + "Hold Black: when you lighten the shadows, the deepest blacks stay black.": "Hold svart: når du gjør skyggene lysere, forblir de dypeste svarttonene svarte.", + "Horizontal": "Vannrett", + "Hot Folder": "Aktiv mappe", + "Hot Folder is on and waits for a frame to load.": "Aktiv mappe er på og venter på at et bilde skal lastes inn.", + "Hot Folder is on, loading new images from {path}: click to stop.": "Aktiv mappe er på og laster inn nye bilder fra {path}: klikk for å stoppe.", + "Hot Folder: load new images from the current folder as they appear": "Aktiv mappe: last inn nye bilder fra den gjeldende mappen etter hvert som de dukker opp", + "How Estimate reads the grays: Midtone suits a badly faded slide, Gray Range a mild fade with deep shadows.": "Hvordan Beregn leser de grå tonene: Mellomtone passer et sterkt falmet dias, Gråområde en mild falming med dype skygger.", + "How bright Auto Density makes each frame, and how closely it follows the meter.": "Hvor lyst autotetthet gjør hvert bilde, og hvor nøye den følger måleren.", + "How closely Auto Density follows the meter, so a higher value prints dark and bright scenes more alike.": "Hvor nøye autotetthet følger måleren, så en høyere verdi kopierer mørke og lyse motiver likere.", + "How contrasty the negative is: its estimated density range (Δ), read as flat, normal or contrasty.": "Hvor kontrastrikt negativet er: det beregnede tetthetsomfanget (Δ), lest som flatt, normalt eller kontrastrikt.", + "How dark the darkest detail must print, so a flat negative still gets a black.": "Hvor mørk den mørkeste detaljen må kopieres, så et flatt negativ likevel får svart.", + "How easily dust specks are found: lower finds more but can remove real detail, and 1 is off.": "Hvor lett støvkorn blir funnet: lavere finner flere, men kan fjerne ekte detaljer, og 1 er av.", + "How easily hairs are found: lower finds more, and 1 is off.": "Hvor lett hår blir funnet: lavere finner flere, og 1 er av.", + "How far inside the detected edge the crop lands": "Hvor langt innenfor den oppdagede kanten beskjæringen havner", + "How far the Shoulder change reaches into the midtones.": "Hvor langt endringen av Skulder når inn i mellomtonene.", + "How far the Toe change reaches into the midtones.": "Hvor langt endringen av Tå når inn i mellomtonene.", + "How far the crop cuts into the film border: 0% at the film edge, 100% at the picture edge.": "Hvor langt beskjæringen skjærer inn i filmkanten: 0 % ved filmkanten, 100 % ved bildekanten.", + "How far the effect reaches into the picture.": "Hvor langt effekten når inn i bildet.", + "How gradually the tone limit takes effect: raise it if the edges look rough.": "Hvor gradvis tonegrensen virker: øk den hvis kantene ser grove ut.", + "How light or dark the print is, where lower is lighter.": "Hvor lys eller mørk kopien er, der lavere er lysere.", + "How many full-size previews stay in memory.": "Hvor mange forhåndsvisninger i full størrelse som holdes i minnet.", + "How many recent photos stay decoded in memory.": "Hvor mange nylige bilder som holdes dekodet i minnet.", + "How many recently-viewed photos stay decoded in memory for instant navigation.": "Hvor mange nylig viste bilder som holdes dekodet i minnet for umiddelbar navigering.", + "How many rendered frames stay in memory for going back.": "Hvor mange gjengitte bilder som holdes i minnet for å gå tilbake.", + "How many stops the film was pushed or pulled in development": "Hvor mange blendertrinn filmen ble pushet eller pullet i fremkallingen", + "How many stops the film was pushed or pulled in development.": "Hvor mange blendertrinn filmen ble pushet eller pullet i fremkallingen.", + "How many stops this process pushes or pulls the film": "Hvor mange blendertrinn denne prosessen pusher eller puller filmen", + "How much Auto Grade adapts the grade to each negative to give every frame the same detail range.": "Hvor mye autogradering tilpasser graderingen til hvert negativ for å gi hvert bilde samme detaljomfang.", + "How much blue dye is left, compared with red. Below 1.0 means blue faded more than red.": "Hvor mye blått fargestoff som er igjen, sammenlignet med rødt. Under 1,0 betyr at blått har falmet mer enn rødt.", + "How much color is added to the highlights.": "Hvor mye farge som legges til i høylysene.", + "How much color is added to the shadows.": "Hvor mye farge som legges til i skyggene.", + "How much crosstalk correction to apply; run Roll Analysis again after a change.": "Hvor mye krysstalekorreksjon som brukes; kjør rullanalyse på nytt etter en endring.", + "How much green dye is left, compared with red. Below 1.0 means green faded more than red.": "Hvor mye grønt fargestoff som er igjen, sammenlignet med rødt. Under 1,0 betyr at grønt har falmet mer enn rødt.", + "How much of the source covers the destination, where lower lets the original show through": "Hvor mye av kilden som dekker målet, der lavere lar originalen skinne gjennom", + "How much of the {value} density goes into {value1}, where a negative value removes the leak.": "Hvor mye av {value}-tettheten som går inn i {value1}, der en negativ verdi fjerner lekkasjen.", + "How much red dye is left in the slide. Lower it if the faded slide still looks flat.": "Hvor mye rødt fargestoff som er igjen i diaset. Senk den hvis det falmede diaset fortsatt ser flatt ut.", + "How rough the outer edge of the black frame is.": "Hvor grov den ytre kanten av den svarte rammen er.", + "How round the corners of the black frame are.": "Hvor runde hjørnene på den svarte rammen er.", + "How strongly the matrix previews here, while Strength on the Calibration card applies it.": "Hvor sterkt matrisen forhåndsvises her, mens Styrke på kortet Kalibrering bruker den.", + "How suddenly the shadows turn black.": "Hvor brått skyggene blir svarte.", + "How the calibration is applied; run Roll Analysis again after a change.": "Hvordan kalibreringen brukes; kjør rullanalyse på nytt etter en endring.", + "How the camera's raw data becomes pixels: Auto is fast on screen and uses AHD for export.": "Hvordan kameraets rådata blir til piksler: Auto er raskt på skjermen og bruker AHD ved eksport.", + "How the contact sheet is laid out": "Hvordan kontaktarket er satt opp", + "How the film is digitized, for example a DSLR on a copy stand": "Hvordan filmen digitaliseres, for eksempel et speilreflekskamera på et reprostativ", + "How the film was digitized, for example a DSLR on a copy stand": "Hvordan filmen ble digitalisert, for eksempel et speilreflekskamera på et reprostativ", + "How the film was digitized.": "Hvordan filmen ble digitalisert.", + "How the scan is exposed: one pass, several passes for less noise, a short and a long exposure for more range, or both.": "Hvordan skanningen eksponeres: én passering, flere passeringer for mindre støy, en kort og en lang eksponering for større omfang, eller begge.", + "How the scan uses the infrared pass against dust: not at all, as a separate channel, or as ICE in the scanner.": "Hvordan skanningen bruker den infrarøde passeringen mot støv: ikke i det hele tatt, som en egen kanal, eller som ICE i skanneren.", + "How the settings that are on meet each frame's edit": "Hvordan innstillingene som er på, møter redigeringen av hvert bilde", + "How tightly black and white are set: positive gives more contrast, and negative keeps more shadow and highlight detail.": "Hvor stramt svart og hvitt settes: positiv gir mer kontrast, og negativ beholder flere detaljer i skygger og høylys.", + "How tightly the color balance is set: positive balances more strongly, and negative is gentler.": "Hvor stramt fargebalansen settes: positiv balanserer sterkere, og negativ er mildere.", + "How to handle colors the paper cannot print: Relative Colorimetric clips them, and Perceptual compresses all colors to keep their relations.": "Hvordan farger papiret ikke kan gjengi, skal håndteres: Relativ kolorimetrisk klipper dem, og Perseptuell komprimerer alle farger for å bevare forholdet mellom dem.", + "How wide a band to cut away between the two frames, for the black gap the camera leaves.": "Hvor bredt bånd som skal skjæres bort mellom de to bildene, for det svarte mellomrommet kameraet etterlater.", + "Hue Trim": "Justering av fargetone", + "ICC profiles (*.icc *.icm)": "ICC-profiler (*.icc *.icm)", + "ICE": "ICE", + "IR": "IR", + "IR Attenuation": "IR-demping", + "IR Dust Removal": "IR-støvfjerning", + "IR Removal": "IR-fjerning", + "IR Removal: remove dust and scratches with the scanner's infrared channel.": "IR-fjerning: fjern støv og riper med skannerens infrarøde kanal.", + "IR Shutter": "IR-lukker", + "IR Shutter {shutter} is not a step this camera offers; the infrared frame shoots at the camera's shutter": "IR-lukker {shutter} er ikke et trinn dette kameraet tilbyr; det infrarøde bildet tas med kameraets lukkertid", + "IR Threshold": "IR-terskel", + "IR scanning not supported by this device": "Denne enheten støtter ikke IR-skanning", + "ISO": "ISO", + "ISO sensitivity": "ISO-følsomhet", + "ISO-R Grade": "ISO-R-gradering", + "ISO-R Grade ↑/↓": "ISO-R-gradering ↑/↓", + "ISO: use the one you will scan with": "ISO: bruk den du skal skanne med", + "Ignores a border around the frame when measuring, so film edges and holders do not count.": "Ser bort fra en kant rundt bildet ved måling, så filmkanter og holdere ikke teller med.", + "Ilford sheet size, or Custom for a size dragged or typed": "Arkstørrelse fra Ilford, eller Egendefinert for en størrelse som dras eller skrives inn", + "Image description": "Bildebeskrivelse", + "Image only": "Bare bildet", + "Import": "Importer", + "Import Folder as a Roll": "Importer mappe som rull", + "Import Folder as a Roll…": "Importer mappe som rull…", + "Import ICC Profile": "Importer ICC-profil", + "Import Subfolders as Rolls": "Importer undermapper som ruller", + "Import Subfolders as Rolls…": "Importer undermapper som ruller…", + "Import a folder as a roll": "Importer en mappe som en rull", + "Import a folder as a roll, add single files, or scan film": "Importer en mappe som en rull, legg til enkeltfiler, eller skann film", + "Import a folder as a roll, or its subfolders as one roll each": "Importer en mappe som en rull, eller undermappene som én rull hver", + "Import an ICC profile into {user_icc_dir}": "Importer en ICC-profil til {user_icc_dir}", + "Imported {value} from “{value1}”": "Importerte {value} fra «{value1}»", + "Include {value}": "Ta med {value}", + "Index Library": "Indekser biblioteket", + "Index the library for search by meaning": "Indekser biblioteket for søk etter betydning", + "Index the library, so search by meaning covers every roll.": "Indekser biblioteket, så søk etter betydning dekker hver rull.", + "Indexing canceled": "Indekseringen ble avbrutt", + "Indexing for search by meaning": "Indekserer for søk etter betydning", + "Indexing for search by meaning failed": "Indeksering for søk etter betydning mislyktes", + "Indexing for search by meaning…": "Indekserer for søk etter betydning…", + "Indexing library for search by meaning": "Indekserer biblioteket for søk etter betydning", + "Indexing {current}/{total}: {name}": "Indekserer {current}/{total}: {name}", + "Infer from Folder Name": "Utled fra mappenavnet", + "Infer gear from the folder name": "Utled utstyr fra mappenavnet", + "Infrared": "Infrarød", + "Infrared exposure failed, frame kept without it: {error}": "Den infrarøde eksponeringen mislyktes, bildet ble beholdt uten den: {error}", + "Infrared: end every scan with an infrared exposure for dust removal, with infrared LEDs, an infrared-capable camera and manual focus.": "Infrarød: avslutt hver skanning med en infrarød eksponering for støvfjerning, med infrarøde LED-er, et kamera som tåler infrarødt og manuell fokus.", + "Input ICC": "Inngangs-ICC", + "Input gamma": "Inngangsgamma", + "Insert the strip, then press Detect Frames.": "Sett inn stripen, og trykk så Oppdag bilder.", + "Install Update": "Installer oppdatering", + "Installing. NegPy closes and opens again on the new version.": "Installerer. NegPy lukkes og åpnes igjen med den nye versjonen.", + "Interface": "Grensesnitt", + "Interface, performance and storage settings for the whole app": "Innstillinger for grensesnitt, ytelse og lagring for hele appen", + "Invert: act everywhere except inside this mask.": "Inverter: virk overalt unntatt innenfor denne masken.", + "Iron Blue": "Jernblå", + "Iron blue toner, B&W negatives only: blue shadows and navy blacks.": "Jernblåtoner, bare S/H-negativer: blå skygger og marineblå svarttoner.", + "Its frames keep their edits and the baseline they carry.": "Bildene beholder redigeringene og grunnlaget de har.", + "Its independent edit is deleted. The frame goes back to the edit shared with every other roll.": "Den uavhengige redigeringen slettes. Bildet går tilbake til redigeringen som deles med hver annen rull.", + "JPEG Progressive": "Progressiv JPEG", + "JPEG Quality": "JPEG-kvalitet", + "JPEG XL (lossless)": "JPEG XL (tapsfri)", + "JPEG XL cannot tag the selected color space ({names}).\nChoose sRGB, P3 D65, Rec 2020 or Grayscale, or a different format.": "JPEG XL kan ikke merke det valgte fargerommet ({names}).\nVelg sRGB, P3 D65, Rec 2020 eller Gråtoner, eller et annet format.", + "JPEG XL cannot use {value}. Choose sRGB, P3 D65, Rec 2020 or Grayscale, or another format.": "JPEG XL kan ikke bruke {value}. Velg sRGB, P3 D65, Rec 2020 eller Gråtoner, eller et annet format.", + "JPEG and WebP are 8-bit formats and ignore this": "JPEG og WebP er 8-biters formater og ser bort fra dette", + "JXL (lossless)": "JXL (tapsfri)", + "JXL Distance": "JXL-avstand", + "JXL Effort": "JXL-innsats", + "JXL Lossless": "JXL tapsfri", + "Jinja2 template with the variables {{ date }} and {{ seq }}, for example {{ date }}_{{ \"%03d\" % seq }}": "Jinja2-mal med variablene {{ date }} og {{ seq }}, for eksempel {{ date }}_{{ \"%03d\" % seq }}", + "Join {value} into the ImageDescription": "Slå sammen {value} i ImageDescription", + "Jump to a chapter of the tour": "Hopp til et kapittel i omvisningen", + "Keep": "Behold", + "Keep / reject marks": "Behold/forkast-merker", + "Keep It On": "La det være på", + "Keep Light On: keep the Scanlight lit after each scan, to frame the next shot on the camera's screen.": "La lyset være på: la Scanlight lyse etter hver skanning, for å ramme inn neste bilde på kameraskjermen.", + "Keep and Reject": "Behold og forkast", + "Keep the copied grain and detail, but match the brightness and color around the destination": "Behold det kopierte kornet og detaljene, men tilpass lysheten og fargen rundt målet", + "Keep the scan's size, set a print size and DPI, or set the long edge in pixels.": "Behold skanningens størrelse, angi en kopistørrelse og DPI, eller angi den lange kanten i piksler.", + "Keep the selected frames": "Behold de valgte bildene", + "Keep the zoom level when switching images, instead of resetting to fit": "Behold zoomnivået ved bytte av bilde, i stedet for å tilbakestille til tilpasset", + "Keep this crop and return to the Scan panel": "Behold denne beskjæringen og gå tilbake til skannepanelet", + "Keep this edit as a named version.": "Behold denne redigeringen som en navngitt versjon.", + "Keep this frame's automatic values as fixed settings and turn Auto off for it.": "Behold de automatiske verdiene for dette bildet som faste innstillinger og slå av Auto for det.", + "Keep this framing and selection, and return to the Scan panel": "Behold denne innrammingen og dette valget, og gå tilbake til skannepanelet", + "Keep this window and return to the Scan panel": "Behold dette vinduet og gå tilbake til skannepanelet", + "Keep {value}": "Behold {value}", + "Keepers": "Beholdte", + "Keepers Only": "Bare beholdte", + "Keeps detail in the brightest areas, such as a sunlit wall or sky, so they do not print pure white.": "Beholder detaljer i de lyseste områdene, som en solbelyst vegg eller himmel, så de ikke kopieres helt hvite.", + "Keeps its own value on: {value}": "Beholder egen verdi på: {value}", + "Keeps skin tones from getting too strong in color. 0 is off.": "Hindrer at hudtoner får for sterk farge. 0 er av.", + "Keeps the print stock you picked.": "Beholder kopipapiret du valgte.", + "Keyboard Shortcuts": "Hurtigtaster", + "Keyboard Shortcuts…": "Hurtigtaster…", + "Keystone Lines": "Perspektivlinjer", + "LED LEVELS": "LED-NIVÅER", + "LENS CORRECTION": "OBJEKTIVKORREKSJON", + "Lab": "Lab", + "Label text": "Etikettekst", + "Language": "Språk", + "Largest dust size to remove, where larger values can remove real detail.": "Største støvstørrelse som fjernes, der større verdier kan fjerne ekte detaljer.", + "Largest image size the GPU loads at once. 0 lets the hardware decide.": "Største bildestørrelse GPU-en laster inn på én gang. 0 lar maskinvaren bestemme.", + "Last file": "Siste fil", + "Later": "Senere", + "Later edits never delete a work print; this is the only way to delete one.": "Senere redigeringer sletter aldri en arbeidskopi; dette er den eneste måten å slette en på.", + "Latitude and longitude, or a pasted OpenStreetMap or Google Maps link.": "Breddegrad og lengdegrad, eller en innlimt lenke fra OpenStreetMap eller Google Maps.", + "Lay every visible frame out as film strips on photographic paper, then export the sheet": "Legg ut hvert synlig bilde som filmstriper på fotopapir, og eksporter så arket", + "Lay the frames out by file name, in the order they were taken, or with a new strip for each scene.": "Ordne bildene etter filnavn, i rekkefølgen de ble tatt, eller med en ny stripe for hver scene.", + "Leave at the default unless you know why you need to change it.": "La standardverdien stå med mindre du vet hvorfor du må endre den.", + "Leave frames in the rejected state out of the run; the numbers fill the frames that remain.": "Utelat forkastede bilder fra rekken; numrene fyller bildene som er igjen.", + "Leave the current view (peek, split, strip, zone overlay) or cancel the active tool; Crop and Draw Region undo their changes": "Forlat den gjeldende visningen (titt, deling, stripe, soneoverlegg) eller avbryt det aktive verktøyet; Beskjær og Tegn område angrer endringene sine", + "Leave the frame as it was": "La bildet være som det var", + "Left": "Venstre", + "Length of the second exposure: above 1 the light tones reverse.": "Lengden på den andre eksponeringen: over 1 snus de lyse tonene.", + "Lens": "Objektiv", + "Lens Correction": "Objektivkorreksjon", + "Lens make": "Objektivmerke", + "Lens model": "Objektivmodell", + "Lenses": "Objektiver", + "Level": "Vater", + "Level {value:+.2f}°": "Vater {value:+.2f}°", + "Level: draw a line along the horizon or a vertical edge, and the picture turns to level it.": "Vater: tegn en linje langs horisonten eller en loddrett kant, så roteres bildet til det er i vater.", + "Library": "Bibliotek", + "Library already indexed for search by meaning": "Biblioteket er allerede indeksert for søk etter betydning", + "Library indexing": "Bibliotekindeksering", + "Library rolls": "Bibliotekruller", + "Library search": "Biblioteksøk", + "Lifts the shadows of a contrasty frame when its darkest detail would go black.": "Løfter skyggene i et kontrastrikt bilde når den mørkeste detaljen ville blitt svart.", + "Light": "Lys", + "Light Gray": "Lysegrå", + "Light On": "Lys på", + "Light Source": "Lyskilde", + "Light Table": "Lysbord", + "Light Table: larger frames, fewer to a row": "Lysbord: større bilder, færre per rad", + "Light Table: smaller frames, more to a row": "Lysbord: mindre bilder, flere per rad", + "Light Table: the frames to a row; fewer frames are larger": "Lysbord: bildene per rad; færre bilder er større", + "Light Table: the whole roll in rows, in place of the canvas": "Lysbord: hele rullen i rader, i stedet for lerretet", + "Light: R{r} G{g} B{b}": "Lys: R{r} G{g} B{b}", + "Light: W{w}": "Lys: W{w}", + "Like Linear, but very strong colors come from a slightly blurred copy, so they do not speckle.": "Som Lineær, men svært sterke farger hentes fra en litt uskarp kopi, så de ikke blir prikkete.", + "Line": "Linje", + "Line Sensitivity": "Linjefølsomhet", + "Line: click once on a long straight scratch, and the whole line is found and repaired.": "Linje: klikk én gang på en lang, rett ripe, så blir hele linjen funnet og reparert.", + "Linear": "Lineær", + "Linear Output failed for {name}: {e}": "Lineært resultat mislyktes for {name}: {e}", + "Linear Output is not supported for this file type": "Lineært resultat støttes ikke for denne filtypen", + "Linear RAW": "Lineær RAW", + "Linear RAW has no effect on a slide.": "Lineær RAW har ingen virkning på et dias.", + "Linear RAW is locked for a Trichrome triplet.": "Lineær RAW er låst for en Trichrome-triplett.", + "Linear-export {value}?": "Vil du eksportere {value} lineært?", + "Linear: the samples are linear light, such as a scanner's raw output.": "Lineær: verdiene er lineært lys, som rådata fra en skanner.", + "List settings still at their default, so they can be applied too": "List opp innstillinger som fortsatt har standardverdien, så de også kan brukes", + "Lith": "Lith", + "Lith Abruptness ↑/↓": "Lith-brådhet ↑/↓", + "Lith Exposure": "Lith-eksponering", + "Lith Exposure ↑/↓": "Lith-eksponering ↑/↓", + "Lith abruptness down": "Lith-brådhet ned", + "Lith abruptness up": "Lith-brådhet opp", + "Lith exposure down": "Lith-eksponering ned", + "Lith exposure up": "Lith-eksponering opp", + "Lith print: creamy warm highlights and hard, deep blacks, in the paper's color, with only Selenium and Gold toners.": "Lith-kopi: kremaktige, varme høylys og harde, dype svarttoner, i papirets farge, med bare selen- og gulltonere.", + "Lith printing on/off": "Lith-kopiering på/av", + "Lith: only Selenium and Gold change the print.": "Lith: bare Selen og Gull endrer kopien.", + "Live View": "Direktevisning", + "Live View → click the clear film base": "Direktevisning → klikk den klare filmbasen", + "Live view failed: the camera stopped answering ({reason}). Reconnect or power-cycle the camera, then start Live View again.": "Direktevisningen mislyktes: kameraet sluttet å svare ({reason}). Koble til på nytt eller slå kameraet av og på, og start direktevisningen igjen.", + "Live view running": "Direktevisning kjører", + "Live view: {e}": "Direktevisning: {e}", + "Load": "Last inn", + "Load Demo Negative": "Last inn demonegativ", + "Load Edit from Sidecar": "Last inn redigering fra sidefil", + "Load Edit from Sidecar…": "Last inn redigering fra sidefil…", + "Load Metadata": "Last inn metadata", + "Load Roll": "Last inn rull", + "Load Scans…": "Last inn skanninger…", + "Load Them": "Last dem inn", + "Load a frame first, because the Hot Folder watches the folder of the newest frame": "Last inn et bilde først, fordi aktiv mappe overvåker mappen til det nyeste bildet", + "Load from a metadata preset…": "Last inn fra en metadataforhåndsinnstilling…", + "Load this frame's edit from a sidecar…": "Last inn redigeringen for dette bildet fra en sidefil…", + "Load {value} from “{label}”?": "Vil du laste inn {value} fra «{label}»?", + "Loaded edit from {value}": "Lastet inn redigering fra {value}", + "Loaded {value} for this session only. The folder roll “{name}” holds only files from its own folder. Use Save as Virtual Roll… to keep them in a new roll.": "Lastet inn {value} bare for denne økten. Mapperullen «{name}» inneholder bare filer fra sin egen mappe. Bruk Lagre som virtuell rull… for å beholde dem i en ny rull.", + "Loading live view…": "Laster inn direktevisning…", + "Lock": "Lås", + "Lock Bounds: keep this frame's measured black and white points, even when the crop or Roll Analysis changes.": "Lås grenser: behold det målte svart- og hvitpunktet for dette bildet, selv når beskjæringen eller rullanalysen endres.", + "Locked from frame {exposure_lock_frame}{when}. Every scan reuses it.": "Låst fra bilde {exposure_lock_frame}{when}. Hver skanning bruker den på nytt.", + "Locked to the preset: pick Set by Hand under + to set it by hand.": "Låst til forhåndsinnstillingen: velg Angi selv under + for å angi den selv.", + "Log": "Log", + "Log Scale: shows small areas, such as clipped ends, that the linear scale hides": "Logaritmisk skala: viser små områder, som klippede ender, som den lineære skalaen skjuler", + "Long Edge": "Lang kant", + "Long edge": "Lang kant", + "Long edge of the exported image": "Lang kant på det eksporterte bildet", + "Long edge of the interactive canvas. Higher is sharper at 100% zoom, and more VRAM and CPU per frame.": "Lang kant på det interaktive lerretet. Høyere er skarpere ved 100 % zoom, og mer VRAM og CPU per bilde.", + "Long edge of the print": "Lang kant på kopien", + "Look": "Utseende", + "Look & Finish": "Utseende og etterarbeid", + "Look tab": "Utseendefanen", + "Look: Lab, Alternative Processes, Toning": "Utseende: Lab, alternative prosesser, toning", + "Looking for the camera and the Scanlight.": "Ser etter kameraet og Scanlight.", + "Looking up place…": "Slår opp stedet…", + "Lossless": "Tapsfri", + "Lower finds fainter lines; raise it if the repair spreads onto good film.": "Lavere finner svakere linjer; øk den hvis reparasjonen sprer seg over på god film.", + "Lower finds more dust; higher removes less.": "Lavere finner mer støv; høyere fjerner mindre.", + "Luma": "Luma", + "Luma Range Clip": "Luma-områdeklipping", + "Luma Range Clip ↑/↓": "Luma-områdeklipping ↑/↓", + "Luma range clip down": "Luma-områdeklipping ned", + "Luma range clip up": "Luma-områdeklipping opp", + "MANUAL HEAL · 0": "MANUELL LEGEING · 0", + "MANUAL HEAL · {num_heals}": "MANUELL LEGEING · {num_heals}", + "MASKS · 0": "MASKER · 0", + "MASKS · {n}": "MASKER · {n}", + "Magenta": "Magenta", + "Magenta down": "Magenta ned", + "Magenta up": "Magenta opp", + "Magenta ↑/↓": "Magenta ↑/↓", + "Make": "Merke", + "Make a profile from three shots of the bare light: red, green and blue.": "Lag en profil fra tre bilder av det bare lyset: rødt, grønt og blått.", + "Make an editable copy of the selected profile": "Lag en redigerbar kopi av den valgte profilen", + "Make each folder of scans under a parent a roll of its own": "Gjør hver mappe med skanninger under en overordnet mappe til en egen rull", + "Make each frame from three exposures (red, green and blue), a setting that each roll keeps.": "Lag hvert bilde fra tre eksponeringer (rød, grønn og blå), en innstilling som hver rull beholder.", + "Make every group a scene and give each one its own baseline.": "Gjør hver gruppe til en scene og gi hver av dem sitt eget grunnlag.", + "Make one area lighter (dodge) or darker (burn). Draw a shape, an oval or a fading card edge, then drag its Burn slider. {value} shows your marks on the picture.": "Gjør ett område lysere (skygging) eller mørkere (etterbelysning). Tegn en form, en oval eller en kortkant som toner ut, og dra så glidebryteren Etterbelysning. {value} viser merkene dine på bildet.", + "Make one folder of scans a roll and open it": "Gjør én mappe med skanninger til en rull og åpne den", + "Make this frame's own measurement the roll average? Every frame on the average takes it.": "Vil du gjøre dette bildets egen måling til rullens gjennomsnitt? Hvert bilde som følger gjennomsnittet, tar den i bruk.", + "Make this virtual roll the folder roll of its folder": "Gjør denne virtuelle rullen til mapperullen for mappen sin", + "Makes the bottom border wider than the others, as in a picture mat.": "Gjør den nedre kanten bredere enn de andre, som i et passepartout.", + "Makes the picture warmer or cooler.": "Gjør bildet varmere eller kaldere.", + "Manage": "Administrer", + "Manage Database": "Administrer database", + "Manage Database…": "Administrer database…", + "Manufacturer": "Produsent", + "Map the darkest tone to the paper's darkest black, in place of clipping.": "Tilordne den mørkeste tonen til papirets dypeste svart, i stedet for å klippe.", + "Map unavailable. Enter coordinates by hand.": "Kartet er ikke tilgjengelig. Skriv inn koordinatene selv.", + "Mark frame as keeper": "Merk bildet som beholdt", + "Mark paper black and paper white on the frame": "Merk papirsvart og papirhvitt på bildet", + "Mark the frames you want. {value} keeps a frame and {value1} rejects it; batch exports skip rejects. The filter box finds frames by name or by details such as film:portra.": "Merk bildene du vil ha. {value} beholder et bilde og {value1} forkaster det; satsvis eksport hopper over forkastede. Filterfeltet finner bilder etter navn eller etter detaljer som film:portra.", + "Marked": "Merket", + "Marking a frame Keeper or Reject moves on to the next frame": "Når du merker et bilde som beholdt eller forkastet, går du videre til neste bilde", + "Marks told apart by color alone use colors this vision keeps": "Merker som bare skilles ved farge, bruker farger dette synet beholder", + "Masking": "Maskering", + "Match Tone": "Tilpass tone", + "Matrix": "Matrise", + "Max aperture": "Største blender", + "Measure Alone": "Mål alene", + "Measure Alone: take this frame off the roll average and measure it on its own, from its current crop.": "Mål alene: ta dette bildet ut av rullens gjennomsnitt og mål det for seg, fra den gjeldende beskjæringen.", + "Measure a crosstalk matrix from a color chart": "Mål en krysstalematrise fra et fargekart", + "Measure a matrix from a color chart shot on this film and scanned on this rig…": "Mål en matrise fra et fargekart fotografert på denne filmen og skannet på denne riggen…", + "Measure all visible frames and use their average, so the flat masters of a roll match.": "Mål alle synlige bilder og bruk gjennomsnittet deres, så de flate masterne i en rull samsvarer.", + "Measure and Save": "Mål og lagre", + "Measure from Chart": "Mål fra kart", + "Measure one frame inside the strip, not at its end, and use its exposure for every scan until you unlock it.": "Mål ett bilde inne på stripen, ikke i enden, og bruk eksponeringen for hver skanning til du låser den opp.", + "Measure the frame or the selection alone, off the roll average": "Mål bildet eller utvalget alene, utenfor rullens gjennomsnitt", + "Measure this frame on its own first: click Measure Alone.": "Mål dette bildet for seg først: klikk Mål alene.", + "Measure {value} Alone": "Mål {value} alene", + "Measure {value} Alone: take each selected frame off the roll average and measure it on its own.": "Mål {value} alene: ta hvert valgte bilde ut av rullens gjennomsnitt og mål det for seg.", + "Measured": "Målt", + "Measuring the average…": "Måler gjennomsnittet…", + "Measuring the strip…": "Måler stripen…", + "Measuring…": "Måler…", + "Measuring… {value:.0f}%": "Måler… {value:.0f}%", + "Memory ceiling for that cache. Lower it on a machine with little RAM.": "Minnetak for den hurtigbufferen. Senk det på en maskin med lite RAM.", + "Merge": "Slå sammen", + "Merge Exposures (HDR)": "Slå sammen eksponeringer (HDR)", + "Merge Exposures (Trichrome)": "Slå sammen eksponeringer (Trichrome)", + "Merge Exposures (Trichrome): join the three selected frames into one, finding red, green and blue.": "Slå sammen eksponeringer (Trichrome): slå de tre valgte bildene sammen til ett, og finn rød, grønn og blå.", + "Merge Frame to TIFF Negative…": "Slå sammen bilde til TIFF-negativ…", + "Merge Roll to TIFF Negative…": "Slå sammen rull til TIFF-negativer…", + "Merge Selected to TIFF Negative…": "Slå sammen valgte til TIFF-negativer…", + "Merge selected exposures into one HDR frame": "Slå sammen valgte eksponeringer til ett HDR-bilde", + "Merge three selected exposures into one trichrome frame": "Slå sammen tre valgte eksponeringer til ett Trichrome-bilde", + "Merge to TIFF Negative": "Slå sammen til TIFF-negativ", + "Merge {value} into {value1}?": "Vil du slå sammen {value} til {value1}?", + "Merged {value} exposures spanning {stops:.1f} stops": "Slo sammen {value} eksponeringer over {stops:.1f} blendertrinn", + "Merged {value} to TIFF negatives": "Slo sammen {value} til TIFF-negativer", + "Merging an already-merged frame is not supported": "Det støttes ikke å slå sammen et bilde som allerede er sammenslått", + "Merging exposures": "Slår sammen eksponeringer", + "Merging is for transparencies; black-and-white reversal film is not supported yet": "Sammenslåing er for transparenter; svart-hvitt-reversalfilm støttes ikke ennå", + "Merging to TIFF negatives": "Slår sammen til TIFF-negativer", + "Metadata": "Metadata", + "Metadata Preview": "Forhåndsvisning av metadata", + "Metadata tab": "Metadatafanen", + "Metadata: My Gear tab": "Metadata: fanen Mitt utstyr", + "Metadata: Presets tab": "Metadata: fanen Forhåndsinnstillinger", + "Meter": "Måler", + "Meter Frame…": "Mål bilde…", + "Meter a frame and lock the scan exposure (Coolscan)": "Mål et bilde og lås skanneeksponeringen (Coolscan)", + "Meter every frame on its own again": "Mål hvert bilde for seg igjen", + "Meter exposure in hardware before the scan": "Mål eksponeringen i maskinvaren før skanningen", + "Meter frame:": "Målebilde:", + "Meter the clicked film base and save the result as this preset": "Mål den klikkede filmbasen og lagre resultatet som denne forhåndsinnstillingen", + "Meter: set the shutter between {value} and {value1} first": "Måler: angi lukkertiden mellom {value} og {value1} først", + "Meter: set the shutter from one RAW probe": "Måler: angi lukkertiden fra én RAW-prøve", + "Meter: set the shutter so the film base lands just under clipping, at this ISO and aperture.": "Måler: angi lukkertiden slik at filmbasen havner like under klipping, med denne ISO-en og blenderen.", + "Meter: this body publishes no writable shutter": "Måler: dette kamerahuset har ingen skrivbar lukkertid", + "Meter: {reason}": "Måler: {reason}", + "Meter: {where} at {level} needs a shutter faster than {limit}; close the aperture or lower the ISO": "Måler: {where} på {level} krever en lukkertid raskere enn {limit}; blend ned eller senk ISO-en", + "Meter: {where} at {level} → {label} ({stops:+.1f} st){note}": "Måler: {where} på {level} → {label} ({stops:+.1f} trinn){note}", + "Meter: {where} at {level}; {label} is not a step this body offers": "Måler: {where} på {level}; {label} er ikke et trinn dette kamerahuset tilbyr", + "Meter: {where} clipped on {clipped_fraction:.1%} of the picture at {shutter}; set {label} ({stops:+.1f} st), meter again{note}": "Måler: {where} er klippet på {clipped_fraction:.1%} av bildet ved {shutter}; angi {label} ({stops:+.1f} trinn), mål på nytt{note}", + "Meter: {where} reads {level}, too dark to meter; check the light": "Måler: {where} leser {level}, for mørkt til å måle; kontroller lyset", + "Metering": "Lysmåling", + "Metering Band": "Målebånd", + "Metering Strength": "Målestyrke", + "Metering failed: {value}": "Lysmålingen mislyktes: {value}", + "Metering…": "Måler…", + "Method": "Metode", + "Mid Gray": "Mellomgrå", + "Midtone": "Mellomtone", + "Midtone contrast, with no change to the deepest black and the paper white.": "Mellomtonekontrast, uten endring av den dypeste svarttonen og papirhvitt.", + "Midtone: from the color of the middle grays.": "Mellomtone: fra fargen på de midterste grå tonene.", + "Minimize": "Minimer", + "Mode": "Modus", + "Mode: B&W Negative": "Modus: S/H-negativ", + "Mode: Color Negative": "Modus: fargenegativ", + "Mode: Transparency": "Modus: transparent", + "Model": "Modell", + "Monitor profile the preview is displayed on (affects preview only, not export)": "Skjermprofilen forhåndsvisningen vises på (påvirker bare forhåndsvisningen, ikke eksporten)", + "More": "Mer", + "More actions": "Flere handlinger", + "More actions for the selected frames": "Flere handlinger for de valgte bildene", + "Move Source Files to the Trash": "Flytt kildefilene til papirkurven", + "Move down": "Flytt ned", + "Move each merged frame's source files to the Trash, or keep the frame in the Film Strip beside its TIFF": "Flytt kildefilene til hvert sammenslått bilde til papirkurven, eller behold bildet i filmstripen ved siden av TIFF-filen", + "Move every panel back to its default place": "Flytt hvert panel tilbake til standardplassen", + "Move the selected frames' files to the Trash…": "Flytt filene til de valgte bildene til papirkurven…", + "Move the {value} to the Trash?": "Vil du flytte {value} til papirkurven?", + "Move this frame to the Trash?": "Vil du flytte dette bildet til papirkurven?", + "Move to Trash": "Flytt til papirkurven", + "Move to Trash…": "Flytt til papirkurven…", + "Move up": "Flytt opp", + "Moved {value} to the Trash": "Flyttet {value} til papirkurven", + "Moved {value} to the Trash, {message}": "Flyttet {value} til papirkurven, {message}", + "Moves every frame along the film to line it up with the pictures.": "Flytter hvert bilde langs filmen for å rette det inn etter bildene.", + "Moves every frame forward along the film to line it up with the pictures, as this transport cannot back up.": "Flytter hvert bilde fremover langs filmen for å rette det inn etter bildene, fordi denne transporten ikke kan gå bakover.", + "Moves the black point: positive lifts the blacks, and negative makes them deeper.": "Flytter svartpunktet: positiv løfter svarttonene, og negativ gjør dem dypere.", + "Moves the crop inside (positive) or outside (negative) the found edge.": "Flytter beskjæringen innenfor (positiv) eller utenfor (negativ) den funne kanten.", + "Moves the white point: positive brightens the print, and negative brings back highlights.": "Flytter hvitpunktet: positiv gjør kopien lysere, og negativ henter tilbake høylys.", + "Moving the film to the next frame…": "Flytter filmen til neste bilde…", + "Multi-Exposure and Multi-Pass are not available at {dpi} dpi.": "Multieksponering og flerpassering er ikke tilgjengelig ved {dpi} dpi.", + "Multi-Pass": "Flerpassering", + "Multiply by the as-shot WB gains before writing": "Multipliser med WB-forsterkningene fra opptaket før skriving", + "Multiply the scan values so that they fill more of the file's 16-bit range.": "Multipliser skanneverdiene så de fyller mer av filens 16-biters område.", + "My Gear": "Mitt utstyr", + "N/A": "I/T", + "NEGATIVE": "NEGATIV", + "Name": "Navn", + "Name (printer and paper):": "Navn (skriver og papir):", + "Name:": "Navn:", + "Named versions of this frame: click one to use it, or right-click to export, rename or delete it.": "Navngitte versjoner av dette bildet: klikk én for å bruke den, eller høyreklikk for å eksportere, gi nytt navn til eller slette den.", + "Narrowband Light": "Smalbåndslys", + "Navigation": "Navigering", + "Navigator: click or drag to move the view": "Navigator: klikk eller dra for å flytte visningen", + "NegPy": "NegPy", + "NegPy Hit an Error": "NegPy støtte på en feil", + "NegPy Sidecars (*.negpy)": "NegPy-sidefiler (*.negpy)", + "NegPy data folder": "NegPy-datamappe", + "NegPy meters the clear base and solves the LED levels and the shutter for this stock, then saves the preset": "NegPy måler den klare basen og løser LED-nivåene og lukkertiden for denne filmtypen, og lagrer så forhåndsinnstillingen", + "NegPy turns scans of film into finished pictures. It works like a darkroom: you pick a paper, then set brightness, contrast and color.

The chapter menu jumps anywhere. A circle marks a task to try; Undo reverses it.": "NegPy gjør skanninger av film til ferdige bilder. Det virker som et mørkerom: du velger et papir, og angir så lyshet, kontrast og farge.

Kapittelmenyen hopper hvor som helst. En sirkel markerer en oppgave du kan prøve; Angre opphever den.", + "NegPy {value} is up to date.": "NegPy {value} er oppdatert.", + "NegPy {version}": "NegPy {version}", + "NegPy {version} Is Available": "NegPy {version} er tilgjengelig", + "Negative": "Negativ", + "New (Ware)": "Ny (Ware)", + "New Matrix": "Ny matrise", + "New Matrix {value}": "Ny matrise {value}", + "New Metadata Preset": "Ny metadataforhåndsinnstilling", + "New Preset": "Ny forhåndsinnstilling", + "New Preset — Calibrate on the Film Base": "Ny forhåndsinnstilling — kalibrer på filmbasen", + "New matrix, starting from identity": "Ny matrise, med identitet som utgangspunkt", + "Next": "Neste", + "Next Frame": "Neste bilde", + "Next crop guide overlay": "Neste overlegg for beskjæringshjelp", + "Next file": "Neste fil", + "Next sheet": "Neste ark", + "Next step (→ or Enter), or press Esc to end the tour": "Neste trinn (→ eller Enter), eller trykk Esc for å avslutte omvisningen", + "No Flat Field profile configured": "Ingen flatfeltprofil er konfigurert", + "No GPU available on this hardware. NegPy uses the CPU.": "Ingen GPU tilgjengelig på denne maskinvaren. NegPy bruker CPU-en.", + "No IR channel in this scan": "Ingen IR-kanal i denne skanningen", + "No average for this frame yet": "Ikke noe gjennomsnitt for dette bildet ennå", + "No baseline yet: this frame uses its own analysis until Roll Analysis runs": "Ikke noe grunnlag ennå: dette bildet bruker sin egen analyse til rullanalysen kjører", + "No cards edited": "Ingen kort redigert", + "No clear area to measure the stain on this frame.": "Ikke noe klart område for å måle flekken på dette bildet.", + "No corner pills": "Ingen hjørnemerker", + "No favorites yet. Use Edit Favorites to pick the sliders you use most.": "Ingen favoritter ennå. Bruk Rediger favoritter for å velge glidebryterne du bruker mest.", + "No files support Linear Output": "Ingen filer støtter lineært resultat", + "No film loaded": "Ingen film lastet inn", + "No folder to infer gear from": "Ingen mappe å utlede utstyr fra", + "No folders with images found in “{value}”": "Fant ingen mapper med bilder i «{value}»", + "No frame in this roll can be merged": "Ingen bilder i denne rullen kan slås sammen", + "No frame of this roll is in a scene": "Ingen bilder i denne rullen er i en scene", + "No frames in the library match that search": "Ingen bilder i biblioteket samsvarer med det søket", + "No frames loaded": "Ingen bilder lastet inn", + "No frames match the filter": "Ingen bilder samsvarer med filteret", + "No frames match the {named} filter.
Show all frames": "Ingen bilder samsvarer med filteret {named}.
Vis alle bilder", + "No frames to auto-crop": "Ingen bilder å autobeskjære", + "No frames under another light: the roll baseline fits every frame": "Ingen bilder under et annet lys: rullens grunnlag passer hvert bilde", + "No frames were detected on the loaded film.": "Ingen bilder ble oppdaget på den innlastede filmen.", + "No grays to measure.": "Ingen grå toner å måle.", + "No image loaded to estimate from.": "Ikke noe bilde lastet inn å beregne fra.", + "No live-view image. Is the camera in PC Remote mode? On macOS, a background app such as a cloud sync client can hold the camera; unplug it to free it.": "Ikke noe direktevisningsbilde. Er kameraet i PC Remote-modus? På macOS kan en bakgrunnsapp, som en skysynkroniseringsklient, holde kameraet; koble den fra for å frigjøre det.", + "No matrices for this film type yet. Make one in the editor.": "Ingen matriser for denne filmtypen ennå. Lag en i redigeringsverktøyet.", + "No monitor ICC profile found. The preview assumes sRGB.": "Fant ingen ICC-profil for skjermen. Forhåndsvisningen antar sRGB.", + "No place matched, or the lookup is unreachable.": "Ingen sted samsvarte, eller oppslaget kan ikke nås.", + "No practical use: as-shot WB gains do not correct a narrowband capture": "Ingen praktisk nytte: WB-forsterkninger fra opptaket korrigerer ikke et smalbåndsopptak", + "No practical use: each Trichrome channel is its own narrowband exposure, not a broadband as-shot gain": "Ingen praktisk nytte: hver Trichrome-kanal er sin egen smalbåndseksponering, ikke en bredbåndsforsterkning fra opptaket", + "No preset selected": "Ingen forhåndsinnstilling valgt", + "No preset selected. Add one with the + button.": "Ingen forhåndsinnstilling valgt. Legg til en med +-knappen.", + "No presets yet. Click Manage to add one.": "Ingen forhåndsinnstillinger ennå. Klikk Administrer for å legge til en.", + "No preview": "Ingen forhåndsvisning", + "No reliable grays found on this frame.": "Fant ingen pålitelige grå toner i dette bildet.", + "No roll": "Ingen rull", + "No rolls yet": "Ingen ruller ennå", + "No scanners detected": "Ingen skannere oppdaget", + "No scanners detected. Plug in your scanner and click Refresh.": "Ingen skannere oppdaget. Koble til skanneren og klikk Oppdater.", + "No scene has a loaded frame": "Ingen scene har et innlastet bilde", + "No scratch found there. Click right on the line.": "Fant ingen ripe der. Klikk rett på linjen.", + "No selected frame can be merged": "Ingen valgte bilder kan slås sammen", + "No sensor correction matrix configured": "Ingen sensorkorreksjonsmatrise er konfigurert", + "No stain found to remove.": "Fant ingen flekk å fjerne.", + "No supported assets found": "Fant ingen støttede elementer", + "None": "Ingen", + "Normal": "Normal", + "Normalization Bounds": "Normaliseringsgrenser", + "Not a camera RAW, so a film scanner.": "Ikke en kamera-RAW, altså en filmskanner.", + "Not applied to a merged bracket.": "Brukes ikke på en sammenslått eksponeringsserie.", + "Not applied to a transparency.": "Brukes ikke på et transparent.", + "Not applied to an as-captured transparency.": "Brukes ikke på et transparent slik det ble tatt opp.", + "Not applied to slides.": "Brukes ikke på dias.", + "Not sure which slider to move? Pick Better, the balance scale, shows two versions beside your edit. Click the better one; each round tunes one control.": "Usikker på hvilken glidebryter du skal flytte? Velg bedre, skålvekten, viser to versjoner ved siden av redigeringen din. Klikk den beste; hver runde finjusterer én kontroll.", + "Not used for a Trichrome triplet.": "Brukes ikke for en Trichrome-triplett.", + "Note: the Sheet filter is on, so the analysis reads only the {value}.": "Merk: arkfilteret er på, så analysen leser bare {value}.", + "Notes": "Notater", + "Notes for this preset": "Notater for denne forhåndsinnstillingen", + "Nothing in “{folder_name}” matches your gear, ISO or date": "Ingenting i «{folder_name}» samsvarer med utstyret, ISO-en eller datoen din", + "Nothing loaded to save as a roll": "Ingenting er lastet inn som kan lagres som en rull", + "Nothing to apply": "Ingenting å bruke", + "Nothing to apply: every card already follows the roll": "Ingenting å bruke: hvert kort følger allerede rullen", + "Nothing to paste": "Ingenting å lime inn", + "Nothing to undo: no change to several frames since your last edit.": "Ingenting å angre: ingen endring av flere bilder siden forrige redigering.", + "Nothing to update": "Ingenting å oppdatere", + "Now": "Nå", + "Number of exposures to stack, where each extra pass adds about one scan pass per exposure.": "Antall eksponeringer som skal stables, der hver ekstra passering legger til omtrent én skannepassering per eksponering.", + "OK": "OK", + "OUTPUT INTENT": "UTDATAHENSIKT", + "Off": "Av", + "Off (default)": "Av (standard)", + "Off while this frame prints on the roll average: turn off Luma and Color to pick by hand.": "Av mens dette bildet kopieres etter rullens gjennomsnitt: slå av Luma og Farge for å velge selv.", + "Offset": "Forskyvning", + "Offset cuts into the frame": "Forskyvningen skjærer inn i bildet", + "Offset held at the frame pitch": "Forskyvningen holdes ved bildeavstanden", + "Offset, Drift and cropping, in full": "Forskyvning, avdrift og beskjæring, i sin helhet", + "On": "På", + "On disk: {value} databases + {value1} thumbnails": "På disken: {value} databaser + {value1} miniatyrbilder", + "One exposure per scan, the fastest at standard quality.": "Én eksponering per skanning, raskest med standard kvalitet.", + "One filter per line. A folder that matches is skipped, with everything in it.\nText matches any part of the name, ignoring case: export skips old_exports.\n* is a wildcard, and the filter must match the whole name: raw_* skips raw_01.": "Ett filter per linje. En mappe som samsvarer, hoppes over, med alt innhold.\nTekst samsvarer med en hvilken som helst del av navnet, uten hensyn til store og små bokstaver: export hopper over old_exports.\n* er et jokertegn, og filteret må samsvare med hele navnet: raw_* hopper over raw_01.", + "One third of a zone darker": "En tredjedels sone mørkere", + "One third of a zone lighter": "En tredjedels sone lysere", + "One white-light exposure plus an infrared exposure, with no calibration: Meter sets the camera's exposure.": "Én eksponering med hvitt lys pluss en infrarød eksponering, uten kalibrering: Måler angir kameraets eksponering.", + "One white-light exposure with no calibration: Meter sets the camera's exposure.": "Én eksponering med hvitt lys uten kalibrering: Måler angir kameraets eksponering.", + "Only one frame here, so there is nothing to apply to.": "Det er bare ett bilde her, så det er ingenting å bruke det på.", + "Only the settings that are on change; the rest of each frame's edit stays": "Bare innstillingene som er på, endres; resten av redigeringen av hvert bilde blir stående", + "Open": "Åpne", + "Open Preferences": "Åpne innstillinger", + "Open Releases Page": "Åpne utgivelsessiden", + "Open Roll Settings": "Åpne rullinnstillinger", + "Open a frame to pin it as the reference": "Åpne et bilde for å feste det som referanse", + "Open a frame: double-click a roll, or load the demo negative.": "Åpne et bilde: dobbeltklikk en rull, eller last inn demonegativet.", + "Open a whole frame of the chart to measure it.": "Åpne et helt bilde av kartet for å måle det.", + "Open or save a roll to use Roll Analysis.": "Åpne eller lagre en rull for å bruke rullanalyse.", + "Open or save a roll to use scenes.": "Åpne eller lagre en rull for å bruke scener.", + "Open the Library tab": "Åpne fanen Bibliotek", + "Open the Tools menu: every canvas tool": "Åpne Verktøy-menyen: hvert lerretsverktøy", + "Open the crosstalk matrix editor.": "Åpne redigeringsverktøyet for krysstalematrisen.", + "Open the full guide for this panel": "Åpne hele veiledningen for dette panelet", + "Open the live view to frame and focus": "Åpne direktevisningen for å ramme inn og fokusere", + "Open the output folder as a roll in the Library, so the Roll tab and Roll Analysis work as you scan.": "Åpne utdatamappen som en rull i biblioteket, så fanen Rull og rullanalyse virker mens du skanner.", + "Open the window that reorders which parameter each round proposes": "Åpne vinduet som endrer rekkefølgen på hvilken parameter hver runde foreslår", + "Open the {title} guide": "Åpne veiledningen for {title}", + "Open this roll first, because Roll Analysis measures the loaded files.": "Åpne denne rullen først, fordi rullanalyse måler de innlastede filene.", + "Open this roll first, because the baseline goes onto the loaded files.": "Åpne denne rullen først, fordi grunnlaget legges på de innlastede filene.", + "Opening Live View to meter…": "Åpner direktevisning for måling…", + "Optical Hair Threshold": "Optisk hårterskel", + "Optical Removal": "Optisk fjerning", + "Optical Removal: find and remove dust without an infrared channel, and right-drag on the picture to protect an area.": "Optisk fjerning: finn og fjern støv uten en infrarød kanal, og høyredra på bildet for å beskytte et område.", + "Optical Size": "Optisk størrelse", + "Optical Spot Threshold": "Optisk flekkterskel", + "Optics": "Optikk", + "Or drop pictures or a folder onto the window": "Eller slipp bilder eller en mappe på vinduet", + "Orange #21": "Oransje #21", + "Order": "Rekkefølge", + "Original": "Opprinnelig", + "Original Size (1:1)": "Opprinnelig størrelse (1:1)", + "Original capture": "Opprinnelig opptak", + "Original metadata will be copied from the source file on export.": "Opprinnelige metadata kopieres fra kildefilen ved eksport.", + "Original size (100%): one scan pixel per screen pixel, with exact fine detail only in HQ": "Opprinnelig størrelse (100 %): én skannepiksel per skjermpiksel, med nøyaktige fine detaljer bare i HQ", + "Other": "Annet", + "Other format": "Annet format", + "Output": "Utdata", + "Output & Records": "Utdata og poster", + "Output Intent": "Utdatahensikt", + "Output file format, where Mono writes one gray plane, for film with a single record.": "Filformat for utdata, der Mono skriver ett gråplan, for film med én enkelt post.", + "Output folder does not exist: {folder}": "Utdatamappen finnes ikke: {folder}", + "Output folder does not exist: {output_folder}": "Utdatamappen finnes ikke: {output_folder}", + "Output folder…": "Utdatamappe…", + "Outside the paper's range at this grade and exposure, so the closest print is shown.": "Utenfor papirets område med denne graderingen og eksponeringen, så den nærmeste kopien vises.", + "Oval": "Oval", + "Oval Mask": "Oval maske", + "Oval: drag out an oval, then use its handles to move, stretch and turn it.": "Oval: dra ut en oval, og bruk så håndtakene til å flytte, strekke og dreie den.", + "Overlay": "Overlegg", + "Overlays": "Overlegg", + "Overlays: marks drawn over the frame that never change the edit": "Overlegg: merker tegnet over bildet som aldri endrer redigeringen", + "Overwrite": "Overskriv", + "Overwrite All": "Overskriv alle", + "Overwrite Existing Files": "Overskriv eksisterende filer", + "PNG Compression": "PNG-komprimering", + "PRESET": "FORHÅNDSINNSTILLING", + "PRESETS": "FORHÅNDSINNSTILLINGER", + "PRINTING NOTES": "KOPIERINGSNOTATER", + "PROFILES": "PROFILER", + "Paint over dust; only the marks inside the brush are repaired": "Mal over støv; bare merkene innenfor penselen repareres", + "Paint over the defect; the dashed circle marks the source, Alt-click picks a new one": "Mal over feilen; den stiplede sirkelen markerer kilden, Alt-klikk velger en ny", + "Paint over the defect; the dashed circle marks the source.": "Mal over feilen; den stiplede sirkelen markerer kilden.", + "Panel layout reset": "Panelutformingen er tilbakestilt", + "Paper": "Papir", + "Paper Black": "Papirsvart", + "Paper Black: show the black of real paper, a little lighter than pure black.": "Papirsvart: vis svarttonen til ekte papir, litt lysere enn rent svart.", + "Paper Ratio": "Papirforhold", + "Paper Response": "Papirrespons", + "Paper Test Strip: print the frame on every paper side by side, then keep the one you like.": "Prøvestripe for papir: kopier bildet på hvert papir side om side, og behold det du liker.", + "Paper White": "Papirhvitt", + "Paper White matches the border to the paper color; Custom uses the color you pick.": "Papirhvitt tilpasser kanten til papirfargen; Egendefinert bruker fargen du velger.", + "Paper White: show the white of real paper, a little darker than pure white.": "Papirhvitt: vis hvitfargen til ekte papir, litt mørkere enn rent hvitt.", + "Paper height; drag the sheet's top or bottom edge to change it": "Papirhøyde; dra den øvre eller nedre kanten av arket for å endre den", + "Paper ratio": "Papirforhold", + "Paper test strip": "Prøvestripe for papir", + "Paper test strip ready. Click a paper to see it on the whole frame.": "Prøvestripen for papir er klar. Klikk et papir for å se det på hele bildet.", + "Paper test strip: the frame on every print stock": "Prøvestripe for papir: bildet på hvert kopipapir", + "Paper width; drag the sheet's left or right edge to change it": "Papirbredde; dra den venstre eller høyre kanten av arket for å endre den", + "Parade": "Parade", + "Parade Overlay: R, G and B on one plot, off under this Color vision because its fringes tell the channels apart by hue alone": "Paradeoverlegg: R, G og B i ett plott, av med dette fargesynet fordi kantene skiller kanalene bare ved fargetone", + "Parade Overlay: R, G and B on one plot, which needs Base Relative on the scan": "Paradeoverlegg: R, G og B i ett plott, som krever Baserelativ på skanningen", + "Parade Overlay: R, G and B on one plot, white where they agree": "Paradeoverlegg: R, G og B i ett plott, hvitt der de stemmer overens", + "Parade: red, green and blue side by side, where a gray subject sits at one height in all three.": "Parade: rød, grønn og blå side om side, der et grått motiv ligger i samme høyde i alle tre.", + "Passes": "Passeringer", + "Paste Settings": "Lim inn innstillinger", + "Paste Settings…": "Lim inn innstillinger…", + "Paste a coordinate pair or a map link for the capture place, or pick one with the map button.": "Lim inn et koordinatpar eller en kartlenke for opptaksstedet, eller velg ett med kartknappen.", + "Paste settings": "Lim inn innstillinger", + "Paste the copied settings onto this frame, or every selected frame": "Lim inn de kopierte innstillingene på dette bildet, eller på hvert valgte bilde", + "Pasted": "Limt inn", + "Path": "Bane", + "Peek": "Titt", + "Peek Embedded Preview": "Titt på innebygd forhåndsvisning", + "Peek Embedded Preview: the camera's own JPEG of this shot, for comparison.": "Titt på innebygd forhåndsvisning: kameraets egen JPEG av dette bildet, til sammenligning.", + "Peek Flat Scan": "Titt på flat skanning", + "Peek Negative": "Titt på negativ", + "Peek Negative: the scan as loaded, before inversion and edits.": "Titt på negativ: skanningen slik den ble lastet inn, før invertering og redigeringer.", + "Peek flat scan (digital intermediate); hold to peek only while down": "Titt på flat skanning (digitalt mellomledd); hold for å titte bare mens tasten er nede", + "Peek flat scan: show the flat master for a moment, with no change to your edit": "Titt på flat skanning: vis den flate masteren et øyeblikk, uten endring av redigeringen din", + "Peek negative (source as loaded); hold to peek only while down": "Titt på negativ (kilden slik den ble lastet inn); hold for å titte bare mens tasten er nede", + "Peek the camera's embedded preview; hold to peek only while down": "Titt på kameraets innebygde forhåndsvisning; hold for å titte bare mens tasten er nede", + "Peek the roll average; hold to peek only while down": "Titt på rullens gjennomsnitt; hold for å titte bare mens tasten er nede", + "Performance": "Ytelse", + "Persistent Settings": "Faste innstillinger", + "Persistent Settings chooses which edits carry onto the next file you open.": "Faste innstillinger velger hvilke redigeringer som følger med til neste fil du åpner.", + "Persistent Settings…": "Faste innstillinger…", + "Phi Grid": "Phi-rutenett", + "Photo": "Foto", + "Pick Better": "Velg bedre", + "Pick Better ready. Click the better version.": "Velg bedre er klar. Klikk den beste versjonen.", + "Pick Better: pick the better of two images": "Velg bedre: velg det beste av to bilder", + "Pick Better: shows two versions beside your edit, and each click on the better one tunes one control.": "Velg bedre: viser to versjoner ved siden av redigeringen din, og hvert klikk på den beste finjusterer én kontroll.", + "Pick Frames": "Velg bilder", + "Pick Heal and click a dust speck on the picture.": "Velg Lege og klikk et støvkorn på bildet.", + "Pick New Source": "Velg ny kilde", + "Pick a format and size on File, then click Export at the bottom; its arrow picks this frame, the selection or all frames. Flat exports a flat master for other editors.": "Velg et format og en størrelse under Fil, og klikk så Eksporter nederst; pilen velger dette bildet, utvalget eller alle bilder. Flat eksporterer en flat master for andre redigeringsprogrammer.", + "Pick a new source with the next click on the photo": "Velg en ny kilde med neste klikk på bildet", + "Pick a preset: one of your own, or a built-in one added under +.": "Velg en forhåndsinnstilling: en av dine egne, eller en innebygd lagt til under +.", + "Pick a print stock other than Digital and compare the print.": "Velg et annet kopipapir enn Digital og sammenlign kopien.", + "Pick a reference image and save it as a named profile": "Velg et referansebilde og lagre det som en navngitt profil", + "Pick by Hand": "Velg selv", + "Pick by Hand: click once the frame has rendered": "Velg selv: klikk når bildet er gjengitt", + "Pick on a map, or paste coordinates": "Velg på et kart, eller lim inn koordinater", + "Pick the capture place on a map (contacts OpenStreetMap)": "Velg opptaksstedet på et kart (kontakter OpenStreetMap)", + "Pick the custom border color…": "Velg den egendefinerte kantfargen…", + "Pick the file for this channel": "Velg filen for denne kanalen", + "Pick the three exposures of a trichrome frame by hand": "Velg de tre eksponeringene for et Trichrome-bilde selv", + "Pick the {value} you own from the built-in list, or add a custom one.": "Velg {value} du eier fra den innebygde listen, eller legg til en egendefinert.", + "Pick three bare-light exposures, red only, green only and blue only, with no film in the holder and the light and camera settings you scan with. Expose just below clipping. With a tethered camera and a Scanlight, Capture from Camera… shoots them for you.": "Velg tre eksponeringer av bart lys, bare rødt, bare grønt og bare blått, uten film i holderen og med lys- og kamerainnstillingene du skanner med. Eksponer like under klipping. Med et tilkoblet kamera og en Scanlight tar Ta opp fra kamera… dem for deg.", + "Pick three different files for red, green and blue": "Velg tre ulike filer for rød, grønn og blå", + "Pick which of the current settings to store as a new preset": "Velg hvilke av de gjeldende innstillingene som skal lagres som en ny forhåndsinnstilling", + "Picked: {values}": "Valgt: {values}", + "Picking a roll loads its saved baseline onto the loaded files.": "Når du velger en rull, lastes det lagrede grunnlaget inn på de innlastede filene.", + "Pin {value} · reads {measured}": "Nål {value} · leser {measured}", + "Pixels": "Piksler", + "Place": "Sted", + "Place Zones": "Plasser soner", + "Plumb {value:+.2f}°": "Lodd {value:+.2f}°", + "Plus an infrared exposure at the IR Shutter, for a light with infrared LEDs and a camera that passes 850 nm.": "Pluss en infrarød eksponering ved IR-lukker, for et lys med infrarøde LED-er og et kamera som slipper gjennom 850 nm.", + "Positive": "Positiv", + "Positive, slides only: the file is already a positive, such as a scanned print or another app's export.": "Positiv, bare dias: filen er allerede et positiv, for eksempel en skannet kopi eller en eksport fra en annen app.", + "Preferences": "Innstillinger", + "Preferences: interface, performance and storage settings for the whole app": "Innstillinger: innstillinger for grensesnitt, ytelse og lagring for hele appen", + "Preferences…": "Innstillinger…", + "Preflash": "Forbelysning", + "Preflash down": "Forbelysning ned", + "Preflash up": "Forbelysning opp", + "Preflash ↑/↓": "Forbelysning ↑/↓", + "Preflash: brings back detail in the brightest areas, and also lowers contrast a little.": "Forbelysning: henter tilbake detaljer i de lyseste områdene, og senker også kontrasten litt.", + "Preparing long exposure": "Forbereder lang eksponering", + "Prescan": "Forhåndsskanning", + "Prescan and set crop (Plustek)": "Forhåndsskann og angi beskjæring (Plustek)", + "Prescan canceled": "Forhåndsskanningen ble avbrutt", + "Prescan failed": "Forhåndsskanningen mislyktes", + "Prescan returned no image": "Forhåndsskanningen returnerte ikke noe bilde", + "Prescan — Set Crop": "Forhåndsskanning — angi beskjæring", + "Prescanning at 1200 dpi… %p%": "Forhåndsskanner med 1200 dpi… %p%", + "Prescan…": "Forhåndsskann…", + "Preset": "Forhåndsinnstilling", + "Preset Name": "Navn på forhåndsinnstilling", + "Preset applied": "Forhåndsinnstillingen er brukt", + "Presets": "Forhåndsinnstillinger", + "Presets are off while the output intent is Linear.": "Forhåndsinnstillinger er av mens utdatahensikten er Lineær.", + "Press Esc to close.": "Trykk Esc for å lukke.", + "Press {value} and type a word such as “grade”.": "Trykk {value} og skriv et ord som «gradering».", + "Press {value} for the split. Press it again to close it.": "Trykk {value} for delingen. Trykk igjen for å lukke den.", + "Press {value} to keep this frame.": "Trykk {value} for å beholde dette bildet.", + "Press {value} to make a test strip.": "Trykk {value} for å lage en prøvestripe.", + "Preview": "Forhåndsvisning", + "Preview All": "Forhåndsvis alle", + "Preview DPI": "DPI for forhåndsvisning", + "Preview Export Size": "Forhåndsvis eksportstørrelse", + "Preview Flat": "Forhåndsvis flat", + "Preview Strength": "Forhåndsvisningsstyrke", + "Preview Strip — Set a Window per Frame": "Forhåndsvis stripe — angi et vindu per bilde", + "Preview Strip…": "Forhåndsvis stripe…", + "Preview a frame and set the scan window reused for every frame": "Forhåndsvis et bilde og angi skannevinduet som brukes for hvert bilde", + "Preview a frame to set its window": "Forhåndsvis et bilde for å angi vinduet", + "Preview a frame, drag on it to crop, and turn on the frames to scan.": "Forhåndsvis et bilde, dra på det for å beskjære, og slå på bildene som skal skannes.", + "Preview cache": "Hurtigbuffer for forhåndsvisning", + "Preview cache limit": "Grense for hurtigbufferen for forhåndsvisning", + "Preview canceled": "Forhåndsvisningen ble avbrutt", + "Preview done. Failed {value}: {failed}": "Forhåndsvisningen er ferdig. Mislyktes {value}: {failed}", + "Preview each frame, set a window per frame, and pick which frames to scan": "Forhåndsvis hvert bilde, angi et vindu per bilde, og velg hvilke bilder som skal skannes", + "Preview every frame position at the preview resolution.": "Forhåndsvis hver bildeposisjon med forhåndsvisningsoppløsningen.", + "Preview failed": "Forhåndsvisningen mislyktes", + "Preview failed: {error}": "Forhåndsvisningen mislyktes: {error}", + "Preview failed: {msg}": "Forhåndsvisningen mislyktes: {msg}", + "Preview frame {frame}": "Forhåndsvis bilde {frame}", + "Preview size": "Forhåndsvisningsstørrelse", + "Preview the current holder position and set a crop window for the scan": "Forhåndsvis den gjeldende holderposisjonen og angi et beskjæringsvindu for skanningen", + "Preview the holder, then drag to crop: a corner resizes, the inside moves. Apply Window keeps it, and Scan Frame scans now.": "Forhåndsvis holderen, og dra for å beskjære: et hjørne endrer størrelsen, innsiden flytter. Bruk vindu beholder det, og Skann bilde skanner nå.", + "Preview — Set the Scan Window": "Forhåndsvisning — angi skannevinduet", + "Previewing frame {frame}…": "Forhåndsviser bilde {frame}…", + "Previewing {value}…": "Forhåndsviser {value}…", + "Previewing…": "Forhåndsviser…", + "Previewing… %p%": "Forhåndsviser… %p%", + "Preview…": "Forhåndsvis…", + "Previous": "Forrige", + "Previous file": "Forrige fil", + "Previous sheet": "Forrige ark", + "Print": "Kopi", + "Print Density": "Kopitetthet", + "Print Density Target": "Mål for kopitetthet", + "Print Density ↑/↓": "Kopitetthet ↑/↓", + "Print Preset": "Forhåndsinnstilling for kopi", + "Print Size": "Kopistørrelse", + "Print Stock": "Kopipapir", + "Print each frame with its own edit, or print the whole roll at one exposure and grade as a straight proof.": "Kopier hvert bilde med sin egen redigering, eller kopier hele rullen med én eksponering og gradering som en rett prøve.", + "Print film": "Kopifilm", + "Print normally, with no alternative process.": "Kopier normalt, uten alternativ prosess.", + "Print on this paper: make it the Print stock": "Kopier på dette papiret: gjør det til kopipapiret", + "Print on white paper with dark labels, to save ink on a home printer": "Kopier på hvitt papir med mørke etiketter, for å spare blekk på en hjemmeskriver", + "Print or Peek": "Kopi eller titt", + "Print resolution in dots per inch": "Kopioppløsning i punkter per tomme", + "Print stock": "Kopipapir", + "Print the frames on film strips with their perforations and edge print; off lays them upright in rows, as on the Light Table": "Kopier bildene på filmstriper med perforering og kantpåskrift; av legger dem stående i rader, som på lysbordet", + "Print the maker's edge markings: stock name, frame numbers and the DX barcode": "Kopier produsentens kantmerking: filmnavn, bildenumre og DX-strekkoden", + "Print the roll name, film, developer, camera and date above the strips": "Kopier rullnavn, film, fremkaller, kamera og dato over stripene", + "Print {size:g} cm @ {dpi} dpi": "Kopi {size:g} cm @ {dpi} dpi", + "Print: the picture as you see it.": "Kopi: bildet slik du ser det.", + "Printing": "Kopiering", + "Printing Aids": "Kopieringshjelpemidler", + "Printing Notes": "Kopieringsnotater", + "Printing Pick Better…": "Kopierer Velg bedre…", + "Printing notes (dodge/burn map + print recipe)": "Kopieringsnotater (kart over skygging/etterbelysning + kopioppskrift)", + "Printing notes need a rendered frame": "Kopieringsnotater krever et gjengitt bilde", + "Printing notes saved: {value}": "Kopieringsnotater lagret: {value}", + "Printing test strip…": "Kopierer prøvestripe…", + "Printing the color ring-around…": "Kopierer fargeringen…", + "Printing the paper test strip…": "Kopierer prøvestripen for papir…", + "Printing the test strip…": "Kopierer prøvestripen…", + "Prints on the roll average.": "Kopieres etter rullens gjennomsnitt.", + "Prints on the roll average. Its color is far from the other frames.": "Kopieres etter rullens gjennomsnitt. Fargen er langt fra de andre bildene.", + "Prints on {stock}.": "Kopieres på {stock}.", + "Probe at Cursor": "Sonde ved pekeren", + "Probe at cursor (zone, print density and ΔD beside the pointer)": "Sonde ved pekeren (sone, kopitetthet og ΔD ved siden av pekeren)", + "Process": "Prosess", + "Processes": "Prosesser", + "Processing…": "Behandler…", + "Profile": "Profil", + "Profile name": "Profilnavn", + "Profile name:": "Profilnavn:", + "Progressive": "Progressiv", + "Proof": "Prøve", + "Proof on Screen": "Prøvetrykk på skjerm", + "Proof: {value}": "Prøve: {value}", + "Proofing a different profile than the export writes": "Prøvetrykket bruker en annen profil enn den eksporten skriver", + "Protanopia / deuteranopia (red-green)": "Protanopi / deuteranopi (rød-grønn)", + "Protect Original Metadata": "Beskytt opprinnelige metadata", + "Protect Original Metadata: export the source file's own metadata unchanged, without the Shot and Process cards.": "Beskytt opprinnelige metadata: eksporter kildefilens egne metadata uendret, uten kortene Opptak og Prosess.", + "Pull -1": "Pull -1", + "Pull -2": "Pull -2", + "Pull -3": "Pull -3", + "Push +1": "Push +1", + "Push +2": "Push +2", + "Push +3": "Push +3", + "Push / Pull": "Push / Pull", + "Push / pull": "Push / pull", + "Put back every frame and roll value the last Apply, Paste, Reset or Roll push changed": "Sett tilbake hver bilde- og rullverdi som forrige Bruk, Lim inn, Tilbakestill eller Rull-dytting endret", + "Puts more of the Dye Separation change on dull colors and less on strong ones.": "Legger mer av endringen i fargestoffseparasjon på matte farger og mindre på sterke.", + "Quality": "Kvalitet", + "Quality loss: a higher value makes a smaller file.": "Kvalitetstap: en høyere verdi gir en mindre fil.", + "Queued asset discovery until {_active_batch_title} finishes": "Søk etter elementer er satt i kø til {_active_batch_title} er ferdig", + "REFERENCE": "REFERANSE", + "REFERENCE · {_name}": "REFERANSE · {_name}", + "RGB LED light, as in a Nikon Coolscan or Kodak Pakon.": "RGB-LED-lys, som i en Nikon Coolscan eller Kodak Pakon.", + "ROLLS": "RULLER", + "ROTATE AND FLIP": "ROTER OG SPEILVEND", + "Radius": "Radius", + "Radius of the manual heal brush": "Radius for den manuelle legepenselen", + "Raise to print the middle tones darker, or lower to print them brighter.": "Øk for å kopiere mellomtonene mørkere, eller senk for å kopiere dem lysere.", + "Randomize": "Tilfeldig", + "Ratio": "Forhold", + "Raw Decode": "RAW-dekoding", + "Re-capture the current frame without advancing the counter": "Ta opp det gjeldende bildet på nytt uten å gå videre i telleren", + "Re-cutting {value}…": "Skjærer {value} på nytt…", + "Re-exposure": "Ny eksponering", + "Re-exposure higher": "Ny eksponering høyere", + "Re-exposure lower": "Ny eksponering lavere", + "Re-exposure ↑/↓": "Ny eksponering ↑/↓", + "Read More…": "Les mer…", + "Read one line per pass: slower, and free of line registration": "Les én linje per passering: tregere, og uten linjeregistrering", + "Reading capture dates…": "Leser opptaksdatoer…", + "Reading the three exposures": "Leser de tre eksponeringene", + "Reading the {title} Panel": "Les panelet {title}", + "Readouts": "Avlesninger", + "Reads per line the scanner averages": "Avlesninger per linje som skanneren tar gjennomsnittet av", + "Reads the scan linear, with no white balance.": "Leser skanningen lineært, uten hvitbalanse.", + "Reanalyze": "Analyser på nytt", + "Rebate Trim": "Justering av filmkant", + "Rebuilds blown highlights in a camera RAW: Blend suits sun and sky, and Reconstruct is stronger but can get the color wrong.": "Gjenoppbygger utbrente høylys i en kamera-RAW: Blanding passer sol og himmel, og Rekonstruer er sterkere, men kan gi feil farge.", + "Reconstruct": "Rekonstruer", + "Recovery": "Gjenoppretting", + "Red": "Rød", + "Red #25": "Rød #25", + "Red LED level": "Nivå for rød LED", + "Red Survival": "Rød overlevelse", + "Red faded more than this range covers. Lower Red Survival by eye.": "Rødt har falmet mer enn dette området dekker. Senk Rød overlevelse etter øyemål.", + "Red, green and blue at full power, at the camera's shutter: the Aperture Priority Meter sets it, or + calibrates a stock.": "Rød, grønn og blå med full styrke, med kameraets lukkertid: Måler med blenderprioritet angir den, eller + kalibrerer en filmtype.", + "Red, green and blue at full power, one exposure each, plus infrared, with no calibration: Meter sets the camera's exposure.": "Rød, grønn og blå med full styrke, én eksponering hver, pluss infrarød, uten kalibrering: Måler angir kameraets eksponering.", + "Red, green and blue at full power, one exposure each, with no calibration: Meter sets the camera's exposure.": "Rød, grønn og blå med full styrke, én eksponering hver, uten kalibrering: Måler angir kameraets eksponering.", + "Redo": "Gjør om", + "Reduce Export Memory Use": "Reduser minnebruk ved eksport", + "Reduce Export Memory Use applies to the next export started, no restart needed.": "Reduser minnebruk ved eksport gjelder for neste eksport som startes, uten omstart.", + "Reference View": "Referansevisning", + "Reference images ({wildcards});;All files (*)": "Referansebilder ({wildcards});;Alle filer (*)", + "Reference view: pin this frame beside the canvas to match others to it": "Referansevisning: fest dette bildet ved siden av lerretet for å tilpasse andre til det", + "Reference view: pin this frame beside the canvas, to match others to it": "Referansevisning: fest dette bildet ved siden av lerretet, for å tilpasse andre til det", + "Refresh": "Oppdater", + "Refresh device list": "Oppdater enhetslisten", + "Refresh, Discovery Filters and Index Library": "Oppdater, søkefiltre og indekser biblioteket", + "Regex mode": "Regex-modus", + "Register red and blue to the green exposure, to a fraction of a pixel, to remove fringing from capture drift.": "Registrer rød og blå mot den grønne eksponeringen, til en brøkdel av en piksel, for å fjerne fargekanter fra avdrift under opptaket.", + "Reject": "Forkast", + "Reject frame (skipped by batch export)": "Forkast bildet (hoppes over ved satsvis eksport)", + "Reject the selected frames: batch export skips them": "Forkast de valgte bildene: satsvis eksport hopper over dem", + "Reject {value}": "Forkast {value}", + "Release Notes on GitHub": "Utgivelsesnotater på GitHub", + "Remove": "Fjern", + "Remove Stain": "Fjern flekk", + "Remove Stain: makes the clear areas white again, for a faded slide whose highlights turned yellow.": "Fjern flekk: gjør de klare områdene hvite igjen, for et falmet dias der høylysene har blitt gule.", + "Remove all {value} from this image?": "Vil du fjerne alle {value} fra dette bildet?", + "Remove dust and scratches with the infrared channel while scanning, baked into the file": "Fjern støv og riper med den infrarøde kanalen under skanningen, bakt inn i filen", + "Remove every window (scan full frames)": "Fjern hvert vindu (skann hele bilder)", + "Remove from Roll": "Fjern fra rull", + "Remove from Roll…": "Fjern fra rull…", + "Remove from Scene": "Fjern fra scene", + "Remove the built-in preset from the list; it stays under + › Built-in Presets": "Fjern den innebygde forhåndsinnstillingen fra listen; den blir værende under + › Innebygde forhåndsinnstillinger", + "Remove the selected frames from the roll…": "Fjern de valgte bildene fra rullen…", + "Remove the selected profile": "Fjern den valgte profilen", + "Remove the {value} from “{roll_name}”?": "Vil du fjerne {value} fra «{roll_name}»?", + "Remove this camera from your gear": "Fjern dette kameraet fra utstyret ditt", + "Remove this film stock from your gear": "Fjern denne filmtypen fra utstyret ditt", + "Remove this frame from the session and keep its saved edit": "Fjern dette bildet fra økten og behold den lagrede redigeringen", + "Remove this frame from “{roll_name}”?": "Vil du fjerne dette bildet fra «{roll_name}»?", + "Remove this lens from your gear": "Fjern dette objektivet fra utstyret ditt", + "Remove this pin": "Fjern denne nålen", + "Remove this process from your gear": "Fjern denne prosessen fra utstyret ditt", + "Remove this scan setup from your gear": "Fjern dette skanneoppsettet fra utstyret ditt", + "Remove {value} from the scene": "Fjern {value} fra scenen", + "Removed {value}": "Fjernet {value}", + "Removed {value} from the roll": "Fjernet {value} fra rullen", + "Removed “{name}” from the list; it stays under + › Built-in Presets": "Fjernet «{name}» fra listen; den blir værende under + › Innebygde forhåndsinnstillinger", + "Removes colored speckles and keeps the film grain.": "Fjerner fargede prikker og beholder filmkornet.", + "Removes the leak exactly, but colors that the film barely passes can print speckled.": "Fjerner lekkasjen nøyaktig, men farger som filmen så vidt slipper gjennom, kan bli prikkete.", + "Rename": "Gi nytt navn", + "Rename All": "Gi alle nytt navn", + "Rename Roll": "Gi rullen nytt navn", + "Rename Scene": "Gi scenen nytt navn", + "Rename Scene…": "Gi scenen nytt navn…", + "Rename Work Print": "Gi arbeidskopien nytt navn", + "Rename the Folder Too": "Gi også mappen nytt navn", + "Rename the actual folder as well, in place": "Gi også den faktiske mappen nytt navn, på stedet", + "Rename the preset, or change which fields it stores": "Gi forhåndsinnstillingen nytt navn, eller endre hvilke felt den lagrer", + "Renames the actual folder, in place. Inside a cloud-sync folder (Dropbox, iCloud, OneDrive), a sync client may see this as a delete and re-upload rather than a rename.": "Gir den faktiske mappen nytt navn, på stedet. I en mappe som synkroniseres til skyen (Dropbox, iCloud, OneDrive), kan en synkroniseringsklient se dette som en sletting og ny opplasting i stedet for et nytt navn.", + "Rename…": "Gi nytt navn…", + "Render Exposure": "Gjengivelseseksponering", + "Render Exposure ↑/↓": "Gjengivelseseksponering ↑/↓", + "Render exposure down": "Gjengivelseseksponering ned", + "Render exposure up": "Gjengivelseseksponering opp", + "Render out-of-date thumbnails again in the background": "Gjengi utdaterte miniatyrbilder på nytt i bakgrunnen", + "Render the full-resolution scan instead of a downscaled preview": "Gjengi skanningen i full oppløsning i stedet for en nedskalert forhåndsvisning", + "Render the pipeline on the GPU": "Gjengi prosesseringen på GPU-en", + "Render the source frame before syncing bounds": "Gjengi kildebildet før grensene synkroniseres", + "Render this frame before syncing its bounds": "Gjengi dette bildet før grensene synkroniseres", + "Rendered frames": "Gjengitte bilder", + "Rendered frames held for navigating back with no re-render.": "Gjengitte bilder som holdes for å navigere tilbake uten ny gjengivelse.", + "Rendering the frame with each trial matrix…": "Gjengir bildet med hver prøvematrise…", + "Rendering the frame…": "Gjengir bildet…", + "Rendering the merge as {value}": "Gjengir sammenslåingen som {value}", + "Rendering the merge at the bracket middle": "Gjengir sammenslåingen ved midten av serien", + "Reopen your last session ({value})?": "Vil du åpne forrige økt på nytt ({value})?", + "Repair": "Reparer", + "Repeats the same exposure and stacks the results to reduce noise, slower and best for a well-exposed frame.": "Gjentar samme eksponering og stabler resultatene for å redusere støy, tregere og best for et godt eksponert bilde.", + "Replace Built-in Space": "Erstatt innebygd fargerom", + "Replace Look": "Erstatt utseende", + "Replace Preset": "Erstatt forhåndsinnstilling", + "Replace Work Print": "Erstatt arbeidskopi", + "Replace an existing file without asking, instead of asking first.": "Erstatt en eksisterende fil uten å spørre, i stedet for å spørre først.", + "Replay the guided feature tour": "Spill av den veiledede omvisningen på nytt", + "Report an Issue…": "Rapporter et problem…", + "Requires Linear RAW, for a Single Exposure under narrowband light.": "Krever lineær RAW, for én eksponering under smalbåndslys.", + "Rescan": "Skann på nytt", + "Reset": "Tilbakestill", + "Reset All": "Tilbakestill alle", + "Reset Everything": "Tilbakestill alt", + "Reset Everything…": "Tilbakestill alt…", + "Reset Frames": "Tilbakestill bilder", + "Reset Frames to Defaults": "Tilbakestill bilder til standard", + "Reset Frames to Defaults…": "Tilbakestill bilder til standard…", + "Reset Panel Layout": "Tilbakestill panelutformingen", + "Reset Settings": "Tilbakestill innstillinger", + "Reset Settings: discard all edits and return this frame to its default look": "Tilbakestill innstillinger: forkast alle redigeringer og gi dette bildet standardutseendet tilbake", + "Reset Split to Roll Default": "Tilbakestill delingen til rullens standard", + "Reset every visible frame to defaults": "Tilbakestill hvert synlig bilde til standard", + "Reset the look settings to defaults first, and keep crop, rotation, metadata, export and retouch marks.": "Tilbakestill utseendeinnstillingene til standard først, og behold beskjæring, rotasjon, metadata, eksport og retusjmerker.", + "Reset this frame to the roll's settings": "Tilbakestill dette bildet til rullens innstillinger", + "Reset this tab to defaults": "Tilbakestill denne fanen til standard", + "Reset this tab to the roll's settings": "Tilbakestill denne fanen til rullens innstillinger", + "Reset to Roll Settings": "Tilbakestill til rullens innstillinger", + "Reset to Roll Settings: put the roll's settings back on every card that differs.": "Tilbakestill til rullens innstillinger: sett rullens innstillinger tilbake på hvert kort som er ulikt.", + "Reset to the roll: {value}": "Tilbakestilt til rullen: {value}", + "Reset {count} {frame_word} to defaults": "Tilbakestill {count} {frame_word} til standard", + "Reset {tab}": "Tilbakestill {tab}", + "Reset {title} to defaults": "Tilbakestill {title} til standard", + "Reset {title} to the roll's settings": "Tilbakestill {title} til rullens innstillinger", + "Reset {value} on {tab} to their default settings?": "Vil du tilbakestille {value} på {tab} til standardinnstillingene?", + "Reset {value} to defaults": "Tilbakestill {value} til standard", + "Reset {value} to their default settings?": "Vil du tilbakestille {value} til standardinnstillingene?", + "Reset {value} to their default settings? Frames the filter hides stay as they are.": "Vil du tilbakestille {value} til standardinnstillingene? Bilder som filteret skjuler, blir som de er.", + "Reset {value}…": "Tilbakestill {value}…", + "Resolution": "Oppløsning", + "Resolution of the exported sheet; the file prints at its true size at this DPI": "Oppløsningen til det eksporterte arket; filen skrives ut i sann størrelse med denne DPI-en", + "Resolution used for the preview scan": "Oppløsning som brukes for forhåndsvisningsskanningen", + "Resolution used for the preview scans": "Oppløsning som brukes for forhåndsvisningsskanningene", + "Restart NegPy to apply the changed startup settings.": "Start NegPy på nytt for å ta i bruk de endrede oppstartsinnstillingene.", + "Restore": "Gjenopprett", + "Restore Defaults": "Gjenopprett standardverdier", + "Restore Removed Frames": "Gjenopprett fjernede bilder", + "Restore Removed Frames…": "Gjenopprett fjernede bilder…", + "Restore Session": "Gjenopprett økt", + "Restore the frames removed from the roll…": "Gjenopprett bildene som ble fjernet fra rullen…", + "Restored {value} to “{name}”": "Gjenopprettet {value} i «{name}»", + "Resume Where You Left Off": "Fortsett der du slapp", + "Retake": "Ta på nytt", + "Retake shoots the {color} exposure again over this frame's.": "Ta på nytt tar {color}-eksponeringen igjen over den for dette bildet.", + "Retake the current frame": "Ta det gjeldende bildet på nytt", + "Retake: capture the last frame again, without advancing the frame number": "Ta på nytt: ta opp det siste bildet igjen, uten å gå videre i bildenummeret", + "Retouch": "Retusj", + "Return every card that differs from the roll to the roll's settings": "Sett hvert kort som er ulikt rullen, tilbake til rullens innstillinger", + "Return the strip after a batch, or keep it and its previews loaded when this is off.": "Returner stripen etter en satsvis kjøring, eller behold den og forhåndsvisningene innlastet når dette er av.", + "Reverse Scroll Zoom": "Omvendt rullezoom", + "Reverse scroll-to-zoom direction (scroll up zooms out)": "Omvendt retning for zoom med rulling (rulling opp zoomer ut)", + "Revert the last pick and re-show that pair": "Angre det siste valget og vis det paret igjen", + "Reverted to this roll's shared edit": "Gikk tilbake til denne rullens delte redigering", + "Right": "Høyre", + "Ring-around": "Fargering", + "Ring-around: show the picture as a grid of color variations, then click the one that looks right.": "Fargering: vis bildet som et rutenett av fargevarianter, og klikk den som ser riktig ut.", + "Roll": "Rull", + "Roll Analysis": "Rullanalyse", + "Roll Analysis (measure the roll's baseline)": "Rullanalyse (mål rullens grunnlag)", + "Roll Analysis first crops the frames that wait for Auto Crop.": "Rullanalyse beskjærer først bildene som venter på autobeskjæring.", + "Roll Analysis measures the exposure bounds of every file outside a scene and applies their average to those files, so they share a consistent baseline.": "Rullanalyse måler eksponeringsgrensene for hver fil utenfor en scene og bruker gjennomsnittet på de filene, så de deler et ensartet grunnlag.", + "Roll Analysis: measure every loaded frame and save the average as the roll's baseline.": "Rullanalyse: mål hvert innlastet bilde og lagre gjennomsnittet som rullens grunnlag.", + "Roll Average": "Rullens gjennomsnitt", + "Roll Average: try the average on this frame; right-click turns every average on or off.": "Rullens gjennomsnitt: prøv gjennomsnittet på dette bildet; høyreklikk slår hvert gjennomsnitt på eller av.", + "Roll Baseline": "Rullens grunnlag", + "Roll Label": "Rulletikett", + "Roll Settings": "Rullinnstillinger", + "Roll Settings: tag gear, film and process details on this frame, the selection or the whole roll.": "Rullinnstillinger: merk utstyr, film og prosessdetaljer på dette bildet, utvalget eller hele rullen.", + "Roll analysis complete": "Rullanalysen er fullført", + "Roll average": "Rullens gjennomsnitt", + "Roll average measured again: {value} took the new average": "Rullens gjennomsnitt er målt på nytt: {value} tok det nye gjennomsnittet", + "Roll average {index}/{total}": "Rullens gjennomsnitt {index}/{total}", + "Roll name must be a single safe name (not \".\" or \"..\", and no path separators).": "Rullnavnet må være ett enkelt, trygt navn (ikke \".\" eller \"..\", og ingen baneskilletegn).", + "Roll name, used in export file name templates as {{ roll }}": "Rullnavn, brukt i maler for eksportfilnavn som {{ roll }}", + "Roll subfolder of the output folder; the name also starts each camera frame's file name": "Rullens undermappe i utdatamappen; navnet innleder også filnavnet til hvert kamerabilde", + "Roll tab": "Rullfanen", + "Rolls": "Ruller", + "Rotate 90° CCW": "Roter 90° mot klokken", + "Rotate 90° CW": "Roter 90° med klokken", + "Rotate 90° clockwise.": "Roter 90° med klokken.", + "Rotate 90° counter-clockwise.": "Roter 90° mot klokken.", + "Rotate CCW": "Roter mot klokken", + "Rotate CW": "Roter med klokken", + "Rotate crop guide orientation": "Roter retningen på beskjæringshjelpen", + "Rotate the guide orientation (spiral, triangles)": "Roter retningen på hjelpen (spiral, trekanter)", + "Rotated {total} {frame_word}": "Roterte {total} {frame_word}", + "Rotates all hues, for a light that turns yellows orange or greens olive; keep 0 for white light.": "Roterer alle fargetoner, for et lys som gjør gult oransje eller grønt olivenfarget; behold 0 for hvitt lys.", + "Rotation": "Rotasjon", + "Roughness": "Ruhet", + "Round {round} · {name}": "Runde {round} · {name}", + "Roundness": "Rundhet", + "Run the preview pass again": "Kjør forhåndsvisningspasseringen på nytt", + "Run this preset on every export; off keeps it for later": "Kjør denne forhåndsinnstillingen ved hver eksport; av beholder den til senere", + "SCENES": "SCENER", + "SELECTED MASK": "VALGT MASKE", + "SIZE": "STØRRELSE", + "SPLIT TONING": "DELT TONING", + "STEP {number}": "TRINN {number}", + "Sabattier": "Sabattier", + "Sabattier Strength": "Sabattier-styrke", + "Sabattier Strength ↑/↓": "Sabattier-styrke ↑/↓", + "Sabattier printing on/off": "Sabattier-kopiering på/av", + "Sabattier strength down": "Sabattier-styrke ned", + "Sabattier strength up": "Sabattier-styrke opp", + "Sabattier: a flash of light during development reverses the light tones and draws light lines along edges.": "Sabattier: et lysglimt under fremkallingen snur de lyse tonene og tegner lyse linjer langs kantene.", + "Same as Source": "Samme som kilden", + "Same as source": "Samme som kilden", + "Samples": "Prøver", + "Save": "Lagre", + "Save Flat Field Profile": "Lagre flatfeltprofil", + "Save Manual Preset": "Lagre manuell forhåndsinnstilling", + "Save Preset": "Lagre forhåndsinnstilling", + "Save Proof Preset": "Lagre forhåndsinnstilling for prøve", + "Save Sidecars on Export": "Lagre sidefiler ved eksport", + "Save Work Print": "Lagre arbeidskopi", + "Save Work Print: keep the current edit as a named version": "Lagre arbeidskopi: behold den gjeldende redigeringen som en navngitt versjon", + "Save Work Print…": "Lagre arbeidskopi…", + "Save a JPEG that loads in passes on a web page.": "Lagre en JPEG som lastes inn i passeringer på en nettside.", + "Save a preset from the current settings": "Lagre en forhåndsinnstilling fra de gjeldende innstillingene", + "Save as Virtual Roll": "Lagre som virtuell rull", + "Save as Virtual Roll…": "Lagre som virtuell rull…", + "Save on Export": "Lagre ved eksport", + "Save the current edit as a named work print": "Lagre den gjeldende redigeringen som en navngitt arbeidskopi", + "Save the current proof set-up as a named preset": "Lagre det gjeldende prøveoppsettet som en navngitt forhåndsinnstilling", + "Save the hand-set recipe as a named preset (only while setting one by hand)": "Lagre den manuelt angitte oppskriften som en navngitt forhåndsinnstilling (bare mens du angir en selv)", + "Save the loaded frames as a virtual roll": "Lagre de innlastede bildene som en virtuell rull", + "Save the loaded frames as the folder roll of their folder": "Lagre de innlastede bildene som mapperullen for mappen deres", + "Save the printing notes as a JPEG in the export folder, at full resolution when HQ is on.": "Lagre kopieringsnotatene som en JPEG i eksportmappen, i full oppløsning når HQ er på.", + "Save these frames as a folder roll, or use Save as Virtual Roll… in the ⋮ menu to keep exactly these frames": "Lagre disse bildene som en mapperull, eller bruk Lagre som virtuell rull… i ⋮-menyen for å beholde nøyaktig disse bildene", + "Save these frames as a virtual roll, a fixed list of frames that new files never join": "Lagre disse bildene som en virtuell rull, en fast liste med bilder som nye filer aldri blir med i", + "Save these frames as the folder roll of their folder": "Lagre disse bildene som mapperullen for mappen deres", + "Save to Disk": "Lagre på disk", + "Save to Folder Roll": "Lagre i mapperull", + "Save to Folder Roll: the frames come from more than one folder.": "Lagre i mapperull: bildene kommer fra mer enn én mappe.", + "Saved as folder roll “{name}”": "Lagret som mapperullen «{name}»", + "Saved as virtual roll “{name}”": "Lagret som den virtuelle rullen «{name}»", + "Saved edits (images)": "Lagrede redigeringer (bilder)", + "Saved preset “{name}”": "Lagret forhåndsinnstillingen «{name}»", + "Saved preset “{name}” and its sensor crosstalk profile": "Lagret forhåndsinnstillingen «{name}» og sensorkrysstaleprofilen", + "Saved preset “{name}”, with no sensor crosstalk profile: the probe exposures were too dim to measure one": "Lagret forhåndsinnstillingen «{name}», uten sensorkrysstaleprofil: prøveeksponeringene var for svake til å måle en", + "Saved process": "Lagret prosess", + "Saved sensor profile “{name}”. Select it on a single-shot negative.": "Lagret sensorprofilen «{name}». Velg den på et negativ tatt med ett bilde.", + "Saved sets of metadata values. Apply one to write its fields onto this frame.": "Lagrede sett med metadataverdier. Bruk ett for å skrive feltene til dette bildet.", + "Saved setup": "Lagret oppsett", + "Saved shots of the bare light, used to even out the light.": "Lagrede bilder av det bare lyset, brukt til å jevne ut lyset.", + "Saves to {_out_dir}": "Lagres i {_out_dir}", + "Save…": "Lagre…", + "Scale the whole interface": "Skaler hele grensesnittet", + "Scan": "Skann", + "Scan Frame": "Skann bilde", + "Scan Setup": "Skanneoppsett", + "Scan Setups": "Skanneoppsett", + "Scan a low-DPI preview and set the crop for the next scan": "Skann en forhåndsvisning med lav DPI og angi beskjæringen for neste skanning", + "Scan a separate infrared channel for dust detection": "Skann en egen infrarød kanal for støvoppdaging", + "Scan as Roll": "Skann som rull", + "Scan clip": "Skanneklipping", + "Scan date": "Skannedato", + "Scan density": "Skannetetthet", + "Scan density above film base": "Skannetetthet over filmbasen", + "Scan density above rebate base": "Skannetetthet over basen i filmkanten", + "Scan density above thin end": "Skannetetthet over den tynne enden", + "Scan every frame on the strip": "Skann hvert bilde på stripen", + "Scan exposure time, for a scanner that exposes it": "Eksponeringstid for skanning, for en skanner som har den", + "Scan frame {frame}": "Skann bilde {frame}", + "Scan into the output folder itself and name the roll after it, or into a Roll subfolder when this is off.": "Skann rett inn i utdatamappen og gi rullen samme navn, eller inn i en rullundermappe når dette er av.", + "Scan method": "Skannemetode", + "Scan mode": "Skannemodus", + "Scan now with the current settings": "Skann nå med de gjeldende innstillingene", + "Scan place": "Skannested", + "Scan resolution in dots per inch": "Skanneoppløsning i punkter per tomme", + "Scan resolution in dots per inch; type a value between the listed stops": "Skanneoppløsning i punkter per tomme; skriv en verdi mellom trinnene i listen", + "Scan stopped": "Skanningen stoppet", + "Scan straight into NegPy, from a film scanner (Coolscan, Plustek or SANE) or a camera on a copy stand with a Scanlight. The frames open as a roll while they scan.": "Skann rett inn i NegPy, fra en filmskanner (Coolscan, Plustek eller SANE) eller et kamera på et reprostativ med en Scanlight. Bildene åpnes som en rull mens de skannes.", + "Scan tab": "Skannefanen", + "Scan the frames that are on now, with the current settings": "Skann bildene som er på nå, med de gjeldende innstillingene", + "Scan the full window instead of a crop": "Skann hele vinduet i stedet for en beskjæring", + "Scan the holder at the preview resolution.": "Skann holderen med forhåndsvisningsoppløsningen.", + "Scan the selected frames into the output folder, or stop the scan": "Skann de valgte bildene inn i utdatamappen, eller stopp skanningen", + "Scan the whole default frame instead": "Skann hele standardbildet i stedet", + "Scan the whole frame instead": "Skann hele bildet i stedet", + "Scan too large for the GPU memory. Showing a {capped_long_edge}px preview in place of full resolution.": "Skanningen er for stor for GPU-minnet. Viser en forhåndsvisning på {capped_long_edge} px i stedet for full oppløsning.", + "Scan with a film scanner or a camera on a copy stand": "Skann med en filmskanner eller et kamera på et reprostativ", + "Scan without metering?": "Vil du skanne uten lysmåling?", + "Scan {value}": "Skann {value}", + "Scan, or stop the capture": "Skann, eller stopp opptaket", + "Scan, or stop the capture (second key)": "Skann, eller stopp opptaket (andre tast)", + "Scanlight — Live View": "Scanlight — direktevisning", + "Scanlight — Scan (no live view)": "Scanlight — skann (uten direktevisning)", + "Scanlight: {e}": "Scanlight: {e}", + "Scanlight: {value}": "Scanlight: {value}", + "Scanned frame {frame}: {path}": "Skannet bilde {frame}: {path}", + "Scanned the strip: {frames}. Insert the next strip.": "Skannet stripen: {frames}. Sett inn neste stripe.", + "Scanned: {path}": "Skannet: {path}", + "Scanner": "Skanner", + "Scanner busy: {e}": "Skanneren er opptatt: {e}", + "Scanner transport backend": "Transportmotor for skanneren", + "Scanning": "Skanning", + "Scanning for assets…": "Søker etter elementer…", + "Scanning library…": "Søker gjennom biblioteket…", + "Scanning library… {walked} files": "Søker gjennom biblioteket… {walked} filer", + "Scanning preview at 1200 dpi…": "Skanner forhåndsvisning med 1200 dpi…", + "Scanning setup applied to {count} other {frame_word}": "Skanneoppsettet er brukt på {count} andre {frame_word}", + "Scanning setup is not used for slides.": "Skanneoppsettet brukes ikke for dias.", + "Scanning… %p%": "Skanner… %p%", + "Scan…": "Skann…", + "Scene": "Scene", + "Scene Analysis": "Sceneanalyse", + "Scene Analysis measures the exposure bounds of each scene's files and applies their average to that scene alone.": "Sceneanalyse måler eksponeringsgrensene for filene i hver scene og bruker gjennomsnittet bare på den scenen.", + "Scene Analysis: measure “{value}” and give its frames their own baseline": "Sceneanalyse: mål «{value}» og gi bildene sitt eget grunnlag", + "Scene average measured again: {value} took the new average": "Scenens gjennomsnitt er målt på nytt: {value} tok det nye gjennomsnittet", + "Scene average {index}/{total}": "Scenens gjennomsnitt {index}/{total}", + "Scene “{value}” analyzed": "Scenen «{value}» er analysert", + "Scene: {name}": "Scene: {name}", + "Scratch": "Ripe", + "Scratch: click points along a scratch or hair, then press Enter, or Esc to cancel.": "Ripe: klikk punkter langs en ripe eller et hår, og trykk så Enter, eller Esc for å avbryte.", + "Scroll up zooms out instead of in": "Rulling opp zoomer ut i stedet for inn", + "Search a place, click the map, or paste coordinates or a map link. Opening this dialog contacts OpenStreetMap.": "Søk etter et sted, klikk på kartet, eller lim inn koordinater eller en kartlenke. Når denne dialogen åpnes, kontaktes OpenStreetMap.", + "Search actions, press a shortcut, then choose or Enter…": "Søk i handlinger, trykk en hurtigtast, og velg eller trykk Enter…", + "Search by Meaning": "Søk etter betydning", + "Search by meaning is not ready yet": "Søk etter betydning er ikke klart ennå", + "Search by meaning: describe what you are looking for, in place of field:value terms": "Søk etter betydning: beskriv det du leter etter, i stedet for felt:verdi-uttrykk", + "Search cameras…": "Søk i kameraer…", + "Search film stocks…": "Søk i filmtyper…", + "Search lenses…": "Søk i objektiver…", + "Search presets…": "Søk i forhåndsinnstillinger…", + "Search processes…": "Søk i prosesser…", + "Search rolls…": "Søk i ruller…", + "Search scan setups…": "Søk i skanneoppsett…", + "Search the whole library and load the matches": "Søk i hele biblioteket og last inn treffene", + "Search the whole library and load the matches.": "Søk i hele biblioteket og last inn treffene.", + "Searches the gear that you declared as your own. Pick Other… for the full catalog.": "Søker i utstyret du har oppgitt som ditt eget. Velg Annet… for hele katalogen.", + "Searching library…": "Søker i biblioteket…", + "Searching library… {walked} files": "Søker i biblioteket… {walked} filer", + "Searching…": "Søker…", + "Search…": "Søk…", + "See and clear the data that NegPy stores": "Se og fjern dataene NegPy lagrer", + "Seeing the Print": "Se kopien", + "Select Contact Sheet Output Folder": "Velg utdatamappe for kontaktark", + "Select Export Directory": "Velg eksportmappe", + "Select Exposure": "Velg eksponering", + "Select Flat Field Reference": "Velg flatfeltreferanse", + "Select Folder": "Velg mappe", + "Select Images": "Velg bilder", + "Select Output Folder": "Velg utdatamappe", + "Select Ratio…": "Velg forhold…", + "Select an exposure to shoot again in Live View: R, G, B or IR": "Velg en eksponering som skal tas på nytt i direktevisning: R, G, B eller IR", + "Select frames in the Film Strip, then right-click › Scene › Group as Scene…": "Velg bilder i filmstripen, og høyreklikk så › Scene › Grupper som scene…", + "Select gear or enter process metadata to see a preview.": "Velg utstyr eller skriv inn prosessmetadata for å se en forhåndsvisning.", + "Select output folder for preset '{name}'": "Velg utdatamappe for forhåndsinnstillingen «{name}»", + "Select preset": "Velg forhåndsinnstilling", + "Select scanner": "Velg skanner", + "Select the folder for NegPy data": "Velg mappen for NegPy-data", + "Select the frames of “{value}” in the Film Strip": "Velg bildene i «{value}» i filmstripen", + "Select the gamma encoding of the input TIFF so it can be linearized before export.": "Velg gammakodingen for TIFF-inndataene slik at de kan lineariseres før eksport.", + "Select the {band} bare-light exposure": "Velg eksponeringen av {band} bart lys", + "Select three single frames to merge as a trichrome": "Velg tre enkeltbilder som skal slås sammen til Trichrome", + "Select two or more exposures of the same frame to merge": "Velg to eller flere eksponeringer av samme bilde som skal slås sammen", + "Select two or more frames to stitch": "Velg to eller flere bilder som skal sys sammen", + "Selected": "Valgte", + "Selected Frames ({sel_count})": "Valgte bilder ({sel_count})", + "Selenium": "Selen", + "Selenium down": "Selen ned", + "Selenium toner, B&W negatives only: deeper, cooler blacks.": "Selentoner, bare S/H-negativer: dypere, kaldere svarttoner.", + "Selenium up": "Selen opp", + "Selenium ↑/↓": "Selen ↑/↓", + "Sensitizer": "Sensibilisator", + "Sensor Correction": "Sensorkorreksjon", + "Sensor Crosstalk Profile": "Sensorkrysstaleprofil", + "Sensor crosstalk profile: removes the color leak between channels in a single exposure under narrowband light; run Roll Analysis again after a change.": "Sensorkrysstaleprofil: fjerner fargelekkasjen mellom kanalene i én eksponering under smalbåndslys; kjør rullanalyse på nytt etter en endring.", + "Separation Damping": "Separasjonsdemping", + "Separation Damping down": "Separasjonsdemping ned", + "Separation Damping up": "Separasjonsdemping opp", + "Separation Damping ↑/↓": "Separasjonsdemping ↑/↓", + "Sepia": "Sepia", + "Sepia down": "Sepia ned", + "Sepia toner, B&W negatives only: warm brown tones, from the highlights down.": "Sepiatoner, bare S/H-negativer: varme brune toner, fra høylysene og nedover.", + "Sepia up": "Sepia opp", + "Sepia ↑/↓": "Sepia ↑/↓", + "Sequence…": "Sekvens…", + "Sequential Frame Numbers": "Fortløpende bildenumre", + "Session & Storage": "Økt og lagring", + "Set": "Angi", + "Set Source": "Angi kilde", + "Set Targets…": "Angi mål…", + "Set by Hand": "Angi selv", + "Set in override.toml, which wins over this dialog": "Angitt i override.toml, som går foran denne dialogen", + "Set it for this preset, in the camera's own steps.": "Angi den for denne forhåndsinnstillingen, i kameraets egne trinn.", + "Set shortcuts and keyboard step sizes for slider actions. Search by name or press a shortcut to filter results, then choose or press Enter. Duplicate bindings are rejected. Reset All restores defaults.": "Angi hurtigtaster og tastaturtrinn for glidebryterhandlinger. Søk etter navn eller trykk en hurtigtast for å filtrere resultatene, og velg eller trykk Enter. Dupliserte bindinger avvises. Tilbakestill alle gjenoppretter standardverdiene.", + "Set the ISO and aperture that you scan with. A later change to either one makes every scan from this preset wrong.": "Angi ISO-en og blenderen du skanner med. En senere endring av en av dem gjør hver skanning fra denne forhåndsinnstillingen feil.", + "Set the brightness and contrast that Auto aims for, for every frame.": "Angi lysheten og kontrasten Auto sikter mot, for hvert bilde.", + "Set the levels and the exposure. Scan uses them as they stand; the save button keeps them as a preset.": "Angi nivåene og eksponeringen. Skann bruker dem slik de er; lagreknappen beholder dem som en forhåndsinnstilling.", + "Set the light levels. The save button keeps them as a preset. Connect the camera to set the exposure as well.": "Angi lysnivåene. Lagreknappen beholder dem som en forhåndsinnstilling. Koble til kameraet for også å angi eksponeringen.", + "Set the shutter from one RAW probe: a negative's base or a slide's highlights, just under clipping": "Angi lukkertiden fra én RAW-prøve: basen i et negativ eller høylysene i et dias, like under klipping", + "Set this scan's crop and where it splits.": "Angi beskjæringen for denne skanningen og hvor den deles.", + "Set values once, then apply the groups that are on to the current frame, a selection or the whole roll.": "Angi verdier én gang, og bruk så gruppene som er på, på det gjeldende bildet, et utvalg eller hele rullen.", + "Sets the Survival sliders from the grays in this frame. If it finds no result, set them by hand.": "Angir overlevelsesglidebryterne ut fra de grå tonene i dette bildet. Hvis den ikke finner noe resultat, angir du dem selv.", + "Sets the brightness of each frame for you, so the frames of a roll print alike.": "Angir lysheten i hvert bilde for deg, så bildene i en rull kopieres likt.", + "Sets the contrast of each frame for you, so contrasty and flat negatives both print well.": "Angir kontrasten i hvert bilde for deg, så både kontrastrike og flate negativer kopieres godt.", + "Settings": "Innstillinger", + "Settings copied": "Innstillingene er kopiert", + "Settings for the whole application. Changes apply as you make them, except where a row says otherwise.": "Innstillinger for hele programmet. Endringer trer i kraft med en gang, unntatt der en rad sier noe annet.", + "Settings pasted": "Innstillingene er limt inn", + "Settings that carry onto the next file you open": "Innstillinger som følger med til neste fil du åpner", + "Setup": "Oppsett", + "Shadow Hold": "Hold skygger", + "Shadow Hue": "Skyggetone", + "Shadow Reach": "Skyggerekkevidde", + "Shadow Strength": "Skyggestyrke", + "Shadow hue down": "Skyggetone ned", + "Shadow hue up": "Skyggetone opp", + "Shadow hue ↑/↓": "Skyggetone ↑/↓", + "Shadow strength down": "Skyggestyrke ned", + "Shadow strength up": "Skyggestyrke opp", + "Shadow strength ↑/↓": "Skyggestyrke ↑/↓", + "Shadows": "Skygger", + "Shadows CMY": "Skygger CMY", + "Shadows Density": "Skyggetetthet", + "Shadows Density ↑/↓": "Skyggetetthet ↑/↓", + "Shadows Grade": "Skyggegradering", + "Shadows Grade Trim": "Justering av skyggegradering", + "Shadows Grade ↑/↓": "Skyggegradering ↑/↓", + "Shadows and Highlights": "Skygger og høylys", + "Shadows density down": "Skyggetetthet ned", + "Shadows density up": "Skyggetetthet opp", + "Shadows grade down": "Skyggegradering ned", + "Shadows grade up": "Skyggegradering opp", + "Shape of the crop, for Auto Crop and the crop tool.": "Formen på beskjæringen, for autobeskjæring og beskjæringsverktøyet.", + "Shape: 0 is round, like a lens; 1 follows the edges of the print.": "Form: 0 er rund, som et objektiv; 1 følger kantene på kopien.", + "Shapes the highlights: positive softens them, and negative brightens them and can lose detail.": "Former høylysene: positiv mykner dem, og negativ gjør dem lysere og kan miste detaljer.", + "Shapes the shadows: positive lifts them, and negative makes them deeper.": "Former skyggene: positiv løfter dem, og negativ gjør dem dypere.", + "Share of each channel at the scanner's maximum, which no edit can bring back.": "Andel av hver kanal ved skannerens maksimum, som ingen redigering kan hente tilbake.", + "Share of the picture that prints pure black in the shadows or pure white in the highlights.": "Andel av bildet som kopieres helt svart i skyggene eller helt hvitt i høylysene.", + "Share of the picture that the proof printer or paper cannot print, shown while soft proofing.": "Andel av bildet som prøveskriveren eller papiret ikke kan gjengi, vist under prøvetrykk på skjerm.", + "Share of the scan that dust removal rewrote, which can change the picture itself above 5%.": "Andel av skanningen som støvfjerningen skrev om, noe som kan endre selve bildet over 5 %.", + "Sharpen Masking": "Skarphetsmaskering", + "Sharpen Method": "Skarphetsmetode", + "Sharpen Radius": "Skarphetsradius", + "Sharpening": "Skarphet", + "Sharpening down": "Skarphet ned", + "Sharpening up": "Skarphet opp", + "Sharpening ↑/↓": "Skarphet ↑/↓", + "Sharpens detail without colored edges.": "Gjør detaljer skarpere uten fargede kanter.", + "Sharpens only edges, so sky, skin and grain stay smooth.": "Gjør bare kanter skarpere, så himmel, hud og korn holder seg jevne.", + "Sharpness compared with the best seen: turn the focus ring past the peak and back to find it.": "Skarphet sammenlignet med den beste som er sett: drei fokusringen forbi toppen og tilbake for å finne den.", + "Sheet filter: show frames by their keep and reject marks": "Arkfilter: vis bilder etter behold- og forkast-merkene", + "Sheet {value} of {pages}": "Ark {value} av {pages}", + "Shifts the color between cyan and red.": "Forskyver fargen mellom cyan og rødt.", + "Shifts the color between magenta and green.": "Forskyver fargen mellom magenta og grønt.", + "Shifts the color between yellow and blue.": "Forskyver fargen mellom gult og blått.", + "Shoot Flat Field": "Ta flatfelt", + "Shoot the infrared exposure with each frame": "Ta den infrarøde eksponeringen med hvert bilde", + "Shoot the red, green and blue exposures now?": "Vil du ta den røde, grønne og blå eksponeringen nå?", + "Shoot the red, green and blue exposures with the camera and the Scanlight, then save the profile under the name above.": "Ta den røde, grønne og blå eksponeringen med kameraet og Scanlight, og lagre så profilen under navnet ovenfor.", + "Shooting the Flat Field on the empty dock…": "Tar flatfeltet på den tomme holderen…", + "Shortcut": "Hurtigtast", + "Shortcut Hints": "Hurtigtasthint", + "Shortcut hints (show a control's keys after a mouse edit)": "Hurtigtasthint (vis tastene til en kontroll etter en redigering med musen)", + "Shot": "Opptak", + "Shoulder": "Skulder", + "Shoulder Trim": "Justering av skulder", + "Shoulder Width": "Skulderbredde", + "Shoulder Width Trim": "Justering av skulderbredde", + "Shoulder Width of this color layer only.": "Skulderbredde bare for dette fargelaget.", + "Shoulder Width ↑/↓": "Skulderbredde ↑/↓", + "Shoulder down": "Skulder ned", + "Shoulder up": "Skulder opp", + "Shoulder width down": "Skulderbredde ned", + "Shoulder width up": "Skulderbredde opp", + "Shoulder ↑/↓": "Skulder ↑/↓", + "Show Defaults": "Vis standardverdier", + "Show GPU Memory Warning": "Vis advarsel om GPU-minne", + "Show a message when a scan is reduced to fit the GPU texture cap.": "Vis en melding når en skanning reduseres for å passe teksturgrensen for GPU-en.", + "Show clipping zebras": "Vis klippesebraer", + "Show colors the printer cannot print as gray.": "Vis farger skriveren ikke kan gjengi, som grått.", + "Show every frame, and click one to leave it out or put it back.": "Vis hvert bilde, og klikk ett for å utelate det eller sette det tilbake.", + "Show in Folder": "Vis i mappe", + "Show nkscan.log in its folder": "Vis nkscan.log i mappen sin", + "Show on screen how the picture will print, without a change to the export.": "Vis på skjermen hvordan bildet blir kopiert, uten å endre eksporten.", + "Show or hide this mask's outline on the canvas": "Vis eller skjul omrisset til denne masken på lerretet", + "Show shortcuts": "Vis hurtigtaster", + "Show the built-in catalog alongside your own gear": "Vis den innebygde katalogen ved siden av ditt eget utstyr", + "Show the frame at its export size, to judge sharpening and grain": "Vis bildet i eksportstørrelse, for å vurdere skarphet og korn", + "Show the frame at the export size, to judge sharpening and grain (does not change your edit)": "Vis bildet i eksportstørrelse, for å vurdere skarphet og korn (endrer ikke redigeringen din)", + "Show the frame's R, G and B histogram under the image": "Vis R-, G- og B-histogrammet for bildet under bildet", + "Show the frame's file in its folder": "Vis bildets fil i mappen", + "Show the full keyboard shortcuts reference": "Vis hele oversikten over hurtigtaster", + "Show the live histogram": "Vis det levende histogrammet", + "Show the paper's own white and tint, so the picture gets dimmer, as the print will be.": "Vis papirets egen hvitfarge og tone, så bildet blir mattere, slik kopien vil bli.", + "Show the paper's real black, so shadows look lighter, as on the print.": "Vis papirets virkelige svart, så skyggene ser lysere ut, som på kopien.", + "Show the printing notes over the frame: the masks with their values, and the print settings.": "Vis kopieringsnotatene over bildet: maskene med verdiene sine, og kopiinnstillingene.", + "Show the rolls whose name or line under it holds this text": "Vis rullene der navnet eller linjen under inneholder denne teksten", + "Show what dust removal found (Marked) or the scan's infrared channel (IR).": "Vis hva støvfjerningen fant (Merket) eller skanningens infrarøde kanal (IR).", + "Show which Adams zone each part of the print falls in": "Vis hvilken Adams-sone hver del av kopien havner i", + "Show {names}": "Vis {names}", + "Show {value} here": "Vis {value} her", + "Shows the Raw Decode and Optics cards.": "Viser kortene RAW-dekoding og Optikk.", + "Shows the Raw Decode, Optics and Trichrome cards.": "Viser kortene RAW-dekoding, Optikk og Trichrome.", + "Shuffle the order on each run; the order set here is used only when this is off": "Bland rekkefølgen ved hver kjøring; rekkefølgen som er angitt her, brukes bare når dette er av", + "Shutter": "Lukker", + "Shutter for the infrared exposure, set by hand and usually much slower than the visible exposure.": "Lukkertid for den infrarøde eksponeringen, angitt for hånd og vanligvis mye lengre enn den synlige eksponeringen.", + "Shutter speed": "Lukkertid", + "Sidecar": "Sidefil", + "Simulate Ink Black": "Simuler blekksvart", + "Simulate Paper White": "Simuler papirhvitt", + "Single Exposure": "Én eksponering", + "Single Exposure only: a Trichrome triplet reads each LED alone and has no sensor mixing to unmix.": "Bare én eksponering: en Trichrome-triplett leser hver LED for seg og har ingen sensorblanding å skille.", + "Single Exposure only: also save a sensor crosstalk profile for this preset, from the same exposures.": "Bare én eksponering: lagre også en sensorkrysstaleprofil for denne forhåndsinnstillingen, fra de samme eksponeringene.", + "Single exposure with red, green and blue lit together.": "Én eksponering med rødt, grønt og blått tent samtidig.", + "Single white-light exposure.": "Én eksponering med hvitt lys.", + "Single-Pass": "Én passering", + "Size": "Størrelse", + "Size Mode": "Størrelsesmodus", + "Size of the detail to sharpen, in pixels: small for fine grain, larger for soft scans.": "Størrelsen på detaljene som skal gjøres skarpere, i piksler: liten for fint korn, større for myke skanninger.", + "Size ↑/↓": "Størrelse ↑/↓", + "Skin Protection": "Hudbeskyttelse", + "Skip": "Hopp over", + "Skip Chapter": "Hopp over kapittel", + "Skip Rejected Frames": "Hopp over forkastede bilder", + "Skip Step": "Hopp over trinn", + "Skip this step, or press Esc to end the tour": "Hopp over dette trinnet, eller trykk Esc for å avslutte omvisningen", + "Skipped:\n": "Hoppet over:\n", + "Slide": "Dias", + "Slides are not scanned with narrowband light, so the profile is skipped and your choice is kept for negatives.": "Dias skannes ikke med smalbåndslys, så profilen hoppes over og valget ditt beholdes for negativer.", + "Snap": "Snap", + "Snap (midtone) down": "Snap (mellomtone) ned", + "Snap (midtone) up": "Snap (mellomtone) opp", + "Snap Trim": "Justering av Snap", + "Snap ↑/↓": "Snap ↑/↓", + "Snatch Point": "Snappunkt", + "Snatch Point ↑/↓": "Snappunkt ↑/↓", + "Snatch point earlier": "Snappunkt tidligere", + "Snatch point later": "Snappunkt senere", + "Soft Proof": "Prøvetrykk på skjerm", + "Soft proof is off, so the preview does not show the export's color clipping": "Prøvetrykk på skjerm er av, så forhåndsvisningen viser ikke fargeklippingen i eksporten", + "Soft proof the print on screen": "Prøvetrykk av kopien på skjermen", + "Softness of the brush edge. 0 is a hard edge.": "Mykhet i penselkanten. 0 er en hard kant.", + "Software": "Programvare", + "Solving exposures": "Løser eksponeringer", + "Solving {controls}": "Løser {controls}", + "Something went wrong and was logged:\n\n{error}\n\nThe app kept running. If it keeps happening, please attach the log file ({value}) to a bug report on GitHub.": "Noe gikk galt, og det ble logget:\n\n{error}\n\nAppen fortsatte å kjøre. Hvis det skjer igjen, legg ved loggfilen ({value}) i en feilrapport på GitHub.", + "Sort the frames in the Film Strip": "Sorter bildene i filmstripen", + "Sort the roll list": "Sorter rullisten", + "Source has no IR channel": "Kilden har ingen IR-kanal", + "Source scan device (capture not set)": "Kildeenhet for skanning (opptak ikke angitt)", + "Split Trichrome": "Del opp Trichrome", + "Split Trichrome: turn this frame back into its three exposures.": "Del opp Trichrome: gjør dette bildet om til de tre eksponeringene igjen.", + "Split a trichrome frame back into its three exposures": "Del et Trichrome-bilde tilbake i de tre eksponeringene", + "Split against the default conversion; drag the divider": "Delt mot standardkonverteringen; dra skillelinjen", + "Split direction": "Delingsretning", + "Split each scan into two frames, for half-frame cameras.": "Del hver skanning i to bilder, for halvformatkameraer.", + "Spot Threshold": "Flekkterskel", + "Spot Threshold ↑/↓": "Flekkterskel ↑/↓", + "Spot threshold down": "Flekkterskel ned", + "Spot threshold up": "Flekkterskel opp", + "Square First": "Rett opp først", + "Square First: straighten the frame to its film edges before Auto Crop crops it.": "Rett opp først: rett bildet etter filmkantene før autobeskjæring beskjærer det.", + "Square to Film": "Rett mot film", + "Square to Film could not read the frame edges clearly enough": "Rett mot film kunne ikke lese bildekantene tydelig nok", + "Square to Film failed; see the log": "Rett mot film mislyktes; se loggen", + "Square to Film: line the picture up with the edges of the film.": "Rett mot film: rett bildet inn etter kantene på filmen.", + "Square to Film: square the frame to its edges": "Rett mot film: rett bildet etter kantene", + "Square to Film: the frame is already square": "Rett mot film: bildet er allerede rett", + "Square to Film: {changes}": "Rett mot film: {changes}", + "Standard": "Standard", + "Start": "Start", + "Start here. Pick the film (Color, B&W or Slide), what you scanned with, and the light. NegPy then shows only the controls your setup uses.": "Start her. Velg filmen (Farge, S/H eller Dias), hva du skannet med, og lyset. NegPy viser da bare kontrollene oppsettet ditt bruker.", + "Start the next roll: step the Roll name to the next one with no subfolder yet": "Start neste rull: gå videre til neste rullnavn som ikke har noen undermappe ennå", + "Start the next scan roll": "Start neste skannerull", + "Starting live view…": "Starter direktevisning…", + "Starting {title}…": "Starter {title}…", + "State": "Fylke", + "Step": "Trinn", + "Step size for {group_id} must be greater than zero.": "Trinnstørrelsen for {group_id} må være større enn null.", + "Step {step}: density {value:.2f}": "Trinn {step}: tetthet {value:.2f}", + "Sticky Zoom": "Fast zoom", + "Sticky zoom (keep zoom level when switching images)": "Fast zoom (behold zoomnivået ved bytte av bilde)", + "Stitch": "Sy sammen", + "Stitch Selected Frames": "Sy sammen valgte bilder", + "Stitch failed: {e}": "Sammensyingen mislyktes: {e}", + "Stitched composite of {value}": "Sammensydd bilde av {value}", + "Stitched composite of {value} (Trichrome)": "Sammensydd bilde av {value} (Trichrome)", + "Stitched {value}": "Sydde sammen {value}", + "Stitches, HDR merges, trichromes and half-frame scans cannot go to the Trash.": "Sammensyinger, HDR-sammenslåinger, Trichrome-bilder og halvformatskanninger kan ikke flyttes til papirkurven.", + "Stitching an already-stitched frame is not supported": "Det støttes ikke å sy sammen et bilde som allerede er sammensydd", + "Stitching frames": "Syr sammen bilder", + "Stock name": "Filmnavn", + "Stop": "Stopp", + "Stop Preview": "Stopp forhåndsvisning", + "Stop the download and keep search by meaning off.": "Stopp nedlastingen og la søk etter betydning være av.", + "Stop the running job.": "Stopp jobben som kjører.", + "Stops the film was pushed or pulled in development.": "Blendertrinn filmen ble pushet eller pullet i fremkallingen.", + "Store the current frame's metadata as a preset": "Lagre metadataene for det gjeldende bildet som en forhåndsinnstilling", + "Stored data": "Lagrede data", + "Stores nothing": "Lagrer ingenting", + "Straight Proof": "Rett prøve", + "Straighten": "Rett opp", + "Straightens lines that bow out (positive) or in (negative).": "Retter opp linjer som buer utover (positiv) eller innover (negativ).", + "Strength": "Styrke", + "Strip": "Stripe", + "Strip Preview": "Stripeforhåndsvisning", + "Strip shows a band of the frame per paper; Grid shows every paper on the whole frame, small.": "Stripe viser et bånd av bildet per papir; Rutenett viser hvert papir på hele bildet, i liten størrelse.", + "Strongly recommended: crop all images before you run the analysis. Without a crop, the small centered margin of the Analysis Buffer cannot exclude the sprocket holes and the empty space outside the frame. That region then goes into the luma and color average, and every file gets a less accurate result.": "Sterkt anbefalt: beskjær alle bilder før du kjører analysen. Uten beskjæring kan ikke den lille sentrerte margen i analysebufferen utelukke perforeringshullene og det tomme området utenfor bildet. Det området tas da med i gjennomsnittet for luma og farge, og hver fil får et mindre nøyaktig resultat.", + "Strongly recommended: crop the remaining {value} before you run the analysis. Uncropped files use the small centered margin of the Analysis Buffer, which cannot exclude the sprocket holes and the empty space outside the frame. That region then goes into the luma and color average, and every file gets a less accurate result.": "Sterkt anbefalt: beskjær de resterende {value} før du kjører analysen. Filer uten beskjæring bruker den lille sentrerte margen i analysebufferen, som ikke kan utelukke perforeringshullene og det tomme området utenfor bildet. Det området tas da med i gjennomsnittet for luma og farge, og hver fil får et mindre nøyaktig resultat.", + "Subfolder": "Undermappe", + "Subfolder of source": "Undermappe av kilden", + "Superfine": "Superfin", + "Supported Images ({value})": "Støttede bilder ({value})", + "Supported Images ({wildcards})": "Støttede bilder ({wildcards})", + "Swing": "Sving", + "Swing {value:+.1f}%": "Sving {value:+.1f}%", + "Swing ↑/↓": "Sving ↑/↓", + "Swing: straighten a wall that narrows to one side, where positive stretches the left side.": "Sving: rett opp en vegg som smalner mot én side, der positiv strekker venstre side.", + "Switch the toolbar between the photo's tools and the roll's": "Bytt verktøylinjen mellom verktøyene for bildet og for rullen", + "Switch to another canvas tool": "Bytt til et annet lerretsverktøy", + "Sync Bounds": "Synkroniser grenser", + "Sync Bounds: give other frames this frame's measured black and white points, and nothing else": "Synkroniser grenser: gi andre bilder dette bildets målte svart- og hvitpunkt, og ingenting annet", + "Sync Bounds…": "Synkroniser grenser…", + "Sync bounds to other frames…": "Synkroniser grenser til andre bilder…", + "System Default": "Systemstandard", + "TIFF": "TIFF", + "TIFF Compression": "TIFF-komprimering", + "TIFF negatives": "TIFF-negativer", + "TOOLBAR": "VERKTØYLINJE", + "Tabs": "Faner", + "Take the Tour": "Ta omvisningen", + "Take the black and white point span from this frame": "Hent spennet mellom svart- og hvitpunkt fra dette bildet", + "Take the film out of the holder. Set the camera to Manual (M), at the ISO and aperture you scan with.\n\nNegPy lights each LED in turn and sets the shutter itself.": "Ta filmen ut av holderen. Sett kameraet til manuell (M), med ISO-en og blenderen du skanner med.\n\nNegPy tenner hver LED etter tur og angir lukkertiden selv.", + "Take the per-channel color balance from this frame": "Hent fargebalansen per kanal fra dette bildet", + "Take the tour: a guided walk through NegPy, one chapter at a time": "Ta omvisningen: en veiledet tur gjennom NegPy, ett kapittel om gangen", + "Tannin": "Tannin", + "Tannin down": "Tannin ned", + "Tannin up": "Tannin opp", + "Tannin ↑/↓": "Tannin ↑/↓", + "Tea or tannin toner: turns bleached areas brown, so use Bleach first for a full brown.": "Te- eller tannintoner: gjør blekede områder brune, så bruk Bleking først for en helt brun tone.", + "Temperature": "Temperatur", + "Temperature (°C)": "Temperatur (°C)", + "Temperature cooler": "Kaldere temperatur", + "Temperature warmer": "Varmere temperatur", + "Temperature ↑/↓": "Temperatur ↑/↓", + "Temporarily show the flat master in the canvas (does not change your edit)": "Vis den flate masteren midlertidig på lerretet (endrer ikke redigeringen din)", + "Test Strip": "Prøvestripe", + "Test Strip: show a grid of versions at different brightness and contrast, then click the one you like.": "Prøvestripe: vis et rutenett av versjoner med ulik lyshet og kontrast, og klikk den du liker.", + "Test strip": "Prøvestripe", + "That roll no longer exists": "Den rullen finnes ikke lenger", + "The 120 camera's frame size": "Bildestørrelsen til 120-kameraet", + "The Analysis Readout": "Analyseavlesningen", + "The Flat Field clips at the fastest shutter. Close the aperture or lower the ISO.": "Flatfeltet klipper ved raskeste lukkertid. Blend ned eller senk ISO-en.", + "The Library: Rolls": "Biblioteket: ruller", + "The Roll": "Rullen", + "The Screen": "Skjermen", + "The analysis reads the cropped negative area of each file.": "Analysen leser det beskårne negativområdet i hver fil.", + "The box speed of the film": "Filmens nominelle følsomhet", + "The camera model, written to the photo's metadata": "Kameramodellen, skrevet til bildets metadata", + "The camera reports no shutter speeds.": "Kameraet rapporterer ingen lukkertider.", + "The camera this preset writes onto a frame": "Kameraet denne forhåndsinnstillingen skriver til et bilde", + "The camera's own JPEG of this shot, for comparison.": "Kameraets egen JPEG av dette bildet, til sammenligning.", + "The chart in the frame, whose published patch colors the fit aims for.": "Kartet i bildet, med de publiserte feltfargene som tilpasningen sikter mot.", + "The city where the frame was shot.": "Byen der bildet ble tatt.", + "The clear areas have a yellow stain: turn on Remove Stain.": "De klare områdene har en gul flekk: slå på Fjern flekk.", + "The color around the photo on the canvas": "Fargen rundt bildet på lerretet", + "The color space of the exported file, or an imported profile for a printer or paper.": "Fargerommet til den eksporterte filen, eller en importert profil for en skriver eller et papir.", + "The company that makes the film": "Firmaet som lager filmen", + "The contrast Auto Grade gives each frame.": "Kontrasten autogradering gir hvert bilde.", + "The country where the frame was shot.": "Landet der bildet ble tatt.", + "The developer temperature, in degrees Celsius.": "Fremkallertemperaturen, i grader Celsius.", + "The developer that the film was processed in.": "Fremkalleren filmen ble behandlet i.", + "The developer the film was processed in": "Fremkalleren filmen ble behandlet i", + "The developer this process uses": "Fremkalleren denne prosessen bruker", + "The dodge and burn map and the print recipe": "Kartet over skygging og etterbelysning og kopioppskriften", + "The edit, as it prints": "Redigeringen, slik den kopieres", + "The exposure of the original shot, if you know it. Click the lock to type free text such as 1/125s f/2.8 ISO 400 -1/3ev.": "Eksponeringen for det opprinnelige bildet, hvis du vet den. Klikk låsen for å skrive fritekst som 1/125s f/2.8 ISO 400 -1/3ev.", + "The exposure of the original shot.": "Eksponeringen for det opprinnelige bildet.", + "The exposure shot with this color of light alone and no film in the holder.": "Eksponeringen tatt med bare denne lysfargen og uten film i holderen.", + "The exposure written to the frame, for example 1/125s f/2.8 ISO 400": "Eksponeringen som skrives til bildet, for eksempel 1/125s f/2.8 ISO 400", + "The fields that are on are joined with • into the export ImageDescription. Empty values are skipped.": "Feltene som er på, slås sammen med • i ImageDescription ved eksport. Tomme verdier hoppes over.", + "The file format of the export.": "Filformatet for eksporten.", + "The file type of the linear export.": "Filtypen for den lineære eksporten.", + "The files and their edits stay. Their scene, card locks and own edit in this roll are deleted.": "Filene og redigeringene deres blir værende. Scenen, kortlåsene og den egne redigeringen i denne rullen slettes.", + "The files and their shared edits stay. Each roll's settings, Roll Analysis baseline, film base, scenes, card locks and the edits made only in that roll are lost. Refresh will not add a deleted folder roll again.": "Filene og de delte redigeringene deres blir værende. Hver rulls innstillinger, rullanalysegrunnlag, filmbase, scener, kortlåser og redigeringene som bare ble gjort i den rullen, går tapt. Oppdater legger ikke til en slettet mapperull igjen.", + "The files and their shared edits stay. Each roll's settings, Roll Analysis baseline, film base, scenes, card locks and the edits made only in that roll are lost. Refresh will not add these rolls again.": "Filene og de delte redigeringene deres blir værende. Hver rulls innstillinger, rullanalysegrunnlag, filmbase, scener, kortlåser og redigeringene som bare ble gjort i den rullen, går tapt. Oppdater legger ikke til disse rullene igjen.", + "The files and their shared edits stay. The folder roll's settings, Roll Analysis baseline, film base, scenes, card locks and the edits made only in this roll are lost. Refresh will not add it again.": "Filene og de delte redigeringene deres blir værende. Mapperullens innstillinger, rullanalysegrunnlag, filmbase, scener, kortlåser og redigeringene som bare ble gjort i denne rullen, går tapt. Oppdater legger den ikke til igjen.", + "The files and their shared edits stay. The virtual roll's list of frames, settings, Roll Analysis baseline, film base, scenes, card locks and the edits made only in this roll are lost.": "Filene og de delte redigeringene deres blir værende. Den virtuelle rullens liste over bilder, innstillinger, rullanalysegrunnlag, filmbase, scener, kortlåser og redigeringene som bare ble gjort i denne rullen, går tapt.", + "The film did not eject: {error}. Use the dock's buttons.": "Filmen ble ikke matet ut: {error}. Bruk knappene på holderen.", + "The film did not move: {error}. Move it with the dock's buttons.": "Filmen flyttet seg ikke: {error}. Flytt den med knappene på holderen.", + "The film format, when the list does not have it": "Filmformatet, når listen ikke har det", + "The film format, when the list does not have it.": "Filmformatet, når listen ikke har det.", + "The film is out. Insert the next strip.": "Filmen er ute. Sett inn neste stripe.", + "The film size this stock comes in": "Filmstørrelsen denne filmtypen finnes i", + "The film stock this preset writes onto a frame": "Filmtypen denne forhåndsinnstillingen skriver til et bilde", + "The film type these numbers describe, and the only one the matrix shows and works for.": "Filmtypen disse tallene beskriver, og den eneste matrisen vises og virker for.", + "The flat master: the scan as a log-encoded digital intermediate, with no print curve": "Den flate masteren: skanningen som et logaritmisk kodet digitalt mellomledd, uten kopikurve", + "The focal length of the lens": "Brennvidden til objektivet", + "The folder for contact sheets, or the export folder when this is empty.": "Mappen for kontaktark, eller eksportmappen når dette er tomt.", + "The folder is not on disk, so move it back or delete the roll.": "Mappen finnes ikke på disken, så flytt den tilbake eller slett rullen.", + "The folds under Print Density and ISO-R Grade change only the shadows or only the highlights: lighter, darker, or more or less contrast.": "Foldene under Kopitetthet og ISO-R-gradering endrer bare skyggene eller bare høylysene: lysere, mørkere, eller mer eller mindre kontrast.", + "The frame as it exports, at the size the Export panel sets, to judge sharpening and grain": "Bildet slik det eksporteres, i størrelsen eksportpanelet angir, for å vurdere skarphet og korn", + "The frame number the first frame of the run is set to.": "Bildenummeret det første bildet i rekken settes til.", + "The frame's folder is gone": "Mappen til bildet er borte", + "The frames of one film should print alike. Reanalyze measures the whole roll and shares the result, so the frames match. It crops the frames that wait for Auto Crop first. Scenes get their own.": "Bildene fra én film skal kopieres likt. Analyser på nytt måler hele rullen og deler resultatet, så bildene samsvarer. Den beskjærer først bildene som venter på autobeskjæring. Scener får sitt eget.", + "The frames the settings go to": "Bildene innstillingene går til", + "The gap between successive frame numbers.": "Avstanden mellom påfølgende bildenumre.", + "The grays in one channel are too flat to measure.": "De grå tonene i én kanal er for flate til å måles.", + "The language of the interface, applied when NegPy starts again.": "Språket i grensesnittet, brukt når NegPy starter på nytt.", + "The largest brightness change Auto Density may make, so a lower value keeps a roll more even.": "Den største endringen i lyshet autotetthet kan gjøre, så en lavere verdi holder en rull jevnere.", + "The largest image size the GPU loads at once.": "Den største bildestørrelsen GPU-en laster inn på én gang.", + "The lens model, written to the photo's metadata": "Objektivmodellen, skrevet til bildets metadata", + "The lens this preset writes onto a frame": "Objektivet denne forhåndsinnstillingen skriver til et bilde", + "The light behind the negative": "Lyset bak negativet", + "The long edge of the preview on the canvas.": "Den lange kanten på forhåndsvisningen på lerretet.", + "The maker of the camera or lens, written to the photo's metadata": "Produsenten av kameraet eller objektivet, skrevet til bildets metadata", + "The mask acts on every tone inside its shape.": "Masken virker på hver tone innenfor formen.", + "The mask acts only on tones darker than Tone Zone, so the ground lightens and the sky stays as it is.": "Masken virker bare på toner mørkere enn Tonesone, så bakken blir lysere og himmelen blir som den er.", + "The mask acts only on tones lighter than Tone Zone, so a sky burn stops at the skyline.": "Masken virker bare på toner lysere enn Tonesone, så en etterbelysning av himmelen stopper ved horisonten.", + "The most memory the preview cache can use.": "Det meste minnet hurtigbufferen for forhåndsvisning kan bruke.", + "The name in the Matrix dropdown, such as the film and the rig.": "Navnet i rullegardinlisten Matrise, for eksempel filmen og riggen.", + "The name of the film stock, which is also the name of the new preset.": "Navnet på filmtypen, som også er navnet på den nye forhåndsinnstillingen.", + "The name of the film, for example Portra 400": "Navnet på filmen, for eksempel Portra 400", + "The name of the folder beside each source file.": "Navnet på mappen ved siden av hver kildefil.", + "The name this group takes as a scene.": "Navnet denne gruppen får som scene.", + "The name this item shows in every gear list": "Navnet dette elementet vises med i hver utstyrsliste", + "The next frame is in place.": "Neste bilde er på plass.", + "The paper shown full frame: the arrows step to the neighbor paper, the name opens the list": "Papiret som vises i fullt bilde: pilene går til nabopapiret, navnet åpner listen", + "The paper the picture prints on, which sets the contrast curve and, on color paper, the colors.": "Papiret bildet kopieres på, som angir kontrastkurven og, på fargepapir, fargene.", + "The pen chooses which fields a preset stores; these edit their values.": "Pennen velger hvilke felt en forhåndsinnstilling lagrer; disse redigerer verdiene.", + "The picture is in the middle, the Film Strip under it, and the controls on the right. The Roll tab holds what a roll shares, the Frame tab one picture's print. Edits save by themselves.": "Bildet er i midten, filmstripen under det, og kontrollene til høyre. Fanen Rull inneholder det en rull deler, fanen Bilde kopien av ett bilde. Redigeringer lagres av seg selv.", + "The printed contrast Auto Grade aims for on every frame, higher for punchier prints.": "Den kopierte kontrasten autogradering sikter mot på hvert bilde, høyere for mer slagkraftige kopier.", + "The printer or paper profile to preview, which follows the Export profile until you pick one.": "Skriver- eller papirprofilen som skal forhåndsvises, som følger eksportprofilen til du velger en.", + "The profile's name in the Matrix dropdown: name the film and the rig.": "Navnet på profilen i rullegardinlisten Matrise: gi navn til filmen og riggen.", + "The profile's name in the sensor Profile dropdown: name the camera and the light.": "Navnet på profilen i rullegardinlisten Sensorprofil: gi navn til kameraet og lyset.", + "The readout above the tabs describes the print: the paper curve with histograms, a strip of zones from black to white, and warnings when parts go pure black or white.": "Avlesningen over fanene beskriver kopien: papirkurven med histogrammer, en stripe med soner fra svart til hvitt, og advarsler når deler blir helt svarte eller hvite.", + "The roll keeps its own values. Each frame's reset is a normal undo step, so you can revert it after.": "Rullen beholder sine egne verdier. Tilbakestillingen av hvert bilde er et vanlig angretrinn, så du kan gjøre den om etterpå.", + "The roll name that export file name templates use as {{ roll }}.": "Rullnavnet som maler for eksportfilnavn bruker som {{ roll }}.", + "The roll's average black and white points; right-click turns every average on or off.": "Rullens gjennomsnittlige svart- og hvitpunkt; høyreklikk slår hvert gjennomsnitt på eller av.", + "The roll's average color balance.": "Rullens gjennomsnittlige fargebalanse.", + "The roll's gray balance for Cast Removal.": "Rullens gråbalanse for fjerning av fargestikk.", + "The roll's name in the library": "Rullens navn i biblioteket", + "The scan as loaded, before inversion and edits.": "Skanningen slik den ble lastet inn, før invertering og redigeringer.", + "The scan capture frame number, used in export filename templates as {{ frame }}.": "Bildenummeret fra skanneopptaket, brukt i maler for eksportfilnavn som {{ frame }}.", + "The scan capture roll name, used in export filename templates as {{ roll }}.": "Rullnavnet fra skanneopptaket, brukt i maler for eksportfilnavn som {{ roll }}.", + "The scan uses the exposure set on the camera. If the film base clips, the shadows of the print turn solid black. Click Cancel, then click Meter.": "Skanningen bruker eksponeringen som er angitt på kameraet. Hvis filmbasen klippes, blir skyggene i kopien helt svarte. Klikk Avbryt, og klikk så Måler.", + "The scanner returned the strip while idle. Insert it again, then press Detect Frames.": "Skanneren returnerte stripen mens den var inaktiv. Sett den inn igjen, og trykk så Oppdag bilder.", + "The scanner returned the strip while idle. Insert it again.": "Skanneren returnerte stripen mens den var inaktiv. Sett den inn igjen.", + "The scanner sat idle long enough to return the strip": "Skanneren var inaktiv lenge nok til å returnere stripen", + "The scanner sat idle long enough to return the strip. Frame selection cleared.": "Skanneren var inaktiv lenge nok til å returnere stripen. Bildevalget er fjernet.", + "The scanner sat idle long enough to return the strip. Measuring it again…": "Skanneren var inaktiv lenge nok til å returnere stripen. Måler den på nytt…", + "The shape of the final print, or Original to keep the frame's shape.": "Formen på den ferdige kopien, eller Opprinnelig for å beholde bildets form.", + "The source files and their sidecars go to the Trash. The edits stay in the database, so a restored file keeps its edit.": "Kildefilene og sidefilene deres flyttes til papirkurven. Redigeringene blir værende i databasen, så en gjenopprettet fil beholder redigeringen sin.", + "The state or region where the frame was shot.": "Fylket eller regionen der bildet ble tatt.", + "The strip stopped after {frames}. Move the film, then click Scan.": "Stripen stoppet etter {frames}. Flytt filmen, og klikk så Skann.", + "The three frames do not seem to show the same picture. Use Edit Trichrome… to join them anyway.": "De tre bildene ser ikke ut til å vise samme motiv. Bruk Rediger Trichrome… for å slå dem sammen likevel.", + "The three layers faded equally, so there is nothing to estimate.": "De tre lagene har falmet like mye, så det er ingenting å beregne.", + "The tone curve that the input TIFF was saved with, so that the export can undo it.": "Tonekurven TIFF-inndataene ble lagret med, slik at eksporten kan oppheve den.", + "The visual quality of a lossy file, or the compression effort of a lossless one.": "Den visuelle kvaliteten til en fil med tap, eller komprimeringsinnsatsen for en tapsfri fil.", + "The widest aperture of the lens": "Den største blenderåpningen til objektivet", + "The {bands} {value} clipped. Shoot again, dimmer, for an accurate matrix.": "{bands} {value} ble klippet. Ta på nytt, svakere, for en nøyaktig matrise.", + "These apply to every frame. The open frame previews them.": "Disse gjelder for hvert bilde. Det åpne bildet forhåndsviser dem.", + "These cards fit your scanner. Frame Assembly joins three-color shots or splits half frames. Calibration fixes color errors from your light and camera. Optics fixes lens distortion and uneven light.": "Disse kortene passer skanneren din. Bildesammensetning slår sammen trefargeopptak eller deler halvformater. Kalibrering retter fargefeil fra lyset og kameraet ditt. Optikk retter objektivforvrengning og ujevnt lys.", + "These corrections become part of the exported file. Export again from the original to get the data without them.": "Disse korreksjonene blir en del av den eksporterte filen. Eksporter på nytt fra originalen for å få dataene uten dem.", + "These frames are not one roll, so an edit here also shows in the roll the frame came from.": "Disse bildene er ikke én rull, så en redigering her vises også i rullen bildet kom fra.", + "These frames are not one roll, so save them as a roll to give them a shared value.": "Disse bildene er ikke én rull, så lagre dem som en rull for å gi dem en felles verdi.", + "They are hashed and thumbnailed on load, which takes a moment on a large roll.": "De får hash og miniatyrbilde ved innlasting, noe som tar litt tid for en stor rull.", + "They return to the roll with their edits.": "De går tilbake til rullen med redigeringene sine.", + "Thirds": "Tredjedeler", + "This camera has no live view, which calibration needs to aim at the film base": "Dette kameraet har ingen direktevisning, som kalibreringen trenger for å sikte mot filmbasen", + "This camera is not reporting settable shutter speeds. Set it to Manual (M), make sure live view is running, then try again.": "Dette kameraet rapporterer ingen lukkertider som kan angis. Sett det til manuell (M), sørg for at direktevisningen kjører, og prøv igjen.", + "This cannot be undone.": "Dette kan ikke angres.", + "This device has no eject control": "Denne enheten har ingen utmatingskontroll", + "This file carries no embedded preview": "Denne filen har ingen innebygd forhåndsvisning", + "This film shows the picture in infrared too (B&W, Kodachrome), so IR Removal is off.": "Denne filmen viser bildet også i infrarødt (S/H, Kodachrome), så IR-fjerning er av.", + "This frame is not a Bayer or X-Trans RAW, so it arrives already de-mosaiced.": "Dette bildet er ikke en Bayer- eller X-Trans-RAW, så det kommer allerede demosaikkert.", + "This frame is not a triplet or a stitch": "Dette bildet er ikke en triplett eller en sammensying", + "This frame is one exposure, not a Trichrome triplet.": "Dette bildet er én eksponering, ikke en Trichrome-triplett.", + "This frame printed on the roll's average, to try it before you apply it": "Dette bildet kopieres etter rullens gjennomsnitt, så du kan prøve det før du bruker det", + "This frame's place in the Film Strip's order and filter": "Plassen til dette bildet i filmstripens rekkefølge og filter", + "This preset stores nothing.": "Denne forhåndsinnstillingen lagrer ingenting.", + "This removes every heal and scratch repair on this frame.": "Dette fjerner hver legeing og ripereparasjon på dette bildet.", + "This roll has no frames": "Denne rullen har ingen bilder", + "This roll now has its own edit for this frame": "Denne rullen har nå sin egen redigering for dette bildet", + "This scanner reports no film/transparency sources. NegPy v1 supports film scanning only.": "Denne skanneren rapporterer ingen film-/transparentkilder. NegPy v1 støtter bare filmskanning.", + "Thumbnail predates a settings change; open the frame or use Update Thumbnails to refresh it.": "Miniatyrbildet er eldre enn en endring av innstillingene; åpne bildet eller bruk Oppdater miniatyrbilder for å oppdatere det.", + "Thumbnail update canceled": "Oppdateringen av miniatyrbilder ble avbrutt", + "Thumbnail update failed: {message}": "Oppdateringen av miniatyrbilder mislyktes: {message}", + "Thumbnails {index}/{total}": "Miniatyrbilder {index}/{total}", + "Tile size: the grid reflows to fit the dialog.": "Flisstørrelse: rutenettet tilpasser seg dialogen.", + "Tilt": "Tilt", + "Tilt and swing with reference lines": "Tilt og sving med referanselinjer", + "Tilt {value:+.1f}%": "Tilt {value:+.1f}%", + "Tilt ↑/↓": "Tilt ↑/↓", + "Tilt: straighten buildings that lean back because the camera pointed up, where positive stretches the top.": "Tilt: rett opp bygninger som lener bakover fordi kameraet pekte oppover, der positiv strekker toppen.", + "Time": "Tid", + "To invert a negative, NegPy measures its darkest and brightest parts. Keep the film border and holder out: crop tight, or draw the area to measure ({value}).": "For å invertere et negativ måler NegPy de mørkeste og lyseste delene. Hold filmkanten og holderen utenfor: beskjær stramt, eller tegn området som skal måles ({value}).", + "To scan: {value}.": "Skal skannes: {value}.", + "Toe": "Tå", + "Toe Trim": "Justering av tå", + "Toe Width": "Tåbredde", + "Toe Width Trim": "Justering av tåbredde", + "Toe Width of this color layer only.": "Tåbredde bare for dette fargelaget.", + "Toe Width ↑/↓": "Tåbredde ↑/↓", + "Toe down": "Tå ned", + "Toe up": "Tå opp", + "Toe width down": "Tåbredde ned", + "Toe width up": "Tåbredde opp", + "Toe ↑/↓": "Tå ↑/↓", + "Toggle Apply Flat Field": "Slå Bruk flatfelt av/på", + "Toggle Auto Crop": "Slå autobeskjæring av/på", + "Toggle Auto Density": "Slå autotetthet av/på", + "Toggle Auto Density and Auto Grade together": "Slå autotetthet og autogradering av/på samtidig", + "Toggle Auto Grade": "Slå autogradering av/på", + "Toggle Auto Masking": "Slå automaskering av/på", + "Toggle Controls Panel": "Slå kontrollpanelet av/på", + "Toggle Crop Empty Corners": "Slå Beskjær tomme hjørner av/på", + "Toggle Dmax pick": "Slå valg av Dmax av/på", + "Toggle Dmin pick": "Slå valg av Dmin av/på", + "Toggle Folder as Roll": "Slå Mappe som rull av/på", + "Toggle Hold Black (a shadow lift keeps the black point)": "Slå Hold svart av/på (et skyggeløft beholder svartpunktet)", + "Toggle IR Removal": "Slå IR-fjerning av/på", + "Toggle Infrared: an infrared exposure after every frame (Scanlight)": "Slå Infrarød av/på: en infrarød eksponering etter hvert bilde (Scanlight)", + "Toggle Keep Light On (Scanlight)": "Slå La lyset være på av/på (Scanlight)", + "Toggle Lock Bounds": "Slå Lås grenser av/på", + "Toggle Optical Removal": "Slå optisk fjerning av/på", + "Toggle Positive (source is already a finished positive)": "Slå Positiv av/på (kilden er allerede et ferdig positiv)", + "Toggle Remove Stain (Fade Restoration)": "Slå Fjern flekk av/på (fargegjenoppretting)", + "Toggle Scan as Roll": "Slå Skann som rull av/på", + "Toggle Shoot Flat Field (Lento Dock)": "Slå Ta flatfelt av/på (Lento Dock)", + "Toggle Square First": "Slå Rett opp først av/på", + "Toggle WB picker": "Slå WB-velgeren av/på", + "Toggle analysis region draw": "Slå tegning av analyseområde av/på", + "Toggle cast average": "Slå gjennomsnitt for fargestikk av/på", + "Toggle clone tool": "Slå kloneverktøyet av/på", + "Toggle controls panel (re-docks when floating)": "Slå kontrollpanelet av/på (festes igjen når det er flytende)", + "Toggle dodge & burn card-edge mask draw": "Slå tegning av kortkantmaske for skygging og etterbelysning av/på", + "Toggle dodge & burn mask draw": "Slå tegning av maske for skygging og etterbelysning av/på", + "Toggle dodge & burn oval mask draw": "Slå tegning av oval maske for skygging og etterbelysning av/på", + "Toggle film base pick": "Slå valg av filmbase av/på", + "Toggle heal tool": "Slå legeverktøyet av/på", + "Toggle high-quality (full-resolution) preview": "Slå forhåndsvisning i høy kvalitet (full oppløsning) av/på", + "Toggle manual crop": "Slå manuell beskjæring av/på", + "Toggle right-click excludes from Optical Removal": "Slå av/på at høyreklikk utelater fra optisk fjerning", + "Toggle scratch tool": "Slå ripeverktøyet av/på", + "Toggle search by meaning": "Slå søk etter betydning av/på", + "Toggle the Level tool": "Slå vaterverktøyet av/på", + "Toggle the blue exposure for Retake": "Slå den blå eksponeringen for Ta på nytt av/på", + "Toggle the gear catalog": "Slå utstyrskatalogen av/på", + "Toggle the green exposure for Retake": "Slå den grønne eksponeringen for Ta på nytt av/på", + "Toggle the infrared exposure for Scan and Retake": "Slå den infrarøde eksponeringen for Skann og Ta på nytt av/på", + "Toggle the red exposure for Retake": "Slå den røde eksponeringen for Ta på nytt av/på", + "Toggle tilt/swing reference-lines tool": "Slå verktøyet for referanselinjer for tilt/sving av/på", + "Toggle transport-scratch line tool": "Slå linjeverktøyet for transportriper av/på", + "Tonal Span": "Tonespenn", + "Tone": "Tone", + "Tone Limit": "Tonegrense", + "Tone Softness": "Tonemykhet", + "Tone Zone": "Tonesone", + "Toners act in the order shown, and a later toner does not change what an earlier one toned.": "Tonerne virker i rekkefølgen som vises, og en senere toner endrer ikke det en tidligere toner har tonet.", + "Toning": "Toning", + "Tools": "Verktøy", + "Tools: crop, straighten, the pickers, masks and retouching, each with its own row here": "Verktøy: beskjæring, oppretting, velgerne, masker og retusj, hver med sin egen rad her", + "Top": "Øverst", + "Transparency (E-6)": "Transparent (E-6)", + "Transparency: slide or reversal film": "Transparent: dias eller reversalfilm", + "Transport Line": "Transportlinje", + "Tray rocking after the flash: less rocking draws wider light lines along edges.": "Vipping av skålen etter lysglimtet: mindre vipping tegner bredere lyse linjer langs kantene.", + "Triage": "Sortering", + "Trichrome": "Trichrome", + "Trichrome + IR, Color Negative": "Trichrome + IR, fargenegativ", + "Trichrome Mode": "Trichrome-modus", + "Trichrome merges one exposure per LED, and Single Exposure takes one exposure with red, green and blue lit together.": "Trichrome slår sammen én eksponering per LED, og Én eksponering tar én eksponering med rødt, grønt og blått tent samtidig.", + "Trichrome triplet": "Trichrome-triplett", + "Trichrome, Color Negative": "Trichrome, fargenegativ", + "Tritanopia (blue-yellow)": "Tritanopi (blå-gul)", + "Tuned on a rig": "Finjustert på en rigg", + "Turn Auto Density and Auto Grade on or off together": "Slå autotetthet og autogradering av eller på samtidig", + "Turn Off Trichrome Mode": "Slå av Trichrome-modus", + "Turn On": "Slå på", + "Turn every frame off": "Slå av hvert bilde", + "Turn every {title} setting on or off": "Slå hver {title}-innstilling av eller på", + "Turn off every listed setting": "Slå av hver innstilling i listen", + "Turn off what you do not want, drag on a tile to crop it, slide under a tile to shift it.": "Slå av det du ikke vil ha, dra på en flis for å beskjære den, og skyv under en flis for å forskyve den.", + "Turn on Search by meaning in Preferences first": "Slå på Søk etter betydning i Innstillinger først", + "Turn on every listed setting": "Slå på hver innstilling i listen", + "Turn the Scanlight on at the framing light, or turn it off.": "Slå på Scanlight med innrammingslyset, eller slå det av.", + "Turn the Scanlight on or off": "Slå Scanlight av eller på", + "Turn the paper (swap width and height)": "Drei papiret (bytt bredde og høyde)", + "Turn this diptych back into one plain frame?": "Vil du gjøre dette diptyket om til ett vanlig bilde igjen?", + "Turn this diptych back into one plain scan, deleting both halves' edits": "Gjør dette diptyket om til én vanlig skanning igjen, og slett redigeringene av begge halvdelene", + "Turn {mode} Mode on for {value}?": "Vil du slå på {mode}-modus for {value}?", + "Turns on the Export panel's “Overwrite existing files” option; stays on until you turn it off.": "Slår på alternativet «Overskriv eksisterende filer» i eksportpanelet; forblir på til du slår det av.", + "Turns the picture by small amounts, clockwise for a positive value.": "Dreier bildet litt, med klokken for en positiv verdi.", + "Type": "Type", + "Type a place name, then choose a match to put the pin there.": "Skriv et stedsnavn, og velg et treff for å plassere nålen der.", + "Type a plain-language description in the Film Strip search box to find frames, instead of field:value terms": "Skriv en beskrivelse i vanlig språk i søkefeltet i filmstripen for å finne bilder, i stedet for felt:verdi-uttrykk", + "Type a search first": "Skriv et søk først", + "Type a search first, e.g. film:portra": "Skriv et søk først, f.eks. film:portra", + "Type a slider, card or action, or another editor's word for it: contrast, white balance.": "Skriv en glidebryter, et kort eller en handling, eller ordet et annet redigeringsprogram bruker: kontrast, hvitbalanse.", + "UI scale": "Skalering av grensesnittet", + "USE AVERAGE": "BRUK GJENNOMSNITT", + "UV exposure time: more makes the print bluer, and less makes it paler.": "UV-eksponeringstid: mer gjør kopien blåere, og mindre gjør den blekere.", + "Unavailable: {reason}": "Ikke tilgjengelig: {reason}", + "Uncompressed": "Ukomprimert", + "Undid {label}: {value}": "Angret {label}: {value}", + "Undo": "Angre", + "Undo Change to Several Frames": "Angre endring av flere bilder", + "Undo Last Clone": "Angre siste kloning", + "Undo Last Heal": "Angre siste legeing", + "Undo Last Point Backspace": "Angre siste punkt Backspace", + "Undo Last: remove the most recent clone stroke": "Angre siste: fjern det nyeste klonestrøket", + "Undo Last: remove the most recent manual heal": "Angre siste: fjern den nyeste manuelle legeingen", + "Undo Pick Better": "Angre Velg bedre", + "Undo the last change to several frames": "Angre den siste endringen av flere bilder", + "Undo this change on every frame it reached.": "Angre denne endringen på hvert bilde den nådde.", + "Undo-history steps": "Trinn i angrehistorikken", + "Undone": "Angret", + "Unknown scan error": "Ukjent skannefeil", + "Unload": "Last ut", + "Unload All": "Last ut alle", + "Unload All…": "Last ut alle…", + "Unload Selected": "Last ut valgte", + "Unload Selected…": "Last ut valgte…", + "Unload all frames from the Film Strip?": "Vil du laste ut alle bilder fra filmstripen?", + "Unload all frames of “{roll_name}” from the Film Strip?": "Vil du laste ut alle bilder i «{roll_name}» fra filmstripen?", + "Unload every frame from the Film Strip": "Last ut hvert bilde fra filmstripen", + "Unload the {value} from the session?": "Vil du laste ut {value} fra økten?", + "Unload this image from the session?": "Vil du laste ut dette bildet fra økten?", + "Unload: remove this frame from the session and keep its saved edit": "Last ut: fjern dette bildet fra økten og behold den lagrede redigeringen", + "Unload…": "Last ut…", + "Unlock": "Lås opp", + "Unlock the sliders and the exposure settings, which Scan uses as they are and the save button keeps as a preset.": "Lås opp glidebryterne og eksponeringsinnstillingene, som Skann bruker slik de er og lagreknappen beholder som en forhåndsinnstilling.", + "Unlock to edit": "Lås opp for å redigere", + "Unmarked Only": "Bare umerkede", + "Unmerge Exposures": "Del opp eksponeringer", + "Unmerge an HDR frame back into its exposures": "Del et HDR-bilde tilbake i eksponeringene", + "Unsharp Mask": "Uskarp maske", + "Unsharp Mask makes edges crisper, and Deconvolution undoes the scanner's blur when Radius is the blur size.": "Uskarp maske gjør kantene skarpere, og Dekonvolusjon opphever skannerens uskarphet når Radius er størrelsen på uskarpheten.", + "Unsplit": "Del opp", + "Unsplit Diptych": "Del opp diptyk", + "Unsplit Diptych…": "Del opp diptyk…", + "Unsplit a diptych back into one plain frame": "Del et diptyk tilbake til ett vanlig bilde", + "Unstitch": "Del opp sammensying", + "Update Thumbnail": "Oppdater miniatyrbilde", + "Update Thumbnails": "Oppdater miniatyrbilder", + "Update Thumbnails Automatically": "Oppdater miniatyrbilder automatisk", + "Update Thumbnails: re-render every out-of-date thumbnail in the roll": "Oppdater miniatyrbilder: gjengi hvert utdatert miniatyrbilde i rullen på nytt", + "Update selected frames' thumbnails": "Oppdater miniatyrbildene for de valgte bildene", + "Update thumbnails for the whole roll": "Oppdater miniatyrbilder for hele rullen", + "Update to v{version}…": "Oppdater til v{version}…", + "Update {value}": "Oppdater {value}", + "Updated {value}": "Oppdaterte {value}", + "Updating {value}…": "Oppdaterer {value}…", + "Use Average": "Bruk gjennomsnitt", + "Use Average Cast": "Bruk gjennomsnittlig fargestikk", + "Use Average Color": "Bruk gjennomsnittlig farge", + "Use Average Luma": "Bruk gjennomsnittlig luma", + "Use Export profile": "Bruk eksportprofilen", + "Use Frame": "Bruk bilde", + "Use Own Measurement": "Bruk egen måling", + "Use Shared Edit": "Bruk delt redigering", + "Use This Frame": "Bruk dette bildet", + "Use This Frame: make this frame's measurement the roll's baseline.": "Bruk dette bildet: gjør målingen av dette bildet til rullens grunnlag.", + "Use a frame of a SpyderCheckr 24 or a ColorChecker 24 shot on this film and scanned on this rig. Drag the four white dots onto the centers of the chart's corner patches, in any order. The chart can sit any way up, sideways or mirrored. NegPy reads its orientation from the colors, then finds the Crosstalk matrix that brings the patches nearest the chart's published colors, through the frame's own render.": "Bruk et bilde av et SpyderCheckr 24 eller ColorChecker 24 fotografert på denne filmen og skannet på denne riggen. Dra de fire hvite prikkene til midten av hjørnefeltene i kartet, i hvilken som helst rekkefølge. Kartet kan stå hvilken vei som helst, sidelengs eller speilvendt. NegPy leser retningen ut fra fargene, og finner så krysstalematrisen som bringer feltene nærmest kartets publiserte farger, gjennom bildets egen gjengivelse.", + "Use the Shared Edit Again": "Bruk den delte redigeringen igjen", + "Use the Shared Edit Again…": "Bruk den delte redigeringen igjen…", + "Use the file's own correction for color fringes.": "Bruk filens egen korreksjon for fargekanter.", + "Use the file's own lens distortion correction, in place of the slider.": "Bruk filens egen korreksjon av objektivforvrengning, i stedet for glidebryteren.", + "Use the roll's average black and white points, with color still per frame; right-click turns every average on or off.": "Bruk rullens gjennomsnittlige svart- og hvitpunkt, med farge fortsatt per bilde; høyreklikk slår hvert gjennomsnitt på eller av.", + "Use the roll's average color balance, with black and white points still per frame.": "Bruk rullens gjennomsnittlige fargebalanse, med svart- og hvitpunkt fortsatt per bilde.", + "Use this color profile for a scan that has none.": "Bruk denne fargeprofilen for en skanning som ikke har noen.", + "Uses export destination": "Bruker eksportmålet", + "Using “{value}”, a baseline saved for a different roll": "Bruker «{value}», et grunnlag som er lagret for en annen rull", + "VIGNETTE": "VIGNETT", + "Vanadium": "Vanadium", + "Vanadium toner, B&W negatives only: green midtones and highlights while blacks stay black.": "Vanadiumtoner, bare S/H-negativer: grønne mellomtoner og høylys, mens svarttonene forblir svarte.", + "Vectorscope": "Vektorskop", + "Vectorscope: the print's colors on the a*b* plane, where a cast, a toner or a split tone shows": "Vektorskop: kopiens farger i a*b*-planet, der et fargestikk, en toner eller en delt toning vises", + "Version and project page": "Versjon og prosjektside", + "Vertical": "Loddrett", + "View": "Vis", + "Vignette Burn": "Vignett-etterbelysning", + "Vignette Roundness": "Vignettrundhet", + "Vignette Size": "Vignettstørrelse", + "Vignette burn down": "Vignett-etterbelysning ned", + "Vignette burn up": "Vignett-etterbelysning opp", + "Vignette burn ↑/↓": "Vignett-etterbelysning ↑/↓", + "Vignette size down": "Vignettstørrelse ned", + "Vignette size up": "Vignettstørrelse opp", + "Vignette size ↑/↓": "Vignettstørrelse ↑/↓", + "Virtual roll: a fixed list of frames that new files never join": "Virtuell rull: en fast liste med bilder som nye filer aldri blir med i", + "WB Picker": "WB-velger", + "Wait this long after Scan or Retake before the shot, so a shaky table can settle.": "Vent så lenge etter Skann eller Ta på nytt før bildet tas, så et ustøtt bord rekker å falle til ro.", + "Wait this long between successive trichrome channel exposures so the camera can flush the previous shot.": "Vent så lenge mellom påfølgende Trichrome-kanaleksponeringer, så kameraet rekker å tømme forrige bilde.", + "Wait until the files finish loading": "Vent til filene er ferdig lastet inn", + "Waiting for the camera…": "Venter på kameraet…", + "Washing soda bleach: removes blue, highlights first.": "Bleking med vaskesoda: fjerner blått, høylysene først.", + "Waveform": "Bølgeform", + "Waveform: the print's tones across the frame, column by column, on the zone scale": "Bølgeform: kopiens toner over bildet, kolonne for kolonne, på soneskalaen", + "WebP Lossless": "WebP tapsfri", + "WebP Method": "WebP-metode", + "WebP Quality": "WebP-kvalitet", + "Welcome to NegPy": "Velkommen til NegPy", + "What is on the film, because silver and Kodachrome block infrared": "Hva som er på filmen, fordi sølv og Kodachrome blokkerer infrarødt", + "What the Lento Dock does with the film when you click Scan.": "Hva Lento Dock gjør med filmen når du klikker Skann.", + "What the canvas shows: the print, a before/after split, or a peek.": "Hva lerretet viser: kopien, en delt før/etter-visning, eller en titt.", + "What the toolbar shows: the photo's tools, or the roll's filter, sort and actions": "Hva verktøylinjen viser: verktøyene for bildet, eller filteret, sorteringen og handlingene for rullen", + "What you scanned with, which NegPy reads from the file until you pick one.": "Hva du skannet med, som NegPy leser fra filen til du velger noe.", + "When a work print loads, restore its saved metadata": "Gjenopprett de lagrede metadataene når en arbeidskopi lastes inn", + "When on, a right-click on the picture brings back the mark under it, without a menu.": "Når dette er på, henter et høyreklikk på bildet tilbake merket under det, uten meny.", + "When on, each run shuffles this order; the order you set here is used only when Randomize is off.": "Når dette er på, blander hver kjøring denne rekkefølgen; rekkefølgen du angir her, brukes bare når Tilfeldig er av.", + "When the frames were taken: a year, a month, a date, or a date and time.": "Når bildene ble tatt: et år, en måned, en dato, eller en dato og et klokkeslett.", + "When the picture was taken: a year, a month, a date, or a date and time.": "Når bildet ble tatt: et år, en måned, en dato, eller en dato og et klokkeslett.", + "When the print leaves the developer: higher gives deeper, colder blacks, and lower stays light and warm.": "Når kopien tas ut av fremkalleren: høyere gir dypere, kaldere svarttoner, og lavere forblir lys og varm.", + "Where Auto Grade puts the darkest and brightest detail.": "Hvor autogradering plasserer den mørkeste og lyseste detaljen.", + "Where scanned frames are written": "Hvor skannede bilder skrives", + "Where the exported files go.": "Hvor de eksporterte filene havner.", + "Where the midtones sit, in stops: positive is bright (high-key), negative is dark (low-key).": "Hvor mellomtonene ligger, i blendertrinn: positiv er lyst (high-key), negativ er mørkt (low-key).", + "Where the picture was taken: paste coordinates or a map link, or pick a place on the map.": "Hvor bildet ble tatt: lim inn koordinater eller en kartlenke, eller velg et sted på kartet.", + "Where the reversal starts: tones darker than this stay as they are.": "Hvor reverseringen starter: toner mørkere enn dette blir som de er.", + "Where the tone limit starts: 0 is black, V is middle gray, X is white.": "Hvor tonegrensen starter: 0 er svart, V er mellomgrå, X er hvit.", + "Where these numbers came from: measured on a reference, tuned by eye on a rig, or read from film data sheets.": "Hvor disse tallene kom fra: målt på en referanse, finjustert etter øyemål på en rigg, eller lest fra datablader for film.", + "Which average the peek prints with: the scene's or the roll's": "Hvilket gjennomsnitt titten kopierer med: scenens eller rullens", + "Which exposure of a merged bracket to show, in stops below the brightest usable shot.": "Hvilken eksponering av en sammenslått eksponeringsserie som skal vises, i blendertrinn under det lyseste brukbare bildet.", + "Which kind of gear this pane lists": "Hvilken type utstyr denne ruten viser", + "Which tones inside the selected mask's shape it acts on": "Hvilke toner innenfor formen til den valgte masken den virker på", + "White": "Hvit", + "White / Black Point": "Hvit-/svartpunkt", + "White Balance": "Hvitbalanse", + "White LED, used only by white-light presets: it cannot light together with red, green and blue.": "Hvit LED, brukt bare av forhåndsinnstillinger med hvitt lys: den kan ikke lyse sammen med rød, grønn og blå.", + "White Light": "Hvitt lys", + "White Light (B&W or Slide Film)": "Hvitt lys (S/H- eller diasfilm)", + "White Light + IR, Color Negative": "Hvitt lys + IR, fargenegativ", + "White Light + IR, Slide": "Hvitt lys + IR, dias", + "White Light, B&W Negative": "Hvitt lys, S/H-negativ", + "White Light, Color Negative": "Hvitt lys, fargenegativ", + "White Light, Slide": "Hvitt lys, dias", + "White Paper": "Hvitt papir", + "White Point": "Hvitpunkt", + "White Point Trim": "Justering av hvitpunkt", + "White Point ↑/↓": "Hvitpunkt ↑/↓", + "White point down": "Hvitpunkt ned", + "White point up": "Hvitpunkt opp", + "Whole Roll ({roll_count})": "Hele rullen ({roll_count})", + "Whole Strip": "Hele stripen", + "Whole strip": "Hele stripen", + "Width": "Bredde", + "Window": "Vindu", + "Wipe the entire database: edits, history, marks, rig profiles, export presets and all app preferences.": "Tøm hele databasen: redigeringer, historikk, merker, riggprofiler, eksportforhåndsinnstillinger og alle appinnstillinger.", + "Wipe the entire database: every saved edit, undo history, keep/reject mark, flat-field profile, export preset, and all app preferences?\n\nThe app returns to its first-run state.": "Vil du tømme hele databasen: hver lagret redigering, angrehistorikk, behold/forkast-merke, flatfeltprofil, eksportforhåndsinnstilling og alle appinnstillinger?\n\nAppen går tilbake til tilstanden ved første oppstart.", + "With Positive on, Linear RAW would undo Positive, so leave it off.": "Med Positiv på ville lineær RAW oppheve Positiv, så la den være av.", + "Work Print": "Arbeidskopi", + "Work Prints": "Arbeidskopier", + "Work prints": "Arbeidskopier", + "Working strength, for example 1+1, 1+50 or stock": "Bruksstyrke, for eksempel 1+1, 1+50 eller stock", + "Working strength, for example 1+1, 1+50 or stock.": "Bruksstyrke, for eksempel 1+1, 1+50 eller stock.", + "Works on film density, with no speckle, but strong colors are slightly less vivid.": "Virker på filmtettheten, uten prikker, men sterke farger blir litt mindre livlige.", + "Write a diagnostics log (nkscan.log) in the NegPy folder, with Trace for a bug report.": "Skriv en diagnoselogg (nkscan.log) i NegPy-mappen, med Trace for en feilrapport.", + "Write edit sidecars for all visible frames now": "Skriv redigeringssidefiler for alle synlige bilder nå", + "Write the selected preset's fields onto this frame": "Skriv feltene fra den valgte forhåndsinnstillingen til dette bildet", + "Write this profile as a .toml in the NegPy/crosstalk folder, so it is reusable": "Skriv denne profilen som en .toml i mappen NegPy/crosstalk, så den kan brukes på nytt", + "Written to exported files on export.": "Skrives til eksporterte filer ved eksport.", + "Written to standard EXIF": "Skrevet til standard EXIF", + "Wrote {value}": "Skrev {value}", + "Yellow": "Gul", + "Yellow #8": "Gul #8", + "Yellow down": "Gul ned", + "Yellow up": "Gul opp", + "Yellow ↑/↓": "Gul ↑/↓", + "Yellow-Green #11": "Gulgrønn #11", + "You Are Ready": "Du er klar", + "You are on {value}. Download it from the releases page to update.": "Du har {value}. Last den ned fra utgivelsessiden for å oppdatere.", + "You are on {value}. NegPy can install this for you ({size} download).": "Du har {value}. NegPy kan installere denne for deg (nedlasting på {size}).", + "You can also connect the Scanlight to scan in RGB.": "Du kan også koble til Scanlight for å skanne i RGB.", + "You can import a folder as a roll again at any time.": "Du kan importere en mappe som en rull igjen når som helst.", + "You have not added any presets yet.": "Du har ikke lagt til noen forhåndsinnstillinger ennå.", + "You have not added any {value} yet.": "Du har ikke lagt til noen {value} ennå.", + "Your Scanning Setup": "Skanneoppsettet ditt", + "Your own notes on this item": "Dine egne notater om dette elementet", + "Your own text for the roll label, or the roll name, film, camera and date when this is empty.": "Din egen tekst for rulletiketten, eller rullnavnet, filmen, kameraet og datoen når dette er tomt.", + "Your presets": "Dine forhåndsinnstillinger", + "Your saved edits stay in the database.": "De lagrede redigeringene dine blir værende i databasen.", + "Your saved edits stay in the database. The roll stays in the Library.": "De lagrede redigeringene dine blir værende i databasen. Rullen blir værende i biblioteket.", + "Your saved edits stay in the database. This only removes the frames from the list.": "De lagrede redigeringene dine blir værende i databasen. Dette fjerner bare bildene fra listen.", + "Zone Highlight": "Soneutheving", + "Zone Highlight: hover a zone strip cell to show that zone alone on the frame": "Soneutheving: hold pekeren over en celle i sonestripen for å vise bare den sonen på bildet", + "Zone Highlight: show the zone strip's hovered zone alone on the frame": "Soneutheving: vis bare sonen som pekeren holdes over i sonestripen, på bildet", + "Zone Overlay": "Soneoverlegg", + "Zone Overlay look: a color per zone, eleven gray steps, or zone numbers on a grid.": "Utseende for soneoverlegget: én farge per sone, elleve grå trinn, eller sonenumre i et rutenett.", + "Zone Placement": "Soneplassering", + "Zone overlay": "Soneoverlegg", + "Zone placement: pick a zone on the strip, then click the picture, and that spot prints in that zone.": "Soneplassering: velg en sone på stripen, og klikk så på bildet, så kopieres det punktet i den sonen.", + "Zone {value}: {value1:.1f}%": "Sone {value}: {value1:.1f}%", + "Zone {zone}": "Sone {zone}", + "Zone {zone}: {share} of the frame": "Sone {zone}: {share} av bildet", + "Zone, print density and ΔD of the pixel under the pointer": "Sone, kopitetthet og ΔD for pikselen under pekeren", + "Zoom": "Zoom", + "Zoom 100%": "Zoom 100 %", + "Zoom 200%": "Zoom 200 %", + "Zoom in": "Zoom inn", + "Zoom out": "Zoom ut", + "Zoom: at 100%, one scan pixel fills one screen pixel": "Zoom: ved 100 % fyller én skannepiksel én skjermpiksel", + "_No release notes._": "_Ingen utgivelsesnotater._", + "a TIFF negative": "et TIFF-negativ", + "aborted": "avbrutt", + "acutance {acutance:.1f} · {detail}": "skarphet {acutance:.1f} · {detail}", + "base": "base", + "blue": "blå", + "cameras": "kameraer", + "card": "kort|kort", + "choose a valid output folder (the current one does not exist)": "velg en gyldig utdatamappe (den gjeldende finnes ikke)", + "choose an output folder": "velg en utdatamappe", + "clipboard": "utklippstavle", + "clone stroke": "klonestrøk", + "close the aperture (e.g. f/11) or lower the ISO, then recalibrate": "blend ned (f.eks. f/11) eller senk ISO-en, og kalibrer på nytt", + "connect the Scanlight": "koble til Scanlight", + "connect the camera": "koble til kameraet", + "contact sheet": "kontaktark", + "cropped": "beskåret", + "current metadata": "gjeldende metadata", + "current settings": "gjeldende innstillinger", + "e.g. 1+50": "f.eks. 1+50", + "e.g. 1+50, stock": "f.eks. 1+50, stock", + "e.g. 1/125s f/2.8 ISO 400": "f.eks. 1/125s f/2.8 ISO 400", + "e.g. 12": "f.eks. 12", + "e.g. 20": "f.eks. 20", + "e.g. 6×7": "f.eks. 6×7", + "e.g. D-76": "f.eks. D-76", + "e.g. DSLR copy-stand scan": "f.eks. skanning med speilreflekskamera på reprostativ", + "e.g. Portra 400": "f.eks. Portra 400", + "e.g. Portra 400 + CS-Lite": "f.eks. Portra 400 + CS-Lite", + "e.g. Roll001": "f.eks. Roll001", + "e.g. TIFF": "f.eks. TIFF", + "e.g. Tokyo, Japan": "f.eks. Oslo, Norge", + "e.g. X-T30 + Scanlight v4": "f.eks. X-T30 + Scanlight v4", + "edit sidecar": "redigeringssidefil|redigeringssidefiler", + "exist": "finnes", + "exists": "finnes", + "export {width} × {height} px": "eksport {width} × {height} px", + "exposure": "eksponering|eksponeringer", + "file": "fil|filer", + "film stocks": "filmtyper", + "filtered roll": "filtrert rull|filtrerte ruller", + "flash {value:.2f}": "forbelysning {value:.2f}", + "folder missing": "mappen mangler", + "frame": "bilde|bilder", + "full frame": "fullt bilde", + "green": "grønn", + "highlights": "høylys", + "image": "bilde|bilder", + "keeper": "beholdt bilde|beholdte bilder", + "lenses": "objektiver", + "loaded frame": "innlastet bilde|innlastede bilder", + "locked frame": "låst bilde|låste bilder", + "manual heal": "manuell legeing", + "negative": "negativ", + "new roll": "ny rull|nye ruller", + "nkscan is not installed, so there is no debug log to write": "nkscan er ikke installert, så det finnes ingen feilsøkingslogg å skrive", + "no camera: plug it in over USB, in PC Remote mode": "ikke noe kamera: koble det til med USB, i PC Remote-modus", + "no scratch directory: {error}": "ingen midlertidig mappe: {error}", + "none yet": "ingen ennå", + "open the aperture (e.g. f/5.6) or raise the ISO, then recalibrate": "åpne blenderen (f.eks. f/5.6) eller øk ISO-en, og kalibrer på nytt", + "over-exposed": "overeksponert", + "photo": "foto|fotoer", + "positive": "positiv", + "preset": "forhåndsinnstilling|forhåndsinnstillinger", + "preview res": "forhåndsvisningsoppl.", + "preview res · HQ off": "forhåndsvisningsoppl. · HQ av", + "processes": "prosesser", + "raw device": "RAW-enhet|RAW-enheter", + "reading {value:.0f} s/frame": "leser {value:.0f} s/bilde", + "red": "rød", + "rejected": "forkastet", + "removed": "fjernet", + "rendering export…": "gjengir eksport…", + "roll": "rull|ruller", + "row": "rad|rader", + "scan setups": "skanneoppsett", + "scene": "scene|scener", + "search": "søk", + "select or create a preset": "velg eller lag en forhåndsinnstilling", + "selected frame": "valgt bilde", + "selected image": "valgt bilde", + "set": "sett|sett", + "setting": "innstilling|innstillinger", + "sheet": "ark|ark", + "slot": "plass|plasser", + "strip": "stripe|striper", + "thin end": "tynn ende", + "this roll": "denne rullen", + "thumbnail": "miniatyrbilde|miniatyrbilder", + "under-exposed": "undereksponert", + "visible frame": "synlig bilde|synlige bilder", + "wait for the calibration to finish": "vent til kalibreringen er ferdig", + "wait for the exposure to reach the camera": "vent til eksponeringen når kameraet", + "wait for the meter": "vent på måleren", + "window": "vindu|vinduer", + "{_CLAMP_NOTICE} on {value} {frames}.": "{_CLAMP_NOTICE} på {value} {frames}.", + "{_CLAMP_NOTICE} on {value} {frames}. Reduce Offset, Drift or that frame's own slider.": "{_CLAMP_NOTICE} på {value} {frames}. Reduser Forskyvning, Avdrift eller bildets egen glidebryter.", + "{_CUT_NOTICE} on {value} {frames}: up to {worst:.1f} mm of picture lost off the frame tail. Reduce Offset or that frame's own slider, or feed the strip again.": "{_CUT_NOTICE} på {value} {frames}: opptil {worst:.1f} mm av bildet tapt i enden av bildet. Reduser Forskyvning eller bildets egen glidebryter, eller mat stripen inn på nytt.", + "{_active_batch_title} is already running": "{_active_batch_title} kjører allerede", + "{caption}, restored": "{caption}, gjenopprettet", + "{caption}, unmixed": "{caption}, skilt", + "{control} ({page})": "{control} ({page})", + "{count} masks already carry a tone limit, so set one of them to All to limit this one.": "{count} masker har allerede en tonegrense, så sett en av dem til Alle for å begrense denne.", + "{count} selected": "{count} valgt", + "{count} skipped": "{count} hoppet over", + "{count} {frames} · {rows} {row_word} · {value1} {sheets}": "{count} {frames} · {rows} {row_word} · {value1} {sheets}", + "{count} {frames} · {strips} {strip_word} of {value} · {value1} {sheets}": "{count} {frames} · {strips} {strip_word} av {value} · {value1} {sheets}", + "{count}× sampled": "{count}× prøvetatt", + "{fastest}, the fastest the Scanlight meters cleanly": "{fastest}, det raskeste Scanlight måler rent", + "{label} ready. Click a patch to keep it.": "{label} er klar. Klikk et felt for å beholde det.", + "{label}: {n} {noun} to {count} {frame_word}": "{label}: {n} {noun} til {count} {frame_word}", + "{message}\nYou can still download it from the releases page.": "{message}\nDu kan fortsatt laste den ned fra utgivelsessiden.", + "{message}. Frames that keep their own value: {count}": "{message}. Bilder som beholder egen verdi: {count}", + "{method} reads green {g}, blue {b}.": "{method} leser grønn {g}, blå {b}.", + "{mode} Mode": "{mode}-modus", + "{names}\n\nOverwrite, save with a new name, or cancel?": "{names}\n\nVil du overskrive, lagre med et nytt navn eller avbryte?", + "{names} +{count}": "{names} +{count}", + "{name} Copy": "{name} kopi", + "{name}: a TIFF cannot carry a slide's camera color matrix": "{name}: en TIFF kan ikke bære kameraets fargematrise for et dias", + "{name}: a bracket would lose its shadow detail in a TIFF": "{name}: en eksponeringsserie ville mistet skyggedetaljene i en TIFF", + "{name}: a diptych cannot merge": "{name}: et diptyk kan ikke slås sammen", + "{name}: a half-frame scan cannot merge": "{name}: en halvformatskanning kan ikke slås sammen", + "{name}: a source file is missing or not a camera RAW": "{name}: en kildefil mangler eller er ikke en kamera-RAW", + "{name}: could not be rendered for the contact sheet": "{name}: kunne ikke gjengis for kontaktarket", + "{name}: this source format cannot merge": "{name}: dette kildeformatet kan ikke slås sammen", + "{phase}… %p%": "{phase}… %p%", + "{picked} of {value}": "{picked} av {value}", + "{prefix}NegPy — {tool_prefix}{filename}": "{prefix}NegPy — {tool_prefix}{filename}", + "{ratio} paper": "{ratio}-papir", + "{reason}\n\nFraming and focus have to be set on the camera itself. Scanning works as usual.": "{reason}\n\nInnramming og fokus må angis på selve kameraet. Skanning virker som vanlig.", + "{reason} The calibration continues without the preview.": "{reason} Kalibreringen fortsetter uten forhåndsvisning.", + "{seconds:g} s": "{seconds:g} s", + "{sheet_note}{crop_status}\n{crop_warning}\n\n{summary} A frame whose color is far from the rest keeps its own exposure and color.\n\nEach frame is measured with its own Metering settings, as the roll average is measured in the background.\n\nContinue?": "{sheet_note}{crop_status}\n{crop_warning}\n\n{summary} Et bilde med farge langt fra resten beholder egen eksponering og farge.\n\nHvert bilde måles med sine egne lysmålingsinnstillinger, slik rullens gjennomsnitt måles i bakgrunnen.\n\nVil du fortsette?", + "{shown} of {total}": "{shown} av {total}", + "{status} · RAW zebras off: {reason}": "{status} · RAW-sebraer av: {reason}", + "{summary} · preview {value}/{total}": "{summary} · forhåndsvisning {value}/{total}", + "{summary} · {value} left out": "{summary} · {value} utelatt", + "{temp:.0f} °C": "{temp:.0f} °C", + "{title} is running; export once it finishes.": "{title} kjører; eksporter når den er ferdig.", + "{title} is this frame's own": "{title} er dette bildets eget", + "{title}: {name} ({current}/{total})": "{title}: {name} ({current}/{total})", + "{title}…": "{title}…", + "{value:+.1f} mm": "{value:+.1f} mm", + "{value:+.2f} mm/frame": "{value:+.2f} mm/bilde", + "{value:.1f} mm": "{value:.1f} mm", + "{value:.1f} × {value1:.1f} mm{offset_txt}": "{value:.1f} × {value1:.1f} mm{offset_txt}", + "{value} {stops:+.1f} EV": "{value} {stops:+.1f} EV", + "{value} {stops:+.1f} EV (as captured)": "{value} {stops:+.1f} EV (slik det ble tatt)", + "{value} (off)": "{value} (av)", + "{value} already merged: delete the negative to merge again": "{value} er allerede slått sammen: slett negativet for å slå sammen på nytt", + "{value} already {value1} in the export destination.": "{value} {value1} allerede i eksportmålet.", + "{value} and {value1}": "{value} og {value1}", + "{value} applied to the whole roll ({value1})": "{value} brukt på hele rullen ({value1})", + "{value} applied to {value1}": "{value} brukt på {value1}", + "{value} blocks infrared": "{value} blokkerer infrarødt", + "{value} composite or forked skipped": "{value} sammensatte eller forgrenede hoppet over", + "{value} cores available. Applies at once, no restart.": "{value} kjerner tilgjengelig. Gjelder med en gang, uten omstart.", + "{value} could not be exported.": "{value} kunne ikke eksporteres.", + "{value} could not go to the Trash": "{value} kunne ikke flyttes til papirkurven", + "{value} could not go to the Trash: {value1}": "{value} kunne ikke flyttes til papirkurven: {value1}", + "{value} detected. Check the framing before scanning.": "{value} oppdaget. Kontroller innrammingen før skanning.", + "{value} failed": "{value} mislyktes", + "{value} failed: {message}": "{value} mislyktes: {message}", + "{value} far from the roll color keep their own exposure and color: {names}": "{value} langt fra rullens farge beholder egen eksponering og farge: {names}", + "{value} far from the roll color keeps its own exposure and color: {names}": "{value} langt fra rullens farge beholder egen eksponering og farge: {names}", + "{value} far from the scene color keep their own exposure and color: {names}": "{value} langt fra scenens farge beholder egen eksponering og farge: {names}", + "{value} far from the scene color keeps its own exposure and color: {names}": "{value} langt fra scenens farge beholder egen eksponering og farge: {names}", + "{value} follows the roll: click to give the roll this frame's value": "{value} følger rullen: klikk for å gi rullen dette bildets verdi", + "{value} follows this frame alone": "{value} følger bare dette bildet", + "{value} found: name and accept them in Roll Analysis": "{value} funnet: gi dem navn og godta dem i rullanalysen", + "{value} is not a readable NegPy sidecar.": "{value} er ikke en lesbar NegPy-sidefil.", + "{value} is this frame's own": "{value} er dette bildets eget", + "{value} kept its own exposure": "{value} beholdt egen eksponering", + "{value} kept their own exposure": "{value} beholdt egen eksponering", + "{value} lists every shortcut and {value1} finds any control. Each card's ⓘ opens its guide. Replay this tour from the ⋯ menu.": "{value} viser hver hurtigtast, og {value1} finner hvilken som helst kontroll. ⓘ på hvert kort åpner veiledningen. Spill av denne omvisningen på nytt fra ⋯-menyen.", + "{value} measured again": "{value} målt på nytt", + "{value} not available": "{value} er ikke tilgjengelig", + "{value} of {value1}": "{value} av {value1}", + "{value} of {value1} edited": "{value} av {value1} redigert", + "{value} shows the whole roll as film on a light table, for sorting. Right-click a selection to stitch parts of a large negative or merge a bracket (HDR).": "{value} viser hele rullen som film på et lysbord, for sortering. Høyreklikk et utvalg for å sy sammen deler av et stort negativ eller slå sammen en eksponeringsserie (HDR).", + "{value} splits the canvas between the default conversion and your edit. Hold {value1} to see the scan as a negative.": "{value} deler lerretet mellom standardkonverteringen og redigeringen din. Hold {value1} for å se skanningen som et negativ.", + "{value} without an average yet kept their own measurement": "{value} uten gjennomsnitt ennå beholdt egen måling", + "{value} {frame_word} found": "{value} {frame_word} funnet", + "{value} {value1}": "{value} {value1}", + "{value} → {value1}": "{value} → {value1}", + "{value}%": "{value}%", + "{value}% HQ": "{value}% HQ", + "{value}: {value1}": "{value}: {value1}", + "{value}{value1}": "{value}{value1}", + "{visible} of {n} frames": "{visible} av {n} bilder", + "~{value} min left": "~{value} min igjen", + "~{value} s left": "~{value} s igjen", + "—": "—", + "— None —": "— Ingen —", + "“{name}” already exists. Replace it?": "«{name}» finnes allerede. Vil du erstatte den?", + "“{name}” held {value} from other folders. A folder roll now holds only the files in its folder, so they no longer open with it. Their edits are kept.": "«{name}» inneholdt {value} fra andre mapper. En mapperull inneholder nå bare filene i sin egen mappe, så de åpnes ikke lenger med den. Redigeringene deres beholdes.", + "“{name}” is a built-in preset; pick another name": "«{name}» er en innebygd forhåndsinnstilling; velg et annet navn", + "“{name}” is already a folder roll, so open it from the Library.": "«{name}» er allerede en mapperull, så åpne den fra biblioteket.", + "“{name}” was not saved: the film base is {label}.": "«{name}» ble ikke lagret: filmbasen er {label}.", + "“{new_name}” already exists. Replace it?": "«{new_name}» finnes allerede. Vil du erstatte den?", + "“{old}” is now the folder roll “{name}”": "«{old}» er nå mapperullen «{name}»", + "“{old}” is now the folder roll “{name}”. {value} no longer on disk left the roll.": "«{old}» er nå mapperullen «{name}». {value} som ikke lenger finnes på disken, forlot rullen.", + "“{value}” already exists in the export folder.": "«{value}» finnes allerede i eksportmappen.", + "…and {value} more": "…og {value} til", + "→ lands {achieved}": "→ havner på {achieved}", + "● {value}": "● {value}", + "✓ Captured": "✓ Tatt opp", + "✓ Frame {frame} captured": "✓ Bilde {frame} tatt opp" +} diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 36d58ca9a..ab5a069d9 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -46,6 +46,10 @@ def chinese() -> Iterator[None]: ("vi", ["en-US"], "vi"), ("VI_VN", ["en-US"], "vi"), ("system", ["vi-VN"], "vi"), + ("nb", ["en-US"], "nb"), + ("system", ["nb-NO"], "nb"), + ("system", ["no"], "nb"), + ("system", ["nn-NO"], "nb"), ("system", [], "en"), ("invalid", ["zh-CN"], "en"), ], @@ -227,7 +231,7 @@ def test_language_preference_is_saved_and_waits_for_restart(locale: str) -> None assert reopened.language_combo.currentData() == locale -@pytest.mark.parametrize("locale,word", [("zh_CN", "密度"), ("ja", "濃度"), ("ko", "농도"), ("de", "Dichte")]) +@pytest.mark.parametrize("locale,word", [("zh_CN", "密度"), ("ja", "濃度"), ("ko", "농도"), ("de", "Dichte"), ("nb", "tetthet")]) def test_localized_guides_cover_every_panel_and_fall_back(locale: str, word: str) -> None: from negpy.desktop.view.widgets.section_help_dialog import _localized_guides, _read_guides, guide_markdown diff --git a/tests/test_sequential_frame.py b/tests/test_sequential_frame.py index 11b0525de..6f2d5fbf0 100644 --- a/tests/test_sequential_frame.py +++ b/tests/test_sequential_frame.py @@ -20,14 +20,14 @@ def test_sequential_preview_uses_language_count_forms(qapp, locale: str, noun: s i18n.configure(previous) -@pytest.mark.parametrize("count,noun", [(1, "Bild"), (2, "Bilder")]) -def test_sequential_preview_uses_german_count_forms(qapp, count: int, noun: str) -> None: +@pytest.mark.parametrize("locale,count,noun", [("de", 1, "Bild"), ("de", 2, "Bilder"), ("nb", 1, "bilde"), ("nb", 2, "bilder")]) +def test_sequential_preview_uses_singular_and_plural_count_forms(qapp, locale: str, count: int, noun: str) -> None: from negpy.desktop.view.widgets.granular_settings_dialog import SequentialFrameDialog from negpy.kernel.system import i18n previous = i18n.language() try: - i18n.configure("de") + i18n.configure(locale) dialog = SequentialFrameDialog(None, count, count, 10) assert dialog.preview.text().endswith(f"({count} {noun})") finally: diff --git a/tests/test_step_combo.py b/tests/test_step_combo.py index c45608f2e..7a11b31e4 100644 --- a/tests/test_step_combo.py +++ b/tests/test_step_combo.py @@ -104,7 +104,7 @@ def test_a_header_never_becomes_current(qapp): assert changed == [] -@pytest.mark.parametrize("locale", ["zh_CN", "ja", "ko", "de", "vi"]) +@pytest.mark.parametrize("locale", ["zh_CN", "ja", "ko", "de", "vi", "nb"]) def test_export_ratio_arrows_keep_stored_values_in_translated_ui(qapp, locale: str) -> None: from negpy.desktop.view.widgets.export_settings_form import ExportSettingsForm from negpy.kernel.system import i18n From b6b4d3670e563cc4adf2ea850c52b1367cb3b5c4 Mon Sep 17 00:00:00 2001 From: Akinyemi Paul Odulate Date: Wed, 4 Nov 2026 20:41:52 +0000 Subject: [PATCH 39/46] Format the Blend tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CiaS7mdcgupjy3zYtMa4Su --- tests/test_capture_modes.py | 7 ++++++- tests/test_capture_worker.py | 27 +++++++++++++++++++++------ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/tests/test_capture_modes.py b/tests/test_capture_modes.py index 75bc017aa..d72e107e0 100644 --- a/tests/test_capture_modes.py +++ b/tests/test_capture_modes.py @@ -23,7 +23,12 @@ def test_a_recorded_capture_reads_back_by_path(): record_capture_mode(repo, "/scans/roll/Roll_Frame001.ARW", BLEND) record_capture_mode(repo, "/scans/roll/Roll_Frame002.ARW", SINGLE) record_capture_mode(repo, "/scans/other/Other_Frame001.ARW", SINGLE) - paths = ["/scans/roll/Roll_Frame001.ARW", "/scans/roll/Roll_Frame002.ARW", "/scans/roll/Roll_Frame003.ARW", "/scans/other/Other_Frame001.ARW"] + paths = [ + "/scans/roll/Roll_Frame001.ARW", + "/scans/roll/Roll_Frame002.ARW", + "/scans/roll/Roll_Frame003.ARW", + "/scans/other/Other_Frame001.ARW", + ] assert capture_modes(repo, paths) == { "/scans/roll/Roll_Frame001.ARW": BLEND, "/scans/roll/Roll_Frame002.ARW": SINGLE, diff --git a/tests/test_capture_worker.py b/tests/test_capture_worker.py index 58ab17aa4..72b7554ee 100644 --- a/tests/test_capture_worker.py +++ b/tests/test_capture_worker.py @@ -1321,7 +1321,9 @@ def test_advance_film_after_a_blend_scan_moves_the_film(tmp_path, monkeypatch): monkeypatch.setattr(worker, "_ensure_light", lambda _port: dock) assert worker.run_capture( - CaptureRequest(roll_name="Roll", frame_number=1, output_folder=str(tmp_path), levels=(255, 255, 255), blend_capture=True, advance_film=True) + CaptureRequest( + roll_name="Roll", frame_number=1, output_folder=str(tmp_path), levels=(255, 255, 255), blend_capture=True, advance_film=True + ) ) assert dock.events == ["advance"] @@ -1340,8 +1342,14 @@ def test_a_blend_strip_holds_bulb_between_frames_and_restores_it_before_the_flat worker.run_capture( CaptureRequest( - roll_name="Roll", frame_number=1, output_folder=str(tmp_path), levels=(255, 255, 255), shutters=("1/2",) * 3, - blend_capture=True, whole_strip=True, strip_flat=True, + roll_name="Roll", + frame_number=1, + output_folder=str(tmp_path), + levels=(255, 255, 255), + shutters=("1/2",) * 3, + blend_capture=True, + whole_strip=True, + strip_flat=True, ) ) @@ -1352,7 +1360,9 @@ def test_a_blend_strip_holds_bulb_between_frames_and_restores_it_before_the_flat def test_a_single_blend_scan_puts_the_shutter_back(tmp_path, monkeypatch): camera = _BulbCamera() worker = _blend_worker(monkeypatch, camera, []) - worker.run_capture(CaptureRequest(roll_name="Roll", frame_number=1, output_folder=str(tmp_path), levels=(255, 255, 255), blend_capture=True)) + worker.run_capture( + CaptureRequest(roll_name="Roll", frame_number=1, output_folder=str(tmp_path), levels=(255, 255, 255), blend_capture=True) + ) assert camera.kept == [False] @@ -1394,8 +1404,13 @@ def capture_bulb(self, out_path, expose, **kw): worker.run_capture( CaptureRequest( - roll_name="Roll", frame_number=4, output_folder=str(tmp_path), levels=(255, 255, 255), shutters=("1/2",) * 3, - blend_capture=True, whole_strip=True, + roll_name="Roll", + frame_number=4, + output_folder=str(tmp_path), + levels=(255, 255, 255), + shutters=("1/2",) * 3, + blend_capture=True, + whole_strip=True, ) ) From b908a0311735d63815d2fb0345e4332b8b15c7d1 Mon Sep 17 00:00:00 2001 From: jboneng Date: Wed, 4 Nov 2026 22:21:28 +0100 Subject: [PATCH 40/46] Re-run CI From eddd5d1a7c8d467ee87c36c26ce9ce44d098cbb2 Mon Sep 17 00:00:00 2001 From: Kurt Windisch Date: Wed, 4 Nov 2026 16:50:33 -0800 Subject: [PATCH 41/46] fix(optics): keep camera Optics off new film scanner frames A new frame took the last chosen flat field profile and the carried distortion from any source, so a camera rig's corrections applied to a film scanner roll. The Optics card showed only while a correction was on, so clicking the bulb changed the picture and hid the card. A new frame now drops the carried Optics when Scanning Setup says Film Scanner or the file is not a camera RAW. Values that the frame's roll holds still apply. A film scanner frame leaves the carried values alone when it saves, so the next camera frame keeps them. The card stays in view after a correction turns off, until another frame opens. Co-Authored-By: Claude Opus 5.5 --- docs/USER_GUIDE.md | 2 +- negpy/desktop/session.py | 47 +++++++++++++------- negpy/desktop/view/sidebar/controls_panel.py | 10 ++++- negpy/translations/USER_GUIDE.de.md | 4 +- negpy/translations/USER_GUIDE.en.md | 2 +- negpy/translations/USER_GUIDE.ja.md | 4 +- negpy/translations/USER_GUIDE.ko.md | 4 +- negpy/translations/USER_GUIDE.zh_CN.md | 4 +- tests/test_desktop_session.py | 44 ++++++++++++++++++ tests/test_scan_setup.py | 15 +++++++ 10 files changed, 109 insertions(+), 27 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e8e98f61a..2a60ab6dc 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -505,7 +505,7 @@ Removes uneven light, such as darker corners, with a shot of the bare light sour * **Apply Flat Field**: applies the selected profile to this roll. * **Check Flat Field**: shows how well the profile evens out its own shot. Even gray is good; a band or patch shows where it is off. -A frame keeps its own profile unless it follows its roll, so each light and film format can have one. A new frame, or a frame whose profile is missing, takes the last profile you chose. +A frame keeps its own profile unless it follows its roll, so each light and film format can have one. A new camera frame, or a frame whose profile is missing, takes the last profile you chose. A new film scanner frame starts with no Optics corrections, and shows this card only if it holds one. ### 10.4 Calibration: colors from your scanning setup diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index 517f32c48..0a1507022 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -34,8 +34,8 @@ from negpy.features.exposure.models import apply_targets from negpy.features.process.capture_color import is_source_profile from negpy.features.geometry.logic import flip_geometry_and_analysis, has_manual_crop, rotate_geometry_and_analysis -from negpy.features.process.models import invalidate_local_bounds, mode_aware_exposure_reset, with_process_mode -from negpy.infrastructure.loaders.source_kind import with_scanner_positive_default +from negpy.features.process.models import invalidate_local_bounds, mode_aware_exposure_reset, scan_setup_rig, with_process_mode +from negpy.infrastructure.loaders.source_kind import is_scanned_positive_source, with_scanner_positive_default from negpy.features.rgbscan.models import RgbScanConfig, is_rgb_triplet from negpy.features.hdr.logic import resolve_anchor, seed_shadow_density from negpy.features.hdr.models import ANCHOR_EV_UNSET, HdrConfig, hdr_frame_paths @@ -1394,25 +1394,26 @@ def _persist_sticky_settings(self, config: WorkspaceConfig) -> None: from dataclasses import asdict snapshot = sticky_snapshot(config) - if PresetStore(self.repo).owns_sensor_profile(config.process.sensor_profile): - # A Scanlight preset's profile reaches a roll through that preset's scans. Carried - # from here it would land on rolls scanned another way. - stored = self.repo.get_global_setting(STICKY_CONFIG_KEY) - stored = stored if isinstance(stored, dict) else {} - for key in ("sensor_profile", "sensor_matrix"): + stored = self.repo.get_global_setting(STICKY_CONFIG_KEY) + stored = stored if isinstance(stored, dict) else {} + + def keep_stored(keys: tuple) -> None: + for key in keys: if key in stored: snapshot[key] = stored[key] else: snapshot.pop(key, None) + + if PresetStore(self.repo).owns_sensor_profile(config.process.sensor_profile): + # A Scanlight preset's profile reaches a roll through that preset's scans. Carried + # from here it would land on rolls scanned another way. + keep_stored(("sensor_profile", "sensor_matrix")) if has_manual_crop(config.geometry) or self._keep_carried_crop: # A hand-drawn crop, or a reset of one, is one frame's placement: the carried crop stays as it was, whole. - stored = self.repo.get_global_setting(STICKY_CONFIG_KEY) - stored = stored if isinstance(stored, dict) else {} - for key in ("crop_from_auto", "crop_rect", "crop_detect_key"): - if key in stored: - snapshot[key] = stored[key] - else: - snapshot.pop(key, None) + keep_stored(("crop_from_auto", "crop_rect", "crop_detect_key")) + if self._film_scanner_file(self.state.current_file_path or ""): + # Optics describe the camera rig, so a film scanner frame leaves its carried values alone. + keep_stored(rolls.card_fields("lens") + rolls.card_fields("flatfield")) self.repo.save_global_settings( { STICKY_CONFIG_KEY: snapshot, @@ -1510,10 +1511,24 @@ def _hydrate_asset_config(self, asset: dict) -> WorkspaceConfig: # # Not a bare WorkspaceConfig(): only this config holds the shipped autocrop fields, and the # print/transfer curves are an identity at it (transfer_grade_ref, test_transparency_transfer.py). - config = self._overlay_roll_defaults(self._apply_sticky_settings(DEFAULT_WORKSPACE_CONFIG, only_global=False), asset, fresh=True) + carried = self._apply_sticky_settings(DEFAULT_WORKSPACE_CONFIG, only_global=False) + config = self._overlay_roll_defaults(self._without_camera_optics(carried, asset["path"]), asset, fresh=True) config = self._asset_defaults(config, asset) return config if self._roll_sets_positive(asset) else with_scanner_positive_default(config, asset["path"]) + def _film_scanner_file(self, path: str) -> bool: + """The Scanning Setup says Film Scanner, or *path* is not a camera RAW. A pick of Camera + does not cover a TIFF, because the Scan tab's source can differ from the Setup card.""" + return scan_setup_rig(self.repo.get_global_setting("scan_setup"))[0] == "scanner" or is_scanned_positive_source(path) + + def _without_camera_optics(self, config: WorkspaceConfig, path: str) -> WorkspaceConfig: + """*config* without the camera rig's carried Optics when *path* is a film scanner frame.""" + base = DEFAULT_WORKSPACE_CONFIG + geometry = replace(config.geometry, **{f: getattr(base.geometry, f) for f in rolls.card_fields("lens")}) + bare = replace(config, geometry=geometry, flatfield=base.flatfield) + # Telling a Coolscan NEF from a camera NEF opens the file, so skip it when nothing carries. + return bare if bare != config and self._film_scanner_file(path) else config + def _roll_sets_positive(self, asset: dict) -> bool: """The frame's roll holds a Positive value on its Film card, which beats the scanner default.""" roll_id = rolls.frame_roll(self.repo, self.state.active_roll_id, asset) diff --git a/negpy/desktop/view/sidebar/controls_panel.py b/negpy/desktop/view/sidebar/controls_panel.py index ff496aa9b..871b94c4d 100644 --- a/negpy/desktop/view/sidebar/controls_panel.py +++ b/negpy/desktop/view/sidebar/controls_panel.py @@ -1,4 +1,6 @@ from negpy.kernel.system.i18n import tr +from typing import Optional + import qtawesome as qta from PyQt6.QtWidgets import ( QMenu, @@ -1054,6 +1056,8 @@ def _sync_all_sidebars(self) -> None: self._sync_scope_buttons() self._sync_modified_dots() + _optics_held_by: Optional[str] = None + def _sync_scan_setup(self) -> None: """The scanning setup decides which rig cards show: Raw Decode and Optics are a camera's, Trichrome a narrowband camera's. A card or row that holds a setting stays in view.""" @@ -1067,7 +1071,11 @@ def _sync_scan_setup(self) -> None: self.sensor_section.setVisible(self.sensor_sidebar.has_controls()) self.demosaic_section.setVisible(camera or cfg.process.highlight_reconstruction != _DEFAULT_PROCESS.highlight_reconstruction) optics_edited = any(getattr(cfg.geometry, f) != getattr(_DEFAULT_GEOMETRY, f) for f in _LENS_FIELDS) or cfg.flatfield.apply - self.optics_section.setVisible(camera or optics_edited) + file_hash = state.current_file_hash + if optics_edited: + self._optics_held_by = file_hash + # A correction turned off stays in reach until another frame opens. + self.optics_section.setVisible(camera or optics_edited or (bool(file_hash) and file_hash == self._optics_held_by)) _ROLL_CARD_LABELS = AppController._ROLL_CARD_LABELS diff --git a/negpy/translations/USER_GUIDE.de.md b/negpy/translations/USER_GUIDE.de.md index d6a0fbd76..7cb7cb11f 100644 --- a/negpy/translations/USER_GUIDE.de.md +++ b/negpy/translations/USER_GUIDE.de.md @@ -404,7 +404,7 @@ Wie eine Kamera-RAW-Datei in Pixel umgewandelt wird. Wird bei einer Kamera angez * **Vorschau** / **Export**: die Demosaicing-Methode, die die Farben ergänzt, die ein Sensorpixel nicht sieht. **Auto** ist am Bildschirm schnell und nutzt für den Export **AHD**. **VNG** ist glatt und **PPG** schnell. **DCB** und **DHT** erhalten mehr feine Details, können aber Korn in Muster verwandeln. **AAHD** macht Kanten weicher. Es werden nur die Methoden deines LibRaw-Builds aufgeführt. * **Wiederherstellung** (Dias): rekonstruiert ausgebrannte Lichter in einer Kamera-RAW-Datei. **Mischen** passt zu Sonne und Himmel. **Rekonstruieren** ist stärker, kann aber die Farbe falsch treffen. - + ### 10.7 Optik Korrekturen für Objektiv und Licht, gleich für jedes Bild, das du mit diesem Aufbau scannst. Wird bei einer Kamera angezeigt. @@ -422,7 +422,7 @@ Entfernt ungleichmäßiges Licht, zum Beispiel dunklere Ecken, mit einer Aufnahm * **Flat Field anwenden**: wendet das gewählte Profil auf diese Rolle an. * **Flat Field prüfen**: zeigt, wie gut das Profil seine eigene Aufnahme ausgleicht. Gleichmäßiges Grau ist gut. Ein Streifen oder Fleck zeigt, wo es abweicht. -Ein Bild behält sein eigenes Profil, solange es nicht seiner Rolle folgt. So kann jedes Licht und jedes Filmformat eines haben. Ein neues Bild oder ein Bild mit fehlendem Profil nimmt das zuletzt gewählte Profil. +Ein Bild behält sein eigenes Profil, solange es nicht seiner Rolle folgt. So kann jedes Licht und jedes Filmformat eines haben. Ein neues Kamerabild oder ein Bild mit fehlendem Profil nimmt das zuletzt gewählte Profil. Ein neues Bild vom Filmscanner beginnt ohne Optik-Korrekturen und zeigt diese Karte nur, wenn es eine Korrektur hat. ### Analoge Ausrüstung diff --git a/negpy/translations/USER_GUIDE.en.md b/negpy/translations/USER_GUIDE.en.md index e8e98f61a..2a60ab6dc 100644 --- a/negpy/translations/USER_GUIDE.en.md +++ b/negpy/translations/USER_GUIDE.en.md @@ -505,7 +505,7 @@ Removes uneven light, such as darker corners, with a shot of the bare light sour * **Apply Flat Field**: applies the selected profile to this roll. * **Check Flat Field**: shows how well the profile evens out its own shot. Even gray is good; a band or patch shows where it is off. -A frame keeps its own profile unless it follows its roll, so each light and film format can have one. A new frame, or a frame whose profile is missing, takes the last profile you chose. +A frame keeps its own profile unless it follows its roll, so each light and film format can have one. A new camera frame, or a frame whose profile is missing, takes the last profile you chose. A new film scanner frame starts with no Optics corrections, and shows this card only if it holds one. ### 10.4 Calibration: colors from your scanning setup diff --git a/negpy/translations/USER_GUIDE.ja.md b/negpy/translations/USER_GUIDE.ja.md index f51be0f76..0a829b54b 100644 --- a/negpy/translations/USER_GUIDE.ja.md +++ b/negpy/translations/USER_GUIDE.ja.md @@ -326,7 +326,7 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 - **プレビュー**/**書き出し**:デモザイクの方法。センサーの各画素が捉えない色を補います。**自動**は画面表示で速く、書き出しでは **AHD** を使います。**VNG** はなめらか、**PPG** は高速です。**DCB** と **DHT** は細部を多く残しますが、粒子が模様のようになることがあります。**AAHD** は輪郭を柔らかくします。お使いの LibRaw にある方法だけが表示されます。 - **回復**(スライド):カメラ RAW の白飛びしたハイライトを再構成します。**ブレンド**は太陽や空に向きます。**再構成**はより強力ですが、色を誤ることがあります。 - + ## 光学補正 レンズと光源の補正です。同じ環境でスキャンするすべてのフレームに共通です。カメラのときに表示されます。 @@ -344,7 +344,7 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 - **フラットフィールドを適用**:選択したプロファイルをこのロールに適用します。 - **フラットフィールドを確認**:プロファイルが自身の画像をどれだけ均一にするかを表示します。均一なグレーなら良好です。帯やむらはずれている場所を示します。 -フレームはロールに従う場合を除き自身のプロファイルを保持するため、光源やフィルムフォーマットごとにプロファイルを持てます。新しいフレームと、プロファイルが見つからないフレームは、最後に選んだプロファイルを使います。 +フレームはロールに従う場合を除き自身のプロファイルを保持するため、光源やフィルムフォーマットごとにプロファイルを持てます。新しいカメラのフレームと、プロファイルが見つからないフレームは、最後に選んだプロファイルを使います。フィルムスキャナーの新しいフレームは光学補正なしで始まり、補正がある場合にのみこのカードを表示します。 ## キャリブレーション:スキャン環境による色 diff --git a/negpy/translations/USER_GUIDE.ko.md b/negpy/translations/USER_GUIDE.ko.md index a8d09d682..073054b0c 100644 --- a/negpy/translations/USER_GUIDE.ko.md +++ b/negpy/translations/USER_GUIDE.ko.md @@ -326,7 +326,7 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 - **미리보기** / **내보내기**: 디모자이크 방식으로, 센서의 각 픽셀이 보지 못하는 색을 채웁니다. **자동**은 화면에서 빠르고 내보내기에는 **AHD**를 씁니다. **VNG**는 부드럽고 **PPG**는 빠릅니다. **DCB**와 **DHT**는 세부를 더 살리지만 입자가 무늬처럼 보일 수 있습니다. **AAHD**는 가장자리를 부드럽게 합니다. 사용 중인 LibRaw에 있는 방식만 표시됩니다. - **복구**(슬라이드): 카메라 RAW의 날아간 밝은 영역을 다시 만듭니다. **블렌드**는 해와 하늘에 알맞습니다. **재구성**은 더 강하지만 색이 틀릴 수 있습니다. - + ## 광학 보정 렌즈와 광원 보정으로, 같은 환경에서 스캔하는 모든 프레임에 공통입니다. 카메라일 때 보입니다. @@ -344,7 +344,7 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 - **플랫 필드 적용**: 선택한 프로필을 이 롤에 적용합니다. - **플랫 필드 확인**: 프로필이 자기 이미지를 얼마나 고르게 하는지 보여줍니다. 고른 회색이면 좋고, 띠나 얼룩은 어긋난 곳을 보여줍니다. -프레임은 롤을 따르지 않는 한 자체 프로필을 유지하므로 광원과 필름 포맷마다 프로필을 둘 수 있습니다. 새 프레임과 프로필이 없는 프레임은 마지막으로 고른 프로필을 사용합니다. +프레임은 롤을 따르지 않는 한 자체 프로필을 유지하므로 광원과 필름 포맷마다 프로필을 둘 수 있습니다. 새 카메라 프레임과 프로필이 없는 프레임은 마지막으로 고른 프로필을 사용합니다. 필름 스캐너의 새 프레임은 광학 보정 없이 시작하며, 보정이 있을 때만 이 카드를 보여줍니다. ## 보정: 스캔 환경에 따른 색 diff --git a/negpy/translations/USER_GUIDE.zh_CN.md b/negpy/translations/USER_GUIDE.zh_CN.md index bd9ee60a1..c18be0e58 100644 --- a/negpy/translations/USER_GUIDE.zh_CN.md +++ b/negpy/translations/USER_GUIDE.zh_CN.md @@ -326,7 +326,7 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 - **预览** / **导出**:去马赛克方法,补出每个传感器像素看不到的颜色。**自动**在屏幕上更快,导出时使用 **AHD**。**VNG** 平滑,**PPG** 快速。**DCB** 和 **DHT** 保留更多细节,但可能把颗粒变成花纹。**AAHD** 让边缘更柔和。只列出你的 LibRaw 版本中有的方法。 - **恢复**(反转片):重建相机 RAW 中过曝的高光。**混合**适合太阳和天空。**重建**更强,但颜色可能出错。 - + ## 光学 镜头和光源的校正,对你用此设备扫描的每一帧都相同。使用相机时显示。 @@ -344,7 +344,7 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 - **应用平场**:把所选配置应用到此卷。 - **检查平场**:显示配置对其自身照片的均匀效果。均匀的灰色表示良好;条带或斑块表示有偏差的位置。 -除非帧跟随其胶卷,否则帧保留自己的配置,因此每种光源和胶片画幅都可以有一个配置。新帧或配置已缺失的帧使用你最后选择的配置。 +除非帧跟随其胶卷,否则帧保留自己的配置,因此每种光源和胶片画幅都可以有一个配置。新的相机帧或配置已缺失的帧使用你最后选择的配置。新的胶片扫描仪帧开始时没有光学校正,只有带校正时才显示此卡片。 ## 校准:扫描设备带来的颜色 diff --git a/tests/test_desktop_session.py b/tests/test_desktop_session.py index 7fab5ebed..1154baee4 100644 --- a/tests/test_desktop_session.py +++ b/tests/test_desktop_session.py @@ -671,6 +671,50 @@ def test_no_chosen_flat_field_profile_leaves_a_new_file_uncorrected(self): ff = self.session._apply_sticky_settings(WorkspaceConfig(), only_global=False).flatfield self.assertEqual((ff.profile_id, ff.apply), ("", False)) + def _fresh_config(self, path, setup=None, roll_defaults=None): + """A first open of *path* with a camera rig's flat field and distortion carried.""" + settings = { + "flatfield_active_profile": "ff-35", + "sticky_config": {"distortion_k1": 0.05}, + "sticky_rows": ["geometry.distortion_k1"], + "scan_setup": setup, + "rolls_by_id": {"r1": {"kind": "virtual", "name": "Portra", "defaults": roll_defaults}} if roll_defaults else {}, + } + self.mock_repo.get_global_setting.side_effect = lambda key, default=None: settings.get(key, default) + self.session.state.active_roll_id = "r1" if roll_defaults else None + with ( + patch("negpy.desktop.session.load_or_promote", return_value=None), + patch("negpy.desktop.session.FlatFieldProfiles.get", return_value=SimpleNamespace(id="ff-35", k1=0.0)), + patch("negpy.desktop.session.load_sticky_rows", return_value=[next(r for r in all_rows() if "distortion_k1" in r.fields)]), + patch("negpy.desktop.session.is_scanned_positive_source", side_effect=lambda p: not p.endswith(".cr3")), + ): + return self.session.config_for_asset({"name": path, "path": path, "hash": "h"}) + + def test_a_new_camera_frame_takes_the_carried_optics(self): + config = self._fresh_config("/roll/a.cr3") + self.assertEqual((config.flatfield.profile_id, config.flatfield.apply), ("ff-35", True)) + self.assertEqual(config.geometry.distortion_k1, 0.05) + + def test_a_new_film_scanner_frame_takes_no_carried_optics(self): + for path, setup in (("/roll/a.tif", None), ("/roll/a.tif", {"capture": "camera"}), ("/roll/a.cr3", {"capture": "scanner"})): + with self.subTest(path=path, setup=setup): + config = self._fresh_config(path, setup) + self.assertEqual(config.flatfield, WorkspaceConfig().flatfield) + self.assertEqual(config.geometry.distortion_k1, 0.0) + + def test_a_film_scanner_frame_takes_optics_its_roll_holds(self): + config = self._fresh_config("/roll/a.tif", roll_defaults={"profile_id": "ff-35", "apply": True}) + self.assertEqual((config.flatfield.profile_id, config.flatfield.apply), ("ff-35", True)) + + def test_a_film_scanner_frame_leaves_the_carried_optics_for_the_next_camera_frame(self): + stored = {"distortion_k1": 0.05, "apply": True, "profile_id": "ff-35"} + self.mock_repo.get_global_setting.side_effect = lambda key, default=None: stored if key == "sticky_config" else default + self.session.state.current_file_path = "/roll/a.tif" + with patch("negpy.desktop.session.is_scanned_positive_source", return_value=True): + self.session._persist_sticky_settings(WorkspaceConfig()) + snapshot = self.mock_repo.save_global_settings.call_args.args[0]["sticky_config"] + self.assertEqual({k: snapshot[k] for k in stored}, stored) + def test_paper_black_carries_to_new_files(self): """Sticky must carry an explicit value over the file's base.""" sticky = {"sticky_config": {"paper_black": False}} diff --git a/tests/test_scan_setup.py b/tests/test_scan_setup.py index 3b8370a19..2cb4bf288 100644 --- a/tests/test_scan_setup.py +++ b/tests/test_scan_setup.py @@ -9,6 +9,7 @@ from negpy.desktop.session import AppState, DesktopSessionManager from negpy.desktop.view.sidebar.scan_setup import ScanSetupSidebar, resolved_rig from negpy.domain.models import WorkspaceConfig +from negpy.features.flatfield.models import FlatFieldConfig from negpy.features.process.models import ProcessMode, scan_setup_rig, scan_setup_values, with_process_mode from negpy.services.assets import rolls from negpy.services.rendering.preview_manager import PreviewManager @@ -169,6 +170,20 @@ def test_a_setting_keeps_its_control_in_view(self): panel = self._panel({"capture": "scanner", "light": "white"}, config=config) self.assertFalse(panel.sensor_sidebar.light_fold.content.isHidden()) + def test_turning_off_flat_field_keeps_optics_in_view_on_that_frame(self): + on = WorkspaceConfig(flatfield=FlatFieldConfig(apply=True, profile_id="ff-35")) + panel = self._panel({"capture": "scanner", "light": "white"}, config=on, current_file_hash="a") + self.assertFalse(panel.optics_section.isHidden()) + + state = panel.controller.state + state.config = replace(on, flatfield=replace(on.flatfield, apply=False)) + panel._sync_scan_setup() + self.assertFalse(panel.optics_section.isHidden()) + + state.current_file_hash = "b" + panel._sync_scan_setup() + self.assertTrue(panel.optics_section.isHidden()) + def test_bw_on_a_white_light_scanner_leaves_calibration_empty(self): config = with_process_mode(WorkspaceConfig(), ProcessMode.BW) panel = self._panel({"capture": "scanner", "light": "white"}, config=config) From ff9b0b99b71468ff14bf96134f65f75f70f3cd99 Mon Sep 17 00:00:00 2001 From: Kurt Windisch Date: Wed, 4 Nov 2026 17:08:52 -0800 Subject: [PATCH 42/46] fix(raw-decode): keep carried Recovery off new film scanner frames Recovery has no effect on a file that is not a camera RAW, but a carried value kept the Raw Decode card in view on a film scanner frame, where a Slide TIFF could not change it. A new film scanner frame now drops the carried Recovery, and saving that frame leaves the carried value for the next camera slide. The card stays in view after Recovery turns off, until another frame opens. Co-Authored-By: Claude Opus 5.5 --- negpy/desktop/session.py | 13 +++++++------ negpy/desktop/view/sidebar/controls_panel.py | 8 ++++++-- tests/test_desktop_session.py | 16 ++++++++++------ tests/test_scan_setup.py | 15 +++++++++++++++ 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index 0a1507022..8ed442f33 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -1412,8 +1412,8 @@ def keep_stored(keys: tuple) -> None: # A hand-drawn crop, or a reset of one, is one frame's placement: the carried crop stays as it was, whole. keep_stored(("crop_from_auto", "crop_rect", "crop_detect_key")) if self._film_scanner_file(self.state.current_file_path or ""): - # Optics describe the camera rig, so a film scanner frame leaves its carried values alone. - keep_stored(rolls.card_fields("lens") + rolls.card_fields("flatfield")) + # These settings belong to a camera, so a film scanner frame leaves their carried values alone. + keep_stored(rolls.card_fields("lens") + rolls.card_fields("flatfield") + ("highlight_reconstruction",)) self.repo.save_global_settings( { STICKY_CONFIG_KEY: snapshot, @@ -1512,7 +1512,7 @@ def _hydrate_asset_config(self, asset: dict) -> WorkspaceConfig: # Not a bare WorkspaceConfig(): only this config holds the shipped autocrop fields, and the # print/transfer curves are an identity at it (transfer_grade_ref, test_transparency_transfer.py). carried = self._apply_sticky_settings(DEFAULT_WORKSPACE_CONFIG, only_global=False) - config = self._overlay_roll_defaults(self._without_camera_optics(carried, asset["path"]), asset, fresh=True) + config = self._overlay_roll_defaults(self._without_camera_settings(carried, asset["path"]), asset, fresh=True) config = self._asset_defaults(config, asset) return config if self._roll_sets_positive(asset) else with_scanner_positive_default(config, asset["path"]) @@ -1521,11 +1521,12 @@ def _film_scanner_file(self, path: str) -> bool: does not cover a TIFF, because the Scan tab's source can differ from the Setup card.""" return scan_setup_rig(self.repo.get_global_setting("scan_setup"))[0] == "scanner" or is_scanned_positive_source(path) - def _without_camera_optics(self, config: WorkspaceConfig, path: str) -> WorkspaceConfig: - """*config* without the camera rig's carried Optics when *path* is a film scanner frame.""" + def _without_camera_settings(self, config: WorkspaceConfig, path: str) -> WorkspaceConfig: + """*config* without the carried Optics and Recovery when *path* is a film scanner frame.""" base = DEFAULT_WORKSPACE_CONFIG geometry = replace(config.geometry, **{f: getattr(base.geometry, f) for f in rolls.card_fields("lens")}) - bare = replace(config, geometry=geometry, flatfield=base.flatfield) + process = replace(config.process, highlight_reconstruction=base.process.highlight_reconstruction) + bare = replace(config, geometry=geometry, flatfield=base.flatfield, process=process) # Telling a Coolscan NEF from a camera NEF opens the file, so skip it when nothing carries. return bare if bare != config and self._film_scanner_file(path) else config diff --git a/negpy/desktop/view/sidebar/controls_panel.py b/negpy/desktop/view/sidebar/controls_panel.py index 871b94c4d..ec63bf51a 100644 --- a/negpy/desktop/view/sidebar/controls_panel.py +++ b/negpy/desktop/view/sidebar/controls_panel.py @@ -1056,6 +1056,7 @@ def _sync_all_sidebars(self) -> None: self._sync_scope_buttons() self._sync_modified_dots() + _raw_decode_held_by: Optional[str] = None _optics_held_by: Optional[str] = None def _sync_scan_setup(self) -> None: @@ -1069,12 +1070,15 @@ def _sync_scan_setup(self) -> None: (camera and light == "narrowband") or bool(self.controller.rgb_scan_mode_for_roll(state.active_roll_id)) ) self.sensor_section.setVisible(self.sensor_sidebar.has_controls()) - self.demosaic_section.setVisible(camera or cfg.process.highlight_reconstruction != _DEFAULT_PROCESS.highlight_reconstruction) + raw_decode_edited = cfg.process.highlight_reconstruction != _DEFAULT_PROCESS.highlight_reconstruction optics_edited = any(getattr(cfg.geometry, f) != getattr(_DEFAULT_GEOMETRY, f) for f in _LENS_FIELDS) or cfg.flatfield.apply file_hash = state.current_file_hash + if raw_decode_edited: + self._raw_decode_held_by = file_hash if optics_edited: self._optics_held_by = file_hash - # A correction turned off stays in reach until another frame opens. + # A setting turned off stays in reach until another frame opens. + self.demosaic_section.setVisible(camera or raw_decode_edited or (bool(file_hash) and file_hash == self._raw_decode_held_by)) self.optics_section.setVisible(camera or optics_edited or (bool(file_hash) and file_hash == self._optics_held_by)) _ROLL_CARD_LABELS = AppController._ROLL_CARD_LABELS diff --git a/tests/test_desktop_session.py b/tests/test_desktop_session.py index 1154baee4..777e162b8 100644 --- a/tests/test_desktop_session.py +++ b/tests/test_desktop_session.py @@ -675,8 +675,7 @@ def _fresh_config(self, path, setup=None, roll_defaults=None): """A first open of *path* with a camera rig's flat field and distortion carried.""" settings = { "flatfield_active_profile": "ff-35", - "sticky_config": {"distortion_k1": 0.05}, - "sticky_rows": ["geometry.distortion_k1"], + "sticky_config": {"distortion_k1": 0.05, "highlight_reconstruction": 2}, "scan_setup": setup, "rolls_by_id": {"r1": {"kind": "virtual", "name": "Portra", "defaults": roll_defaults}} if roll_defaults else {}, } @@ -685,7 +684,10 @@ def _fresh_config(self, path, setup=None, roll_defaults=None): with ( patch("negpy.desktop.session.load_or_promote", return_value=None), patch("negpy.desktop.session.FlatFieldProfiles.get", return_value=SimpleNamespace(id="ff-35", k1=0.0)), - patch("negpy.desktop.session.load_sticky_rows", return_value=[next(r for r in all_rows() if "distortion_k1" in r.fields)]), + patch( + "negpy.desktop.session.load_sticky_rows", + return_value=[r for r in all_rows() if {"distortion_k1", "highlight_reconstruction"} & set(r.fields)], + ), patch("negpy.desktop.session.is_scanned_positive_source", side_effect=lambda p: not p.endswith(".cr3")), ): return self.session.config_for_asset({"name": path, "path": path, "hash": "h"}) @@ -694,20 +696,22 @@ def test_a_new_camera_frame_takes_the_carried_optics(self): config = self._fresh_config("/roll/a.cr3") self.assertEqual((config.flatfield.profile_id, config.flatfield.apply), ("ff-35", True)) self.assertEqual(config.geometry.distortion_k1, 0.05) + self.assertEqual(config.process.highlight_reconstruction, 2) - def test_a_new_film_scanner_frame_takes_no_carried_optics(self): + def test_a_new_film_scanner_frame_takes_no_carried_camera_settings(self): for path, setup in (("/roll/a.tif", None), ("/roll/a.tif", {"capture": "camera"}), ("/roll/a.cr3", {"capture": "scanner"})): with self.subTest(path=path, setup=setup): config = self._fresh_config(path, setup) self.assertEqual(config.flatfield, WorkspaceConfig().flatfield) self.assertEqual(config.geometry.distortion_k1, 0.0) + self.assertEqual(config.process.highlight_reconstruction, 0) def test_a_film_scanner_frame_takes_optics_its_roll_holds(self): config = self._fresh_config("/roll/a.tif", roll_defaults={"profile_id": "ff-35", "apply": True}) self.assertEqual((config.flatfield.profile_id, config.flatfield.apply), ("ff-35", True)) - def test_a_film_scanner_frame_leaves_the_carried_optics_for_the_next_camera_frame(self): - stored = {"distortion_k1": 0.05, "apply": True, "profile_id": "ff-35"} + def test_a_film_scanner_frame_leaves_the_carried_values_for_the_next_camera_frame(self): + stored = {"distortion_k1": 0.05, "apply": True, "profile_id": "ff-35", "highlight_reconstruction": 2} self.mock_repo.get_global_setting.side_effect = lambda key, default=None: stored if key == "sticky_config" else default self.session.state.current_file_path = "/roll/a.tif" with patch("negpy.desktop.session.is_scanned_positive_source", return_value=True): diff --git a/tests/test_scan_setup.py b/tests/test_scan_setup.py index 2cb4bf288..0944879a4 100644 --- a/tests/test_scan_setup.py +++ b/tests/test_scan_setup.py @@ -184,6 +184,21 @@ def test_turning_off_flat_field_keeps_optics_in_view_on_that_frame(self): panel._sync_scan_setup() self.assertTrue(panel.optics_section.isHidden()) + def test_turning_off_recovery_keeps_raw_decode_in_view_on_that_frame(self): + base = WorkspaceConfig() + on = replace(base, process=replace(base.process, highlight_reconstruction=2)) + panel = self._panel({"capture": "scanner", "light": "white"}, config=on, current_file_hash="a") + self.assertFalse(panel.demosaic_section.isHidden()) + + state = panel.controller.state + state.config = base + panel._sync_scan_setup() + self.assertFalse(panel.demosaic_section.isHidden()) + + state.current_file_hash = "b" + panel._sync_scan_setup() + self.assertTrue(panel.demosaic_section.isHidden()) + def test_bw_on_a_white_light_scanner_leaves_calibration_empty(self): config = with_process_mode(WorkspaceConfig(), ProcessMode.BW) panel = self._panel({"capture": "scanner", "light": "white"}, config=config) From 7bb30011b26bc7f58ccf45ebe3f111e03fc177b8 Mon Sep 17 00:00:00 2001 From: Kurt Windisch Date: Wed, 4 Nov 2026 17:56:08 -0800 Subject: [PATCH 43/46] fix(optics): stop camera settings at the source for film scanner frames Review of the first two commits found gaps: - A saved film scanner frame took the last chosen profile on reopen, and a legacy profile's distortion with it. - A profile picked or cleared on a film scanner frame replaced the profile that new camera frames take. - A frame locked against its roll on Optics lost the card, so it could not rejoin the roll. - Every persisted edit parsed the stored sticky config. _apply_sticky_settings now takes the frame's path and skips the camera settings there, which replaces the strip after the carry. The fields sit in one constant, _CAMERA_ONLY_FIELDS. The file check runs only when a camera setting would carry. Deleting the last chosen profile and Shoot Flat Field still set it from any frame. Co-Authored-By: Claude Opus 5.5 --- docs/USER_GUIDE.md | 6 +- negpy/desktop/controller.py | 16 +++--- negpy/desktop/session.py | 60 ++++++++++---------- negpy/desktop/view/sidebar/controls_panel.py | 7 ++- negpy/services/assets/rolls.py | 2 +- negpy/translations/USER_GUIDE.de.md | 8 +-- negpy/translations/USER_GUIDE.en.md | 6 +- negpy/translations/USER_GUIDE.ja.md | 8 +-- negpy/translations/USER_GUIDE.ko.md | 8 +-- negpy/translations/USER_GUIDE.zh_CN.md | 8 +-- tests/test_composite_inheritance.py | 8 +-- tests/test_controller.py | 23 +++++++- tests/test_desktop_session.py | 29 ++++++++++ tests/test_scan_setup.py | 7 +++ tests/test_source_encoding.py | 2 +- 15 files changed, 132 insertions(+), 66 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 2a60ab6dc..62e427db2 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -73,7 +73,7 @@ Right-click a card header for **Copy Card Settings** and **Undo Change to Severa ### What carries to the next frame -A new frame gets the roll's film type, and the crop, print stock and Lab settings of the last frame you edited. The look (brightness, color, dodge and burn, toning) starts clean. **Preferences → Session & Storage → Persistent Settings…** sets what carries. +A new frame gets the roll's film type, and the crop, print stock and Lab settings of the last frame you edited. The look (brightness, color, dodge and burn, toning) starts clean. **Preferences → Session & Storage → Persistent Settings…** sets what carries. Optics and Recovery carry to camera RAW frames only. **Reset Settings** returns a frame to the defaults. It does not change the scanning choices on the **Setup** card. @@ -485,7 +485,7 @@ How a camera RAW file is turned into pixels. Shown for a camera. * **Camera WB / Linear RAW**: Camera WB uses the camera's white balance. Linear RAW decodes neutral, for narrowband light. Setup sets it for you. * **Preview** / **Export**: the demosaic method, which fills in the colors each sensor pixel cannot see. **Auto** is fast on screen and uses **AHD** for export. **VNG** is smooth and **PPG** fast. **DCB** and **DHT** keep more fine detail but can turn grain into patterns. **AAHD** softens edges. Only the methods in your LibRaw build are listed. -* **Recovery** (slides): rebuilds blown highlights in a camera RAW. **Blend** suits sun and sky. **Reconstruct** is stronger but can get the color wrong. +* **Recovery** (slides): rebuilds blown highlights in a camera RAW. **Blend** suits sun and sky. **Reconstruct** is stronger but can get the color wrong. A new film scanner frame starts with Recovery off. ### 10.3 Optics @@ -505,7 +505,7 @@ Removes uneven light, such as darker corners, with a shot of the bare light sour * **Apply Flat Field**: applies the selected profile to this roll. * **Check Flat Field**: shows how well the profile evens out its own shot. Even gray is good; a band or patch shows where it is off. -A frame keeps its own profile unless it follows its roll, so each light and film format can have one. A new camera frame, or a frame whose profile is missing, takes the last profile you chose. A new film scanner frame starts with no Optics corrections, and shows this card only if it holds one. +A frame keeps its own profile unless it follows its roll, so each light and film format can have one. A new camera frame, or a camera frame whose profile is missing, takes the last profile you chose on a camera frame. A new film scanner frame starts with no Optics corrections. With Film Scanner set, this card shows only on a frame that holds one. ### 10.4 Calibration: colors from your scanning setup diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 710de9d37..7a98cf119 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -6906,19 +6906,21 @@ def reanalyze_current_file(self) -> None: self.session.update_config(replace(self.state.config, process=new_process), record_history=False) self.request_render() - def set_active_flatfield_profile(self, profile_id: str) -> None: + def set_active_flatfield_profile(self, profile_id: str, remember: bool = False) -> None: """ Sets the Flat Field card's profile on the current frame like any other roll card (an - empty ``profile_id`` clears it), and makes it the profile a frame that names none takes. + empty ``profile_id`` clears it), and makes it the profile a camera frame that names none + takes. A film scanner frame leaves that one as it is, unless *remember* is set. """ from negpy.services.assets.flatfield import FlatFieldProfiles - self.session.repo.save_global_setting("flatfield_active_profile", profile_id or "") + if remember or not self.session.film_scanner_file(self.state.current_file_path or ""): + self.session.repo.save_global_setting("flatfield_active_profile", profile_id or "") prof = FlatFieldProfiles.get(profile_id) if profile_id else None pid = prof.id if prof else "" self.set_roll_default("flatfield", profile_id=pid, apply=bool(pid)) - def save_flatfield_profile(self, name: str, path: str) -> None: + def save_flatfield_profile(self, name: str, path: str, remember: bool = False) -> None: """ Bakes a reference image into a named flat-field profile and makes it active. """ @@ -6931,7 +6933,7 @@ def save_flatfield_profile(self, name: str, path: str) -> None: self.set_status(tr("Flat Field: could not read that reference image"), 3000, kind="error") return profile_id, check = baked - self.set_active_flatfield_profile(profile_id) + self.set_active_flatfield_profile(profile_id, remember=remember) if check.clipped: self.set_status( tr("Flat Field profile '{name}' saved, but the reference is clipped: shoot it darker to correct the falloff", name=name), @@ -6965,7 +6967,7 @@ def delete_flatfield_profile(self, profile_id: str) -> None: invalidate_gain(profile_id) active = self.session.repo.get_global_setting("flatfield_active_profile") or "" if active == profile_id: - self.set_active_flatfield_profile("") + self.set_active_flatfield_profile("", remember=True) elif self.state.config.flatfield.profile_id == profile_id: self.set_roll_default("flatfield", profile_id=active if active and FlatFieldProfiles.get(active) else "") @@ -7862,7 +7864,7 @@ def _on_capture_flat(self, path: str) -> None: """A strip's bare-light reference: save it as the roll's Flat Field profile, which its frames take.""" req = getattr(self, "_last_capture_req", None) roll = req.roll_name if req is not None else os.path.splitext(os.path.basename(path))[0] - self.save_flatfield_profile(f"{roll} (Lento Dock)", path) + self.save_flatfield_profile(f"{roll} (Lento Dock)", path, remember=True) def _on_capture_finished(self, paths: list) -> None: """Feed the captured frame(s) into NegPy. An RGB triplet → RGB-Scan, C-41; a single-capture diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index 8ed442f33..758b3f7a1 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, field, replace from enum import Enum, auto from contextlib import contextmanager -from typing import Any, Callable, Collection, Dict, Iterator, List, Optional, Set, Tuple +from typing import Any, Callable, Collection, Dict, Iterable, Iterator, List, Optional, Set, Tuple import numpy as np from PyQt6.QtCore import QAbstractListModel, QModelIndex, QObject, Qt, pyqtSignal @@ -86,6 +86,9 @@ class ToolMode(Enum): REVERTIBLE_TOOLS = frozenset({ToolMode.CROP_MANUAL, ToolMode.ANALYSIS_DRAW}) # Zone Overlay looks, by stored key and menu label. +# Settings that describe a camera, which carry to camera frames only. +_CAMERA_ONLY_FIELDS = frozenset(rolls.card_fields("lens") + rolls.card_fields("flatfield") + ("highlight_reconstruction",)) + ZONE_STYLES = {"false_color": tr("Color"), "zone_map": tr("Grayscale"), "grid": tr("Grid")} @@ -1269,7 +1272,7 @@ def save_flat_output_prefs(self) -> None: self.repo.save_global_setting("linear_format", self.state.linear_format) self.repo.save_global_setting("linear_jxl_effort", self.state.linear_jxl_effort) - def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = False) -> WorkspaceConfig: + def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = False, path: str = "") -> WorkspaceConfig: """ Overlays globally persisted settings onto the config. @@ -1285,6 +1288,8 @@ def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = Fa the last chosen flat-field profile, the Kelvin roll-locks, the export fields with no catalog row, and the scan-setup preferences. They apply regardless of `sticky_settings_enabled`, which only gates the catalog-row overlay above. + + A film scanner *path* (film_scanner_file) takes no _CAMERA_ONLY_FIELDS and no last chosen profile. """ from negpy.features.metadata.models import resolve_description_fields @@ -1295,8 +1300,21 @@ def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = Fa if remainder: config = replace(config, export=replace(config.export, **remainder)) + if only_global: + rows = load_sticky_rows(self.repo) + rows = [r for r in rows if r.section in GLOBAL_TIER_SECTIONS] + elif self.state.sticky_settings_enabled: + rows = load_sticky_rows(self.repo) + else: + rows = [] + active_ff = self.repo.get_global_setting("flatfield_active_profile") ff_id = active_ff if active_ff and FlatFieldProfiles.get(active_ff) is not None else "" + camera_rows = [r for r in rows if _CAMERA_ONLY_FIELDS.intersection(r.fields)] + # Telling a Coolscan NEF from a camera NEF opens the file, so ask only when a camera setting would carry. + if (ff_id or camera_rows) and self.film_scanner_file(path): + ff_id = "" + rows = [r for r in rows if r not in camera_rows] config = self._with_known_flatfield_profile(config, ff_id) # Distortion left the profile for the per-image geometry; adopt a legacy value once, # from the frame's own profile, on frames that carry none of their own. @@ -1304,13 +1322,6 @@ def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = Fa if config.geometry.distortion_k1 == 0.0 and own_ff is not None and own_ff.k1 != 0.0: config = replace(config, geometry=replace(config.geometry, distortion_k1=own_ff.k1)) - if only_global: - rows = load_sticky_rows(self.repo) - rows = [r for r in rows if r.section in GLOBAL_TIER_SECTIONS] - elif self.state.sticky_settings_enabled: - rows = load_sticky_rows(self.repo) - else: - rows = [] # Description fields carry on their own key, so the last Description… confirm wins # for the roll rather than whichever frame was saved last. wants_desc = any("description_fields" in r.fields for r in rows) @@ -1394,10 +1405,10 @@ def _persist_sticky_settings(self, config: WorkspaceConfig) -> None: from dataclasses import asdict snapshot = sticky_snapshot(config) - stored = self.repo.get_global_setting(STICKY_CONFIG_KEY) - stored = stored if isinstance(stored, dict) else {} - def keep_stored(keys: tuple) -> None: + def keep_stored(keys: Iterable[str]) -> None: + stored = self.repo.get_global_setting(STICKY_CONFIG_KEY) + stored = stored if isinstance(stored, dict) else {} for key in keys: if key in stored: snapshot[key] = stored[key] @@ -1411,9 +1422,8 @@ def keep_stored(keys: tuple) -> None: if has_manual_crop(config.geometry) or self._keep_carried_crop: # A hand-drawn crop, or a reset of one, is one frame's placement: the carried crop stays as it was, whole. keep_stored(("crop_from_auto", "crop_rect", "crop_detect_key")) - if self._film_scanner_file(self.state.current_file_path or ""): - # These settings belong to a camera, so a film scanner frame leaves their carried values alone. - keep_stored(rolls.card_fields("lens") + rolls.card_fields("flatfield") + ("highlight_reconstruction",)) + if self.film_scanner_file(self.state.current_file_path or ""): + keep_stored(_CAMERA_ONLY_FIELDS) self.repo.save_global_settings( { STICKY_CONFIG_KEY: snapshot, @@ -1485,7 +1495,7 @@ def load_edit_from_sidecar(self, path: str) -> bool: if saved is None: return False asset = self.state.uploaded_files[idx] - config = self._apply_sticky_settings(saved, only_global=True) + config = self._apply_sticky_settings(saved, only_global=True, path=asset["path"]) before = self.state.config self.update_config(resolve_asset_hdr(resolve_asset_stitch(resolve_asset_rgbscan(config, asset), asset), asset), persist=True) self.relock_diverged_cards(before) @@ -1504,32 +1514,24 @@ def _hydrate_asset_config(self, asset: dict) -> WorkspaceConfig: if saved_config is not None: # A saved edit keeps its own process mode and shadow lift, which are the user's # now, so only the wiring overlays apply. - config = self._overlay_roll_defaults(self._apply_sticky_settings(saved_config, only_global=True), asset) + config = self._overlay_roll_defaults(self._apply_sticky_settings(saved_config, only_global=True, path=asset["path"]), asset) return resolve_asset_hdr(resolve_asset_stitch(resolve_asset_rgbscan(config, asset), asset), asset) # Sticky settings include the global process mode, which a composite must not take over # the mode of the frames it was built from. _asset_defaults applies after. # # Not a bare WorkspaceConfig(): only this config holds the shipped autocrop fields, and the # print/transfer curves are an identity at it (transfer_grade_ref, test_transparency_transfer.py). - carried = self._apply_sticky_settings(DEFAULT_WORKSPACE_CONFIG, only_global=False) - config = self._overlay_roll_defaults(self._without_camera_settings(carried, asset["path"]), asset, fresh=True) + config = self._overlay_roll_defaults( + self._apply_sticky_settings(DEFAULT_WORKSPACE_CONFIG, only_global=False, path=asset["path"]), asset, fresh=True + ) config = self._asset_defaults(config, asset) return config if self._roll_sets_positive(asset) else with_scanner_positive_default(config, asset["path"]) - def _film_scanner_file(self, path: str) -> bool: + def film_scanner_file(self, path: str) -> bool: """The Scanning Setup says Film Scanner, or *path* is not a camera RAW. A pick of Camera does not cover a TIFF, because the Scan tab's source can differ from the Setup card.""" return scan_setup_rig(self.repo.get_global_setting("scan_setup"))[0] == "scanner" or is_scanned_positive_source(path) - def _without_camera_settings(self, config: WorkspaceConfig, path: str) -> WorkspaceConfig: - """*config* without the carried Optics and Recovery when *path* is a film scanner frame.""" - base = DEFAULT_WORKSPACE_CONFIG - geometry = replace(config.geometry, **{f: getattr(base.geometry, f) for f in rolls.card_fields("lens")}) - process = replace(config.process, highlight_reconstruction=base.process.highlight_reconstruction) - bare = replace(config, geometry=geometry, flatfield=base.flatfield, process=process) - # Telling a Coolscan NEF from a camera NEF opens the file, so skip it when nothing carries. - return bare if bare != config and self._film_scanner_file(path) else config - def _roll_sets_positive(self, asset: dict) -> bool: """The frame's roll holds a Positive value on its Film card, which beats the scanner default.""" roll_id = rolls.frame_roll(self.repo, self.state.active_roll_id, asset) diff --git a/negpy/desktop/view/sidebar/controls_panel.py b/negpy/desktop/view/sidebar/controls_panel.py index ec63bf51a..08190e867 100644 --- a/negpy/desktop/view/sidebar/controls_panel.py +++ b/negpy/desktop/view/sidebar/controls_panel.py @@ -1071,7 +1071,12 @@ def _sync_scan_setup(self) -> None: ) self.sensor_section.setVisible(self.sensor_sidebar.has_controls()) raw_decode_edited = cfg.process.highlight_reconstruction != _DEFAULT_PROCESS.highlight_reconstruction - optics_edited = any(getattr(cfg.geometry, f) != getattr(_DEFAULT_GEOMETRY, f) for f in _LENS_FIELDS) or cfg.flatfield.apply + locked_cards = self.controller.locked_roll_cards() + optics_edited = ( + any(getattr(cfg.geometry, f) != getattr(_DEFAULT_GEOMETRY, f) for f in _LENS_FIELDS) + or cfg.flatfield.apply + or any(card in locked_cards for card in _SECTION_CARDS["optics"]) + ) file_hash = state.current_file_hash if raw_decode_edited: self._raw_decode_held_by = file_hash diff --git a/negpy/services/assets/rolls.py b/negpy/services/assets/rolls.py index 64a077a76..7023ec85a 100644 --- a/negpy/services/assets/rolls.py +++ b/negpy/services/assets/rolls.py @@ -715,7 +715,7 @@ def virtual_rolls(repo: Any) -> List[tuple]: # spread (a metering run, not an edit). "baseline": ("process", ("use_luma_average", "use_color_average", "use_cast_average")), "lens": ("geometry", ("distortion_k1", "lens_distortion_from_metadata", "lens_ca_from_metadata")), - # A frame that names no profile_id takes the last chosen one, upstream of roll defaults. + # A camera frame that names no profile_id takes the last chosen one, upstream of roll defaults. "flatfield": ("flatfield", ("apply", "profile_id")), # Metadata describes the roll almost by definition: one camera, one stock, one # development, one scanning rig. capture_frame is unique to one frame, and diff --git a/negpy/translations/USER_GUIDE.de.md b/negpy/translations/USER_GUIDE.de.md index 7cb7cb11f..ed62079d5 100644 --- a/negpy/translations/USER_GUIDE.de.md +++ b/negpy/translations/USER_GUIDE.de.md @@ -395,16 +395,16 @@ Misst die ganze Rolle einmal und teilt das Ergebnis, damit die Bilder eines Film * **Szenen**: Gruppen von Bildern mit eigener Basislinie, zum Beispiel ein Motiv unter einem Licht ([Szenen](#scenes)). **Analysieren** misst die Szene, **Auswählen** wählt ihre Bilder aus und **Löschen** entfernt die Gruppe. * **Szenen erkennen**: findet die Gruppen von Bildern unter anderem Licht als die Rolle, in beliebiger Bildreihenfolge, und zeigt sie auf dem Leuchttisch. Jede Gruppe wartet unter **Gefunden**. Um eine Gruppe zu ändern, ziehe Bilder auf eine andere Gruppe oder auf ein Bild ohne Szene, um sie auszuschließen. Rechtsklick › **Szene** macht dasselbe. Gib ihr einen Namen und **Übernehme** sie als Szene mit eigener Basislinie, oder **Verwirf** sie. - + ### 10.6 Raw-Dekodierung Wie eine Kamera-RAW-Datei in Pixel umgewandelt wird. Wird bei einer Kamera angezeigt. * **Kamera-WB / Lineares RAW**: Kamera-WB nutzt den Weißabgleich der Kamera. Lineares RAW dekodiert neutral, für schmalbandiges Licht. Die Einrichtung stellt es für dich ein. * **Vorschau** / **Export**: die Demosaicing-Methode, die die Farben ergänzt, die ein Sensorpixel nicht sieht. **Auto** ist am Bildschirm schnell und nutzt für den Export **AHD**. **VNG** ist glatt und **PPG** schnell. **DCB** und **DHT** erhalten mehr feine Details, können aber Korn in Muster verwandeln. **AAHD** macht Kanten weicher. Es werden nur die Methoden deines LibRaw-Builds aufgeführt. -* **Wiederherstellung** (Dias): rekonstruiert ausgebrannte Lichter in einer Kamera-RAW-Datei. **Mischen** passt zu Sonne und Himmel. **Rekonstruieren** ist stärker, kann aber die Farbe falsch treffen. +* **Wiederherstellung** (Dias): rekonstruiert ausgebrannte Lichter in einer Kamera-RAW-Datei. **Mischen** passt zu Sonne und Himmel. **Rekonstruieren** ist stärker, kann aber die Farbe falsch treffen. Ein neues Bild vom Filmscanner beginnt mit ausgeschalteter Wiederherstellung. - + ### 10.7 Optik Korrekturen für Objektiv und Licht, gleich für jedes Bild, das du mit diesem Aufbau scannst. Wird bei einer Kamera angezeigt. @@ -422,7 +422,7 @@ Entfernt ungleichmäßiges Licht, zum Beispiel dunklere Ecken, mit einer Aufnahm * **Flat Field anwenden**: wendet das gewählte Profil auf diese Rolle an. * **Flat Field prüfen**: zeigt, wie gut das Profil seine eigene Aufnahme ausgleicht. Gleichmäßiges Grau ist gut. Ein Streifen oder Fleck zeigt, wo es abweicht. -Ein Bild behält sein eigenes Profil, solange es nicht seiner Rolle folgt. So kann jedes Licht und jedes Filmformat eines haben. Ein neues Kamerabild oder ein Bild mit fehlendem Profil nimmt das zuletzt gewählte Profil. Ein neues Bild vom Filmscanner beginnt ohne Optik-Korrekturen und zeigt diese Karte nur, wenn es eine Korrektur hat. +Ein Bild behält sein eigenes Profil, solange es nicht seiner Rolle folgt. So kann jedes Licht und jedes Filmformat eines haben. Ein neues Kamerabild oder ein Kamerabild mit fehlendem Profil nimmt das Profil, das du zuletzt bei einem Kamerabild gewählt hast. Ein neues Bild vom Filmscanner beginnt ohne Optik-Korrekturen. Mit der Einstellung Filmscanner erscheint diese Karte nur bei einem Bild, das eine Korrektur hat. ### Analoge Ausrüstung diff --git a/negpy/translations/USER_GUIDE.en.md b/negpy/translations/USER_GUIDE.en.md index 2a60ab6dc..62e427db2 100644 --- a/negpy/translations/USER_GUIDE.en.md +++ b/negpy/translations/USER_GUIDE.en.md @@ -73,7 +73,7 @@ Right-click a card header for **Copy Card Settings** and **Undo Change to Severa ### What carries to the next frame -A new frame gets the roll's film type, and the crop, print stock and Lab settings of the last frame you edited. The look (brightness, color, dodge and burn, toning) starts clean. **Preferences → Session & Storage → Persistent Settings…** sets what carries. +A new frame gets the roll's film type, and the crop, print stock and Lab settings of the last frame you edited. The look (brightness, color, dodge and burn, toning) starts clean. **Preferences → Session & Storage → Persistent Settings…** sets what carries. Optics and Recovery carry to camera RAW frames only. **Reset Settings** returns a frame to the defaults. It does not change the scanning choices on the **Setup** card. @@ -485,7 +485,7 @@ How a camera RAW file is turned into pixels. Shown for a camera. * **Camera WB / Linear RAW**: Camera WB uses the camera's white balance. Linear RAW decodes neutral, for narrowband light. Setup sets it for you. * **Preview** / **Export**: the demosaic method, which fills in the colors each sensor pixel cannot see. **Auto** is fast on screen and uses **AHD** for export. **VNG** is smooth and **PPG** fast. **DCB** and **DHT** keep more fine detail but can turn grain into patterns. **AAHD** softens edges. Only the methods in your LibRaw build are listed. -* **Recovery** (slides): rebuilds blown highlights in a camera RAW. **Blend** suits sun and sky. **Reconstruct** is stronger but can get the color wrong. +* **Recovery** (slides): rebuilds blown highlights in a camera RAW. **Blend** suits sun and sky. **Reconstruct** is stronger but can get the color wrong. A new film scanner frame starts with Recovery off. ### 10.3 Optics @@ -505,7 +505,7 @@ Removes uneven light, such as darker corners, with a shot of the bare light sour * **Apply Flat Field**: applies the selected profile to this roll. * **Check Flat Field**: shows how well the profile evens out its own shot. Even gray is good; a band or patch shows where it is off. -A frame keeps its own profile unless it follows its roll, so each light and film format can have one. A new camera frame, or a frame whose profile is missing, takes the last profile you chose. A new film scanner frame starts with no Optics corrections, and shows this card only if it holds one. +A frame keeps its own profile unless it follows its roll, so each light and film format can have one. A new camera frame, or a camera frame whose profile is missing, takes the last profile you chose on a camera frame. A new film scanner frame starts with no Optics corrections. With Film Scanner set, this card shows only on a frame that holds one. ### 10.4 Calibration: colors from your scanning setup diff --git a/negpy/translations/USER_GUIDE.ja.md b/negpy/translations/USER_GUIDE.ja.md index 0a829b54b..b461c3efb 100644 --- a/negpy/translations/USER_GUIDE.ja.md +++ b/negpy/translations/USER_GUIDE.ja.md @@ -317,16 +317,16 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 カメラではRAW デコードと光学のカードが表示されます。ナローバンド光のカメラでは、さらにトリクロームとキャリブレーションも表示されます。変更すると開いているすべてのフレームが更新されます(Ctrl+Z でフレームごとに戻せます)が、自分で選んだプリント素材はそのままです。カメラのスキャンでは、フレームごとに撮影時の光源からデコードとプリント素材を決めます。 - + ## RAW デコード カメラの RAW ファイルを画素に変える方法です。カメラのときに表示されます。 - **カメラWB/リニアRAW**:カメラWB はカメラのホワイトバランスを使います。リニアRAW はニュートラルにデコードし、ナローバンド光に向きます。セットアップが自動で設定します。 - **プレビュー**/**書き出し**:デモザイクの方法。センサーの各画素が捉えない色を補います。**自動**は画面表示で速く、書き出しでは **AHD** を使います。**VNG** はなめらか、**PPG** は高速です。**DCB** と **DHT** は細部を多く残しますが、粒子が模様のようになることがあります。**AAHD** は輪郭を柔らかくします。お使いの LibRaw にある方法だけが表示されます。 -- **回復**(スライド):カメラ RAW の白飛びしたハイライトを再構成します。**ブレンド**は太陽や空に向きます。**再構成**はより強力ですが、色を誤ることがあります。 +- **回復**(スライド):カメラ RAW の白飛びしたハイライトを再構成します。**ブレンド**は太陽や空に向きます。**再構成**はより強力ですが、色を誤ることがあります。フィルムスキャナーの新しいフレームは回復がオフの状態で始まります。 - + ## 光学補正 レンズと光源の補正です。同じ環境でスキャンするすべてのフレームに共通です。カメラのときに表示されます。 @@ -344,7 +344,7 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 - **フラットフィールドを適用**:選択したプロファイルをこのロールに適用します。 - **フラットフィールドを確認**:プロファイルが自身の画像をどれだけ均一にするかを表示します。均一なグレーなら良好です。帯やむらはずれている場所を示します。 -フレームはロールに従う場合を除き自身のプロファイルを保持するため、光源やフィルムフォーマットごとにプロファイルを持てます。新しいカメラのフレームと、プロファイルが見つからないフレームは、最後に選んだプロファイルを使います。フィルムスキャナーの新しいフレームは光学補正なしで始まり、補正がある場合にのみこのカードを表示します。 +フレームはロールに従う場合を除き自身のプロファイルを保持するため、光源やフィルムフォーマットごとにプロファイルを持てます。新しいカメラのフレームと、プロファイルが見つからないカメラのフレームは、カメラのフレームで最後に選んだプロファイルを使います。フィルムスキャナーの新しいフレームは光学補正なしで始まります。フィルムスキャナーを設定しているときは、補正を持つフレームでのみこのカードを表示します。 ## キャリブレーション:スキャン環境による色 diff --git a/negpy/translations/USER_GUIDE.ko.md b/negpy/translations/USER_GUIDE.ko.md index 073054b0c..0a6eb4bd0 100644 --- a/negpy/translations/USER_GUIDE.ko.md +++ b/negpy/translations/USER_GUIDE.ko.md @@ -317,16 +317,16 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 카메라에서는 RAW 디코딩과 광학 카드가 보입니다. 협대역 광의 카메라에서는 삼색과 보정 카드도 보입니다. 바꾸면 열려 있는 모든 프레임이 갱신되지만(Ctrl+Z로 프레임마다 되돌림), 직접 고른 프린트 소재는 그대로입니다. 카메라 스캔은 프레임마다 촬영한 광원에 따라 디코딩과 프린트 소재를 정합니다. - + ## RAW 디코딩 카메라 RAW 파일을 픽셀로 바꾸는 방식입니다. 카메라일 때 보입니다. - **카메라 WB / 선형 RAW**: 카메라 WB는 카메라의 화이트 밸런스를 씁니다. 선형 RAW는 중립으로 디코딩하며 협대역 광에 알맞습니다. 설정이 자동으로 정합니다. - **미리보기** / **내보내기**: 디모자이크 방식으로, 센서의 각 픽셀이 보지 못하는 색을 채웁니다. **자동**은 화면에서 빠르고 내보내기에는 **AHD**를 씁니다. **VNG**는 부드럽고 **PPG**는 빠릅니다. **DCB**와 **DHT**는 세부를 더 살리지만 입자가 무늬처럼 보일 수 있습니다. **AAHD**는 가장자리를 부드럽게 합니다. 사용 중인 LibRaw에 있는 방식만 표시됩니다. -- **복구**(슬라이드): 카메라 RAW의 날아간 밝은 영역을 다시 만듭니다. **블렌드**는 해와 하늘에 알맞습니다. **재구성**은 더 강하지만 색이 틀릴 수 있습니다. +- **복구**(슬라이드): 카메라 RAW의 날아간 밝은 영역을 다시 만듭니다. **블렌드**는 해와 하늘에 알맞습니다. **재구성**은 더 강하지만 색이 틀릴 수 있습니다. 필름 스캐너의 새 프레임은 복구가 꺼진 상태로 시작합니다. - + ## 광학 보정 렌즈와 광원 보정으로, 같은 환경에서 스캔하는 모든 프레임에 공통입니다. 카메라일 때 보입니다. @@ -344,7 +344,7 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 - **플랫 필드 적용**: 선택한 프로필을 이 롤에 적용합니다. - **플랫 필드 확인**: 프로필이 자기 이미지를 얼마나 고르게 하는지 보여줍니다. 고른 회색이면 좋고, 띠나 얼룩은 어긋난 곳을 보여줍니다. -프레임은 롤을 따르지 않는 한 자체 프로필을 유지하므로 광원과 필름 포맷마다 프로필을 둘 수 있습니다. 새 카메라 프레임과 프로필이 없는 프레임은 마지막으로 고른 프로필을 사용합니다. 필름 스캐너의 새 프레임은 광학 보정 없이 시작하며, 보정이 있을 때만 이 카드를 보여줍니다. +프레임은 롤을 따르지 않는 한 자체 프로필을 유지하므로 광원과 필름 포맷마다 프로필을 둘 수 있습니다. 새 카메라 프레임과 프로필이 없는 카메라 프레임은 카메라 프레임에서 마지막으로 고른 프로필을 사용합니다. 필름 스캐너의 새 프레임은 광학 보정 없이 시작합니다. 필름 스캐너로 설정하면 보정이 있는 프레임에서만 이 카드를 보여줍니다. ## 보정: 스캔 환경에 따른 색 diff --git a/negpy/translations/USER_GUIDE.zh_CN.md b/negpy/translations/USER_GUIDE.zh_CN.md index c18be0e58..482199b91 100644 --- a/negpy/translations/USER_GUIDE.zh_CN.md +++ b/negpy/translations/USER_GUIDE.zh_CN.md @@ -317,16 +317,16 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 相机会显示 RAW 解码和光学卡片。使用窄带光的相机还会显示三色扫描和校准卡片。更改后所有打开的帧都会更新(Ctrl+Z 可逐帧撤销),但你自己选择的印相材料保持不变。相机扫描按每帧的光源设置解码和印相材料。 - + ## RAW 解码 把相机 RAW 文件变成像素的方式。使用相机时显示。 - **相机白平衡 / 线性 RAW**:相机白平衡使用相机的白平衡。线性 RAW 以中性方式解码,适合窄带光。设置会自动选择。 - **预览** / **导出**:去马赛克方法,补出每个传感器像素看不到的颜色。**自动**在屏幕上更快,导出时使用 **AHD**。**VNG** 平滑,**PPG** 快速。**DCB** 和 **DHT** 保留更多细节,但可能把颗粒变成花纹。**AAHD** 让边缘更柔和。只列出你的 LibRaw 版本中有的方法。 -- **恢复**(反转片):重建相机 RAW 中过曝的高光。**混合**适合太阳和天空。**重建**更强,但颜色可能出错。 +- **恢复**(反转片):重建相机 RAW 中过曝的高光。**混合**适合太阳和天空。**重建**更强,但颜色可能出错。新的胶片扫描仪帧开始时恢复为关闭。 - + ## 光学 镜头和光源的校正,对你用此设备扫描的每一帧都相同。使用相机时显示。 @@ -344,7 +344,7 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 - **应用平场**:把所选配置应用到此卷。 - **检查平场**:显示配置对其自身照片的均匀效果。均匀的灰色表示良好;条带或斑块表示有偏差的位置。 -除非帧跟随其胶卷,否则帧保留自己的配置,因此每种光源和胶片画幅都可以有一个配置。新的相机帧或配置已缺失的帧使用你最后选择的配置。新的胶片扫描仪帧开始时没有光学校正,只有带校正时才显示此卡片。 +除非帧跟随其胶卷,否则帧保留自己的配置,因此每种光源和胶片画幅都可以有一个配置。新的相机帧或配置已缺失的相机帧使用你在相机帧上最后选择的配置。新的胶片扫描仪帧开始时没有光学校正。设置为胶片扫描仪时,只有带校正的帧才显示此卡片。 ## 校准:扫描设备带来的颜色 diff --git a/tests/test_composite_inheritance.py b/tests/test_composite_inheritance.py index c07abec03..3065de84a 100644 --- a/tests/test_composite_inheritance.py +++ b/tests/test_composite_inheritance.py @@ -39,7 +39,7 @@ def _session(self, saved_config=None, sticky_mode=ProcessMode.C41): session.state = AppState() session.repo = MagicMock() session.repo.get_global_setting.side_effect = lambda k, default=None: {"last_process_mode": sticky_mode}.get(k, default) - session._apply_sticky_settings = lambda c, only_global=False: ( + session._apply_sticky_settings = lambda c, only_global=False, path="": ( c if only_global else replace(c, process=replace(c.process, process_mode=sticky_mode)) ) import negpy.desktop.session as mod @@ -247,7 +247,7 @@ def _session(self, saved_config=None): session.state = AppState() session.repo = MagicMock() session.repo.get_global_setting.side_effect = lambda k, default=None: default - session._apply_sticky_settings = lambda c, only_global=False: c + session._apply_sticky_settings = lambda c, only_global=False, path="": c import negpy.desktop.session as mod orig = mod.load_or_promote @@ -353,7 +353,7 @@ def test_reset_matches_what_opening_the_asset_fresh_gives(self): orig = mod.load_or_promote mod.load_or_promote = lambda *a, **k: None try: - session._apply_sticky_settings = lambda c, only_global=False: c + session._apply_sticky_settings = lambda c, only_global=False, path="": c fresh = session._hydrate_asset_config(asset) finally: mod.load_or_promote = orig @@ -432,7 +432,7 @@ def _session(self, saved_config=None, sticky_mode=ProcessMode.C41): session.state = AppState() session.repo = MagicMock() session.repo.get_global_setting.side_effect = lambda k, default=None: default - session._apply_sticky_settings = lambda c, only_global=False: ( + session._apply_sticky_settings = lambda c, only_global=False, path="": ( c if only_global else replace(c, process=replace(c.process, process_mode=sticky_mode)) ) import negpy.desktop.session as mod diff --git a/tests/test_controller.py b/tests/test_controller.py index f57f19ded..122ce2b9a 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -2,7 +2,7 @@ import sys import tempfile import unittest -from unittest.mock import ANY, MagicMock, patch +from unittest.mock import ANY, MagicMock, patch, call from dataclasses import replace from types import SimpleNamespace @@ -6095,6 +6095,27 @@ def test_a_source_with_no_preview_says_so_and_stays_off(self): self.assertFalse(self.controller.state.embedded_peek) self.assertEqual(seen, [False], "the menu item must not stay checked on a file with no preview") + def _pick_profile(self, film_scanner, **kwargs): + from negpy.services.assets.flatfield import FlatFieldProfile, FlatFieldProfiles + + repo = self.mock_session_manager.repo + repo.save_global_setting.reset_mock() + self.mock_session_manager.film_scanner_file.return_value = film_scanner + self.controller.set_roll_default = MagicMock() + with patch.object(FlatFieldProfiles, "get", return_value=FlatFieldProfile("ff-120", "120", 0.0, "")): + self.controller.set_active_flatfield_profile("ff-120", **kwargs) + self.controller.set_roll_default.assert_called_once_with("flatfield", profile_id="ff-120", apply=True) + return repo.save_global_setting.call_args_list + + def test_a_camera_frame_sets_the_last_chosen_profile(self): + self.assertEqual(self._pick_profile(False), [call("flatfield_active_profile", "ff-120")]) + + def test_a_film_scanner_frame_leaves_the_last_chosen_profile(self): + self.assertEqual(self._pick_profile(True), []) + + def test_a_capture_sets_the_last_chosen_profile_from_any_frame(self): + self.assertEqual(self._pick_profile(True, remember=True), [call("flatfield_active_profile", "ff-120")]) + def test_the_peeks_are_mutually_exclusive(self): with patch("negpy.desktop.controller.PreviewManager.try_splash_preview", return_value=(self._preview(), (8, 6))): self.controller.state.negative_peek = True diff --git a/tests/test_desktop_session.py b/tests/test_desktop_session.py index 777e162b8..447e43187 100644 --- a/tests/test_desktop_session.py +++ b/tests/test_desktop_session.py @@ -706,6 +706,35 @@ def test_a_new_film_scanner_frame_takes_no_carried_camera_settings(self): self.assertEqual(config.geometry.distortion_k1, 0.0) self.assertEqual(config.process.highlight_reconstruction, 0) + def _reopened_tiff(self, path="/roll/a.tif"): + """A saved frame with no profile, reopened while a legacy profile with a k1 is the last chosen.""" + settings = {"flatfield_active_profile": "ff-35"} + self.mock_repo.get_global_setting.side_effect = lambda key, default=None: settings.get(key, default) + self.session.state.active_roll_id = None + with ( + patch("negpy.desktop.session.load_or_promote", return_value=WorkspaceConfig()), + patch("negpy.desktop.session.FlatFieldProfiles.get", return_value=SimpleNamespace(id="ff-35", k1=-0.05)), + patch("negpy.desktop.session.is_scanned_positive_source", side_effect=lambda p: not p.endswith(".cr3")), + ): + return self.session.config_for_asset({"name": path, "path": path, "hash": "h"}) + + def test_a_saved_film_scanner_frame_takes_no_last_chosen_profile(self): + config = self._reopened_tiff() + self.assertEqual(config.flatfield.profile_id, "") + self.assertEqual(config.geometry.distortion_k1, 0.0) + + def test_a_saved_camera_frame_takes_the_last_chosen_profile(self): + self.assertEqual(self._reopened_tiff("/roll/a.cr3").flatfield.profile_id, "ff-35") + + def test_the_file_is_not_read_when_no_camera_setting_carries(self): + self.mock_repo.get_global_setting.side_effect = lambda key, default=None: default + with ( + patch("negpy.desktop.session.is_scanned_positive_source") as scanned, + patch("negpy.desktop.session.load_sticky_rows", return_value=[]), + ): + self.session._apply_sticky_settings(WorkspaceConfig(), only_global=False, path="/roll/a.nef") + scanned.assert_not_called() + def test_a_film_scanner_frame_takes_optics_its_roll_holds(self): config = self._fresh_config("/roll/a.tif", roll_defaults={"profile_id": "ff-35", "apply": True}) self.assertEqual((config.flatfield.profile_id, config.flatfield.apply), ("ff-35", True)) diff --git a/tests/test_scan_setup.py b/tests/test_scan_setup.py index 0944879a4..dac4f7c0d 100644 --- a/tests/test_scan_setup.py +++ b/tests/test_scan_setup.py @@ -184,6 +184,13 @@ def test_turning_off_flat_field_keeps_optics_in_view_on_that_frame(self): panel._sync_scan_setup() self.assertTrue(panel.optics_section.isHidden()) + def test_a_frame_locked_on_flat_field_keeps_optics_in_view(self): + panel = self._panel({"capture": "scanner", "light": "white"}, current_file_hash="a") + self.assertTrue(panel.optics_section.isHidden()) + panel.controller.locked_roll_cards.return_value = {"flatfield"} + panel._sync_scan_setup() + self.assertFalse(panel.optics_section.isHidden()) + def test_turning_off_recovery_keeps_raw_decode_in_view_on_that_frame(self): base = WorkspaceConfig() on = replace(base, process=replace(base.process, highlight_reconstruction=2)) diff --git a/tests/test_source_encoding.py b/tests/test_source_encoding.py index 126330749..c91116163 100644 --- a/tests/test_source_encoding.py +++ b/tests/test_source_encoding.py @@ -275,7 +275,7 @@ def _hydrating_session(monkeypatch, tmp_path, saved=None): session = DesktopSessionManager.__new__(DesktopSessionManager) session.state = AppState() session.repo = repo - session._apply_sticky_settings = lambda c, only_global=False: c if only_global else with_process_mode(c, ProcessMode.E6) + session._apply_sticky_settings = lambda c, only_global=False, path="": c if only_global else with_process_mode(c, ProcessMode.E6) monkeypatch.setattr(session_mod, "load_or_promote", lambda *a, **k: saved) return session From 6a5495f010e5b7e8ac9206edcca4e9be23f56d36 Mon Sep 17 00:00:00 2001 From: Jon Destoppeleire Date: Wed, 4 Nov 2026 08:48:56 -0800 Subject: [PATCH 44/46] feat(perf): size the Preferences memory limits to the machine The memory limits in Preferences are fixed numbers. An 8 GB machine can set a 32 GB cache with no warning, and a 128 GB machine stops at a 32 GB cache and 16 HQ buffers. Each cache row now shows a recommendation of half of the machine's RAM, or how many entries of that row's size fit in that half. The user can go past it, and the hint then turns amber. A row stops at its old limit or all of RAM, whichever is larger. GPU texture cap goes to 65536 px, past the long edge of a 400 MP frame, with no recommendation. Default values and the default HQ cap do not change. Co-Authored-By: Claude Opus 5.5 --- docs/USER_GUIDE.md | 3 +- .../view/widgets/preferences_dialog.py | 68 ++++++++++++-- negpy/kernel/system/memory.py | 57 ++++++++--- negpy/kernel/system/override.py | 36 +++++++ negpy/translations/USER_GUIDE.en.md | 3 +- negpy/translations/de.json | 9 +- negpy/translations/ja.json | 9 +- negpy/translations/ko.json | 9 +- negpy/translations/vi.json | 9 +- negpy/translations/zh_CN.json | 9 +- tests/test_override_config.py | 28 ++++++ tests/test_preferences_dialog.py | 94 ++++++++++++++++++- tests/test_system_memory.py | 12 +++ 13 files changed, 303 insertions(+), 43 deletions(-) create mode 100644 tests/test_system_memory.py diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e8e98f61a..b95f6025c 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -854,7 +854,8 @@ Shows the whole strip, to place and pick the frames before the scan. **Apply Fra * **Search by Meaning**: find frames by description (§2). * **Update Thumbnails Automatically**: renders out-of-date thumbnails again in the background, when a roll opens and after a change to many frames. Off (the default), the frames get an amber dot until you use **Update Thumbnails**. * **Preview size**: the canvas resolution. Higher is sharper and uses more memory. -* **Preview cache**, **HQ buffers**, **Rendered frames** and **GPU texture cap**: how much memory NegPy uses. Lower them on a computer with little memory, or if exports run out of GPU memory. +* **Preview cache**, **Preview cache limit**, **HQ buffers** and **Rendered frames**: how much memory NegPy uses. Each row shows the recommendation for this computer. You can set a higher value, and the hint then turns amber. +* **GPU texture cap**: the largest image the GPU loads at once. At 0, an integrated GPU uses 6144 px and other GPUs have no limit. A value here replaces that limit. Lower it if exports run out of GPU memory. * **Reduce Export Memory Use**: exports in smaller pieces, for a computer where exports run out of GPU memory. * **Show GPU Memory Warning**: tells you when an HQ preview is reduced to fit. diff --git a/negpy/desktop/view/widgets/preferences_dialog.py b/negpy/desktop/view/widgets/preferences_dialog.py index 506345c78..5700b9028 100644 --- a/negpy/desktop/view/widgets/preferences_dialog.py +++ b/negpy/desktop/view/widgets/preferences_dialog.py @@ -18,7 +18,7 @@ from negpy.desktop.controller import AppController from negpy.desktop.view.styles.color_vision import PALETTES -from negpy.desktop.view.styles.templates import field_label, hint_label, labeled_action, labeled_toggle, pin_dialog_default +from negpy.desktop.view.styles.templates import field_label, hint_label, labeled_action, labeled_toggle, pin_dialog_default, set_hint_kind from negpy.desktop.view.styles.theme import THEME from negpy.desktop.view.widgets.collapsible import CollapsibleSection from negpy.desktop.view.widgets.dialog_geometry import remember_dialog_geometry @@ -27,10 +27,13 @@ from negpy.domain.types import AppConfig from negpy.infrastructure.gpu.device import GPUDevice from negpy.kernel.system.config import APP_CONFIG +from negpy.kernel.system.memory import total_system_memory_bytes from negpy.kernel.system.override import ( PREVIEW_SIZE_DEFAULT, PREVIEW_SIZE_MAX, PREVIEW_SIZE_MIN, + Machine, + cache_entry_limit, load_or_create, toml_pinned_keys, ) @@ -53,6 +56,18 @@ class NumberRow: scale: int = 1 +def row_recommendation(spec: "NumberRow", machine: Machine) -> int | None: + """The safe setting for this machine in the row's shown units; None when unknown.""" + limit = cache_entry_limit(spec.key, machine, spec.scale) + return None if limit is None else max(spec.minimum, limit) + + +def row_ceiling(spec: "NumberRow", machine: Machine) -> int: + """The spin box maximum: what the whole of RAM holds, never below the row's static maximum.""" + limit = cache_entry_limit(spec.key, machine, spec.scale, share=1.0) + return spec.maximum if limit is None else max(spec.maximum, limit) + + NUMBER_ROWS: tuple[NumberRow, ...] = ( NumberRow( "preview_render_size", @@ -81,7 +96,7 @@ class NumberRow: 32768, 128, " MB", - tr("Memory ceiling for that cache. Lower it on a machine with little RAM."), + tr("Memory ceiling for that cache."), tr("The most memory the preview cache can use."), scale=1024 * 1024, ), @@ -92,7 +107,9 @@ class NumberRow: 16, 1, tr(" photos"), - tr("Full-size previews kept in memory. Each uses hundreds of MB; keeping the last one makes going back instant."), + tr( + "Full-size previews kept in memory; keeping the last one makes going back instant. The recommendation counts frames the size of the open one." + ), tr("How many full-size previews stay in memory."), ), NumberRow( @@ -109,10 +126,10 @@ class NumberRow: "max_texture_size", tr("GPU texture cap"), 0, - 16384, + 65536, 1024, " px", - tr("Largest image size the GPU loads at once. 0 lets the hardware decide."), + tr("Largest image size the GPU loads at once; an HQ preview larger than this is reduced to it. 0 uses this machine's default."), tr("The largest image size the GPU loads at once."), ), ) @@ -143,6 +160,10 @@ def __init__(self, controller: AppController, parent=None, pinned_keys: set[str] self._gpu_available = GPUDevice.get().is_available self._pinned = _pinned_keys() if pinned_keys is None else pinned_keys self._spins: dict[str, QSpinBox] = {} + self._hints: dict[str, QLabel] = {} + self._total_ram = total_system_memory_bytes() + w, h = self.session.state.original_res or (0, 0) + self._frame_px = int(w) * int(h) self.setWindowTitle(tr("Preferences")) self.resize(700, 720) @@ -395,11 +416,13 @@ def hint(widget_or_text, kind: str = "muted") -> None: for spec in NUMBER_ROWS: grid.addWidget(field_label(spec.label), row, 0) spin = QSpinBox() - spin.setRange(spec.minimum, spec.maximum) + stored = self._stored_number(spec) + # The stored value always shows, even above the ceiling this machine would offer. + spin.setRange(spec.minimum, max(row_ceiling(spec, self._machine()), stored)) spin.setSingleStep(spec.step) spin.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons) spin.setSuffix(spec.suffix) - spin.setValue(self._stored_number(spec)) + spin.setValue(stored) if spec.key in self._pinned: spin.setEnabled(False) spin.setToolTip(tr("Set in override.toml, which wins over this dialog")) @@ -409,8 +432,9 @@ def hint(widget_or_text, kind: str = "muted") -> None: self._spins[spec.key] = spin grid.addWidget(spin, row, 1) row += 1 - note = spec.hint + (tr(" Set in override.toml.") if spec.key in self._pinned else "") - grid.addWidget(hint_label(note), row, 0, 1, 2) + self._hints[spec.key] = hint_label() + self._refresh_hint(spec) + grid.addWidget(self._hints[spec.key], row, 0, 1, 2) row += 1 return host @@ -463,14 +487,38 @@ def _stored_number(self, spec: NumberRow) -> int: """What the spin box shows: the live value, which is already the resolved one.""" live = getattr(APP_CONFIG, spec.key, None) value = default_for(spec.key) if live is None else int(live) - return max(spec.minimum, min(spec.maximum, value // spec.scale)) + return max(spec.minimum, value // spec.scale) def _mark_restart(self) -> None: self._restart_hint.setText(tr("Restart NegPy to apply the changed startup settings.")) self._restart_hint.show() + def _machine(self) -> Machine: + """The machine as the dialog's own Preview size describes it, so every row agrees with it.""" + spin = self._spins.get("preview_render_size") + preview_px = spin.value() if spin is not None else int(APP_CONFIG.preview_render_size) + return Machine(self._total_ram, self._frame_px, preview_px) + + def _refresh_hint(self, spec: NumberRow) -> None: + note = spec.hint + (tr(" Set in override.toml.") if spec.key in self._pinned else "") + machine = self._machine() + recommended = row_recommendation(spec, machine) + if spec.key == "preview_cache_max_full_res_entries" and machine.total_ram_bytes and not machine.frame_px: + note += tr(" Open a frame to size the recommendation to it.") + above = recommended is not None and self._spins[spec.key].value() > recommended + if above: + note += tr( + " Above the recommended {value}{unit} for this machine. NegPy may run out of memory.", value=recommended, unit=spec.suffix + ) + elif recommended is not None: + note += tr(" Recommended up to {value}{unit} on this machine.", value=recommended, unit=spec.suffix) + self._hints[spec.key].setText(note) + set_hint_kind(self._hints[spec.key], "warning" if above else "muted") + def _on_number_changed(self, spec: NumberRow, value: int) -> None: self.repo.save_global_setting(spec.key, int(value) * spec.scale) + for row in NUMBER_ROWS: + self._refresh_hint(row) self._mark_restart() def _on_ui_scale_changed(self, index: int) -> None: diff --git a/negpy/kernel/system/memory.py b/negpy/kernel/system/memory.py index b1bddbcbc..47aada445 100644 --- a/negpy/kernel/system/memory.py +++ b/negpy/kernel/system/memory.py @@ -8,30 +8,57 @@ from pathlib import Path -def _windows_available_memory() -> int | None: - class MemoryStatusEx(ctypes.Structure): - _fields_ = [ - ("dwLength", ctypes.c_ulong), - ("dwMemoryLoad", ctypes.c_ulong), - ("ullTotalPhys", ctypes.c_ulonglong), - ("ullAvailPhys", ctypes.c_ulonglong), - ("ullTotalPageFile", ctypes.c_ulonglong), - ("ullAvailPageFile", ctypes.c_ulonglong), - ("ullTotalVirtual", ctypes.c_ulonglong), - ("ullAvailVirtual", ctypes.c_ulonglong), - ("ullAvailExtendedVirtual", ctypes.c_ulonglong), - ] +class _MemoryStatusEx(ctypes.Structure): + _fields_ = [ + ("dwLength", ctypes.c_ulong), + ("dwMemoryLoad", ctypes.c_ulong), + ("ullTotalPhys", ctypes.c_ulonglong), + ("ullAvailPhys", ctypes.c_ulonglong), + ("ullTotalPageFile", ctypes.c_ulonglong), + ("ullAvailPageFile", ctypes.c_ulonglong), + ("ullTotalVirtual", ctypes.c_ulonglong), + ("ullAvailVirtual", ctypes.c_ulonglong), + ("ullAvailExtendedVirtual", ctypes.c_ulonglong), + ] + +def _windows_memory_status() -> _MemoryStatusEx | None: try: - status = MemoryStatusEx() + status = _MemoryStatusEx() status.dwLength = ctypes.sizeof(status) if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)): # type: ignore[attr-defined] - return int(status.ullAvailPhys) + return status except Exception: pass return None +def _windows_available_memory() -> int | None: + status = _windows_memory_status() + return None if status is None else int(status.ullAvailPhys) + + +def _windows_total_memory() -> int | None: + status = _windows_memory_status() + return None if status is None else int(status.ullTotalPhys) + + +def _posix_total_memory() -> int | None: + try: + pages = int(os.sysconf("SC_PHYS_PAGES")) + page_size = int(os.sysconf("SC_PAGE_SIZE")) + except (AttributeError, OSError, TypeError, ValueError): + return None + return pages * page_size if pages > 0 and page_size > 0 else None + + +def total_system_memory_bytes() -> int | None: + """Physical memory installed, or None when the OS cannot report it.""" + if sys.platform == "win32": + return _windows_total_memory() + return _posix_total_memory() + + def _linux_available_memory() -> int | None: try: for line in Path("/proc/meminfo").read_text(encoding="ascii").splitlines(): diff --git a/negpy/kernel/system/override.py b/negpy/kernel/system/override.py index dca8e9d8b..a837c4f67 100644 --- a/negpy/kernel/system/override.py +++ b/negpy/kernel/system/override.py @@ -24,6 +24,42 @@ # submit and crash the app. Discrete GPUs are unaffected and stay uncapped. INTEGRATED_GPU_TEXTURE_CAP_DEFAULT = 6144 +# The Preferences recommendations come from RAM alone: each cache pool gets this share of it. +CACHE_RAM_SHARE = 1 / 2 +DECODED_BYTES_PER_PX = 12 # float32 RGB, a preview cache entry +TEXTURE_BYTES_PER_PX = 16 # rgba32float, a render memo entry + + +@dataclass(frozen=True) +class Machine: + """What the Preferences recommendations rest on. RAM None is unknown; frame_px is 0 when + no frame is open.""" + + total_ram_bytes: int | None + frame_px: int + preview_px: int + + +def cache_entry_limit(key: str, machine: Machine, scale: int = 1, share: float = CACHE_RAM_SHARE) -> int | None: + """How many entries of this performance key's size fit in a share of RAM; None when + the key has no derived limit, RAM is unknown, or the HQ row has no open frame.""" + if not machine.total_ram_bytes: + return None + budget = int(machine.total_ram_bytes * share) + if key == "preview_cache_max_bytes": + per_entry = scale + elif key == "preview_cache_max_full_res_entries": + if not machine.frame_px: + return None + per_entry = machine.frame_px * (DECODED_BYTES_PER_PX + TEXTURE_BYTES_PER_PX) + elif key == "preview_cache_max_entries": + per_entry = machine.preview_px**2 * DECODED_BYTES_PER_PX + elif key == "render_memo_max_entries": + per_entry = machine.preview_px**2 * TEXTURE_BYTES_PER_PX + else: + return None + return budget // max(1, per_entry) + _DEFAULT_TOML_LINUX_WIN = """\ # NegPy Override Configuration diff --git a/negpy/translations/USER_GUIDE.en.md b/negpy/translations/USER_GUIDE.en.md index e8e98f61a..b95f6025c 100644 --- a/negpy/translations/USER_GUIDE.en.md +++ b/negpy/translations/USER_GUIDE.en.md @@ -854,7 +854,8 @@ Shows the whole strip, to place and pick the frames before the scan. **Apply Fra * **Search by Meaning**: find frames by description (§2). * **Update Thumbnails Automatically**: renders out-of-date thumbnails again in the background, when a roll opens and after a change to many frames. Off (the default), the frames get an amber dot until you use **Update Thumbnails**. * **Preview size**: the canvas resolution. Higher is sharper and uses more memory. -* **Preview cache**, **HQ buffers**, **Rendered frames** and **GPU texture cap**: how much memory NegPy uses. Lower them on a computer with little memory, or if exports run out of GPU memory. +* **Preview cache**, **Preview cache limit**, **HQ buffers** and **Rendered frames**: how much memory NegPy uses. Each row shows the recommendation for this computer. You can set a higher value, and the hint then turns amber. +* **GPU texture cap**: the largest image the GPU loads at once. At 0, an integrated GPU uses 6144 px and other GPUs have no limit. A value here replaces that limit. Lower it if exports run out of GPU memory. * **Reduce Export Memory Use**: exports in smaller pieces, for a computer where exports run out of GPU memory. * **Show GPU Memory Warning**: tells you when an HQ preview is reduced to fit. diff --git a/negpy/translations/de.json b/negpy/translations/de.json index 950f0facf..945c7c0eb 100644 --- a/negpy/translations/de.json +++ b/negpy/translations/de.json @@ -1,5 +1,8 @@ { " (base unreadable)": " (Filmbasis nicht lesbar)", + " Above the recommended {value}{unit} for this machine. NegPy may run out of memory.": " Über der Empfehlung von {value}{unit} für diesen Rechner. NegPy kann der Speicher ausgehen.", + " Open a frame to size the recommendation to it.": " Öffne ein Bild, damit die Empfehlung auf dessen Größe abgestimmt wird.", + " Recommended up to {value}{unit} on this machine.": " Auf diesem Rechner werden bis zu {value}{unit} empfohlen.", " Ref": " Ref", " Set in override.toml.": " In override.toml festgelegt.", " frames": " Bilder", @@ -1118,7 +1121,7 @@ "Full frame": "Ganzes Bild", "Full frame. Click the image to magnify.": "Ganzes Bild. Klicke auf das Bild, um zu vergrößern.", "Full frame{offset_txt}": "Ganzes Bild{offset_txt}", - "Full-size previews kept in memory. Each uses hundreds of MB; keeping the last one makes going back instant.": "Im Speicher gehaltene Vorschauen in voller Größe. Jede belegt Hunderte MB; die letzte zu behalten macht das Zurückgehen sofort möglich.", + "Full-size previews kept in memory; keeping the last one makes going back instant. The recommendation counts frames the size of the open one.": "Im Speicher gehaltene Vorschauen in voller Größe; die letzte zu behalten macht das Zurückgehen sofort möglich. Die Empfehlung rechnet mit Bildern in der Größe des geöffneten.", "GPU Acceleration": "GPU-Beschleunigung", "GPU acceleration failed. Using the CPU.": "GPU-Beschleunigung fehlgeschlagen. Die CPU wird verwendet.", "GPU texture cap": "GPU-Texturgrenze", @@ -1378,7 +1381,7 @@ "Label text": "Beschriftungstext", "Language": "Sprache", "Largest dust size to remove, where larger values can remove real detail.": "Größte Staubgröße, die entfernt wird. Höhere Werte können echte Details entfernen.", - "Largest image size the GPU loads at once. 0 lets the hardware decide.": "Größtes Bild, das die GPU auf einmal lädt. Bei 0 entscheidet die Hardware.", + "Largest image size the GPU loads at once; an HQ preview larger than this is reduced to it. 0 uses this machine's default.": "Größtes Bild, das die GPU auf einmal lädt; eine größere HQ-Vorschau wird darauf verkleinert. Bei 0 gilt der Standardwert dieses Rechners.", "Last file": "Letzte Datei", "Later": "Später", "Later edits never delete a work print; this is the only way to delete one.": "Spätere Bearbeitungen löschen nie einen Arbeitsabzug. Nur so lässt er sich löschen.", @@ -1538,7 +1541,7 @@ "Measuring the strip…": "Streifen wird gemessen…", "Measuring…": "Messung läuft…", "Measuring… {value:.0f}%": "Messung läuft… {value:.0f}%", - "Memory ceiling for that cache. Lower it on a machine with little RAM.": "Speichergrenze für diesen Cache. Senke sie bei einem Rechner mit wenig RAM.", + "Memory ceiling for that cache.": "Speichergrenze für diesen Cache.", "Merge": "Zusammenführen", "Merge Exposures (HDR)": "Belichtungen zusammenführen (HDR)", "Merge Exposures (Trichrome)": "Belichtungen zusammenführen (Trichrome)", diff --git a/negpy/translations/ja.json b/negpy/translations/ja.json index da73f6432..5aa8899ba 100644 --- a/negpy/translations/ja.json +++ b/negpy/translations/ja.json @@ -1,5 +1,8 @@ { " (base unreadable)": "(ベース読取不可)", + " Above the recommended {value}{unit} for this machine. NegPy may run out of memory.": " このマシンの推奨値 {value}{unit} を超えています。NegPy のメモリが不足するおそれがあります。", + " Open a frame to size the recommendation to it.": " フレームを開くと、そのサイズに合わせた推奨値になります。", + " Recommended up to {value}{unit} on this machine.": " このマシンでは {value}{unit} までを推奨します。", " Ref": " 参照", " Set in override.toml.": " override.toml で設定されています。", " frames": " フレーム", @@ -1118,7 +1121,7 @@ "Full frame": "フレーム全体", "Full frame. Click the image to magnify.": "フルフレーム - 画像をクリックして拡大します", "Full frame{offset_txt}": "フルフレーム{offset_txt}", - "Full-size previews kept in memory. Each uses hundreds of MB; keeping the last one makes going back instant.": "メモリに保持するフルサイズのプレビュー。一つで数百 MB を使い、直前のものを保持すると即座に戻れます。", + "Full-size previews kept in memory; keeping the last one makes going back instant. The recommendation counts frames the size of the open one.": "メモリに保持するフルサイズのプレビュー。直前のものを保持すると即座に戻れます。推奨値は、開いているフレームと同じサイズで計算します。", "GPU Acceleration": "GPUアクセラレーション", "GPU acceleration failed. Using the CPU.": "GPU アクセラレーションに失敗しました — CPU を使用しています", "GPU texture cap": "GPU テクスチャ上限", @@ -1378,7 +1381,7 @@ "Label text": "ラベルの文字", "Language": "言語", "Largest dust size to remove, where larger values can remove real detail.": "取り除くほこりの最大サイズで、大きくすると本来の細部まで消えることがあります。", - "Largest image size the GPU loads at once. 0 lets the hardware decide.": "GPU が一度に読み込む画像の最大サイズ。0 ならハードウェアに任せます。", + "Largest image size the GPU loads at once; an HQ preview larger than this is reduced to it. 0 uses this machine's default.": "GPU が一度に読み込む画像の最大サイズ。これより大きい HQ プレビューはこのサイズに縮小されます。0 ならこのマシンの既定値を使います。", "Last file": "最後のファイル", "Later": "後で", "Later edits never delete a work print; this is the only way to delete one.": "後の編集でワークプリントが消えることはありません。削除できるのはここだけです。", @@ -1538,7 +1541,7 @@ "Measuring the strip…": "ストリップを測定中…", "Measuring…": "測定中…", "Measuring… {value:.0f}%": "測定中… {value:.0f}%", - "Memory ceiling for that cache. Lower it on a machine with little RAM.": "そのキャッシュのメモリ上限。 RAM が少ないマシンではこの値を下げてください。", + "Memory ceiling for that cache.": "そのキャッシュのメモリ上限。", "Merge": "マージ", "Merge Exposures (HDR)": "露出の結合 (HDR)", "Merge Exposures (Trichrome)": "露出の結合 (トリクローム)", diff --git a/negpy/translations/ko.json b/negpy/translations/ko.json index d955912a0..e2e733fff 100644 --- a/negpy/translations/ko.json +++ b/negpy/translations/ko.json @@ -1,5 +1,8 @@ { " (base unreadable)": " (베이스 판독 불가)", + " Above the recommended {value}{unit} for this machine. NegPy may run out of memory.": " 이 시스템의 권장값 {value}{unit}을 초과합니다. NegPy의 메모리가 부족해질 수 있습니다.", + " Open a frame to size the recommendation to it.": " 프레임을 열면 그 크기에 맞춘 권장값이 표시됩니다.", + " Recommended up to {value}{unit} on this machine.": " 이 시스템에서는 {value}{unit}까지 권장합니다.", " Ref": " 참조", " Set in override.toml.": " override.toml에 설정합니다.", " frames": " 프레임", @@ -1118,7 +1121,7 @@ "Full frame": "전체 프레임", "Full frame. Click the image to magnify.": "전체 프레임 — 확대하려면 이미지를 클릭하세요.", "Full frame{offset_txt}": "전체 프레임{offset_txt}", - "Full-size previews kept in memory. Each uses hundreds of MB; keeping the last one makes going back instant.": "메모리에 보관하는 원본 크기 미리보기입니다. 하나에 수백 MB를 쓰며, 직전 것을 보관하면 바로 돌아갈 수 있습니다.", + "Full-size previews kept in memory; keeping the last one makes going back instant. The recommendation counts frames the size of the open one.": "메모리에 보관하는 원본 크기 미리보기입니다. 직전 것을 보관하면 바로 돌아갈 수 있습니다. 권장값은 열려 있는 프레임과 같은 크기로 계산합니다.", "GPU Acceleration": "GPU 가속", "GPU acceleration failed. Using the CPU.": "GPU 가속 실패 - CPU 사용", "GPU texture cap": "GPU 텍스처 캡", @@ -1378,7 +1381,7 @@ "Label text": "라벨 문구", "Language": "언어", "Largest dust size to remove, where larger values can remove real detail.": "제거할 먼지의 최대 크기로, 크게 하면 실제 세부도 지울 수 있습니다.", - "Largest image size the GPU loads at once. 0 lets the hardware decide.": "GPU가 한 번에 불러오는 이미지의 최대 크기입니다. 0이면 하드웨어가 정합니다.", + "Largest image size the GPU loads at once; an HQ preview larger than this is reduced to it. 0 uses this machine's default.": "GPU가 한 번에 불러오는 이미지의 최대 크기입니다. 이보다 큰 HQ 미리보기는 이 크기로 줄입니다. 0이면 이 시스템의 기본값을 사용합니다.", "Last file": "마지막 파일", "Later": "나중에", "Later edits never delete a work print; this is the only way to delete one.": "이후의 편집으로 워크 프린트가 지워지지 않습니다. 삭제하는 방법은 이것뿐입니다.", @@ -1538,7 +1541,7 @@ "Measuring the strip…": "스트립 측정…", "Measuring…": "측정 중…", "Measuring… {value:.0f}%": "측정 중… {value:.0f}%", - "Memory ceiling for that cache. Lower it on a machine with little RAM.": "해당 캐시의 메모리 한도입니다. RAM이 적은 시스템에서는 낮추십시오.", + "Memory ceiling for that cache.": "해당 캐시의 메모리 한도입니다.", "Merge": "병합", "Merge Exposures (HDR)": "노출 병합(HDR)", "Merge Exposures (Trichrome)": "노출 병합(삼색)", diff --git a/negpy/translations/vi.json b/negpy/translations/vi.json index 271efbe09..af9f6645f 100644 --- a/negpy/translations/vi.json +++ b/negpy/translations/vi.json @@ -1,5 +1,8 @@ { " (base unreadable)": " (không đọc được nền phim)", + " Above the recommended {value}{unit} for this machine. NegPy may run out of memory.": " Cao hơn mức đề xuất {value}{unit} cho máy này. NegPy có thể hết bộ nhớ.", + " Open a frame to size the recommendation to it.": " Mở một khung hình để tính mức đề xuất theo kích thước của nó.", + " Recommended up to {value}{unit} on this machine.": " Đề xuất tối đa {value}{unit} trên máy này.", " Ref": " Tham chiếu", " Set in override.toml.": " Đặt trong override.toml.", " frames": " khung hình", @@ -1118,7 +1121,7 @@ "Full frame": "Toàn khung hình", "Full frame. Click the image to magnify.": "Toàn khung hình. Bấm vào ảnh để phóng to.", "Full frame{offset_txt}": "Toàn khung hình{offset_txt}", - "Full-size previews kept in memory. Each uses hundreds of MB; keeping the last one makes going back instant.": "Bản xem trước kích thước đầy đủ được giữ trong bộ nhớ. Mỗi bản dùng hàng trăm MB; giữ bản cuối giúp quay lại tức thì.", + "Full-size previews kept in memory; keeping the last one makes going back instant. The recommendation counts frames the size of the open one.": "Các bản xem trước kích thước đầy đủ được giữ trong bộ nhớ; giữ bản gần nhất giúp quay lại tức thì. Mức đề xuất tính theo các khung hình có kích thước bằng khung đang mở.", "GPU Acceleration": "Tăng tốc GPU", "GPU acceleration failed. Using the CPU.": "Tăng tốc GPU thất bại. Đang dùng CPU.", "GPU texture cap": "Giới hạn texture GPU", @@ -1378,7 +1381,7 @@ "Label text": "Văn bản nhãn", "Language": "Ngôn ngữ", "Largest dust size to remove, where larger values can remove real detail.": "Kích thước bụi lớn nhất cần xóa, giá trị lớn hơn có thể xóa cả chi tiết thật.", - "Largest image size the GPU loads at once. 0 lets the hardware decide.": "Kích thước ảnh lớn nhất mà GPU tải một lần. 0 để phần cứng tự quyết định.", + "Largest image size the GPU loads at once; an HQ preview larger than this is reduced to it. 0 uses this machine's default.": "Kích thước ảnh lớn nhất mà GPU tải cùng lúc; bản xem trước HQ lớn hơn sẽ được thu nhỏ về mức này. 0 dùng giá trị mặc định của máy này.", "Last file": "Tệp cuối cùng", "Later": "Để sau", "Later edits never delete a work print; this is the only way to delete one.": "Các chỉnh sửa sau này không bao giờ xóa bản in làm việc; đây là cách duy nhất để xóa một bản.", @@ -1538,7 +1541,7 @@ "Measuring the strip…": "Đang đo dải phim…", "Measuring…": "Đang đo…", "Measuring… {value:.0f}%": "Đang đo… {value:.0f}%", - "Memory ceiling for that cache. Lower it on a machine with little RAM.": "Giới hạn bộ nhớ cho bộ đệm đó. Hạ xuống trên máy ít RAM.", + "Memory ceiling for that cache.": "Giới hạn bộ nhớ cho bộ đệm đó.", "Merge": "Gộp", "Merge Exposures (HDR)": "Gộp phơi sáng (HDR)", "Merge Exposures (Trichrome)": "Gộp phơi sáng (Trichrome)", diff --git a/negpy/translations/zh_CN.json b/negpy/translations/zh_CN.json index 578a49e0d..6cd6c4dae 100644 --- a/negpy/translations/zh_CN.json +++ b/negpy/translations/zh_CN.json @@ -1,5 +1,8 @@ { " (base unreadable)": "(片基不可读)", + " Above the recommended {value}{unit} for this machine. NegPy may run out of memory.": " 超过本机的推荐值 {value}{unit}。NegPy 可能会耗尽内存。", + " Open a frame to size the recommendation to it.": " 打开一帧后,推荐值会按其尺寸计算。", + " Recommended up to {value}{unit} on this machine.": " 本机推荐最多 {value}{unit}。", " Ref": " 参考", " Set in override.toml.": " 在 override.toml 中设置。", " frames": " 帧", @@ -1118,7 +1121,7 @@ "Full frame": "完整画面", "Full frame. Click the image to magnify.": "完整画面,点击图像放大", "Full frame{offset_txt}": "完整画面{offset_txt}", - "Full-size previews kept in memory. Each uses hundreds of MB; keeping the last one makes going back instant.": "保存在内存中的全尺寸预览。每个占用数百 MB;保留上一个可即时返回。", + "Full-size previews kept in memory; keeping the last one makes going back instant. The recommendation counts frames the size of the open one.": "保存在内存中的全尺寸预览;保留上一个可即时返回。推荐值按当前打开帧的尺寸计算。", "GPU Acceleration": "GPU 加速", "GPU acceleration failed. Using the CPU.": "GPU 加速失败,改用 CPU", "GPU texture cap": "GPU 纹理上限", @@ -1378,7 +1381,7 @@ "Label text": "标签文字", "Language": "语言", "Largest dust size to remove, where larger values can remove real detail.": "要去除的最大灰尘尺寸,数值大时可能误删真实细节。", - "Largest image size the GPU loads at once. 0 lets the hardware decide.": "GPU 一次载入的最大图像尺寸。0 表示由硬件决定。", + "Largest image size the GPU loads at once; an HQ preview larger than this is reduced to it. 0 uses this machine's default.": "GPU 一次载入的最大图像尺寸;超过此值的高质量预览会缩小到该尺寸。0 表示使用本机默认值。", "Last file": "最后一个文件", "Later": "稍后", "Later edits never delete a work print; this is the only way to delete one.": "之后的编辑不会删除工作样片;只有这里能删除它。", @@ -1538,7 +1541,7 @@ "Measuring the strip…": "正在测量胶片条…", "Measuring…": "正在测量…", "Measuring… {value:.0f}%": "正在测量… {value:.0f}%", - "Memory ceiling for that cache. Lower it on a machine with little RAM.": "缓存内存上限。内存较少时降低此值。", + "Memory ceiling for that cache.": "缓存内存上限。", "Merge": "合并", "Merge Exposures (HDR)": "合并曝光(HDR)", "Merge Exposures (Trichrome)": "合并曝光(三色扫描)", diff --git a/tests/test_override_config.py b/tests/test_override_config.py index a78a0b6ed..58a99a5d0 100644 --- a/tests/test_override_config.py +++ b/tests/test_override_config.py @@ -11,11 +11,13 @@ INTEGRATED_GPU_TEXTURE_CAP_DEFAULT, PREVIEW_SIZE_MAX, PREVIEW_SIZE_MIN, + Machine, OverrideConfig, _parse, _platform_defaults, apply, apply_stored, + cache_entry_limit, effective_max_texture_size, load_or_create, toml_pinned_keys, @@ -399,6 +401,32 @@ def test_low_vram_export_tiling_none_leaves_app_config_unchanged(self): self.assertFalse(app_config.low_vram_export_tiling) +_GIB = 1024**3 + + +class TestCacheEntryLimit(unittest.TestCase): + """Every Preferences recommendation derives from the Machine passed in; none reads the host.""" + + _KEYS = ("preview_cache_max_bytes", "preview_cache_max_full_res_entries", "preview_cache_max_entries", "render_memo_max_entries") + + def _limits(self, machine): + return [cache_entry_limit(key, machine, 1024**2 if key == "preview_cache_max_bytes" else 1) for key in self._KEYS] + + def test_old_pc_with_a_small_scan(self): + self.assertEqual(self._limits(Machine(4 * _GIB, 8_000_000, 1600)), [2048, 9, 69, 52]) + + def test_workstation_with_a_pixel_shift_frame(self): + self.assertEqual(self._limits(Machine(512 * _GIB, 400_000_000, 8192)), [262144, 24, 341, 256]) + + def test_ram_unknown_no_open_frame_and_other_keys_derive_nothing(self): + self.assertEqual(self._limits(Machine(None, 61_005_568, 1600)), [None] * 4) + self.assertIsNone(cache_entry_limit("preview_cache_max_full_res_entries", Machine(128 * _GIB, 0, 1600))) + self.assertIsNone(cache_entry_limit("preview_render_size", Machine(128 * _GIB, 61_005_568, 1600))) + + def test_a_zero_preview_size_does_not_divide_by_zero(self): + self.assertGreater(cache_entry_limit("render_memo_max_entries", Machine(8 * _GIB, 0, 0)), 0) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_preferences_dialog.py b/tests/test_preferences_dialog.py index e6a54fcb8..bf03e89d0 100644 --- a/tests/test_preferences_dialog.py +++ b/tests/test_preferences_dialog.py @@ -3,7 +3,8 @@ from PyQt6.QtWidgets import QDialog -from negpy.desktop.view.widgets.preferences_dialog import NUMBER_ROWS, PreferencesDialog, default_for +from negpy.desktop.view.widgets.preferences_dialog import NUMBER_ROWS, PreferencesDialog, default_for, row_ceiling, row_recommendation +from negpy.kernel.system.override import Machine from tests.conftest import FakeController, FakeRepo @@ -151,6 +152,97 @@ def test_every_number_row_has_a_default_inside_its_range(self): self.assertGreaterEqual(default_for(row.key) // row.scale, row.minimum) self.assertLessEqual(default_for(row.key) // row.scale, row.maximum) + def test_a_small_machine_keeps_the_old_limit_with_half_of_ram_recommended(self): + with patch(_RAM, return_value=8 * _GIB): + dlg = _dlg() + spin, hint = dlg._spins["preview_cache_max_bytes"], dlg._hints["preview_cache_max_bytes"] + self.assertEqual(spin.maximum(), 32768) + self.assertIn("Recommended up to 4096 MB", hint.text()) + self.assertEqual(hint.property("hint"), "muted") + + def test_a_value_above_the_recommendation_turns_the_hint_amber(self): + with patch(_RAM, return_value=8 * _GIB): + dlg = _dlg() + spin, hint = dlg._spins["preview_cache_max_bytes"], dlg._hints["preview_cache_max_bytes"] + spin.setValue(5000) + self.assertEqual(hint.property("hint"), "warning") + self.assertIn("Above the recommended 4096 MB", hint.text()) + self.assertEqual(dlg.repo.data["preview_cache_max_bytes"], 5000 * 1024 * 1024) + spin.setValue(3000) + self.assertEqual(hint.property("hint"), "muted") + + def test_the_texture_cap_can_go_high_with_no_recommendation_or_warning(self): + with patch(_RAM, return_value=8 * _GIB): + dlg = _dlg() + spin, hint = dlg._spins["max_texture_size"], dlg._hints["max_texture_size"] + self.assertEqual(spin.maximum(), 65536) + spin.setValue(65536) + self.assertNotIn("ecommended", hint.text()) + self.assertEqual(hint.property("hint"), "muted") + self.assertEqual(dlg.repo.data["max_texture_size"], 65536) + + def test_a_stored_value_above_the_ceiling_still_shows_as_an_advisory(self): + from negpy.desktop.view.widgets import preferences_dialog as module + + with patch(_RAM, return_value=8 * _GIB), patch.object(module.APP_CONFIG, "preview_cache_max_bytes", 40 * _GIB): + dlg = _dlg() + spin, hint = dlg._spins["preview_cache_max_bytes"], dlg._hints["preview_cache_max_bytes"] + self.assertEqual(spin.value(), 40960) + self.assertEqual(spin.maximum(), 40960) + self.assertEqual(hint.property("hint"), "warning") + + def test_raising_preview_size_in_the_dialog_resizes_the_other_rows(self): + with patch(_RAM, return_value=8 * _GIB): + dlg = _dlg() + hint = dlg._hints["render_memo_max_entries"] + self.assertIn("Recommended up to 104 frames", hint.text()) + dlg._spins["preview_render_size"].setValue(8192) + self.assertIn("Above the recommended 4 frames", hint.text()) + self.assertEqual(hint.property("hint"), "warning") + + def test_ram_unknown_shows_no_recommendation(self): + with patch(_RAM, return_value=None): + dlg = _dlg() + for key, hint in dlg._hints.items(): + with self.subTest(key): + self.assertNotIn("Recommended up to", hint.text()) + self.assertEqual(dlg._spins["preview_cache_max_bytes"].maximum(), 32768) + + +_RAM = "negpy.desktop.view.widgets.preferences_dialog.total_system_memory_bytes" +_GIB = 1024**3 +_ROWS = {row.key: row for row in NUMBER_ROWS} + + +class TestRowRecommendationAndCeiling(unittest.TestCase): + """A row's recommendation and ceiling come from the Machine; the static maximum is the fallback.""" + + def test_ram_unknown_recommends_nothing_and_keeps_the_static_maxima(self): + machine = Machine(None, 61_005_568, 1600) + for key, row in _ROWS.items(): + with self.subTest(key): + self.assertIsNone(row_recommendation(row, machine)) + self.assertEqual(row_ceiling(row, machine), row.maximum) + + def test_the_ceiling_is_the_whole_of_ram_on_a_large_machine(self): + machine = Machine(128 * _GIB, 61_005_568, 1600) + self.assertEqual(row_recommendation(_ROWS["preview_cache_max_bytes"], machine), 65536) + self.assertEqual(row_ceiling(_ROWS["preview_cache_max_bytes"], machine), 131072) + self.assertEqual(row_recommendation(_ROWS["preview_cache_max_full_res_entries"], machine), 40) + self.assertEqual(row_ceiling(_ROWS["preview_cache_max_full_res_entries"], machine), 80) + + def test_the_ceiling_never_falls_below_the_static_maximum(self): + machine = Machine(4 * _GIB, 61_005_568, 8192) + for key, row in _ROWS.items(): + with self.subTest(key): + self.assertGreaterEqual(row_ceiling(row, machine), row.maximum) + + def test_no_open_frame_recommends_nothing_for_hq_buffers(self): + row = _ROWS["preview_cache_max_full_res_entries"] + machine = Machine(128 * _GIB, 0, 1600) + self.assertIsNone(row_recommendation(row, machine)) + self.assertEqual(row_ceiling(row, machine), row.maximum) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_system_memory.py b/tests/test_system_memory.py new file mode 100644 index 000000000..b89701347 --- /dev/null +++ b/tests/test_system_memory.py @@ -0,0 +1,12 @@ +from negpy.kernel.system.memory import available_system_memory_bytes, total_system_memory_bytes + + +def test_total_memory_probe_reports_a_size_or_unknown(): + """CI hosts differ; the probe only has to answer with bytes or None.""" + total = total_system_memory_bytes() + assert total is None or (isinstance(total, int) and total > 0) + + +def test_available_memory_probe_reports_a_size_or_unknown(): + available = available_system_memory_bytes() + assert available is None or (isinstance(available, int) and available > 0) From ea6934c0f344d1787ba89f8cf4459c99e73bc0d4 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 5 Nov 2026 11:23:24 +0100 Subject: [PATCH 45/46] Fill the Norwegian catalog for the memory limits and film scanner optics --- negpy/translations/USER_GUIDE.nb.md | 8 ++++---- negpy/translations/nb.json | 9 ++++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/negpy/translations/USER_GUIDE.nb.md b/negpy/translations/USER_GUIDE.nb.md index 8098117b8..38ff18b66 100644 --- a/negpy/translations/USER_GUIDE.nb.md +++ b/negpy/translations/USER_GUIDE.nb.md @@ -313,16 +313,16 @@ Skannevalgene er én innstilling for hele appen. Listen under dem viser hvordan Et kamera viser kortene RAW-dekoding og Optikk. Et kamera med smalbåndslys viser også Trichrome og Kalibrering. En endring oppdaterer hvert åpent bilde (Ctrl+Z angrer den per bilde), men et kopipapir du har valgt selv, blir stående. En kameraskanning angir dekodingen og kopipapiret for hvert bilde ut fra sitt eget lys. - + ### 10.2 RAW-dekoding Hvordan en kamera-RAW-fil gjøres om til piksler. Vises for et kamera. * **Kamera-WB / Lineær RAW**: Kamera-WB bruker kameraets hvitbalanse. Lineær RAW dekoder nøytralt, for smalbåndslys. Oppsett angir det for deg. * **Forhåndsvisning** / **Eksport**: demosaikkmetoden, som fyller inn fargene hver sensorpiksel ikke kan se. **Auto** er raskt på skjermen og bruker **AHD** ved eksport. **VNG** er jevn og **PPG** rask. **DCB** og **DHT** beholder flere fine detaljer, men kan gjøre korn om til mønstre. **AAHD** mykner kanter. Bare metodene i din LibRaw-versjon vises. -* **Gjenoppretting** (dias): gjenoppbygger utbrente høylys i en kamera-RAW. **Blanding** passer sol og himmel. **Rekonstruer** er sterkere, men kan gi feil farge. +* **Gjenoppretting** (dias): gjenoppbygger utbrente høylys i en kamera-RAW. **Blanding** passer sol og himmel. **Rekonstruer** er sterkere, men kan gi feil farge. Et nytt bilde fra filmskanneren starter med Gjenoppretting av. - + ### 10.3 Optikk Korreksjoner for objektivet og lyset, de samme for hvert bilde du skanner med dette oppsettet. Vises for et kamera. @@ -340,7 +340,7 @@ Fjerner ujevnt lys, for eksempel mørkere hjørner, med et bilde av den bare lys * **Bruk flatfelt**: bruker den valgte profilen på denne rullen. * **Kontroller flatfelt**: viser hvor godt profilen jevner ut sitt eget bilde. Jevnt grått er bra; et bånd eller en flekk viser hvor den avviker. -Et bilde beholder sin egen profil med mindre det følger rullen, så hvert lys og filmformat kan ha én. Et nytt bilde, eller et bilde der profilen mangler, får den siste profilen du valgte. +Et bilde beholder sin egen profil med mindre det følger rullen, så hvert lys og filmformat kan ha én. Et nytt kamerabilde, eller et kamerabilde der profilen mangler, får den siste profilen du valgte på et kamerabilde. Et nytt bilde fra filmskanneren starter uten Optikk-korreksjoner. Med Filmskanner valgt vises dette kortet bare på et bilde som har en korreksjon. ### 10.4 Kalibrering: farger fra skanneoppsettet ditt diff --git a/negpy/translations/nb.json b/negpy/translations/nb.json index 7f112d5a8..31a9837e2 100644 --- a/negpy/translations/nb.json +++ b/negpy/translations/nb.json @@ -1,5 +1,8 @@ { " (base unreadable)": " (grunnlaget kan ikke leses)", + " Above the recommended {value}{unit} for this machine. NegPy may run out of memory.": " Over anbefalingen på {value}{unit} for denne maskinen. NegPy kan gå tom for minne.", + " Open a frame to size the recommendation to it.": " Åpne et bilde for å tilpasse anbefalingen til det.", + " Recommended up to {value}{unit} on this machine.": " Anbefalt opptil {value}{unit} på denne maskinen.", " Ref": " Ref", " Set in override.toml.": " Angitt i override.toml.", " frames": " bilder", @@ -1118,7 +1121,7 @@ "Full frame": "Fullt bilde", "Full frame. Click the image to magnify.": "Fullt bilde. Klikk på bildet for å forstørre.", "Full frame{offset_txt}": "Fullt bilde{offset_txt}", - "Full-size previews kept in memory. Each uses hundreds of MB; keeping the last one makes going back instant.": "Forhåndsvisninger i full størrelse som holdes i minnet. Hver bruker flere hundre MB; å beholde den siste gjør det umiddelbart å gå tilbake.", + "Full-size previews kept in memory; keeping the last one makes going back instant. The recommendation counts frames the size of the open one.": "Forhåndsvisninger i full størrelse som holdes i minnet; når den siste beholdes, går du tilbake umiddelbart. Anbefalingen regner med bilder på størrelse med det åpne.", "GPU Acceleration": "GPU-akselerasjon", "GPU acceleration failed. Using the CPU.": "GPU-akselerasjonen mislyktes. Bruker CPU-en.", "GPU texture cap": "Teksturgrense for GPU", @@ -1378,7 +1381,7 @@ "Label text": "Etikettekst", "Language": "Språk", "Largest dust size to remove, where larger values can remove real detail.": "Største støvstørrelse som fjernes, der større verdier kan fjerne ekte detaljer.", - "Largest image size the GPU loads at once. 0 lets the hardware decide.": "Største bildestørrelse GPU-en laster inn på én gang. 0 lar maskinvaren bestemme.", + "Largest image size the GPU loads at once; an HQ preview larger than this is reduced to it. 0 uses this machine's default.": "Største bildestørrelse GPU-en laster inn om gangen; en HQ-forhåndsvisning som er større, reduseres til den. 0 bruker standarden for denne maskinen.", "Last file": "Siste fil", "Later": "Senere", "Later edits never delete a work print; this is the only way to delete one.": "Senere redigeringer sletter aldri en arbeidskopi; dette er den eneste måten å slette en på.", @@ -1538,7 +1541,7 @@ "Measuring the strip…": "Måler stripen…", "Measuring…": "Måler…", "Measuring… {value:.0f}%": "Måler… {value:.0f}%", - "Memory ceiling for that cache. Lower it on a machine with little RAM.": "Minnetak for den hurtigbufferen. Senk det på en maskin med lite RAM.", + "Memory ceiling for that cache.": "Minnegrense for den hurtigbufferen.", "Merge": "Slå sammen", "Merge Exposures (HDR)": "Slå sammen eksponeringer (HDR)", "Merge Exposures (Trichrome)": "Slå sammen eksponeringer (Trichrome)", From 22760bcdbcfc58f15e5b8f921f1c123ac0e8c12b Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Thu, 5 Nov 2026 11:26:19 +0100 Subject: [PATCH 46/46] Fill the vi and nb catalogs for Frame No. sort, trim the Film Strip guide, add order tests --- docs/USER_GUIDE.md | 4 ++-- negpy/translations/USER_GUIDE.de.md | 2 +- negpy/translations/USER_GUIDE.en.md | 4 ++-- negpy/translations/USER_GUIDE.ja.md | 2 +- negpy/translations/USER_GUIDE.ko.md | 2 +- negpy/translations/USER_GUIDE.nb.md | 8 ++++---- negpy/translations/USER_GUIDE.zh_CN.md | 2 +- negpy/translations/nb.json | 3 ++- negpy/translations/vi.json | 3 ++- tests/test_contact_sheet_roll.py | 10 ++++++++++ tests/test_desktop_session.py | 16 ++++++++++++++++ 11 files changed, 42 insertions(+), 14 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index ebddf9b54..d90b9eb1b 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -132,9 +132,9 @@ The **Film Strip** under the canvas shows the open frames in one row. Drag its t The toolbar's **Roll** row holds: -* The roll's name and its count of frames, keepers and rejects. +* The roll's name and count of frames, keepers and rejects. * The **filter box**: type part of a file name, or terms such as `film:portra iso:>=400` (below). The search button, or **Enter**, searches the whole library. -* **Sort**: by name, date, scene, or by roll and frame number. +* **Sort**: name, date, scene or frame number. * **Sheet filter**: all frames, keepers only, hide rejected, or unmarked only. The button is highlighted while a filter hides frames. * **Roll Settings** (the tag): gear and film details for this frame, the selection or the roll. * **Apply**: copies chosen settings of this frame to the selection or the roll. diff --git a/negpy/translations/USER_GUIDE.de.md b/negpy/translations/USER_GUIDE.de.md index 360cfe231..2468e3e31 100644 --- a/negpy/translations/USER_GUIDE.de.md +++ b/negpy/translations/USER_GUIDE.de.md @@ -18,7 +18,7 @@ Eine Ordnerrolle enthält nur die Dateien ihres Ordners. Eine Datei, die du aus Um die Bilder im Filmstreifen als Rolle zu speichern, nutze den Filmstreifen. **In Ordnerrolle speichern** macht aus Bildern, die alle aus einem Ordner stammen, die Rolle dieses Ordners. Seine anderen Dateien erscheinen als entfernt. **Als virtuelle Rolle speichern…** behält genau diese Bilder, aus beliebigen Ordnern. In Ordnerrolle speichern macht auch aus einer virtuellen Rolle eines Ordners die Rolle dieses Ordners. Die neue Rolle startet mit den Einstellungen, den Kartensperren und den nur in dieser Rolle gemachten Bearbeitungen der geöffneten Rolle. Ein Bild hat in jeder Rolle, die es enthält, dieselbe Bearbeitung. Rechtsklick → **In dieser Rolle unabhängig bearbeiten** gibt ihm in einer Rolle eine eigene Bearbeitung. - + ### Filmstreifen Der **Filmstreifen** unter der Arbeitsfläche zeigt die geöffneten Bilder in einer Reihe. Ziehe seinen oberen Rand, um die Bilder zu vergrößern. diff --git a/negpy/translations/USER_GUIDE.en.md b/negpy/translations/USER_GUIDE.en.md index ebddf9b54..d90b9eb1b 100644 --- a/negpy/translations/USER_GUIDE.en.md +++ b/negpy/translations/USER_GUIDE.en.md @@ -132,9 +132,9 @@ The **Film Strip** under the canvas shows the open frames in one row. Drag its t The toolbar's **Roll** row holds: -* The roll's name and its count of frames, keepers and rejects. +* The roll's name and count of frames, keepers and rejects. * The **filter box**: type part of a file name, or terms such as `film:portra iso:>=400` (below). The search button, or **Enter**, searches the whole library. -* **Sort**: by name, date, scene, or by roll and frame number. +* **Sort**: name, date, scene or frame number. * **Sheet filter**: all frames, keepers only, hide rejected, or unmarked only. The button is highlighted while a filter hides frames. * **Roll Settings** (the tag): gear and film details for this frame, the selection or the roll. * **Apply**: copies chosen settings of this frame to the selection or the roll. diff --git a/negpy/translations/USER_GUIDE.ja.md b/negpy/translations/USER_GUIDE.ja.md index 32be59f63..db2f26f1a 100644 --- a/negpy/translations/USER_GUIDE.ja.md +++ b/negpy/translations/USER_GUIDE.ja.md @@ -6,7 +6,7 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 ロールを読み込み、フィルムモードとスキャン装置を選びます。光源・レンズ補正、トリミング、ロール解析を行ってから、各フレームの濃度、コントラスト、色補正、修復を調整し、最後に書き出します。カード見出しのフレーム/ロールで適用範囲を選び、ロール設定への復帰で共有値を戻せます。個々のトリミング位置と回転はフレームに保持されます。 - + ## フィルムストリップ キャンバスの下の**フィルムストリップ**は、開いているフレームを1列に表示します。上端をドラッグすると、フレームが大きくなります。 diff --git a/negpy/translations/USER_GUIDE.ko.md b/negpy/translations/USER_GUIDE.ko.md index 82c594f23..637d91e39 100644 --- a/negpy/translations/USER_GUIDE.ko.md +++ b/negpy/translations/USER_GUIDE.ko.md @@ -6,7 +6,7 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 롤을 불러와 필름 모드와 스캔 장치를 선택합니다. 광원과 렌즈를 보정하고 프레임 자르기와 롤 분석을 수행한 다음, 각 프레임의 농도·대비·색 보정·복구를 조정하여 내보냅니다. 카드 제목의 프레임/롤 버튼으로 적용 범위를 정하고 롤 설정으로 되돌려 공유 값을 복원할 수 있습니다. 자르기 위치와 회전은 프레임별로 유지됩니다. - + ## 필름 스트립 캔버스 아래의 **필름 스트립**은 열린 프레임을 한 줄로 보여줍니다. 위쪽 가장자리를 드래그하면 프레임이 커집니다. diff --git a/negpy/translations/USER_GUIDE.nb.md b/negpy/translations/USER_GUIDE.nb.md index 38ff18b66..8182644ff 100644 --- a/negpy/translations/USER_GUIDE.nb.md +++ b/negpy/translations/USER_GUIDE.nb.md @@ -18,7 +18,7 @@ En mapperull inneholder bare filene i sin egen mappe. En fil du legger til fra e Bruk filmstripen for å lagre bildene i filmstripen som en rull. **Lagre i mapperull** gjør bilder som alle kommer fra én mappe, til rullen for den mappen. De andre filene i mappen vises som fjernet. **Lagre som virtuell rull…** beholder nøyaktig disse bildene, fra hvilke som helst mapper. Lagre i mapperull gjør også en virtuell rull fra én mappe til rullen for den mappen. Den nye rullen starter med den åpne rullens innstillinger, kortlåser og redigeringene som bare ble gjort i den rullen. Et bilde har én redigering i hver rull som inneholder det. Høyreklikk → **Rediger uavhengig i denne rullen** gir det en egen redigering i én rull. - + ### Filmstripe **Filmstripen** under lerretet viser de åpne bildene på én rad. Dra den øvre kanten for å gjøre bildene større. @@ -27,7 +27,7 @@ Raden **Rull** på verktøylinjen inneholder: * Navnet på rullen og antallet bilder, beholdte og forkastede. * **Filterfeltet**: skriv en del av et filnavn, eller uttrykk som `film:portra iso:>=400` (se nedenfor). Søkeknappen, eller **Enter**, søker i hele biblioteket. -* **Sorter**: etter navn, dato eller scene. +* **Sorter**: etter navn, dato, scene, eller etter rull og bildenummer. * **Arkfilter**: alle bilder, bare beholdte, skjul forkastede, eller bare umerkede. Knappen er uthevet mens et filter skjuler bilder. * **Rullinnstillinger** (merkelappen): detaljer om utstyr og film for dette bildet, utvalget eller rullen. * **Bruk**: kopierer valgte innstillinger fra dette bildet til utvalget eller rullen. @@ -505,7 +505,7 @@ Ekstra filer ved siden av kopien. Hver har en bryter til venstre og en engangsha * **Autotetthet / -gradering**: **Bak inn ved eksport** fastsetter den automatiske lysheten og kontrasten for hvert eksporterte bilde, så de ikke endres senere. **Bak inn i rullen** gjør det for hvert synlig bilde nå. * **Redigeringssidefiler**: **Lagre ved eksport** skriver en `.negpy`-fil med redigeringen ved siden av hver kilde. **Eksporter sidefiler** skriver dem for alle synlige bilder. Høyreklikk et bilde → **Last inn redigering fra sidefil…** leser en tilbake. - + ### Kontaktark På fanen **Ved siden av eksportene**. Et kontaktark viser hele rullen som filmstriper på ett papirark, slik en kontaktkopi i mørkerommet gjør. @@ -514,7 +514,7 @@ På fanen **Ved siden av eksportene**. Et kontaktark viser hele rullen som films * **Kontaktark…**: åpner arket. Dra en kant av papiret for å endre størrelsen. * **Filmformat**: 35 mm, 35 mm halvformat eller 120 (med **Bildestørrelse** for 120). * **Papir**, **Bredde**, **Høyde** og **DPI**: størrelsen og oppløsningen til arket. Standardpapiret har plass til en rull med 36 bilder. -* **Rekkefølge**: etter filnavn, etter dato, eller etter scene med en ny stripe eller rad for hver scene. +* **Rekkefølge**: etter filnavn, etter dato, etter rull og bildenummer, eller etter scene med en ny stripe eller rad for hver scene. * **Kopi**: **Som redigert**, eller **Rett prøve**: hele rullen med én eksponering og normal kontrast, så du ser hvilke negativer som er tynne eller tette. * **Velg bilder**: klikk et bilde for å utelate det eller sette det tilbake. Forkastede er utelatt i starten. * **Kantpåskrift**: produsentens merking langs kantene, for eksempel filmnavnet, bildenumrene og de fargede linjene en filmtype trykker ved perforeringen. diff --git a/negpy/translations/USER_GUIDE.zh_CN.md b/negpy/translations/USER_GUIDE.zh_CN.md index a3933b591..f9da7fb14 100644 --- a/negpy/translations/USER_GUIDE.zh_CN.md +++ b/negpy/translations/USER_GUIDE.zh_CN.md @@ -6,7 +6,7 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 载入胶卷后,选择胶片模式与扫描装置,校正光源和镜头,裁切画面并分析胶卷,再调整单帧密度、反差、滤色与修复,最后导出。卡片标题的画面/胶卷按钮控制应用范围,恢复胶卷设置可拉回共享值。单帧裁切位置与旋转保留在画面上。 - + ## 胶片条 画布下方的**胶片条**把打开的帧排成一行。拖动其上边缘可放大帧。 diff --git a/negpy/translations/nb.json b/negpy/translations/nb.json index 31a9837e2..bf1219953 100644 --- a/negpy/translations/nb.json +++ b/negpy/translations/nb.json @@ -1096,6 +1096,7 @@ "Frame Counter": "Bildeteller", "Frame Info": "Bildeinfo", "Frame Info: {value}": "Bildeinfo: {value}", + "Frame No.": "Bildenr.", "Frame info on the canvas: off, basic or full": "Bildeinfo på lerretet: av, grunnleggende eller full", "Frame length on the loaded film; Auto where the holder fixes it": "Bildelengde på den innlastede filmen; Auto der holderen bestemmer den", "Frame or Roll": "Bilde eller rull", @@ -1387,7 +1388,7 @@ "Later edits never delete a work print; this is the only way to delete one.": "Senere redigeringer sletter aldri en arbeidskopi; dette er den eneste måten å slette en på.", "Latitude and longitude, or a pasted OpenStreetMap or Google Maps link.": "Breddegrad og lengdegrad, eller en innlimt lenke fra OpenStreetMap eller Google Maps.", "Lay every visible frame out as film strips on photographic paper, then export the sheet": "Legg ut hvert synlig bilde som filmstriper på fotopapir, og eksporter så arket", - "Lay the frames out by file name, in the order they were taken, or with a new strip for each scene.": "Ordne bildene etter filnavn, i rekkefølgen de ble tatt, eller med en ny stripe for hver scene.", + "Lay the frames out by file name, by the order they were taken, by roll and frame number, or by scene.": "Ordne bildene etter filnavn, etter rekkefølgen de ble tatt i, etter rull og bildenummer, eller etter scene.", "Leave at the default unless you know why you need to change it.": "La standardverdien stå med mindre du vet hvorfor du må endre den.", "Leave frames in the rejected state out of the run; the numbers fill the frames that remain.": "Utelat forkastede bilder fra rekken; numrene fyller bildene som er igjen.", "Leave the current view (peek, split, strip, zone overlay) or cancel the active tool; Crop and Draw Region undo their changes": "Forlat den gjeldende visningen (titt, deling, stripe, soneoverlegg) eller avbryt det aktive verktøyet; Beskjær og Tegn område angrer endringene sine", diff --git a/negpy/translations/vi.json b/negpy/translations/vi.json index af9f6645f..dc29ca582 100644 --- a/negpy/translations/vi.json +++ b/negpy/translations/vi.json @@ -1096,6 +1096,7 @@ "Frame Counter": "Bộ đếm khung hình", "Frame Info": "Thông tin khung hình", "Frame Info: {value}": "Thông tin khung hình: {value}", + "Frame No.": "Số khung hình", "Frame info on the canvas: off, basic or full": "Thông tin khung hình trên khung vẽ: tắt, cơ bản hoặc đầy đủ", "Frame length on the loaded film; Auto where the holder fixes it": "Chiều dài khung hình trên phim đã nạp; Tự động nếu khay giữ cố định nó", "Frame or Roll": "Khung hình hoặc cuộn", @@ -1387,7 +1388,7 @@ "Later edits never delete a work print; this is the only way to delete one.": "Các chỉnh sửa sau này không bao giờ xóa bản in làm việc; đây là cách duy nhất để xóa một bản.", "Latitude and longitude, or a pasted OpenStreetMap or Google Maps link.": "Vĩ độ và kinh độ, hoặc một liên kết OpenStreetMap hay Google Maps đã dán.", "Lay every visible frame out as film strips on photographic paper, then export the sheet": "Sắp xếp mọi khung hình đang hiển thị thành các dải phim trên giấy ảnh, rồi xuất tờ đó", - "Lay the frames out by file name, in the order they were taken, or with a new strip for each scene.": "Sắp xếp khung hình theo tên tệp, theo thứ tự chụp, hoặc với một dải mới cho mỗi cảnh.", + "Lay the frames out by file name, by the order they were taken, by roll and frame number, or by scene.": "Xếp khung hình theo tên tệp, theo thứ tự chụp, theo cuộn và số khung hình, hoặc theo cảnh.", "Leave at the default unless you know why you need to change it.": "Để ở mặc định trừ khi bạn biết tại sao cần thay đổi.", "Leave frames in the rejected state out of the run; the numbers fill the frames that remain.": "Bỏ các khung hình đang ở trạng thái bị loại ra khỏi lượt đánh số; các số được điền cho những khung hình còn lại.", "Leave the current view (peek, split, strip, zone overlay) or cancel the active tool; Crop and Draw Region undo their changes": "Rời khung xem hiện tại (xem nhanh, chia đôi, dải, lớp phủ vùng) hoặc hủy công cụ đang dùng; Cắt và Vẽ vùng hoàn tác thay đổi của chúng", diff --git a/tests/test_contact_sheet_roll.py b/tests/test_contact_sheet_roll.py index 51b0f726e..b515d5cf8 100644 --- a/tests/test_contact_sheet_roll.py +++ b/tests/test_contact_sheet_roll.py @@ -17,6 +17,7 @@ FrameFacts, SheetFrame, creation_order, + frame_order, name_order, cropped_aspect, infer_format, @@ -69,6 +70,15 @@ def test_ties_break_by_name_then_half(self): frames = [_frame("x.tif", half=2, birth=5), _frame("x.tif", half=1, birth=5), _frame("a.tif", birth=5)] assert creation_order(frames) == [2, 1, 0] + def test_frame_order_is_roll_then_frame_number_then_name(self): + frames = [ + _frame("a.tif", capture_roll="02", capture_frame=1), + _frame("b.tif", capture_roll="01", capture_frame=10), + _frame("c.tif", capture_roll="01", capture_frame=2), + _frame("e.tif", capture_roll="01", capture_frame=2), + ] + assert frame_order(frames) == [2, 3, 1, 0] + def test_name_order_ignores_case_and_time(self): frames = [_frame("b.tif", birth=1), _frame("x.tif", half=2), _frame("A.tif", birth=9), _frame("x.tif", half=1)] assert [(f.name, f.half) for f in name_order(frames)] == [("A.tif", 0), ("b.tif", 0), ("x.tif", 1), ("x.tif", 2)] diff --git a/tests/test_desktop_session.py b/tests/test_desktop_session.py index 447e43187..553cc1841 100644 --- a/tests/test_desktop_session.py +++ b/tests/test_desktop_session.py @@ -1957,6 +1957,22 @@ def test_no_blocks_under_other_orders_or_a_semantic_query(self): self.assertEqual(self.model.scene_runs(), []) +class TestFrameNumberSort(unittest.TestCase): + def test_frames_sort_by_roll_then_frame_number_then_name(self): + state = AppState() + state.uploaded_files = [{"name": n, "path": f"/r/{n}", "hash": n} for n in ("a.tif", "b.tif", "c.tif", "d.tif")] + facts = { + "a.tif": {"capture_roll": "02", "frame": 1}, + "b.tif": {"capture_roll": "01", "frame": 10}, + "c.tif": {"capture_roll": "01", "frame": 2}, + "d.tif": {"capture_roll": "01", "frame": 2}, + } + model = AssetListModel(state, facts_provider=lambda: facts) + model.set_sort_order("frame") + names = [state.uploaded_files[i]["name"] for i in model._sorted_indices] + self.assertEqual(names, ["c.tif", "d.tif", "b.tif", "a.tif"]) + + class TestNavButtonBoundaries(unittest.TestCase): """Regression for #407: Next/Prev enable must be computed in display space (sorted/filtered order), matching session.next_file/prev_file — not raw