From 9b94557a8b96839465d58e1555fa40d96c1d5471 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sun, 11 Oct 2026 14:00:08 +0200 Subject: [PATCH 1/2] chore: trim comments and docstrings added since 0.63.0 Remove comments that restate code, narrate history or quote one-run measurements; cut over-budget docstrings to the constraint they carry. AST-identical with docstrings stripped, except two tooltip strings (Edit Toolbar hint, Diffusion). WGSL code unchanged. --- negpy/desktop/auto_sliders.py | 8 +- negpy/desktop/controller.py | 106 ++++++------------ negpy/desktop/main.py | 13 +-- negpy/desktop/session.py | 41 +++---- negpy/desktop/settings_catalog.py | 5 +- negpy/desktop/sticky.py | 4 +- negpy/desktop/view/canvas/gpu_widget.py | 5 +- negpy/desktop/view/canvas/toolbar.py | 8 +- negpy/desktop/view/canvas/widget.py | 2 +- negpy/desktop/view/sidebar/altprocess.py | 9 +- .../view/sidebar/calibration_window.py | 6 +- .../desktop/view/sidebar/live_view_window.py | 12 +- negpy/desktop/view/sidebar/paper.py | 9 +- negpy/desktop/view/sidebar/right_panel.py | 3 +- negpy/desktop/view/sidebar/scanlight.py | 27 ++--- negpy/desktop/view/sidebar/tone.py | 4 +- negpy/desktop/view/sidebar/toning.py | 6 +- negpy/desktop/view/styles/color_vision.py | 5 +- negpy/desktop/view/widgets/charts.py | 51 +++------ .../view/widgets/sensor_calibration_dialog.py | 6 +- negpy/desktop/view/widgets/slippy_map.py | 2 +- .../view/widgets/strip_preview_dialog.py | 5 +- negpy/desktop/workers/export.py | 14 +-- negpy/desktop/workers/library.py | 2 +- negpy/domain/migrations.py | 6 +- negpy/domain/models.py | 6 +- negpy/features/altprocess/models.py | 17 +-- negpy/features/exposure/analysis.py | 12 +- negpy/features/exposure/auto_sliders.py | 5 +- negpy/features/exposure/logic.py | 25 ++--- negpy/features/exposure/models.py | 15 +-- negpy/features/exposure/normalization.py | 49 +++----- negpy/features/exposure/shaders/exposure.wgsl | 24 ++-- negpy/features/flatfield/logic.py | 20 ++-- negpy/features/geometry/batch_autocrop.py | 11 +- negpy/features/geometry/logic.py | 23 +--- negpy/features/lab/logic.py | 22 +--- negpy/features/lens/warps.py | 5 +- negpy/features/local/logic.py | 11 +- negpy/features/local/models.py | 5 +- negpy/features/process/sensor.py | 27 ++--- negpy/features/sabattier/logic.py | 17 +-- .../sabattier/shaders/sabattier_h.wgsl | 5 +- negpy/features/transparency/logic.py | 10 +- .../transparency/shaders/transfer.wgsl | 5 +- negpy/infrastructure/loaders/helpers.py | 14 +-- negpy/infrastructure/loaders/lens_metadata.py | 8 +- negpy/infrastructure/loaders/logluv.py | 9 +- negpy/infrastructure/scanners/sane_backend.py | 22 +--- negpy/infrastructure/scanners/settings.py | 10 +- negpy/kernel/caching/logic.py | 12 +- negpy/kernel/system/parallel.py | 15 +-- negpy/services/assets/composites.py | 6 +- negpy/services/assets/flatfield.py | 18 +-- negpy/services/assets/half_frame.py | 15 +-- .../services/assets/migrations/roll_fields.py | 6 +- negpy/services/capture/calibration.py | 82 ++++---------- negpy/services/capture/meter.py | 8 +- negpy/services/export/linear_output.py | 10 +- negpy/services/rendering/engine.py | 11 +- negpy/services/rendering/gpu_engine.py | 68 ++++------- negpy/services/rendering/image_processor.py | 30 ++--- tests/scanners/test_capability_activity.py | 2 +- tests/test_autocrop_state_table.py | 11 +- tests/test_capture_calibration.py | 14 +-- tests/test_capture_meter.py | 7 +- tests/test_capture_unmix.py | 5 +- tests/test_capture_worker.py | 3 +- tests/test_controller.py | 8 +- tests/test_coordinate_mapping_offframe.py | 1 - tests/test_desktop_session.py | 3 +- tests/test_dust_overlay.py | 3 +- tests/test_embedding_worker.py | 3 +- tests/test_export_unopened_guard.py | 7 +- tests/test_flatfield_logic.py | 3 +- tests/test_frame_skew.py | 5 +- tests/test_gpu_analysis_cache.py | 3 +- tests/test_gpu_dpr_change.py | 9 +- tests/test_gpu_slide_shadow_reach.py | 3 +- tests/test_gpu_texture_pool_eviction.py | 3 +- tests/test_hidden_file_discovery.py | 9 +- tests/test_icc_identify.py | 1 - tests/test_local_sidebar.py | 1 - tests/test_location_picker_dialog.py | 1 - tests/test_metadata_fast_lens.py | 2 +- tests/test_metadata_lens.py | 2 +- tests/test_overlay_border_padding.py | 2 +- tests/test_paper_sidebar.py | 9 +- tests/test_parallel_dispatch.py | 5 +- tests/test_pipeline_parity.py | 9 +- tests/test_print_wedge.py | 3 +- tests/test_printing_notes_overlay.py | 3 +- tests/test_sabattier_logic.py | 3 +- tests/test_scanlight_sidebar.py | 8 +- tests/test_sensor_unmix_modes.py | 12 +- tests/test_slippy_map.py | 2 +- tests/test_soft_proof_lut.py | 7 +- tests/test_vectorscope.py | 1 - tests/test_waveform.py | 1 - 99 files changed, 373 insertions(+), 818 deletions(-) diff --git a/negpy/desktop/auto_sliders.py b/negpy/desktop/auto_sliders.py index d9feebdec..a8570b617 100644 --- a/negpy/desktop/auto_sliders.py +++ b/negpy/desktop/auto_sliders.py @@ -1,8 +1,6 @@ -"""The Auto Density / Auto Grade slider conversion for the open frame: its render path's -inverse, fed by the meters of the frame's last plain render. - -The meters are kept per file because last_metrics outlives a frame switch and a -navigate-back memo paint shows a frame before its own render lands. +"""Auto Density / Auto Grade slider conversion for the open frame: its render path's inverse, +fed by the meters of the frame's last plain render. Meters are kept per file: last_metrics +outlives a frame switch, and a memo paint shows a frame before its own render lands. """ from typing import Any, Dict, Mapping diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 322051efc..b558c4b8c 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -389,9 +389,8 @@ def baseline_compare_config(config: WorkspaceConfig) -> WorkspaceConfig: "midtone_gamma": "Snap", } -# A background thumbnail refresh grows its own preview cache on top of whatever the -# navigation and Auto-crop all frames caches already hold; deferred and retried rather than -# started under memory pressure. +# A background thumbnail refresh adds its own preview cache to the navigation and Auto-crop all +# frames caches, so under memory pressure it waits and retries. _THUMBNAIL_REFRESH_MEMORY_RETRY_MS = 5000 # Mean decode seconds past which a refresh that decode dominates is read-bound. _THUMBNAIL_READ_BOUND_DECODE_S = 3.0 @@ -2096,9 +2095,8 @@ def _half_frame_geometry_for(self, file_hash: str, file_path: str = "") -> HalfG return HalfGeometry() def _remap_half_frame_edits(self, file_hash: str, new_geom: HalfGeometry, old_geom: Optional[HalfGeometry] = None) -> None: - """Re-anchor both halves' saved manual edits from ``old_geom`` (by default the file's - effective geometry) to ``new_geom``, so a heal stroke, dust spot, scratch line or - dodge/burn mask stays on the same physical film location when the split or crop moves. + """Re-anchor both halves' saved manual edits from ``old_geom`` (default: the file's effective + geometry) to ``new_geom``, so they stay on the same film when the split or crop moves. The default may decode the scan, so it is resolved only for a file with saved half edits.""" path = self._path_for_base_hash(file_hash) rows = [ @@ -2346,7 +2344,6 @@ def _on_rgb_grouped(self, summary: dict) -> None: _ = keep def _forget_unloaded_frames(self) -> None: - """Drops the in-memory thumbnails, stale flags, embeddings and EXIF of frames no longer loaded.""" loaded = self.state.uploaded_files keys = {asset_thumbnail_key(f) for f in loaded} hashes = {f["hash"] for f in loaded} @@ -2664,10 +2661,8 @@ def load_file(self, file_path: str, preserve_zoom: bool = False, force_detect: b self._preview_load_t0 = time.perf_counter() keep_preview = preserve_zoom and self._requested_file_path == file_path self._requested_file_path = file_path - # A strip belongs to one frame, and the memo fast path below repaints without - # going through request_render, so drop it here too. Zone pins froze their sample - # from this frame and go the same way. The compare split, the flat peek and marked - # Keystone Lines hold the frame the user is leaving, so they go too. + # The memo fast path below repaints without request_render, so the state tied to the frame + # the user leaves goes here: test strip, zone pins, compare split, flat peek, Keystone Lines. self._clear_test_strip() self._drop_zone_pins() self.exit_compare() @@ -3147,7 +3142,6 @@ def toggle_clipping_overlay(self, force: Optional[bool] = None) -> None: self.clipping_overlay_changed.emit(self.state.clipping_overlay) def toggle_zone_hover_highlight(self, force: Optional[bool] = None) -> None: - """Turns the zone strip's hover highlight on or off, persisted.""" on = (not self.state.zone_hover_highlight) if force is None else bool(force) self.state.zone_hover_highlight = on self.session.repo.save_global_setting("zone_hover_highlight", on) @@ -5230,10 +5224,9 @@ def _card_values(config, card_key: str) -> dict: @staticmethod def _with_card_values(config, card_key: str, values: dict, remeter: bool = True, force_remeter: bool = False): - """*config* with *card_key*'s section carrying *values*. Fields that move the - crop also feed the meter, so a real change to one drops the cached per-frame - bounds with it; re-freezing a card on its own values must not. ``force_remeter`` - is a commit after preview ticks, which already wrote the value it compares with.""" + """*config* with *card_key*'s section carrying *values*. A real change to a meter input drops + the cached bounds; re-freezing a card on its own values must not. ``force_remeter`` is a + commit after preview ticks, which already wrote the value it compares with.""" section, _fields = rolls.ROLL_DEFAULT_FIELDS[card_key] current = getattr(config, section) remeter = remeter and ( @@ -5293,13 +5286,9 @@ def diverged_roll_cards(self) -> List[str]: return [key for key in self._ROLL_CARDS if key in locked] def _lock_roll_card(self, card_key: str) -> None: - """Locks or unlocks *card_key* to match whether the active frame's own - already-applied value actually differs from the roll's -- editing a value and - then editing it back to what the roll already says is not a divergence, so the - card must not stay marked This Frame Only just because it was touched. Shared - tail of set_roll_default and set_process_mode/set_positive_source (the "film" - card). Written in the roll the frame resolves through (rolls.frame_roll), so a search - result's edit holds in its own roll. No-op with no frame, or a frame in no roll.""" + """Lock *card_key* only while the active frame's value differs from the roll's: a value + edited back to the roll's is no divergence. Written in the roll the frame resolves + through (rolls.frame_roll). No-op with no frame, or a frame in no roll.""" if not self.state.current_file_hash: return roll_id = self._active_frame_roll() @@ -5468,8 +5457,7 @@ def set_card_scope(self, card_key: Union[str, tuple], scope: str) -> None: self.set_roll_card_locked(key, True) def sync_auto_crop_lock(self) -> None: - """_lock_roll_card for a write that cleared a hand-drawn crop outside the controller, - such as the Geometry card's reset: the frame now differs from a roll with Auto Crop on.""" + """_lock_roll_card after a write outside the controller cleared a hand-drawn crop.""" self._lock_roll_card("auto_crop") def sync_metadata_card_locks(self) -> None: @@ -6402,10 +6390,9 @@ def _set_roll_sensor_profile(self, roll_id: str, name: str, matrix: tuple) -> No rolls.set_roll_defaults(self.session.repo, roll_id, **wanted) def _on_capture_finished(self, paths: list) -> None: - """Feed the captured frame(s) into NegPy. A 3-file RGB triplet → RGB-Scan negative - (C-41) pipeline; a single-capture RGB frame → an ordinary single RAW, C-41; a single - white-light slide → E-6/positive; a normal white-light camera scan → an ordinary - single RAW (RGB-Scan off, process left to NegPy).""" + """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 → + a single RAW, process left to NegPy.""" self.capture_finished.emit(paths) if not paths: return @@ -6413,9 +6400,8 @@ def _on_capture_finished(self, paths: list) -> None: 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)) - # RGB-Scan (triplet merge) is on only for an actual RGB triplet. Off for a single - # white-light slide, a single-capture RGB frame and a normal camera scan. - # It belongs to the roll of the folder the files land in, not to the open roll. + # RGB-Scan (triplet merge) is on only for an RGB triplet. It belongs to the roll of the + # folder the files land in, not to the open roll. as_roll = bool(getattr(req, "as_roll", False)) folder = os.path.dirname(paths[0]) target_roll = ( @@ -6439,9 +6425,8 @@ def _on_capture_finished(self, paths: list) -> None: capture_frame=capture_frame, ) elif rgb: - # Narrowband RGB exposures, three or one, carry no broadband orange-mask signal - # for the normal classifier. They are negative scans unless capture metadata says - # otherwise, so carry C-41 through discovery instead of guessing from the pixels. + # Narrowband RGB exposures carry no broadband orange-mask signal for the classifier, + # so they import as C-41 unless capture metadata says otherwise. self._pending_capture_imports[_capture_import_key(paths[0])] = _PendingCaptureImport( process_mode=ProcessMode.C41, capture_roll=capture_roll, @@ -6852,12 +6837,9 @@ def toggle_compare(self) -> None: self._request_compare_baseline() def rerender_active_view(self) -> None: - """Re-render the canvas after a rotate or flip, keeping the flat peek and the compare split. - - A plain request_render() would exit the flat peek. The compare split survives a plain - render, and its baseline half re-captures on the key change. A scan peek ends here, as - on every committed rotation. - """ + """Re-render after a rotate or flip. Keeps the flat peek, which a plain request_render() + would exit, and the compare split, whose baseline half re-captures on the key change. + A scan peek ends, as on every committed rotation.""" if self.state.negative_peek or self.state.embedded_peek: self.reset_all_peeks() if self.state.flatfield_peek: @@ -7074,11 +7056,8 @@ def _paint_embedded_peek(self) -> None: self._store_peek_frame(img, splash=True, crop_preview_full=context.crop_preview_full) def _store_peek_frame(self, buffer: np.ndarray, splash: bool, crop_preview_full: bool) -> None: - """Put a painted peek on the canvas, beside the print in last_metrics rather than over it. - - `interactive` is False because a prior Flat Peek render (readback_metrics=False) can - leave the print's flag True, and right_panel then skips the histogram refresh. - """ + """Put a painted peek on the canvas, beside the print in last_metrics. `interactive` is + False: a prior Flat Peek render can leave it True, and right_panel then skips the histogram.""" self.state.peek_frame = { "base_positive": buffer, "content_rect": None, @@ -7131,13 +7110,9 @@ def toggle_embedded_peek(self, force: Optional[bool] = None) -> None: self.request_render() def _paint_flatfield_peek(self) -> None: - """Put the selected Flat Field profile's self-check on the canvas, whatever frame is open. - - The profile's stored reference copy times its gain, each channel over its lit-area - median, ±EVENNESS_RANGE as black..white, so a good profile shows gray. A profile saved - before the copy was stored shows the light its gain corrects instead. Sensor layout: - the frame's geometry is not the reference's. - """ + """Paint the selected profile's reference times its gain, ±EVENNESS_RANGE as black..white: + a good profile shows gray. With no stored reference, show the light its gain corrects. + Sensor layout: the open frame's geometry is not the reference's.""" import cv2 from negpy.features.flatfield.logic import EVENNESS_RANGE, GAIN_VIEW_RANGE, evenness_view, self_corrected @@ -7172,12 +7147,8 @@ def _paint_flatfield_peek(self) -> None: self.set_status(message, 10000) def toggle_flatfield_peek(self, force: Optional[bool] = None) -> None: - """Show how well the selected Flat Field profile corrects its own reference. - - ``force`` sets an explicit state; otherwise toggles. Mutually exclusive with the - other peeks, the before/after compare view and the test strip. With no profile - selected it says so and stays off. - """ + """Show how well the selected Flat Field profile corrects its own reference. Excludes the + other peeks, the compare view and the test strip. With no profile it says so and stays off.""" if self.state.preview_raw is None: return target = (not self.state.flatfield_peek) if force is None else force @@ -7237,9 +7208,7 @@ def _batch_params_for(self, f: dict) -> WorkspaceConfig: half has it enabled and the other's DB entry defaults to 0, propagate the active session value so both frames get identical dye-unmixing during export. """ - # The active file uses the live session config, which may hold unsaved changes the - # user expects in the export. Other files use their saved edit as the canvas resolves - # it, or the session config when they have none. + # Other files use their saved edit as the canvas resolves it, else the session config. if f.get("hash") == self.state.current_file_hash: params = self.state.config elif self.session.repo.load_file_settings(f["hash"]) is not None: @@ -7699,10 +7668,8 @@ def _build_preset_export_tasks(self, files: list[dict], presets: List[ExportPres return tasks def _confirm_unopened_frames(self, files: list[dict]) -> bool: - """A frame with no saved edit exports with the live session settings, while its - filmstrip thumbnail is a quick source-preview inversion, so the file can differ - badly from what the strip shows. The open frame is exempt: its preview is the - export.""" + """A frame with no saved edit exports with the live session settings, but its thumbnail + is a quick inversion, so the two can differ badly. The open frame is exempt.""" hashes = [f["hash"] for f in files if f["hash"] != self.state.current_file_hash] if not hashes: return True @@ -7917,10 +7884,9 @@ def _contact_sheet_roll_name(self, frames: list) -> str: return os.path.basename(os.path.dirname(paths[0])) if paths and paths[0] else "" def _write_edit_sidecars(self, files: list[dict]) -> tuple[int, int, int]: - """Write a .negpy edit sidecar next to each source (each frame's own saved edits). - Returns (written, failed, skipped) — a caller that reports only the written count - turns a read-only source folder into a silent success. A composite or a roll fork - is skipped: the sidecar beside its path belongs to the shared frame.""" + """Write a .negpy edit sidecar next to each source. Returns (written, failed, skipped), so a + read-only folder is no silent success. A composite or a roll fork is skipped: the sidecar + beside its path belongs to the shared frame.""" repo = self.session.repo written = 0 failed = 0 diff --git a/negpy/desktop/main.py b/negpy/desktop/main.py index 732eddd6f..8f4452f10 100644 --- a/negpy/desktop/main.py +++ b/negpy/desktop/main.py @@ -64,11 +64,9 @@ def _filter_qt_messages(mode, context, message: str) -> None: class AppEventFilter(QObject): - """The app-wide event rules, in one filter so each event crosses into Python once. - - Combo and spin boxes never take the wheel; the ignored event goes on to the panel, which - scrolls. Every widget tooltip wraps (plain text never does in Qt), and an icon-only button - takes its tooltip's first clause as its accessible name, the one name a screen reader has.""" + """The app-wide event rules, in one filter so each event crosses into Python once. Combo and + spin boxes never take the wheel, every tooltip wraps (Qt never wraps plain text), and an + icon-only button takes its tooltip's first clause as its accessible name.""" def eventFilter(self, obj, event) -> bool: # noqa: N802 kind = event.type() @@ -342,9 +340,8 @@ def main() -> None: exit_code = app.exec() controller.cleanup() repo.save_global_setting("clean_shutdown", True) - # PyQt's at-exit cleanup deletes each Python-owned top-level QObject while it walks - # sip's wrapper map, and Python code that a destructor runs (event filters, the - # proxy style) can reallocate the map under the walk. Widgets run that code. + # PyQt's at-exit cleanup walks sip's wrapper map while it deletes these, and the Python + # code their destructors run (event filters, the proxy style) can reallocate the map. for widget in [w for w in app.topLevelWidgets() if w.parent() is None and sip.ispyowned(w)]: sip.delete(widget) sys.exit(exit_code) diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index 79f28e20f..39c54c9b0 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -232,7 +232,7 @@ class AppState: # zone strip's hover lights up. Display-only, session-only. clipping_overlay: bool = False zone_highlight: Optional[int] = None - # Whether hovering the zone strip lights up its zone on the frame; a persisted preference. + # A persisted preference. zone_hover_highlight: bool = True # Grain focuser: a near-1:1 loupe following the cursor. Display-only, session-only. @@ -312,9 +312,8 @@ class AppState: embedded_peek: bool = False # Transient: preview is showing the selected Flat Field profile's self-check. flatfield_peek: bool = False - # The canvas keys a painted peek (negative, embedded, Check Flat Field) put on screen. Kept - # out of last_metrics, which holds the print for the thumbnail, the memo and every - # measurement. It stays until the print is painted again, not until the peek flag drops. + # A painted peek's canvas keys (negative, embedded, Check Flat Field), apart from last_metrics, + # which holds the print. Stays until the print is painted again, not until the peek flag drops. peek_frame: Optional[Dict[str, Any]] = None # Linear Output: export the loader's raw decoded buffer as an untagged 16-bit TIFF. @@ -1541,10 +1540,9 @@ def apply_preset_fields( finish: Optional[Callable[[WorkspaceConfig], WorkspaceConfig]] = None, label: str = "Preset applied", ) -> int: - """Overlay a preset's chosen rows onto the current frame, the selection, or - the whole (visible) roll. Unlike sync_selected_settings the source is the - preset itself, so the active frame is a target too. `finish` runs on each - overlaid config, for fields outside the catalog. Returns frames changed.""" + """Overlay a preset's rows onto the current frame, the selection or the visible roll; the + active frame is a target too. `finish` runs on each overlaid config, for fields outside + the catalog. Returns frames changed.""" rows = list(rows) if not (rows or finish) or self.state.selected_file_idx == -1: return 0 @@ -1755,12 +1753,8 @@ def update_config(self, config: WorkspaceConfig, persist: bool = False, render: self.state_changed.emit() def persist_active_batch_config(self, config: WorkspaceConfig) -> None: - """Persist Auto-crop all frames before exposing it as active in-memory state. - - Non-active Auto-crop all frames results are written directly. This companion path - preserves that behavior while ensuring a storage error cannot leave an - unrendered crop live in memory. - """ + """Persist the active frame's Auto-crop all frames result before it goes live in memory, + so a storage error never leaves an unsaved crop live.""" if not self.state.current_file_hash: raise RuntimeError("Cannot persist batch settings without an active file") self.repo.save_file_settings( @@ -1790,11 +1784,9 @@ def push_external_history(self, file_hash: str, old_config: WorkspaceConfig, new self.state.stale_thumbnails.add(asset_thumbnail_key(asset)) def _relock_diverged_cards(self) -> None: - """Lock each roll card on which the active frame's restored config differs from the - roll's defaults. History, a work print and a paste restore a config but not its - locks, and an unlocked card takes the roll's values on the next load. Never unlocks: - a card pinned at the roll's own value stays pinned. The one exception is Auto Crop on - a hand-drawn frame, which is never locked (rolls.holds_own_crop).""" + """Lock each roll card on which the restored config differs from the roll's: history, a work + print and a paste restore no locks, and an unlocked card takes the roll's values on load. + Never unlocks, except Auto Crop on a hand-drawn frame (rolls.holds_own_crop).""" idx = self.state.selected_file_idx if not self.state.current_file_hash or not (0 <= idx < len(self.state.uploaded_files)): return @@ -2019,14 +2011,9 @@ def copy_settings_with_bounds(self) -> None: self.copy_settings(include_bounds=True) def apply_pasted_fields(self, rows, include_bounds: bool = True) -> None: - """Overlay the picked clipboard settings onto the active frame, or onto every - selected frame when the selection holds more than one. - - The per-frame bounds ride along when the clipboard holds them (only a copy - with bounds does; copy_settings strips them otherwise) and the paste picker - keeps its bounds row ticked. They are written after the rows because a - pasted bounds-input field invalidates them. - """ + """Overlay the picked clipboard settings onto the active frame, or onto each selected frame. + The per-frame bounds come along when the clipboard holds them and their row is ticked, + written after the rows because a pasted bounds-input field invalidates them.""" rows = list(rows) clip = self.state.clipboard if clip is None or not self.state.current_file_hash: diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py index b3e59a9b6..5104d2558 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -364,9 +364,8 @@ def rows_for_section(section: str) -> list[SettingRow]: return [r for r in all_rows() if r.section == section] -# Exposure field partitions: the Filtration, Tone and Paper Response sections split -# ExposureConfig, for both per-section modified counts and scoped resets. render_intent is -# in none, since it is flat-master output, nor is cast_removal_strength, the Calibration card's. +# Filtration, Tone and Paper Response split ExposureConfig for modified counts and scoped resets. +# render_intent (flat-master output) and cast_removal_strength (the Calibration card's) are in none. COLOR_FIELDS = ( "wb_cyan", "wb_magenta", diff --git a/negpy/desktop/sticky.py b/negpy/desktop/sticky.py index 647ceca4e..08ef76b45 100644 --- a/negpy/desktop/sticky.py +++ b/negpy/desktop/sticky.py @@ -162,8 +162,8 @@ def migrate_legacy_export_destination(repo: IRepository) -> None: def migrate_auto_crop_sticky_row(repo: IRepository) -> None: - """Add Auto Crop to a saved row choice, once. It became a default carried row; a user - who never chose rows gets it from DEFAULT_STICKY_IDS, and an untick after this stays.""" + """Add Auto Crop to a saved row choice, once. A user who never chose rows gets it from + DEFAULT_STICKY_IDS, and an untick after this stays.""" if repo.get_global_setting(_AUTO_CROP_ROW_MIGRATED_KEY): return repo.save_global_setting(_AUTO_CROP_ROW_MIGRATED_KEY, True) diff --git a/negpy/desktop/view/canvas/gpu_widget.py b/negpy/desktop/view/canvas/gpu_widget.py index 819b0d235..6138097c8 100644 --- a/negpy/desktop/view/canvas/gpu_widget.py +++ b/negpy/desktop/view/canvas/gpu_widget.py @@ -226,9 +226,8 @@ def resizeEvent(self, event) -> None: def event(self, event) -> bool: if event.type() == QEvent.Type.DevicePixelRatioChange: - # A move to a screen with another scale factor delivers no resize, and - # only the render widget's resizeEvent recomputes its physical size - # (it ignores its event argument): re-run it, then reconfigure debounced. + # A move to a screen with another scale factor delivers no resize, and only the render + # widget's resizeEvent (which ignores its argument) recomputes its physical size. subwidget = getattr(self.canvas, "_subwidget", None) if subwidget is not None: subwidget.resizeEvent(None) diff --git a/negpy/desktop/view/canvas/toolbar.py b/negpy/desktop/view/canvas/toolbar.py index 263eb6bee..313aabfc4 100644 --- a/negpy/desktop/view/canvas/toolbar.py +++ b/negpy/desktop/view/canvas/toolbar.py @@ -1205,7 +1205,7 @@ def open_toolbar_editor(self) -> None: self._item_ids, title="Edit Toolbar", chosen_header="TOOLBAR", - hint="Drag to reorder. Whatever does not fit the canvas width hides, geometry first and navigation last. The ⋯ menu always holds every action, whichever ones the row shows.", + hint="Drag to reorder. What does not fit the canvas width hides, geometry first and navigation last; the ⋯ menu holds every action.", defaults=list(DEFAULT_TOOLBAR_IDS), repo=self.session.repo, ) @@ -1222,10 +1222,8 @@ def open_toolbar_editor(self) -> None: self.set_available_width(self._available_width or self.width()) def set_available_width(self, w: int) -> None: - """Show as many of the chosen controls as fit the canvas width. - - The overflow menu is not touched here — it always carries the full action set (see - _init_ui), so a control leaving the row never changes what the menu contains.""" + """Show as many of the chosen controls as fit the canvas width. The overflow menu always + carries the full action set (see _init_ui), so the menu does not follow the row.""" self._available_width = w budget = self._toolbar_width_budget(w) diff --git a/negpy/desktop/view/canvas/widget.py b/negpy/desktop/view/canvas/widget.py index 3a343a3de..8ba278636 100644 --- a/negpy/desktop/view/canvas/widget.py +++ b/negpy/desktop/view/canvas/widget.py @@ -543,7 +543,7 @@ def _try_pinch_gesture(self, ev: QGestureEvent) -> bool: self._pinch_brush_step(k) ev.setAccepted(g, True) return True - # A pinch reports its centre in screen coordinates. + # A pinch reports its center in screen coordinates. anchor = self.mapFromGlobal(g.centerPoint()) if self._apply_scale_at(k, anchor): ev.setAccepted(g, True) diff --git a/negpy/desktop/view/sidebar/altprocess.py b/negpy/desktop/view/sidebar/altprocess.py index 2fee15da0..5235900d3 100644 --- a/negpy/desktop/view/sidebar/altprocess.py +++ b/negpy/desktop/view/sidebar/altprocess.py @@ -14,12 +14,9 @@ class AltProcessSidebar(BaseSidebar): - """ - Alternative printing processes, one at a time: lith-developed, a cyanotype or a - Sabattier print, never two. Lith's paper comes from the Exposure panel; the - cyanotype is on rag paper and takes its color from the sensitizer; a Sabattier - print is plain silver. - """ + """Alternative printing processes, one at a time: lith, cyanotype or Sabattier. Lith's paper + comes from the Exposure panel, the cyanotype takes its color from the sensitizer, and a + Sabattier print is plain silver.""" def _init_ui(self) -> None: conf = self.state.config.altproc diff --git a/negpy/desktop/view/sidebar/calibration_window.py b/negpy/desktop/view/sidebar/calibration_window.py index b055dd0b4..548c0796c 100644 --- a/negpy/desktop/view/sidebar/calibration_window.py +++ b/negpy/desktop/view/sidebar/calibration_window.py @@ -141,9 +141,9 @@ def wants_sensor_profile(self) -> bool: return self.capture_btn.currentIndex() == 1 and self._sensor_profile_wanted def set_inputs_locked(self, locked: bool) -> None: - """Freeze the calibration inputs while a run is in progress: the film-stock name, the capture mode, the Create Sensor Profile toggle, the base - ROI (clicking the image must not move the patch being metered), and the ISO/aperture the - base is metered at. Re-enabled at any terminal outcome so a failed run can be retried.""" + """Freeze the calibration inputs while a run is in progress: film-stock name, capture mode, + Create Sensor Profile toggle, base ROI (a click must not move the patch being metered) and the + base ISO/aperture. Re-enabled at any terminal outcome so a failed run can be retried.""" self._running = locked self.name_edit.setEnabled(not locked) self.capture_btn.setEnabled(not locked) diff --git a/negpy/desktop/view/sidebar/live_view_window.py b/negpy/desktop/view/sidebar/live_view_window.py index a689c8817..abd974198 100644 --- a/negpy/desktop/view/sidebar/live_view_window.py +++ b/negpy/desktop/view/sidebar/live_view_window.py @@ -1,11 +1,7 @@ -"""Large pop-out window for the Scanlight live view. - -Hosts a `RoiImageLabel` plus an inline toolbar (Scan, Retake, Meter and the histogram and -zebra toggles), a capture progress bar and a status line, so a whole roll can be framed, -focused, metered and scanned without switching back to the side panel. The live image -carries a magnifier cursor: a click aims the camera focus magnifier at that spot, a -double-click returns to full frame. The buttons emit signals; `ScanlightSidebar` wires them -and mirrors scanning state + status. +"""Pop-out window for the Scanlight live view: frame, focus, meter and scan a whole roll here. + +A click aims the camera focus magnifier at that spot, a double-click returns to full frame. +The buttons emit signals; `ScanlightSidebar` wires them and mirrors scanning state and status. """ import time diff --git a/negpy/desktop/view/sidebar/paper.py b/negpy/desktop/view/sidebar/paper.py index 6c4c07cec..9139de98a 100644 --- a/negpy/desktop/view/sidebar/paper.py +++ b/negpy/desktop/view/sidebar/paper.py @@ -69,8 +69,7 @@ def _init_ui(self) -> None: self.paper_label = paper_row.itemAt(0).widget() self.layout.addLayout(paper_row) - # Density-domain saturation, composed into the same dye_mix slot as the paper's real dye - # crosstalk, rather than a post-hoc Lab-space a*/b* + # Density-domain saturation in the same dye_mix slot as the paper's real dye crosstalk, not a Lab a*/b* step. self.dye_separation_slider = CompactSlider("Dye Separation", 0.5, 1.5, conf.dye_separation, has_neutral=True) self.dye_separation_trim_slider = CompactSlider("Dye Separation", -0.4, 0.4, 0.0, has_neutral=True) self.dye_separation_trim_slider.setToolTip( @@ -113,7 +112,7 @@ def _init_ui(self) -> None: self.layout.addStretch() - # Global-only controls, greyed while a channel page is active. + # Global-only controls, grayed while a channel page is active. self._global_only = (self.paper_dmin_btn, self.paper_black_btn, self.paper_combo) def _channel_index(self) -> int: @@ -269,8 +268,8 @@ def sync_ui(self) -> None: self.sh_w_slider.setValue(conf.shoulder_width) self.dye_separation_slider.setValue(conf.dye_separation) self.separation_damping_slider.setValue(conf.separation_damping) - # It redistributes Dye Separation's push and does nothing on its own, so at 1.0 - # separation on every channel it is dead — a per-channel trim also arms it. + # It redistributes Dye Separation's push and does nothing on its own, so it is dead at 1.0 + # separation on every channel. A per-channel trim also arms it. sep_k3 = per_channel_dye_separation( conf.dye_separation, (conf.dye_separation_trim_red, conf.dye_separation_trim_green, conf.dye_separation_trim_blue), diff --git a/negpy/desktop/view/sidebar/right_panel.py b/negpy/desktop/view/sidebar/right_panel.py index ab8bc7be8..cd84441f1 100644 --- a/negpy/desktop/view/sidebar/right_panel.py +++ b/negpy/desktop/view/sidebar/right_panel.py @@ -37,8 +37,6 @@ from negpy.desktop.view.widgets.stats import DensitometerRow, NegativeStatsWidget, ZonePlacementRows from negpy.desktop.view.widgets.overflow_bar import OverflowBar -# ControlsPanel sections built into the Roll tab (_build_roll_page), not a Frame sub-tab -- -# reveal_section routes these to the Roll group instead of Frame's inner tab switcher. # The Roll tab's cards that own settings, for its header's Apply picker. _ROLL_TAB_CARDS = ( "film", @@ -53,6 +51,7 @@ "flatfield", ) +# ControlsPanel sections built into the Roll tab (_build_roll_page): reveal_section routes them to the Roll group. _ROLL_SECTION_ATTRS = frozenset( { "assembly_section", diff --git a/negpy/desktop/view/sidebar/scanlight.py b/negpy/desktop/view/sidebar/scanlight.py index 848ddcac3..c789d8327 100644 --- a/negpy/desktop/view/sidebar/scanlight.py +++ b/negpy/desktop/view/sidebar/scanlight.py @@ -486,11 +486,8 @@ def _push_light(self) -> None: # white LED an RGB-only body lacks. White stays off: the Scanlight cannot mix both. r, g, b = (int(slider.value()) for slider in (self.r_slider, self.g_slider, self.b_slider)) if not self._manual_mode and self._preset_selected() and not self._settings.single_capture: - # A stored triplet preset frames by its own mix, but dimmed (issue #573): all three - # channels burn at once against a single-channel scan exposure, so full levels - # blow the live view out. The scan never reads this, since capture levels come - # from the preset. Manual building keeps full levels for the operator, and a - # single-capture preset frames under its own scan light. + # A triplet preset frames by its own mix, dimmed: all three channels burn at once + # against a single-channel exposure. The scan takes its levels from the preset. r, g, b = framing_levels(r, g, b) self.controller.set_scanlight_color(r, g, b, 0, self._settings.port) self._update_settings_from_ui() @@ -798,10 +795,9 @@ def _on_camera_setting_applied(self, _which: str) -> None: self._apply_gating() def _available_shutters(self, info: dict | None = None, scanlight: bool = True) -> tuple[str, ...]: - """The camera's writable shutter labels (from the live-view settings JSON, or `info`, - that JSON's shutter entry) as the ladder calibration solves on, so it solves on this - body's own speeds. `scanlight` keeps it inside the span a PWM-lit capture meters on; - without the Scanlight it is the body's whole ladder.""" + """The body's writable shutter labels (from the live-view settings JSON, or `info`, its + shutter entry): the ladder calibration solves on. `scanlight` keeps it inside the span a + PWM-lit capture meters on; without it, the body's whole ladder.""" if info is None: info = self._settings_json().get("shutter") or {} if not info.get("writable", True): # absent = unknown, so only an explicit read-only blocks @@ -1146,10 +1142,9 @@ def _exposure_key(self, shutter: object = None) -> tuple: ) def _current_raw_clip(self, rgb: np.ndarray) -> "_RawClip | None": - """The Meter probe's RAW clipping, while it still describes the frame on screen: the same - exposure and the same picture. The picture is the first preview at the metered exposure, - compared by rank correlation, so a film advance, a reframe or the focus magnifier - hands the zebras back to the preview; a brighter preview of the same frame does not.""" + """The Meter probe's RAW clipping, while the frame on screen has the same exposure and the + same picture (rank correlation against the first preview at the metered exposure). A film + advance, reframe or magnifier hands the zebras back to the preview; a brighter one does not.""" clip = self._raw_clip if clip is None or self._magnifier_on: return None @@ -1420,7 +1415,6 @@ def _on_calibration_finished(self, result) -> None: profile_note = " and its sensor profile" else: profile_note = ", with no sensor profile: the probe exposures were too dim to measure one" - # persist + reload + select + re-gate (bakes settings.iso/aperture) self._save_current_as_preset(name, sensor_profile) self._lv_target = self.lv_image self.calib_window.hide() @@ -1568,9 +1562,8 @@ def _start_capture(self, retake: bool) -> None: ) self.set_scanning(True) if rgb and not req.white_mode and not single: - # Triplet progress arrives only after each channel, so show the bar at 0% right away - # and the click has immediate feedback. Single-exposure captures emit no progress - # events, so a bar there would sit at 0%. Skip it. + # Triplet progress arrives only after each channel, so the bar shows at 0% at once. + # A single-exposure capture emits no progress, so it gets no bar. self.lv_window.set_progress(0.0) self.controller.start_capture(req) diff --git a/negpy/desktop/view/sidebar/tone.py b/negpy/desktop/view/sidebar/tone.py index 52175f18d..e4f776502 100644 --- a/negpy/desktop/view/sidebar/tone.py +++ b/negpy/desktop/view/sidebar/tone.py @@ -152,8 +152,8 @@ def _init_ui(self) -> None: self.diffusion_slider = CompactSlider("Diffusion", 0.0, 1.0, conf.diffusion) self.diffusion_slider.setToolTip( "Diffusion: a diffuser under the enlarger lens, a Softar or a stocking. The value is how much " - "light it scatters: a stronger diffuser, or more of the exposure given through it. Shadows " - "bleed into highlights and edges soften; flat areas print as before. 0 is off." + "light it scatters. Shadows bleed into highlights and edges soften; flat areas print as before. " + "0 is off." ) self.layout.addWidget(self.preflash_slider) self.layout.addWidget(self.diffusion_slider) diff --git a/negpy/desktop/view/sidebar/toning.py b/negpy/desktop/view/sidebar/toning.py index 760bca02e..51587af6b 100644 --- a/negpy/desktop/view/sidebar/toning.py +++ b/negpy/desktop/view/sidebar/toning.py @@ -152,10 +152,8 @@ def sync_ui(self) -> None: self.copper_slider.setVisible(is_bw) self.vanadium_slider.setVisible(is_bw) - # Only selenium and gold do anything distinctive on a lith print, and a cyanotype holds no - # silver for any bath to react with; a Sabattier print is plain silver. Disabled rather than - # hidden, so the values stay live and come back afterwards. Tooltips stay put: - # apply_shortcut_tooltips owns some of these. + # Only selenium and gold act on a lith print, and a cyanotype holds no silver. Disabled, + # not hidden, so the values come back; apply_shortcut_tooltips owns some tooltips. alt = self.state.config.altproc.alt_process if is_bw else AltProcess.NONE cyano_on = alt == AltProcess.CYANOTYPE for w in (self.sepia_slider, self.blue_slider, self.copper_slider, self.vanadium_slider): diff --git a/negpy/desktop/view/styles/color_vision.py b/negpy/desktop/view/styles/color_vision.py index 7ce10908c..29d0f6a13 100644 --- a/negpy/desktop/view/styles/color_vision.py +++ b/negpy/desktop/view/styles/color_vision.py @@ -17,9 +17,8 @@ class VisionPalette: spot: str # one mark over the picture itself, clear of foliage greens and sky blues -# Standard's pair is neon: a mark has to read over any film. The color-blind colors are -# Okabe–Ito (Color Universal Design) colors on an axis that vision keeps. A pair keeps clear of -# the amber exclusion band on the same overlay, and every color reads as text on the dark panel. +# Standard is neon to read over any film; the others are Okabe-Ito colors on an axis that vision +# keeps. A pair keeps clear of the amber exclusion band, and every color reads as text on the panel. PALETTES: tuple[VisionPalette, ...] = ( VisionPalette("standard", "Standard", ("#39FF14", "#FF00FF"), (THEME.dodge, THEME.burn), "#FF00FF"), VisionPalette("protan_deutan", "Protanopia / deuteranopia (red-green)", ("#56B4E9", "#D55E00"), ("#E69F00", "#56B4E9"), "#CC79A7"), diff --git a/negpy/desktop/view/widgets/charts.py b/negpy/desktop/view/widgets/charts.py index 593d37d9b..851d4c423 100644 --- a/negpy/desktop/view/widgets/charts.py +++ b/negpy/desktop/view/widgets/charts.py @@ -37,11 +37,8 @@ def _draw_hover_ring(painter: QPainter, at: QPointF) -> None: class PhotometricCurveWidget(QWidget): - """ - The Analysis chart: H&D curve over one bottom band holding the negative's density - histogram and the print's RGB+L histogram, plus pivot, zone shading/ticks, clip marks, - hover marker + curve dot. - """ + """The Analysis chart: H&D curve over a bottom band that holds the negative's density + histogram and the print's RGB+L histogram.""" # Data coordinate ranges _X_MIN, _X_MAX = -0.1, 1.1 # plt_x domain @@ -699,9 +696,9 @@ def _draw_zone_shading( class WaveformWidget(QWidget): - """Print luma across the frame: x is the image column, y the print tone, brightness how - many pixels sit there, each cell drawn in its pixels' mean color. Zone lines III, V and VII on the zone ruler; columns that reach - paper black or paper white are marked on the bottom and top edges.""" + """Print luma across the frame: x is the image column, y the print tone, brightness the pixel + count, each cell in its pixels' mean color. Columns that reach paper black or paper white are + marked on the bottom and top edges.""" _GUIDE_ZONES = ((3, "III"), (5, "V"), (7, "VII")) @@ -806,10 +803,9 @@ def paintEvent(self, event) -> None: class VectorscopeWidget(QWidget): - """The print's CIELAB a*b* plane: +a right, +b up, neutral at the center cross. Brightness - is how many pixels sit at a hue and chroma, each drawn in its own hue. Dashed rings mark - chroma 5, 20 and 40; rim ticks mark the working-space primaries and secondaries. The log - chroma scale opens up the near-neutral center, where a toner or a cast lives.""" + """The print's CIELAB a*b* plane, +a right and +b up; brightness is the pixel count at a hue + and chroma. Rim ticks mark the working-space primaries and secondaries. The log chroma scale + opens up the near-neutral center, where a toner or a cast lives.""" _SIZE = 160 _RINGS = (5.0, 20.0, 40.0) @@ -948,16 +944,9 @@ def paintEvent(self, event) -> None: class ZoneStripWidget(QWidget): - """ - 10-cell print-zone occupancy strip (0–IX): cell tone = zone brightness, - opacity = occupancy (√-scaled so small-but-real mass reads). Extreme cells - tint red on blocked shadows / blown highlights. - - Also the zone-placement control: clicking a cell asks for that zone, and the - next click on the photo places a tone there. A double-click toggles the canvas - Zone Overlay instead, so a single click waits out the double-click interval. - Hovering a cell names it (zone_hovered), and leaving names None. - """ + """Print-zone occupancy strip (0 to IX), opacity square-root scaled so a small mass reads. + A cell click asks for that zone and the next photo click places a tone there. A double-click + toggles the Zone Overlay, so a single click waits out the double-click interval.""" zone_clicked = pyqtSignal(int) zone_double_clicked = pyqtSignal() @@ -1092,13 +1081,9 @@ def paintEvent(self, event) -> None: class StepWedgeWidget(QWidget): - """ - Stouffer T2115-style 21-step transmission wedge printed through the frame's current - print settings: the curve the chart above plots, then the alternative process and toning. - Steps run paper black (left) to paper white (right), matching the chart's x axis. The - usable span is bracketed where neighbouring steps still separate, and labels are wedge - density in this scan's own D units. - """ + """21-step transmission wedge printed through the frame's curve, alternative process and + toning, paper black left to paper white right. The usable span is bracketed where neighboring + steps still separate; labels are wedge density in this scan's own D units.""" # Label every tenth step, so a sidebar-width strip still gets an axis. At sidebar width # the 21 patches are far too narrow to label individually. @@ -1115,11 +1100,9 @@ def __init__(self, parent=None): self._colors: list = [] def update_data(self, enc: Any, step_density: float, color_space: str, monitor_icc_bytes: Any = None, proof: Any = None) -> None: - """`enc` is the 21 display-encoded patches, gray (21,) or toned (21, 3). The patches go - through the same display transform the canvas used — including the soft proof, which - rides the display LUT rather than being baked into the buffer — so wedge and frame can't - disagree. The usable span and label contrast read the patches' luminance. - """ + """`enc` is the 21 display-encoded patches, gray (21,) or toned (21, 3). They go through the + canvas's display transform, soft proof included (it rides the display LUT, not the buffer), + so wedge and frame agree. The usable span and label contrast read the patches' luminance.""" from negpy.desktop.converters import ImageConverter from negpy.features.lab.logic import LUM_B, LUM_G, LUM_R from negpy.kernel.image.logic import working_oetf_decode, working_oetf_encode diff --git a/negpy/desktop/view/widgets/sensor_calibration_dialog.py b/negpy/desktop/view/widgets/sensor_calibration_dialog.py index ed6309b9f..227d72ab9 100644 --- a/negpy/desktop/view/widgets/sensor_calibration_dialog.py +++ b/negpy/desktop/view/widgets/sensor_calibration_dialog.py @@ -1,9 +1,7 @@ """Calibrate the sensor-crosstalk matrix from three bare-light exposures. -Red-only / green-only / blue-only captures (no film in the holder, same light -and camera settings as scanning) measure the sensor's response to each band; -the inverted mix is saved as a named sensor profile the Calibration panel selects. -The exposures are picked as files, or shot by the tethered camera under the Scanlight. +Red, green and blue captures with no film, at the scanning light and camera settings, measure +each band; the inverted mix is saved as a named sensor profile. Files, or a tethered capture. """ import numpy as np diff --git a/negpy/desktop/view/widgets/slippy_map.py b/negpy/desktop/view/widgets/slippy_map.py index 8cffb3a5b..d02d8f014 100644 --- a/negpy/desktop/view/widgets/slippy_map.py +++ b/negpy/desktop/view/widgets/slippy_map.py @@ -20,7 +20,7 @@ # Panning requests tiles faster than they arrive. Cap the requests so stale ones cannot pile up. _MAX_PENDING_TILES = 24 # Fetches run on daemon threads that nothing joins: a lookup stalled in DNS can outlive the -# view and the app, and a join on it froze the GUI with the GIL held. +# view and the app, and a join on it would freeze the GUI with the GIL held. _TILE_SLOTS = threading.Semaphore(4) # One mouse notch is 120 units. A trackpad sends many smaller deltas, so the wheel must sum # to a notch instead of taking a zoom level per event. diff --git a/negpy/desktop/view/widgets/strip_preview_dialog.py b/negpy/desktop/view/widgets/strip_preview_dialog.py index 2b98aa989..3b912d612 100644 --- a/negpy/desktop/view/widgets/strip_preview_dialog.py +++ b/negpy/desktop/view/widgets/strip_preview_dialog.py @@ -52,9 +52,8 @@ _GRID_MARGIN = 36 # dialog width the strip grid does not get: frame, scrollbar, padding _TILE_SLIDER_H = 18 # the per-frame offset slider under each tile _TILES_PER_ROW = 6 # columns assumed before the grid has a width to measure -# A transport that measures the strip reports its frame count only as previews arrive, so ask -# for a roll's worth and keep the tiles it answers with. Half frames on a 40-exposure roll are 80. -# ponytail: fixed ceiling; ask the session for its measured count if a longer strip appears. +# A measuring transport reports its frame count only as previews arrive, so ask for a roll's +# worth (80 half frames on a 40-exposure roll) and keep the tiles it answers with. _DISCOVERY_SLOTS = 100 # Pause after an offset moves before its tiles are re-cut, so one drag makes one request. _RECUT_DELAY_MS = 250 diff --git a/negpy/desktop/workers/export.py b/negpy/desktop/workers/export.py index a701a29c4..1358b0f4f 100644 --- a/negpy/desktop/workers/export.py +++ b/negpy/desktop/workers/export.py @@ -171,10 +171,8 @@ def resolve_export_dir(task: ExportTask) -> str: def _looks_border_crushed(buffer, task: "ExportTask") -> bool: - """An uncropped scan whose bright holder border drove normalization renders as a - near-black print inside a black frame edge. Judged on the rendered positive: - no crop set, black border ring, and an inner region far darker than any - plausible print.""" + """An uncropped scan whose bright holder border drove normalization: on the rendered + positive, a black border ring around an inner region far darker than any plausible print.""" if task.params.geometry.crop_rect is not None: return False arr = buffer[:: max(1, buffer.shape[0] // 512), :: max(1, buffer.shape[1] // 512)] @@ -301,11 +299,9 @@ def arm(self) -> None: @pyqtSlot(list) def run_batch(self, tasks: List[ExportTask]) -> None: - """Processes an ordered list of export tasks, pipelined: the prefetcher - prepares the next source and the finisher encodes+writes the previous - render while the current one renders. One finish in flight, so at most - two full-res buffers are held. A failed file never stops the batch, and every - exit emits `finished` or `cancelled`: that releases the batch lane.""" + """Export the tasks pipelined: the next source prepares and the previous render encodes + while the current one renders, so at most two full-res buffers are held. A failed file + never stops the batch, and every exit emits `finished` or `cancelled` to free the lane.""" self._written = set() total = len(tasks) finisher = ThreadPoolExecutor(max_workers=1) diff --git a/negpy/desktop/workers/library.py b/negpy/desktop/workers/library.py index 1f6e56d71..760fdd2c2 100644 --- a/negpy/desktop/workers/library.py +++ b/negpy/desktop/workers/library.py @@ -23,7 +23,7 @@ class LibrarySearchTask: roots: list[str] query: str - # Read on the worker thread: parsing every saved edit takes seconds on a large library. + # Read on the worker thread: parsing every saved edit is slow on a large library. load_configs: Callable[[], dict[str, Any]] = dict load_marks: Callable[[], dict[str, str]] = dict rewalk: bool = False # drop the cached traversal first (folders changed on disk) diff --git a/negpy/domain/migrations.py b/negpy/domain/migrations.py index daff771bf..79b09b31e 100644 --- a/negpy/domain/migrations.py +++ b/negpy/domain/migrations.py @@ -101,10 +101,8 @@ # 16-bit master, so a saved DNG preset keeps its bit depth instead of 8-bit JPEG. RETIRED_EXPORT_FORMATS: Dict[str, str] = {"DNG": "TIFF"} -# Renamed crosstalk profile display names, old -> current. The Portra entries follow -# Kodak's 2026 rebrand of the line to Ektacolor Pro; the datasheets were not re-issued, -# so the old name stays in the display name. Applied in ProcessConfig.__post_init__, -# not here: roll defaults overlay the field raw, outside any flat-config load. +# Crosstalk profile display names, old -> current; Portra stays in the name, its datasheets were not re-issued. +# Applied in ProcessConfig.__post_init__, not migrate_flat_config: roll defaults overlay the field raw. CROSSTALK_PROFILE_RENAMES: Dict[str, str] = { "Default": "Generic C41", "Kodak Portra 160 (approx)": "Kodak Ektacolor Pro 160 / Portra 160 (approx)", diff --git a/negpy/domain/models.py b/negpy/domain/models.py index a48b00314..1a1a20038 100644 --- a/negpy/domain/models.py +++ b/negpy/domain/models.py @@ -468,10 +468,8 @@ def _build_local(d: Dict[str, Any]) -> LocalAdjustmentsConfig: def _build_mask(m: Dict[str, Any]) -> LocalMask: verts = tuple(tuple(v) for v in m.get("vertices", [])) - # A mask's exposure was brightness-signed (`strength`, positive = dodge) before it became - # exposure-signed stops (positive = burn), the same flip vignette_strength made. Nested - # in local_masks, so it cannot live in MIGRATIONS' flat rewrites. Keyed on the legacy - # name, which only a pre-flip save carries. + # Legacy `strength` is brightness-signed (positive = dodge), `stops` exposure-signed + # (positive = burn). Nested in local_masks, so no flat migration can rewrite it. stops = -float(m["strength"]) if "strength" in m else float(m.get("stops", 0.0)) return LocalMask( vertices=verts, diff --git a/negpy/features/altprocess/models.py b/negpy/features/altprocess/models.py index 4354851b3..9d5fa4774 100644 --- a/negpy/features/altprocess/models.py +++ b/negpy/features/altprocess/models.py @@ -19,15 +19,9 @@ class Sensitizer(StrEnum): @dataclass(frozen=True) class AltProcessConfig: - """ - The Alternative Processes panel. One config for every process because they - are mutually exclusive — you cannot lith-develop a cyanotype — so the state - is one enum rather than booleans that could all be set. - - Lith takes its color from the Exposure panel's paper profile; cyanotype is - on rag paper and takes its color from the sensitizer; a Sabattier print is - neutral silver. - """ + """The Alternative Processes panel. The processes are mutually exclusive, so the state + is one enum. Lith takes its color from the paper profile, cyanotype from the sensitizer; + a Sabattier print is neutral silver.""" alt_process: AltProcess = AltProcess.NONE lith_exposure: float = 2.0 @@ -38,9 +32,8 @@ class AltProcessConfig: cyano_scale: float = 1.4 cyano_bleach: float = 0.0 cyano_tannin: float = 0.0 - # Sabattier: how far the light tones reverse (1 flattens them to the fold, above 1 they - # reverse), where the fold sits (a fraction of the paper's Dmax) and the tray's agitation - # after the flash (0 still, wide Mackie lines; 1 constant, none). + # Sabattier: strength 1 flattens the light tones to the fold, above 1 reverses them; the fold + # sits at reexposure x paper Dmax; agitation 0 draws wide Mackie lines, 1 none. sabattier_strength: float = 1.3 sabattier_reexposure: float = 0.45 sabattier_agitation: float = 0.7 diff --git a/negpy/features/exposure/analysis.py b/negpy/features/exposure/analysis.py index 17926acb3..7b1302069 100644 --- a/negpy/features/exposure/analysis.py +++ b/negpy/features/exposure/analysis.py @@ -21,21 +21,19 @@ # which is what the marginal RGBL histogram cannot answer. COLOR_HIST_BINS = 32 -# Waveform: print luma per image column, (bins, cols) with row 0 = black. Mirrored as the -# array length and constants in waveform.wgsl. +# Waveform: print luma per image column, (bins, cols) with row 0 = black. waveform.wgsl mirrors this. WAVEFORM_COLS = 256 WAVEFORM_BINS = 128 # Per-cell R, G, B sums are fixed point (u32 atomics on the GPU): encoded value x this scale. WAVEFORM_COLOR_SCALE = 1023 -# Vectorscope: the print's CIELAB a*b* in polar bins, hue x chroma, with chroma on a log -# axis so the linear and the log display both read one histogram. The last chroma bin holds -# everything past SCOPE_MAX_CHROMA. Mirrored in vectorscope.wgsl. +# Vectorscope: CIELAB a*b* in hue x log-chroma bins, so linear and log displays read one +# histogram; the last chroma bin holds all past SCOPE_MAX_CHROMA. vectorscope.wgsl mirrors this. SCOPE_HUE_BINS = 180 SCOPE_CHROMA_BINS = 64 SCOPE_MAX_CHROMA = 60.0 SCOPE_LOG_KNEE = 4.0 # chroma where the log axis turns from linear to compressed -# The scope reads a distribution, so the CPU twin bins a strided sample (its Lab and trig are the slow part). +# The scope reads a distribution, so the CPU twin bins a strided sample. _SCOPE_MAX_SAMPLES = 262_144 # Full-res exports run through the same normalization stage, so cap the cost. @@ -56,7 +54,7 @@ def output_histogram(buffer: Any) -> Optional[np.ndarray]: buffer = buffer[::step, ::step] if buffer.dtype == np.uint8: # An 8-bit frame (the camera live view) bins by value: the same bins as the float - # path's floor(v / 255 * 256), at a fraction of np.histogram's cost per frame. + # path's floor(v / 255 * 256). rows = [np.bincount(buffer[..., c].ravel(), minlength=OUTPUT_HIST_BINS) for c in range(3)] lum = get_luminance(np.ascontiguousarray(buffer.astype(np.float32) / 255.0)) rows.append( diff --git a/negpy/features/exposure/auto_sliders.py b/negpy/features/exposure/auto_sliders.py index cf0aa7005..47052ed04 100644 --- a/negpy/features/exposure/auto_sliders.py +++ b/negpy/features/exposure/auto_sliders.py @@ -1,8 +1,7 @@ """Auto Density and Auto Grade on their sliders. -The stored value stays an offset on the meter; the slider shows meter + offset in the -slider's own units, and an edit converts back. A toggle flips only the flag, so the -slider gains or drops the meter's share. +The stored value is an offset on the meter; the slider shows meter + offset, and an edit +converts back. A toggle flips only the flag. """ from typing import Any, Dict, Mapping, Optional, Tuple diff --git a/negpy/features/exposure/logic.py b/negpy/features/exposure/logic.py index 495702174..f634d3ec7 100644 --- a/negpy/features/exposure/logic.py +++ b/negpy/features/exposure/logic.py @@ -88,10 +88,8 @@ def separation_damping_gain_np(k: float, damping: float, chroma: Any, ref_spread @njit(inline="always") def plane_taps(gh: int, gw: int, x: int, y: int, rect_x: float, rect_y: float, rect_w: float, rect_h: float): - """Where render pixel (x, y) falls on a gh×gw analysis-grid plane that covers the printed - frame `rect` in render pixels: the four tap indices and the two blend weights of - OpenCV's half-pixel bilinear, taps clamped, which is the edge replication outside the - frame. exposure.wgsl mirrors it.""" + """Taps and weights of render pixel (x, y) on a gh×gw analysis-grid plane covering `rect`: OpenCV's half-pixel + bilinear, taps clamped (edge replication outside the frame). exposure.wgsl mirrors this.""" px = (x + 0.5 - rect_x) * gw / rect_w - 0.5 py = (y + 0.5 - rect_y) * gh / rect_h - 0.5 lx = min(max(np.floor(px), 0.0), gw - 1.0) @@ -948,13 +946,9 @@ def _grade_trim_mult(grade: float, trim: float, c: Dict[str, Any]) -> float: def local_grade_factor_map(grade_deltas: np.ndarray, grade: float) -> np.ndarray: - """ - Per-pixel slope multiplier for the local-grade map: the same R/(R+ΔR) ratio - _grade_trim_mult gives a per-layer trim, so a masked region prints at its own - grade on the same ladder. Rotation happens about the channel pivot in the - kernel, which is what keeps a grade-only mask from shifting its own midtone. - Single source for the CPU kernel; exposure.wgsl mirrors it. - """ + """Per-pixel slope multiplier for the local-grade map, the ratio _grade_trim_mult gives + a per-layer trim. The kernel rotates about the channel pivot, so a grade-only mask keeps + its midtone. exposure.wgsl mirrors this.""" from negpy.features.exposure.models import EXPOSURE_CONSTANTS c = EXPOSURE_CONSTANTS @@ -1135,12 +1129,9 @@ def shadow_hold_offset( d_min: float = 0.0, paper: Optional[PaperProfile] = None, ) -> float: - """ - Auto Grade's shadow lift, the shadow-side partner of highlight_hold_offset: a negative - shadow_density term, shadow_hold_strength of the straight-line run of the textured dark tail - past Shadow Reach's line plus shadow_hold_overshoot, capped at shadow_hold_max. The tail - normally prints past paper black, so it is a partial correction, not a landing. Never burns. - """ + """Auto Grade's shadow lift (a negative shadow_density), the partner of + highlight_hold_offset, on the textured dark tail past Shadow Reach's line. A partial + correction: the tail still prints past paper black. Never burns.""" c = effective_constants(paper) strength = float(c["shadow_hold_strength"]) if strength <= 0.0: diff --git a/negpy/features/exposure/models.py b/negpy/features/exposure/models.py index 2923d6d48..fa6fd3b7a 100644 --- a/negpy/features/exposure/models.py +++ b/negpy/features/exposure/models.py @@ -49,9 +49,8 @@ class ExposureConfig: contrast_mask: float = 0.0 # The mask's spacer: the scale above which tones are masked. [2.0, 6.0] %. mask_spacer: float = 4.0 - # A diffuser under the enlarger lens: the light the paper sees is mixed with a blurred - # copy of itself, so shadows bleed into highlights. [0, 1]; 0 = off. The spread is fixed - # (DIFFUSION_SPREAD). + # A diffuser under the enlarger lens: the paper sees the light mixed with a blurred copy, + # so shadows bleed into highlights. [0, 1]; 0 = off; fixed spread (DIFFUSION_SPREAD). diffusion: float = 0.0 # Split grade: zone contrast in ISO-R points (negative = harder), global # value + per-layer trims like Grade. @@ -202,9 +201,8 @@ def __post_init__(self) -> None: # and the roll-off width of the confidence agreement term beyond it. "neutral_axis_agreement_deadzone": 0.10, "neutral_axis_agreement_scale": 0.20, - # Interdecile width of a near-neutral set's R-G and B-G offsets (normalized density): - # free below the dead zone, then the confidence rolls off over the scale. A set as wide - # as this holds colored content beside its grays. + # Interdecile width of a near-neutral set's R-G and B-G offsets: a wide set holds colored + # content beside its grays, so confidence rolls off past the dead zone over the scale. "neutral_axis_width_deadzone": 0.15, "neutral_axis_width_scale": 0.30, # Width (percentile points) of the luma-extreme band the same-pixel color @@ -263,9 +261,8 @@ def __post_init__(self) -> None: "highlight_hold_percentile": 2.0, "highlight_hold_density": 0.10, "highlight_hold_max": 0.5, - # Auto Grade shadow hold: an automatic shadow zone lift (never a burn) of strength times - # the straight-line run of shadow reach's tail past its own line plus the overshoot, - # capped at shadow_hold_max. Partial, like auto_grade_strength. 0 = off. + # Auto Grade shadow hold: a partial shadow lift (never a burn) on Shadow Reach's tail past + # its line plus the overshoot, capped at shadow_hold_max. 0 = off. "shadow_hold_strength": 0.40, "shadow_hold_overshoot": 0.15, "shadow_hold_max": 0.3, diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index d83189533..fd0566539 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -337,17 +337,9 @@ def measure_neutral_axis_from_log( roi: Optional[tuple[int, int, int, int]] = None, analysis_buffer: float = 0.0, ) -> Optional[Tuple[Tuple[float, float, float], Tuple[float, float, float], Optional[Tuple[float, float, float]], float]]: - """ - Per-channel neutral axis: median raw-log density at a highlight, a midtone and a shadow - luma band, over each band's lowest-chroma pixels. Two passes: pass 1 selects through the - residual cast under a loose cap (a strong but correctable cast must not collapse the axis; - saturated content still fails it), then the affine R/B->G correction implied by its - mid+shadow refs re-ranks chroma so pass 2 selects true neutrals under the strict cap. - Returns (midtone, shadow, highlight, confidence) — highlight is None when that band has no - trustworthy neutral set (callers then fit a 2-point line); confidence in [0,1] combines the - grey sets' corrected tightness, the midtone sample size, mid<->shadow deviation agreement and - the width of each set's offsets from green (drives Auto Cast Removal). None overall when midtone or shadow is missing (shadow tie). - """ + """Per-channel neutral axis: median raw-log density of the lowest-chroma pixels in a highlight, midtone and shadow band. + Pass 1 uses a loose cap so a strong but correctable cast keeps the axis; its mid+shadow correction re-ranks chroma for a + strict pass 2. Returns (midtone, shadow, highlight or None, confidence in [0, 1]); None without a midtone or shadow set.""" from negpy.features.exposure.models import EXPOSURE_CONSTANTS if roi: @@ -685,14 +677,9 @@ def analysis_grid( converge_h: float = 0.0, roi_norm: Optional[Tuple[float, float, float, float]] = None, ) -> np.ndarray: - """ - The linear frame on the analysis grid with its geometry replayed and cropped to the - printed frame, the array the Contrast Mask and Diffusion planes are blurred from. - - Takes the frame *before* geometry and replays it on the downsampled copy, so both - engines call this on the same array. `roi_norm` is the printed frame as - (y1, y2, x1, x2) fractions: a rebate or surround blurred in prints as a vignette. - """ + """The frame before geometry, downsampled to the analysis grid, geometry replayed and cropped to `roi_norm` + ((y1, y2, x1, x2) fractions; a rebate blurred in prints as a vignette). Both engines call this on the same + array, and the Contrast Mask and Diffusion planes are blurred from it.""" from negpy.features.exposure.models import EXPOSURE_CONSTANTS from negpy.features.geometry.logic import apply_fine_rotation, apply_keystone, apply_radial_distortion @@ -739,13 +726,9 @@ def contrast_mask_plane( roi_norm: Optional[Tuple[float, float, float, float]] = None, spacer: float = MASK_SPACER_DEFAULT, ) -> Tuple[np.ndarray, float]: - """ - The blurred low-gamma plane an unsharp mask is built from, zero-mean, on the analysis - grid (`analysis_grid`), plus the val it was centred on. The gamma's sign picks the - mask's polarity and so the direction; the centre is the val a flat area rotates about, - which the Analysis chart needs to draw the mask's band. `spacer` is per-cent of the - grid: the scale above which tones are masked. - """ + """The zero-mean blurred low-gamma plane an unsharp mask is built from, on `analysis_grid`, plus the val it + was centred on (the Analysis chart draws the mask's band from it). The gamma's sign picks the polarity. + `spacer` is per-cent of the grid: the scale above which tones are masked.""" image = analysis_grid(image, rotation, fine_rotation, flip_horizontal, flip_vertical, distortion_k1, converge_v, converge_h, roi_norm) # A frame that never metered normalizes to huge values; no stretch, no mask. @@ -772,11 +755,8 @@ def diffusion_grid( converge_h: float = 0.0, roi_norm: Optional[Tuple[float, float, float, float]] = None, ) -> np.ndarray: - """ - The light the paper sees through a diffuser, as log density on the analysis grid - (`analysis_grid`): the unmixed light, blurred. The print's normalization is a per-channel - gain on that light, so the grid takes no bounds. The blur is `DIFFUSION_SPREAD`. - """ + """The unmixed light blurred by `DIFFUSION_SPREAD`, as log density on `analysis_grid`. It takes no bounds: + the print's normalization is a per-channel gain on that light.""" image = analysis_grid(image, rotation, fine_rotation, flip_horizontal, flip_vertical, distortion_k1, converge_v, converge_h, roi_norm) light = np.ascontiguousarray(10.0 ** unmix_log_image(prefilter_log_grid(image, None, 0.0), unmix), dtype=np.float32) sigma = DIFFUSION_SPREAD * 0.01 * min(light.shape[:2]) @@ -784,11 +764,8 @@ def diffusion_grid( def diffusion_plane(grid: np.ndarray, bounds: LogNegativeBounds, panchromatic: bool = False) -> Optional[np.ndarray]: - """ - `diffusion_grid` in the print's own normalization, so the print kernel can mix it with - the pixel as light. `panchromatic` collapses the plane to luma, as a B&W print collapses - the pixel it is mixed with. None when the frame never metered. - """ + """`diffusion_grid` in the print's normalization, for the kernel to mix as light. `panchromatic` collapses it + to luma, as a B&W print does the pixel. None when the frame never metered.""" if luminance_density_range(bounds) < 1e-6: return None val = normalize_log_image(grid, bounds) diff --git a/negpy/features/exposure/shaders/exposure.wgsl b/negpy/features/exposure/shaders/exposure.wgsl index d88411191..2b7e1e1c5 100644 --- a/negpy/features/exposure/shaders/exposure.wgsl +++ b/negpy/features/exposure/shaders/exposure.wgsl @@ -51,9 +51,8 @@ struct ExposureUniforms { // Hue Trim: x = rotation in radians; yzw = preflash fraction (0 = off), threshold // value and paper gamma (preflash_params). Costs no slot; 288B already spanned two. hue: vec4, - // Contrast Mask: x = stops per unit of plane (contrast_mask_scale; 0 gates the - // mask off), yz = the printed frame's origin in rotated pixels, w = the Diffusion - // mix (0 gates it off). + // x = Contrast Mask stops per unit of plane (0 = off), yz = the printed frame's origin + // in rotated pixels, w = the Diffusion mix (0 = off). mask: vec4, // The printed frame's span in rotated pixels in xy, shared by both planes; zw pad. mask_span: vec4, @@ -70,16 +69,13 @@ struct ExposureUniforms { @group(0) @binding(2) var params: ExposureUniforms; // Per-pixel dodge/burn EV map, rasterised on the CPU (shared with the CPU path). @group(0) @binding(3) var ev_tex: texture_2d; -// The analysis-grid planes: r = the Contrast Mask plane, gba = the Diffusion plane's -// channels. Upscaled here rather than uploaded at render size, so a slider costs a -// uniform write and no transfer. +// Analysis-grid planes: r = Contrast Mask, gba = Diffusion. Upscaled here, not uploaded at +// render size, so a slider costs a uniform write and no transfer. @group(0) @binding(4) var mask_tex: texture_2d; // Shape alpha of each tone-limited mask, one per channel (planes 2+ of compute_local_maps). @group(0) @binding(5) var key_tex: texture_2d; -// The planes at this pixel. Mirrors plane_taps in exposure/logic.py: OpenCV's -// half-pixel bilinear, taps clamped, which is the edge replication outside the -// printed frame. +// The planes at this pixel. Mirrors plane_taps in exposure/logic.py. fn plane_sample(coords: vec2) -> vec4 { let dims = vec2(textureDimensions(mask_tex)); let p = (vec2(coords) + vec2(0.5) - params.mask.yz) * dims / params.mask_span.xy - vec2(0.5); @@ -149,9 +145,8 @@ fn oetf_encode(t: f32) -> f32 { return pow(x, 0.45470693); } -// Copied verbatim from lab.wgsl's rgb_to_lab/lab_to_rgb (WGSL has no includes): -// Adobe RGB 1998 primaries, D65, scene-linear both ways. A primaries or -// white-point change must update every copy (lab.wgsl, exposure.wgsl, transfer.wgsl). +// Copy of lab.wgsl's rgb_to_lab/lab_to_rgb (Adobe RGB 1998, D65, scene-linear); WGSL has no includes, +// so a primaries or white-point change updates lab.wgsl, exposure.wgsl and transfer.wgsl. fn hue_rgb_to_lab(rgb: vec3) -> vec3 { let r = max(rgb.r, 0.0); let g = max(rgb.g, 0.0); @@ -233,9 +228,8 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let d_max_base = select(vec3(params.d_max) - toe3 * params.toe_height, vec3(params.d_max), toe_neg); let d_max_eff = max(d_max_base, d_min_eff + vec3(0.1)); - // Dodge/burn print exposure (stops, positive = burn; same domain as cmy_offsets) in .r, - // the masks' flash fraction in .g, the local grade's ISO-R deltas in .b, turned into a - // slope multiplier here as local_grade_factor_map does, so a Grade change re-uploads nothing. + // .r = dodge/burn stops (positive = burn), .g = flash fraction, .b = ISO-R deltas, turned into a + // slope multiplier here (local_grade_factor_map) so a Grade change re-uploads nothing. var ev = 0.0; var gfac = 1.0; var flash = params.hue.y; diff --git a/negpy/features/flatfield/logic.py b/negpy/features/flatfield/logic.py index 8cc1e7e37..7eb238aed 100644 --- a/negpy/features/flatfield/logic.py +++ b/negpy/features/flatfield/logic.py @@ -18,15 +18,14 @@ # and the blur kernel stays tiny. _GAIN_WORK_SIZE = 256 # Blur sigma as a fraction of the long side: small enough to follow the light source's own -# pattern, which repeats from shot to shot. A wider blur leaves it in the corrected frame. +# pattern, which repeats from shot to shot. _BLUR_DIVISOR = 64.0 # A profile stores the reference at this size for Check Flat Field: twice the gain's, so dust # and the carrier edge stay sharp on the canvas. _CHECK_COPY_SIZE = 512 -# A reference pixel below _LIT_FRACTION of the bright level (_LIT_PERCENTILE of luminance) -# is carrier, not falloff. A percentile, not the median, so a carrier that fills most of the -# frame still reads dark. Under _MIN_LIT_FRACTION lit pixels the whole frame is used. +# Below _LIT_FRACTION of the _LIT_PERCENTILE luminance is carrier (a percentile, so a carrier that fills +# most of the frame still reads dark). Under _MIN_LIT_FRACTION lit pixels the whole frame is used. _LIT_PERCENTILE = 95.0 _LIT_FRACTION = 0.2 _MIN_LIT_FRACTION = 0.02 @@ -34,11 +33,8 @@ # to hold, and takes the value of the nearest pixel that is not. _MIN_LIT_WEIGHT = 0.05 -# The Check Flat Field view maps ±EVENNESS_RANGE about the median to black..white, and a -# profile with no stored reference shows its gain over ±GAIN_VIEW_RANGE instead. A reference -# that self-corrects to a wider spread than UNEVEN_LIMIT gets a warning at bake. -# The limit sits above the shortfall a smooth gain leaves at the frame edge on a steep -# falloff, so a good flat does not warn. +# Check Flat Field shows ±EVENNESS_RANGE about the median, or ±GAIN_VIEW_RANGE of gain with no reference. +# UNEVEN_LIMIT warns at bake; it sits above the edge shortfall a smooth gain leaves on a steep falloff. EVENNESS_RANGE = 0.10 GAIN_VIEW_RANGE = 0.15 UNEVEN_LIMIT = 0.06 @@ -174,7 +170,6 @@ def check_reference(reference: ImageBuffer, gain: np.ndarray) -> Evenness: def self_corrected(reference: np.ndarray, gain: np.ndarray) -> np.ndarray: - """A reference multiplied by a gain, resized to the reference.""" if gain.shape[:2] != reference.shape[:2]: gain = cv2.resize(gain, (reference.shape[1], reference.shape[0]), interpolation=cv2.INTER_LINEAR) return reference.astype(np.float32) * gain @@ -217,9 +212,8 @@ def apply_flatfield( image: ImageBuffer, config: FlatFieldConfig, gain_crop: Optional[Callable[[np.ndarray], np.ndarray]] = None ) -> ImageBuffer: """Multiply the linear source by the reference gain map. No-op when inactive or unresolved. - - ``gain_crop`` cuts the gain to the part of the frame *image* holds, such as one half-frame; - the gain is then resized to *image*.""" + ``gain_crop`` cuts the gain to the part of the frame *image* holds (one half-frame); the gain + is then resized to *image*.""" if not config.apply or not config.profile_id: return image entry = _resolve(config.profile_id) diff --git a/negpy/features/geometry/batch_autocrop.py b/negpy/features/geometry/batch_autocrop.py index 791b0a9e7..28ab73649 100644 --- a/negpy/features/geometry/batch_autocrop.py +++ b/negpy/features/geometry/batch_autocrop.py @@ -473,14 +473,9 @@ def _roll_border(evidence: Sequence[CropEvidence]) -> tuple[tuple[float, ...], i def _threshold_fallback_frames(evidence: Sequence[CropEvidence]) -> list[CropEvidence]: - """Frames carrying a threshold box, presented as detections. - - Only for a roll where the film detector found no box on any frame. Film that overfills - the sensor leaves no bed to read a box against, so every frame abstains, the roll gets no - template, and Auto-crop all frames returns nothing at all while single-frame Auto Crop — which - falls back to this same box — crops each of them. The box is near enough the whole frame - to be no crop by itself; the border walk that has already run is what trims it. - """ + """Frames carrying a threshold box, presented as detections, for a roll where the film detector found no box + (film that overfills the sensor leaves no bed). Single-frame Auto Crop falls back to the same box; the border + walk that already ran trims it.""" boxes = [item for item in evidence if item.roi is None and item.fallback_roi is not None] if not boxes: return [] diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index 3e854d63f..9a9332b64 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -1649,11 +1649,8 @@ def _run(profile: np.ndarray, limit: int, from_start: bool) -> int: def _remap_tiled(img: ImageBuffer, source_of: Callable[[np.ndarray, np.ndarray], Tuple[np.ndarray, np.ndarray]]) -> ImageBuffer: - """Same-size bilinear warp with replicated edges, one output tile at a time. - - ``source_of(xs, ys)`` maps output pixel coords to source coords. Each tile reads only the - source box its samples fall in, so every remap stays under OpenCV's size limit. - """ + """Same-size bilinear warp with replicated edges, one output tile at a time. ``source_of(xs, ys)`` maps output + to source coords; each tile reads only its source box, so every remap stays under OpenCV's size limit.""" h, w = img.shape[:2] out = np.empty_like(img) for y0 in range(0, h, _WARP_TILE): @@ -2248,7 +2245,7 @@ def autocrop_detection_key(geometry: GeometryConfig) -> str: geometry.autocrop_ratio, geometry.autocrop_mode, round(geometry.autocrop_rebate_trim, 4), - # Only when set, so rects frozen before distortion was keyed stay valid. + # Only when set: a frozen rect with no distortion keeps its key. *((round(geometry.distortion_k1, 4),) if geometry.distortion_k1 else ()), ) ) @@ -2259,17 +2256,9 @@ def resolve_autocrop_rect( geometry: GeometryConfig, preview_size: float, ) -> Optional[Tuple[float, float, float, float]]: - """Detect the auto crop and return a normalized rect in transformed-image space, as - GeometryConfig.crop_rect stores it and the canvas overlay draws it. - - The only border detection a render reaches; the caller freezes the result, so every - later render of the edit crops identically at any resolution. Runs on a copy normalized - to AUTOCROP_DETECT_RES, replaying the GeometryProcessor transform order - (rot90 -> flips -> fine rotation -> distortion -> keystone). - - The rect excludes Crop Offset, which get_manual_rect_coords adds back on every render; - only the 2 px baseline margin is baked in. None when the buffer is too small. - """ + """The auto crop as a normalized rect in transformed-image space; the caller freezes it, so later renders crop + the same at any resolution. Runs at AUTOCROP_DETECT_RES in GeometryProcessor order (rot90 -> flips -> fine + rotation -> distortion -> keystone). Excludes Crop Offset, which get_manual_rect_coords adds per render; None when too small.""" h, w = img.shape[:2] det_s = min(1.0, AUTOCROP_DETECT_RES / max(h, w)) if det_s < 1.0: diff --git a/negpy/features/lab/logic.py b/negpy/features/lab/logic.py index 8a6bd2ee3..11015775f 100644 --- a/negpy/features/lab/logic.py +++ b/negpy/features/lab/logic.py @@ -206,14 +206,9 @@ def _usm_sharpening_block( radius: float = 1.0, masking: float = 0.0, ) -> ImageBuffer: - """ - L-channel unsharp mask; mirrors lab_sharpen_h/v.wgsl + the lab.wgsl sharpen - block. Soft-gated USM with an overshoot clamp to the local 3x3 range (halo - suppression) and an optional edge mask (boxed |∇L|) protecting flat areas. - - Radius is in pixels of the buffer it runs on, ahead of any export resize, so - the preview under-represents full-size acutance; judge sharpening at 1:1. - """ + """L-channel soft-gated USM, its overshoot clamped to the local 3x3 range, with an optional edge mask for flat + areas. Radius is in pixels of this buffer, before any export resize; judge sharpening at 1:1. + lab_sharpen_h/v.wgsl and lab.wgsl mirror this.""" if amount <= 0: return img @@ -249,14 +244,9 @@ def apply_rl_sharpening( radius: float = 1.0, masking: float = 0.0, ) -> ImageBuffer: - """ - Richardson-Lucy deconvolution on linear luminance Y (Gaussian PSF), applied - as an RGB ratio so chroma is preserved. Mirrors rl_*.wgsl. Iterations are - fixed by radius (rl_iterations); no per-pixel early stop or damping — the - edge mask governs grain, matching RawTherapee's shipped configuration. - - Radius is the PSF width in output pixels — see apply_usm_sharpening. - """ + """Richardson-Lucy deconvolution of linear Y (Gaussian PSF of width radius), applied as an RGB ratio so chroma + holds. Iterations are fixed by radius and the edge mask governs grain, so there is no damping. + rl_*.wgsl mirrors this.""" if amount <= 0: return img diff --git a/negpy/features/lens/warps.py b/negpy/features/lens/warps.py index e2c55682d..415f053b0 100644 --- a/negpy/features/lens/warps.py +++ b/negpy/features/lens/warps.py @@ -110,9 +110,8 @@ def remap( channel: int, corrections: LensCorrections = LensCorrections(True, True), ) -> tuple[np.ndarray, np.ndarray]: - # The model follows darktable's embedded-metadata reader (GPL-3.0+) and - # https://github.com/trou/panasonic-rw2: Ru = Rd + scale*(a*Rd^3 + b*Rd^5 + c*Rd^7), - # with Rd (source) and Ru (corrected) both normalised to the half-diagonal. + # Model from darktable's embedded-metadata reader (GPL-3.0+) and https://github.com/trou/panasonic-rw2: + # Ru = Rd + scale*(a*Rd^3 + b*Rd^5 + c*Rd^7), Rd (source) and Ru (corrected) normalized to the half-diagonal. h, w = shape[:2] x = (np.arange(w, dtype=np.float32)[None, :] - w * 0.5) * lens.fill_scale y = (np.arange(start, stop, dtype=np.float32)[:, None] - h * 0.5) * lens.fill_scale diff --git a/negpy/features/local/logic.py b/negpy/features/local/logic.py index 28c0c7bf3..7a1b8dd47 100644 --- a/negpy/features/local/logic.py +++ b/negpy/features/local/logic.py @@ -124,14 +124,9 @@ def compute_local_maps( converge_v: float = 0.0, converge_h: float = 0.0, ) -> np.ndarray: - """ - Build the per-pixel dodge/burn maps [h, w, 3 + K] float32. Planes 0, 1 and 2 are sums - over the unlimited masks of the mask's value times its feathered alpha: print - exposure in stops (positive = burn, negative = dodge), the local grade delta in - ISO-R points and the flash fraction. Plane 3 + k is the feathered alpha alone of - the k-th of `limited_masks`, whose weight also depends on each pixel's tone, so it - cannot be pre-summed. All-zeros when there are no masks. - """ + """Per-pixel dodge/burn maps [h, w, 3 + K] float32. Planes 0-2 sum the unlimited masks' value x feathered alpha: + stops (positive = burn), grade delta in ISO-R points, flash fraction. Plane 3 + k is the alpha of the k-th of + `limited_masks`, which cannot be pre-summed because its weight depends on tone. All zeros with no masks.""" limited = limited_indices(config) maps = np.zeros((h, w, 3 + len(limited)), dtype=np.float32) if not config.masks: diff --git a/negpy/features/local/models.py b/negpy/features/local/models.py index d68ff6835..6a1bec0ed 100644 --- a/negpy/features/local/models.py +++ b/negpy/features/local/models.py @@ -42,9 +42,8 @@ class LocalMask: # Local grade in ISO-R points off the global grade, where negative is harder: the # darkroom's "burn this in through the hard filter". 0 prints at the frame's grade. grade: float = 0.0 - # Paper pre-exposure under the mask, a fraction of the threshold exposure like the frame's - # Preflash: it compresses the region's highlights without the density a burn adds. 0 is - # unflashed. A tone-limited mask carries no flash. + # Preflash under the mask, a fraction of the threshold exposure: compresses the region's + # highlights without a burn's density. 0 = off; a tone-limited mask carries no flash. flash: float = 0.0 shape: MaskShape = MaskShape.POLYGON # Apply the mask outside the shape, not inside it. diff --git a/negpy/features/process/sensor.py b/negpy/features/process/sensor.py index 3d56e1772..48c441c4a 100644 --- a/negpy/features/process/sensor.py +++ b/negpy/features/process/sensor.py @@ -1,14 +1,7 @@ -"""Sensor (CFA) crosstalk calibration for single-shot narrowband scans. - -The camera's color-filter passbands overlap the narrowband source's bands, so a -pure red/green/blue exposure leaks into the other channels — a fixed property of -the sensor+light pair, independent of film. Calibrated once from three bare-light -exposures and corrected with a 3x3 unmix on the LINEAR capture, before the -log/inversion where the film-dye crosstalk (Density Mixer) lives. - -Where the film passes almost none of a band's light, the subtraction leaves a value -smaller than its own grain and calibration error, and the log after it turns that -into maximum density. `SensorUnmix` picks how that case is handled. +"""Sensor (CFA) crosstalk of a sensor+light pair for single-shot narrowband scans: calibrated from three +bare-light exposures, corrected by a 3x3 unmix on the linear capture, before the log where film-dye crosstalk lives. +Where the film passes almost none of a band, the subtraction falls below grain and the log turns it into maximum +density; `SensorUnmix` picks how that case is handled. """ from typing import Optional @@ -50,9 +43,8 @@ # No channel is read denser than this transmission relative to the base, 3 D, past any film: # a dead or black-clipped pixel would otherwise reach the other channels through the log. _DENSITY_RANGE = 1e-3 -# Two-Scale: a channel this far below the other two, relative to each one's blurred -# neighborhood, is a defect (a dead or black-clipped photosite), not film. A real edge moves -# all three channels together. +# Two-Scale: a channel this far below the other two against its blurred neighborhood is a dead or +# black-clipped photosite; a real edge moves all three channels together. _DEFECT_RATIO = 1.0 / 64.0 @@ -236,10 +228,9 @@ def _density_unmix(x: np.ndarray, m: np.ndarray) -> Optional[np.ndarray]: def _two_scale_unmix(x: np.ndarray, m: np.ndarray) -> Optional[np.ndarray]: - """Linear where the unmix amplifies a channel no more than it does at the film base. - Elsewhere the color comes from a blurred, soft-floored unmix and the detail is added back - with the base's log gain, so grain is never amplified past it. Never reaches zero. - None when the base gives no usable linearisation point.""" + """Linear where the unmix amplifies a channel no more than at the film base. Elsewhere a blurred, soft-floored + unmix gives the color and the detail returns at the base's log gain, so grain is never amplified past it. + Never reaches zero; None when the base gives no usable linearisation point.""" base = film_base(x) if base is None or np.any(base <= _LOG_FLOOR) or np.any(m.astype(np.float64) @ base <= _LOG_FLOOR): return None diff --git a/negpy/features/sabattier/logic.py b/negpy/features/sabattier/logic.py index b7d7a4e16..af91c1f47 100644 --- a/negpy/features/sabattier/logic.py +++ b/negpy/features/sabattier/logic.py @@ -28,20 +28,9 @@ def apply_sabattier( reexposure: float = 0.45, line_sigma_px: float = 0.0, ) -> ImageBuffer: - """ - Sabattier effect on a linear-reflectance print: white light part-way through - development. Where silver has developed the re-exposure finds little fresh halide, so - dense tones hold; the lighter the tone, the more fresh halide develops after the flash, - so the scale folds at the re-exposure density R = reexposure * d_max: a tone D below it - prints at D + strength (R - D), paper white at strength * R. Strength 1 flattens the - light tones onto the fold; above 1 they reverse, the lighter the darker. - - Bromide released by the developing dense areas diffuses into the adjoining light ones - and restrains their re-development, which leaves a light line along every edge between - dark and light: the Mackie line. `line_sigma_px` is the bromide's reach in render - pixels; 0 draws no lines. The blur's taps are `gaussian_kernel_1d`'s, the same array - the shaders read, so both engines draw the same line. The result is neutral silver. - """ + """Sabattier on a linear-reflectance print: developed silver leaves little fresh halide, so the scale folds at + R = reexposure * d_max and lighter tones reverse past strength 1. Bromide from dense areas leaves a light Mackie + line along each edge, `line_sigma_px` wide (0: none). The shaders read the same `gaussian_kernel_1d` taps.""" if not enabled: return img diff --git a/negpy/features/sabattier/shaders/sabattier_h.wgsl b/negpy/features/sabattier/shaders/sabattier_h.wgsl index e3ea9aa31..39f09eaa5 100644 --- a/negpy/features/sabattier/shaders/sabattier_h.wgsl +++ b/negpy/features/sabattier/shaders/sabattier_h.wgsl @@ -1,6 +1,5 @@ -// Sabattier, pass 2 of 3: the developed-silver mask blurred horizontally. The taps are -// gaussian_kernel_1d's, uploaded verbatim (sabattier_k buffer), so support and weights -// match the CPU's sepFilter2D; clamped coordinates are its BORDER_REPLICATE. +// Sabattier, pass 2 of 3: the mask blurred horizontally with gaussian_kernel_1d's taps (sabattier_k), +// so it matches the CPU's sepFilter2D; clamped coordinates are its BORDER_REPLICATE. struct SabattierUniforms { re: f32, fold_width: f32, diff --git a/negpy/features/transparency/logic.py b/negpy/features/transparency/logic.py index bcfa88668..f628b8664 100644 --- a/negpy/features/transparency/logic.py +++ b/negpy/features/transparency/logic.py @@ -243,13 +243,9 @@ def transfer_auto_terms( shadow_point: Optional[float], highlight_point: Optional[float], ) -> Tuple[float, float, float, float]: - """ - (exposure_offset, contrast, highlight_density_auto, shadow_density_auto) with Auto - Density/Auto Grade folded onto the manual values -- single source for CPU and GPU. Mirrors - the paper path's own anchor placement, effective_grade_range, Shadow Reach, Highlight - Hold and Shadow Hold, restated on this curve's plain density-linear model instead of the paper's - toe/shoulder one. A toggle off, or a None meter, leaves its terms at their manual values. - """ + """(exposure_offset, contrast, highlight_density_auto, shadow_density_auto) with the autos folded onto the + manual values, for CPU and GPU. The paper path's autos restated on this density-linear curve; a toggle off + or a None meter leaves its terms manual.""" c = TRANSFER_CONSTANTS pivot = float(c["transfer_contrast_pivot"]) R = TRANSFER_DENSITY_RANGE diff --git a/negpy/features/transparency/shaders/transfer.wgsl b/negpy/features/transparency/shaders/transfer.wgsl index ad14e6b65..2047ac389 100644 --- a/negpy/features/transparency/shaders/transfer.wgsl +++ b/negpy/features/transparency/shaders/transfer.wgsl @@ -65,9 +65,8 @@ fn oetf_encode(t: f32) -> f32 { return pow(x, 0.45470693); } -// Copied verbatim from lab.wgsl's rgb_to_lab/lab_to_rgb (WGSL has no includes): -// Adobe RGB 1998 primaries, D65, scene-linear both ways. A primaries or -// white-point change must update every copy (lab.wgsl, exposure.wgsl, transfer.wgsl). +// Copy of lab.wgsl's rgb_to_lab/lab_to_rgb (Adobe RGB 1998, D65, scene-linear); WGSL has no includes, +// so a primaries or white-point change updates lab.wgsl, exposure.wgsl and transfer.wgsl. fn hue_rgb_to_lab(rgb: vec3) -> vec3 { let r = max(rgb.r, 0.0); let g = max(rgb.g, 0.0); diff --git a/negpy/infrastructure/loaders/helpers.py b/negpy/infrastructure/loaders/helpers.py index 5615318cb..27fdba68a 100644 --- a/negpy/infrastructure/loaders/helpers.py +++ b/negpy/infrastructure/loaders/helpers.py @@ -175,11 +175,8 @@ def identify_color_space_from_icc(icc_bytes: Optional[bytes]) -> Optional[str]: def _tiff_preview_page(file_path: str) -> Optional[Image.Image]: - """Reduced-resolution preview page of a TIFF-based raw or scan, or None. - - Page 0 holds the preview only when the full-res data sits in SubIFDs, which is how - DNG writers lay it out. Scanner DNGs write that page 16-bit, so both depths count. - A later top-level page counts only when it is marked reduced or Nikon Scan wrote it. + """Reduced-resolution preview page of a TIFF-based raw or scan, or None. Page 0 (8- or 16-bit) counts only when the + full-res data sits in SubIFDs, as DNG lays it out; a later top-level page only when it is marked reduced or Nikon Scan wrote it. """ try: import tifffile @@ -215,11 +212,8 @@ def _is_nikon_scan(page: Any) -> bool: def _reduced_top_level_page(tif: Any) -> Optional[Any]: - """8-bit RGB reduced copy of page 0 stored as a later top-level page, or None. - - Nikon Scan writes one as IFD1 without the NewSubfileType reduced flag, so its - Software tag stands in for the flag. The aspect check rejects unrelated pages. - """ + """8-bit RGB reduced copy of page 0 stored as a later top-level page, or None. Nikon Scan writes one as IFD1 + without the NewSubfileType reduced flag, so its Software tag stands in for the flag.""" if len(tif.pages) < 2: return None main = tif.pages[0] diff --git a/negpy/infrastructure/loaders/lens_metadata.py b/negpy/infrastructure/loaders/lens_metadata.py index b9079a66e..b14499120 100644 --- a/negpy/infrastructure/loaders/lens_metadata.py +++ b/negpy/infrastructure/loaders/lens_metadata.py @@ -128,12 +128,8 @@ def numbers(tag: Any) -> tuple[int, ...]: def _read_panasonic(file_path: str) -> LensMetadata: - """Read Panasonic RW2's embedded distortion polynomial from its non-TIFF IFD0. - - The reader follows darktable's Panasonic model (GPL-3.0+) and - https://github.com/trou/panasonic-rw2: Ru = Rd + scale*(a*Rd^3 + b*Rd^5 + c*Rd^7), - with Rd and Ru normalised to the half-diagonal of the decoded image. - """ + """Read Panasonic RW2's embedded distortion polynomial from its non-TIFF IFD0. Follows darktable's Panasonic + model (GPL-3.0+) and https://github.com/trou/panasonic-rw2; radii are normalized to the decoded image's half-diagonal.""" with open(file_path, "rb") as f: data = f.read() if len(data) < 10 or data[:2] != b"II" or data[2:4] != b"\x55\x00": diff --git a/negpy/infrastructure/loaders/logluv.py b/negpy/infrastructure/loaders/logluv.py index 5a73755e2..c79d8b901 100644 --- a/negpy/infrastructure/loaders/logluv.py +++ b/negpy/infrastructure/loaders/logluv.py @@ -54,12 +54,9 @@ def logluv32_to_xyz(packed: np.ndarray) -> np.ndarray: def decode_strip_logluv32(compressed: bytes, npixels: int, width: int | None = None) -> np.ndarray: - """Decode a LogLuv32 RLE strip → npixels uint32 values. - - libtiff codes each scanline on its own: four byte-planes (shifts 24,16,8,0) - run-length coded in turn, runs ≥128 count+value, else literals. *width* is - the scanline length; None reads the strip as one scanline. - """ + """Decode a LogLuv32 RLE strip → npixels uint32 values. libtiff codes each scanline on its own: four byte-planes + (shifts 24,16,8,0), each run-length coded (runs ≥128 count+value, else literals). *width* is the scanline length; + None reads the strip as one scanline.""" buf = np.frombuffer(compressed, dtype=np.uint8) width = npixels if not width else width pixels = np.zeros(npixels, dtype=np.uint32) diff --git a/negpy/infrastructure/scanners/sane_backend.py b/negpy/infrastructure/scanners/sane_backend.py index 85d130847..344c51aef 100644 --- a/negpy/infrastructure/scanners/sane_backend.py +++ b/negpy/infrastructure/scanners/sane_backend.py @@ -681,25 +681,9 @@ def _split_rgbi(arr: np.ndarray) -> tuple[np.ndarray, np.ndarray]: def _align_ir_to_rgb(rgb: np.ndarray, ir: np.ndarray) -> np.ndarray: - """Register a separately-scanned IR plane onto the RGB frame by a whole-pixel shift. - - Only the 'source' IR strategy (a second full mechanical pass — e.g. Plustek, or - genesys's "Transparency Adapter Infrared") needs this: the carriage re-homes - between the two scans and is not perfectly repeatable, so IR can land a few - pixels off the visible frame. Inline RGBI/coolscan3 IR shares one photosite - read per line with RGB, so no carriage re-home separates them and it is not - registered here; a small optical offset between IR and RGB can remain. - - Whole pixels only, no sub-pixel interpolation: a dust defect is a *minimum* in - the IR ratio, and resampling softens that dip — downsample_ir documents the same - failure mode for INTER_AREA (a thin hair's dip shallowed 0.22 -> 0.31 and - shattered). This runs on raw sensor counts, upstream of every noise-calibrated - landmark in ir_ratio_and_gain (_ir_normalize_ratio's per-frame MAD sigma), so - blurring it here would bias those measurements on exactly the low-native-sigma - hardware (Plustek/SilverFast, σ ~0.005 per #715) this strategy exists for. A - carriage-repeatability offset is whole pixels anyway — nothing is lost by not - going sub-pixel. - """ + """Register a separately scanned IR plane onto the RGB frame by a whole-pixel shift. Only the 'source' IR strategy needs + it: its second mechanical pass re-homes the carriage, which is not repeatable. Never sub-pixel: resampling softens a dust + defect's dip in the IR ratio and biases the noise-calibrated stats in ir_ratio_and_gain.""" if ir.size == 0 or rgb.shape[:2] != ir.shape[:2]: return ir ref = rgb.astype(np.float32) diff --git a/negpy/infrastructure/scanners/settings.py b/negpy/infrastructure/scanners/settings.py index 9435c08eb..52479628c 100644 --- a/negpy/infrastructure/scanners/settings.py +++ b/negpy/infrastructure/scanners/settings.py @@ -113,13 +113,9 @@ def from_dict(cls, data: dict) -> "ScannerSettings": def resolve_batch_selection( settings: ScannerSettings, *, capacity: int | None = None, whole_strip: bool = False ) -> tuple[tuple[int, ...], dict[int, Rect], Rect | None]: - """(frames, per-frame windows, base window) for a BatchRequest. - - A named selection wins. With none, a transport that measures the film gets an empty tuple, - meaning every frame it finds; a feeder gets every slot it holds, because its own frame - count reads 0 and an empty tuple there would scan nothing. Only a single-frame device - reads `scan_window`; a strip sets its windows per frame. - """ + """(frames, per-frame windows, base window) for a BatchRequest. A named selection wins. Else a transport that measures + the film gets () for every frame it finds, and a feeder every slot it holds: its frame count reads 0, so () would scan + nothing. Only a single-frame device reads `scan_window`.""" if settings.selected_frames: frames = tuple(sorted(settings.selected_frames)) windows = {f: settings.frame_windows[f] for f in frames if f in settings.frame_windows} diff --git a/negpy/kernel/caching/logic.py b/negpy/kernel/caching/logic.py index 72eb112f1..548ba4cc4 100644 --- a/negpy/kernel/caching/logic.py +++ b/negpy/kernel/caching/logic.py @@ -24,15 +24,9 @@ def _md5_of_serialized(serialized: str) -> str: def calculate_config_hash(config: Any) -> str: - """ - Stable hash of config state. - - Fast path: a hashable config (a frozen dataclass, a tuple) is keyed by the MD5 of its - repr. Not hash(): it collides on values a slider reaches, hash(-1.0) == hash(-2.0). - - Fallback: configs with to_dict (e.g. WorkspaceConfig) or unhashable frozen - dataclasses (lists/arrays in fields) go through JSON+MD5. - """ + """Stable hash of config state. A hashable config is keyed by the MD5 of its repr, not by + hash(), which collides on slider values (hash(-1.0) == hash(-2.0)). A config with to_dict, + or an unhashable one, goes through JSON+MD5.""" if not hasattr(config, "to_dict"): try: hash(config) diff --git a/negpy/kernel/system/parallel.py b/negpy/kernel/system/parallel.py index 12d496af3..7dd926c06 100644 --- a/negpy/kernel/system/parallel.py +++ b/negpy/kernel/system/parallel.py @@ -81,18 +81,9 @@ def configure_cpu_parallel(override: Optional[bool]) -> None: def parallel_njit(**jit_kwargs: Any) -> Callable: - """ - @njit that compiles serial AND parallel variants and picks one per call. - - The kernel body may use `prange`; with parallel=False it degrades to plain - `range`, so both variants compile from the same source. Each variant JITs - lazily on first use, so the unused one costs nothing. Parallel calls are - serialized behind the module lock (see module docstring). - - A caller-supplied cache=True holds for both variants, each in its own cache file - (_serial_copy). One shared file would let a "serial" call load the parallel binary - and defeat the failsafe. - """ + """@njit that compiles serial and parallel variants lazily from one source (`prange` runs as + `range` in the serial one) and picks one per call; parallel calls serialize behind the module + lock. cache=True gives each variant its own file, or a serial call could load the parallel binary.""" jit_kwargs.pop("parallel", None) def wrap(py_func: Callable) -> "_DualDispatcher": diff --git a/negpy/services/assets/composites.py b/negpy/services/assets/composites.py index b5af3206c..268376883 100644 --- a/negpy/services/assets/composites.py +++ b/negpy/services/assets/composites.py @@ -56,10 +56,8 @@ def restore_maps(repo: Any) -> tuple: def composite_entry(asset: dict) -> Optional[dict]: - """The storable record of an asset's composite membership, or None when it is a - plain frame. Settings that ride on the asset (align, render exposure) are part of - the record: they are the composite's, not the edit's. The hash is the shared one, as a - roll fork belongs to the roll that made it.""" + """The storable record of an asset's composite membership, or None for a plain frame. Settings on the asset (align, + render exposure) are the composite's, not the edit's. The hash is the shared one, as a roll fork belongs to its roll.""" if asset.get("stitch_paths"): return { "kind": "stitch", diff --git a/negpy/services/assets/flatfield.py b/negpy/services/assets/flatfield.py index 350de7a34..86fec4f9b 100644 --- a/negpy/services/assets/flatfield.py +++ b/negpy/services/assets/flatfield.py @@ -33,21 +33,9 @@ class Baked(NamedTuple): class FlatFieldProfiles: - """ - npz I/O for flat-field reference profiles (illumination-falloff gain maps). - - One file per profile in ``APP_CONFIG.flatfield_dir``, named ``.npz`` and - holding the baked per-channel gain map plus rig metadata: the distortion ``k1``, - a display name and the provenance path, and a small copy of the reference with - its self-check, so Check Flat Field needs no source. The reference image is decoded - once, at save time; nothing outside this directory is needed to apply or check the - correction afterward, so moving or deleting the original reference is harmless. - - Keyed by an opaque id rather than the name — the per-image edit references a - profile by id, so a rename (or a name collision across machines) never breaks - the reference. Disk I/O happens on dropdown build, selection and save, never per - render (the render path resolves gains through the logic-layer provider cache). - """ + """npz I/O for flat-field gain-map profiles: one ``.npz`` per profile in ``APP_CONFIG.flatfield_dir``, with rig + metadata and a small reference copy with its self-check, so applying or checking never needs the source. Keyed by id, not + name, so a rename never breaks an edit's reference. Disk I/O never runs per render (the logic-layer provider caches gains).""" @staticmethod def _path_for_id(profile_id: str) -> str: diff --git a/negpy/services/assets/half_frame.py b/negpy/services/assets/half_frame.py index ff6a2081e..8ba909a3b 100644 --- a/negpy/services/assets/half_frame.py +++ b/negpy/services/assets/half_frame.py @@ -261,18 +261,9 @@ def remap_point(x: float, y: float, half: int, old_geom: HalfGeometry, new_geom: def remap_workspace_config(config: "WorkspaceConfig", half: int, old_geom: HalfGeometry, new_geom: HalfGeometry) -> "WorkspaceConfig": - """Re-anchor every position-based manual edit in a half's saved config, so a - heal stroke, dust spot, scratch line or dodge/burn mask stays on the same - physical film location after that half's crop/split changes. - - ``geometry.crop_rect`` and ``process.analysis_rect`` are cleared instead: unlike these, they live in - transformed-image space (after rotation/flip/keystone/distortion), not raw - space, so the same point-remap does not apply to it, and a rect drawn - against the old half's frame boundary has no correct position in the new - one. Clearing (not remapping) leaves an auto-detected crop armed to - re-detect against the new boundary, and drops a manual one back to none — - either beats silently keeping a crop that no longer lines up with the frame. - """ + """Re-anchor every position-based manual edit in a half's saved config (heal, dust, scratch, dodge/burn) to the same + film location after the half's crop/split changes. ``geometry.crop_rect`` and ``process.analysis_rect`` live in + transformed-image space and have no place on the new boundary, so they are cleared: an auto crop re-detects, a manual one drops.""" if old_geom == new_geom: return config diff --git a/negpy/services/assets/migrations/roll_fields.py b/negpy/services/assets/migrations/roll_fields.py index 553a83430..816317bad 100644 --- a/negpy/services/assets/migrations/roll_fields.py +++ b/negpy/services/assets/migrations/roll_fields.py @@ -156,10 +156,8 @@ def migrate_paper_card_split(repo) -> None: def migrate_auto_crop_roll_locks(repo) -> None: - """Auto Crop became a roll default (the ``auto_crop`` card). Locks that card on every - frame saved with Auto Crop on, so the first Roll push of it off does not clear their - crops. A hand-drawn crop needs no lock (rolls.holds_own_crop). Same guards as - migrate_new_roll_field_locks.""" + """Auto Crop is a roll default (the ``auto_crop`` card). Locks that card on every frame saved with Auto Crop on, so the + first Roll push of it off does not clear their crops. A hand-drawn crop needs no lock (rolls.holds_own_crop).""" if repo.get_global_setting(_AUTO_CROP_FLAG): return try: diff --git a/negpy/services/capture/calibration.py b/negpy/services/capture/calibration.py index c49e1c4fa..ebb0fd418 100644 --- a/negpy/services/capture/calibration.py +++ b/negpy/services/capture/calibration.py @@ -1,38 +1,7 @@ -"""Per-channel ETTR exposure auto-calibration for RGB narrowband film scanning. +"""Per-channel ETTR exposure calibration for RGB narrowband scanning on a linear model: Signal_c = k_c · Level_c · t. -The film base is metered inside a user ROI and each of R/G/B is exposed just below clipping -("expose to the right"). The key idea is a **linear model**: - - Signal_c = k_c · Level_c · t - -where `k_c` (the channel response) is *measured*, never assumed, so any sensor (Sony, Fuji -X-Trans, …) is handled automatically. With one **shared** shutter `t` and three per-channel LED -levels there are 4 knobs and 3 targets, leaving one degree of freedom (the shutter). It is fixed -uniquely by putting the **dimmest** channel near PWM_MAX_SAFE (fastest shutter + highest levels at -once — no quality/speed trade-off; the gap to PWM_MAX is the verify trim's headroom). Everything -is then solved in one shot instead of searched. - -A single-capture preset lights R, G and B together for one exposure, so each sensor channel also -reads the neighboring LEDs: - - Signal_c = t · Σ_j M_cj · Level_j - -`M` is measured one LED at a time, like `k`, and the levels come from solving that system for one -target on all three channels. The highest level takes the place of the dimmest channel in the -shutter pick. - -Two representations of a shutter coexist deliberately. Labels are rounded display names for a -geometric ladder ("1/3" exposes 0.315 s, not 0.333 s): ordering/snapping read the label literally -(`shutter_seconds`), anything multiplied into the physics uses the rung's true time -(`true_seconds`). The model above only holds in the second representation. - -No dark frame: rawpy already subtracts the sensor bias, so `black = 0` (and the injected demosaic -must scale by the camera's white level, never per-frame — see `linear_demosaic`). An exposure -target that is unreachable at the hardware limits raises `CalibrationExposureError` ("over"/ -"under", carrying the aperture advice) as early as the probe can prove it — a preset that misses -its target is worthless, so none is saved (rig decision). Hardware-free: light, camera and a -`demosaic` callable are injected. -""" +k_c (a mixing matrix M for a single capture) is measured, never assumed. The shared shutter puts the dimmest channel near +PWM_MAX_SAFE. The injected demosaic scales by the camera's white level, never per frame.""" from __future__ import annotations @@ -65,9 +34,8 @@ SATURATION_VALUE = int(CLIP_CEILING * 0.998) # ≈ 65404 PWM_MIN = 40 PWM_MAX = 255 -# A single-capture solve seats its highest level near PWM_MAX_SAFE and the other two fall where -# the mixing puts them, often under PWM_MIN. Below 10 one count moves the exposure by more -# than the verify tolerance. +# A single-capture solve puts its two lower levels where the mixing sends them, often under +# PWM_MIN. Below 10, one count moves the exposure by more than the verify tolerance. PWM_MIN_SINGLE = 10 # Aim the dimmest channel here, not at 255: this is the phase-4 trim's headroom, not an # LED limit. The LEDs are gently concave, so `k` measured at the probe level over-states @@ -403,10 +371,9 @@ def nearest_shutter(label: str, candidates: tuple[str, ...]) -> str: def scan_ladder(labels) -> tuple[str, ...]: - """A body's shutter labels as the ladder a run solves on: parseable, fastest first, and - inside the built-in ladder's span. The slow end keeps the under-exposure cure reachable - for a closed-down aperture. The fast end keeps the PWM-lit LED out of banding: a short - exposure integrates too few pulses and meters noise.""" + """A body's shutter labels as the ladder a run solves on: parseable, fastest first, inside the built-in span. The slow + end keeps the under-exposure cure reachable for a closed-down aperture; the fast end keeps the PWM-lit LED out of + banding (a short exposure integrates too few pulses).""" floor, ceiling = shutter_seconds(SHUTTER_CANDIDATES[0]), shutter_seconds(SHUTTER_CANDIDATES[-1]) return tuple(label for label in usable_ladder(tuple(str(v).strip() for v in labels)) if floor <= shutter_seconds(label) <= ceiling) @@ -572,12 +539,9 @@ def measure_sensor_response( progress: Optional[ProgressCb] = None, cancel=None, ) -> np.ndarray: - """The sensor's response to each LED alone: `out[c][j]` is sensor channel c's ROI mean - under LED j. A probe that lands dim is shot again nearer the target, so the neighboring - channels stand clear of the black level. - - Raises CalibrationExposureError("over") when minimum exposure still clips, and - RuntimeError when a probe stays under MIN_PROFILE_SIGNAL.""" + """The sensor's response to each LED alone: `out[c][j]` is sensor channel c's ROI mean under LED j. A dim probe is + shot again nearer the target, so the neighboring channels clear the black level. Raises CalibrationExposureError("over") + when minimum exposure still clips, RuntimeError when a probe stays under MIN_PROFILE_SIGNAL.""" candidates, start_shutter, report, check_cancel = _run_setup(candidates, start_shutter, progress, cancel) T = target_signal(TARGET_FRACTION) response = np.zeros((3, 3)) @@ -620,10 +584,9 @@ def _meter_rgb(self, rgb, shutter: str, roi: Roi, scratch_path: str, clip_channe return signals, linearity, plateau, means def _verify_single(self, levels, shutter, T, m, candidates, roi, scratch_path, check_cancel) -> dict[str, ChannelCalibration]: - """Capture with all three LEDs at the solved levels, then alternate two corrections until - the shot is in budget and on target. A clip guard dims the three together. A trim through - the mixing matrix moves each channel to target, and holds a channel the guard dimmed at - the signal it has, so the trim cannot raise it back into clipping.""" + """Capture with all three LEDs at the solved levels, then alternate a clip guard (dims all three) and a trim through + the mixing matrix until the shot is in budget and on target. The trim holds a dimmed channel at its signal, so it + cannot raise it back into clipping.""" levels = np.array(levels, dtype=float) secs = true_seconds(shutter, candidates) held = np.zeros(3, dtype=bool) @@ -862,10 +825,9 @@ def _raise_exposure(level: int, shutter: str, gain: float, candidates: tuple[str def _sensor_matrix(mean_response: np.ndarray) -> Optional[tuple[float, ...]]: - """The sensor unmix from the single-capture probes. `mean_response[c][j]` is sensor channel - c's ROI mean under LED j alone. The mean, not the p99.9 the exposure solve meters: a - percentile reads the weak neighboring channels high. None when a probe is too dim to - measure them, or the three are not independent.""" + """The sensor unmix from the single-capture probes' ROI means (`mean_response[c][j]`: sensor channel c under LED j). + The mean, not the p99.9 the exposure solve meters, which reads the weak neighboring channels high. None when a probe is + too dim or the three are not independent.""" if np.diag(mean_response).min() < MIN_PROFILE_SIGNAL: return None try: @@ -875,13 +837,9 @@ def _sensor_matrix(mean_response: np.ndarray) -> Optional[tuple[float, ...]]: def _solve_single(m: np.ndarray, T: int, candidates: tuple[str, ...]) -> tuple[str, tuple[int, int, int]]: - """Single-capture solve. `m[c][j]` is sensor channel c's response to LED j, so the levels - that put all three channels on T at exposure t are T·M⁻¹·1 / t. The shutter is the fastest - one that keeps the highest level at ≤ PWM_MAX_SAFE. - - Raises CalibrationExposureError("under") when even PWM_MAX at the slowest shutter stays - materially below target, and RuntimeError when no levels inside the LED range balance the - channels.""" + """`m[c][j]` is sensor channel c's response to LED j, so the levels are T·M⁻¹·1 / t at the fastest shutter that keeps the + highest level ≤ PWM_MAX_SAFE. Raises CalibrationExposureError("under") when PWM_MAX at the slowest shutter stays below + target, RuntimeError when no levels in the LED range balance the channels.""" try: weights = np.linalg.solve(m, np.ones(3)) # level·seconds per count of target, per LED except np.linalg.LinAlgError: diff --git a/negpy/services/capture/meter.py b/negpy/services/capture/meter.py index 501419626..61f04fc11 100644 --- a/negpy/services/capture/meter.py +++ b/negpy/services/capture/meter.py @@ -1,10 +1,6 @@ """Exposure meter for a white-light or camera-only scan: one RAW probe sets the shutter. - -The medium decides what must not clip. On a negative the clear base becomes the print's black -point, so the base, read in the rebate around the picture, is placed at the target. On a -positive the picture's brightest highlights are. A capture with no rebate meters the picture's -highlights either way: nothing brighter is in the frame. The medium is the preset's, or the -import's own classifier on the probe. Hardware-free: the caller shoots and decodes the probe. +A negative places its clear base (read in the rebate) at the target, since it becomes the print's black point. A positive, +or a capture with no rebate, places its brightest highlights. The medium is the preset's, else the import classifier's. """ from __future__ import annotations diff --git a/negpy/services/export/linear_output.py b/negpy/services/export/linear_output.py index 2e1b4080b..0be903ba9 100644 --- a/negpy/services/export/linear_output.py +++ b/negpy/services/export/linear_output.py @@ -768,13 +768,9 @@ def _decode_stitch_part( process: Optional[ProcessConfig], unmix: bool = True, ) -> tuple[np.ndarray, Optional[_CameraWB], _SourceMeta]: - """Decode one stitch part with flatfield and, when ``unmix``, sensor correction applied. - Returns (buffer, as-shot WB, source metadata). - - Triplet merge is performed when *rgbscan* is a valid triplet config. - Sensor correction is skipped for triplets (no cross-channel leakage - with narrowband exposures). A part decodes as it would on its own, whatever its format. - """ + """Decode one stitch part as it decodes alone: flatfield, a triplet merge for a triplet *rgbscan*, and the sensor + correction when ``unmix``, never for a triplet (narrowband exposures carry no crosstalk). Returns (buffer, as-shot WB, + source metadata).""" is_triplet = rgbscan is not None and is_rgb_triplet(rgbscan) demosaic = process.demosaic_export if process is not None else DemosaicMode.AUTO diff --git a/negpy/services/rendering/engine.py b/negpy/services/rendering/engine.py index cf4b50775..17aefc025 100644 --- a/negpy/services/rendering/engine.py +++ b/negpy/services/rendering/engine.py @@ -224,10 +224,8 @@ def run_base(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: ) current_img, pipeline_changed = self._run_stage(current_img, base_key, "base", run_base, context, pipeline_changed) - # Built from the source, not from the base output, so the GPU engine can call the - # same helper on the same array. Keyed on base, so the Contrast Mask slider re-runs - # only the exposure stage. - # Pre-trim, as the GPU builds it: White/Black Point trims are a look, not the source. + # Built from the source and keyed on base, so the GPU calls the same helper and the slider re-runs only exposure. + # Pre-trim bounds, as on the GPU: White/Black Point trims are a look, not the source. mask_bounds = context.metrics.get("pre_trim_bounds", context.metrics.get("final_bounds")) if settings.exposure.contrast_mask != 0.0 and mask_bounds is not None: mask_roi = context.active_roi @@ -250,9 +248,8 @@ def run_base(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: context.metrics["contrast_mask_centre"] = self._mask_plane[2] context.metrics["contrast_mask_roi"] = None - # Same recipe as the mask plane, in the print's own normalization, so the kernel can - # mix it with the pixel as light. Print curve only: the transfer curve and the Flat - # master take no plane. + # Same recipe as the mask plane, in the print's own normalization, so the kernel mixes it with the pixel as light. + # Print curve only: the transfer curve and the Flat master take no plane. diff_bounds = context.metrics.get("final_bounds") if ( settings.exposure.diffusion > 0.0 diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index 8d7b0c9eb..b80010fe9 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -119,11 +119,8 @@ # Above this pixel count, tiling bounds the complete working set. TILING_THRESHOLD_PX = 12_000_000 HISTOGRAM_BINS = 256 -# Metrics buffer layout in u32 words: RGBL output histogram (metrics.wgsl), the RGBL -# density histogram (density_hist.wgsl), the joint RGB histogram (color_hist.wgsl), the luma -# waveform (waveform.wgsl), the a*b* vectorscope (vectorscope.wgsl), then the waveform's -# per-cell R, G, B sums (waveform.wgsl). -# 256 B-aligned offsets, mirrored as WGSL array lengths. Append-only. +# Metrics buffer in u32 words: RGBL output and density histograms, joint RGB histogram, luma waveform, a*b* vectorscope, +# waveform RGB sums. 256 B-aligned offsets, mirrored as WGSL array lengths. Append-only. _METRICS_HIST_WORDS = HISTOGRAM_BINS * 4 _METRICS_DENSITY_BASE = 1024 _METRICS_DENSITY_WORDS = DENSITY_HIST_BINS * 4 # R, G, B, Luma @@ -158,13 +155,9 @@ def _build_analysis_source( tiling_mode: bool, max_size: int, ) -> Tuple[np.ndarray, float]: - """The shared meter grid's own buffer: oriented, downsampled, warped and sliced once - for every meter reading it. - - The whole frame is warped before the ROI is cut, as the CPU engine and the print - stage do: a crop rotated about its own centre is a different region. Downsampled - first, since only a meter reads the result. - """ + """The shared meter grid's own buffer: oriented, downsampled (only a meter reads it), warped and sliced once for every + meter reading it. The whole frame is warped before the ROI is cut, as on the CPU and in the print stage: a crop rotated + about its own center is a different region.""" analysis_source = img if geometry.rotation != 0: analysis_source = np.rot90(analysis_source, k=geometry.rotation) @@ -213,13 +206,9 @@ def _keystone_inverse_bytes(converge_v: float, converge_h: float) -> bytes: def _analysis_cache_key(settings: WorkspaceConfig, analysis_source_hash: str) -> tuple: - """Identity of the auto-exposure analysis: only the fields the meter reads. - White/black point offsets and trims apply downstream as uniforms and must - not invalidate it. - - Every geometry field that moves which pixels land in the metered region: the - orientation, the warps and the crop. - """ + """Identity of the auto-exposure analysis: only the fields the meter reads, including every geometry field that moves + which pixels land in the metered region. White/black point offsets and trims apply downstream as uniforms and must not + invalidate it.""" e = settings.exposure p = settings.process g = settings.geometry @@ -491,10 +480,9 @@ def _get_intermediate_texture(self, w: int, h: int, usage: int, label: str) -> G return self._tex_cache[key] def evict_stale_textures(self, destroy: bool = True) -> None: - """Drop pool textures untouched by the previous render. Bounds VRAM: a - same-dimensions roll keeps its chain, a dimension change frees the old one a - render later. A preview passes destroy=False, since the canvas may still sample - an evicted texture; it is then freed when its last reference goes.""" + """Drop pool textures untouched by the previous render. Bounds VRAM: a same-dimensions roll keeps its chain, a + dimension change frees the old one a render later. A preview passes destroy=False, since the canvas may still + sample an evicted texture; it is freed with its last reference.""" self._render_gen += 1 stale = [k for k, gen in self._tex_gen.items() if gen < self._render_gen - 1] if not stale: @@ -758,9 +746,8 @@ def process_to_texture( _roll_color = settings.process.use_color_average and settings.process.is_locked_initialized needs_bounds_analysis = not (bounds_override or (_roll_luma and _roll_color) or settings.process.is_local_initialized) transfer = render_path(settings.process) is not RenderPath.PRINT - # Measure the anchor for every render that reads it: Auto Density, and on the - # transfer path Auto Grade's Shadow Reach, which reads it whatever Auto Density - # says. Previews also measure it for the Analysis-panel stats. + # Auto Density reads the anchor, and so does Auto Grade's Shadow Reach on the transfer + # path whatever Auto Density says. Previews also measure it for the Analysis-panel stats. anchor_used = settings.exposure.auto_exposure or (transfer and settings.exposure.auto_normalize_contrast) needs_anchor = metered_anchor_override is None and not tiling_mode and (anchor_used or readback_metrics) needs_textural = textural_range_override is None and not tiling_mode and settings.exposure.auto_normalize_contrast @@ -979,9 +966,8 @@ def _analyze_bounds() -> LogNegativeBounds: float(mask_rect[3]), ) - # The Diffusion plane shares the mask texture (its gba lanes) and the mask rect. Built - # in the print's own normalization, trims included, so the shader mixes it with the - # pixel as light. Print path only: the transfer pass binds no plane. + # The Diffusion plane shares the mask texture (gba) and rect. It is built in the print's own + # normalization, trims included, so the shader mixes it as light. Print path only. diff_plane = None diff_key = None diff_rect = None @@ -1229,9 +1215,8 @@ def _analyze_bounds() -> LogNegativeBounds: if local_maps is None: local_maps = np.zeros((h_rot, w_rot, 3), dtype=np.float32) ev_plane = local_maps[:, :, 0] - # r = dodge/burn EV, g = the masks' flash, b = local grade ISO-R deltas, which - # tone-limited masks add their grade to. One texture, so neither map costs a - # bind slot. + # r = dodge/burn EV, g = the masks' flash, b = local grade ISO-R deltas (tone-limited + # masks add their grade here). One texture, so neither map costs a bind slot. tex_local_ev.upload(np.dstack([ev_plane, local_maps[:, :, 2], local_maps[:, :, 1]])) if local_maps.shape[2] > 3: planes = np.zeros((*ev_plane.shape, MAX_KEYED_MASKS), dtype=np.float32) @@ -1309,9 +1294,8 @@ def _analyze_bounds() -> LogNegativeBounds: if start_stage <= 2: self._sharpen_state_key = None if start_stage <= 4: - # Sharpen state (USM blur, or RL deconvolution) feeds the lab pass. A 1x1 dummy - # keeps binding 3 valid when sharpening is off. The blur reads only its input, - # the radius and the kernel, so a lab-only change reuses the last one. + # Sharpen state (USM blur or RL deconvolution) feeds the lab pass; a 1x1 dummy keeps binding 3 valid when it is + # off. The blur reads only its input, radius and kernel, so a lab-only change reuses the last one. usage = wgpu.TextureUsage.STORAGE_BINDING | wgpu.TextureUsage.TEXTURE_BINDING lab_u = self._get_uniform_binding("lab") sharpen_key = (settings.lab.sharpen_method, float(settings.lab.sharpen_radius), w_rot, h_rot) @@ -1360,9 +1344,7 @@ def _analyze_bounds() -> LogNegativeBounds: tex_pre_toning = tex_lab - # --- Alternative processes (lith / cyanotype / sabattier) --- - # Mutually exclusive, and no pass at all when none is picked. Toning then reads - # tex_lab directly. + # Alternative processes are mutually exclusive, with no pass when none is picked: toning then reads tex_lab. alt = settings.altproc.alt_process if alt != AltProcess.NONE and settings.process.process_mode == ProcessMode.BW: usage_alt = wgpu.TextureUsage.STORAGE_BINDING | wgpu.TextureUsage.TEXTURE_BINDING | wgpu.TextureUsage.COPY_SRC @@ -2102,9 +2084,8 @@ def _upload_unified_uniforms( max(exp.preflash, 0.0), *preflash_params(exp.grade, d_min, paper), ) - # Contrast Mask: stops per unit of plane (0 = off), then the printed frame's - # origin and span in rotated pixels, shared with the Diffusion plane; w = the - # diffusion mix (0 = off). The shader does the upscale. + # Contrast Mask: stops per plane unit (0 = off), then the printed frame's origin and span in rotated pixels, shared + # with the Diffusion plane; w = the diffusion mix (0 = off). The shader does the upscale. + struct.pack("ffff", contrast_mask_scale_v, plane_rect_v[0], plane_rect_v[1], diffusion_mix) + struct.pack("ffff", plane_rect_v[2], plane_rect_v[3], 0.0, 0.0) + key_rows.tobytes() @@ -2203,9 +2184,8 @@ def _upload_unified_uniforms( + b"\x00" * 4 ) - # The Mackie-line taps are gaussian_kernel_1d's, as the CPU convolves with, uploaded - # to sabattier_k; the shaders take the half-width from the array itself. A zero - # sigma leaves the key None, which also skips the mask and blur passes. + # Mackie-line taps from gaussian_kernel_1d, as on the CPU; the shaders read the half-width + # from the array. A zero sigma leaves the key None and skips the mask and blur passes. sc = SABATTIER_CONSTANTS sab_sigma = max(float(sabattier_sigma), 0.0) sab_radius = 0 diff --git a/negpy/services/rendering/image_processor.py b/negpy/services/rendering/image_processor.py index 8a13633a5..0f3d27d9a 100644 --- a/negpy/services/rendering/image_processor.py +++ b/negpy/services/rendering/image_processor.py @@ -697,13 +697,9 @@ def run_pipeline( cache_stages: bool = True, gain_slices: tuple = (), ) -> 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 decoded frame, - such as one half-frame; the flat-field gain takes the same cuts. - """ + """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 + decoded frame; the flat-field gain takes the same cuts.""" # Flat-field is a source pre-correction, before geometry and crop. Folding its token # into source_hash invalidates the engine cache when it changes. Stitch buffers arrive # per-part flat-fielded, because correcting the composite canvas as one frame would @@ -995,15 +991,9 @@ def _decode_sensor_rgb( def _load_source_f32( self, file_path: str, params: WorkspaceConfig, fast_decode: bool = False, unmix: bool = True ) -> Tuple[np.ndarray, Optional[np.ndarray], str]: - """Decode a source file to a flatfield-corrected, EXIF-oriented float32 buffer. - - A stitch composite decodes every part and assembles them by replaying the - registration stored in ``params.stitch``, each part against its own rgbscan - config rather than the primary's. ``unmix=False`` leaves out the sensor unmix that - `_unmix_source` applies to the whole buffer. - - Returns (f32_buffer, ir_buffer, source_color_space). - """ + """Decode a source file to a flatfield-corrected, EXIF-oriented float32 buffer → (f32, ir, source color space). A + stitch replays the registration in ``params.stitch``, each part with its own rgbscan config. ``unmix=False`` leaves + out the sensor unmix that `_unmix_source` applies to the whole buffer.""" is_triplet = is_rgb_triplet(params.rgbscan) or stitch_has_triplets(params.stitch) # Narrowband triplet channels don't survive half_size CFA binning. fast_decode = fast_decode and not is_triplet @@ -2233,12 +2223,8 @@ def soft_proof_preview( ) if result is None: return pil_img - # Output-to-display transform, so the proof is shown in display space instead - # of being reinterpreted by the viewer. Always runs, not only when a monitor - # profile is known: without it the proof leaks output-space numbers to the - # screen and shifts per output space (issue #243). A GRAY result goes through - # it too, from "L", since its TRC matches only an sRGB-TRC display. A display - # profile lcms cannot target keeps the output-space result, not the unproofed source. + # Output-to-display transform, always, or the proof leaks output-space numbers to the screen. A GRAY result goes + # through it from "L" (its TRC fits only an sRGB-TRC display); a display profile lcms cannot target keeps the output-space result. try: proofed = ImageCms.profileToProfile( result, diff --git a/tests/scanners/test_capability_activity.py b/tests/scanners/test_capability_activity.py index 7f1c4740d..c2fe5dfdf 100644 --- a/tests/scanners/test_capability_activity.py +++ b/tests/scanners/test_capability_activity.py @@ -75,7 +75,7 @@ def __init__(self, constraint) -> None: def test_a_single_frame_adapter_is_not_a_strip() -> None: - """coolscan3 reports frame 1..1 for a mount adapter (MA-21, SF-210).""" + """coolscan3 reports frame 1..1 for a mount adapter.""" from negpy.infrastructure.scanners.sane_backend import _detect_adapter_frame_capacity assert _detect_adapter_frame_capacity({"frame": _FrameOption((1, 1, 1))}) is None diff --git a/tests/test_autocrop_state_table.py b/tests/test_autocrop_state_table.py index 42e560b1b..1cee7a9e4 100644 --- a/tests/test_autocrop_state_table.py +++ b/tests/test_autocrop_state_table.py @@ -1,8 +1,4 @@ -"""The Auto Crop state table: one test per row the other suites do not already cover. - -Row ids (R, E, H, A) match the state table in the Auto Crop plan; the rules they check -are the Roll/Frame invariants P1–P11. -""" +"""The Auto Crop state table: one test per row the other suites do not already cover.""" import gc from dataclasses import replace @@ -558,7 +554,6 @@ def test_E19_a_preset_settles_the_active_frames_lock_too(repo): def test_E19_with_no_roll_open_a_copy_settles_in_the_frames_own_roll(repo): - """Search results: no active roll, but each frame still resolves through its own.""" session, roll = _session_with_roll(repo, roll_auto=False) session.state.active_roll_id = None session.state.config = _cfg("auto") @@ -614,8 +609,7 @@ def test_D35_with_no_roll_open_the_run_records_each_frames_own_roll(ctl): def test_E19_a_copy_into_a_roll_with_no_auto_crop_value_locks_nothing(repo): - """The restore rule: a roll with no Auto Crop value applies nothing, so a later Roll - push still reaches the copied frames.""" + """A roll with no Auto Crop value applies nothing, so a later Roll push still reaches the copied frames.""" session = DesktopSessionManager(repo) roll = rolls.create_virtual_roll(repo, "Roll", ["/r/h1.tif", "/r/h2.tif"]) state = session.state @@ -635,7 +629,6 @@ def test_E19_a_copy_into_a_roll_with_no_auto_crop_value_locks_nothing(repo): @pytest.mark.parametrize("crop, carried", [("hand-drawn", True), ("auto", False)]) def test_E2_reset_crop_keeps_the_carried_auto_crop_only_for_a_hand_drawn_crop(repo, tmp_path, crop, carried): - """Clearing a hand-drawn crop is one frame's placement; clearing an auto crop turns auto-crop off.""" session = DesktopSessionManager(repo) session.state.uploaded_files = [{"name": "a.tif", "path": str(tmp_path / "a.tif"), "hash": "ha"}] session.select_file(0) diff --git a/tests/test_capture_calibration.py b/tests/test_capture_calibration.py index 13e89acff..ea09acc1c 100644 --- a/tests/test_capture_calibration.py +++ b/tests/test_capture_calibration.py @@ -266,17 +266,13 @@ def close(self): def _make_demosaic(light, camera, *, k_scale=1.0, level_cap=None, sliver=0, crosstalk=0.0, concave=1.0): - """Linear fake sensor (128×128 so a sub-0.1 % clip sliver fits below the p99.9 cut). No bias. - `k_scale` scales all channels uniformly (like aperture); `level_cap` saturates the LED above a - level (a channel solved to max LED lands under target → under-exposed); `sliver` over-bright - pixels clip at the solve but the LED-down clip guard resolves them; `crosstalk` is the share - of each neighboring LED a sensor channel also reads; `concave` below 1 makes a low LED level - brighter than its share of full drive.""" + """Linear fake sensor, no bias, 128x128 so a clip sliver under 0.1% fits below the p99.9 cut. + `k_scale` scales all channels; `level_cap` saturates the LED above a level; `sliver` pixels clip until the LED-down guard + dims them; `crosstalk` is the share of each neighbor LED a channel reads; `concave` below 1 brightens low LED levels.""" def demosaic(_path): - # A body exposes the ladder's TRUE time, not the label's fraction ("1/3" is 0.315 s). The - # fake sensor must do the same, or it silently absorbs the rounding the solver has to - # handle — and the rig failure it caused would be untestable here. + # A body exposes the ladder's true time, not the label's fraction ("1/3" is 0.315 s), and the + # fake sensor must too, or it absorbs the rounding the solver has to handle. sec = true_seconds(camera.last_shutter, SHUTTER_CANDIDATES) img = np.zeros((128, 128, 3)) lit = [min(level, level_cap) if level_cap is not None else level for level in light.last] diff --git a/tests/test_capture_meter.py b/tests/test_capture_meter.py index 24999fcec..d5bbaa302 100644 --- a/tests/test_capture_meter.py +++ b/tests/test_capture_meter.py @@ -92,8 +92,7 @@ def test_fine_clipped_highlights_count_at_the_probe_resolution(): def test_a_saturated_p999_is_never_metered_as_a_level(): - """A saturated p99.9 is a lower bound: the probe steps down and asks again instead of - nudging a third of a stop.""" + """A saturated p99.9 is a lower bound, so the probe steps down and asks again.""" img = _frame(border=0.02, picture=0.3, h=600, w=900, orange=False) img[120:200, 200:700] = CLIP_CEILING # about 9% of the picture blown reading = meter_frame(img, "1/30", medium="positive") @@ -138,8 +137,7 @@ def test_the_peak_map_keeps_a_clip_narrower_than_a_block(): def test_the_raw_clip_mask_predicts_the_metered_shutter(): - """The RAW is linear: a probe pixel at half saturation clips one stop slower, not at the - probe's own shutter.""" + """The RAW is linear: a probe pixel at half saturation clips one stop slower.""" from dataclasses import replace from negpy.services.capture.meter import raw_clip_mask @@ -200,7 +198,6 @@ def test_the_framing_check_refuses_another_aspect_and_another_picture(): def test_a_clipped_probe_past_the_fastest_rung_still_steps_to_it(): - """Two stops down passes the ladder's end, but the fastest rung is still a step down.""" img = _frame(border=0.02, picture=0.3, h=600, w=900, orange=False) img[120:200, 200:700] = CLIP_CEILING ladder = ("1/250", "1/125", "1/60") diff --git a/tests/test_capture_unmix.py b/tests/test_capture_unmix.py index a87a104b7..a481ae100 100644 --- a/tests/test_capture_unmix.py +++ b/tests/test_capture_unmix.py @@ -109,8 +109,6 @@ def test_legacy_default_crosstalk_profile_name_migrates(): def test_portra_crosstalk_profile_names_migrate_to_ektacolor_pro(): - """The Portra gallery profiles carry Kodak's Ektacolor Pro rebrand in their display - names; a saved edit naming the old row must select the renamed one.""" from negpy.domain.models import WorkspaceConfig from negpy.services.assets.crosstalk import CrosstalkProfiles @@ -124,8 +122,7 @@ def test_portra_crosstalk_profile_names_migrate_to_ektacolor_pro(): def test_renamed_crosstalk_profile_coerces_on_every_construction(): - """A roll default overlays crosstalk_profile raw through dataclasses.replace, - outside any flat-config load, so the rename must run in __post_init__.""" + """A roll default overlays crosstalk_profile through dataclasses.replace, so the rename runs in __post_init__.""" from dataclasses import replace from negpy.features.process.models import ProcessConfig diff --git a/tests/test_capture_worker.py b/tests/test_capture_worker.py index c608c9824..fad4bd7b4 100644 --- a/tests/test_capture_worker.py +++ b/tests/test_capture_worker.py @@ -251,7 +251,7 @@ def capture(self, path, shutter=None, **_kwargs): worker.run_exposure_probe(ExposureProbeRequest(shutter="Bulb")) assert len(failures) == 1 and errors == [] - assert worker._camera is not None # the session stands; only the maths failed + assert worker._camera is not None # the session stands; only the math failed class ClaimedCamera: @@ -566,7 +566,6 @@ def test_presence_poll_reports_a_missing_scanlight(monkeypatch): def test_a_probe_that_cannot_be_decoded_keeps_the_session(tmp_path, monkeypatch): - """A RAW LibRaw rejects is the decoder's failure, not the camera's: the stream stays up.""" import negpy.infrastructure.capture.raw_demosaic as demosaic_module from negpy.desktop.workers.capture_worker import ExposureProbeRequest diff --git a/tests/test_controller.py b/tests/test_controller.py index 7c5215985..13a537cc3 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -5502,8 +5502,7 @@ def test_rerender_active_view_re_renders_the_flat_master(self): self.assertEqual(kwargs.get("config_override"), flat_master_config(self.controller.state.config)) def test_an_edit_ends_flat_peek(self): - """The debounced render after an edit paints the print, so the badge and the - toolbar button must go off with it, and one press of M turns the peek back on.""" + """The render after an edit paints the print, so the badge and the toolbar button go off with it.""" from negpy.features.exposure.models import RenderIntent self.controller.state.flat_peek = True @@ -5558,9 +5557,8 @@ def test_negative_peek_paints_the_source_with_only_the_level_and_the_oetf(self): self.assertTrue(self.controller.state.negative_peek) self.assertTrue(painted) metrics = self.controller.state.peek_frame - # No camera matrix on this source, so the display level and the encode are - # all that separate it from the buffer the loader read. See TestNegativePeekColor - # for the camera-native path. + # No camera matrix on this source, so only the display level and the encode separate it from + # the loader's buffer. TestNegativePeekColor covers the camera-native path. np.testing.assert_allclose(metrics["base_positive"], working_oetf_encode(source * lightbox_level(source, None))) # Working space, so the display conversion runs; the proof does not. self.assertFalse(metrics["splash"]) diff --git a/tests/test_coordinate_mapping_offframe.py b/tests/test_coordinate_mapping_offframe.py index 4dd854558..23a151101 100644 --- a/tests/test_coordinate_mapping_offframe.py +++ b/tests/test_coordinate_mapping_offframe.py @@ -58,7 +58,6 @@ def test_an_inside_point_still_round_trips(case) -> None: def test_a_click_in_the_fine_rotation_wedge_maps_off_frame_not_to_the_corner() -> None: - """The corners of a fine-rotated frame hold no source; a click there lies just past the edge.""" grid = _grid(fine_rot=6.0) rx, ry = CoordinateMapping.map_click_to_raw(0.005, 0.005, grid) assert (rx, ry) != pytest.approx((0.0, 0.0), abs=1e-3) diff --git a/tests/test_desktop_session.py b/tests/test_desktop_session.py index 14d17544b..ff7c31242 100644 --- a/tests/test_desktop_session.py +++ b/tests/test_desktop_session.py @@ -1878,8 +1878,7 @@ def test_a_save_inside_carried_crop_kept_leaves_the_carried_auto_crop(self): self.assertTrue(self._fresh().geometry.crop_from_auto) def test_with_the_crop_row_ticked_a_hand_drawn_crop_does_not_carry(self): - """The carried rect and Auto flag stay a consistent pair: a fresh frame gets the last - auto setup, not a hand-drawn rect marked auto.""" + """The carried rect and Auto flag stay a pair: a fresh frame gets the last auto setup, not a hand-drawn rect marked auto.""" self.store["sticky_rows"] = ["geometry.crop_from_auto", "geometry.crop_rect"] self._set_auto_crop(True) config = self.session.state.config diff --git a/tests/test_dust_overlay.py b/tests/test_dust_overlay.py index 050f9b00e..8ead10d93 100644 --- a/tests/test_dust_overlay.py +++ b/tests/test_dust_overlay.py @@ -112,8 +112,7 @@ def test_ir_layer_none_without_ir_or_uv(): def test_repaired_masks_wash_in_their_source_color(): - """Every defect source arrives as a mask now, so color is what tells them apart: - under Standard, green for optically detected specks, magenta for IR and inpainted defects.""" + """Every defect source arrives as a mask, so color tells them apart.""" from negpy.desktop.view.styles.color_vision import palette_for luma_color, ir_color = (QColor(c) for c in palette_for("standard").pair) diff --git a/tests/test_embedding_worker.py b/tests/test_embedding_worker.py index 9f0028453..5d1df2f68 100644 --- a/tests/test_embedding_worker.py +++ b/tests/test_embedding_worker.py @@ -102,8 +102,7 @@ async def fake_batch(files, store, repo, progress_callback=None, ready_callback= def test_arm_resets_cancel_for_the_next_batch(): - """A cancel() left over from a batch that already finished does not leak into the - next one: the controller arms the worker before each dispatch.""" + """The controller arms the worker before each dispatch, so a stale cancel() does not reach the next batch.""" vector = np.zeros(2, dtype=np.float32) seen = [] diff --git a/tests/test_export_unopened_guard.py b/tests/test_export_unopened_guard.py index ff24e7109..069ec293a 100644 --- a/tests/test_export_unopened_guard.py +++ b/tests/test_export_unopened_guard.py @@ -1,7 +1,6 @@ -"""Batch export warns before rendering frames without a saved edit: their quick -filmstrip thumbnails are a source-preview inversion, while the export renders the -full pipeline with the live session settings, so the file can differ badly from -what the strip shows. The open frame is exempt: its preview is the export.""" +"""Batch export warns before rendering frames without a saved edit: their strip thumbnail is +a quick source-preview inversion, but the export runs the full pipeline with the live session +settings. The open frame is exempt: its preview is the export.""" from unittest.mock import MagicMock diff --git a/tests/test_flatfield_logic.py b/tests/test_flatfield_logic.py index 6b519a304..ad002a200 100644 --- a/tests/test_flatfield_logic.py +++ b/tests/test_flatfield_logic.py @@ -156,8 +156,7 @@ def test_invalidate_drops_cache(gain_store): def test_a_half_frame_takes_its_own_half_of_the_gain(gain_store): - """The preview flat-fields one half after slicing; it must match the export, which - flat-fields the whole scan and slices after.""" + """The preview flat-fields after slicing and the export before; both must match.""" from dataclasses import replace from negpy.domain.models import WorkspaceConfig diff --git a/tests/test_frame_skew.py b/tests/test_frame_skew.py index 85677e846..598592dbd 100644 --- a/tests/test_frame_skew.py +++ b/tests/test_frame_skew.py @@ -70,9 +70,8 @@ def _masked_strip(h: int = 600, w: int = 900) -> np.ndarray: @pytest.mark.parametrize("angle", [-1.1, 0.8]) def test_a_holder_edge_beside_the_picture_is_not_a_film_edge(angle: float, monkeypatch: pytest.MonkeyPatch) -> None: - # The holder masks the film's own edges, and its band is square to the camera, not the - # film. The film detector then boxes the canvas top and left down to the holder and the - # next frame; the gate refiner still boxes the picture. + # The holder band masks the film edges and is square to the camera, so the film detector boxes + # the holder and the next frame; the gate refiner still boxes the picture. from dataclasses import replace import negpy.features.geometry.skew as skew_module diff --git a/tests/test_gpu_analysis_cache.py b/tests/test_gpu_analysis_cache.py index 965a8796c..c3417b3c9 100644 --- a/tests/test_gpu_analysis_cache.py +++ b/tests/test_gpu_analysis_cache.py @@ -32,8 +32,7 @@ def test_creative_slider_keeps_key(self): self.assertEqual(k0, _analysis_cache_key(cfg, "src")) def test_geometry_warps_change_key(self): - """Fine rotation, keystone, distortion and crop-to-valid move which pixels land in - the metered region, so a drag must re-meter as the CPU engine does.""" + """Warps move which pixels land in the metered region, so a drag re-meters as the CPU engine does.""" k0 = _analysis_cache_key(self.cfg, "src") for cfg in ( replace(self.cfg, geometry=replace(self.cfg.geometry, fine_rotation=2.0)), diff --git a/tests/test_gpu_dpr_change.py b/tests/test_gpu_dpr_change.py index c4a00c102..842dc7a3c 100644 --- a/tests/test_gpu_dpr_change.py +++ b/tests/test_gpu_dpr_change.py @@ -1,9 +1,6 @@ -"""A device-pixel-ratio change refreshes the GPU surface. - -A window dragged to a screen with another scale factor changes the surface size -in device pixels without a resize event, so the render widget's physical size -must be recomputed and the swapchain reconfigured through the resize debounce. -""" +"""A device-pixel-ratio change refreshes the GPU surface. A move to a screen with another scale +factor changes the device-pixel size without a resize event, so the widget's physical size is +recomputed and the swapchain reconfigured through the resize debounce.""" from unittest.mock import MagicMock diff --git a/tests/test_gpu_slide_shadow_reach.py b/tests/test_gpu_slide_shadow_reach.py index e62eec911..868247154 100644 --- a/tests/test_gpu_slide_shadow_reach.py +++ b/tests/test_gpu_slide_shadow_reach.py @@ -1,6 +1,5 @@ """Shadow Reach on a slide reads the metered anchor whatever Auto Density says, so the GPU -must meter it for Auto Grade alone. Preview renders read metrics back and always metered -it; an export does not, and printed the slide without Shadow Reach.""" +meters it for Auto Grade alone, also in an export that reads no metrics back.""" import unittest from dataclasses import replace diff --git a/tests/test_gpu_texture_pool_eviction.py b/tests/test_gpu_texture_pool_eviction.py index eaf1c6d90..af616d1b5 100644 --- a/tests/test_gpu_texture_pool_eviction.py +++ b/tests/test_gpu_texture_pool_eviction.py @@ -48,8 +48,7 @@ def test_dimension_change_frees_the_old_chain(self): self.assertGreaterEqual(gen, self.eng._render_gen - 1) def test_an_evicted_mask_texture_is_uploaded_again(self): - """A masked frame, two mask-free frames, then the same masked frame: the dodge/burn - texture was evicted and re-created empty, so its upload must not be skipped.""" + """An evicted dodge/burn texture comes back empty, so its next upload must not be skipped.""" from dataclasses import replace from negpy.features.local.models import LocalAdjustmentsConfig, LocalMask diff --git a/tests/test_hidden_file_discovery.py b/tests/test_hidden_file_discovery.py index d2b8b4a95..a60e2a6d0 100644 --- a/tests/test_hidden_file_discovery.py +++ b/tests/test_hidden_file_discovery.py @@ -1,9 +1,6 @@ -"""Dot-prefixed files never become assets. - -`.DS_Store` and a macOS `._frame.dng` AppleDouble fork both carry the source's extension, -so an extension-only filter lets them through. The fork is readable, so it hashes as a -valid asset and only fails later at decode, as "Unsupported file format or not RAW file". -""" +"""Dot-prefixed files never become assets. `.DS_Store` and a macOS `._frame.dng` AppleDouble +fork carry the source's extension, so an extension-only filter lets them through, and the +readable fork hashes as a valid asset that fails only at decode.""" from __future__ import annotations diff --git a/tests/test_icc_identify.py b/tests/test_icc_identify.py index 377af675c..83115cd0a 100644 --- a/tests/test_icc_identify.py +++ b/tests/test_icc_identify.py @@ -10,7 +10,6 @@ [("AdobeCompat-v4", "ADOBE_RGB"), ("ProPhoto-v4", "PROPHOTO"), ("Rec2020-v4", "REC2020"), ("DisplayP3-v4", "P3_D65")], ) def test_the_bundled_profiles_are_recognized(name, expected): - """An export reopened in NegPy must decode with the space it was written in.""" import os from negpy.domain.models import ColorSpace diff --git a/tests/test_local_sidebar.py b/tests/test_local_sidebar.py index 904d93653..a5d902477 100644 --- a/tests/test_local_sidebar.py +++ b/tests/test_local_sidebar.py @@ -335,7 +335,6 @@ def test_a_grayed_tool_shortcut_shows_its_tab_without_arming(qapp): def test_a_shortcut_puts_down_an_armed_tool_on_a_grayed_page(qapp): - """A read-only page grays an armed tool's button; its shortcut must still put the tool down.""" window = MagicMock() button = QPushButton("Heal") button.setCheckable(True) diff --git a/tests/test_location_picker_dialog.py b/tests/test_location_picker_dialog.py index 5aa4fa969..c10150fcf 100644 --- a/tests/test_location_picker_dialog.py +++ b/tests/test_location_picker_dialog.py @@ -202,7 +202,6 @@ def test_the_top_hit_is_highlighted_for_the_next_return(monkeypatch) -> None: def test_closing_never_waits_on_a_stalled_lookup(monkeypatch) -> None: - """A lookup stuck in DNS outlives the dialog; closing it must not join that thread.""" import threading import time diff --git a/tests/test_metadata_fast_lens.py b/tests/test_metadata_fast_lens.py index a16804a44..a63cc504a 100644 --- a/tests/test_metadata_fast_lens.py +++ b/tests/test_metadata_fast_lens.py @@ -12,6 +12,6 @@ def test_a_lens_faster_than_f1_still_dumps_its_exif(): ) exif = _build_custom_exif(payload) - assert piexif.dump(exif) # a negative APEX rational raised here and dropped every field + assert piexif.dump(exif) assert piexif.ExifIFD.MaxApertureValue not in exif["Exif"] assert exif["Exif"][piexif.ExifIFD.FNumber] diff --git a/tests/test_metadata_lens.py b/tests/test_metadata_lens.py index 04adfd2f4..b1c3fbfbc 100644 --- a/tests/test_metadata_lens.py +++ b/tests/test_metadata_lens.py @@ -252,7 +252,7 @@ def test_panasonic_reader_flows_through_the_shared_render_path(tmp_path): assert result.shape == image.shape assert result.dtype == np.float32 assert not np.array_equal(result, image) # the warp moved the pixels - assert np.abs(result[60, 45] - image[60, 45]).max() == pytest.approx(0.0, abs=1e-6) # the centre is fixed + assert np.abs(result[60, 45] - image[60, 45]).max() == pytest.approx(0.0, abs=1e-6) # the center is fixed def test_metadata_cache_tracks_file_revision(tmp_path): diff --git a/tests/test_overlay_border_padding.py b/tests/test_overlay_border_padding.py index bcee02866..f60c03674 100644 --- a/tests/test_overlay_border_padding.py +++ b/tests/test_overlay_border_padding.py @@ -48,7 +48,7 @@ def test_a_zoom_mid_drag_keeps_the_exclusion_stroke_and_the_rotate_handle_on_the overlay = CanvasOverlay(AppState()) old = QRectF(0, 0, 200, 100) - overlay._view_rect = QRectF(-100, -50, 400, 200) # zoomed 2x about the centre + overlay._view_rect = QRectF(-100, -50, 400, 200) # zoomed 2x about the center overlay._exclude_drag_pts = [QPointF(50, 25)] overlay._rotate_center = QPointF(100, 50) overlay._rotate_press = QPointF(150, 50) diff --git a/tests/test_paper_sidebar.py b/tests/test_paper_sidebar.py index 64fd57106..0118f89c6 100644 --- a/tests/test_paper_sidebar.py +++ b/tests/test_paper_sidebar.py @@ -21,8 +21,7 @@ def _combo_items(combo): def test_paper_reset_covers_every_paper_control(): - """The card's reset clears PAPER_FIELDS, so a control that falls out of the list - leaves a visible slider its own reset cannot clear.""" + """The card's reset clears PAPER_FIELDS, so a control missing from it cannot be reset.""" from negpy.desktop.settings_catalog import PAPER_FIELDS for field in ("paper_profile", "paper_black", "paper_dmin", "midtone_gamma", "toe", "shoulder", "dye_separation", "separation_damping"): @@ -30,8 +29,7 @@ def test_paper_reset_covers_every_paper_control(): def test_separation_damping_locked_without_a_separation_push(qapp): - """It redistributes Dye Separation's push and has no effect of its own, so at - separation 1.0 it renders nothing — a live slider there reads as broken.""" + """It only redistributes Dye Separation's push, so at separation 1.0 it renders nothing.""" controller, sidebar = _sidebar() assert not sidebar.separation_damping_slider.isEnabled() @@ -117,7 +115,6 @@ def test_channel_selector_retargets_and_syncs(qapp): def test_a_tone_trim_marks_no_paper_layer(qapp): - """Each card's selector dots only its own trims.""" _controller, sidebar = _sidebar(grade_trim_red=15.0, shadow_grade_trim_red=5.0) assert not any(sidebar.ch_btn._edited) @@ -154,8 +151,6 @@ def test_paper_model_controls_hidden_on_a_slide_and_a_positive(qapp): def test_dye_separation_trim_swaps_per_channel_on_transfer_too(qapp): - """The transfer curve wires the per-channel trims like the print path, so the global/trim - swap on the channel tabs matches.""" controller = MagicMock() controller.state = AppState() cfg = controller.state.config diff --git a/tests/test_parallel_dispatch.py b/tests/test_parallel_dispatch.py index 740e8df3c..73a82ef21 100644 --- a/tests/test_parallel_dispatch.py +++ b/tests/test_parallel_dispatch.py @@ -27,9 +27,8 @@ def _double(arr): def test_serial_and_parallel_variants_never_share_a_cache_file(): - # Numba's disk cache is keyed by qualname and source line. Sharing one file, a "serial" - # call could load the PARALLEL binary and defeat the failsafe, so the serial variant - # caches under its own qualname. + # Numba's disk cache keys on qualname and source line, so the serial variant takes its own + # qualname: a shared one could load the parallel binary and defeat the failsafe. from negpy.features.exposure.logic import _apply_print_curve_kernel from negpy.infrastructure.display.icc_lut import _apply_lut_f32_jit diff --git a/tests/test_pipeline_parity.py b/tests/test_pipeline_parity.py index 81b0a287f..ce05ef74d 100644 --- a/tests/test_pipeline_parity.py +++ b/tests/test_pipeline_parity.py @@ -447,9 +447,8 @@ def test_desaturation(self): assert np.allclose(cpu_result, gpu_result, atol=0.5, rtol=0.2), f"Max diff: {np.max(np.abs(cpu_result - gpu_result)):.6f}" def test_chroma_denoise(self): - # Isolate chroma denoise: disable the sharpen default. The GPU shader scales - # its a*/b* blur radius by chroma_denoise * scale_factor (Fibonacci-disk taps - # approximating the CPU bilateralFilter sigmaSpace), so the two paths track. + # Isolate chroma denoise from the sharpen default. The GPU scales its a*/b* blur radius by + # chroma_denoise * scale_factor to follow the CPU bilateralFilter sigmaSpace. s = replace( _make_base_settings(), lab=LabConfig(chroma_denoise=3.0, sharpen=0.0), @@ -589,8 +588,7 @@ def test_split_toning_both(self): self._run_and_compare(s) def test_split_toning_tight_parity(self): - """Full-strength split toning on a smooth ramp, where the untoned engines agree - exactly, so a mask-shape mismatch the class tolerance absorbs shows here.""" + """A smooth ramp where the untoned engines agree exactly shows a mask mismatch the class tolerance absorbs.""" h, w = 64, 64 grad = np.linspace(0.05, 0.9, w, dtype=np.float32) img = np.repeat(grad[None, :], h, axis=0) @@ -803,7 +801,6 @@ def test_wide_lines(self): self._assert_did_something(s, self._settings(alt_process=AltProcess.SABATTIER, sabattier_agitation=1.0)) def test_sabattier_selenium(self): - """Plain silver: selenium tones it like an ordinary print.""" s = self._settings(ToningConfig(selenium_strength=0.8), alt_process=AltProcess.SABATTIER) self._run_and_compare(s) self._assert_did_something(s, self._settings(alt_process=AltProcess.SABATTIER)) diff --git a/tests/test_print_wedge.py b/tests/test_print_wedge.py index 06781dc70..c531c3eda 100644 --- a/tests/test_print_wedge.py +++ b/tests/test_print_wedge.py @@ -72,8 +72,7 @@ def test_the_render_runs_the_wedges_print_stages() -> None: def test_the_wedge_strip_stays_on_the_serial_kernels() -> None: - """The wedge runs on the GUI thread: a strip at or over SERIAL_MAX_ELEMENTS would take the - parallel kernels' lock and wait behind the render thread's full-frame kernels.""" + """The wedge runs on the GUI thread, where the parallel kernels' lock would make it wait behind a render.""" from negpy.features.exposure.analysis import WEDGE_STEPS from negpy.kernel.system.parallel import SERIAL_MAX_ELEMENTS diff --git a/tests/test_printing_notes_overlay.py b/tests/test_printing_notes_overlay.py index f77a1a893..f9589bc3c 100644 --- a/tests/test_printing_notes_overlay.py +++ b/tests/test_printing_notes_overlay.py @@ -167,8 +167,7 @@ def test_the_card_sits_inside_the_picture() -> None: def test_the_sheet_draws_the_burn_in_the_color_vision_burn_color() -> None: - """Tritanopia's burn is bluish green, Standard's is blue: the hatch, blended over gray, - leans green over red only under the first.""" + """Tritanopia's burn is bluish green and Standard's is blue, so the hatch leans green only under the first.""" frame = QImage(W, H, QImage.Format.Format_RGB32) frame.fill(0x00808080) diff --git a/tests/test_sabattier_logic.py b/tests/test_sabattier_logic.py index 348373e49..8754dea98 100644 --- a/tests/test_sabattier_logic.py +++ b/tests/test_sabattier_logic.py @@ -85,8 +85,7 @@ def test_the_output_is_neutral_silver(): def test_a_sabattier_print_keeps_its_carrier_ramp_like_a_plain_print(): - """Plain silver: the rebate prints through the kernel and the toners; lith and cyanotype - fall back to the flat carrier tone because their color is not in the ramp.""" + """Plain silver prints the rebate through the kernel and toners; lith and cyanotype use the flat carrier tone.""" from dataclasses import replace from negpy.domain.models import WorkspaceConfig diff --git a/tests/test_scanlight_sidebar.py b/tests/test_scanlight_sidebar.py index 78c69e1c1..e8aee0cc2 100644 --- a/tests/test_scanlight_sidebar.py +++ b/tests/test_scanlight_sidebar.py @@ -422,9 +422,8 @@ def test_reset_magnifier_clears_state(): def test_the_body_changing_its_magnifier_restarts_the_focus_peak(tmp_path, monkeypatch): - # MF Assist zooms in when the ring turns and the body's timeout zooms back out, with no - # click here. Each switch changes the sharpness scale, so the peak restarts and the click - # toggle follows the body. + # The body zooms in on a ring turn and out on its timeout, with no click here. Each switch + # changes the sharpness scale, so the peak restarts and the click toggle follows the body. import json import numpy as np @@ -1905,9 +1904,6 @@ def test_live_view_window_shows_the_focus_reading(): assert w.lv_window.focus_label.text() == "Focus meter: no reading" -# ── single-capture presets ──────────────────────────────────────── - - def _select_stored(w, monkeypatch, preset): monkeypatch.setattr(w._presets, "get", lambda _n: preset) w.preset_combo.addItem("TestStock", "TestStock") diff --git a/tests/test_sensor_unmix_modes.py b/tests/test_sensor_unmix_modes.py index fe4de3bde..1e6db7b4a 100644 --- a/tests/test_sensor_unmix_modes.py +++ b/tests/test_sensor_unmix_modes.py @@ -128,8 +128,7 @@ def test_mode_roundtrips_and_an_unknown_value_reads_the_default(): def test_film_base_reads_one_shared_set_of_cells(): - """No clear base in frame: neutral shadows, plus a deep red and a deep blue area that are - each thinner than the shadows in one channel only. Per-channel thin ends would mix them.""" + """No clear base: a red and a blue area each thinner than the shadows in one channel; per-channel thin ends would mix them.""" rng = np.random.default_rng(9) density = np.full((240, 360, 3), 0.8) density[60:180, 40:90] = 0.1 # neutral shadows @@ -185,8 +184,7 @@ def record(img, matrix, mode=SensorUnmix.LINEAR): def test_export_unmixes_a_half_frame_after_slicing_it(monkeypatch, tmp_path): - """The preview slices a half before the unmix, so the export must too: Two-Scale and - Density read the film base from the buffer they are given.""" + """The preview slices before the unmix, and Two-Scale and Density read the base from the buffer they are given.""" from negpy.services.rendering import image_processor as ip class Stop(Exception): @@ -222,8 +220,7 @@ def test_a_dead_pixel_does_not_blow_up_two_scale(): def test_two_scale_still_acts_when_the_base_is_mostly_leak(): - """A light so unbalanced that most of the base's own blue is green leak: the floor must not - hide how far Linear amplifies the neon.""" + """Most of the base's blue is green leak: the floor must not hide how far Linear amplifies the neon.""" rng = np.random.default_rng(12) base = np.array([0.23, 1.0, 0.3], np.float32) assert density_unmix_matrix(_M, base)[2, 2] > 5.0 @@ -266,8 +263,7 @@ def stop(*args, **kwargs): def test_two_scale_keeps_a_hard_light_edge(): - """A light's core far denser than its surround drops every channel together; that is an - edge, not a dead photosite, and keeps its detail.""" + """A dense light core drops every channel together: an edge, not a dead photosite, so it keeps its detail.""" import negpy.features.process.sensor as sensor img = _neon_frame(np.random.default_rng(13), noise=0.0) diff --git a/tests/test_slippy_map.py b/tests/test_slippy_map.py index 8779452e6..ad49b9fa8 100644 --- a/tests/test_slippy_map.py +++ b/tests/test_slippy_map.py @@ -162,7 +162,7 @@ def test_shutdown_stops_queued_fetches(monkeypatch) -> None: def test_shutdown_never_waits_on_a_running_fetch(monkeypatch) -> None: - """A fetch stalled in DNS outlives the view; a join on it froze the GUI with the GIL held.""" + """A fetch stalled in DNS outlives the view; a join on it freezes the GUI with the GIL held.""" import threading import time diff --git a/tests/test_soft_proof_lut.py b/tests/test_soft_proof_lut.py index fdd2c7346..888187c07 100644 --- a/tests/test_soft_proof_lut.py +++ b/tests/test_soft_proof_lut.py @@ -96,12 +96,7 @@ def test_monitor_profile_is_part_of_the_key(self): self.assertFalse(np.array_equal(plain, on_p3), "the proof must land on the monitor profile") def test_greyscale_proof_lands_on_a_non_srgb_trc_monitor(self): - """A neutral proofs the same to Greyscale as to an RGB space, on any monitor. - - The gray profile carries the sRGB TRC, so its values shown raw are right only on - an sRGB-TRC display. A pure-gamma monitor (Adobe RGB here) shows the difference - in the shadows. - """ + """The gray profile carries the sRGB TRC, so its raw values are right only on an sRGB-TRC display.""" from negpy.infrastructure.display.color_mgmt import icc_bytes_for_space monitor = icc_bytes_for_space("Adobe RGB") diff --git a/tests/test_vectorscope.py b/tests/test_vectorscope.py index c03b15173..ea0dbd1c8 100644 --- a/tests/test_vectorscope.py +++ b/tests/test_vectorscope.py @@ -62,7 +62,6 @@ def test_the_display_keeps_every_pixel_and_puts_neutral_at_the_center() -> None: @unittest.skipUnless(GPUDevice.get().is_available, "GPU not available") class TestVectorscopeParity(unittest.TestCase): def test_wgsl_bins_match_the_cpu(self): - """vectorscope.wgsl and vectorscope_histogram() bin the same frame, so the scope does not change with the engine.""" from negpy.services.rendering.image_processor import ImageProcessor rng = np.random.default_rng(5) diff --git a/tests/test_waveform.py b/tests/test_waveform.py index 82e6b8336..cc6828f4f 100644 --- a/tests/test_waveform.py +++ b/tests/test_waveform.py @@ -52,7 +52,6 @@ def test_each_cell_sums_the_color_of_its_pixels() -> None: @unittest.skipUnless(GPUDevice.get().is_available, "GPU not available") class TestWaveformParity(unittest.TestCase): def test_wgsl_bins_match_the_cpu(self): - """waveform.wgsl and waveform() bin the same frame, so the chart does not change with the engine.""" from negpy.domain.models import WorkspaceConfig from negpy.services.rendering.image_processor import ImageProcessor From 65a30840f6b1352c26116685d7350c76ee9f719f Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sun, 11 Oct 2026 14:00:08 +0200 Subject: [PATCH 2/2] docs: humanize and shorten the docs USER_GUIDE -18%, PIPELINE -15%, CAMERA_SCANNING/CROSSTALK/README/ CONTRIBUTING ~-20%, the rest lighter. Panel markers, headings, links, formulas, constants and citations kept. crosstalk/README now shows the current process values (Color Negative / Transparency); the guide's Pakon model list matches the loader. --- CLAUDE.md | 115 +++--- CONTRIBUTING.md | 142 ++++---- README.md | 73 ++-- crosstalk/README.md | 61 ++-- docs/CAMERA_SCANNING.md | 448 ++++++++++------------- docs/CROSSTALK.md | 201 +++++----- docs/FILTERING.md | 20 +- docs/PIPELINE.md | 459 +++++++++++------------ docs/PLUSTEK_WINDOWS.md | 18 +- docs/TEMPLATING.md | 68 ++-- docs/USER_GUIDE.md | 790 +++++++++++++++++++--------------------- 11 files changed, 1120 insertions(+), 1275 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 12017631d..fd944ab34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Guidance for Claude Code in this repository. > **Keep this file current.** A change that alters stage order, the feature pattern or the commands updates it in the same change. -> **Keep the user docs current, in the same change.** `docs/USER_GUIDE.md` covers every panel and control and is **rendered in-app** — each panel's ⓘ opens the section marked `` — so a stale doc is a stale in-app guide. A control added, renamed, retired or given a new range or default belongs there. `docs/PIPELINE.md` covers what each stage does to the pixels: update it when stage order, the math, a mirrored constant or a default changes. Retiring a control deletes its prose. +> **Keep the user docs current, in the same change.** `docs/USER_GUIDE.md` covers every panel and control and is **rendered in-app**: each panel's ⓘ opens the section marked ``, so a stale doc is a stale in-app guide. Update it when a control is added, renamed, retired or given a new range or default. Retiring a control deletes its prose. `docs/PIPELINE.md` covers what each stage does to the pixels: update it when stage order, the math, a mirrored constant or a default changes. > **Leave `docs/CHANGELOG.md` alone unless asked.** It is written per release, not per change. @@ -27,69 +27,71 @@ All commands run through `uv run`; never invoke pytest/ruff/ty directly. Run `ma ## Architecture -NegPy is a film-negative processing desktop app (PyQt6 + WebGPU). Images flow through a multi-stage pipeline implemented twice — CPU (numpy/Numba) and GPU (WGSL via `wgpu`) — which must stay in numerical parity. +NegPy is a film-negative processing desktop app (PyQt6 + WebGPU). Images flow through a multi-stage pipeline implemented twice, CPU (numpy/Numba) and GPU (WGSL via `wgpu`), which must stay in numerical parity. ### Data model -`WorkspaceConfig` (`negpy/domain/models.py`) — frozen dataclass of per-feature configs, the single source of truth for an edit. Change via `dataclasses.replace`, never mutate. `to_dict`/`from_flat_dict` serialize to one **flat** key namespace, so a duplicate field name across sub-configs silently clobbers. +`WorkspaceConfig` (`negpy/domain/models.py`) is a frozen dataclass of per-feature configs and the single source of truth for an edit. Change it via `dataclasses.replace`, never mutate. `to_dict`/`from_flat_dict` serialize to one **flat** key namespace, so a duplicate field name across sub-configs silently clobbers. -Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored to `.negpy` JSON sidecars next to sources. DB wins; a loaded sidecar is promoted into the DB (`negpy/services/assets/sidecar.py`, `session.py`). +Edits persist in SQLite (`edits.db`, keyed by content hash), optionally mirrored to `.negpy` JSON sidecars next to sources. The DB wins; a loaded sidecar is promoted into the DB (`negpy/services/assets/sidecar.py`, `session.py`). -On Windows, `desktop.py` checks data-folder access before importing app configuration. A blocked default folder raises a native dialog that suggests Local AppData and lets the user pick another; no data is copied. The choice is saved under Local AppData and outranks Documents; `NEGPY_USER_DIR` outranks the record. +On Windows, `desktop.py` checks data-folder access before it imports app configuration. A blocked default folder opens a native dialog that suggests Local AppData or another folder; no data is copied. The choice is saved under Local AppData and outranks Documents; `NEGPY_USER_DIR` outranks it. -**Migrations** (`negpy/domain/migrations.py`) — every legacy fixup for persisted configs lives here, not inline in `from_flat_dict`: `KEY_RENAMES` (renamed fields), `DROPPED_KEYS` (removed fields, dropped without the unknown-key warning), `RETIRED_EXPORT_FORMATS`, and `migrate_flat_config()` for value rewrites. Renaming or removing a config field, or retiring an enum value, means one entry here. Two exceptions stay in their dataclasses because they must run on *every* construction, not just on load: `ExposureConfig.__post_init__` (legacy grade → ISO R, `cast_removal` bool → strength) and the tuple-rehydrating `__post_init__`s. The module imports nothing from `models.py`, which imports it, so use string literals. +**Migrations** (`negpy/domain/migrations.py`) hold every legacy fixup for persisted configs, not inline code in `from_flat_dict`: `KEY_RENAMES` (renamed fields), `DROPPED_KEYS` (removed fields, dropped without the unknown-key warning), `RETIRED_EXPORT_FORMATS`, and `migrate_flat_config()` for value rewrites. Renaming or removing a config field, or retiring an enum value, means one entry here. Two exceptions stay in their dataclasses because they must run on *every* construction, not only on load: `ExposureConfig.__post_init__` (legacy grade → ISO R, `cast_removal` bool → strength) and the tuple-rehydrating `__post_init__`s. The module imports nothing from `models.py`, which imports it, so use string literals. -Migrations that rewrite *rows* rather than a config payload need a repository, so they live in `services/assets/migrations/` instead, one module per migration (e.g. `hash.py` for edits saved under a superseded content hash, `roll_fields.py` for card locks when a field becomes a roll default or a card splits). Most run once at startup from `desktop/main.py`. +Migrations that rewrite *rows* instead of a config payload need a repository, so they live in `services/assets/migrations/`, one module per migration (e.g. `hash.py` for edits saved under a superseded content hash, `roll_fields.py` for card locks when a field becomes a roll default or a card splits). Most run once at startup from `desktop/main.py`. -**Composite membership** (`services/assets/composites.py`) — which files a stitch or an HDR merge is made of is a user decision that nothing in the files records, so it is stored per primary path and lives until the composite is dissolved, not until the file list changes. Every asset discovery re-attaches from it and drops the parts it consumed. `_persist_session` upserts, never rewrites: the open files are one folder, the store is all of them. +**Composite membership** (`services/assets/composites.py`): which files make up a stitch or an HDR merge is a user decision that no file records. It is stored per primary path and lives until the composite is dissolved, not until the file list changes. Every asset discovery re-attaches from it and drops the parts it consumed. `_persist_session` upserts, never rewrites: the open files are one folder, the store is all of them. -**Rolls** (`services/assets/rolls.py`) — a Roll is a named, openable group of frames and the Library's only unit: a recognized folder, or a virtual roll built by hand from whatever the Film Strip holds. Folders are an import mechanism, not a live view. A roll also carries its own Half Frame and Trichrome Mode state (`half_frame_mode_by_roll`, `rgbscan_mode_by_roll`; a roll with no Trichrome entry reads the last mode chosen and records it on its first discovery) and roll-wide defaults for the Film Mode, Calibration, Crop, Roll Analysis, Metering, Raw Decode, Lens, Flat Field and Metadata cards (`ROLL_DEFAULT_FIELDS`, one `(config section, fields)` pair per card, so a card is not tied to `ProcessConfig`; the Optics section drives Lens and Flat Field, Calibration drives `sensor` and `cast_removal` (an ExposureConfig field), and Crop drives `autocrop` and `auto_crop` (Auto Crop's on/off), through `controls_panel._SECTION_CARDS`, one scope pair over both), and a Crop push that changes the roll's Auto Crop for other frames asks first (`confirm_roll_auto_crop`); a frame that diverges is *locked* on that card until Roll pushes it back out (`AppController.set_card_scope`, `apply_roll_card`). A frame resolves and locks through one roll, `rolls.frame_roll`: the active roll, else its file's home roll (a library search's mixed results). What a card holds is the roll's or the rig's — the film edge, the scanning lens, the light, the stock's White/Black Point — never one frame's own placement: the crop rectangle, the rotation, the easel movements and the capture frame number stay per-frame. Every section header carries the same Frame/Roll pair (`CollapsibleSection.set_scope_buttons`), whose Roll half only pushes, and a Reset to Roll button that pulls the roll's value back (`set_roll_revert`, `AppController.revert_to_roll`); a frame-level card has no roll default and reads Roll from `rolls.section_push`, a record of what a whole-roll apply put there, which never overlays onto another frame. A roll also holds **scenes** (`rolls.roll_scenes`): hand-picked groups keyed by unforked hash, one scene per frame, each with its own normalization baseline that Roll Analysis skips. +**Rolls** (`services/assets/rolls.py`): a Roll is a named, openable group of frames and the Library's only unit: a recognized folder, or a virtual roll built by hand from the Film Strip. Folders are an import mechanism, not a live view. A roll has its own Half Frame and Trichrome Mode state (`half_frame_mode_by_roll`, `rgbscan_mode_by_roll`; a roll with no Trichrome entry reads the last mode chosen and records it on first discovery). -**Roll-scoped edit fork** (`services/assets/rolls.py`) — a photo shared by more than one Roll normally has one edit, the same wherever it is opened from. An explicit per-frame fork gives it an independent edit for one Roll alone, keyed the same way a half-frame scan's two halves already are: the content hash suffixed (`roll_edit_hash`, `#roll:`), not a new column. `AppController._apply_roll_forks()` rewrites a discovered asset's hash to its fork on open; `load_or_promote()`'s `forked` flag keeps a fork from ever falling back to the shared edit's path-based recovery or sidecar. Triage marks and legacy-hash migration read the roll suffix back off (`unforked_hash`), since a keep/reject judgement and a superseded-hash carry-over both belong to the physical scan, not to one Roll's fork of it. +A roll holds defaults for the Film Mode, Calibration, Crop, Roll Analysis, Metering, Raw Decode, Lens, Flat Field and Metadata cards (`ROLL_DEFAULT_FIELDS`, one `(config section, fields)` pair per card, so a card is not tied to `ProcessConfig`). A section that drives two cards has one scope pair over both, through `controls_panel._SECTION_CARDS`: Optics drives Lens and Flat Field, Calibration drives `sensor` and `cast_removal` (an ExposureConfig field), Crop drives `autocrop` and `auto_crop` (Auto Crop's on/off). A Crop push that changes Auto Crop for the roll's other frames asks first (`confirm_roll_auto_crop`). A frame that diverges is *locked* on that card until Roll pushes it back out (`AppController.set_card_scope`, `apply_roll_card`). A frame resolves and locks through one roll, `rolls.frame_roll`: the active roll, else its file's home roll (a library search's mixed results). -**Search by meaning** (`services/assets/semantic_model.py`, `embeddings.py`, `clip_tokenizer.py`) — opt-in CLIP (ONNX) search over one vector per frame in the `image_embeddings` table, keyed by `MODEL_VERSION` so a model swap leaves old vectors unread. Only the inference engine ships; the weights download on first use. `workers/embedding.py` indexes exactly like `ThumbnailWorker`. +A card holds the roll's or the rig's values (the film edge, the scanning lens, the light, the stock's White/Black Point), never one frame's placement: the crop rectangle, rotation, easel movements and capture frame number stay per-frame. Every section header has the same Frame/Roll pair (`CollapsibleSection.set_scope_buttons`), whose Roll half only pushes, and a Reset to Roll button that pulls the roll's value back (`set_roll_revert`, `AppController.revert_to_roll`). A frame-level card has no roll default. It reads Roll from `rolls.section_push`, a record of what a whole-roll apply put there, which never overlays onto another frame. A roll also holds **scenes** (`rolls.roll_scenes`): hand-picked groups keyed by unforked hash, one scene per frame, each with its own normalization baseline that Roll Analysis skips. -**Gear catalog** (`features/metadata/gear_*.py`, `services/assets/gear_match.py`) — bundled reference gear plus the user's own. A newly imported roll's folder name is matched against it (shared words, and a delimiter-free run for abbreviations; a token that carries a digit, `portra400`, `hp5`, outranks a plain word, so a subject word beside the stock does not cancel it); more than one candidate at the strongest tier counts as no match. +**Roll-scoped edit fork** (`services/assets/rolls.py`): a photo in more than one Roll has one edit, unless an explicit per-frame fork gives it an independent edit for one Roll. A fork is keyed like a half-frame scan's two halves: the content hash suffixed (`roll_edit_hash`, `#roll:`), not a new column. `AppController._apply_roll_forks()` rewrites a discovered asset's hash to its fork on open; `load_or_promote()`'s `forked` flag keeps a fork from falling back to the shared edit's path-based recovery or sidecar. Triage marks and legacy-hash migration read the roll suffix back off (`unforked_hash`), because a keep/reject mark and a superseded-hash carry-over belong to the physical scan. + +**Search by meaning** (`services/assets/semantic_model.py`, `embeddings.py`, `clip_tokenizer.py`): opt-in CLIP (ONNX) search over one vector per frame in the `image_embeddings` table, keyed by `MODEL_VERSION` so a model swap leaves old vectors unread. Only the inference engine ships; the weights download on first use. `workers/embedding.py` indexes exactly like `ThumbnailWorker`. + +**Gear catalog** (`features/metadata/gear_*.py`, `services/assets/gear_match.py`): bundled reference gear plus the user's own. A new roll's folder name is matched against it by shared words, and by a delimiter-free run for abbreviations. A token with a digit (`portra400`, `hp5`) outranks a plain word, so a subject word beside the stock does not cancel it. More than one candidate at the strongest tier is no match. ### Pipeline -- **CPU**: `DarkroomEngine.process()` (`negpy/services/rendering/engine.py`) — base (geometry + normalization) → exposure (incl. dodge/burn) → clahe → lab → alt process → toning → crop → finish. The first four stages are cached per config-hash via `_run_stage()`; the rest run unconditionally. The base and exposure stages route on `process.path.render_path` (`base_processor` / `exposure_processor` in `engine.py`): `PRINT` runs the negative's processors in `features/exposure/`, `TRANSFER` and `POSITIVE` (a slide as captured, a Positive frame) run `features/transparency/`. The negative's processors hold no slide branch, and `features/exposure/` never imports `features/transparency/` (`tests/test_slide_separation.py`). The alt-process stage (lith, cyanotype or Sabattier, never two) is B&W-only and off by default; when off, both engines skip it rather than run an identity pass. -- **GPU**: `GPUEngine` (`negpy/services/rendering/gpu_engine.py`) — the same logical stages as WGSL compute shaders from `negpy/features//shaders/`, with its own config-diff change detection. +- **CPU**: `DarkroomEngine.process()` (`negpy/services/rendering/engine.py`): base (geometry + normalization) → exposure (incl. dodge/burn) → clahe → lab → alt process → toning → crop → finish. The first four stages are cached per config-hash via `_run_stage()`; the rest run unconditionally. The base and exposure stages route on `process.path.render_path` (`base_processor` / `exposure_processor` in `engine.py`): `PRINT` runs the negative's processors in `features/exposure/`; `TRANSFER` and `POSITIVE` (a slide as captured, a Positive frame) run `features/transparency/`. The negative's processors hold no slide branch, and `features/exposure/` never imports `features/transparency/` (`tests/test_slide_separation.py`). The alt-process stage (lith, cyanotype or Sabattier, never two) is B&W-only and off by default; when off, both engines skip it instead of running an identity pass. +- **GPU**: `GPUEngine` (`negpy/services/rendering/gpu_engine.py`) runs the same logical stages as WGSL compute shaders from `negpy/features//shaders/`, with its own config-diff change detection. - **Orchestration**: `ImageProcessor` (`image_processor.py`) tries GPU first and falls back to CPU. Export always runs full-res, with CPU stage caching off (`PipelineContext.cache_stages`). Linear DNG decode, CPU saturation and unsharp masking use row blocks to bound temporary storage. `PipelineContext` carries `scale_factor`, `process_mode`, `active_roi` and a `metrics` dict between stages. -- **Embedded lens correction** (`features/lens`) is a single-file decode step shared by preview and export: flat-field, lens warp, then sensor unmix and user geometry. Its independent distortion and CA settings and flat-field token belong to the source identity. It is disabled for composite setup, composite assembly and RGB+IR sources. -- **Source bakes** run before either engine, on the linear source: flat-field, sensor unmix and every defect repair (IR, detected specks, painted heal strokes). Both engines re-upload that source per frame, so a bake reaches them parity-free and needs no shader. Each bake folds a token into `source_hash` to invalidate the engine cache. -- **Working space**: scene-linear internally; the working OETF (Adobe RGB 1998 TRC — a pure 563/256 power, no linear segment) is the final engine step. Lab/toning compute CIELAB directly from linear, D65. Adobe RGB rather than a wide gamut because ProPhoto's imaginary primaries inflate chroma in the saturation and toning stages. +- **Embedded lens correction** (`features/lens`) is a single-file decode step shared by preview and export: flat-field, lens warp, then sensor unmix and user geometry. Its independent distortion and CA settings and its flat-field token belong to the source identity. It is disabled for composite setup, composite assembly and RGB+IR sources. +- **Source bakes** run before either engine, on the linear source: flat-field, sensor unmix and every defect repair (IR, detected specks, painted heal strokes). Both engines re-upload that source per frame, so a bake needs no shader and has parity for free. Each bake folds a token into `source_hash` to invalidate the engine cache. +- **Working space**: scene-linear internally. The working OETF (Adobe RGB 1998 TRC, a pure 563/256 power with no linear segment) is the final engine step. Lab/toning compute CIELAB directly from linear, D65. Adobe RGB, not a wide gamut, because ProPhoto's imaginary primaries inflate chroma in the saturation and toning stages. -`docs/PIPELINE.md` describes each stage's behaviour and controls in depth. +`docs/PIPELINE.md` describes each stage's behavior and controls in depth. ### Feature pattern Every feature lives in `negpy/features//`: -- `models.py` — frozen dataclass config with defaults -- `logic.py` — pure functions on numpy arrays -- `processor.py` — thin wrapper with `process(img, context) -> ImageBuffer` -- `shaders/.wgsl` — optional GPU compute shader +- `models.py`: frozen dataclass config with defaults +- `logic.py`: pure functions on numpy arrays +- `processor.py`: thin wrapper with `process(img, context) -> ImageBuffer` +- `shaders/.wgsl`: optional GPU compute shader -`features/transparency/` has no `models.py`: its controls are the Exposure and Process fields. Every new `ExposureConfig` field is classified in `tests/test_slide_separation.py` as transfer-live, Positive-only, print-only or routing. +`features/transparency/` has no `models.py`: its controls are the Exposure and Process fields. `tests/test_slide_separation.py` classifies every new `ExposureConfig` field as transfer-live, Positive-only, print-only or routing. `features/altprocess/` holds only `models.py`. Lith, cyanotype and Sabattier are mutually exclusive, so they share the Alternative Processes panel and one `AltProcessConfig`; their logic and shaders stay in `features/lith/`, `features/cyanotype/` and `features/sabattier/`. Sabattier's Mackie-line blur is the stage's one spatial term: its taps come from `gaussian_kernel_1d` on both engines, and the tiled export grows its halo by the kernel's half-width. -`features/lens/warps.py` holds frozen lens models with `has_distortion`, `has_ca`, and -`remap(...)`, as defined by `LensWarp` in `models.py`. `logic.py` applies their maps in -row blocks. File readers are registered in `infrastructure/loaders/lens_metadata.py`. +`features/lens/warps.py` holds frozen lens models with `has_distortion`, `has_ca` and `remap(...)`, as defined by `LensWarp` in `models.py`. `logic.py` applies their maps in row blocks. File readers are registered in `infrastructure/loaders/lens_metadata.py`. ### Desktop (MVC) -- `AppState` (`negpy/desktop/session.py`) — mutable session state -- `AppController` (`negpy/desktop/controller.py`) — single controller; all UI interactions call it; emits `config_updated` / `image_updated` -- Workers (`negpy/desktop/workers/`) — heavy work in QThread-backed objects, Qt-signal communication -- Source loaders (`negpy/infrastructure/loaders/`) own `load_bounded_preview(...)`. It returns an oriented RGB image within the requested long edge, or `None` when the loader cannot keep the decode bounded. Automatic thumbnails never use a full camera RAW demosaic. `LoaderFactory.estimate_linear_preview_prefetch_memory(...)` estimates every neighbor decode, and `decide_prefetch` admits it against available RAM. Neighbor prefetch decodes on its own worker thread, and a foreground load may enter the decode gate alongside an abandoned prefetch of another file (`_DecodeGate`); a foreground load of the file a prefetch is decoding waits for that decode instead of cancelling it (`PreviewLoadState.expect_generation`). -- **Thumbnail fingerprints** (`services/assets/thumbnail_fingerprint.py`) — a thumbnail JPEG's comment holds a hash of the resolved config (minus `metadata`/`export`, `_UNHASHED_FIELDS`, `_BELOW_THUMBNAIL_SECTIONS` and every field at its default, so a new defaulted field changes no fingerprint), the input profile, composite companion files and `THUMBNAIL_RENDER_VERSION`; the display transform is baked in but not hashed. Roll-scope Update Thumbnails renders only mismatches, and a missing, legacy or `quick` thumbnail counts as one; roll open dots only real mismatches (`_seed_stale_thumbnails`). A live render is fingerprinted from the config it ran on (`render_identity`), and a pixel-neutral write (measured bounds, frozen auto-crop) calls `_carry_render_identity`. A splash, crop-tool view or `config_override` render carries no identity and never reaches disk. A negative or embedded peek paints into `AppState.peek_frame`, which the canvas reads through `canvas_value`, so `last_metrics` keeps the print under it. A batch turn keeps a current fingerprint current (`thumbnail_turn_snapshot`). **Bump `THUMBNAIL_RENDER_VERSION` in any change that alters rendered pixels for unchanged settings, a changed default included** (`test_a_changed_default_bumps_the_render_version` fails on an unbumped one recorded in `tests/thumbnail_fingerprint_defaults.json`). A config section that never reaches the pixels belongs in `NON_PIXEL_SECTIONS`; an export-only or label field in a hashed section belongs in `_UNHASHED_FIELDS`. -- Sidebars (`negpy/desktop/view/sidebar/.py`) — one per feature, registered in `ControlsPanel`, synced on `config_updated` -- The Film Strip's thumbnail grid (`ThumbnailGridView` in `files.py`) owns click-driven selection itself — `mousePressEvent`/`mouseMoveEvent`/`mouseReleaseEvent` decide plain/Shift/Ctrl once, from the modifiers at press, and reapply that on every later stage. `QAbstractItemView`'s own selection handling recomputes independently at each stage instead, reading modifiers fresh each time, which is what made Shift/Ctrl-click erratic before this — don't call `setSelection`/rely on the base class's mouse handling for this view. Under Scene sort it also places its own cells (`_place_scene_runs`, re-run from `updateGeometries` because Qt re-flows every cell on each layout), so the model stays one row per frame. -- **Shortcuts** (`negpy/desktop/view/shortcut_registry.py`) — `REGISTRY` is the single source of truth for every binding: one `ShortcutEntry(default_key, description, category)` per action id, dispatched through the matching entry in the action map in `keyboard_shortcuts.py`. It also feeds the shortcut editor, the `?` overlay and `tooltip_with_shortcut()`. - **Any new user-facing toggle, tool or action gets a registry entry** — leave `default_key` empty rather than inventing a conflicting one. Check for collisions before picking: the same key on two actions makes Qt fire `activatedAmbiguously` and both go dead. `docs/KEYBOARD.md` is generated, so run `uv run python -m negpy.desktop.view.keyboard_doc` after a registry change. Copy that names a key reads it through `key_for`/`label_with_shortcut`, never as a literal. +- `AppState` (`negpy/desktop/session.py`): mutable session state +- `AppController` (`negpy/desktop/controller.py`): the single controller; all UI interactions call it; emits `config_updated` / `image_updated` +- Workers (`negpy/desktop/workers/`): heavy work in QThread-backed objects, with Qt-signal communication +- Source loaders (`negpy/infrastructure/loaders/`) own `load_bounded_preview(...)`: an oriented RGB image within the requested long edge, or `None` when the loader cannot bound the decode. Automatic thumbnails never use a full camera RAW demosaic. `LoaderFactory.estimate_linear_preview_prefetch_memory(...)` estimates every neighbor decode, and `decide_prefetch` admits it against available RAM. Neighbor prefetch decodes on its own worker thread. A foreground load may enter the decode gate alongside an abandoned prefetch of another file (`_DecodeGate`); a foreground load of the file a prefetch is decoding waits for that decode instead of cancelling it (`PreviewLoadState.expect_generation`). +- **Thumbnail fingerprints** (`services/assets/thumbnail_fingerprint.py`): a thumbnail JPEG's comment holds a hash of the resolved config (minus `metadata`/`export`, `_UNHASHED_FIELDS`, `_BELOW_THUMBNAIL_SECTIONS` and every field at its default, so a new defaulted field changes no fingerprint), the input profile, composite companion files and `THUMBNAIL_RENDER_VERSION`. The display transform is baked in but not hashed. Roll-scope Update Thumbnails renders only mismatches, and a missing, legacy or `quick` thumbnail counts as one; opening a roll dots only real mismatches (`_seed_stale_thumbnails`). A live render is fingerprinted from the config it ran on (`render_identity`), and a pixel-neutral write (measured bounds, frozen auto-crop) calls `_carry_render_identity`. A splash, crop-tool view or `config_override` render carries no identity and never reaches disk. A negative or embedded peek paints into `AppState.peek_frame`, which the canvas reads through `canvas_value`, so `last_metrics` keeps the print under it. A batch turn keeps a current fingerprint current (`thumbnail_turn_snapshot`). **Bump `THUMBNAIL_RENDER_VERSION` in any change that alters rendered pixels for unchanged settings, a changed default included** (`test_a_changed_default_bumps_the_render_version` fails on an unbumped one recorded in `tests/thumbnail_fingerprint_defaults.json`). A config section that never reaches the pixels belongs in `NON_PIXEL_SECTIONS`; an export-only or label field in a hashed section belongs in `_UNHASHED_FIELDS`. +- Sidebars (`negpy/desktop/view/sidebar/.py`): one per feature, registered in `ControlsPanel`, synced on `config_updated` +- The Film Strip's thumbnail grid (`ThumbnailGridView` in `files.py`) owns click-driven selection. `mousePressEvent`/`mouseMoveEvent`/`mouseReleaseEvent` decide plain/Shift/Ctrl once, from the modifiers at press, and reapply that at every later stage. `QAbstractItemView`'s own handling reads fresh modifiers at each stage, which makes Shift/Ctrl-click erratic, so never call `setSelection` or rely on the base class's mouse handling here. Under Scene sort it also places its own cells (`_place_scene_runs`, re-run from `updateGeometries` because Qt re-flows every cell on each layout), so the model stays one row per frame. +- **Shortcuts** (`negpy/desktop/view/shortcut_registry.py`): `REGISTRY` is the single source of truth for every binding: one `ShortcutEntry(default_key, description, category)` per action id, dispatched through the matching entry in the action map in `keyboard_shortcuts.py`. It also feeds the shortcut editor, the `?` overlay and `tooltip_with_shortcut()`. + **Any new user-facing toggle, tool or action gets a registry entry**. Leave `default_key` empty instead of inventing a conflicting one. Check for collisions before you pick a key: the same key on two actions makes Qt fire `activatedAmbiguously` and both go dead. `docs/KEYBOARD.md` is generated, so run `uv run python -m negpy.desktop.view.keyboard_doc` after a registry change. Copy that names a key reads it through `key_for`/`label_with_shortcut`, never as a literal. ## Adding a new feature @@ -100,32 +102,43 @@ row blocks. File readers are registered in `infrastructure/loaders/lens_metadata 5. Add a sidebar and register it in `ControlsPanel`, building every control from the factories in **UI conventions** below. Sections come from `widgets/collapsible.make_section`; mark the panel's `docs/USER_GUIDE.md` section with `` above the heading to get the ⓘ guide 6. If it adds a toggle/tool/action, add a `REGISTRY` entry in `shortcut_registry.py` plus its action-map entry in `keyboard_shortcuts.py` 7. Add unit tests; if the feature has both CPU and GPU paths, add a parity test (pattern: `test_gpu_curve_parity.py`) -8. Document it: the panel and its controls in `docs/USER_GUIDE.md`, the stage's behaviour and math in `docs/PIPELINE.md` +8. Document it: the panel and its controls in `docs/USER_GUIDE.md`, the stage's behavior and math in `docs/PIPELINE.md` ## UI conventions -**A new control reuses an existing one. It never introduces a new look.** Find the closest control already in the app, call the same factory with the same tokens, and copy nothing. A new size, colour, width, spacing value, button shape or toggle idiom needs the user's agreement first: the panels sit in one tab stack, so a private look is visible beside the shared one. - -- **Controls come from a factory**, never a bare `QPushButton` + `setStyleSheet`. All live in `styles/templates.py`; `BaseSidebar._tool_toggle` and friends are thin wrappers. `tool_toggle` (icon-only or icon+label toggle; `align_left=True` for toggles stacked in one column), `labeled_toggle` (checkable), `labeled_action` (its one-shot twin; `primary=True` for the panel's one call to action), `icon_button` (icon-only action), `header_button` (a section header's small flat icon, also a help or row icon in a line of text), `templates.field_label` (label beside a combo/entry; `width=` makes it a right-aligned label column; `field_row` builds the whole label + field + icon-button row; in a card with sliders the row takes the slider label column and track width), `templates.hint_label` (a line of help under a control), `section_subheader` (grouping; `header_row(header, *icons)` puts icon tools that act on the whole subsection at its right end), `CollapsibleSection` (a panel section, and the only reset affordance), `CompactSlider` (one row: label, track, value; `CollapsibleSection.set_content` aligns a card's label and value columns, so one slider per row, never two side by side), `SliderGroup` (a rail under the control its members depend on: a main slider's modifiers, such as Toe Width under Toe, or the settings of the feature a toggle or dropdown above turns on; the main control stays outside the rail, and peers of equal rank get no rail). `SegmentedChoice` and `ChoiceButton` (`widgets/choice_button.py`, one API) for every mutually exclusive choice in a panel, never a row of exclusive toggles. `SegmentedChoice` keeps two to four short, fixed choices in view: a recessed track with a neutral raised chip, never the red toggle look; `icon_only=` for color-coded choices such as R/G/B. `ChoiceButton` is a button with a chevron that opens a menu, for a longer list, one a device decides (`set_choices(choices, data)`) or a setting that need not stay in view (Border color). Radio buttons stay in dialog forms. Booleans in a panel are toggle buttons; `QCheckBox` is for a list of options in a form. Dropdowns and number boxes ignore the scroll wheel app-wide (`AppEventFilter` in `desktop/main.py`), so the wheel always scrolls the panel; no widget takes it back. -- **Type**: four size tokens in `styles/theme.py` — `font_size_small` (12, caption/hint), `font_size_base` (13, body and the QSS global), `font_size_header` (14, section), `font_size_title` (16, dialog title), plus `font_size_display` for the wordmark and `font_size_micro` (9) for chart axes only. All in px; the sheet reads them as `@font_size_basepx`. Never a literal size in a stylesheet string or a `setPixelSize`. -- **Colour**: `text_primary` body, `text_secondary` secondary copy, `text_hint` captions and hints, `warn_amber` advisories, `error` errors and invalid input (not the accent, which means selected/armed), `status_success` a good state. `text_muted` is the **disabled** grey — 2.6:1 on the panel, so never on text a user has to read. Channel colours have a fill tier (`channel_red`) and a text tier (`channel_red_text`). Every other colour is a token too, read from QSS as `@name`; `tests/test_theme_tokens.py` fails on a literal hex outside a painter alpha wash. A view that tells marks apart by hue alone reads them by role from `styles/color_vision.py` (`palette_for(state.color_vision)`, the Preferences Color vision choice) and repaints on `session.color_vision_changed`. +**A new control reuses an existing one. It never introduces a new look.** Call the factory of the closest existing control with the same tokens; copy nothing. A new size, color, width, spacing value, button shape or toggle idiom needs the user's agreement first: the panels share one tab stack, so a private look shows beside the shared one. + +- **Controls come from a factory**, never a bare `QPushButton` + `setStyleSheet`. All live in `styles/templates.py`; `BaseSidebar._tool_toggle` and friends are thin wrappers. + - `tool_toggle`: icon-only or icon+label toggle; `align_left=True` for toggles stacked in one column. + - `labeled_toggle` (checkable) and `labeled_action`, its one-shot twin; `primary=True` for the panel's one call to action. + - `icon_button`: icon-only action. `header_button`: a section header's small flat icon, also a help or row icon in a line of text. + - `templates.field_label`: label beside a combo/entry; `width=` makes it a right-aligned label column. `field_row` builds the whole label + field + icon-button row; in a card with sliders the row takes the slider label column and track width. `templates.hint_label`: a line of help under a control. + - `section_subheader` for grouping; `header_row(header, *icons)` puts icon tools that act on the whole subsection at its right end. + - `CollapsibleSection`: a panel section, and the only reset affordance. + - `CompactSlider`: one row of label, track, value. `CollapsibleSection.set_content` aligns a card's label and value columns, so one slider per row, never two side by side. + - `SliderGroup`: a rail under the control its members depend on: a main slider's modifiers (Toe Width under Toe), or the settings of the feature that a toggle or dropdown above turns on. The main control stays outside the rail, and peers of equal rank get no rail. + - `SegmentedChoice` and `ChoiceButton` (`widgets/choice_button.py`, one API) for every mutually exclusive choice in a panel, never a row of exclusive toggles. `SegmentedChoice` keeps two to four short, fixed choices in view: a recessed track with a neutral raised chip, never the red toggle look; `icon_only=` for color-coded choices such as R/G/B. `ChoiceButton` is a button with a chevron that opens a menu, for a longer list, a list a device decides (`set_choices(choices, data)`) or a setting that need not stay in view (Border color). + - Radio buttons stay in dialog forms. Booleans in a panel are toggle buttons; `QCheckBox` is for a list of options in a form. + - Dropdowns and number boxes ignore the scroll wheel app-wide (`AppEventFilter` in `desktop/main.py`), so the wheel always scrolls the panel; no widget takes it back. +- **Type**: four size tokens in `styles/theme.py`: `font_size_small` (12, caption/hint), `font_size_base` (13, body and the QSS global), `font_size_header` (14, section), `font_size_title` (16, dialog title), plus `font_size_display` for the wordmark and `font_size_micro` (9) for chart axes only. All in px; the sheet reads them as `@font_size_basepx`. Never a literal size in a stylesheet string or a `setPixelSize`. +- **Color**: `text_primary` body, `text_secondary` secondary copy, `text_hint` captions and hints, `warn_amber` advisories, `error` errors and invalid input (not the accent, which means selected/armed), `status_success` a good state. `text_muted` is the **disabled** gray, 2.6:1 on the panel, so never on text a user has to read. Channel colors have a fill tier (`channel_red`) and a text tier (`channel_red_text`). Every other color is a token too, read from QSS as `@name`; `tests/test_theme_tokens.py` fails on a literal hex outside a painter alpha wash. A view that tells marks apart by hue alone reads them by role from `styles/color_vision.py` (`palette_for(state.color_vision)`, the Preferences Color vision choice) and repaints on `session.color_vision_changed`. - **Geometry**: `ICON_BUTTON_WIDTH`, `FIELD_LABEL_WIDTH`, `default_button_height()`, `SCAN_BUTTON_HEIGHT` (the Scan buttons only) and the `THEME.space_*` scale. A row that needs a width already has one. A panel body has no side inset of its own; the section card insets. -- **Slider metadata**: unit in `unit=` (`"%"`, `" st"` stops, `" R"` ISO-R points, `" px"` — space before a word, none before a symbol), never in the label; decimals from `step`/`precision`. -- **Dialogs**: a hand-rolled footer calls `templates.pin_dialog_default(default, *others)`, which pins Enter, opts the rest out of `autoDefault` and marks the one filled button; a dialog with buttons in its body passes `scope=self` once built; a `QDialogButtonBox` gets `pin_button_box(box)`. Verbs: **Cancel** before one action verb (OK for a plain form, else Apply, Save, Scan); **Close** alone on a view-only dialog. Delete confirmations go through `view/confirm.py`. Do not re-declare the dialog background; the sheet paints it. A resizable dialog takes a keyword-only `repo` and ends `__init__` with `remember_dialog_geometry(self, repo, "")` (`widgets/dialog_geometry.py`); a fixed-size one calls `setFixedWidth`/`setFixedSize` instead. `tests/test_dialog_footers.py` and `tests/test_dialog_geometry.py` walk every dialog. -- **Labels**: control names Title Case ("Toe Width", "Paper White"); a label beside a combo/entry sentence case ("Film stock", "Input gamma"). Same concept, same words in every panel, so grep for the words before writing a new label. +- **Slider metadata**: unit in `unit=` (`"%"`, `" st"` stops, `" R"` ISO-R points, `" px"`; a space before a word, none before a symbol), never in the label; decimals from `step`/`precision`. +- **Dialogs**: a hand-rolled footer calls `templates.pin_dialog_default(default, *others)`, which pins Enter, opts the rest out of `autoDefault` and marks the one filled button. A dialog with buttons in its body passes `scope=self` once built. A `QDialogButtonBox` gets `pin_button_box(box)`. Verbs: **Cancel** before one action verb (OK for a plain form, else Apply, Save, Scan); **Close** alone on a view-only dialog. Delete confirmations go through `view/confirm.py`. Do not re-declare the dialog background; the sheet paints it. A resizable dialog takes a keyword-only `repo` and ends `__init__` with `remember_dialog_geometry(self, repo, "")` (`widgets/dialog_geometry.py`); a fixed-size one calls `setFixedWidth`/`setFixedSize` instead. `tests/test_dialog_footers.py` and `tests/test_dialog_geometry.py` walk every dialog. +- **Labels**: control names in Title Case ("Toe Width", "Paper White"); a label beside a combo/entry in sentence case ("Film stock", "Input gamma"). The same concept takes the same words in every panel, so grep for the words before you write a new label. - **Copy**: American spelling in every user-visible string and in `docs/` (Color, gray, center). Buttons, menu items and dialog/message-box titles are Title Case; a message-box title is the feature name. An item that opens a dialog, a picker or a confirmation ends in `…` (the character, never three dots); `tests/test_ui_copy.py` fails on three dots or a British spelling in a string. The feature is "Flat Field". No emoji. - **Tooltips**: every control gets one. `AppEventFilter` (`desktop/main.py`) wraps every plain tooltip app-wide and names an icon-only button after its first clause; `wrap_tooltip()` is for a footer or markup. A shortcut-bearing widget is tooltipped in `controls_panel.apply_shortcut_tooltips()` only; a local `setToolTip` there is overwritten. -- **Hide or gray out**: hide a single control the frame's film or mode can never use; gray out a group or a whole panel the mode cannot use, under one `hint_label` that says why; gray out a control that waits on a toggle or resource on the same frame. +- **Hide or gray out**: hide a single control the frame's film or mode can never use. Gray out a group or a whole panel the mode cannot use, under one `hint_label` that says why. Gray out a control that waits on a toggle or resource on the same frame. If no existing control fits, say so and propose the addition. Do not ship a one-off. ## Style -- Use **ASD-STE100 Simplified Technical English** -- **Comments minimal.** Comment only non-obvious constraints the code can't express (a cache contract, an ordering requirement, a rejected-alternative trap). Never narrate what the next line does, restate the diff or justify a change to a reviewer. One dense line beats a paragraph; docstrings short and factual. -- **Write the state, not the change.** Code, comments and docs describe how the thing works now. Never "used to", "no longer" or a war story about the bug: the symptom belongs in the commit message, and only there. A constraint that exists *because* of a past bug is written as the rule ("detection runs once, upstream of both engines"), not as its history. -- **No measurements in comments or docs.** Keep out timings, frame rates, file sizes, speedups, error deltas and sample counts from a test run ("113→31 ms", "2.7% further right", "one 35mm night scan"): they are true of one machine and one file, and they rot silently. State the constraint instead ("this scan is cached because it is the slowest step in a preview"). Numbers belong in the commit message, a test assertion or a report. Exceptions: a value the code depends on (a threshold, a limit, a unit) and a documented control range. -- **Budget the prose.** An inline comment is 1–2 lines, a docstring 1–4. A new control gets **one** `docs/USER_GUIDE.md` bullet; a stage change gets at most a short paragraph in `docs/PIPELINE.md`. Over budget means the point is buried: cut, don't reformat. Skip emphasis and rhetoric (bold, "on purpose", "this is not an optimization", em-dash asides, punchlines). +- Use **ASD-STE100 Simplified Technical English**. +- **Comments minimal.** Comment only non-obvious constraints the code cannot express (a cache contract, an ordering requirement, a rejected-alternative trap). Never narrate the next line, restate the diff or justify a change to a reviewer. One dense line beats a paragraph; docstrings short and factual. +- **Write the state, not the change.** Code, comments and docs describe how the thing works now. Never "used to", "no longer" or a war story about the bug: the symptom belongs in the commit message, and only there. Write a constraint that exists *because* of a past bug as the rule ("detection runs once, upstream of both engines"), not as its history. +- **No measurements in comments or docs.** No timings, frame rates, file sizes, speedups, error deltas or sample counts from a test run ("113→31 ms", "2.7% further right", "one 35mm night scan"): they are true of one machine and one file, and rot silently. State the constraint instead ("this scan is cached because it is the slowest step in a preview"). Numbers belong in the commit message, a test assertion or a report. Exceptions: a value the code depends on (a threshold, a limit, a unit) and a documented control range. +- **Budget the prose.** An inline comment is 1–2 lines, a docstring 1–4. A new control gets **one** `docs/USER_GUIDE.md` bullet; a stage change gets at most a short paragraph in `docs/PIPELINE.md`. Over budget means the point is buried: cut, do not reformat. Skip emphasis and rhetoric (bold, "on purpose", "this is not an optimization", em-dash asides, punchlines). - **Say it once.** A paragraph that states one constraint three ways buries it. A constraint shared by a function and its caller goes in the function that enforces it; the caller says nothing. -- **No notes to a reviewer.** A comment asking someone to confirm, verify or check something is stale the day it merges, and it survives the answer. Open questions and things still to test go in the PR description. `TODO` is for work the code needs, not a message to a person. +- **No notes to a reviewer.** A comment asking someone to confirm, verify or check something is stale the day it merges, and outlives the answer. Open questions and things still to test go in the PR description. `TODO` is for work the code needs, not a message to a person. - **End on the constraint.** The last sentence of a comment, a docstring or a doc paragraph is a fact, not a conclusion about it ("the one question the pipeline cannot answer about itself", "this is what Esc is for"). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a4d938423..a24c95689 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,28 +1,25 @@ # Contributing to NegPy -Thank you for your interest in contributing to **NegPy**! +## Claiming an Issue -## 🙋 Claiming an Issue +Contributors with read access cannot use GitHub's assignee UI, so comment on the issue: -Contributors with read access can't use GitHub's assignee UI, so NegPy lets you -self-assign by commenting on an issue: +- `/assign`: assign the issue to yourself +- `/unassign`: remove yourself from the issue -- `/assign` — assign the issue to yourself -- `/unassign` — remove yourself from the issue +A 👀 reaction on your comment confirms that the command ran. -A 👀 reaction on your comment confirms the command ran. +## Development Setup -## 🛠️ Development Setup - -NegPy requires **Python 3.13+**. We use **uv** for environment and dependency management. +NegPy requires Python 3.13+ and `uv`. ### 1. Prerequisites -Install [uv](https://docs.astral.sh/uv/getting-started/installation/) if you haven't already. +Install [uv](https://docs.astral.sh/uv/getting-started/installation/). -**Scanner support (optional):** +Scanner support (optional): -- **SANE** (Linux/macOS) — Coolscans and other SANE film scanners. Install the system library, then the `sane` group (`uv sync --group sane` or `pip install negpy[sane]`): - - **Linux** (Debian/Ubuntu): +- **SANE** (Linux/macOS): Coolscans and other SANE film scanners. Install the system library, then the `sane` group (`uv sync --group sane` or `pip install negpy[sane]`): + - **Linux**: ```bash sudo pacman -S sane # arch sudo apt install libsane-dev # debian/ubuntu @@ -32,13 +29,12 @@ Install [uv](https://docs.astral.sh/uv/getting-started/installation/) if you hav brew install sane-backends ``` -- **Plustek USB** (Windows/macOS/Linux) — optional [pyopticfilm](https://github.com/jboneng/pyopticfilm) driver (8200i SE and 8100 V2): `uv sync --group plustek` or `pip install negpy[plustek]`. Windows installs `libusb-package` via pyopticfilm (bundled in release builds). On Windows, bind WinUSB with Zadig for the scanner's USB id (`07b3:1825` 8200i SE, `07b3:1824` 8100 V2) before scanning (vendor/SilverFast drivers conflict). See [docs/PLUSTEK_WINDOWS.md](docs/PLUSTEK_WINDOWS.md). +- **Plustek USB** (Windows/macOS/Linux): the [pyopticfilm](https://github.com/jboneng/pyopticfilm) driver: `uv sync --group plustek` or `pip install negpy[plustek]`. On Windows, pyopticfilm installs `libusb-package`, and the scanner needs WinUSB bound with Zadig in place of the vendor/SilverFast driver (`07b3:1825` 8200i SE, `07b3:1824` 8100 V2). See [docs/PLUSTEK_WINDOWS.md](docs/PLUSTEK_WINDOWS.md). -- **Nikon Coolscan** (Windows/macOS/Linux) — [nkscan](https://github.com/activexray/nkscan) 0.12 or newer, shipped as wheels: `uv sync --group nkscan` or `pip install negpy[nkscan]`. On Linux, USB needs a udev rule for vendor `04b0`; FireWire/SCSI needs the `sg` module. +- **Nikon Coolscan** (Windows/macOS/Linux): [nkscan](https://github.com/activexray/nkscan) 0.12 or newer, shipped as wheels: `uv sync --group nkscan` or `pip install negpy[nkscan]`. On Linux, USB needs a udev rule for vendor `04b0`, and FireWire/SCSI needs the `sg` module. ### 2. Python Environment -The `Makefile` handles synchronization via `uv`. Run this to set up your environment: ```bash make install @@ -54,43 +50,37 @@ make run #### A separate user directory for development NegPy keeps its databases, caches, presets, logs and `override.toml` in one user -directory — `Documents/NegPy` by default. If you also use NegPy for real work, point -your development builds at a different directory, so that test edits and stale caches -cannot touch the real one. - -Set `NEGPY_USER_DIR` to an absolute path. The `Makefile` reads an optional, gitignored -`.env.local`: +directory, `Documents/NegPy` by default. If you also use NegPy for real work, give your +development builds their own directory: set `NEGPY_USER_DIR` to an absolute path in an +optional, gitignored `.env.local`, which the `Makefile` reads: ```make # .env.local NEGPY_USER_DIR = $(HOME)/negpy-devhome ``` -`make run` then uses that directory, and creates it if it is missing. Without the file, -nothing changes. +`make run` then uses (and creates) that directory. -Write `$(HOME)`, not `~` or `$HOME`: make leaves `~` alone, and reads `$HOME` as the -variable `H` followed by `OME`. Either one resolves to a path inside the checkout, so -make stops and tells you when the value is not absolute. Note that the path must also -be right for the platform — `/home/you/...` is a Linux path, and macOS puts your home -under `/Users/you`. +Write `$(HOME)`, not `~` or `$HOME`: make leaves `~` alone and reads `$HOME` as `$H` +followed by `OME`. make stops when the value is not absolute. On macOS your home is under +`/Users/you`, not `/home/you/...`. -To start again with no saved edits and no caches: +To reset it: ```bash make clear-devhome ``` -The target deletes the whole directory after a confirmation (`FORCE=1` skips it), and -refuses to run if `NEGPY_USER_DIR` is unset or points at the default directory. An -`override.toml` you rely on lives in there too, so keep a copy to put back. +This deletes the whole directory after a confirmation (`FORCE=1` skips it). It refuses to +run if `NEGPY_USER_DIR` is unset or points at the default directory. Keep a copy of any +`override.toml` you rely on. Two things stay outside the user directory: -- `.negpy` sidecars, which are written next to the source images. A sidecar written by a - development build is promoted into the database of whichever install opens that image - next. Sidecar export is off by default; if you turn it on, test against copies. -- Exports, wherever you send them. +- `.negpy` sidecars next to the source images. The next install that opens an image + promotes its sidecar into its database. Sidecar export is off by default; if you turn it + on, test against copies. +- Exports. #### Running without scanning hardware @@ -100,56 +90,50 @@ make run-sim Starts the app with a simulated camera, Scanlight and film scanners (`NEGPY_SIMULATE_HARDWARE=1`). Only the gphoto2 module, the serial port and one scanner -backend are fake; the drivers, workers and panels above them are the real code. +backend are fake. -- **Camera**: frames are exposed from a synthetic negative under the Scanlight's color and - the body's shutter and ISO, and saved as Bayer DNGs. Put the calibration ROI on the clear - base band around the picture. +- **Camera**: exposes frames from a synthetic negative under the Scanlight's color and the + body's shutter and ISO, saved as Bayer DNGs. Put the calibration ROI on the clear base + band around the picture. - **Scanlight**: a v4b that answers the firmware query and reports LED temperature. - **Film Scanner**: the "Simulated" backend has a feeder (frames by index, IR, Eject), a prescan device (full-window preview, then crop) and a roll device (frames found on the strip). -## 🏗️ Project Structure - -The codebase follows a modular architecture: +## Project Structure -- `negpy/domain/`: Core data models, types, and interfaces. -- `negpy/features/`: Image processing logic implementations (Exposure, Geometry, Lab, etc.). -- `negpy/infrastructure/`: Low-level system implementations (GPU resources, file loaders). -- `negpy/kernel/`: Core system services (Logging, Config, caching). -- `negpy/services/`: High-level orchestration (Rendering engine, Export service). -- `negpy/desktop/`: PyQt6 UI implementation (View, Controller, Workers). -- `tests/`: Unit and integration tests. +- `negpy/domain/`: core data models, types and interfaces. +- `negpy/features/`: image processing logic (Exposure, Geometry, Lab, etc.). +- `negpy/infrastructure/`: low-level system code (GPU resources, file loaders). +- `negpy/kernel/`: core system services (Logging, Config, caching). +- `negpy/services/`: high-level orchestration (Rendering engine, Export service). +- `negpy/desktop/`: PyQt6 UI (View, Controller, Workers). +- `tests/`: unit and integration tests. -## 📐 Coding Standards +## Coding Standards -**Always run `make format` before committing.** +Always run `make format` before committing. ### 1. Style & Formatting -- **Ruff**: Used for both linting and formatting. -- **Type Hints**: Required for all new function definitions (`ty` is enforced). Using `cast` to get around it is frowned upon. -- **Docstrings**: Use clear, concise docstrings for classes and public methods. -- **Style**: Use double quotes for strings, snake_case for variables and functions, and PascalCase for classes. +- Ruff lints and formats. +- Type hints are required on every new function definition (`ty` is enforced). Do not use `cast` to get around it. +- Clear, short docstrings on classes and public methods. +- Double quotes for strings, snake_case for variables and functions, PascalCase for classes. ### 2. Testing -We use `pytest`. New features should include unit tests in the `tests/` directory. +New features include unit tests (`pytest`) in `tests/`. ```bash make test ``` -`make test` skips tests marked `slow` by default (see `addopts` in `pyproject.toml`). This includes the performance metrics suite in `tests/metrics/`. - -To run the metrics tests and write a JSON results file: +`make test` skips tests marked `slow` (see `addopts` in `pyproject.toml`), including the performance metrics suite in `tests/metrics/`. To run it and write a JSON results file: ```bash NEGPY_METRICS_OUT=metrics.json uv run pytest tests/metrics/ -m "slow" -q ``` -Fixtures (Canon CR2, Nikon NEF, Sony ARW, Fuji RAF, Leica DNG) are downloaded automatically from rawsamples.ch on first run (~20–30 MB each) and cached in `~/.cache/negpy-metrics/`. Tests skip gracefully if a download fails. - -To point a test at a local file instead of downloading, set the per-format env var: +The fixtures (Canon CR2, Nikon NEF, Sony ARW, Fuji RAF, Leica DNG) download from rawsamples.ch on the first run (~20–30 MB each) and are cached in `~/.cache/negpy-metrics/`. A test skips if its download fails. To use a local file, set the per-format env var: ```bash NEGPY_PERF_RAW_CR2=/path/to/file.CR2 \ @@ -160,29 +144,27 @@ uv run pytest tests/metrics/ -m "slow" -q Available overrides: `NEGPY_PERF_RAW_CR2`, `NEGPY_PERF_RAW_NEF`, `NEGPY_PERF_RAW_ARW`, `NEGPY_PERF_RAW_RAF`, `NEGPY_PERF_RAW_DNG`. ### 3. Workflow (The Makefile) -The `Makefile` is the central source of truth for developer commands and executes everything via `uv run`: -- `make install`: Set up environment and sync dependencies. -- `make lint`: Run Ruff checks. -- `make type`: Run `ty` type checks. -- `make test`: Run all unit tests. -- `make format`: Auto-format code with Ruff. -- `make all`: Run lint, type, and test in sequence. -- `make clean`: Removes cache and build artifacts. -- `make run-sim`: Runs the app with simulated scanning hardware (see [above](#running-without-scanning-hardware)). -- `make clear-devhome`: Deletes the development user directory (see [above](#a-separate-user-directory-for-development)). +Every developer command runs through `uv run`: +- `make install`: set up and sync the environment. +- `make lint`: Ruff checks. +- `make type`: `ty` type checks. +- `make test`: unit tests. +- `make format`: auto-format with Ruff. +- `make all`: lint, type and test in sequence. +- `make clean`: remove cache and build artifacts. +- `make run-sim`: run with simulated scanning hardware (see [above](#running-without-scanning-hardware)). +- `make clear-devhome`: delete the development user directory (see [above](#a-separate-user-directory-for-development)). -## 📦 Building and Packaging +## Building and Packaging -To build the standalone application for your current OS: +To build the standalone application for your current OS with PyInstaller: ```bash make build ``` -This will trigger the Python backend build via PyInstaller. -On macOS, you can choose the target architecture for the DMG build with `NEGPY_MACOS_ARCH`. -For an Intel build from a compatible macOS environment, run: +On macOS, `NEGPY_MACOS_ARCH` sets the DMG's target architecture. For an Intel build: ```bash NEGPY_MACOS_ARCH=x86_64 make build diff --git a/README.md b/README.md index d1dc904ea..670c1b7f2 100644 --- a/README.md +++ b/README.md @@ -12,75 +12,72 @@ [![Discord](https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/JySNzUWgwy) -NegPy turns scans of film negatives and slides into finished pictures. I wrote it because I wanted a tool made for film scans that does more than invert them. It models film and photographic paper, and adds the conveniences of a lab scanner on top. - -It is written in Python and runs on Linux, macOS and Windows. +NegPy turns scans of film negatives and slides into finished pictures. I wrote it because I wanted a tool made for film scans that does more than invert them. It models film and photographic paper, and adds the conveniences of a lab scanner. It is written in Python and runs on Linux, macOS and Windows. ![NegPy main window](docs/media/0620.png) -The [User Guide](docs/USER_GUIDE.md) covers every panel and control. The same text opens inside the app from the ⓘ on each panel. [PIPELINE.md](docs/PIPELINE.md) explains the math. +The [User Guide](docs/USER_GUIDE.md) covers every panel and control (the ⓘ on each panel opens it in the app). [PIPELINE.md](docs/PIPELINE.md) explains the math. ## Features ### Conversion -* No camera profiles and no border color picking. The orange mask is removed from each channel's own sensitometry. -* The print is built in density space on an H&D curve with a toe, a straight section and a shoulder, and graded in ISO-R points like darkroom paper. -* Auto Density and Auto Grade meter each frame, so a conversion is usable before you touch a slider. You can set your own targets. -* Paper profiles taken from Ilford, Kodak, Foma and Fuji datasheets. +* The orange mask is removed from each channel's own sensitometry: no camera profiles, no border color picking. +* The print is built in density space on an H&D curve (toe, straight section, shoulder) and graded in ISO-R points like darkroom paper. +* Auto Density and Auto Grade meter each frame to adjustable targets. +* Paper profiles from Ilford, Kodak, Foma and Fuji datasheets. * Color negative, B&W and slide modes. Slides open as captured, with optional normalization for faded film and HDR merging of bracketed exposures. ### Rolls -* The library is a list of rolls. A roll is a folder you import, or a virtual roll you build from any frames. -* Roll-wide defaults for film mode, crop, calibration, metering, raw decode, lens and metadata. A frame you change stays yours until you push the roll setting back to it. -* Roll Analysis measures the whole roll and gives every frame the same baseline. Scenes split a roll into groups that each get their own. -* Keep/reject triage, half-frame splitting, stitching of multi-shot scans, and search by metadata or (opt-in) by what is in the picture. +* The library is a list of rolls: imported folders, or virtual rolls built from any frames. +* Roll-wide defaults for film mode, crop, calibration, metering, raw decode, lens and metadata, with per-frame overrides. +* Roll Analysis gives every frame of a roll, or of a scene within it, the same baseline. +* Keep/reject triage, half-frame splitting, stitching, and search by metadata or (opt-in) content. ### Capture -* Film scanners: Plustek OpticFilm 8200i SE and 8100 V2 over USB, Nikon Coolscan through its own driver, Reflecta and Pacific Image, and anything SANE supports. -* Tethered camera scanning, including red/green/blue narrowband captures with an RGB [Scanlight](https://github.com/jackw01/scanlight). macOS and Linux only. See the [Camera Scanning guide](docs/CAMERA_SCANNING.md). -* Trichrome merge of three narrowband exposures, with sub-pixel alignment. +* Film scanners: Plustek OpticFilm 8200i SE and 8100 V2 over USB, Nikon Coolscan, Reflecta, Pacific Image, and anything SANE supports. +* Tethered camera scanning (macOS and Linux), including narrowband RGB captures with a [Scanlight](https://github.com/jackw01/scanlight) and a sub-pixel Trichrome merge ([guide](docs/CAMERA_SCANNING.md)). * Flat-field correction, sensor calibration for narrowband light, and lens correction read from the file. * Camera RAWs, TIFF, DNG, JPEG XL and scanner formats such as Pakon, Coolscan NEF, Flextight FFF and Noritsu. ### Editing -* Dodge and burn with polygon, oval and card-edge masks. Each mask has its own strength, feather, contrast grade and tone limit. -* Dust, hair and scratch removal: automatic, from the scanner's IR channel (NegPy or OpenICE method), or painted by hand. Heals keep the film grain. -* Darkroom tools: test strips, a color ring-around, a step wedge, a zone overlay, a spot densitometer and zone placement. +* Dodge and burn with polygon, oval and card-edge masks, each with its own strength, feather, contrast grade and tone limit. +* Dust, hair and scratch removal: automatic, from the IR channel (NegPy or OpenICE method), or painted, keeping the film grain. +* Darkroom tools: test strips, ring-around, step wedge, zone overlay, spot densitometer and zone placement. * Contrast mask, tilt and swing, edge burn, filed carrier and print mats. -* Lith and cyanotype processes and a set of toners for B&W. -* GPU rendering through Vulkan, Metal or DX12, with a CPU fallback that gives the same result. +* Lith, cyanotype and toners for B&W. +* GPU rendering (Vulkan, Metal or DX12) with a CPU fallback that gives the same result. ### Output -* ICC color management with monitor profile detection and soft proofing for paper and printer profiles. +* ICC color management, monitor profile detection and soft proofing. * Export to JPEG, TIFF, PNG, WebP and JPEG XL, with borders, presets, contact sheets and [filename templates](docs/TEMPLATING.md). -* A flat 16-bit TIFF master for Lightroom, Darktable or Photoshop, or a linear export that skips the pipeline. -* Gear and capture metadata (camera, lens, film, development, date and place) written to EXIF and XMP. +* A flat 16-bit TIFF master for other editors, or a linear export that skips the pipeline. +* Gear and capture metadata written to EXIF and XMP. ### Your data -* Non-destructive. Source files are never modified. -* Edits live in a local SQLite database keyed by file content, so you can move and rename files. Optional `.negpy` sidecars sit next to the scans. +* Non-destructive: source files are never modified. +* Edits live in a local SQLite database keyed by file content, so files can move. Optional `.negpy` sidecars sit next to the scans. * Undo history and named work prints survive a restart. * [Keyboard shortcuts](docs/KEYBOARD.md) can be remapped. ## Getting started -Download the build for your OS from the [Releases page](https://github.com/marcinz606/NegPy/releases). After that, NegPy updates itself: when a new release is out it shows an Update Available notice, and one click downloads and installs it. +Download the build for your OS from the [Releases page](https://github.com/marcinz606/NegPy/releases). NegPy then updates itself from an Update Available notice. To build from source, see [CONTRIBUTING.md](CONTRIBUTING.md). -The builds are not signed. It is a free hobby project and I don't pay Apple or Microsoft for developer certificates, so expect a warning the first time you run it. +The builds are not signed, so expect a warning on first run. ### Linux Make the `.AppImage` executable with `chmod +x` and run it. -Scanning through SANE needs SANE installed. Tethered camera scanning may need `libgphoto2`. The app runs without either. +Optional, for SANE and camera scanning: ``` sudo apt install libsane # Debian/Ubuntu sudo pacman -S sane libgphoto2 # Arch @@ -104,36 +101,36 @@ Or add it as a flake input: ### macOS 1. Open the `.dmg` and drag NegPy to `/Applications`. -2. Run `xattr -cr /Applications/NegPy.app` in Terminal to clear the warning. +2. Run `xattr -cr /Applications/NegPy.app` in Terminal. 3. Launch it. -SANE scanning and camera scanning use [Homebrew](https://brew.sh/) packages. The app runs without them. +Optional, for SANE and camera scanning ([Homebrew](https://brew.sh/)): ``` brew install sane-backends libgphoto2 ``` -To build the DMG yourself, set `NEGPY_MACOS_ARCH=x86_64` for Intel or `NEGPY_MACOS_ARCH=arm64` for Apple Silicon. +To build the DMG, set `NEGPY_MACOS_ARCH=x86_64` (Intel) or `NEGPY_MACOS_ARCH=arm64` (Apple Silicon). ### Windows -Run the installer and click through the warnings. +Run the installer past the warnings. -Plustek OpticFilm 8200i SE and 8100 V2 scanners need the WinUSB driver. Install it with [Zadig](https://zadig.akeo.ie/) in place of the SilverFast driver (`07b3:1825` for the 8200i SE, `07b3:1824` for the 8100 V2). The release build includes everything else. See [PLUSTEK_WINDOWS.md](docs/PLUSTEK_WINDOWS.md). Camera scanning is not available on Windows, because libgphoto2 has no Windows build. +Plustek OpticFilm scanners need the WinUSB driver, installed with [Zadig](https://zadig.akeo.ie/) in place of the SilverFast driver. See [PLUSTEK_WINDOWS.md](docs/PLUSTEK_WINDOWS.md). Camera scanning is not available on Windows. ## Data location -Everything is in `Documents/NegPy`. On Windows, if that folder is blocked, NegPy asks for another one and suggests Local AppData. The `NEGPY_USER_DIR` environment variable overrides both. +Everything is in `Documents/NegPy`. On Windows, if that folder is blocked, NegPy asks for another and suggests Local AppData. `NEGPY_USER_DIR` overrides both. * `edits.db`: your edits. -* `settings.db`: app settings such as the last export settings. +* `settings.db`: app settings. * `cache/`: thumbnails. Safe to delete. * `export/`: default export folder. * `icc/`: paper and printer profiles. -* `override.toml`: startup overrides, see below. +* `override.toml`: startup overrides (below). ## Troubleshooting -If NegPy crashes on startup or renders wrong, edit `override.toml`. NegPy writes it on first run with defaults for your OS. Most `[performance]` values are also in Preferences (`Ctrl + ,`), but the file wins, so you can fix things when the app will not start. +If NegPy crashes on startup or renders wrong, edit `override.toml` (written on first run). Most `[performance]` values are also in Preferences (`Ctrl + ,`), but the file wins. ```toml [rendering] @@ -168,7 +165,7 @@ max_texture_size = "auto" level = "info" ``` -`backend = "cpu"` turns GPU rendering off, for when the GPU backend crashes on your hardware. +`backend = "cpu"` turns GPU rendering off if the GPU backend crashes. ## More diff --git a/crosstalk/README.md b/crosstalk/README.md index 6ef65d838..6dbde16c2 100644 --- a/crosstalk/README.md +++ b/crosstalk/README.md @@ -3,40 +3,35 @@ Community-contributed channel-unmix matrices for NegPy's **Crosstalk** controls (Calibration panel: *Matrix* + *Strength*). -Every `.toml` here is bundled with the app and read live, next to the user's own -matrices in `/NegPy/crosstalk/`, so they show up in the sidebar dropdown -out of the box and update with each release. - -A matrix describes a **scanning setup**, not only a film. The dyes' unwanted absorptions -are one cause of channel mixing; the light source's spectrum and the sensor's colour -filters are others, and in the density domain all three look the same. So a datasheet-derived -matrix is a starting point, and one tuned on real scans is usually better — name yours after -the whole combination (film + light + camera) rather than just the stock. - -The matrices currently in this folder are all **(approx)**: read off published spectral-dye-density -spec sheets rather than measured, and describing the dyes alone. That makes them a reasonable -starting point for a **Narrowband Scanner** (a Coolscan's mono sensor reads one LED at a time, -fully clean; a Pakon's trilinear array comes close, with slight residual bleed), a **Trichrome** -capture, or a Single-Shot Narrowband rig with Single-Shot Narrowband Calibration applied, and an -incomplete -story for a broadband light plus a Bayer sensor, where the -capture adds mixing of its own. **Measured profiles are especially welcome** — say so in the -PR if yours came from real scans rather than a datasheet, and drop the `(approx)` from its name. +NegPy bundles every `.toml` here and reads it in place, beside the user's own matrices in +`/NegPy/crosstalk/`. They show in the sidebar dropdown from the first launch and +update with each release. + +A matrix describes a **scanning setup**, not only a film: the dyes, the light source's +spectrum and the sensor's color filters all mix the channels, and in the density domain +they look the same. Name yours after the whole combination (film + light + camera). + +All matrices in this folder are **(approx)**: read off published spectral-dye-density spec +sheets, not measured, and they describe the dyes alone. They are a good start for a +**Narrowband Scanner** (a Coolscan's mono sensor reads one LED at a time, fully clean; a +Pakon's trilinear array comes close, with slight residual bleed), a **Trichrome** capture, +or a Single-Shot Narrowband rig with Single-Shot Narrowband Calibration applied. With a +broadband light and a Bayer sensor they are incomplete. Measured profiles are especially +welcome: say so in the PR, and drop the `(approx)` from the name. ## Film process -Each profile declares the film it describes; the dropdown and the render both gate on it. +Each profile declares the film it describes. The dropdown and the render both gate on it. ```toml -process = "E-6" # or "C41" — the default when absent +process = "Transparency" # or "Color Negative" (the default when absent) ``` -Every matrix here is currently `C41`; none ships for E-6, so on slides the dropdown starts -empty and disabled until a matching profile exists (the in-app matrix editor can create one, -and stamps it with the process in use). If you add one, note that it means something different -there: a transparency is the finished image, so unmixing its dyes moves the render away from -the slide's own appearance rather than toward the scene — a separation/punch control, not a -correction. See [`../docs/CROSSTALK.md`](../docs/CROSSTALK.md). +The old names `C41` and `E-6` still load. Every matrix here is Color Negative. None ships +for Transparency, so on slides the dropdown starts empty and disabled until a matching +profile exists (the in-app matrix editor can create one, stamped with the process in use). +On a transparency, unmixing moves the render away from the slide's own appearance: a +separation control, not a correction. See [`../docs/CROSSTALK.md`](../docs/CROSSTALK.md). ## Contributing @@ -46,7 +41,7 @@ correction. See [`../docs/CROSSTALK.md`](../docs/CROSSTALK.md). ```toml name = "Kodak Ektacolor Pro 400 (Noritsu)" # optional display name; falls back to filename type = "measured" # measured | tuned | specsheet-based - process = "C41" # C41 | E-6 — which film's dyes; C41 when absent + process = "Color Negative" # Color Negative | Transparency; Color Negative when absent matrix = [ # 3x3, row-major (out R/G/B × in R/G/B) [ 1.00, -0.05, -0.02], [-0.04, 1.00, -0.08], @@ -54,10 +49,10 @@ correction. See [`../docs/CROSSTALK.md`](../docs/CROSSTALK.md). ] ``` -3. Name the file after the setup it was calibrated for — film stock plus light source and +3. Name the file after the setup it was calibrated for: film stock plus light source and camera/scanner where you know them (`ektacolor_pro_400_scanlight_a7c2.toml`). -4. Note in your PR how the matrix was derived (datasheet, test chart, or tuned by eye on - real scans — all three are welcome, just say which) and what rig it came from. +4. In your PR, say how you derived the matrix (datasheet, test chart, or tuned by eye on + real scans) and what rig it came from. -Keep the diagonal near `1.0` and off-diagonal terms small — NegPy row-normalizes -the matrix, so it only redistributes color *differences* between channels. +Keep the diagonal near `1.0` and the off-diagonal terms small. NegPy row-normalizes the +matrix, so it only redistributes color *differences* between channels. diff --git a/docs/CAMERA_SCANNING.md b/docs/CAMERA_SCANNING.md index 35e4d8b86..b49e7d589 100644 --- a/docs/CAMERA_SCANNING.md +++ b/docs/CAMERA_SCANNING.md @@ -1,227 +1,182 @@ # Camera Scanning The **Camera Scanning** tab captures negatives with a tethered camera and imports them -straight into NegPy. There is no separate capture app and no folder shuffling. It has two -modes, and it selects between them from the hardware it finds. - ---- +into NegPy. It has two modes and picks one from the hardware it finds. ## What it does -**Normal camera scanning.** One exposure of the frame under whatever light you use, -imported as an ordinary RAW and processed like any other file. This needs only a supported -camera. +**Normal camera scanning.** One exposure of the frame under any light, imported as an +ordinary RAW. This needs only a supported camera. **Narrowband RGB scanning.** With an RGB [Scanlight](https://github.com/jackw01/scanlight) connected, the light flashes red, then green, then blue, and the camera takes one exposure -per channel. NegPy's **Trichrome** merge sub-pixel-aligns the three RAWs and assembles one -frame before inversion. - -Three shots beat one because a single broadband exposure lets each dye layer contaminate -the neighboring channels. The green Bayer filter is the broadest of the three, so it -catches leakage from the red and the blue light at once. One narrow band at a time removes -that crosstalk by construction, and every channel gets the full dynamic range of the sensor -instead of sharing it. +per channel. NegPy's **Trichrome** merge sub-pixel-aligns the three RAWs into one frame +before inversion. One narrow band at a time removes the crosstalk between dye layers, and +each channel gets the full dynamic range of the sensor. **Single-capture narrowband scanning.** An RGB preset can instead light red, green and blue -together for one exposure. The frame is imported as an ordinary RAW, and its roll is -recorded with Trichrome Mode off. It takes one shutter actuation per frame and has no registration between channels. -The channels overlap on the sensor, so pair it with a -[sensor calibration](#sensor-calibration) profile. - ---- +together for one exposure: one shutter actuation per frame and no registration. The frame +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. ## What you need | | | |---|---| -| **Camera** | Any body [libgphoto2 can drive](http://gphoto.org/proj/libgphoto2/support.php) with live view and remote capture. A body missing from that list often still works through the generic PTP driver. An a7C II does. | -| **python-gphoto2** | An optional dependency, free software (LGPL). `pip install gphoto2`. | -| **Scanlight** *(optional)* | Needed for narrowband RGB only. Normal camera scanning works without it. | +| **Camera** | Any body [libgphoto2 can drive](http://gphoto.org/proj/libgphoto2/support.php) with live view and remote capture. A body missing from that list often works through the generic PTP driver (an a7C II does). | +| **python-gphoto2** | Optional dependency, LGPL. `pip install gphoto2`. No vendor SDK is bundled, linked or required. | +| **Scanlight** *(optional)* | Needed for narrowband RGB only. | -**NegPy runs well without any of this.** Without python-gphoto2, the Camera Scanning tab -shows a one-line setup hint, and every other part of NegPy is unaffected. Nothing -proprietary is involved: libgphoto2 is LGPL, and no vendor SDK is bundled, linked or -required. +Without python-gphoto2, the Camera Scanning tab shows a one-line setup hint and the rest +of NegPy works as usual. -> ⚠️ **macOS and Linux only.** libgphoto2 has no Windows build, so there are no Windows +> **macOS and Linux only.** libgphoto2 has no Windows build, so there are no Windows > wheels and the tab cannot connect there. ---- - ## Setup ```bash uv sync --group camera # or: pip install gphoto2 ``` -That is the whole install. libgphoto2 ships inside the wheel, so there is nothing else to -download, build or place. If you package the app yourself, run that same command before -`make build`. The build then bundles libgphoto2's camera drivers. Skip it and the packaged -app shows the setup hint. - -Then put the camera in **PC Remote** mode and plug it in over USB. NegPy detects it -automatically. There is no address to type, no login and no pairing. +libgphoto2 ships inside the wheel. If you package the app yourself, run the same command +before `make build` to bundle libgphoto2's camera drivers; without it, the packaged app +shows the setup hint. -> On macOS a PTP camera is claimed by the system camera daemon as soon as nothing else holds -> it, and it does not let go again for minutes. NegPy therefore takes the claim the moment the -> Camera Scanning panel sees the body and keeps it: while that panel is open the camera belongs -> to NegPy, and Preview, Photos, Image Capture and other tethering software cannot use it. -> Unplugging the cable hands it back. -> -> What wakes that daemon is any application asking for a camera, and it does not have to be one -> you opened. A background sync client is the common case, silently and repeatedly: check for -> those first if the camera reads as in use with nothing visible running. +Put the camera in **PC Remote** mode and plug it in over USB. NegPy detects it. ---- +> On macOS the system camera daemon claims a free PTP camera and keeps it for minutes, so +> NegPy claims the body as soon as the Camera Scanning panel sees it. While that panel is +> open, Preview, Photos, Image Capture and other tethering software cannot use the camera. +> Unplug the cable to release it. If the camera reads as in use with nothing visible +> running, see [Troubleshooting](#troubleshooting). ## Scanning -**Frame and focus.** Open **Live View**. Click anywhere on the image to aim the -camera's *hardware* focus magnifier at that spot. Click again to return to the full frame. -The **Focus meter** under the image reads live sharpness against the best value since the -view last changed. A click changes the view. So does the body's own magnifier: Sony's MF -Assist zooms in when the focus ring turns, and **Focus Magnif. Time** zooms back out after -2 s or 5 s. Set it to **No Limit** to keep the zoom while you focus. Turn the focus ring past -best focus, then back until it reads **at peak**. It works on every body with live view. -In white-light and normal (camera-only) scanning, you can set ISO, shutter and aperture -live from the toolbar. With a calibrated RGB preset those controls are hidden and locked to -the preset instead (see **Presets**), so the scan cannot drift. A control the body cannot -offer is grayed out. Aperture on a lens with no electronic diaphragm is the usual case, and -that is most enlarging and macro glass. - -A thin border marks the edge of the captured frame. +**Frame and focus.** Open **Live View**. Click the image to aim the camera's *hardware* +focus magnifier at that spot; click again for the full frame. The **Focus meter** under the +image compares live sharpness with the best value since the view last changed. A click +changes the view, and so does the body's own magnifier: Sony's MF Assist zooms in when the +focus ring turns, and **Focus Magnif. Time** zooms back out after 2 s or 5 s (set it to +**No Limit** to keep the zoom). Turn the focus ring past best focus, then back until the +meter reads **at peak**. It works on every body with live view. + +In white-light and camera-only scanning, set ISO, shutter and aperture live from the +toolbar. A calibrated RGB preset hides and locks them (see **Presets**). A control the body +cannot offer is grayed out, usually aperture on a lens with no electronic diaphragm (most +enlarging and macro glass). A thin border marks the edge of the captured frame. **Histogram** and **Zebras** on the toolbar read the preview frame. The histogram shows R, G -and B under the image; the zebras hatch pixels at the top (red, 99% and above) and the bottom -(blue, 1% and below) of the preview range. The preview is the body's JPEG, which clips before -the RAW does, so a zebra marks a region close to the limit with some RAW headroom left. On a -negative the clear base must stay under the top; on a slide the picture's highlights must. - -**Meter (white-light and camera-only scanning).** Press **Meter** to take one RAW probe at the -current exposure. The preset's film type decides what must not clip. On **White Light, B&W -Negative**, or a color negative on the AUTO preset, the clear base, read in the rebate around -the picture, is placed at 90% of the RAW range in the brightest channel, the same target -Calibrate uses. On **White Light, Slide** the picture's brightest highlights are. The AUTO -preset classifies the probe the way an import does, so a slide it misreads is metered as a -negative until you pick the Slide preset. A capture with no rebate, or a rebate no brighter -than the picture, meters the picture's highlights either way. - -The shutter moves to the body's nearest step at or under that exposure, as a counted exposure -write: Scan waits until the body confirms it. The status line reads, for example, -`Meter: negative, base at 62% → 1/15 (+0.5 st)`, the stops being the move that step makes. -A clipped base, or highlights clipped in the picture's brightest 0.1%, cannot be measured: the -shutter steps down two stops and the line asks for another probe, which then reads them. The -line also names the share of the picture that clipped. A target past the body's fastest step is -reported with the remedy, close the aperture or lower the ISO, and nothing is written. Under the -Scanlight, Meter needs the shutter on a step between 1/250 s and 2 s, the span Calibrate solves -on (a shorter exposure integrates too few of the LED's PWM pulses); camera-only scanning meters -on the body's whole ladder and lets the probe decide the film type, as its import does. It waits -while a scan, a calibration or another probe runs. A negative's base is the same across a -roll, so one reading serves it; a slide's highlights differ on every frame, so meter each -one. After a probe the zebras switch to the RAW: they hatch where the RAW will clip at the -shutter the Meter set, predicted from the probe (the RAW is linear, so a level scales exactly -with the exposure; a pixel the probe itself clipped stays hatched until a probe reads it). They -hold while the ISO, shutter, aperture and light are the metered ones and the preview is still -the same picture; a film advance, a reframe or the focus magnifier hands them back to the -preview's zebras. The blue shadow hatching stays the preview's. The map is used only when the -first preview at the metered exposure frames the same picture as the probe: the same aspect, and -the same picture by rank correlation of thumbnails, which the JPEG's tone curve cannot change. -A body whose live view crops or reframes the RAW fails it, and the status line says -`RAW zebras off:` with the reason. In-camera lens correction moves the preview's edges by less -than one block of the map and is not checked. A calibrated RGB preset owns its exposure, and the button is hidden with the exposure -steppers. - -**Calibrate (RGB mode).** Set the ISO and the aperture you will scan with. Press **+** -beside the preset dropdown, place the small rectangle on the clear film base, name the -preset and run it. The rebate strip between frames is an ideal target. Calibration meters -that patch and solves one shared shutter plus a per-channel LED level, so each channel -lands just under clipping. It records the ISO and the aperture with them. - -The dropdown beside the name picks the capture mode. **Triplet** solves each channel under +and B. The zebras hatch the top (red, 99% and above) and the bottom (blue, 1% and below) of +the preview range. The preview is the body's JPEG, which clips before the RAW, so a zebra +leaves some RAW headroom. On a negative the clear base must stay under the top; on a slide +the picture's highlights must. + +**Meter (white-light and camera-only scanning).** Press **Meter** to take one RAW probe at +the current exposure. On **White Light, B&W Negative**, or a color negative on the AUTO +preset, the clear base (read in the rebate around the picture) goes to 90% of the RAW range +in the brightest channel, the same target as Calibrate. On **White Light, Slide** the +picture's brightest highlights go there. AUTO classifies the probe as an import does, so it +meters a misread slide as a negative until you pick the Slide preset. With no rebate, or a +rebate no brighter than the picture, Meter uses the picture's highlights. + +The shutter moves to the body's nearest step at or under that exposure, as a counted +exposure write: Scan waits until the body confirms it. The status line reads, for example, +`Meter: negative, base at 62% → 1/15 (+0.5 st)` (the stops are the move that step makes), +and gives the share of the picture that clipped. If the base, or the picture's brightest +0.1%, is clipped, the shutter steps down two stops and the line asks for another probe. For +a target past the body's fastest step, the line says to close the aperture or lower the +ISO, and writes nothing. + +Under the Scanlight, Meter needs the shutter between 1/250 s and 2 s, the span Calibrate +solves on. Camera-only +scanning meters on the body's whole ladder and lets the probe decide the film type. Meter +waits while a scan, a calibration or another probe runs. One reading serves a roll of +negatives; meter each slide. + +After a probe the zebras switch to the RAW: they hatch where the RAW will clip at the +metered shutter, predicted from the linear probe (a pixel the probe clipped stays hatched +until a probe reads it). They hold while the ISO, shutter, aperture and light are the +metered ones and the preview shows the same picture. A film advance, a reframe or the focus +magnifier returns to the preview's zebras. The blue shadow hatching always comes from the +preview. The map is used only when the first preview at the metered exposure has the same +aspect as the probe and matches it by rank correlation of thumbnails. A body whose live +view crops or reframes the RAW fails this check, and the status line says `RAW zebras off:` +with the reason. A calibrated RGB preset hides the button with the exposure steppers. + +**Calibrate (RGB mode).** Set the ISO and the aperture you scan with. Press **+** beside the +preset dropdown, place the small rectangle on the clear film base (the rebate strip between +frames is a good target), name the preset and run it. Calibration solves one shared +shutter plus a per-channel LED level, so each channel lands just under clipping, and +records the ISO and the aperture. The clear base becomes the *black point* after inversion, +so a clip guard checks the raw Bayer photosites and backs the exposure off if any channel +saturates. Save the preset once per film stock and reuse it. + +The dropdown beside the name sets the capture mode. **Triplet** solves each channel under its own LED. **Single Capture** solves the three levels together for one exposure with all -three LEDs lit: each sensor channel also reads the neighboring LEDs, so the run measures -that overlap and lowers the levels to match. If the overlap leaves no levels that balance -the channels, the run stops and says so. Calibrate a triplet preset instead. +three LEDs lit, measuring how much each sensor channel reads the neighboring LEDs. If no +levels balance the channels, the run stops and says so; calibrate a triplet preset instead. With **Create Sensor Profile** on, a Single Capture run also saves a -[sensor calibration](#sensor-calibration) profile under the preset's name, measured from the -same exposures. A roll scanned with the preset takes that profile and turns Linear RAW on. -Turn the toggle off to keep a profile you made yourself. - -That highlight matters, because the clear base becomes the *black point* after inversion. A -clip guard therefore checks the raw Bayer photosites and backs the exposure off if any -channel saturates. Save the preset once per film stock and reuse it. - -If the target is unreachable at your exposure, the run stops at the probe. A pop-up says -which way to adjust: over-exposed means close the aperture or lower the ISO, under-exposed -means open up or raise the ISO. **No preset is saved.** Adjust and calibrate again in the -window that stayed open. - -**Presets.** A selected preset is shown read-only, with its RGB levels, ISO, shutter and -aperture. The scan forces that exposure on the body before every frame, so a bumped dial -cannot falsify the result. To build a preset by hand instead, pick **Create a manual -preset…** from the dropdown. The sliders and the exposure steppers unlock. Dial them in, -**Capture mode** marks the manual preset as a triplet or a single capture. Then press the -save (floppy) button to name and store the preset. White is the white-light -presets' channel only, because the Scanlight cannot light it together with RGB. The three -white-light presets differ in film type alone: AUTO classifies each capture, **B&W Negative** -and **Slide** fix it, for the import and for Meter alike. - -**Scan.** Pick an output folder and a preset, then press **Scan** for each frame. Files -land in a per-roll subfolder, auto-numbered, and are imported and merged automatically, so -the inverted positive appears a moment after the shutter. A single-capture preset writes -one file per frame. **Retake** re-shoots the current frame without advancing the counter. The **Delay between exposures** control adds a pause -between red/green/blue captures so older bodies can finish flushing the previous shot before -the next one arrives; this avoids the USB/PTP lockups that some cameras trigger when they are -bombarded with a new capture command too quickly. - -**Narrowband Scan.** Scans lit by narrowband RGB LEDs render more saturated than -white-light scans, because each channel samples its dye near the absorption peak and the -natural spectral overlap of broadband light is missing. The **Narrowband** toggle in the -Process panel, beside **Linear RAW**, corrects this with the bundled RGBScan input profile, -in the preview and in every export. An explicit **Input ICC** in the Export settings takes +[sensor calibration](#sensor-calibration) profile under the preset's name, from the same +exposures. A roll scanned with the preset takes that profile and turns Linear RAW on. Turn +the toggle off to keep a profile you made yourself. + +If the target is out of reach, the run stops at the probe and a pop-up says what to do: +over-exposed, close the aperture or lower the ISO; under-exposed, open up or raise the ISO. +No preset is saved. Adjust and calibrate again in the window, which stays open. + +**Presets.** A selected preset shows read-only, with its RGB levels, ISO, shutter and +aperture. The scan sets that exposure on the body before every frame. To build a preset by hand, pick **Create a manual preset…** from +the dropdown to unlock the sliders and the exposure steppers. Set them and **Capture +mode** (triplet or single capture), then press the save (floppy) button. Only the +white-light presets use the White channel, because the Scanlight cannot light it together +with RGB. The three white-light presets differ only in film type: AUTO classifies each +capture, **B&W Negative** and **Slide** fix it, for the import and for Meter. + +**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-capture preset writes one file per frame. **Retake** shoots the current frame again +without advancing the counter. **Delay between exposures** adds a pause between the red, +green and blue captures, for older bodies that lock up the USB/PTP link when a new capture +command arrives too soon. + +**Narrowband Scan.** Narrowband LEDs render more saturated than white light. The **Narrowband** toggle in the Process +panel, beside **Linear RAW**, corrects this with the bundled RGBScan input profile, in the +preview and in every export. An explicit **Input ICC** in the Export settings takes precedence while it is set. ### Sensor calibration -A single-shot scan under a narrowband RGB source can come out with hues that no slider -fixes. Yellows drifting orange is the classic sign. The cause is **sensor crosstalk**: the -camera's color-filter passbands overlap the source's bands, so the green pixel sees the -blue LED and some red, and every channel carries a share of its neighbours. It is a fixed -property of your sensor and light pair, independent of the film. +A single-shot scan under a narrowband RGB source can have hues that no slider fixes, such +as yellows that drift orange. The cause is **sensor crosstalk**: the camera's color-filter +passbands overlap the source's bands, so each channel reads some of the other LEDs. It is a +fixed property of your sensor and light pair, independent of the film. -There are three ways to build a profile, and they give near-identical corrections. -[Sensor profile workflows](#sensor-profile-workflows) puts each one in a full scan: +Three ways to build a profile give near-identical corrections (see +[Sensor profile workflows](#sensor-profile-workflows)): -- **With a preset.** A Single Capture preset calibrated with **Create Sensor Profile** on saves a - profile under the preset's name, measured through the film base. Rolls scanned with the - preset take it automatically. Use this if you scan with Single Capture presets. +- **With a preset.** A Single Capture preset calibrated with **Create Sensor Profile** on + saves a profile measured through the film base (workflow A). - **Capture from Camera…** With the camera tethered, a Scanlight connected and no film in - the holder, open the **Calibration** panel, find *Single-Shot Narrowband Calibration*, - press the calibrate button, name the profile and press **Capture from Camera…**, then - confirm. NegPy lights each LED in turn, sets the shutter itself and saves the profile. - It uses the ISO and aperture the camera is set to, and needs no live view. One profile - serves every film stock. Use this for a rig you scan with outside the presets. -- **From files.** Photograph the bare light three times with no film in the holder: red - only, green only, blue only, exposed just below clipping. In the same dialog, pick the - three captures, name the profile and save it. Use this for a light NegPy cannot control. - -A profile measured through the film base and one measured on the bare light differ -slightly, because the base tints each LED's light. The leak also changes a little with -the density of the picture, so no single profile is exact, and neither method is the more -accurate one. One profile for a sensor and light pair is enough. - -The selected profile un-mixes every scan with a 3×3 matrix in the linear domain, before -inversion. Profiles are TOML files in the `NegPy/sensor` folder. Re-run **Roll Analysis** -after you change the profile. + the holder, NegPy lights each LED in turn, sets the shutter and saves the profile. It + uses the camera's ISO and aperture and needs no live view. One profile serves every film + stock and every preset (workflow B, step 1). +- **From files.** Three RAW captures of the bare light (red only, green only, blue only), + for a light NegPy cannot control (workflow D, step 1). + +Film-base and bare-light profiles differ slightly, and neither is more accurate. One +profile per sensor and light pair is enough. + +The profile unmixes every scan with a 3×3 matrix in the linear domain, before inversion. +Profiles are TOML files in the `NegPy/sensor` folder. Re-run **Roll Analysis** after you +change the profile. ### Sensor profile workflows -A profile is built in one of two places: the preset calibration that **+** opens in the -**Preset & Light** panel, or the **Calibration** panel on the Roll tab. They differ in who assigns the profile to -the roll. A preset that made its own profile assigns it on every scan. A profile from the -Calibration panel is assigned by you, once per roll. +You build a profile in the preset calibration that **+** opens in the **Preset & Light** +panel, or in the **Calibration** panel on the Roll tab. A preset that made its own profile +assigns it on every scan. You assign a Calibration-panel profile yourself, once per roll. | Your setup | Workflow | Profile built in | Assigned by | |---|---|---|---| @@ -234,8 +189,7 @@ All four apply to single-shot narrowband frames only. A Triplet preset needs no #### A. The preset makes the profile -Use this when you scan with calibrated Single Capture presets and want nothing more to -set. It is the default. +The default, for calibrated Single Capture presets. Once per film stock: @@ -245,57 +199,53 @@ Once per film stock: preset after the stock. 3. Pick **Single Capture**, leave **Create Sensor Profile** on and press **Calibrate & Save**. -For every roll of that stock, this one and later ones: +For every roll of that stock: 1. Pick the preset in the dropdown. 2. Scan the roll. -What follows from it: - -- The preset and its profile are stored, so there is nothing to calibrate again for the - next roll of the same stock. Calibrate again when the camera, the lens, the light or - the film stock changes. -- The preset holds the profile's name, and the profile is a file in the `NegPy/sensor` - folder. With that file deleted, the preset still scans and the roll gets no correction. +Notes: -- Every scan makes the preset's profile the roll's own and turns Linear RAW on. A profile - you pick by hand for that roll is replaced at the next scan. -- The profile has the preset's name. Calibrating a preset again under the same name writes - the profile again, and so does any other profile saved under that name. -- Each preset has its own profile, measured through that film's base. -- The profile stays with the preset's rolls. It is not carried to frames opened or - scanned another way. +- Calibrate again only when the camera, the lens, the light or the film stock changes. +- The preset holds the profile's name; the profile is a file in the `NegPy/sensor` folder. + If that file is deleted, the preset still scans and the roll gets no correction. +- Every scan makes the preset's profile the roll's own and turns Linear RAW on, replacing a + profile you picked by hand for that roll. +- A new calibration under the preset's name writes the profile again, and so does any + other profile saved under that name. +- Each preset has its own profile, measured through that film's base. Frames opened or + scanned another way do not get it. #### B. A calibrated preset and a separate profile Use this when one profile is to serve every preset and film stock on the rig, when you -already have a profile for this sensor and light, or when a preset calibration reports -that its exposures were too dim to measure one. +already have a profile for this sensor and light, or when a preset calibration reports that +its exposures were too dim to measure one. 1. Build the profile once for the rig. Take the film out of the holder. In the - **Calibration** panel, turn **Linear RAW** on, press the calibrate button, name the - profile and press **Capture from Camera…**. To build it from three bare-light files - you shot yourself instead, pick them in the same dialog as in workflow D, step 1. + **Calibration** panel, turn **Linear RAW** on, press the calibrate button under + *Single-Shot Narrowband Calibration*, name the profile, press **Capture from Camera…** + and confirm. To use three bare-light files instead, pick them in the same dialog as in + workflow D, step 1. 2. Load the film. Calibrate the preset as in workflow A, with **Create Sensor Profile** off. 3. Scan the first frame. In the **Calibration** panel, check that **Linear RAW** is on, pick the profile under **Profile**, and press **Roll** on the panel header. 4. Scan the rest of the roll. New frames take the roll's profile. 5. Re-run **Roll Analysis** if frames were scanned before step 3. -What follows from it: +Notes: -- The preset sets nothing on the roll. A roll with no profile picked is not corrected. -- The profile is measured on the bare light, so it holds no film base. Its correction is - close to a preset's, not identical. -- There is one profile to keep current. Build it again when the camera or the light - changes. +- The preset sets nothing on the roll. A roll with no profile picked gets no correction. +- A bare-light profile has no film base, so its correction is close to a preset's, not + identical. +- Build the profile again when the camera or the light changes. - The profile is a Calibration setting, so it carries to the next frames you open. Set **Profile** to *None* on a roll scanned under another light. #### C. A manual preset -A manual preset is dialed in by hand. Nothing is metered, so it cannot make a profile, -and a single-shot roll scanned with it has no correction until you pick one. +A manual preset meters nothing, so it cannot make a profile. A single-shot roll scanned +with it has no correction until you pick one. 1. Build the profile as in workflow B, step 1, or use one the rig already has. 2. In the **Preset & Light** panel, pick **Create a manual preset…** from the preset @@ -303,13 +253,13 @@ and a single-shot roll scanned with it has no correction until you pick one. and save the preset. 3. Scan the first frame, then assign the profile to the roll as in workflow B, step 3. -What follows from it is the same as in workflow B. The profile does not depend on the LED -levels or the shutter, so one profile serves a manual preset at any levels. +The notes for workflow B apply. The profile does not depend on the LED levels or the +shutter, so one profile serves a manual preset at any levels. #### D. Files not scanned with NegPy -Use this for single-shot RAW files shot to the card, with other tethering software, or -under an RGB light NegPy cannot control. +For single-shot RAW files shot to the card, with other tethering software, or under an RGB +light NegPy cannot control. 1. Build the profile for the camera and light that shot the files. If that rig is a camera NegPy can tether and a Scanlight, use **Capture from Camera…** as in workflow B. @@ -321,66 +271,54 @@ under an RGB light NegPy cannot control. 3. Pick the profile under **Profile** and press **Roll** on the panel header. 4. Re-run **Roll Analysis**. -What follows from it: +Notes: -- The files must be camera RAW. The profile needs Linear RAW, and it is grayed out - without it. -- A profile fits one sensor and light pair. Files from another camera or another light - need their own. -- A light that cannot show one color at a time gives no way to measure a profile. +- The files must be camera RAW. The profile needs Linear RAW and is grayed out without it. +- Files from another camera or light need their own profile. +- A light that cannot show one color at a time cannot measure a profile. - The profile carries to the next frames you open, as in workflow B. ### When no profile applies -Do not use it on RGB-triplet (trichrome) scans. They are crosstalk-free by construction, -because each channel comes from its own single-light exposure, and NegPy skips the -correction automatically for triplet assets. A dedicated film scanner has no sensor -crosstalk either: a Coolscan's mono CCD reads one LED at a time. Its residual color error -is film-dye crosstalk, which the density-domain **Crosstalk** matrix handles (see -[CROSSTALK.md](CROSSTALK.md)). - ---- +RGB-triplet (trichrome) scans have no sensor crosstalk, and NegPy skips the correction for +triplet assets. A dedicated film scanner has none either (a Coolscan's mono CCD reads one +LED at a time). Its remaining color error is film-dye crosstalk, which the density-domain +**Crosstalk** matrix corrects (see [CROSSTALK.md](CROSSTALK.md)). ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| -| The camera dot shows **"in use"** (amber hint) | Another program holds the body, and only one program may claim a PTP camera. On macOS the holder is the system camera daemon, and it is woken by any application that watches for cameras. Often that application is a **background sync client** with no window and no notification: Google Drive running as a login item has been confirmed to do it, and Dropbox and similar tools behave the same way. | Quit the background app, or remove it from **System Settings → General → Login Items** if it starts itself. Unplugging and reconnecting the cable frees the camera too, but only until that app asks again. Preview, Photos and Image Capture do the same when one of them is genuinely open. NegPy reconnects by itself once the camera is free. | +| The camera dot shows **"in use"** (amber hint) | Another program holds the body. On macOS it is the system camera daemon, woken by any app that watches for cameras, often a background sync client with no window (Google Drive as a login item, Dropbox). | Quit the background app, or remove it from **System Settings → General → Login Items**. Reconnecting the cable frees the camera until that app asks again. An open Preview, Photos or Image Capture does the same. NegPy reconnects when the camera is free. | | No camera found, and nothing else is running | The body is not in PC Remote mode, or it is a mass-storage/MTP connection. | Set the camera's USB connection mode to **PC Remote**. | -| `[-10] Timeout reading from or writing to the port`, and no other program holds it | A program crashed while connected. The *camera* still thinks the session is open and refuses a new one. A timeout that clears on its own is retried silently and never reaches you, so seeing this means it did not clear. | Power-cycle the camera, or unplug and replug the cable. Nothing on the computer fixes it. | +| `[-10] Timeout reading from or writing to the port`, and no other program holds it | A program crashed while connected, and the *camera* still holds the old session. NegPy retries timeouts that clear by themselves, so this one did not. | Power-cycle the camera, or unplug and replug the cable. Nothing on the computer fixes it. | | 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. Either the body genuinely lacks it (Sony a6000), or it is connected in MTP mode, where no body has it. | If the message names MTP, set the camera's USB mode to **PC Remote** and reconnect. If not, this is expected. Scanning works normally, but you must set framing and focus on the camera, and calibration is unavailable because it aims through the live view. | -| Capture says the camera returned JPEG instead of RAW | The camera's image-quality setting is JPEG, or RAW+JPEG selected the processed file. | Set image quality to **RAW only**, then retry. | -| The aperture stepper is grayed out | The lens has no electronic diaphragm. | Expected. Set the aperture on the lens itself. | -| A setting snaps back to its old value | Property writes are asynchronous, so the body needs a moment. | NegPy polls until the value lands and logs a warning if it never does. If it never does, that setting is not writable in the body's current mode. Try **M**. | -| The Scanlight is not detected | Wrong USB-C port. | The Scanlight has two ports and only one carries data. The other is power only. Use the data port. | -| The Scanlight is not detected on Linux, and the data port is right | Your user cannot open the serial port; most distributions give it to the `dialout` group. | `sudo usermod -aG dialout $USER`, then log out and back in. | - ---- +| 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. | +| 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. | +| The Scanlight is not detected on Linux, and the data port is right | Your user cannot open the serial port (most distributions give it to the `dialout` group). | `sudo usermod -aG dialout $USER`, then log out and back in. | ## Notes and limitations - **USB only.** libgphoto2 can reach some cameras over the network, but not Sony bodies. - The tab is a tethered-USB workflow. -- **Only Sony bodies are tested**, because that is the hardware on hand. Nothing in NegPy - assumes a vendor: every control is looked up rather than assumed. `iso` and - `shutterspeed` are named the same everywhere. The aperture is `f-number` on Sony and - Panasonic, and `aperture` on Canon, Nikon, Fujifilm, Olympus and Sigma. The RAW suffix - comes from the camera, and the still is taken into memory rather than onto a card. Canon - and Nikon default to the card and will not shoot without one. Reports from other brands - are very welcome. +- **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 + suffix comes from the camera, and the still goes into memory, not onto a card. Canon and + Nikon default to the card and will not shoot without one. Reports from other brands are + welcome. - **The focus magnifier depends on the vendor.** Sony packs the zoom ratio and the target - point into one property, so a click both magnifies *and* aims. Canon (`eoszoom`) and - Nikon (`liveviewimagezoomratio`) split them, and their coordinate space is unknown here, - so a click magnifies where the body already looks. Every other body has no magnifier at - all, and the feature disables itself. A body that stops streaming while magnified (the - Nikon D3300) returns to full frame, and its magnifier stays off for the session. On all of - these, focus with the Focus meter. -- **Tested on macOS.** The Python is portable and libgphoto2 is a Linux-first project, so - Linux should be at least as good. This is unverified. -- **Speed.** A three-shot RGB triplet takes about six seconds on an a7C II over USB. Almost - all of that is the body's per-image transfer latency, not the file size. Stills are taken - inside the running live-view session, so there is no per-frame reconnect. + point into one property, so a click magnifies *and* aims. Canon (`eoszoom`) and Nikon + (`liveviewimagezoomratio`) split them, with an unknown coordinate space, so a click + magnifies where the body already looks. Other bodies have no magnifier, and the feature + disables itself. A body that stops streaming while magnified (the Nikon D3300) returns to + full frame, and its magnifier stays off for the session. On these, focus with the Focus + meter. +- **Tested on macOS.** Linux should work at least as well, but this is not verified. +- **Speed.** Most of a triplet's time is the body's per-image transfer latency. Stills are + taken inside the running live-view session, with no reconnect per frame. - **Credit.** The R/G/B sequencing and the exposure-calibration approach come from [rohanpandula/TriRGB](https://github.com/rohanpandula/TriRGB). The light is [jackw01/scanlight](https://github.com/jackw01/scanlight). The camera is driven through diff --git a/docs/CROSSTALK.md b/docs/CROSSTALK.md index ca7901e10..4a65feb5f 100644 --- a/docs/CROSSTALK.md +++ b/docs/CROSSTALK.md @@ -1,85 +1,66 @@ # Custom Crosstalk Matrices -The **Crosstalk** control in the Calibration sidebar runs a channel-unmix correction. NegPy -ships one built-in matrix (**Generic C41**). You can drop in your own, calibrated for your -film *and your scanning setup*, without touching any code. - ---- +The **Crosstalk** control in the Calibration sidebar unmixes the color channels. NegPy ships +one built-in matrix (**Generic C41**). You can add your own, calibrated for your film *and +your scanning setup*, without code. ## Which film a matrix is for -A matrix describes one dye set, and Color Negative and Transparency do not share one. Each -profile therefore declares its film process: +Color Negative and Transparency use different dyes, so each profile declares its film +process: ```toml process = "Transparency" # or "Color Negative" (the default when the key is absent) ``` -The dropdown offers only matrices for the film you are processing, and the render gates on -the same value. A mismatched profile resolves to identity rather than mixing in the wrong -correction. Every profile written before this key existed is a color negative stock, which -is why the default is `Color Negative`. Files written before the modes were renamed (`C41`, -`B&W`, `E-6`) still load. - -No Transparency matrix ships with NegPy today. On slides the Matrix dropdown starts empty, -and it and the Strength slider are disabled. The editor button stays live: open it, press -**+** for a new matrix, and it is created for the process you are working in. The editor's -**Process** control sets that value on any profile you save, so you can then select a -matrix you built for a slide rig. Crosstalk is hidden outright only in B&W Negative, which -has one emulsion and so nothing to unmix. - -> **Reversal film reads differently.** On a negative the dyes' unwanted absorptions are an -> error to remove before inversion, so unmixing moves the render *toward* the scene. A -> transparency **is** the finished image. What you see on a lightbox already includes those -> absorptions, so unmixing moves it *away* from the slide's own appearance and toward the -> dye-amount image behind it. In Transparency, treat Crosstalk as a color-separation -> control, not a fidelity correction, and leave Strength at 0 when you want to match the -> slide. +The dropdown offers only matrices for the current film, and the render gates on the same +value: a mismatched profile resolves to identity. The old mode names (`C41`, `B&W`, `E-6`) +still load. -## What it does +No Transparency matrix ships with NegPy. On slides the Matrix dropdown starts empty, and it +and the Strength slider are disabled. The editor button stays live: **+** in the editor +makes a matrix for the current process, and the editor's **Process** control sets the +process on any profile you save. Crosstalk is hidden only in B&W Negative, which has one +emulsion and nothing to unmix. + +> **Reversal film reads differently.** On a negative, unmixing moves the render *toward* +> the scene. A transparency is the finished image, so unmixing moves the render *away* from +> the slide. In Transparency, Crosstalk is a color-separation control, not a fidelity +> correction. Leave Strength at 0 to match the slide. -A color negative's three dye layers are not spectrally pure. The cyan, magenta and yellow -dyes each leak a little density into the channels they should not affect, which gives muddy, -low-separation color. Crosstalk correction unmixes the channels: it multiplies the -per-pixel **negative density** vector by a 3×3 matrix, *before* normalization and the print -curve. That is the domain the matrices were derived in, because secondary dye absorptions -are linear in negative dye density. +## What it does -**The dyes are not the only cause.** Your light source's spectrum and your sensor's -color-filter passbands mix the channels as well. A broad or oddly-shaped light samples each -dye away from its peak and picks up its neighbours, and a camera's color filters overlap. -In the density domain all three causes arrive as the same kind of error, a linear mix, which -is why one 3×3 can absorb them together, and why the same film can need different matrices -on different rigs. +The cyan, magenta and yellow dyes of a color negative each leak some density into the other +channels, which makes color muddy and low in separation. Crosstalk correction multiplies +the per-pixel **negative density** vector by a 3×3 matrix, *before* normalization and the +print curve. Secondary dye absorptions are linear in negative dye density, so the matrices +are derived in this domain. -So think of a matrix as a property of a **whole scanning setup**, not of a film stock. A -profile derived from a datasheet describes the dyes alone. The one that makes *your* scans -sing is the one you tuned on your own light and camera. +The light source and the camera's overlapping color filters add the same kind of linear +mix, so one 3×3 corrects all three together. A matrix is a property of a **whole scanning +setup**, not of a film stock, and the same film can need different matrices on different +rigs. -Two related controls sit in the same panel and are *not* substitutes. The **sensor** matrix -corrects the camera in the linear capture. **Hue Trim** rotates hue for an odd light -spectrum. See [USER_GUIDE.md](USER_GUIDE.md) §10.3. +Two related controls in the same panel do a different job. The **sensor** matrix corrects +the camera in the linear capture. **Hue Trim** rotates hue for an odd light spectrum. See +[USER_GUIDE.md](USER_GUIDE.md) §10.3. > ### What the bundled matrices actually are > > Every bundled profile is **read off a published spectral-dye-density spec sheet, not -> measured on a scan**, hence the *(approx)* in each name. Two consequences are worth -> knowing before you trust one: +> measured on a scan**, hence the *(approx)* in each name. > -> - They model the **dyes only**. That is the complete correction only when your capture -> reads each dye band cleanly: a **Narrowband Scanner** (a Coolscan's mono sensor reads -> one LED at a time, fully clean; a Pakon's trilinear array comes close, with slight -> residual bleed), a **Trichrome** capture, or a Single-Shot Narrowband rig with -> **Single-Shot Narrowband Calibration** applied. With a -> broadband light and a Bayer sensor, your light and CFA add mixing the datasheet knows -> nothing about, and a dyes-only matrix is the wrong shape for it. -> - Consumer datasheets usually publish one *aggregate* midscale-neutral curve rather than -> separated C/M/Y curves, so even the dye part is an estimate read off a plot by eye. +> - They model the **dyes only**. That is the full correction only when the capture reads +> each dye band cleanly: a **Narrowband Scanner** (a Coolscan's mono sensor reads one LED +> at a time, fully clean; a Pakon's trilinear array comes close, with slight residual +> bleed), a **Trichrome** capture, or a Single-Shot Narrowband rig with +> **Single-Shot Narrowband Calibration** applied. A broadband light and a Bayer sensor +> add mixing that a dyes-only matrix cannot model. +> - Consumer datasheets usually publish one *aggregate* midscale-neutral curve, not +> separate C/M/Y curves, so even the dye part is read off a plot by eye. > -> They are still far better starting points than identity for a narrowband rig. Just do not -> expect a bundled stock name to be "the" correction for your scanner. A matrix you tune -> yourself is the more trustworthy object. Bundled matrices are read-only; use **Make -> Editable Copy** to start from one. +> On a narrowband rig they still beat identity, but a matrix you tune yourself is more +> trustworthy. Bundled matrices are read-only. Use **Make Editable Copy** to start from one. The math, per pixel on the raw decoded negative: @@ -96,10 +77,8 @@ M_applied = I · (1 - strength) + M · strength M_applied = M_applied / row_sums(M_applied) # each row normalized to sum 1 ``` -Because every row is renormalized to sum to 1, a uniform gray stays gray. The matrix only -redistributes color *differences* between channels. - ---- +Every row sums to 1, so a uniform gray stays gray. The matrix only redistributes color +*differences* between channels. ## Reading the matrix @@ -112,12 +91,10 @@ The matrix is row-major. **Rows are output channels**, **columns are input chann | **out B** | -0.01 | -0.10 | 1.00 | - The **diagonal** stays near `1.0`, so each channel keeps its own density. -- **Off-diagonal** terms are usually small and negative. They subtract the contamination one - layer leaks into another. -- Keep the rows summing near `1.0`. Large deviations work, because rows are normalized, but - they make the effect harder to reason about. - ---- +- **Off-diagonal** terms are usually small and negative. They subtract what one layer leaks + into another. +- Keep the rows summing near `1.0`. Rows are normalized, so larger deviations work, but are + harder to reason about. ## File format (TOML) @@ -127,9 +104,9 @@ Put `.toml` files in your user folder: /NegPy/crosstalk/ ``` -The bundled gallery (`crosstalk/` in the repo) is read straight from the app and not copied -here, so those profiles stay in the dropdown beside yours and update with each release. A -bundled name wins over a user file with the same name. Each file is one matrix: +The app reads the bundled gallery (`crosstalk/` in the repo) in place, so those profiles +show beside yours and update with each release. A bundled name wins over a user file with +the same name. Each file is one matrix: ```toml # my_film.toml @@ -144,81 +121,69 @@ matrix = [ # 3x3, row-major (out R/G/B × in R/G/B) - `matrix` is **required**: exactly 3 rows of 3 numbers. - `name` is **optional**. Without it, the dropdown shows the filename (without `.toml`). -- `type` is **optional**. It says *where the numbers came from*, which is what decides how - far to trust one. The dropdown groups profiles under a heading per type: +- `type` is **optional**. It says *where the numbers came from*, and so how far to trust + them. The dropdown groups profiles by type: | `type` | Dropdown group | Meaning | | :--- | :--- | :--- | | `measured` | Measured | Fitted against real scans of a known reference. | - | `tuned` | Tuned on a rig | Dialled in by eye on real frames. What the editor saves. | + | `tuned` | Tuned on a rig | Dialed in by eye on real frames. The editor saves this type. | | `specsheet-based` | From spec sheets (approx) | Read off published dye-density curves. Every bundled profile. | - | anything else or absent | Other | Still loads and works. It just does not claim a provenance. | + | anything else or absent | Other | Loads and works, with no provenance. | - NegPy silently skips malformed files (wrong shape, non-numbers, bad TOML). - The name `Generic C41` is reserved for the built-in matrix and ignored if reused. NegPy **bakes the chosen matrix into the edit** when you select it, so saved edits and -presets stay reproducible even if you later move or delete the file. - ---- +presets stay reproducible if you later move or delete the file. ## Using it -1. Drop your `.toml` into `/NegPy/crosstalk/`. +1. Put your `.toml` into `/NegPy/crosstalk/`. 2. Open the **Calibration** sidebar and find the **crosstalk dropdown** under CROSSTALK. New - files appear the next time the panel syncs, for example when you switch photos. Restart - if you do not see it. -3. Pick your profile and raise **Strength** above 0 to apply it. + files show the next time the panel syncs, for example when you switch photos. If yours + does not show, restart. +3. Pick your profile and raise **Strength** above 0. 4. Pick **Generic C41** to go back to the built-in matrix. +Crosstalk changes what the normalization meters read, so re-run **Roll Analysis** (and +re-save locked bounds) after you change the profile or the strength. + ### Rolling your own, the short version -You do not need spectral data or a spectrophotometer to get a better matrix than *Generic -C41*, and you are the only person who can measure your own rig. Recommended loop: +You need no spectral data or spectrophotometer to beat *Generic C41*: 1. Pick a frame whose real colors you know: foliage, sky, a gray card, skin. -2. Start on **Generic C41** and raise **Strength** until the colors separate but before they - go garish. For many rigs this alone is the whole win. +2. Start on **Generic C41** and raise **Strength** until the colors separate, before they + go garish. On many rigs this is enough. 3. Still wrong in a specific way? Open the matrix editor, press **Make Editable Copy**, and - nudge the off-diagonal term for the pair that is off. A green leaking into red reads as - `out R / in G`. Work one term at a time, in small steps. `0.02` is visible. -4. Save it named after the **combination**, for example `Gold 200 + Spectracolor`, not just - the film. Re-run **Roll Analysis**, then check it on a second frame before you trust it. + change the off-diagonal term for the pair that is off. Green leaking into red is + `out R / in G`. Change one term at a time, in small steps. `0.02` is visible. +4. Name it after the **combination**, for example `Gold 200 + Spectracolor`. Re-run **Roll + Analysis** and check it on a second frame. -If the hues are turned rather than muddied, with every color rotated the same way and -neutrals fine, that is a light-spectrum problem. **Hue Trim** is the cheaper fix, and a -matrix will fight it. Muddy, low-separation color is the crosstalk signature. +If every hue turns the same way and neutrals are fine, the light spectrum is the problem. +**Hue Trim** is the cheaper fix, and a matrix will fight it. Muddy, low-separation color is +the crosstalk signature. ### Editing matrices in the app -The **sliders icon** beside the dropdown opens the **matrix editor**, so you do not have to -hand-edit TOML: +The **sliders icon** beside the dropdown opens the **matrix editor**: - Browse every profile. Bundled matrices and **Generic C41** show a lock and are **read-only**. Your own profiles are editable. - Drag the off-diagonal grid sliders (`out R/G/B` × `in R/G/B`) and the preview updates live. - The diagonal is fixed, because the matrix is row-normalized on apply, which makes the - diagonal redundant. Only the six mixing terms are editable. The **Preview strength** slider - controls the preview here only. Use the sidebar **Strength** slider to apply the matrix. + Row normalization makes the diagonal redundant, so only the six mixing terms are + editable. The **Preview strength** slider controls only this preview. The sidebar + **Strength** slider applies the matrix. - **Make Editable Copy** clones the selected locked matrix into an editable profile. -- **Save** writes the profile as a `.toml` into `/NegPy/crosstalk/`, the same - folder profiles are read from, so it shows up in the dropdown. +- **Save** writes the profile as a `.toml` into `/NegPy/crosstalk/`, so it shows + in the dropdown. - **Apply & Close** keeps what you were previewing. **Cancel** reverts. -> Crosstalk is a color operation and is hidden in B&W Negative mode. It changes what the -> normalization meters read, so re-run **Roll Analysis** (and re-save locked bounds) after -> you change the profile or the strength. - ---- - ## Contributing a matrix -Tuned one for your setup? Please send it. Add your `.toml` to the repo's -[`crosstalk/`](../crosstalk/) folder and open a PR. Bundled matrices ship to all users on -the next release. See [`crosstalk/README.md`](../crosstalk/README.md). - -Say what rig it came from, in the profile name and in the PR: the film stock, the light -source and the camera or scanner. Nobody can derive your light and sensor from a datasheet, -so a measured-in-anger profile for a common combination is more useful to other people than -any amount of theory. Light panels also get discontinued, so profiles for the ones people -own are worth keeping. +Add your `.toml` to the repo's [`crosstalk/`](../crosstalk/) folder and open a PR. +Bundled matrices ship to all users in the next release. See +[`crosstalk/README.md`](../crosstalk/README.md). Name the rig in the profile name and in the +PR: the film stock, the light source and the camera or scanner. diff --git a/docs/FILTERING.md b/docs/FILTERING.md index 19d64ea19..fc0c53901 100644 --- a/docs/FILTERING.md +++ b/docs/FILTERING.md @@ -1,10 +1,10 @@ # Filtering the Film Strip -The search box in the left sidebar filters the film strip by filename. Sorting, hot folder mode and "Export All" all use the visible (filtered) set. +The search box in the left sidebar filters the film strip by filename. Sorting, hot folder mode and "Export All" use only the visible (filtered) set. ## Plain mode (default) -Type any text. NegPy matches it against each filename, and ignores case. +NegPy matches the text against each filename and ignores case. | Input | Matches | |-------|---------| @@ -12,7 +12,7 @@ Type any text. NegPy matches it against each filename, and ignores case. | `.cr2` | every `.cr2` file | | `_42` | files that contain `_42` anywhere in the name | -Click the **×** in the box to clear the filter. All loaded files come back immediately. +Click the **×** in the box to clear the filter. ## Regex mode @@ -24,12 +24,12 @@ Click the **`.*`** toggle beside the search box. NegPy compiles the pattern with | `\.(cr2\|nef)$` | only `.cr2` or `.nef` files | | `roll_\d+_scan` | files like `roll_3_scan.tif` | -An invalid regex (for example an unclosed `[`) turns the input border red and keeps the previous filter. Nothing disappears while you type. +An invalid regex (for example an unclosed `[`) turns the input border red and keeps the previous filter. -## Behaviour +## Behavior -- **The selection follows the filter.** NegPy drops hidden files from the multi-select set. The active file moves to the first visible selection, or clears if nothing matches. Sync Edits never touches an invisible file. -- **Export All exports only what is visible.** Filter to a subset, then click Export All. Only that subset is written. -- **Hot Folder mode still ingests new files.** New files that do not match the active filter stay hidden until you clear or relax the filter. -- **The sort order is kept.** The filter is applied after the sort, so the visible order follows your Name/Date and Asc/Desc choice. -- **The filter is session-only.** NegPy starts with an empty filter. +- NegPy drops hidden files from the multi-select set. The active file moves to the first visible selection, or clears if nothing matches. Sync Edits never changes a hidden file. +- Export All writes only the visible files. +- Hot Folder mode still ingests new files. A new file that does not match the filter stays hidden until you clear or relax the filter. +- The filter applies after the sort, so the visible order follows your Name/Date and Asc/Desc choice. +- The filter lasts for one session. diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 95ce77e27..4d17eabb1 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -1,185 +1,189 @@ # The Pipeline -These steps run in order. Each stage passes its buffer to the next. +These steps run in order. Each stage passes its buffer to the next. A helper named in this document as the "single source" for a per-channel value serves the CPU engine, the GPU engine and the curve chart alike. -**Color handling: no input colorspace.** NegPy works on **linear RGB straight from the raw decode** (`output_color=raw`, `gamma=(1,1)`, unity white balance): the sensor's own channels, never converted through camera primaries. Channel balance is handled in film terms: per-channel normalization bounds (§2), crosstalk unmix and cast removal (§3). Adobe RGB (1998) is an assumed boundary profile, not an input characterization (`WORKING_COLOR_SPACE` in `infrastructure/display/color_spaces.py`): CLAHE, Lab and Toning compute CIELAB from linear data with Adobe RGB primaries and D65, and the Adobe RGB TRC is the final engine step. Primaries apply only on the way **out**: the preview is color-managed from the working profile to the display profile, and export converts to the target space and embeds its ICC profile. Every render, preview, detection and thumbnail decode passes `adjust_maximum_thr=0.0`, which pins the scale to the camera's calibrated linearity limit (`user_sat`, or the format's generic white level when the body has no calibration table) instead of LibRaw's per-frame maximum. A roll then decodes on one scale, and the preview matches export. Linear Output's decode keeps the roll-consistency pin but not `user_sat`, because it hands off the least-processed data it can. +**Color handling: no input colorspace.** NegPy works on **linear RGB straight from the raw decode** (`output_color=raw`, `gamma=(1,1)`, unity white balance): the sensor's own channels, never converted through camera primaries. Adobe RGB (1998) is an assumed boundary profile, not an input characterization (`WORKING_COLOR_SPACE` in `infrastructure/display/color_spaces.py`): CLAHE, Lab and Toning compute CIELAB from linear data with Adobe RGB primaries and D65, and the Adobe RGB TRC is the final engine step. Primaries apply only on the way **out**: the preview is color-managed from the working profile to the display profile, and export converts to the target space and embeds its ICC profile. Every render, preview, detection and thumbnail decode passes `adjust_maximum_thr=0.0`, which pins the scale to the camera's calibrated linearity limit (`user_sat`, or the format's generic white level when the body has no calibration table), not LibRaw's per-frame maximum, so a roll decodes on one scale and the preview matches export. Linear Output's decode keeps the pin but not `user_sat`. ## 1. Geometry (Straighten & Crop) **Code**: `negpy.features.geometry` * **Rotation**: 90° steps, then affine fine rotation with bilinear interpolation. * **Lens distortion**: a radial $k_1$ coefficient (`geometry.distortion_k1`, ±0.10), corrected in the same resample. -* **Tilt and Swing** (`geometry.converge_v` / `converge_h`, ±15%): perspective correction named for the enlarger movements. Tilt straightens converging verticals, swing converging horizontals: the plane-to-plane projectivity of a tilted easel (Hartley & Zisserman §2.3). It runs last in the forward chain, after $k_1$, because a projectivity cannot be fitted to a barrel-distorted frame. The unit is percent of the frame, not degrees, because convergence is $(H/2)\sin\tau / D$ and no magnification or focal length is modeled. Output keeps the canvas size and replicates the wedge, as fine rotation does. A quarter-turn or flip carries both with the picture: a turn swaps them between axes, a flip negates the one on the flipped axis (`rotate_geometry_and_analysis`, `toggle_flip`). +* **Tilt and Swing** (`geometry.converge_v` / `converge_h`, ±15%): the plane-to-plane projectivity of a tilted easel (Hartley & Zisserman §2.3). Tilt straightens converging verticals, swing converging horizontals. It runs last in the forward chain, after $k_1$, because a projectivity cannot fit a barrel-distorted frame. The unit is percent of the frame: convergence is $(H/2)\sin\tau / D$, and no magnification or focal length is modeled. Output keeps the canvas size and replicates the wedge, as fine rotation does. A quarter-turn swaps the two between axes; a flip negates the one on the flipped axis (`rotate_geometry_and_analysis`, `toggle_flip`). `keystone_matrix_normalized` is the single definition of the quad; the pixel-space matrix and the GPU's inverse derive from it. Every geometry reader applies it: the uv grid, `map_coords_to_geometry`, autocrop's replay and detection key, and the GPU's analysis replay. Off-frame card-edge handles need `CoordinateMapping` to fit the grid projectively. -* **Crop by Default** (`geometry.crop_to_valid`): the largest axis-aligned, frame-centered rect that fine rotation and keystone leave void-free, used as the ROI when no manual or detected crop is set. Distortion is excluded, because it scales to fill. The valid region is a convex quadrilateral, so `compute_geometry_crop_rect` ternary-searches the half-width, with a nested binary search for the half-height. -* **Auto Skew** (`geometry.skew`): sets fine rotation, tilt and swing from the frame's own straight edges, never the picture. A length-weighted mode of long segments turns the frame near square; the film detector's boxes (or, on a tight scan, the border ring) place the film and gate edges; each side is fitted as a line, and Gauss-Newton levels every line through the app's own warp. A side's line is searched inside its box and only the detector's snap window outside it; a film box on the canvas border loses its sides on that axis; a slide's picture box, found from the canvas, stands in for the film box the detector cannot see; a bowed edge is levelled by its chord. A picture line can fit perfectly, so an applied correction needs box-led lines (or a border ring with film base outside it) and an opposite pair: left/right for tilt, top/bottom for swing. A keystone is kept only between 0.3% and 5%, and only where it explains the edges clearly better than rotation alone. Auto-crop all frames takes each frame's rotation from the same fit. -* **Autocrop**: finds where the film ends and the scanner bed begins from the density jump. Light leaks and odd holders can fool it, so a manual override exists. +* **Crop by Default** (`geometry.crop_to_valid`): the largest axis-aligned, frame-centered rect that fine rotation and keystone leave void-free, used as the ROI when no manual or detected crop is set. Distortion is excluded, because it scales to fill. `compute_geometry_crop_rect` ternary-searches the half-width of the convex valid quadrilateral, with a nested binary search for the half-height. +* **Auto Skew** (`geometry.skew`): sets fine rotation, tilt and swing from the frame's own straight edges, never the picture. A length-weighted mode of long segments turns the frame near square. The film detector's boxes (or, on a tight scan, the border ring) place the film and gate edges. Each side is fitted as a line, and Gauss-Newton levels every line through the app's own warp. A side's line is searched inside its box, and outside it only within the detector's snap window. A film box on the canvas border loses its sides on that axis. On a slide, the picture box found from the canvas stands in for the film box. A bowed edge is leveled by its chord. A correction applies only with box-led lines (or a border ring with film base outside it), because a picture line can fit perfectly, and only with an opposite pair: left/right for tilt, top/bottom for swing. A keystone is kept only between 0.3% and 5%, and only where it explains the edges clearly better than rotation alone. Auto-crop all frames takes each frame's rotation from the same fit. +* **Autocrop**: finds where the film ends and the scanner bed begins from the density jump. A manual override covers light leaks and odd holders. - Detection runs once per edit, in `ImageProcessor` ahead of either engine, and the rect is stored on the edit (`geometry.crop_rect`; `crop_from_auto` marks its origin). Both engines only slice that rect, because a preview and a full-res export can stop on different edges. The rect keeps its detection key (`autocrop_detection_key`: orientation, ratio, mode, rebate trim); a key change re-detects on the next render. Crop Offset is not in the key, because it is re-applied every render. + Detection runs once per edit, in `ImageProcessor` ahead of either engine, and stores the rect on the edit (`geometry.crop_rect`; `crop_from_auto` marks its origin). Both engines only slice that rect, because a preview and a full-res export can stop on different edges. The rect keeps its detection key (`autocrop_detection_key`: orientation, ratio, mode, rebate trim), and a key change re-detects on the next render. Crop Offset is not in the key, because every render re-applies it. - The surround of a detected box must read as bed (uniform and near-clipping, not merely bright) or as a dark holder; otherwise detection falls back to the full frame. The box then has opaque edges trimmed, up to 20% a side, against a near-black level no negative reaches. A run that uses the whole allowance trims nothing, so a slide's black shadows keep their edges. A box under a quarter of the frame is not film, and detection falls back to the full frame; in a roll, the frame is placed from the roll or the roll declines. + The surround of a detected box must read as bed (uniform and near-clipping) or as a dark holder; otherwise detection falls back to the full frame. Opaque edges are then trimmed off the box, up to 20% a side, against a near-black level no negative reaches. A run that uses the whole allowance trims nothing, so a slide's black shadows keep their edges. A box under a quarter of the frame is not film: detection falls back to the full frame, and in a roll the frame is placed from the roll or the roll declines. - The crop is then squared to the Ratio setting. On `Free` (default) the ratio comes from the detected frame, snapped to the nearest real film format, so a 6x6 stays square. The snap is checked against the sensor dimensions only where the detected box is the longer of the two. + The crop is then squared to the Ratio setting. On `Free` (default) the ratio comes from the detected frame, snapped to the nearest real film format. The snap is checked against the sensor dimensions only where the detected box is the longer of the two. * **Rebate Trim**: in *Image only* mode the film box is refined inward to the exposed image, by whichever of three routes can read the frame. - * A **uniformity probe**, when bed, rebate and image tiers separate cleanly. It classifies the box against the rebate/image midpoint and takes the longest run of occupied rows, then columns. A run shorter than half its side is refused, because no rebate is that thick. - * A **per-side edge walk** (`measure_film_edges`), for camera scans, where mask and base are too thin for the ring (its probe is 4% of the box). Each line is walked alone and depths are pooled per side, because a slight tilt mixes mask, base and picture on one row. The walk recognizes mask (near-black) and bed (near-clipping) by level, and a rebate brighter or a holder edge darker than the picture on the same line. Where it ends on a relative test, the edge is the strongest gradient in the run and must be a real step by magnitude, so a tapering subject is not read as a border. Sides are independent; a cap and a support test bound the risk. The walk runs twice: the first pass reaches 3% of the side and is accepted as is; the second reaches 10% (wide holder margins, sprocket rails, a neighboring frame) and is adopted only where it goes deeper and passes four tests. It stopped inside its allowance, its per-line depths fit a straight line, its outermost pixels hold one level along the side, and its closing step carries most of the drop from that level to the picture. + * A **uniformity probe**, when bed, rebate and image tiers separate cleanly. It classifies the box against the rebate/image midpoint and takes the longest run of occupied rows, then columns. It refuses a run shorter than half its side. + * A **per-side edge walk** (`measure_film_edges`), for camera scans, where mask and base are too thin for the ring (its probe is 4% of the box). Each line is walked alone and depths are pooled per side, because a slight tilt mixes mask, base and picture on one row. The walk recognizes mask (near-black) and bed (near-clipping) by level, and a rebate brighter or a holder edge darker than the picture on the same line. Where it ends on a relative test, the edge is the strongest gradient in the run and must be a real step by magnitude. Sides are independent; a cap and a support test bound the risk. The first pass reaches 3% of the side and is accepted as is. The second reaches 10% and is adopted only where it goes deeper and passes four tests: it stopped inside its allowance, its per-line depths fit a straight line, its outermost pixels hold one level along the side, and its closing step carries most of the drop from that level to the picture. * A **per-side ring measurement** (`measure_film_border`), gated on an opposite pair because a bright sky at the frame edge reads like film base. It reads a wide rebate below bed level. - Rebate Trim scales the refinement: 0 stops at the film edge, 1.0 lands on the detected image edge, above 1.0 cuts into the picture. The same factor scales the roll-pooled inset in Auto-crop all frames (Crop card), where walk and ring both feed the roll median and each side is pooled on its own sample count. A roll too short for any side to reach that count trims by the ring alone, gated on an opposite pair. + Rebate Trim scales the refinement: 0 stops at the film edge, 1.0 lands on the detected image edge, above 1.0 cuts into the picture. The same factor scales the roll-pooled inset in Auto-crop all frames, where walk and ring both feed the roll median and each side pools on its own sample count. A roll too short for any side to reach that count trims by the ring alone, gated on an opposite pair. - In a roll, a portrait frame is resolved from its own single-frame crop, because the roll is landscape. A frame whose film box was not found is placed from the roll, its left and right edges taken from a vertical-edge profile. When no frame in the roll found a box (film overfills the sensor), the roll falls back to the threshold box of single-frame Auto Crop, trimmed by the border walk, only where that box is near the whole frame. Deskew is not pooled: the angle comes from the film-box contour, refined by a line fit on the top edge where the two agree, and a frame keeps its own tilt against the roll. + In a roll, a portrait frame resolves from its own single-frame crop, because the roll is landscape. A frame with no film box is placed from the roll, its left and right edges taken from a vertical-edge profile. When no frame in the roll found a box (film overfills the sensor), the roll falls back to the threshold box of single-frame Auto Crop, trimmed by the border walk, only where that box is near the whole frame. Deskew is not pooled: the angle comes from the film-box contour, refined by a line fit on the top edge where the two agree. -**Note:** the crop *selection* is resolved here and becomes the analysis ROI, so normalization can tell image from border. The pixels are cropped only **after Toning**, before Finish, so Lab and the preview overlays see the full frame. The "Analysis buffer" option excludes the outer X% from analysis, and a freehand **analysis region** (`analysis_rect`) sets exactly what the meters read. +**Note:** the crop *selection* resolves here and becomes the analysis ROI. The pixels are cropped only **after Toning**, before Finish, so Lab and the preview overlays see the full frame. The "Analysis buffer" option excludes the outer X% from analysis, and a freehand **analysis region** (`analysis_rect`) sets exactly what the meters read. --- ## 2. Scan Normalization **Code**: `negpy.features.exposure.normalization` -* **Physical model**: the input is a **radiometric measurement**: pixel values are linear transmittance. +* **Physical model**: pixel values are linear transmittance. * **Source corrections** (linear domain, before the log conversion): - * **Flat-field** (`negpy.features.flatfield`): divides out illumination falloff with a blank reference frame. A per-channel gain map $\text{mean}(\text{blur})/\text{blur}$, computed on a 256 px copy and clamped to $[0.25, 4]$, multiplies the linear source. The Gaussian blur has $\sigma$ = 1/64 of the long side: small enough to follow the light source's own pattern (LED hot spots behind a diffuser), which repeats from shot to shot. A profile stores its baked gain, so a profile made with an earlier blur keeps that gain until a new profile is made from its reference. The blur and the mean cover only the lit area: pixels below 0.2 of the 95th-percentile luminance (a carrier edge in the reference) are masked out of a normalized convolution, so a dark border does not overcorrect the frame next to it. Pixels too far from the lit area take the nearest valid value. A profile also stores a 512 px copy of the reference (float16) and its self-check. Check Flat Field is a canvas view, not a stage: that copy times the gain, each channel over its lit-area median, ±10% to black..white; a profile without the copy shows 1/gain over ±15%. The gain is **baked once** into a profile (an `.npz` in `APP_CONFIG.flatfield_dir`, keyed by an opaque id), so moving or deleting the reference file is harmless. The edit stores only the profile id. The render resolves the gain through a provider (`set_gain_provider`, wired to `services/assets/flatfield.py`) and caches it; the profile id and a gain content token fold into the source hash. - * **Embedded lens correction** (`negpy.features.lens`): the scanning camera's own distortion and lateral CA profile (DNG `WarpRectilinear`, Sony ARW, and a radial distortion polynomial for Panasonic RW2), read from the source file and applied at decode after flat-field and before the unmix, so neither engine needs a shader. The RW2 model follows darktable's embedded-metadata reader and carries no lateral CA. It is skipped for composites and RGB+IR sources. A correction that reads past the frame edge is scaled about its center to fill (`fill_scale` ≤ 1); a CA-only correction is not scaled. - * **Sensor crosstalk unmix** (`sensor_matrix`, `features/process/sensor.py`): for single-shot narrowband camera scans, CFA passbands overlap the light's bands, so each channel leaks into the others. It is a property of the sensor and light, not the film. It is calibrated from three bare-light exposures (columns normalized to a unit diagonal), inverted, and applied as a 3×3 unmix of the **linear** capture, ahead of the log. Where the film passes almost none of a band's light, the subtraction leaves a value smaller than its grain and calibration error, and the log turns it 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$; each channel is held at $10^{-3} b_c$ or above, so it never reaches zero. `two_scale` is the clipped linear unmix where its own-channel gain $M_{cc}\bar x_c / (M\bar x)_c$ stays below 1.15 $C_{cc}$, and blends by a smoothstep (full at 1.5 $C_{cc}$, one weight per pixel) 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; $\mathrm{SF}$ is the smooth maximum $\tfrac12\big(u + f + \sqrt{(u - f)^2 + f^2}\big)$ of each unmixed channel and $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, not an edge, and takes their mean. $b$ is the median color of the frame-interior cells thinnest in all three channels together; only its color matters. A channel whose 99.5th percentile sits at its maximum is clipped, and both methods fall back to `linear`. A stitch is unmixed once, assembled, and a half-frame after it is sliced, so preview and export read the same base. `unmix_block_reason` refuses it on a transparency (E-6 is not scanned narrowband) and on a camera-WB decode (`linear_raw` off), where a diagonal gain does not commute with the unmix. The **narrowband scan** toggle instead applies the bundled RGBScan *input* profile at the display and export boundary; an explicit Input ICC overrides it. + * **Flat-field** (`negpy.features.flatfield`): divides out illumination falloff with a blank reference frame. A per-channel gain map $\text{mean}(\text{blur})/\text{blur}$, computed on a 256 px copy and clamped to $[0.25, 4]$, multiplies the linear source. The Gaussian blur has $\sigma$ = 1/64 of the long side, small enough to follow the light source's own pattern. The blur and the mean cover only the lit area: a normalized convolution masks out pixels below 0.2 of the 95th-percentile luminance, so a dark border does not overcorrect the frame next to it. Pixels too far from the lit area take the nearest valid value. A profile also stores a 512 px copy of the reference (float16) for its self-check: Check Flat Field shows that copy times the gain, each channel over its lit-area median, ±10% to black..white, or 1/gain over ±15% without the copy. The gain is **baked once** into a profile (an `.npz` in `APP_CONFIG.flatfield_dir`, keyed by an opaque id) and kept until the profile is rebuilt from its reference. The edit stores only the profile id. The render resolves the gain through a provider (`set_gain_provider`, wired to `services/assets/flatfield.py`) and caches it; the profile id and a gain content token fold into the source hash. + * **Embedded lens correction** (`negpy.features.lens`): the scanning camera's own distortion and lateral CA profile (DNG `WarpRectilinear`, Sony ARW, and a radial distortion polynomial for Panasonic RW2, with no lateral CA, after darktable's reader), applied at decode after flat-field and before the unmix, so neither engine needs a shader. It is skipped for composites and RGB+IR sources. A correction that reads past the frame edge is scaled about its center to fill (`fill_scale` ≤ 1); a CA-only correction is not scaled. + * **Sensor crosstalk unmix** (`sensor_matrix`, `features/process/sensor.py`): in single-shot narrowband camera scans the CFA passbands overlap the light's bands, so each channel leaks into the others. It is calibrated from three bare-light exposures (columns normalized to a unit diagonal), inverted, and applied as a 3×3 unmix of the **linear** capture, ahead of the log. Where the film passes almost none of a band's light, the subtraction leaves a value below its 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$. Each channel is held at $10^{-3} b_c$ or above. + * `two_scale` is the clipped linear unmix where its own-channel gain $M_{cc}\bar x_c / (M\bar x)_c$ stays below 1.15 $C_{cc}$. Above that it blends by a smoothstep (full at 1.5 $C_{cc}$, one weight per pixel) 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. $\mathrm{SF}$ is the smooth maximum $\tfrac12\big(u + f + \sqrt{(u - f)^2 + f^2}\big)$ of each unmixed channel, 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. + + $b$ is the median color of the frame-interior cells thinnest in all three channels together. A channel whose 99.5th percentile sits at its maximum is clipped, and both methods fall back to `linear`. A stitch is unmixed once, assembled, and a half-frame after it is sliced, so preview and export read the same base. `unmix_block_reason` refuses the unmix on a transparency and on a camera-WB decode (`linear_raw` off), where a diagonal gain does not commute with the unmix. The **narrowband scan** toggle instead applies the bundled RGBScan *input* profile at the display and export boundary; an explicit Input ICC overrides it. * **HDR merge** (`features/hdr`): combines a bracket of one frame into a single linear source at decode, next to the triplet merge. It has no shader and no GPU parity surface. - The **reference** is the longest exposure with a negligible clipped fraction (`choose_reference`), and the merge is computed in its units, so the result stays in $[0, 1]$ (`features/process/logic.py` clips at pipeline entry). The render exposure (`output_scale`, clamped to $\le 1$) comes from `hdr_anchor`, a frame the user nominates, because the longest unclipped capture sits above the metered exposure. With no anchor it is the bracket's median ratio in log space. The final clip runs after this scale, so an anchor below the reference keeps radiance above the reference's white. `anchor_choices` offers only frames the bracket contains. + The **reference** is the longest exposure with a negligible clipped fraction (`choose_reference`), and the merge is computed in its units, so the result stays in $[0, 1]$ (`features/process/logic.py` clips at pipeline entry). The render exposure (`output_scale`, clamped to $\le 1$) comes from `hdr_anchor`, a frame the user nominates; with no anchor it is the bracket's median ratio in log space. The final clip runs after this scale, so an anchor below the reference keeps radiance above the reference's white. - The merge is cached **unscaled** and `apply_render_exposure` scales it on the way out, so an exposure change needs no new decode. The merge key (`hdr_merge_token`) excludes the exposure; the source identity (`hdr_token`) includes it. The cached merge holds values **above 1.0**. + The merge is cached **unscaled**, with values **above 1.0**, and `apply_render_exposure` scales it on the way out, so an exposure change needs no new decode. The merge key (`hdr_merge_token`) excludes the exposure; the source identity (`hdr_token`) includes it. - A fresh merge is seeded with a Shadows Density lift (`seed_shadow_density`, $-0.30$ per stop of `shadow_reach_stops`, capped at $-0.70$). It is an edit on the composite, so the slider shows it and zero restores the faithful render. It is bounded so the merged render stays quieter than the single metered frame. A merge recovers shadow precision, not range. + A fresh merge is seeded with a Shadows Density lift (`seed_shadow_density`, $-0.30$ per stop of `shadow_reach_stops`, capped at $-0.70$). It is an edit on the composite, so zero restores the faithful render. - Exposure ratios are solved from the images, because several loaders are scanner formats with no exposure tags. `pair_ratio` is the median of per-sample ratios over samples above the noise floor in the shorter frame and unsaturated in the longer, chained outward from the reference between **exposure-adjacent** frames. `exposure_order_key` orders the bracket by clipped fraction, then level, because `level` pins at $1.0$ for every frame clipping more than 1%. Samples combine by **inverse variance** (weight $r^2$), so the merge is no worse than the best single frame. The weight rolls off to zero between $0.90$ and $0.995$ per channel. Output is float32, because the recovered detail sits below the reference's quantization step. + Exposure ratios are solved from the images (several scanner formats have no exposure tags). `pair_ratio` is the median of per-sample ratios over samples above the noise floor in the shorter frame and unsaturated in the longer, chained outward from the reference between **exposure-adjacent** frames. `exposure_order_key` orders the bracket by clipped fraction, then level, because `level` pins at $1.0$ for every frame clipping more than 1%. Samples combine by **inverse variance** (weight $r^2$). The weight rolls off to zero between $0.90$ and $0.995$ per channel. Output is float32, because the recovered detail sits below the reference's quantization step. - The merge runs **before** flat-field and the sensor unmix, because a gain map applied first would move the saturation point the weights key on. Every frame decodes on the **reference frame's** white balance, because per-frame as-shot multipliers (`use_camera_wb`) make `pair_ratio` solve wrong ratios, which print as contour rings around a blown highlight. The solve (`HdrWorker.run`) decodes one frame at a time with `hdr` cleared and is pinned separately: it passes the first frame's multipliers to the rest, since the reference is not yet known. - * **Composite assembly** (`features/rgbscan`, `features/stitch`): a frame may be built from several files before any of the above. A **Trichrome triplet** takes each output channel from the exposure lit by that band, green and blue registered to red by phase correlation. Its channels never mixed in the sensor, so the crosstalk unmix is skipped. Every exposure decodes on a neutral white balance. A **stitch composite** decodes each part separately, warps the parts into a shared canvas by a registration fixed at stitch time, matches per-channel gain across the overlap, then cuts a minimum-error seam through it and cross-fades over a narrow band at that cut. The IR defect mask follows the same cut, so it describes the part each pixel actually came from. Averaging the whole overlap instead draws twice any detail the registration could not line up, which film that is not flat between shots always leaves behind. The two nest: every part carries its own triplet, and flat-field and unmix apply **per part, never to the composite canvas**. -* **Log conversion**: film density is logarithmic ($D \propto \log E$), so the raw signal goes to log space: + The merge runs **before** flat-field and the sensor unmix, because a gain map applied first would move the saturation point the weights key on. Every frame decodes on the **reference frame's** white balance; per-frame as-shot multipliers (`use_camera_wb`) make `pair_ratio` solve wrong ratios. The solve (`HdrWorker.run`) decodes one frame at a time with `hdr` cleared and passes the first frame's multipliers to the rest, since the reference is not yet known. + * **Composite assembly** (`features/rgbscan`, `features/stitch`): a frame may be built from several files before any of the above. A **Trichrome triplet** takes each output channel from the exposure lit by that band, with green and blue registered to red by phase correlation. The crosstalk unmix is skipped, and every exposure decodes on a neutral white balance. A **stitch composite** decodes each part separately, warps the parts into a shared canvas by a registration fixed at stitch time, and matches per-channel gain across the overlap. It then cuts a minimum-error seam through the overlap and cross-fades over a narrow band at that cut; averaging the whole overlap draws twice any detail the registration could not line up. The IR defect mask follows the same cut. Every part carries its own triplet, and flat-field and unmix apply **per part, never to the composite canvas**. +* **Log conversion**: the raw signal goes to log space: $$E_{log} = \log_{10}(I_{raw})$$ * **Bounding and polarity**: Statistical percentiles detect the usable signal range. The target **White Point** always maps to the **Floor** ($0.0$) and the **Black Point** to the **Ceiling** ($1.0$). * **Negative (Color Negative / B&W Negative)**: raw low signal (dense highlights) maps to Floor ($0.0$). Raw high signal (film base, shadows) maps to Ceiling ($1.0$). Range: 0.01% to 99.99%. - * **Transparency** takes none of this metering: its window is fixed and the camera matrix is applied instead. See *Transparency transfer* in §3. + * **Transparency** takes none of this metering. See *Transparency transfer* in §3. Bounds are sampled on **two independent axes** (`_sample_log_bounds`). A **luma** pass fixes the floor and ceiling mean (center plus span), and a **color** pass fixes each channel's deviation from it, so color balance is tunable without compressing the luminance range. Mono gives zero deviation at any clip. - * **Luma Range Clip** (`luma_range_clip`): the *luminance* percentile window (dynamic range). **Positive** values tighten it symmetrically, for dense or fogged negatives with outlier pixels. **Zero** uses robust extremes: a block-median prefilter rejects dust and speculars, and a small base clip excludes tiny outlier populations. **Negative** values push the bounds *outward*, leaving lifted blacks and unclipped highlights as headroom. - * **Color Clip** (`color_range_clip`): the sampling depth for per-channel color deviation (white balance, orange-mask cast). A **tighter** (larger) value samples deeper for a more outlier-resistant balance; a **gentler** (smaller) value samples nearer the extremes. The neutral default is `base_color_clip` ($1.0$); the slider spans log-interpolated values either side. + * **Luma Range Clip** (`luma_range_clip`): the *luminance* percentile window. **Positive** values tighten it symmetrically. **Zero** uses outlier-resistant extremes: a block-median prefilter rejects dust and speculars, and a small base clip excludes tiny outlier populations. **Negative** values push the bounds *outward*, leaving lifted blacks and unclipped highlights as headroom. + * **Color Clip** (`color_range_clip`): the sampling depth for per-channel color deviation. A larger value samples deeper for a more outlier-resistant balance; a smaller value samples nearer the extremes. The neutral default is `base_color_clip` ($1.0$); the slider spans log-interpolated values either side. - The **thin end** (film base) uses per-channel percentiles, anchored because density is bounded below by base. The **dense end** (scene highlights) reads one shared, chroma-gated pixel set (the lowest-chroma subset of the luma-extreme band, base-anchored), so colored highlights cannot pass as film cast. With no trustworthy neutrals, and always in Transparency, it falls back to per-channel percentiles. - * **White and Black Point offsets**: fine-tune the detected bounds without re-running the analysis. They are roll defaults on the Roll tab's Metering card, as Dmin and Dmax belong to the stock. A **[Global / R / G / B]** selector scopes the sliders to per-layer trims (`white_point_trim_*` / `black_point_trim_*`) added on top of the global offsets, for per-dye-layer Dmin and Dmax correction (`per_channel_point_offsets`, single source for CPU and GPU; Transparency negates; hidden in B&W Negative). - * **Roll baseline and locks**: Roll Analysis measures every frame with its own crop, freehand analysis region and crosstalk. A frame whose luma-free color offsets (per channel and bound) lie further than `POOL_OUTLIER_DISTANCE` log density from the median is an outlier: shot under a different light, or scanned with a different per-channel balance. The baseline pools the other frames: luma is the median of their luma-weighted floors and ceilings, color the mean of their offsets. An outlier keeps its own bounds (both averages off), on this run and on every later apply of the same baseline, because its color offset also shifts luma through G. **Use average: Luma / Color** (`use_luma_average` / `use_color_average`, the Roll Analysis card's roll defaults) apply the baseline independently per axis. **Lock bounds** (`lock_bounds`) exempts a frame from Roll Analysis, on the first run and every re-analysis. A **scene** is a hand-picked group of frames with its own baseline: Scene Analysis pools only its members and writes to them alone. Roll Analysis and a picked roll baseline skip scene members. The analysis also pools each inlier frame's Cast Removal **neutral axis** (raw log, each frame's own unmix and region) by a confidence-weighted median per band and channel (`pool_neutral_axis`); the highlight band pools only when at least half the frames have one. **Use average: Cast** (`use_cast_average`, Color Negative only) renders Cast Removal with that pooled axis (`locked_neutral_axis`), on both engines: the R/B-vs-G curve is the pool's (film and process), the midtone level moves toward the frame's own by `weight × confidence` (`blend_neutral_axis`). The weight is the pool's, $1 - \sigma^2_{noise}/\sigma^2_{offset}$: the spread of the frames' midtone offsets from the pooled curve against the meter noise, estimated as half the spread of their shadow-minus-midtone offsets, since the curve's shape is the same for every frame. Frames under one light give a weight near zero and render alike; a pool mixing lights keeps each frame's own level. Below three frames the weight is zero. -* **Stretch**: every mode stretches each channel independently to $[0, 1]$, which neutralizes the orange mask in negatives and base tints and fading in reversal film. The result is not clamped: tones outside the bounds are rolled off later by the print curve's toe and shoulder. -* **Per-frame metering**: normalization also measures inputs for the Print stage helpers: per-channel **shadow references** ($P_{98}$, for Cast Removal), an **exposure anchor** (for Auto Density) and a **textural range** ($P_{10}$ to $P_{90}$ luminance, for Auto Grade). The anchor and textural meters read only **textured cells** of the block-median grid: sectors of 2×2 blocks of `activity_block` cells vote when their block means span more than `activity_gate_density` ($0.05$ log D; Boyack & Juenger, US 5,724,456). When fewer than `activity_min_fraction` of sectors pass, every cell votes. See §3. -* **Channel unmix / crosstalk** (`crosstalk_strength` / `crosstalk_matrix`): a 3×3 unmix (`.toml` profiles, see docs/CROSSTALK.md) of the raw **negative** log densities *before* bounds analysis and the stretch, where secondary dye absorptions are linear (Beer-Lambert). The light spectrum and CFA passbands are the same linear operator in log density, so a profile belongs to a *scanning setup*, not a film stock. The sensor matrix (linear, before the log) and Hue Trim in §3 are not interchangeable with it. The matrix is blended with identity by strength and row-normalized, so grays are preserved, and every meter (bounds, anchor, shadow refs, neutral axis) reads the unmixed film, including Roll Analysis. After changing the matrix or the strength, re-run Roll Analysis and re-check locked bounds. -* **Scan-clip warning**: the per-channel fraction of source pixels at or above sensor white ($\ge 0.99$ linear) is reported (`scan_clip_fractions`). In a negative, film base and shadows sit near sensor white, so clipping collapses them to $D=0$; the Analysis panel warns above 1%. The engine attempts no reconstruction. + The **thin end** (film base) uses per-channel percentiles. The **dense end** (scene highlights) reads one shared, chroma-gated pixel set (the lowest-chroma subset of the luma-extreme band, base-anchored), so colored highlights cannot pass as film cast. With no trustworthy neutrals, and always in Transparency, it falls back to per-channel percentiles. + * **White and Black Point offsets**: shift the detected bounds without re-running the analysis. Per-layer trims (`white_point_trim_*` / `black_point_trim_*`) add on top of the global offsets (`per_channel_point_offsets`, single source; Transparency negates). + * **Roll baseline and locks**: Roll Analysis measures every frame with its own crop, freehand analysis region and crosstalk. A frame whose luma-free color offsets (per channel and bound) lie further than `POOL_OUTLIER_DISTANCE` log density from the median is an outlier. The baseline pools the other frames: luma is the median of their luma-weighted floors and ceilings, color the mean of their offsets. An outlier keeps its own bounds (both averages off), on this run and on every later apply of the same baseline, because its color offset also shifts luma through G. + + `use_luma_average` / `use_color_average` apply the baseline independently per axis. `lock_bounds` exempts a frame from Roll Analysis. A **scene** pools only its members and writes to them alone; Roll Analysis and a picked roll baseline skip scene members. + + The analysis also pools each inlier frame's Cast Removal **neutral axis** (raw log, each frame's own unmix and region) by a confidence-weighted median per band and channel (`pool_neutral_axis`). The highlight band pools only when at least half the frames have one. `use_cast_average` (Color Negative only) renders Cast Removal with that pooled axis (`locked_neutral_axis`) on both engines. The R/B-vs-G curve is the pool's; the midtone level moves toward the frame's own by `weight × confidence` (`blend_neutral_axis`). The weight is $1 - \sigma^2_{noise}/\sigma^2_{offset}$: the spread of the frames' midtone offsets from the pooled curve against the meter noise, estimated as half the spread of their shadow-minus-midtone offsets. Below three frames the weight is zero. +* **Stretch**: every mode stretches each channel independently to $[0, 1]$, which neutralizes the orange mask and base tints. The result is not clamped: the print curve's toe and shoulder roll off tones outside the bounds. +* **Per-frame metering**: normalization also measures per-channel **shadow references** ($P_{98}$, for Cast Removal), an **exposure anchor** (for Auto Density) and a **textural range** ($P_{10}$ to $P_{90}$ luminance, for Auto Grade). The anchor and textural meters read only **textured cells** of the block-median grid. Sectors of 2×2 blocks of `activity_block` cells vote when their block means span more than `activity_gate_density` ($0.05$ log D; Boyack & Juenger, US 5,724,456). When fewer than `activity_min_fraction` of sectors pass, every cell votes. +* **Channel unmix / crosstalk** (`crosstalk_strength` / `crosstalk_matrix`): a 3×3 unmix (`.toml` profiles, see docs/CROSSTALK.md) of the raw **negative** log densities *before* bounds analysis and the stretch, where secondary dye absorptions are linear. The light spectrum and CFA passbands are the same linear operator in log density, so a profile belongs to a *scanning setup*, not a film stock. The matrix is blended with identity by strength and row-normalized, so grays are kept. Every meter (bounds, anchor, shadow refs, neutral axis) reads the unmixed film, Roll Analysis included, so a matrix or strength change needs a Roll Analysis re-run. +* **Scan-clip warning**: `scan_clip_fractions` reports the per-channel fraction of source pixels at or above sensor white ($\ge 0.99$ linear). In a negative, clipping collapses film base and shadows to $D=0$; the Analysis panel warns above 1%. The engine attempts no reconstruction. --- ## 3. The Print (Exposure) **Code**: `negpy.features.exposure` -* **Virtual darkroom**: simulates light through the normalized log signal onto paper. -* **B&W Negative (panchromatic)**: the normalized signal collapses to a **single density** (its luminance) *before* the curve, so the H&D curve shapes one channel. Per-channel color controls are hidden. -* **Color timing**: subtractive CMY filtration in log space, like an enlarger's dichroic head. Targets **Global**, **Shadows** or **Highlights**. Shadow and highlight offsets are weighted by a sigmoid about the midtone, $w_{sh} = \sigma(3 \cdot (v - z))$ with $z$ the midtone zone center (`anchor_target_density`). The Temperature slider, the WB picker and the temperature roll-lock act on the *selected region's* M/Y pair. -* **The H&D curve**: an **asymmetric toe-linear-shoulder** curve in **density** space. A line of slope $k$ through the exposure pivot is bounded above by the **toe** (into paper black) and below by the **shoulder** (into paper white). Both bounds are independent **softplus** knees, so each slider shapes only its own end. With $v = k \cdot (x_{adj} - x_0)$: +* **B&W Negative (panchromatic)**: the normalized signal collapses to a **single density** (its luminance) *before* the curve, so the H&D curve shapes one channel. +* **Color timing**: subtractive CMY filtration in log space. Targets **Global**, **Shadows** or **Highlights**. Shadow and highlight offsets are weighted by a sigmoid about the midtone, $w_{sh} = \sigma(3 \cdot (v - z))$, with $z$ the midtone zone center (`anchor_target_density`). The Temperature slider, the WB picker and the temperature roll-lock act on the *selected region's* M/Y pair. +* **The H&D curve**: an **asymmetric toe-linear-shoulder** curve in **density** space. A line of slope $k$ through the exposure pivot is bounded above by the **toe** (into paper black) and below by the **shoulder** (into paper white). Both bounds are independent **softplus** knees. With $v = k \cdot (x_{adj} - x_0)$: $$v_1 = D_{min} + \frac{\text{softplus}\big(a_{hl} (v - D_{min})\big)}{a_{hl}} \qquad \text{(shoulder → paper white)}$$ $$D = D_{max} - \frac{\text{softplus}\big(a_{sh} (D_{max} - v_1)\big)}{a_{sh}} \qquad \text{(toe → paper black)}$$ * $D_{min} = 0.06$: paper white. **Paper White Base** (`paper_dmin`) toggles it; off uses $D_{min} = 0$. - * $D_{max} = 2.3$: paper D-max. The softplus toe rolls density into $D_{max}$ directly, with no separate virtual asymptote. - * $a_{sh}, a_{hl}$: knee sharpness, from `toe_sharpness_base` ($6.0$) and `shoulder_sharpness_base` ($3.0$) scaled by `toeshoulder_width_ref` / width. + * $D_{max} = 2.3$: paper D-max, reached directly by the softplus toe. + * $a_{sh}, a_{hl}$: knee sharpness, from `toe_sharpness_base` ($6.0$) and `shoulder_sharpness_base` ($3.0$), scaled by `toeshoulder_width_ref` / width. * $k$: per-channel slope, from **Grade**. * $x_{adj}$: input log-exposure after CMY offsets. $x_0$ is the pivot. -* **Preflash** (`preflash`, 0 to 1, a fraction of the threshold exposure): a uniform exposure added to the image exposure, applied to the straight line before any shaping, $v \leftarrow v_{th} + \gamma \log_{10}\big(10^{(v - v_{th})/\gamma} + f\big)$. $f$ is the flash, $\gamma = 2.9 / (R/100)$ the paper's density per log exposure at the frame's ISO-R grade, and $v_{th}$ the value that prints `preflash_threshold_density` ($0.04$, the ISO R start) above $D_{min}$ (`preflash_params`). The flash is a paper exposure, so the scan's log range does not change it. It follows dodge/burn, the frame's flash and a mask's alike, so no mask dodges a flash; bare paper fogs neutral and stays at or under the threshold. Highlight hold measures its tone after the flash. -* **Variable-gamma paper S-curve**: before the bounds, $v \mathrel{+}= \gamma \cdot w \cdot \tanh\big((v - v^{\ast})/w\big)$ (`paper_midtone_gamma` $= 0.05$, `paper_gamma_width` $= 0.6$). Centered on the reference tone $v^{\ast}$ (the anchor holds), it eases to zero toward toe and shoulder. **Snap** (`midtone_gamma`) is a user trim on the paper's baseline $\gamma$. In R/G/B mode it adds per-layer trims (`midtone_gamma_trim_*`), which is midtone crossover, evaluated per channel (`per_channel_midtone_gamma`, single source for CPU, GPU and chart). -* **Grade (ISO-R)**: contrast as an **ISO range (R) value**, default 115, range 50 to 180. R110 is about paper grade 2; higher R is softer. $k = \text{(grade contrast scale)} \cdot \text{range} / (R/100)$ (`grade_contrast_scale` $= 2.9$), clamped to $[2.0, 10.0]$ Edits on the old 0 to 5 paper-grade scale migrate with $R = 150 - 20 \cdot G$. -* **Split Grade** (`shadow_grade` / `highlight_grade`, ISO-R points, negative = harder): zone-local contrast. The curve rotates about the shadow and highlight zone centers, $v \mathrel{+}= \Delta k_{ch} \cdot w \cdot (v - z_{zone})$, with the same mid-sparing sigmoid weights as Zone Density ($z_{sh} = z + 0.75$, $z_{hl} = z - 0.40$, $k = 4$). The points fold into a slope ratio like Grade trims (`split_grade_deltas`, single source for CPU, GPU and chart), with per-layer trims (`shadow_grade_trim_*` / `highlight_grade_trim_*`). It runs **before** Zone Density as its own block, because sequential blocks stay monotone and shared weights do not. -* **Per-layer trims (crossover correction)**: the **Global / R / G / B** selectors on the Tone and Paper Response cards trim one dye layer against the shared curve. CMY only *shifts* a layer's curve; **crossover** (shadows one color, highlights the complement) needs a shape change: - * **Grade trim** (`grade_trim_*`, ±30 ISO-R points): folds into the slope like a paper's `channel_gamma`. As $k \propto 1/R$, the trim is the ratio $R/(R+\Delta R)$, and the pivot is re-solved per channel, so the layer rotates about the anchor and midtones stay neutral. - * **Toe / Shoulder trims** (`toe_trim_*` / `shoulder_trim_*`, ±1 on the global knee): per-layer endpoint casts. Effective values are clamped to the slider domain (`per_channel_toe_shoulder`, single source for CPU, GPU and chart). - * **Snap trim** (`midtone_gamma_trim_*`, ±0.5 on the global Snap): a cast only in the midtones; endpoints and anchor stay neutral. - * **Width trims** (`toe_width_trim_*` / `shoulder_width_trim_*`, ±2 on the global widths, effective values clamped to [0.1, 5]): per-layer knee *sharpness*, how far one layer's roll-off reaches (`per_channel_widths`, single source for CPU, GPU and chart). -* **Toe and Shoulder**: independent, slider values scaled by $0.85$ internally, evaluated **per channel** (global value plus layer trim). The slider sets roll-off **height**; sharpness comes from the per-channel width control: - * **Toe** (shadows): lifts the paper-black ceiling, $D_{max,eff} = D_{max} - \text{toe} \cdot 0.90$ (`toe_height`). The 0.90 (above `shoulder_height`) roughly equalizes the two sliders in $L^{\ast}$. Negative toe *sharpens* the shadow knee and, with **Paper Black** off, raises the BPC clip point (see Output), which makes exact black reachable. - * **Shoulder** (highlights): lifts the paper-white floor, compressing and graying highlights, $D_{min,eff} = D_{min} + \text{shoulder} \cdot 0.35$ (`shoulder_height`). - * **Grade-coupled baseline**: hard grades get snappier toes and compressed shoulders automatically, scaled by the normalized slope (`toe_grade_strength` $\approx 0.058$, a baseline $\Delta D$ of $0.15 \cdot 0.35$; `shoulder_grade_strength` $= 0.12$). -* **Zone Density (ΔD)**: two achromatic sliders (`shadow_density` ±1.0, `highlight_density` ±1.0), a literal density offset at full zone weight, that move the shadow and highlight zones without reshaping the knees. Unlike regional CMY, each has a **mid-sparing** weight centered in the three-quarter and quarter tones: $v \mathrel{+}= \Delta D_{sh} \cdot \sigma\big(k(v - z_{sh})\big) + \Delta D_{hl} \cdot \big(1 - \sigma(k(v - z_{hl}))\big)$ with $z_{sh} = z + 0.75$, $z_{hl} = z - 0.40$, $k = 4$ (`zone_density_*` constants, mirrored as literals in `exposure.wgsl`). It runs before the softplus bounds, so a shadow burn never passes paper black and a highlight bleach never crosses paper white. The chart mirrors the shift (`CharacteristicCurve`). -* **Dodge & Burn** (`negpy.features.local`): masks with a print exposure in **stops** (`LocalMask.stops`, ±2, default 0; positive burns, negative dodges, signed like `vignette_stops`) and a Gaussian feather ($\sigma$ as a fraction of the short side). They rasterize to a per-pixel stop map added to the log-exposure input with the CMY offsets. One stop is $\log_{10}(2)$ scaled by each channel's stretch range (`local_ev_scale`). Vertices are in raw-image coordinates and follow geometry (rotation, flips, distortion). The Flat intent skips them. -* **Mask shapes** (`LocalMask.shape`): vertices are the universal store and `shape` says how to read them; every control point goes through `map_coords_to_geometry` the same way. +* **Preflash** (`preflash`, 0 to 1, a fraction of the threshold exposure): a uniform exposure added on the straight line, before any shaping: $v \leftarrow v_{th} + \gamma \log_{10}\big(10^{(v - v_{th})/\gamma} + f\big)$. $f$ is the flash, $\gamma = 2.9 / (R/100)$ the paper's density per log exposure at the frame's ISO-R grade, $v_{th}$ the value that prints `preflash_threshold_density` ($0.04$, the ISO R start) above $D_{min}$ (`preflash_params`). The flash is a paper exposure, so the scan's log range does not change it. It follows dodge/burn (the frame's flash and a mask's). Bare paper fogs neutral, at or under the threshold. Highlight hold measures its tone after the flash. +* **Variable-gamma paper S-curve**: before the bounds, $v \mathrel{+}= \gamma \cdot w \cdot \tanh\big((v - v^{\ast})/w\big)$ (`paper_midtone_gamma` $= 0.05$, `paper_gamma_width` $= 0.6$), centered on the reference tone $v^{\ast}$. **Snap** (`midtone_gamma`) is a user trim on the paper's baseline $\gamma$, with per-layer trims (`midtone_gamma_trim_*`) evaluated per channel (`per_channel_midtone_gamma`, single source). +* **Grade (ISO-R)**: contrast as an **ISO range (R) value**, default 115, range 50 to 180. R110 is about paper grade 2; higher R is softer. $k = \text{(grade contrast scale)} \cdot \text{range} / (R/100)$ (`grade_contrast_scale` $= 2.9$), clamped to $[2.0, 10.0]$. Edits on the legacy 0 to 5 paper-grade scale migrate with $R = 150 - 20 \cdot G$. +* **Split Grade** (`shadow_grade` / `highlight_grade`, ISO-R points, negative = harder): the curve rotates about the shadow and highlight zone centers, $v \mathrel{+}= \Delta k_{ch} \cdot w \cdot (v - z_{zone})$, with the Zone Density weights ($z_{sh} = z + 0.75$, $z_{hl} = z - 0.40$, $k = 4$). The points fold into a slope ratio like Grade trims (`split_grade_deltas`, single source), with per-layer trims (`shadow_grade_trim_*` / `highlight_grade_trim_*`). It runs **before** Zone Density as its own block, because sequential blocks stay monotone and shared weights do not. +* **Per-layer trims (crossover correction)**: trim one dye layer against the shared curve. CMY only *shifts* a layer's curve; **crossover** needs a shape change: + * **Grade trim** (`grade_trim_*`, ±30 ISO-R points): folds into the slope like a paper's `channel_gamma`. As $k \propto 1/R$, the trim is the ratio $R/(R+\Delta R)$. The pivot is re-solved per channel, so the layer rotates about the anchor and midtones stay neutral. + * **Toe / Shoulder trims** (`toe_trim_*` / `shoulder_trim_*`, ±1 on the global knee), effective values clamped to the slider domain (`per_channel_toe_shoulder`). + * **Snap trim** (`midtone_gamma_trim_*`, ±0.5 on the global Snap): a cast only in the midtones. + * **Width trims** (`toe_width_trim_*` / `shoulder_width_trim_*`, ±2 on the global widths, effective values clamped to [0.1, 5]): per-layer knee *sharpness* (`per_channel_widths`). +* **Toe and Shoulder**: independent, slider values scaled by $0.85$ internally, evaluated **per channel** (global value plus layer trim). The slider sets roll-off **height**; the width control sets sharpness: + * **Toe** (shadows): lifts the paper-black ceiling, $D_{max,eff} = D_{max} - \text{toe} \cdot 0.90$ (`toe_height`). Negative toe *sharpens* the shadow knee and, with **Paper Black** off, raises the BPC clip point (see Output). + * **Shoulder** (highlights): lifts the paper-white floor, $D_{min,eff} = D_{min} + \text{shoulder} \cdot 0.35$ (`shoulder_height`). + * **Grade-coupled baseline**: hard grades get snappier toes and compressed shoulders, scaled by the normalized slope (`toe_grade_strength` $\approx 0.058$, a baseline $\Delta D$ of $0.15 \cdot 0.35$; `shoulder_grade_strength` $= 0.12$). +* **Zone Density (ΔD)**: two achromatic density offsets (`shadow_density` ±1.0, `highlight_density` ±1.0) at full zone weight, with a **mid-sparing** weight centered in the three-quarter and quarter tones: $v \mathrel{+}= \Delta D_{sh} \cdot \sigma\big(k(v - z_{sh})\big) + \Delta D_{hl} \cdot \big(1 - \sigma(k(v - z_{hl}))\big)$, with $z_{sh} = z + 0.75$, $z_{hl} = z - 0.40$, $k = 4$ (`zone_density_*` constants, mirrored as literals in `exposure.wgsl`). It runs before the softplus bounds, so a shadow burn never passes paper black and a highlight bleach never crosses paper white. The chart mirrors the shift (`CharacteristicCurve`). +* **Dodge & Burn** (`negpy.features.local`): masks with a print exposure in **stops** (`LocalMask.stops`, ±2, default 0; positive burns, signed like `vignette_stops`) and a Gaussian feather ($\sigma$ as a fraction of the short side). They rasterize to a per-pixel stop map added to the log-exposure input with the CMY offsets. One stop is $\log_{10}(2)$ scaled by each channel's stretch range (`local_ev_scale`). Vertices are in raw-image coordinates and follow geometry. The Flat intent skips them. +* **Mask shapes** (`LocalMask.shape`): `shape` says how to read the stored vertices. Every control point goes through `map_coords_to_geometry`. * *Polygon*: N points, closed and Catmull-Rom smoothed (`smooth_polyline`). - * *Oval*: 3 points $(c, p_1, p_2)$. Outline $c + u\cos\theta + v\sin\theta$ over 64 samples, $u = p_1 - c$, $v = p_2 - c$. The axes need not be perpendicular. Points map to pixels before the outline is built, which handles the raw-image aspect ratio. - * *Gradient* (the card edge): 2 points $(a, b)$, alpha $= 1 - \text{smoothstep}(t)$ with $t = \big((p-a)\cdot d\big)/|d|^2$ clamped to $[0,1]$, $d = b - a$. Full exposure at and behind $a$, none at and past $b$. Feather does not apply; handle spacing sets the softness. + * *Oval*: 3 points $(c, p_1, p_2)$. Outline $c + u\cos\theta + v\sin\theta$ over 64 samples, $u = p_1 - c$, $v = p_2 - c$; the axes need not be perpendicular. Points map to pixels before the outline is built. + * *Gradient*: 2 points $(a, b)$, alpha $= 1 - \text{smoothstep}(t)$ with $t = \big((p-a)\cdot d\big)/|d|^2$ clamped to $[0,1]$, $d = b - a$. Feather does not apply. * *Invert* (`LocalMask.invert`): $\alpha \rightarrow 1 - \alpha$ after the feather. - * *Enabled* (`LocalMask.enabled`, default on): a disabled mask is skipped in `compute_local_maps` and adds nothing to either plane. It stays editable, with no canvas tint, printing-notes badge or recipe line. + * *Enabled* (`LocalMask.enabled`, default on): `compute_local_maps` skips a disabled mask. - One function (`local/logic.rasterise`) serves the render, the canvas tint and the printing-notes map. The GPU uses the same CPU-rasterised map (`compute_local_maps` → the dodge/burn texture), so shapes have no parity surface. -* **Local Grade** (`LocalMask.grade`, ISO-R points off the frame's Grade, negative = harder): a burn through a different filter. `compute_local_maps` rasterizes a second plane (plane 0 EV, plane 1 summed $\Delta R$), turned into a per-pixel slope multiplier $R/(R+\Delta R)$ clamped to the ISO-R ladder (`local_grade_factor_map` on the CPU; `exposure.wgsl` mirrors it). It multiplies the straight-line slope only, $v = k \cdot g \cdot (x_{adj} - x_0) + c \cdot x_{adj}^2$, rotating **about the channel pivot**, the same in all three channels; the cast-removal curvature $c$ stays global. On the GPU the $\Delta R$ plane rides the dodge/burn texture's blue channel and the shader applies the frame's grade, so a Grade change uploads no map. Metrics and the zone ruler describe the frame-wide grade. -* **Local Flash** (`LocalMask.flash`, 0 to 1, a fraction of the threshold exposure): the mask's own preflash. `compute_local_maps` pre-sums it into plane 2 as $\sum f_i \alpha_i$, and the preflash formula above runs with the frame's $f$ plus that sum at the pixel, with the frame's $v_{th}$ and $\gamma$. On the GPU the plane rides the dodge/burn texture's green channel. A tone-limited mask carries no flash. -* **Tone limit** (`LocalMask.key` *Highlights* / *Shadows*, `key_zone` and `key_softness` in print zones): the lith mask in register (Kodak O-3), so a burn stops at the subject's own edge. A limited mask's weight is $\alpha \cdot s(t)$, $t = (L - e_0)/(e_1 - e_0)$ clamped, $s = t^2(3-2t)$ (`tone_key_weight`), where $L$ is the luma of the **unburned** normalized log, so no mask can move the pixels it selects. The edges invert the achromatic print curve at $Z \mp S/2$ (`key_edges`, the inverse of `predicted_zone`) from the render's own metrics. The weight depends on each pixel, so the first four limited masks (`MAX_KEYED_MASKS`) keep their own shape planes after planes 0–1 (on the GPU, one texture) and a fifth prints unlimited. Limited grades add to plane 1's $\Delta R$ before the one clamp to the ISO-R ladder; the GPU carries that $\Delta R$ in the dodge/burn texture's blue channel. -* **Contrast Mask** (`ExposureConfig.contrast_mask`, ±0.5, default 0): the darkroom unsharp mask, a blurred low-gamma film mask sandwiched with the negative: $D' = D - g\,\text{blur}(D) + \text{const}$, a linear high-boost in log density (Ctein, *Post Exposure*; Bond, *Unsharp Masking*; Adams, *The Print*). + One function (`local/logic.rasterise`) serves the render, the canvas tint and the printing-notes map. The GPU uses the same CPU-rasterized map (`compute_local_maps` → the dodge/burn texture), so shapes have no parity surface. +* **Local Grade** (`LocalMask.grade`, ISO-R points off the frame's Grade, negative = harder): `compute_local_maps` rasterizes a second plane (plane 0 EV, plane 1 summed $\Delta R$) into a per-pixel slope multiplier $R/(R+\Delta R)$, clamped to the ISO-R ladder (`local_grade_factor_map`; `exposure.wgsl` mirrors it). It multiplies the straight-line slope only, $v = k \cdot g \cdot (x_{adj} - x_0) + c \cdot x_{adj}^2$, rotating **about the channel pivot**, the same in all three channels; the cast-removal curvature $c$ stays global. On the GPU the $\Delta R$ plane rides the dodge/burn texture's blue channel and the shader applies the frame's grade, so a Grade change uploads no map. +* **Local Flash** (`LocalMask.flash`, 0 to 1): the mask's own preflash. `compute_local_maps` pre-sums it into plane 2 as $\sum f_i \alpha_i$, and the preflash formula runs with the frame's $f$ plus that sum, with the frame's $v_{th}$ and $\gamma$. On the GPU the plane rides the dodge/burn texture's green channel. A tone-limited mask carries no flash. +* **Tone limit** (`LocalMask.key` *Highlights* / *Shadows*, `key_zone` and `key_softness` in print zones; Kodak O-3): a limited mask's weight is $\alpha \cdot s(t)$, $t = (L - e_0)/(e_1 - e_0)$ clamped, $s = t^2(3-2t)$ (`tone_key_weight`). $L$ is the luma of the **unburned** normalized log, so no mask can move the pixels it selects. The edges invert the achromatic print curve at $Z \mp S/2$ (`key_edges`, the inverse of `predicted_zone`) from the render's own metrics. The first four limited masks (`MAX_KEYED_MASKS`) keep their own shape planes after planes 0–1 (on the GPU, one texture); a fifth prints unlimited. Limited grades add to plane 1's $\Delta R$ before the one clamp to the ISO-R ladder. +* **Contrast Mask** (`ExposureConfig.contrast_mask`, ±0.5, default 0): a blurred low-gamma film mask sandwiched with the negative, $D' = D - g\,\text{blur}(D) + \text{const}$, a linear high-boost in log density (Ctein, *Post Exposure*; Bond, *Unsharp Masking*; Adams, *The Print*). - $g$ is the signed mask gamma. Positive (a blurred positive) scales the low-frequency range by $(1-g)$; negative (same polarity as the negative) by $(1+g)$. Detail above the blur scale passes at unity either way, so unlike a harder Grade it leaves micro-contrast alone, and it still works when a flat negative sits on the 2.0 slope floor. + $g$ is the signed mask gamma. Positive scales the low-frequency range by $(1-g)$; negative by $(1+g)$. Detail above the blur scale passes at unity, and the mask still works when a flat negative sits on the 2.0 slope floor. - It is not a stage. `contrast_mask_plane` builds a zero-mean plane that becomes stops of print exposure read with the dodge/burn map, equal in every channel (no per-layer trim). Both engines call the same helper on the same pre-geometry array, with $\sigma$ a fraction of the analysis grid, so preview, export and both engines match. The slider is a scalar on the plane (`contrast_mask_scale`) and rebuilds nothing: the CPU caches the plane at render size (`expand_mask_plane`); the GPU keeps it on the analysis grid in its own texture and upscales in the shader. + It is not a stage. `contrast_mask_plane` builds a zero-mean plane that becomes stops of print exposure, read with the dodge/burn map, equal in every channel. Both engines call the same helper on the same pre-geometry array, with $\sigma$ a fraction of the analysis grid. The slider is a scalar on the plane (`contrast_mask_scale`) and rebuilds nothing: the CPU caches the plane at render size (`expand_mask_plane`); the GPU keeps it on the analysis grid in its own texture and upscales in the shader. - **Mask Spacer** (`ExposureConfig.mask_spacer`, 2 to 6%, default 4) is the blur $\sigma$ as a per-cent of the analysis grid: a frequency cut-off, not a strength. A thin spacer reaches further down and compresses harder; the floor keeps it from collapsing into a plain $(1-g)$ Grade. It rebuilds the plane, so it keys both engines' plane caches, including the single plane a tiled export shares across tiles. + **Mask Spacer** (`ExposureConfig.mask_spacer`, 2 to 6%, default 4) is the blur $\sigma$ as a percent of the analysis grid. It rebuilds the plane, so it keys both engines' plane caches, including the single plane a tiled export shares across tiles. - The plane covers the printed frame only (a blurred rebate would print as a vignette), placed back at the crop and edge-replicated outside so the crop tool's full-frame preview has no seam. Hidden on the transparency transfer path, which takes no dodge/burn map. Instruments read the unmasked negative. -* **Diffusion** (`ExposureConfig.diffusion`, 0 to 1, default 0): a diffuser at the enlarger lens. A printer keeps one diffuser on the lens and varies how much of the exposure goes through it, so the spread is fixed: $\sigma$ is `DIFFUSION_SPREAD`, 2% of the printed frame's short side. The paper sees $I' = (1-d)\,I + d\,\text{blur}_\sigma(I)$, a mix of light, not of density, so per channel $v \leftarrow \log_{10}\big((1-d)\,10^{vr} + d\,10^{pr}\big)/r$, with $r$ the channel's log range (`channel_density_ranges`), $p$ the blurred frame in the same normalization and $d$ half the slider value (`diffusion_mix`), since a real diffuser passes much of the light unscattered. `diffusion_grid` blurs the unmixed light on the analysis grid (`analysis_grid`, the Contrast Mask's own); the normalization is a per-channel gain on that light, so `diffusion_plane` only applies the print's final bounds to the grid. A flat area prints as before; light from the thin parts of the negative spreads into the dense ones, so print shadows bleed into highlights. It runs on the normalized value before filtration and dodge/burn, which commute with it. Both engines sample the one grid plane bilinearly with clamped taps (`plane_taps`, `exposure.wgsl`); a B&W print mixes a luma plane, as it prints a luma pixel. On the GPU it rides the Contrast Mask texture's other three channels. The crop and the geometry rebuild the grid; a White/Black Point change only re-normalizes it. Print curve only: off on the transfer path, in the Flat master and in the carrier strip. + The plane covers the printed frame only (a blurred rebate would print as a vignette), placed back at the crop and edge-replicated outside so the crop tool's full-frame preview has no seam. The transfer path takes no dodge/burn map. Instruments read the unmasked negative. +* **Diffusion** (`ExposureConfig.diffusion`, 0 to 1, default 0): a diffuser at the enlarger lens with a fixed spread: $\sigma$ is `DIFFUSION_SPREAD`, 2% of the printed frame's short side. The paper sees $I' = (1-d)\,I + d\,\text{blur}_\sigma(I)$, a mix of light, not of density. Per channel, $v \leftarrow \log_{10}\big((1-d)\,10^{vr} + d\,10^{pr}\big)/r$, with $r$ the channel's log range (`channel_density_ranges`), $p$ the blurred frame in the same normalization, and $d$ half the slider value (`diffusion_mix`). `diffusion_grid` blurs the unmixed light on the analysis grid (`analysis_grid`, the Contrast Mask's own). The normalization is a per-channel gain on that light, so `diffusion_plane` only applies the print's final bounds to the grid. It runs on the normalized value before filtration and dodge/burn, which commute with it. Both engines sample the one grid plane bilinearly with clamped taps (`plane_taps`, `exposure.wgsl`); a B&W print mixes a luma plane. On the GPU it rides the Contrast Mask texture's other three channels. The crop and the geometry rebuild the grid; a White/Black Point change only re-normalizes it. Print curve only: off on the transfer path, in the Flat master and in the carrier strip. * **Output**: print density back to **scene-linear** reflectance (transmittance): $$I_{out} = 10^{-D}$$ - * **Paper Black** (`paper_black`, off): off applies black point compensation, as in ICC relative-colorimetric soft-proofing, so the display shows paper black as black. On keeps the paper's lifted D-max ($10^{-2.3} \approx 0.005$). With compensation each channel is $I_{out} = (I - t_b) / (1 - t_b)$, clamped at $0$, $t_b = 10^{-D_b}$, where $D_b$ is the physical $D_{max}$, or $D_{max} + \text{toe}_{ch} \cdot 0.90$ when that layer's toe is negative. A **negative toe raises the clip point**, which makes exact $0$ reachable. The reference is the *physical* $D_{max}$, not $D_{max,eff}$, so a lifted toe and per-layer shadow casts survive. A negative per-layer toe trim (with compensation on) tints the deepest black. - * **Scene-linear internally**: the exposure stage emits linear light and every creative stage (Local Contrast, Retouch, Lab, Toning, Finish) works on it. The working-space OETF (the **Adobe RGB (1998) TRC**, a pure $563/256 \approx 2.199$ power, no linear segment) is the final engine step, matching the Adobe RGB ICC profile at the display/export boundary. Retouch is perceptual: the CPU brackets it through the OETF (encode → heal → decode); the GPU keeps one encoded region (exposure → clahe/retouch encoded → lab decodes back to linear). + * **Paper Black** (`paper_black`, off): off applies black point compensation, as in ICC relative-colorimetric soft-proofing. On keeps the paper's lifted D-max ($10^{-2.3} \approx 0.005$). With compensation each channel is $I_{out} = (I - t_b) / (1 - t_b)$, clamped at $0$, $t_b = 10^{-D_b}$, where $D_b$ is the physical $D_{max}$, or $D_{max} + \text{toe}_{ch} \cdot 0.90$ when that layer's toe is negative. A **negative toe raises the clip point**, which makes exact $0$ reachable. The reference is the *physical* $D_{max}$, not $D_{max,eff}$, so a lifted toe and per-layer shadow casts survive. + * **Scene-linear internally**: the exposure stage emits linear light, and every creative stage (Local Contrast, Retouch, Lab, Toning, Finish) works on it. The working-space OETF (the **Adobe RGB (1998) TRC**, a pure $563/256 \approx 2.199$ power, no linear segment) is the final engine step. Retouch is perceptual: the CPU brackets it through the OETF (encode → heal → decode); the GPU keeps one encoded region (exposure → clahe/retouch encoded → lab decodes back to linear). * **The soft proof is a color table, not a stage.** `soft_proof_preview` runs on a $65^3$ identity grid inside `soft_proof_lut`, uploaded as the 3D texture the canvas shader samples; the CPU display path uses `apply_lut_f32`. Rendering intent, black point compensation, paper white, ink black and the gamut warning cost one cached rebuild on change, with no shader and no parity surface. `ProofCondition` holds them as one hashable value that keys the table and the render memo. Paper white is the absolute intent; ink black drops black point compensation; the gamut warning sets unprintable grid nodes to flat gray. - * **Printability**: both engines bin encoded content into a joint $32^3$ RGB histogram (`color_hist.wgsl` and `analysis.color_histogram`, mirrored constant `COLOR_HIST_BINS`), since gamut is a property of the triple. The gamut mask is built on the CPU from the proofed output profile (`ImageProcessor.gamut_lut`, cached per profile pair) by round-tripping the identity grid source → output → source, and the Analysis panel combines the two. The tolerance sits above the transform's 8-bit noise, so the read-out under-reports at the boundary. - * **Waveform**: both engines bin encoded luma into 128 tone bins for each of 256 image columns (`waveform.wgsl` and `analysis.waveform`, mirrored constants `WAVEFORM_BINS` and `WAVEFORM_COLS`), over the same content the RGBL histogram reads. The same pass sums each cell's encoded R, G and B in fixed point (`analysis.waveform_color`, mirrored `WAVEFORM_COLOR_SCALE`), so the chart draws each cell in its mean color. - * **Vectorscope**: both engines bin the clamped linear content's CIELAB a\*b\* into 180 hue × 64 chroma bins, chroma on a log axis with a knee at 4 so the linear and the log display read one histogram (`vectorscope.wgsl` and `analysis.vectorscope_histogram`, mirrored `SCOPE_*` constants). The last chroma bin holds everything past chroma 60. - * **Input ICC overrides primaries only, never the tone curve.** The rendered buffer's TRC is always the working-space OETF. For a **matrix/TRC** profile (`infrastructure/display/icc_profile.py`) the boundary transform takes only its primaries (Bradford-adapted to D65, through its `chad` tag when present) as a plain matrix; the declared TRC is inert (`sRGB-*-g10.icc` and `sRGB-*-srgbtrc.icc` render **identically**). A **LUT** (A2B0/B2A0) profile runs through the full CMS transform, so its input curves apply; the bundled narrowband `RGBScan.icc` puts its compensation there, authored against this boundary encoding. + * **Printability**: both engines bin encoded content into a joint $32^3$ RGB histogram (`color_hist.wgsl` and `analysis.color_histogram`, mirrored constant `COLOR_HIST_BINS`). The CPU builds the gamut mask from the proofed output profile (`ImageProcessor.gamut_lut`, cached per profile pair) by round-tripping the identity grid source → output → source. The tolerance sits above the transform's 8-bit noise, so the read-out under-reports at the boundary. + * **Waveform**: both engines bin encoded luma into 128 tone bins for each of 256 image columns (`waveform.wgsl` and `analysis.waveform`, mirrored constants `WAVEFORM_BINS` and `WAVEFORM_COLS`), over the content the RGBL histogram reads. The same pass sums each cell's encoded R, G and B in fixed point (`analysis.waveform_color`, mirrored `WAVEFORM_COLOR_SCALE`) for the cell's mean color. + * **Vectorscope**: both engines bin the clamped linear content's CIELAB a\*b\* into 180 hue × 64 chroma bins, chroma on a log axis with a knee at 4 (`vectorscope.wgsl` and `analysis.vectorscope_histogram`, mirrored `SCOPE_*` constants). The last chroma bin holds everything past chroma 60. + * **Input ICC overrides primaries only, never the tone curve.** The rendered buffer's TRC is always the working-space OETF. For a **matrix/TRC** profile (`infrastructure/display/icc_profile.py`) the boundary transform takes only its primaries (Bradford-adapted to D65, through its `chad` tag when present) as a plain matrix; the declared TRC is inert, so `sRGB-*-g10.icc` and `sRGB-*-srgbtrc.icc` render **identically**. A **LUT** (A2B0/B2A0) profile runs through the full CMS transform, so its input curves apply; the bundled narrowband `RGBScan.icc` puts its compensation there. ### Automatic helpers -The helpers correct each frame partially. With them off, a dense negative prints dense and a flat one prints flat. - -* **Auto Density** (`auto_exposure`, **on**): meters the textured content. The metered tone $m$ is the average of the trimmed window's mean and midpoint ($P_5$ to $P_{95}$ of the textured cells; Boyack & Juenger, US 5,724,456), so a skewed histogram is placed by its detail-bearing span. The anchor is a partial pull from the assumed key (`assumed_anchor` $= 0.46$): +* **Auto Density** (`auto_exposure`, **on**): the metered tone $m$ is the average of the trimmed window's mean and midpoint ($P_5$ to $P_{95}$ of the textured cells; Boyack & Juenger, US 5,724,456). The anchor is a partial pull from the assumed key (`assumed_anchor` $= 0.46$): $$\text{anchor} = \text{assumed} + s \cdot (m - \text{assumed}), \quad \text{clamped to } \pm b$$ - with $s =$ `anchor_meter_strength` ($0.2$) and $b =$ `anchor_meter_band` ($0.12$), so a low-key or high-key shot keeps its mood. The anchor prints at `anchor_target_density` ($0.75$), which sets overall print brightness. + with $s =$ `anchor_meter_strength` ($0.2$) and $b =$ `anchor_meter_band` ($0.12$). The anchor prints at `anchor_target_density` ($0.75$). * **Auto Grade** (`auto_normalize_contrast`, **on**): picks the grade from the textural density range $t$ ($P_{10}$ to $P_{90}$ of the textured cells, log D), a partial ISO R match (Alkofer, US 4,731,671). With $r$ the floor-to-ceil range, the effective contrast range is: $$K \cdot r \cdot \min\!\Big((1 - s) + s \cdot \frac{n}{t},\; c \cdot \frac{n}{t}\Big)$$ - with $K =$ `auto_grade_target` ($0.85$), $n =$ `auto_grade_nominal_range` ($0.9$, a normal negative's textural range), $s =$ `auto_grade_strength` ($0.4$) and $c =$ `auto_grade_max_overfill` ($1.2$). At $s = 0$ every frame prints on one paper; at $s = 1$ every textural range prints to the same span. The cap $c$ keeps a very wide negative from clipping at both ends. + with $K =$ `auto_grade_target` ($0.85$), $n =$ `auto_grade_nominal_range` ($0.9$), $s =$ `auto_grade_strength` ($0.4$) and $c =$ `auto_grade_max_overfill` ($1.2$). - **Shadow reach** (Gindele, US 7,113,649): the darkest textured tone ($P_{99}$ of the textured cells, `shadow_reach_percentile`) must print at least `shadow_reach_density` ($1.9$, straight-line) with the anchor at its target. If the grade falls short, the slope is raised to the line through both points, never lowered. It reads the same normalized luma as Auto Density. + **Shadow reach** (Gindele, US 7,113,649): the darkest textured tone ($P_{99}$, `shadow_reach_percentile`) must print at least `shadow_reach_density` ($1.9$, straight-line) with the anchor at its target. If the grade falls short, the slope is raised to the line through both points, never lowered. - **Highlight hold**, the soft-exposure half of a split-grade print (Agfa, US 4,104,069 / US 3,839,036): the brightest textured tone ($P_2$, `highlight_hold_percentile`) must print at least `highlight_hold_density` ($0.10$; $0$ turns it off). If it would print brighter, an automatic highlight burn is added to the Zone Density highlight term, solved against that term's weight at the tone so it lands exactly and stays under the shoulder, capped at `highlight_hold_max` ($0.5$). It never lifts. Highlights Grade is not the actuator, because it pivots at the highlight zone center, where these tones sit. + **Highlight hold** (Agfa, US 4,104,069 / US 3,839,036): the brightest textured tone ($P_2$, `highlight_hold_percentile`) must print at least `highlight_hold_density` ($0.10$; $0$ turns it off). If it would print brighter, an automatic highlight burn joins the Zone Density highlight term, solved against that term's weight at the tone, capped at `highlight_hold_max` ($0.5$). It never lifts. Highlights Grade is not the actuator, because it pivots at the highlight zone center, where these tones sit. - **Shadow hold**: Shadow Reach's tail ($P_{99}$) usually prints past paper black, so this is a partial lift, not a landing. With $v$ the tail's straight-line value and $v_r$ the value that prints `shadow_reach_density`, the Zone Density shadow term gets $-\min\big(h \cdot (v - v_r - o),\; m\big)$ when $v > v_r + o$, with $h =$ `shadow_hold_strength` ($0.40$; $0$ turns it off), $o =$ `shadow_hold_overshoot` ($0.15$) and $m =$ `shadow_hold_max` ($0.3$). It opens the shadows of a contrasty frame and leaves Grade alone. It never burns. -* **On the sliders** (`features/exposure/auto_sliders.py`, transfer twin in `features/transparency/logic.py`): the stored values stay offsets on the meter. The Tone panel shows their manual equivalent and converts an edit back: Density shifts by $(\text{anchor} - \text{assumed})/$`density_multiplier`, Grade is the ISO R that prints the auto slope on the frame's own range (`slope_to_grade`) and Shadows/Highlights Density add the holds. A toggle flips only the flag, so the slider gains or drops the meter's share. The meters come from the frame's last plain render (`desktop/auto_sliders.py`). -* **Set Targets** (app-global): a dialog beside the two toggles tunes the eight aim values (`anchor_target_density`, `anchor_meter_strength`, `anchor_meter_band`, `auto_grade_target`, `auto_grade_strength`, `shadow_reach_density`, `shadow_hold_strength`, `highlight_hold_density`; `TUNABLE_TARGETS` in `features/exposure/models.py` declares ranges). They are a **calibration, not per-image state**: `apply_targets()` overlays them onto `EXPOSURE_CONSTANTS`, persisted in the `exposure_targets` global setting. No `WorkspaceConfig` hash sees them, so both engines fold a `TARGETS_REVISION` counter into their cache keys: the CPU base stage (the metering), and on the GPU the analysis cache key plus the exposure-stage diff. A `TUNABLE_TARGETS` entry read *outside* those stages needs its own invalidation. -* **Cast Removal** (`cast_removal_strength`, default $1.0$ on a negative, $0$ on a slide; $0$ is off): balances each layer against the frame's own grays so neutrals stay neutral from shadows to highlights, not only at the midtone. Applied strength is `confidence × slider` (`effective_cast_strength`), so a frame with few clean near-neutrals is corrected gently. + **Shadow hold**: a partial lift of Shadow Reach's tail ($P_{99}$). With $v$ the tail's straight-line value and $v_r$ the value that prints `shadow_reach_density`, the Zone Density shadow term gets $-\min\big(h \cdot (v - v_r - o),\; m\big)$ when $v > v_r + o$, with $h =$ `shadow_hold_strength` ($0.40$; $0$ turns it off), $o =$ `shadow_hold_overshoot` ($0.15$) and $m =$ `shadow_hold_max` ($0.3$). It never burns. +* **On the sliders** (`features/exposure/auto_sliders.py`, transfer twin in `features/transparency/logic.py`): the stored values are offsets on the meter. The Tone panel shows their manual equivalent and converts an edit back: Density shifts by $(\text{anchor} - \text{assumed})/$`density_multiplier`, Grade is the ISO R that prints the auto slope on the frame's own range (`slope_to_grade`), and Shadows/Highlights Density add the holds. The meters come from the frame's last plain render (`desktop/auto_sliders.py`). +* **Set Targets** (app-global): tunes eight aim values (`anchor_target_density`, `anchor_meter_strength`, `anchor_meter_band`, `auto_grade_target`, `auto_grade_strength`, `shadow_reach_density`, `shadow_hold_strength`, `highlight_hold_density`; `TUNABLE_TARGETS` in `features/exposure/models.py` declares ranges). `apply_targets()` overlays them onto `EXPOSURE_CONSTANTS`, persisted in the `exposure_targets` global setting. No `WorkspaceConfig` hash sees them, so both engines fold a `TARGETS_REVISION` counter into their cache keys: the CPU base stage, and on the GPU the analysis cache key plus the exposure-stage diff. A `TUNABLE_TARGETS` entry read *outside* those stages needs its own invalidation. +* **Cast Removal** (`cast_removal_strength`, default $1.0$ on a negative, $0$ on a slide; $0$ is off): balances each layer against the frame's own grays, from shadows to highlights. Applied strength is `confidence × slider` (`effective_cast_strength`). - It runs on both **color** processes and is hidden for **B&W Negative**. On a color negative it removes the **orange mask** (start $1.0$); on a transparency a cast can be the photograph (start $0$), and it corrects uneven E-6 dye fade. `cast_removal_for_mode` (`features/process/models.py`) is the one place that swap lives: every route into a mode passes through it, and it rewrites only the other mode's default, so a user-chosen strength survives a mode switch. It is a roll default on the Calibration card (`cast_removal` in `ROLL_DEFAULT_FIELDS`); a Film Mode pushed to the roll carries the roll's strength through the same rule. `services/assets/migrations/cast_removal.py` sweeps legacy slides once at startup (a row rewrite guarded by a done flag, because a load cannot tell a legacy row from a fresh save). + It runs on both **color** processes. `cast_removal_for_mode` (`features/process/models.py`) is the one place the per-mode default swap lives: every route into a mode passes through it, and it rewrites only the other mode's default, so a user-chosen strength survives a mode switch. It is a roll default (`cast_removal` in `ROLL_DEFAULT_FIELDS`). `services/assets/migrations/cast_removal.py` sweeps legacy slides once at startup (a row rewrite guarded by a done flag, because a load cannot tell a legacy row from a fresh save). - The **neutral axis** meter reads the space the curve consumes: the unmixed film log on the print curve; the working-space log after the camera matrix, against the fixed transfer window, on the transparency curve (`processor.py`, `needs_axis` in `gpu_engine.py`). With **Use average: Cast** on, the roll or scene axis replaces this meter (see *Roll baseline and locks*, §2). + The **neutral axis** meter reads the space the curve consumes: the unmixed film log on the print curve, the working-space log after the camera matrix on the transparency curve, against the fixed transfer window (`processor.py`, `needs_axis` in `gpu_engine.py`). With `use_cast_average` on, the roll or scene axis replaces this meter (§2). - The primary solve fits each non-green channel to green's **neutral axis**, from references at a highlight, midtone and shadow luma band, each over the band's lowest-chroma pixels (`neutral_axis_*` constants). A reference is green's median plus the set's median R−G and B−G offsets, so it is the offset of a real pixel when the set holds more than one color. The confidence falls as the interdecile width of those offsets passes `neutral_axis_width_deadzone`: a band with few grays fills its set with colored content. R and B get a **quadratic** through the three points, with the midtone pinned through the pivot. Deviation from green is clamped ($\pm 0.2$, `midtone_cast_max_offset`) and curvature is bounded (`neutral_axis_curv_max_ratio` $= 0.45$ of the slope) to keep each channel monotonic on $[0,1]$. Outside that range the quadratic is held at its vertex (`quadratic_core`), so a value far past the frame's bounds, such as the Filed Carrier's rebate ramp, never folds back. + The primary solve fits each non-green channel to green's **neutral axis**, from references at a highlight, midtone and shadow luma band, each over the band's lowest-chroma pixels (`neutral_axis_*` constants). A reference is green's median plus the set's median R−G and B−G offsets. The confidence falls as the interdecile width of those offsets passes `neutral_axis_width_deadzone`. R and B get a **quadratic** through the three points, with the midtone pinned through the pivot. Deviation from green is clamped ($\pm 0.2$, `midtone_cast_max_offset`), and curvature is bounded (`neutral_axis_curv_max_ratio` $= 0.45$ of the slope) to keep each channel monotonic on $[0,1]$. Outside that range the quadratic is held at its vertex (`quadratic_core`), so a value far past the frame's bounds, such as the Filed Carrier's rebate ramp, never folds back. - With too few trustworthy near-neutrals, Color Negative falls back to a **two-point tie** on the per-channel shadow refs ($P_{98}$, calibrated for a negative), with the luma anchor pinning the midtone: + With too few trustworthy near-neutrals, Color Negative falls back to a **two-point tie** on the per-channel shadow refs ($P_{98}$), with the luma anchor pinning the midtone: $$k_{ch} = k \cdot \frac{\text{anchor} - r_{green}}{\text{anchor} - (r_{green} - \text{cast}_{ch})}$$ The cast is bounded ($\pm 0.1$, `cast_removal_max_offset`). A slide with no neutral axis gets no correction. - The **transparency curve** has no per-channel slope, so it uses a per-channel **affine on density** (`neutral_axis_affine`): a gain and offset that land each channel's midtone and shadow refs on green's, applied before every other control on that curve. Green, a missing axis, zero strength and a degenerate fit are the identity, so the default slide render is a pass-through. The same $\pm 0.2$ clamp applies, the gain is bounded (`cast_affine_gain_limit` $= 2.0$ and its reciprocal), and there is no curvature term. + The **transparency curve** has no per-channel slope, so it uses a per-channel **affine on density** (`neutral_axis_affine`): a gain and offset that land each channel's midtone and shadow refs on green's, applied before every other control on that curve. Green, a missing axis, zero strength and a degenerate fit are the identity. The same $\pm 0.2$ clamp applies, the gain is bounded (`cast_affine_gain_limit` $= 2.0$ and its reciprocal), and there is no curvature term. ### Paper profiles **Code**: `negpy.features.exposure.papers` -A **paper profile** (`paper_profile`, default *Neutral*) sets the H&D curve shape without touching contrast or exposure: $D_{max}/D_{min}$, toe and shoulder sharpness and height, and midtone gamma. Color papers add a per-channel slope crossover (`channel_gamma`), a paper-base tint (`base_tint_cmy`, added to the minimum-density floor, visible in highlights) and a **dye-coupling matrix** (`dye_matrix`, $D_{rgb} = M \cdot D_{dye}$ above base, row-normalized at use). Grade owns contrast, the Density, toe and shoulder sliders trim on top, and *Neutral* reproduces the defaults exactly. +A **paper profile** (`paper_profile`, default *Neutral*) sets the H&D curve shape without touching contrast or exposure: $D_{max}/D_{min}$, toe and shoulder sharpness and height, and midtone gamma. Color papers add a per-channel slope crossover (`channel_gamma`), a paper-base tint (`base_tint_cmy`, added to the minimum-density floor) and a **dye-coupling matrix** (`dye_matrix`, $D_{rgb} = M \cdot D_{dye}$ above base, row-normalized at use). *Neutral* reproduces the defaults exactly. -Profiles are **mode-aware**. Color Negative offers the RA4 papers. B&W Negative offers the B&W papers, which carry no RA4 color terms (paper tone is a Toning job) but carry `lith_path`, which the Lith stage reads. Transparency gets only *Neutral*. An incompatible stored value collapses to *Neutral*. Bundled: **Neutral**; *B&W*: Ilford Multigrade RC, Ilford Multigrade FB Classic, Foma Fomatone, Foma Fomabrom; *RA4*: Kodak Endura Premier, Fujicolor Crystal Archive. Values are loosely mapped from datasheets; mainly $D_{max}$ is grounded. +Profiles are **mode-aware**. Color Negative offers the RA4 papers. B&W Negative offers the B&W papers, which carry no RA4 color terms but carry `lith_path`, which the Lith stage reads. Transparency gets only *Neutral*. An incompatible stored value collapses to *Neutral*. Bundled: **Neutral**; *B&W*: Ilford Multigrade RC, Ilford Multigrade FB Classic, Foma Fomatone, Foma Fomabrom; *RA4*: Kodak Endura Premier, Fujicolor Crystal Archive. Values are loosely mapped from datasheets; mainly $D_{max}$ is grounded. ### Dye Separation **Code**: `negpy.features.exposure.papers.resolve_saturation_matrix` / `compose_density_matrices` -Saturation in density, in the same matrix slot as a paper's `dye_matrix` (compare §6's CIELAB Global Saturation). For density above paper base $e = D - D_{min}$, $M(k) = k \cdot I + (1-k) \cdot J$, where $J$ has every entry $1/3$: $k=1$ is identity, $k=0$ collapses the row to the achromatic mean, $k>1$ boosts separation. $M_{total} = M(k) \cdot M_{dye}$: saturation outermost, so the paper's crosstalk does not reabsorb it. $k$ (`dye_separation`, default 1.0, slider 0.5 to 1.5) takes per-channel trims (`dye_separation_trim_red/green/blue`) like Grade, Toe and Shoulder. Each row sums to 1 whatever its $k$, so neutrals stay flat. $k = 1$ on every channel returns `None` from `resolve_saturation_matrix`, so the default path stays byte-exact. - -$M(k)$ is the isotropic case of the standard row-normalized 3×3 masking operator. Real negatives land around $k = 0.8$ to $1.4$. +Saturation in density, in the same matrix slot as a paper's `dye_matrix`. For density above paper base $e = D - D_{min}$, $M(k) = k \cdot I + (1-k) \cdot J$, where $J$ has every entry $1/3$: $k=1$ is identity, $k=0$ collapses the row to the achromatic mean, $k>1$ boosts separation. $M_{total} = M(k) \cdot M_{dye}$: saturation is outermost, so the paper's crosstalk does not reabsorb it. $k$ (`dye_separation`, default 1.0, slider 0.5 to 1.5) takes per-channel trims (`dye_separation_trim_red/green/blue`). Each row sums to 1, so neutrals stay flat. $k = 1$ on every channel returns `None` from `resolve_saturation_matrix`, so the default path stays byte-exact. Real negatives land around $k = 0.8$ to $1.4$. ### Separation Damping **Code**: `negpy.features.exposure.logic.separation_damping_gain` @@ -188,49 +192,45 @@ $M(k)$ is the isotropic case of the standard row-normalized 3×3 masking operato $$h(c) = \frac{c_0 - c}{c_0 + c}, \qquad k_{eff,ch} = k_{ch}^{\,(1-\tau)\,+\,\tau\,h(c)}, \qquad e'_{ch} = \bar e + k_{eff,ch}\,(e_{ch} - \bar e)$$ -At $\tau = 0$ this is the matrix above, byte-exact. At $\tau = 1$ the gain is $k$ on a near-neutral, 1 at the reference spread $c_0$ (`separation_damping_ref_spread` $= 0.35$, also a WGSL literal) and $1/k$ far above it: muted and vivid colors move in **opposite** directions. Per-pixel $k$ cannot fold into a matrix, so with damping live the `dye_mix` slot carries only the paper's coupling and the separation runs after it in the kernel. +At $\tau = 0$ this is the matrix above, byte-exact. At $\tau = 1$ the gain is $k$ on a near-neutral, 1 at the reference spread $c_0$ (`separation_damping_ref_spread` $= 0.35$, also a WGSL literal) and $1/k$ far above it. Per-pixel $k$ cannot fold into a matrix, so with damping live the `dye_mix` slot carries only the paper's coupling, and the separation runs after it in the kernel. -It is inert at $k = 1$, so the UI disables it there. It stays monotone in $c$ for $k < e^2$, which covers the clamped $[0, 3]$ domain of $k$ and $k_{eff}$. - -$c_0$ and the exponent are a designed control, not a measurement. Lateral dye and inhibitor spread (about 20 to 200 µm) is not modeled; it belongs with sharpening. +It is inert at $k = 1$. It stays monotone in $c$ for $k < e^2$, which covers the clamped $[0, 3]$ domain of $k$ and $k_{eff}$. Lateral dye and inhibitor spread is not modeled. ### Hue Trim (light-source hue rotation) **Code**: `negpy.features.process.hue.apply_hue_trim`, mirrored in `exposure.wgsl` -An unusual scanning light rotates hues ($|\Delta H|$ is flat across CIELAB chroma), so the correction is a rotation: +A rotation of CIELAB hue: $$\begin{pmatrix} a' \\ b' \end{pmatrix} = \begin{pmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{pmatrix} \begin{pmatrix} a \\ b \end{pmatrix}$$ -with $L^{\ast}$ untouched. $\theta$ is `process.hue_trim` in degrees (0 = off, ±30 range). A rotation fixes the origin, so neutrals do not move and it is orthogonal to §2's per-channel color clip, which places the gray axis. +with $L^{\ast}$ untouched. $\theta$ is `process.hue_trim` in degrees (0 = off, ±30 range). Neutrals do not move. -It runs on the **scene-linear print**, after the H&D curve and before the working OETF, inside the exposure stage (a drag does not re-run normalization), and it stays active under `RenderIntent.FLAT`, since a light's hue error is a capture defect. The GPU mirror inlines `rgb_to_lab` and `lab_to_rgb`, copied verbatim from `lab.wgsl` (WGSL has no includes), and rotates `transmittance` before `oetf_encode`. $\theta$ rides the `hue` vec4's x lane in `ExposureUniforms`; its yzw lanes carry the preflash fraction, threshold value and paper gamma. +It runs on the **scene-linear print**, after the H&D curve and before the working OETF, inside the exposure stage (a drag does not re-run normalization). It stays active under `RenderIntent.FLAT`. The GPU mirror inlines `rgb_to_lab` and `lab_to_rgb`, copied verbatim from `lab.wgsl` (WGSL has no includes), and rotates `transmittance` before `oetf_encode`. $\theta$ rides the `hue` vec4's x lane in `ExposureUniforms`; its yzw lanes carry the preflash fraction, threshold value and paper gamma. ### Flat (log) master, "for editing elsewhere" **Code**: `negpy.features.exposure.processor.PhotometricProcessor._process_flat` → `apply_flat_curve` -With render intent **Flat** (`RenderIntent.FLAT`), a **true log encoding** replaces the Print stage: flat, low-contrast, like S-Log or LogC before a LUT. The H&D curve does not run. - -The normalized signal $\text{val} \in [0,1]$ from §2 is already log. The flat master skips the print path's $10^{-D}$ decode and emits it positive-oriented: +With render intent **Flat** (`RenderIntent.FLAT`), a **true log encoding** replaces the Print stage. The H&D curve does not run. The normalized signal $\text{val} \in [0,1]$ from §2 is already log; the flat master skips the $10^{-D}$ decode and emits it positive-oriented: $$I_{out} = \text{clip}\big(\text{lift} + \text{gain} \cdot (1 - \text{val}),\ 0,\ 1\big)$$ -* `flat_log_gain` $= 0.65$: contrast. $<1$ keeps it flat. -* `flat_log_lift` $= 0.10$: where the scene **shadow** lands (black lift). -* Result: shadow → $0.10$, mid-gray → $\approx 0.46$, highlight → $0.75$, with headroom at both ends. +* `flat_log_gain` $= 0.65$: contrast. +* `flat_log_lift` $= 0.10$: where the scene **shadow** lands. +* Result: shadow → $0.10$, mid-gray → $\approx 0.46$, highlight → $0.75$. -Both are fixed (no metering). Manual white balance still applies as a per-channel log shift. The engine bypasses the creative stages (Local Contrast, Lab, Toning, Finish, dodge/burn masks) and bakes no defect repair: only Geometry → Normalization → this log map → Crop run. Export is full-resolution, color-managed at encode into the selected color space, always 16-bit whatever the export Bit Depth says: TIFF at the chosen compression, or lossless JPEG XL if JXL is selected and the color space is JXL-taggable. The CPU engine is forced (there is no GPU flat shader). +Both are fixed. Manual white balance still applies as a per-channel log shift. Only Geometry → Normalization → this log map → Crop run: no creative stages, dodge/burn masks or defect repair. Export is full-resolution, color-managed at encode into the selected color space, and always 16-bit: TIFF at the chosen compression, or lossless JPEG XL if JXL is selected and the color space is JXL-taggable. The CPU engine is forced (there is no GPU flat shader). ### Transparency transfer, "the slide as captured" **Code**: `negpy.features.transparency` (+ `shaders/transfer.wgsl`), `negpy.features.process.capture_color` -For **Transparency** a **transfer curve** replaces the Print stage. It is exactly the identity at default settings, so the render is the capture. **Positive** (a Slide-only setting) takes the same curve. `process.path.render_path` makes the choice (`TRANSFER`, `POSITIVE`, or `PRINT` for every negative), and the engine routes the base and exposure stages to `features/transparency` on it; the negative's processors hold no slide branch. A Flat master of a slide takes the same base, then the flat log map. +For **Transparency** a **transfer curve** replaces the Print stage. It is exactly the identity at default settings. **Positive** (a Slide-only setting) takes the same curve. `process.path.render_path` makes the choice (`TRANSFER`, `POSITIVE`, or `PRINT` for every negative), and the engine routes the base and exposure stages to `features/transparency` on it; the negative's processors hold no slide branch. A Flat master of a slide takes the same base, then the flat log map. -**Normalization** (`TransparencyBaseProcessor`) does no metering to shape the window, but on a Positive frame it meters for Auto Density/Auto Grade, as the paper path does: +**Normalization** (`TransparencyBaseProcessor`) does no metering to shape the window; on a Positive frame it meters for Auto Density/Auto Grade. -* **Neutral decode.** This path always decodes without the as-shot white balance, whatever Linear RAW says (`effective_linear_raw`), because the camera matrix folds the multipliers back in. Decode, CPU and GPU matrix and every cache token ask that one helper, or the render is tinted by the raw green-to-red ratio. The same helper makes the loader read a TIFF as literal linear data whatever its profile. **Positive** is exempt (`TiffLoader.load`'s `positive_source` parameter): its embedded profile decides the decode (untagged falls back to sRGB). -* **Highlight reconstruction** (`highlight_reconstruction` on `ProcessConfig`, `effective_highlight_reconstruction`) sets libraw's `highlight_mode`: `0` Clip (default), `2` Blend, or Reconstruct `3`-`9`; the sidebar exposes Off/Blend/Reconstruct (level `5`). It applies to either Transparency render and is forced to `0` off Transparency, under Narrowband and on a merged bracket, whose clip-detection weighting (`features/hdr/logic.py`) needs pixels near the sensor ceiling. `WorkspaceConfig.__post_init__` holds that invariant once `hdr` names the bracket; `HdrWorker.run`'s solve zeroes it per frame, since it runs before the merge exists. On this path, active reconstruction bakes the real white balance into the decode and skips the matrix fold (`highlight_reconstruction_bakes_wb`, `should_fold_camera_wb`), because libraw finds clipping from its own per-channel gains. A non-Clip mode normalizes libraw's gains against the widest multiplier, which darkens the decode; `highlight_reconstruction_bright_gain` passes that ratio back as libraw's `bright` scale wherever a real white balance (baked or native) meets a non-Clip mode. -* **Camera matrix.** RAW is decoded `output_color=raw` (camera primaries). The decoder's XYZ→camera matrix (libraw `rgb_xyz_matrix`, on `PipelineContext.cam_xyz`) becomes the *working→camera* matrix, is **row-normalized there**, then inverted (dcraw's `cam_xyz_coeff` order). Normalizing after inversion gives a magenta cast that grows with saturation. It is validated against libraw's own cam→sRGB and applied in **linear**, before the log. A source without a matrix (scanner TIFF, JPEG) passes through. The WB fold asks `should_fold_camera_wb`, which refuses a narrowband capture (read through `narrowband_profile_active`, so a flag left on from a negative is inert on a slide). An explicit Input ICC swaps `cam_xyz` for `wb_only_cam_xyz` (`AppController._effective_cam_xyz`), so only the white-balance fold remains: `_XYZ_TO_WORKING` through the same forward/normalize/invert steps gives exactly identity, which the soft-proof and export ICC bypass also assume. -* **Fixed bounds.** The stretch is a constant window: floor at the decoder's white level ($D = 0$), ceiling `TRANSFER_DENSITY_RANGE` $= 3.0$ decades below, the same for every frame, so a bracketed set does not converge. White and Black Point (`per_channel_point_offsets`, shared with the measured path) shift the window additively and are the identity at zero. The dye-crosstalk unmix is skipped (it models negative film). +* **Neutral decode.** This path always decodes without the as-shot white balance, whatever Linear RAW says (`effective_linear_raw`), because the camera matrix folds the multipliers back in. Decode, CPU and GPU matrix and every cache token ask that one helper, or the raw green-to-red ratio tints the render. The same helper makes the loader read a TIFF as literal linear data whatever its profile. **Positive** is exempt (`TiffLoader.load`'s `positive_source` parameter): its embedded profile decides the decode (untagged falls back to sRGB). +* **Highlight reconstruction** (`highlight_reconstruction` on `ProcessConfig`, `effective_highlight_reconstruction`) sets libraw's `highlight_mode`: `0` Clip (default), `2` Blend, or Reconstruct `3`-`9` (the sidebar's Reconstruct is level `5`). It applies to either Transparency render and is forced to `0` off Transparency, under Narrowband and on a merged bracket, whose clip-detection weighting (`features/hdr/logic.py`) needs pixels near the sensor ceiling. `WorkspaceConfig.__post_init__` holds that invariant once `hdr` names the bracket; `HdrWorker.run`'s solve zeroes it per frame, since it runs before the merge exists. On this path, active reconstruction bakes the real white balance into the decode and skips the matrix fold (`highlight_reconstruction_bakes_wb`, `should_fold_camera_wb`), because libraw finds clipping from its own per-channel gains. A non-Clip mode normalizes libraw's gains against the widest multiplier, which darkens the decode; `highlight_reconstruction_bright_gain` passes that ratio back as libraw's `bright` scale wherever a real white balance (baked or native) meets a non-Clip mode. +* **Camera matrix.** RAW is decoded `output_color=raw`. The decoder's XYZ→camera matrix (libraw `rgb_xyz_matrix`, on `PipelineContext.cam_xyz`) becomes the *working→camera* matrix, is **row-normalized there**, then inverted (dcraw's `cam_xyz_coeff` order); normalizing after inversion gives a magenta cast that grows with saturation. It is applied in **linear**, before the log. A source without a matrix (scanner TIFF, JPEG) passes through. The WB fold asks `should_fold_camera_wb`, which refuses a narrowband capture (read through `narrowband_profile_active`, so a flag left on from a negative is inert on a slide). An explicit Input ICC swaps `cam_xyz` for `wb_only_cam_xyz` (`AppController._effective_cam_xyz`), so only the white-balance fold remains: `_XYZ_TO_WORKING` through the same forward/normalize/invert steps gives exactly identity, which the soft-proof and export ICC bypass also assume. +* **Fixed bounds.** The stretch is a constant window: floor at the decoder's white level ($D = 0$), ceiling `TRANSFER_DENSITY_RANGE` $= 3.0$ decades below, the same for every frame. White and Black Point (`per_channel_point_offsets`, shared with the measured path) shift the window additively. The dye-crosstalk unmix is skipped. **Print** (`TransferProcessor` → `apply_transfer_curve`) inverts the window and deviates only by what the user moved. With $n$ the normalized signal and $R$ the density range: @@ -238,64 +238,71 @@ $$D = R \cdot n - \Delta_{exp} + R \cdot \text{cmy} \qquad D \leftarrow p + (D - $$D \mathrel{+}= \text{cmy}_{sh} \cdot w_{sh} + \text{cmy}_{hl} \cdot (1 - w_{sh}) \qquad w_{sh} = \sigma\big(k_{wb}(D - z_{wb})\big)$$ $$D \leftarrow D - t \cdot \text{softplus}(D - k_t,\ w_t) + s \cdot \text{softplus}(k_s - D,\ w_s) \qquad I_{out} = \text{ODT}\big(2^{b} \cdot 10^{-D}\big)$$ -At neutral settings $\Delta_{exp} = 0$, $c = 1$, $t = s = 0$, so $D = R \cdot n$ and $10^{-Rn}$ inverts the normalization. Every term vanishes at its neutral value, so this holds exactly in float32. The toe compresses *above* its knee and the shoulder *below* its own; `softplus` is monotonic for $t, s \in [-1, 1]$. +At neutral settings $\Delta_{exp} = 0$, $c = 1$, $t = s = 0$, so $D = R \cdot n$ and $10^{-Rn}$ inverts the normalization exactly in float32. The toe compresses *above* its knee and the shoulder *below* its own; `softplus` is monotonic for $t, s \in [-1, 1]$. -**Display rendering.** $\text{ODT}$ is applied last: a fixed baseline exposure $b$ = `transfer_baseline_ev` = 0.7 EV (darktable's raw default), then Narkowicz's closed-form fit to the ACES RRT plus sRGB ODT. A raw capture needs it, because the decode anchors to the **sensor white level** (`no_auto_bright`, `adjust_maximum_thr=0`) and a frame exposed below clipping arrives dark. Both terms are fixed, never metered. +**Display rendering.** $\text{ODT}$ is applied last: a fixed baseline exposure $b$ = `transfer_baseline_ev` = 0.7 EV (darktable's raw default), then Narkowicz's closed-form fit to the ACES RRT plus sRGB ODT. The decode anchors to the **sensor white level** (`no_auto_bright`, `adjust_maximum_thr=0`), so a frame exposed below clipping arrives dark without it. Both terms are fixed. **Positive** (`ProcessConfig.positive_source`) skips both. `apply_transfer_curve` returns the scene stage's output clipped to display range, and the Print sliders still act. The GPU packs `baseline_gain` as `1.0` and carries the skip in the `zone_taper.y` lane. -The controls are the Print sliders, each neutral at its default: **Print Density** → $\Delta_{exp}$ (stops, `transfer_density_stops` $= 2.0$ per unit, higher is darker); **ISO-R Grade** → $c$ (`transfer_grade_ref` $/$ grade, so the shipped $100$ is unity); **Toe** / **Shoulder** → $t, s$ with knees $k_t = 1.6$ and $k_s = 0.35$ density; their **Width** sliders → $w_t, w_s$ scaled about `transfer_width_ref` $= 2.5$; white balance → per-channel density offsets through `filtration_offsets`; its **Shadows / Highlights** split → the Regional CMY term (`wb_split_geometry`); **Shadows / Highlights Density** → the Zone Density term. The last two apply after contrast and before the knees. R/G/B trims go through `per_channel_toe_shoulder` and `per_channel_widths`, as on the print curve. +The controls are the Print sliders, each neutral at its default: +* **Print Density** → $\Delta_{exp}$ (stops, `transfer_density_stops` $= 2.0$ per unit, higher is darker). +* **ISO-R Grade** → $c$ (`transfer_grade_ref` $/$ grade, so the shipped $100$ is unity). +* **Toe** / **Shoulder** → $t, s$, with knees $k_t = 1.6$ and $k_s = 0.35$ density. Their **Width** sliders → $w_t, w_s$, scaled about `transfer_width_ref` $= 2.5$. +* White balance → per-channel density offsets through `filtration_offsets`. Its **Shadows / Highlights** split → the Regional CMY term (`wb_split_geometry`). +* **Shadows / Highlights Density** → the Zone Density term. -**Auto Density/Auto Grade** (`transfer_auto_terms`) meter as on a negative, restated on this curve. A slide is a deliberate exposure, so `auto_meter_for_mode` starts them off on Slide, rewriting only the setting still at the mode being left (as `cast_removal_for_mode` does); a live merge holds both off (`WorkspaceConfig.__post_init__`). Auto Density places the anchor at `transfer_contrast_pivot`, the mid-gray density Grade rotates about (`transfer_assumed_anchor` is that reference's fraction of the fixed window). Auto Grade uses `effective_grade_range`/`grade_to_slope` unchanged. Shadow Reach then raises contrast (never lowers) so the textured dark tail reaches `shadow_reach_density`, Highlight Hold adds a burn and Shadow Hold a lift through the Zone Density kernel. `shadow_reach_density`/`highlight_hold_density` map onto this curve by tonal position. +The last two apply after contrast and before the knees. R/G/B trims go through `per_channel_toe_shoulder` and `per_channel_widths`. + +**Auto Density/Auto Grade** (`transfer_auto_terms`) meter as on a negative, restated on this curve. `auto_meter_for_mode` starts them off on Slide, rewriting only the setting still at the mode being left (as `cast_removal_for_mode` does); a live merge holds both off (`WorkspaceConfig.__post_init__`). Auto Density places the anchor at `transfer_contrast_pivot`, the mid-gray density Grade rotates about (`transfer_assumed_anchor` is that reference's fraction of the fixed window). Auto Grade uses `effective_grade_range`/`grade_to_slope` unchanged. Shadow Reach, Highlight Hold and Shadow Hold act as on a negative, the holds through the Zone Density kernel. `shadow_reach_density`/`highlight_hold_density` map onto this curve by tonal position. Zone Density's geometry comes from the print path's (`zone_geometry()`) by **tonal position**, because the scales differ (print $d_{min}$ 0.06 to $d_{max}$ 2.3, here 0 to `TRANSFER_DENSITY_RANGE`): the shadow center 1.50 re-centers to 1.93, with sharpness scaled likewise. `wb_split_geometry()` derives the Regional CMY center $z_{wb}$ and sharpness $k_{wb}$ the same way, from `anchor_target_density` and the print's fixed sharpness of 3.0. -The shadow lift is **tapered to nothing at the bottom of the window** (`_black_taper`, smoothstep over `ZONE_BLACK_TAPER` = 1.0 density), because there is no paper black to stop it lifting the black point. Split Grade is not mirrored: the transfer curve has no per-zone slope. +The shadow lift is **tapered to nothing at the bottom of the window** (`_black_taper`, smoothstep over `ZONE_BLACK_TAPER` = 1.0 density), because no paper black stops it lifting the black point. Split Grade is not mirrored: the transfer curve has no per-zone slope. > `transfer_grade_ref` and `transfer_width_ref` **mirror** `DEFAULT_WORKSPACE_CONFIG`. If they drift apart, the default render stops being the identity. `test_transparency_transfer.py` asserts they agree. -The paper model cannot be the identity (`d_max` floors the blacks, `anchor_target_density` places mid-gray, the midtone snap and paper-white reference stay live at neutral), so the paper-specific controls (paper profile, Paper White/Black, split grade) and the normalization tuning are hidden in this mode. Downstream stages run as usual. +The paper model cannot be the identity (`d_max` floors the blacks, `anchor_target_density` places mid-gray, the midtone snap and paper-white reference stay live at neutral), so its controls and the normalization tuning do not apply here. Downstream stages run as usual. -Dye Separation and Separation Damping stay, since neither needs a paper. Each channel's $k$ (`dye_separation` + `dye_separation_trim_red/green/blue`) applies to density directly, $D'_{ch} = \bar D + k_{eff,ch}(D_{ch} - \bar D)$, §3's $M(k)$ as one scalar per channel. $\bar D$ is the mean of the channels, each softly capped (softplus) at the higher of 2.5 D and the pixel's lightest channel plus 1.5 D. The cap is set on the capture density, where the window clamp always sits at 3 D, and each capped channel then takes its own curve, so Print Density, Grade, WB and the knees cannot move a clamped channel back under the cap. Nothing bounds a channel here the way the paper curve does, and a channel far denser than the rest near the dense end of the window (the red of an out-of-gamut blue sky) is mostly noise, which the plain mean would spread onto the other two. Grays and ordinary colors never reach the cap, so for them $\bar D$ is the plain mean. The cap only rises with its inputs, so $\bar D$ never falls as a channel darkens and a smooth gradient stays smooth. Separation Damping applies §3's $k_{eff}(c)$ law to each channel's $k$ from the shared chroma $c$, measured on the capped channels. +Dye Separation and Separation Damping stay. Each channel's $k$ (`dye_separation` + `dye_separation_trim_red/green/blue`) applies to density directly, $D'_{ch} = \bar D + k_{eff,ch}(D_{ch} - \bar D)$. $\bar D$ is the mean of the channels, each softly capped (softplus) at the higher of 2.5 D and the pixel's lightest channel plus 1.5 D, so a channel far denser than the rest near the dense end of the window (mostly noise) does not spread onto the other two. The cap is set on the capture density, where the window clamp always sits at 3 D, so Print Density, Grade, WB and the knees cannot move a clamped channel back under it. The cap only rises with its inputs, so $\bar D$ never falls as a channel darkens. Separation Damping applies §3's $k_{eff}(c)$ law to each channel's $k$ from the shared chroma $c$, measured on the capped channels. -**Capture toggles.** `linear_raw`, `narrowband_scan` and `sensor_matrix` are sticky global settings, so each is *inert* where it does not apply and its control is grayed with the reason, not hidden. +**Capture toggles.** `linear_raw`, `narrowband_scan` and `sensor_matrix` are sticky global settings, each *inert* where it does not apply. -Linear RAW decodes with `user_wb=[1,1,1,1]`; `camera_to_working_matrix` folds the as-shot multipliers back in, normalized to green so exposure does not move, which makes the render independent of the decode with no shader change (the folded matrix rides the existing `cam*` rows). Linear RAW is inert on this path only; with Positive Source on, it decides the decode again. +Linear RAW decodes with `user_wb=[1,1,1,1]`. `camera_to_working_matrix` folds the as-shot multipliers back in, normalized to green so exposure does not move, which makes the render independent of the decode with no shader change (the folded matrix rides the existing `cam*` rows). Linear RAW is inert on this path only; with Positive Source on, it decides the decode again. -`narrowband_profile_active` refuses Narrowband for E-6 on every path: `RGBScan.icc` characterizes *negative* dyes, and no input profile can recover a slide from three isolated wavelengths. `effective_input_icc` suppresses the implicit profile, `proof_active` follows, and an explicit user Input ICC still wins. `unmix_block_reason` refuses the sensor matrix on the same grounds. +`narrowband_profile_active` refuses Narrowband for E-6 on every path: `RGBScan.icc` characterizes *negative* dyes. `effective_input_icc` suppresses the implicit profile, `proof_active` follows, and an explicit user Input ICC still wins. `unmix_block_reason` refuses the sensor matrix on the same grounds. The GPU mirror is a separate shader, not a branch in `exposure.wgsl`. Like that shader it emits **display-encoded** values (`oetf_encode` at the end of the pass); the CPU stays linear to the end of the engine. ### Linear Output **Code**: `negpy.services.export.linear_output` -With render intent **Linear** the darkroom pipeline is bypassed. The source decodes to its native linear buffer, lossless geometry (EXIF orientation plus user rotation and flip) is applied, and a 16-bit file (whatever the export Bit Depth says) is written: TIFF (default, at the Export panel's TIFF compression) or standalone lossless JPEG XL, chosen in the Format combo. An Effort slider (1 to 9, default 7) trades JPEG XL encoder speed for compression. No normalization, exposure or color management runs, and a correction runs only when its toggle is on. The IR sidecar, when present and not consumed by ICE, uses the same Format; it is single-channel grayscale, and on the JXL gray path `transfer=LINEAR` is its only tag. +With render intent **Linear** the darkroom pipeline is bypassed. The source decodes to its native linear buffer, lossless geometry (EXIF orientation plus user rotation and flip) is applied, and the output is a 16-bit file: TIFF (default, at the Export panel's TIFF compression) or standalone lossless JPEG XL. An Effort slider (1 to 9, default 7) trades JPEG XL encoder speed for compression. No normalization, exposure or color management runs, and a correction runs only when its toggle is on. The IR sidecar, when present and not consumed by ICE, uses the same format; it is single-channel grayscale, and on the JXL gray path `transfer=LINEAR` is its only tag. -**Only the TIFF path is untagged.** A JPEG XL codestream always carries a `ColorEncoding`, even with `photometric`, `primaries` and `transfer` unset (true of `imagecodecs.jpegxl_encode()` and `cjxl`). `_write_jxl()` pins `transfer=LINEAR` but leaves `primaries` at the sRGB default, which is wrong for camera or scanner-native primaries, so a spec-compliant JXL viewer applies a wrong sRGB→display transform. `imagecodecs` cannot pass real chromaticities (`PRIMARIES.CUSTOM`) or an ICC profile (`jpegxl_encode()` has no `iccprofile=`, though libjxl's `JxlEncoderSetICCProfile` has one). NegPy re-imports it correctly, because `JxlLoader` reads no color metadata (`imagecodecs.jpegxl_decode()` surfaces none). +**Only the TIFF path is untagged.** A JPEG XL codestream always carries a `ColorEncoding`, even with `photometric`, `primaries` and `transfer` unset (true of `imagecodecs.jpegxl_encode()` and `cjxl`). `_write_jxl()` pins `transfer=LINEAR` but leaves `primaries` at the sRGB default, so a spec-compliant JXL viewer applies a wrong sRGB→display transform to camera or scanner-native primaries. `imagecodecs` cannot pass `PRIMARIES.CUSTOM` chromaticities or an ICC profile (`jpegxl_encode()` has no `iccprofile=`, though libjxl's `JxlEncoderSetICCProfile` has one). NegPy re-imports the file correctly, because `JxlLoader` reads no color metadata (`imagecodecs.jpegxl_decode()` surfaces none). -* **Pakon RAW**: uint16 scanner data is scaled by an expansion factor. F135 (14-bit sensor, confirmed) defaults to 4× (`PAKON_EXPANSION`); F335 (16-bit, detected by file size) defaults to 1×, off. The 2k Square and Panoram specs are assumed 14-bit like the F135 (not verified; override if needed). 4× puts a typical F135 negative peak around 50 to 55% of range (some external Pakon tools use 2×). The Expansion combo overrides it, and the factor is recorded in the TIFF's ImageDescription tag. -* **LinearRaw DNG**: 4-channel VueScan (RGB+IR) and 3-channel SilverFast HDRi files are read through tifffile, bypassing rawpy. IR, when present, is written as a separate grayscale file with an `_ir` suffix in the main output's Format. Expansion defaults to off; 2× and 4× are available. +* **Pakon RAW**: uint16 scanner data is scaled by an expansion factor. F135 (14-bit sensor) defaults to 4× (`PAKON_EXPANSION`); F335 (16-bit, detected by file size) defaults to 1×, off. The 2k Square and Panoram specs are assumed 14-bit (not verified). 4× puts a typical F135 negative peak around 50 to 55% of range. The Expansion combo overrides it, and the TIFF's ImageDescription tag records the factor. +* **LinearRaw DNG**: 4-channel VueScan (RGB+IR) and 3-channel SilverFast HDRi files are read through tifffile, bypassing rawpy. IR, when present, is written as a separate grayscale file with an `_ir` suffix. Expansion defaults to off; 2× and 4× are available. - A 3-channel LinearRaw DNG that libraw fails to unpack falls back to the same tifffile decode, replaying the `LinearizationTable`, `BlackLevel`/`WhiteLevel` and `DefaultCropOrigin`/`DefaultCropSize` tags. This covers DNG 1.7 JPEG-XL from DxO PhotoLab/PureRAW or Lightroom Enhance ([rawpy#207](https://github.com/letmaik/rawpy/issues/207)). No camera-to-XYZ matrix is computed; only the as-shot white balance (`AsShotNeutral`) applies, under the **Linear RAW** toggle. The fallback is failure-driven, so a future libraw or rawpy that unpacks these files is used automatically. Linear Output's 3-channel path uses the same decode (`_peek_linear_dng_rgb`), so tagged files export at the correct exposure and SilverFast HDRi files are unchanged. A default crop that trims the RGB plane while a same-file HDRi IR page stays full size raises an error. -* **Camera RAW**: demosaiced by rawpy (AHD on a CFA sensor, or the Demosaic panel's choice) with `user_wb=[1,1,1,1]` (unity), `output_color=raw` (sensor-native), `gamma=(1,1)` (linear), `no_auto_bright=True`. The resolved algorithm (the actual choice on an X-Trans CFA; see `resolve_demosaic` in `loaders/helpers.py`) is recorded in ImageDescription; a de-mosaiced source records none. Green-normalized as-shot WB multipliers go into XMP as `RAW-WB: R G B` inside `dc:description` (the `RAW-WB` convention of external linear-workflow tools), the source filename into `crs:RawFileName`. No expansion (sensors use the full bit depth). -* **Coolscan NEF** (`negpy.infrastructure.loaders.nef_loader`): TIFF-structured, with the full-res RGB image in a SubIFD chain (tag 0x014A). The loader picks the largest RGB SubIFD by pixel count and rejects files with CFA/Bayer SubIFDs (camera NEFs). Scanner NEFs are processed images whose content depends on the Nikon Scan settings (curves, gain, DigitalICE and so on), so linear output needs the right settings at scan time. The loader assumes nothing about linearity or color space. No IR channel; extra channels are dropped. No expansion. -* **Flextight FFF** (`negpy.infrastructure.loaders.fff_loader`): Imacon/Hasselblad files in two variants. Uncompressed 16-bit RGB (FlexColor export) is a big-endian TIFF with the image in a top-level IFD, selected by pixel count because SubfileType is unreliable. SGI LogLuv (raw `.3fr`/`.fff`, compression tags 34676/34677) decodes through `negpy.infrastructure.loaders.logluv`, LogLuv32/24 → CIE XYZ → linear sRGB, ported from [flexcolor-tool](https://github.com/rohanpandula/flexcolor-tool) (MIT). + A 3-channel LinearRaw DNG that libraw fails to unpack falls back to the same tifffile decode, replaying the `LinearizationTable`, `BlackLevel`/`WhiteLevel` and `DefaultCropOrigin`/`DefaultCropSize` tags. This covers DNG 1.7 JPEG-XL ([rawpy#207](https://github.com/letmaik/rawpy/issues/207)). No camera-to-XYZ matrix is computed; only the as-shot white balance (`AsShotNeutral`) applies, under the **Linear RAW** toggle. The fallback is failure-driven. Linear Output's 3-channel path uses the same decode (`_peek_linear_dng_rgb`). A default crop that trims the RGB plane while a same-file HDRi IR page stays full size raises an error. +* **Camera RAW**: demosaiced by rawpy (AHD on a CFA sensor, or the Demosaic panel's choice) with `user_wb=[1,1,1,1]`, `output_color=raw`, `gamma=(1,1)`, `no_auto_bright=True`. ImageDescription records the resolved algorithm (`resolve_demosaic` in `loaders/helpers.py`). Green-normalized as-shot WB multipliers go into XMP as `RAW-WB: R G B` inside `dc:description`, and the source filename into `crs:RawFileName`. No expansion. +* **Coolscan NEF** (`negpy.infrastructure.loaders.nef_loader`): TIFF-structured, with the full-res RGB image in a SubIFD chain (tag 0x014A). The loader picks the largest RGB SubIFD by pixel count and rejects files with CFA/Bayer SubIFDs (camera NEFs). The loader assumes nothing about linearity or color space. No IR; extra channels are dropped. No expansion. +* **Flextight FFF** (`negpy.infrastructure.loaders.fff_loader`): two variants. Uncompressed 16-bit RGB (FlexColor export) is a big-endian TIFF with the image in a top-level IFD, selected by pixel count because SubfileType is unreliable. SGI LogLuv (raw `.3fr`/`.fff`, compression tags 34676/34677) decodes through `negpy.infrastructure.loaders.logluv`, LogLuv32/24 → CIE XYZ → linear sRGB, ported from [flexcolor-tool](https://github.com/rohanpandula/flexcolor-tool) (MIT). - LogLuv values exceed 1.0, so the decoder applies per-channel percentile normalization (0.2th/99.8th) after XYZ → linear RGB, as flexcolor-tool does, giving gain-normalized linear transmittance with no gamma. FlexColor metadata comes from tags 50457 (Apple plist: film stock, film type, gamma, DPI, scan date) and 46279 (firmware version, scanner serial). No IR; extra channels dropped. No expansion. + LogLuv values exceed 1.0, so the decoder applies per-channel percentile normalization (0.2th/99.8th) after XYZ → linear RGB, giving gain-normalized linear transmittance. FlexColor metadata comes from tags 50457 (Apple plist: film stock, film type, gamma, DPI, scan date) and 46279 (firmware version, scanner serial). No IR; extra channels dropped. No expansion. * **Noritsu RAW** (`negpy.infrastructure.loaders.noritsu_loader`): headerless BGR 16-bit little-endian dumps. Dimensions come from file size against a table of known Noritsu scan sizes (exact match, known width with novel height, novel-width fallback). BGR is swapped to RGB. 12-bit data in 16-bit range; default expansion 16× (`NORITSU_EXPANSION`). -* **TIFF** (standalone `_decode_tiff`, independent of `TiffLoader` and its sRGB linearization): a 4th channel is IR only when ExtraSamples is 0 (UNSPECIFIED) or missing; 1 and 2 (associated and unassociated alpha) are dropped. Sidecar IR (`_ir.tif`, with optional `_ir_valid` mask) and IR in secondary TIFF pages (SilverFast iSRD) are detected. **Input gamma** declares the source encoding (linear, 1.8, 2.2 or sRGB) for linearization before export. Expansion is available, off by default. +* **TIFF** (standalone `_decode_tiff`, independent of `TiffLoader` and its sRGB linearization): a 4th channel is IR only when ExtraSamples is 0 (UNSPECIFIED) or missing; 1 and 2 (alpha) are dropped. Sidecar IR (`_ir.tif`, with optional `_ir_valid` mask) and IR in secondary TIFF pages (SilverFast iSRD) are detected. **Input gamma** declares the source encoding (linear, 1.8, 2.2 or sRGB) for linearization before export. Expansion is available, off by default. * **Trichrome triplets**: all three narrowband exposures decode and merge through `merge_rgb_triplet()` into one TIFF. The red exposure is primary (and supplies WB and device metadata); green and blue paths come from the frame's `RgbScanConfig`. No sensor correction. - **Merge to TIFF Negative** (`services/export/frame_merge.py`) replaces a triplet or a stitch with one untagged 16-bit linear negative written from the render decode (`ImageProcessor._load_source_f32`), so it loads back to the buffer the pipeline inverts. `decode_params` decides what is baked: a triplet decodes with flat-field off, so the gain map stays live in the edit; a stitch keeps it on, because its registration holds only on per-part corrected buffers. `merged_edit` clears what was baked (`rgbscan` and the sensor matrix, plus `stitch` and `flatfield` for a stitch), `carry_edit` locks those cards against the roll, and `forget_composite` dissolves a stitch's record. A single-shot stitch's preview unmixes the assembled canvas while its export unmixes per part; the merged file holds the per-part version. Discovery never groups a TIFF whose description names a merge (`is_merged_source`). Refused: a bracket (float32 detail below a 16-bit step, render exposure baked in), a transfer or positive frame (`needs_camera_matrix`: its color matrix comes from the RAW), and a source whose format label hides the assembly, such as a LinearRaw DNG (`describes_a_merge`). -* **Stitch composites**: each part is decoded and corrected (flatfield plus sensor correction for single-shot parts, flatfield only for triplet parts), then assembled through `stitch_composite()` with gain compensation, a minimum-error seam and a narrow cross-fade at the cut. Parts can be Trichrome triplets, so a 4-part stitch of triplets turns 12 RAW files into 1 TIFF. + **Merge to TIFF Negative** (`services/export/frame_merge.py`) replaces a triplet or a stitch with one untagged 16-bit linear negative, written from the render decode (`ImageProcessor._load_source_f32`), so it loads back to the buffer the pipeline inverts. `decode_params` decides what is baked: a triplet decodes with flat-field off, so the gain map stays live in the edit; a stitch keeps it on, because its registration holds only on per-part corrected buffers. `merged_edit` clears what was baked (`rgbscan` and the sensor matrix, plus `stitch` and `flatfield` for a stitch), `carry_edit` locks those cards against the roll, and `forget_composite` dissolves a stitch's record. A single-shot stitch's preview unmixes the assembled canvas while its export unmixes per part; the merged file holds the per-part version. Discovery never groups a TIFF whose description names a merge (`is_merged_source`). Refused: a bracket (float32 detail below a 16-bit step, render exposure baked in), a transfer or positive frame (`needs_camera_matrix`), and a source whose format label hides the assembly, such as a LinearRaw DNG (`describes_a_merge`). +* **Stitch composites**: each part is decoded and corrected (flatfield plus sensor correction for single-shot parts, flatfield only for triplet parts), then assembled through `stitch_composite()` as in §2. -**IR Dust Removal** (visible when an IR channel is available, off by default): IR-based dust and scratch correction on the linear buffer before writing. +**IR Dust Removal** (when an IR channel is available, off by default): IR-based dust and scratch correction on the linear buffer before writing. -**Optional corrections** (camera RAW only), all off by default: *White Balance* multiplies by the green-normalized as-shot WB gains; *Flat Field* applies the configured Flat Field gain; *Sensor Correction* applies the crosstalk unmixing matrix. Stitch composites always get Flat Field and sensor correction per part, whatever the toggles, to avoid seams. The corrections run after EXIF orientation and before the user rotation and flips, because the Flat Field gain map is laid out on the EXIF-oriented decode. +**Optional corrections** (camera RAW only), all off by default: *White Balance* multiplies by the green-normalized as-shot WB gains; *Flat Field* applies the configured Flat Field gain; *Sensor Correction* applies the crosstalk unmixing matrix. Stitch composites always get Flat Field and sensor correction per part, to avoid seams. The corrections run after EXIF orientation and before the user rotation and flips, because the Flat Field gain map is laid out on the EXIF-oriented decode. -*Lens Correction* (any source, off by default) applies the Optics card's correction. The embedded warp runs as in `prepare_lens_source` (single camera RAW, after Flat Field, before the unmix); the manual `k1` runs on RGB and IR after the user rotation and flips, with `GeometryProcessor`'s `apply_radial_distortion`, so it matches the CPU export. A half-frame asset skips it, since its file holds the whole scan. The description records "lens" when either ran. +*Lens Correction* (any source, off by default) applies the Optics card's correction. The embedded warp runs as in `prepare_lens_source` (single camera RAW, after Flat Field, before the unmix); the manual `k1` runs on RGB and IR after the user rotation and flips, with `GeometryProcessor`'s `apply_radial_distortion`. A half-frame asset skips it, since its file holds the whole scan. The description records "lens" when either ran. -`wb_bake_block_reason()` makes *White Balance* inert for a Trichrome triplet or a Single-Shot Narrowband capture ("trichrome"/"narrowband", "" otherwise), because as-shot gains correct a broadband scene. Single-Shot Narrowband is detected from the Narrowband toggle or a calibrated sensor matrix. The sidebar toggle grays on the same reason. +`wb_bake_block_reason()` makes *White Balance* inert for a Trichrome triplet or a Single-Shot Narrowband capture ("trichrome"/"narrowband", "" otherwise), because as-shot gains correct a broadband scene. Single-Shot Narrowband is detected from the Narrowband toggle or a calibrated sensor matrix. **Output metadata.** The file holds raw pixels plus Make, Model and DateTime from the source; source ICC profiles, EXIF color space tags and XMP color metadata are never copied. For **TIFF**, the description records source format, expansion, white balance and applied corrections (including whether ICE ran) and ends with "no color management"; as-shot WB also goes into XMP. For Flextight FFF, Make includes film stock and type from the plist, and Model includes the scanner serial. @@ -304,127 +311,122 @@ With render intent **Linear** the darkroom pipeline is bypassed. The source deco ### Peek Negative **Code**: `AppController.toggle_negative_peek` / `_paint_negative_peek` -The canvas equivalent of Linear Output, not a pipeline stage. The decoded source (`AppState.preview_raw`, pre-bake: before flat-field, sensor unmix and every defect repair) runs through `GeometryProcessor` and `CropProcessor`, so it keeps rotation, flip, straighten, keystone and crop, then `working_oetf_encode` for display. Nothing else runs, and `content_rect` is cleared. The state is transient, exclusive with the flat peek and the before/after split. It records the config sections it opened on, all but Geometry, Metadata and Export, and a plain render after any of them changes value drops it. A bounds reset that comes with a geometry edit and a pixel-neutral write (measured bounds) carry that record forward. A committed crop, rotation, rotate, flip, straighten or keystone and a frame change drop it too. +Not a pipeline stage. The decoded source (`AppState.preview_raw`, before flat-field, sensor unmix and every defect repair) runs through `GeometryProcessor` and `CropProcessor`, then `working_oetf_encode` for display. Nothing else runs, and `content_rect` is cleared. -The peek applies `camera_to_working_matrix` before the encode (a source with no matrix passes through), folding in the as-shot multipliers whenever the decode skipped them (`effective_linear_raw`), including narrowband, unlike `should_fold_camera_wb`. `lightbox_level` then applies one scalar gain that puts a high percentile of the frame at display white, measured before the crop and applied after; a frame with no signal returns None and paints unlifted. The frame is *not* marked `splash`; it takes the normal working-to-display conversion with `proof` set False. +The peek applies `camera_to_working_matrix` before the encode. Unlike `should_fold_camera_wb`, it folds in the as-shot multipliers whenever the decode skipped them (`effective_linear_raw`), narrowband included. `lightbox_level` then applies one scalar gain that puts a high percentile of the frame at display white, measured before the crop and applied after; a frame with no signal returns None and paints unlifted. The frame is *not* marked `splash`; it takes the normal working-to-display conversion with `proof` set False. ### Peek Embedded Preview **Code**: `AppController.toggle_embedded_peek` / `_paint_embedded_peek` -The camera's own JPEG (`PreviewManager.try_splash_preview`, the buffer the load splash paints) through `GeometryProcessor` and `CropProcessor`, nothing else. Its context uses the preview's own shape, not `original_res`. It is marked `splash`, so the display transform takes it as sRGB. It is read on the first peek and held in `AppState.preview_embedded` until the frame changes, with the active asset's half-frame slice applied. No metric is derived from it. +The camera's own JPEG (`PreviewManager.try_splash_preview`) through `GeometryProcessor` and `CropProcessor`, nothing else. Its context uses the preview's own shape, not `original_res`. It is marked `splash`, so the display transform takes it as sRGB. It is held in `AppState.preview_embedded` until the frame changes, with the active asset's half-frame slice applied. No metric is derived from it. --- ## 4. Local Contrast (CLAHE) **Code**: `negpy.features.lab.logic.apply_clahe` (CPU) / `negpy/features/lab/shaders/clahe_{hist,cdf,apply}.wgsl` (GPU) -Contrast Limited Adaptive Histogram Equalization on CIELAB $L^{\ast}$, computed from linear working RGB (Adobe RGB, D65). Chroma ($a^{\ast}/b^{\ast}$) is untouched, so saturation does not rise. CPU and GPU mirror each other bin for bin; the parity test pins them to about 1e-6. +CLAHE on CIELAB $L^{\ast}$, computed from linear working RGB (Adobe RGB, D65). Chroma ($a^{\ast}/b^{\ast}$) is untouched. CPU and GPU mirror each other bin for bin; the parity test pins them to about 1e-6. * **Fixed $8\times8$ tile grid** over the full frame at every render scale, so the preview predicts the export. 256 bins over $L^{\ast} \in [0, 100]$. -* **Clip limit**: $\max(1, \lfloor \text{strength} \cdot 2.5 \cdot N_{tile} / 256 \rfloor)$ counts. The excess is spread evenly across all bins, remainder to the lowest, so the tile total is conserved. +* **Clip limit**: $\max(1, \lfloor \text{strength} \cdot 2.5 \cdot N_{tile} / 256 \rfloor)$ counts. The excess is spread evenly across all bins, remainder to the lowest. * **Per-pixel remap**: a smoothstep-weighted bilinear blend of the four neighboring tile CDFs (tile centers at $(\text{pos}/\text{dims}) \cdot 8 - 0.5$, edge-clamped), then $$L_{final} = (1 - \alpha) \cdot L + \alpha \cdot \text{CDF}(L) \cdot 100$$ with $\alpha$ = `clahe_strength`. -* A tiled full-res export hands the CDF of its downsampled meter render to every tile (`clahe_cdf_override`), so the tiles share one seam-free mapping. +* A tiled full-res export hands the CDF of its downsampled meter render to every tile (`clahe_cdf_override`), so the tiles share one mapping. -The control is in the Lab sidebar (`lab.clahe_strength`). Defect repair (§5) runs earlier, on the linear source. +The control is `lab.clahe_strength`. Defect repair (§5) runs earlier, on the linear source. --- ## 5. Retouching **Code**: `negpy.features.retouch` -Removes dust, hairs and scratches. Three sources find defects: the IR channel, statistical detection and manual strokes. Each gives the same continuous **defect score** ($1$ = clean film, floor $0.02$ = certain defect), repaired by one shared fill (step 3 below), plus a routed inpaint for defects too wide for the fill. +Three sources find defects: the IR channel, statistical detection and manual strokes. Each gives the same continuous **defect score** ($1$ = clean film, floor $0.02$ = certain defect), repaired by one shared fill (step 3 below), plus a routed inpaint for defects too wide for the fill. This is not a pipeline stage. Every repair is baked into the **linear source before geometry and normalization**, like flat-field, so the meters read cleaned film and CPU and GPU agree without either implementing it. * **Infrared (IR) dust removal** (scans with an IR channel: Coolscan, SilverFast iSRD, VueScan DNG): - Dust blocks infrared while the dyes pass it, so the IR plane is a defect map. Concepts are ported from digital-fauxice (see `NOTICE.md`), a recreation of Digital ICE. + Dust blocks infrared while the dyes pass it, so the IR plane is a defect map. Concepts are ported from digital-fauxice (see `NOTICE.md`). - The IR plane is downsampled for detection and **follows the buffer** being repaired: at most 1.5× under it, capped at 3600 px. Detecting far below repair resolution inflates the mask and the fill supports, and a defect on a tonal edge then prints a dark blotch. Footprint constants below are pixels at a 1600 px reference and scale with the plane; the score's 3×3 erode does not. Only *large* regions under the dead floor count as non-film, since an opaque hair blocks IR as fully as the holder. + The IR plane is downsampled for detection and **follows the buffer** being repaired: at most 1.5× under it, capped at 3600 px. Detection far below repair resolution inflates the mask and the fill supports, so a defect on a tonal edge prints a dark blotch. Footprint constants below are pixels at a 1600 px reference and scale with the plane; the score's 3×3 erode does not. Only *large* regions under the dead floor count as non-film, since an opaque hair blocks IR as fully as the holder. - 1. **Normalized ratio**: $r = IR / \text{blur}(\text{dilate}(IR))$, about $1.0$ on clean film, lower under defects. A crosstalk fit first divides out the image's IR ghost. The clean-film floor is then rescaled onto the identity point and the dip scale onto a reference σ per frame, stretching only, so a scanner at or above the reference renders bit-identically. - 2. **Division tier**: semi-transparent dust attenuates, so the image is recovered as $RGB / r^{\gamma}$, per-channel γ fitted per frame. The gain never lifts a pixel past its local clean base (defect-excluded mean $- \sigma$); a grain-biased local max prints dark rings. - 3. **Score-weighted fill**: the ratio maps to $s \in [0.02, 1]$ (the IR Threshold slider moves the ramp). Nothing is thresholded, so no mask edge. Cores and hairs are rebuilt as a multiscale score-weighted average over nested supports, $\text{fill} = \left(\sum I \cdot s \cdot w\right) / \left(\sum s \cdot w\right)$; the finest support with clean data wins, so edges continue through defects. Three rungs sit at the detection plane's footprint and one coarse rung at the reference footprint, which alone reaches across a wide defect. + 1. **Normalized ratio**: $r = IR / \text{blur}(\text{dilate}(IR))$, about $1.0$ on clean film, lower under defects. A crosstalk fit first divides out the image's IR ghost. Per frame, the clean-film floor is then rescaled onto the identity point and the dip scale onto a reference σ, stretching only, so a scanner at or above the reference renders bit-identically. + 2. **Division tier**: semi-transparent dust attenuates, so the image is recovered as $RGB / r^{\gamma}$, with per-channel γ fitted per frame. The gain never lifts a pixel past its local clean base (defect-excluded mean $- \sigma$); a grain-biased local max prints dark rings. + 3. **Score-weighted fill**: the ratio maps to $s \in [0.02, 1]$ (the IR Threshold slider moves the ramp). Nothing is thresholded. Cores and hairs are rebuilt as a multiscale score-weighted average over nested supports, $\text{fill} = \left(\sum I \cdot s \cdot w\right) / \left(\sum s \cdot w\right)$. The finest support with clean data wins. Three rungs sit at the detection plane's footprint and one coarse rung at the reference footprint, which alone reaches across a wide defect. - **Original-floor rule**: dust is dark in negative transmittance, so a repair may only lighten (no dark halo). The rule lets this path measure each support's clean fraction on the raw score and correct an all-defect support downstream. It compares the *low-frequency* deficit, since per pixel it keeps grain peaks and clips troughs. + **Original-floor rule**: dust is dark in negative transmittance, so a repair may only lighten. The rule lets this path measure each support's clean fraction on the raw score and correct an all-defect support downstream. It compares the *low-frequency* deficit, since per pixel it keeps grain peaks and clips troughs. 4. **Grain transplant**: both repair paths paste back detail high-passed from the nearest pixel the score calls clean, mirrored *through* the donor so a row across a hair does not streak into bands. Deep in a wide defect the paste flattens and the multiscale blend carries the interior. 5. **Routed inpaint**: defects with chebyshev radius ≥ 5 at detection scale go to structure-following inpaint, alpha-feathered, with the same grain transplant. A 2% frame budget bounds it; the fill always runs. B&W silver and Kodachrome block IR like dust. Such frames are auto-detected (the IR plane mirrors the image) and skipped. * **ICE at scan time** (Scan tab, nkscan backend only): - The nkscan driver runs its own openICE port during the scan. The repair is baked into the file, not an edit. Use it for speed on a batch; use the other paths when the repair must stay editable. + The nkscan driver runs its own openICE port during the scan. The repair is baked into the file, not an edit. * **IR removal, the OpenICE method** (`ir_method = "openice"`, `negpy/features/retouch/openice.py`): - A port of openICE (see `NOTICE.md`), a reverse-engineering of Nikon Scan's Digital ICE verified byte-exact against the original. It replaces steps 1 to 4 above, shares no code with them, keeps the routed inpaint, and runs at the same point. + A port of openICE (see `NOTICE.md`), a reverse-engineering of Nikon Scan's Digital ICE. It replaces steps 1 to 4 above, shares no code with them, keeps the routed inpaint, and runs at the same point. - All work is in log density, $D(v) = \frac{M}{16\ln 2}\ln(vM + 1)$ with $M = 65535$ (Beer-Lambert: a neutral defect is a fixed subtraction, the red dye's IR absorption one scalar). + All work is in log density, $D(v) = \frac{M}{16\ln 2}\ln(vM + 1)$ with $M = 65535$. 1. **Calibration**, once per frame from a roughly 281 px box downsample. The dye→IR crosstalk $c$ is a least-squares slope of IR deviation on red deviation over the four 4×4 quadrants of every all-clear-film 8×8 tile, gated to $|\delta^{IR}/\delta^{R}| \le 0.2$ and weighted $(\delta^R)^2(\sum IR)^2$. Two IR²-weighted reference levels give $IR_\text{ref} = (IR_\text{raw} - c\,R_\text{ref})/(1-c)$. 2. **IR gate**: $g = (d_{IR} - c\,d_R)/(1-c) - \theta$, at $IR_\text{ref}$ on clear film, dropping by the light a defect stole. - 3. **Clean-confidence weight**: $w = \text{clamp}\!\left(1 - (IR_\text{ref} + b - g)/\Delta,\ 0.02,\ 1\right)$ on the horizontal 3-tap minimum of the gate. ICE's fixed $b$ and $\Delta$ do not transfer between scanners, so both come per frame from the gate's median and MAD σ over sampled whole rows (after crosstalk subtraction and the min, at working resolution). The IR Threshold slider biases $\Delta$ between 2σ and 11σ. + 3. **Clean-confidence weight**: $w = \text{clamp}\!\left(1 - (IR_\text{ref} + b - g)/\Delta,\ 0.02,\ 1\right)$ on the horizontal 3-tap minimum of the gate. $b$ and $\Delta$ come per frame from the gate's median and MAD σ over sampled whole rows (after crosstalk subtraction and the min, at working resolution), because ICE's fixed values do not transfer between scanners. The IR Threshold slider biases $\Delta$ between 2σ and 11σ. 4. **Two normalized-convolution pyramids** over four scales: 9×9 octagon (69 cells), 5×5 octagon (21), 3×3 binomial tent (16), pixel. $C_\ell = \sum k_i w_i$; gate pyramid $P_\ell$ and color pyramid $L_\ell$ are $\left(\sum k_i w_i x_i\right)/C_\ell$. - 5. **Reconstruction**: $\text{acc} = L_0 + \gamma_{ch}(IR_\text{ref} - P_0)$, then per scale $\text{detail}^{(\ell)} = (L_\ell - L_{\ell-1}) \cdot 1.25$ through a dead zone $[\beta_\text{lo}, \beta_\text{hi}]$ (min and max of $P_\ell - P_{\ell-1}$ over a 5-point cross). Detail within the IR's own contrast is dust and is dropped; the rest passes, less the threshold, scaled by $\min(2C_1, 1)$, $C_2$, $w^2$. This keeps the fine structure the fill averages away. - 6. **Give up rather than invent**: a pixel is left alone when $w \ge 1$, when any channel reconstructs non-positive, or when one of four 9-sample probes on the 9×9 perimeter is entirely below the dust floor (ICE's 6.5% of clear film, carried as a transmittance fraction). The floor sits near-opaque, so giving up is a last resort. + 5. **Reconstruction**: $\text{acc} = L_0 + \gamma_{ch}(IR_\text{ref} - P_0)$, then per scale $\text{detail}^{(\ell)} = (L_\ell - L_{\ell-1}) \cdot 1.25$ through a dead zone $[\beta_\text{lo}, \beta_\text{hi}]$ (min and max of $P_\ell - P_{\ell-1}$ over a 5-point cross). Detail within the IR's own contrast is dust and is dropped. The rest passes, less the threshold, scaled by $\min(2C_1, 1)$, $C_2$, $w^2$. + 6. **Give up rather than invent**: a pixel is left alone when $w \ge 1$, when any channel reconstructs non-positive, or when one of four 9-sample probes on the 9×9 perimeter is entirely below the dust floor (ICE's 6.5% of clear film, carried as a transmittance fraction). - Output is $\max(L_3, \text{acc} + \text{dither})$ ("only fill, never darken"), written at full strength wherever $w < 1$ with no confidence ramp, as ICE does. Unlifted pixels are written back verbatim, since $D^{-1}(D(v))$ is not bit-exact in float32. - 7. **Grain**: ICE's zero-mean density dither, $\text{dither} = \text{env}(x)\,(u - \tfrac{1}{2})\,\alpha_{ch}x$, $u$ uniform, $\alpha = (0.015, 0.015, 0.025)$, parabolic envelope over $[D(\lfloor 0.01M \rfloor), D(\lfloor 0.99M \rfloor)]$ peaking at 1 mid-band; none unless $x + \text{dither}$ stays in the band. The draw is a hash of the absolute pixel coordinate, not ICE's frame-global LCG, so a banded pass matches a whole-frame one. + Output is $\max(L_3, \text{acc} + \text{dither})$ ("only fill, never darken"), written at full strength wherever $w < 1$, as ICE does. Unlifted pixels are written back verbatim, since $D^{-1}(D(v))$ is not bit-exact in float32. + 7. **Grain**: ICE's zero-mean density dither, $\text{dither} = \text{env}(x)\,(u - \tfrac{1}{2})\,\alpha_{ch}x$, $u$ uniform, $\alpha = (0.015, 0.015, 0.025)$, with a parabolic envelope over $[D(\lfloor 0.01M \rfloor), D(\lfloor 0.99M \rfloor)]$ peaking at 1 mid-band, and none unless $x + \text{dither}$ stays in the band. The draw is a hash of the absolute pixel coordinate, not ICE's frame-global LCG, so a banded pass matches a whole-frame one. 8. **Routing**: unreconstructable defects go to the structure-following inpaint. Components over 0.2% of the frame are dropped first, since some scanners report the rebate as one huge defect that would blow the 2% budget. Byte-exactness is out of scope (80-bit x87 intermediates, frame-global grain order). Density through calibration, gate, all four confidences and all four gate-pyramid levels match the C reference to float32 noise, and the give-up maps agree. -* **Automatic dust removal** (`dust_remove`, with Threshold and Size): - A statistical detector on the visible scan, feeding the same fill. +* **Automatic dust removal** (`dust_remove`, with Threshold and Size): a statistical detector on the visible scan, feeding the same fill. - 1. **Detection plane**: the source min-pooled to the IR detection resolution (`ir_detect_target`), by the same erode-then-average, because an area average dilutes a two-pixel speck below the grain. The preview keeps its own min-pooled plane from load. A segmented RGBI preview filters its IR channel before each segment is reduced, including defects on a segment boundary. + 1. **Detection plane**: the source min-pooled to the IR detection resolution (`ir_detect_target`) by the same erode-then-average, because an area average dilutes a two-pixel speck below the grain. The preview keeps its own min-pooled plane from load. A segmented RGBI preview filters its IR channel before each segment is reduced. 2. **Detection proxy**: percentile-normalized source **density** ($-\log_{10}$ linear luminance, 0.5 to 99.5% window), grade-independent in every process mode. - 3. **Excess and σ**: background = max of a median over $3\times$ Size and a morphological opening by Size, so specks cannot lift their own background and wider features stay. The excess is averaged $3\times3$ and divided by its local MAD σ over $4\times$ Size, both on 8-bit quantized planes. Windows scale with the plane, so Size is a film footprint. - 4. **Gate**: Spot and Hair Threshold each map to a seed bar in σ (3 loosest, 9 default, geometric above the default toward 48; 1.0 is off). Seeds grow through connected pixels to a grow bar within $2\times$ Size, so a hair becomes one component. A compact component must clear the spot bar, raised per pixel by the wide-window σ over a knee of 0.02; a hair-thin one the hair bar, raised more steeply by the median σ along it over a knee of 0.075, so a seed grown along a tonal edge fails. That σ is capped at twice the σ with the hairs filled, so a deep hair on flat film does not raise its own bar. Stat maps are cached, so dragging either Threshold re-runs only the gate. + 3. **Excess and σ**: background = max of a median over $3\times$ Size and a morphological opening by Size, so specks cannot lift their own background. The excess is averaged $3\times3$ and divided by its local MAD σ over $4\times$ Size, both on 8-bit quantized planes. Windows scale with the plane, so Size is a film footprint. + 4. **Gate**: Spot and Hair Threshold each map to a seed bar in σ (3 loosest, 9 default, geometric above the default toward 48; 1.0 is off). Seeds grow through connected pixels to a grow bar within $2\times$ Size. A compact component must clear the spot bar, raised per pixel by the wide-window σ over a knee of 0.02; a hair-thin one the hair bar, raised more steeply by the median σ along it over a knee of 0.075, so a seed grown along a tonal edge fails. That σ is capped at twice the σ with the hairs filled, so a deep hair on flat film does not raise its own bar. Stat maps are cached, so a Threshold change re-runs only the gate. 5. **Specks → score**: at the floor on the defect, ramping to clean across a small pad, then the shared fill. Where IR Removal already repaired a pixel, the optical mark is released. 6. **Hairs → routed inpaint**: strongly elongated defects (thinness $= \text{area}/\text{thickness}^2$, bending-invariant unlike PCA aspect) go to Navier-Stokes inpaint, encoded against the local clean range and alpha-feathered over the dilated PSF skirt. 7. **Exclusions**: right-painted strokes (source-normalized points, width at the heal reference scale) release the optical detections they cover: score back to clean, hair mask cleared. A mark crossing the rim keeps its repair outside the brush. The rim feathers like a manual heal, and the band is the whole path, smoothed and round-capped. This runs on the detection plane after the gate and before the fill, and leaves the IR and manual routes alone. An all-clean score skips the bake. Strokes fold into the source token. -* **Manual heals (Heal and Scratch tools)**: - A painted stroke is a **search area, not a stamp**: damaged pixels inside it are measured, and clean grain comes back byte-identical. +* **Manual heals (Heal and Scratch tools)**: a painted stroke is a **search area, not a stamp**: damaged pixels inside it are measured, and clean grain comes back byte-identical. - The measure is a **two-sided** $|z|$: density high-passed against a local mean (window $3\times$ the brush radius), averaged $3\times3$, over its MAD σ in the stroke's neighborhood. Two-sided because dust is bright in density and a **scratch dark**. **Hysteresis**: $|z| \ge 8$ seeds, connected pixels to $|z| \ge 2$ join. A stroke that never reaches 8 seeds against its own maximum; below $|z| = 5$ it is a no-op. The kept region is padded like the detector's. + The measure is a **two-sided** $|z|$ (dust is bright in density and a **scratch dark**): density high-passed against a local mean (window $3\times$ the brush radius), averaged $3\times3$, over its MAD σ in the stroke's neighborhood. **Hysteresis**: $|z| \ge 8$ seeds, and connected pixels to $|z| \ge 2$ join. A stroke that never reaches 8 seeds against its own maximum; below $|z| = 5$ it is a no-op. The kept region is padded like the detector's. - The fill runs with the **original-floor rule off**, since a scratch reads brighter and must be free to darken. Instead it measures each support's clean fraction *above* the score floor, with supports at the film footprint, not buffer pixels. Too-wide cores route to the inpaint. Sparse defects (strokes and detected specks) are repaired each in a padded crop, with the support ladder still from the whole frame, so results match a full-buffer repair. + The fill runs with the **original-floor rule off**, since a scratch must be free to darken. Instead it measures each support's clean fraction *above* the score floor, with supports at the film footprint, not buffer pixels. Too-wide cores route to the inpaint. Sparse defects (strokes and detected specks) are repaired each in a padded crop, with the support ladder still from the whole frame, so results match a full-buffer repair. -* **Transport scratches (the line tool)**: - A nearly straight scratch a few px wide along the film, too faint per pixel for a brush; it shows only when integrated along the line. +* **Transport scratches (the line tool)**: a nearly straight scratch a few px wide along the film, visible only when integrated along the line. - A click gives a start point. The frame is band-passed *across* the scratch and normalized by a **local** noise scale. The line's **slope** is fitted, pulled toward the click. **Extent and width are measured**: the repair covers the stretch where the ridge holds, and the band grows outward by the same hysteresis as the brush, so a stored line stays valid at any resolution. The mask takes the same skirt pad and fill, original-floor rule off. **Line Sensitivity** sets the ridge bar for both and re-measures placed lines. There is no auto-detection, because a full-length ridge is as likely a horizon, and telling them apart needs a cross-frame pass. + A click gives a start point. The frame is band-passed *across* the scratch and normalized by a **local** noise scale. The line's **slope** is fitted, pulled toward the click. **Extent and width are measured**: the repair covers the stretch where the ridge holds, and the band grows outward by the same hysteresis as the brush, so a stored line stays valid at any resolution. The mask takes the same skirt pad and fill, original-floor rule off. **Line Sensitivity** sets the ridge bar for both and re-measures placed lines. There is no auto-detection, because a full-length ridge is as likely a horizon. -* **Clone strokes (the Clone tool)**: - The last source bake, so a clone copies repaired film. Each stroke stores raw-frame points, a destination-to-source offset, strength, feather and tone match; strokes apply in order, so a later one can copy an earlier one. The patch is the shifted source over a capsule mask whose edge ramps over `feather` × the radius, times strength; off-frame source pixels carry no weight. **Match Tone** scales the source by $\bar d / \bar s$, the Gaussian means ($\sigma$ = brush radius) of destination and source outside the brush, clamped to ±6 stops. A new stroke on the last bake applies only itself. +* **Clone strokes (the Clone tool)**: the last source bake, so a clone copies repaired film. Each stroke stores raw-frame points, a destination-to-source offset, strength, feather and tone match; strokes apply in order. The patch is the shifted source over a capsule mask whose edge ramps over `feather` × the radius, times strength; off-frame source pixels carry no weight. **Match Tone** scales the source by $\bar d / \bar s$, the Gaussian means ($\sigma$ = brush radius) of destination and source outside the brush, clamped to ±6 stops. A new stroke on the last bake applies only itself. -* **Resolution independence**: - Coordinates and sizes are relative to the full-resolution RAW. A half frame reports the cropped and split full-resolution dimensions even when its preview uses a half-size RAW decode. Strokes are in raw-frame coordinates and the repair runs before geometry, so rotations, flips and distortion correction need no mapping. +* **Resolution independence**: coordinates and sizes are relative to the full-resolution RAW. A half frame reports the cropped and split full-resolution dimensions even when its preview uses a half-size RAW decode. Strokes are in raw-frame coordinates and the repair runs before geometry, so rotations, flips and distortion correction need no mapping. --- ## 6. Lab Scanner Mode **Code**: `negpy.features.lab` -Mimics lab scanners such as Frontier or Noritsu. Steps, in order: +Steps, in order: -1. **Chroma Denoise**: a bilateral filter on the A and B channels in LAB. Its range term (`CHROMA_DENOISE_SIGMA_R` $= 15$ in $a^{\ast}b^{\ast}$) rejects taps of a different color, so a saturated object keeps its color inside its edge. L, and its grain, stays untouched. +1. **Chroma Denoise**: a bilateral filter on the A and B channels in LAB. Its range term (`CHROMA_DENOISE_SIGMA_R` $= 15$ in $a^{\ast}b^{\ast}$) rejects taps of a different color, so a saturated object keeps its color inside its edge. L stays untouched. -2. **Global Saturation**: a lightness-preserving chroma scale ($a^{\ast}/b^{\ast}$) in CIELAB, post-decode and independent of the print curve. Paper-dependent color is §3's job (Dye Separation). +2. **Global Saturation**: a lightness-preserving chroma scale ($a^{\ast}/b^{\ast}$) in CIELAB, independent of the print curve. - Below 1.0 it is a flat scale. Above 1.0 it is **gamut-aware** (`gamut_aware_chroma_scale` in `kernel/image/logic.py`, mirrored in `lab.wgsl`), because a per-channel RGB clamp would shift hue. `_in_gamut_lab` checks whether the boosted (L,a,b) decodes to linear working RGB in $[0,1]$. In-gamut pixels get the flat scale; the rest get a softplus-style knee toward their own headroom (the print curve's toe and shoulder shape), the max in-gamut scale found by bisection to under 0.1% error in 10 iterations. + Below 1.0 it is a flat scale. Above 1.0 it is **gamut-aware** (`gamut_aware_chroma_scale` in `kernel/image/logic.py`, mirrored in `lab.wgsl`), because a per-channel RGB clamp would shift hue. `_in_gamut_lab` checks whether the boosted (L,a,b) decodes to linear working RGB in $[0,1]$. In-gamut pixels get the flat scale; the rest get a softplus-style knee toward their own headroom, the max in-gamut scale found by bisection to under 0.1% error in 10 iterations. 3. **Skin Protection** (`skin_chroma_rein` in `kernel/image/logic.py`, mirrored in `lab.wgsl`, `lab.skin_protection`, default 0.5): a soft chroma ceiling inside a skin mask, after saturation. It also runs at Chroma 1.0. - The mask (`_skin_weight`) multiplies a Gaussian on hue at $52°\pm20°$, a chroma window at full weight to $C^{\ast}=35$ and zero by $60$, and a lightness rolloff below $L^{\ast}=15$ and above $95$. Pixels under $C^{\ast}=2$ get zero. The chroma window (skin locus $C^{\ast}$ 12 to 40) separates skin from sunset, terracotta and brick; skin above $C^{\ast}\approx 50$ keeps partial weight, and wood, tan leather and sand count as skin. + The mask (`_skin_weight`) multiplies a Gaussian on hue at $52°\pm20°$, a chroma window at full weight to $C^{\ast}=35$ and zero by $60$, and a lightness rolloff below $L^{\ast}=15$ and above $95$. Pixels under $C^{\ast}=2$ get zero. With $w$ the mask weight and $s$ the slider: $$C_{\text{ceil}} = \frac{22}{s}, \qquad C_0 = 0.6\,C_{\text{ceil}}, \qquad C_{\text{knee}} = C_0 + (C_{\text{ceil}}-C_0)\left(1 - e^{-(C-C_0)/(C_{\text{ceil}}-C_0)}\right)$$ - for $C > C_0$, identity below, $C_{\text{out}} = C + w\,(C_{\text{knee}} - C)$. $a^{\ast}$ and $b^{\ast}$ scale by $C_{\text{out}}/C$, so hue and $L^{\ast}$ are kept. The reciprocal ceiling fades out continuously as $s \to 0$. Chroma is only reduced, so `saturation = 0.0` still reaches gray and the gamut knee's fit holds. + for $C > C_0$, identity below, $C_{\text{out}} = C + w\,(C_{\text{knee}} - C)$. $a^{\ast}$ and $b^{\ast}$ scale by $C_{\text{out}}/C$, so hue and $L^{\ast}$ are kept. The ceiling fades out continuously as $s \to 0$. Chroma is only reduced, so `saturation = 0.0` still reaches gray and the gamut knee's fit holds. 4. **Sharpening**: **Method** picks Unsharp Mask or Deconvolution. Both share Amount, Radius and Masking and the $\text{radius}$ Gaussian taps from `gaussian_kernel_1d` (`sharpen_k` storage buffer), convolved identically by `cv2.sepFilter2D` and the separable WGSL passes, so CPU and GPU match bit for bit. @@ -433,20 +435,20 @@ Mimics lab scanners such as Frontier or Noritsu. Steps, in order: $$L_{diff} = L - \text{blur}(L, \sigma), \qquad \sigma = \text{radius}$$ $$\text{gain} = \text{amount} \cdot 2.5 \cdot \text{smoothstep}(0.25, 0.33, |L_{diff}|) \cdot m \cdot g(L)$$ $$L_{final} = \text{clamp}\big(L + L_{diff}\cdot\text{gain},\; L_{min}-2,\; L_{max}+1\big)$$ - * **Radius** (px): blur $\sigma$ in pixels of the full-size render, so judge sharpening at 1:1, not fit-to-window. A resized export scales the radius down with the image. - * **Masking** ($m$): $\text{smoothstep}(0.5t, t, |\nabla L|)$ on the boxed gradient, $t = 10\cdot\text{masking}$. Protects flat areas; off at 0. + * **Radius** (px): blur $\sigma$ in pixels of the full-size render. A resized export scales the radius down with the image. + * **Masking** ($m$): $\text{smoothstep}(0.5t, t, |\nabla L|)$ on the boxed gradient, $t = 10\cdot\text{masking}$; off at 0. * **Shadow gain** ($g(L)$, fixed): $\tfrac{1}{3} + \tfrac{2}{3}\,\text{smoothstep}(0, 35, L)$, since the thinnest negative has the most grain (Gallagher & Gindele, US 7,228,004). Both methods apply it; Deconvolution reads $L^{\ast}$ of the observed $Y$. - * The noise gate over $[0.25, 0.33]$ is sized for $|L_{diff}|$ at a 1 px radius (tops out near 1.0). The clamp to the local $3\times3$ range is tighter above (+1) than below (−2), because $L^{\ast}$ USM exaggerates light halos. + * The noise gate over $[0.25, 0.33]$ is sized for $|L_{diff}|$ at a 1 px radius. The clamp to the local $3\times3$ range is tighter above (+1) than below (−2), because $L^{\ast}$ USM exaggerates light halos. - **Deconvolution**: Richardson-Lucy on linear luminance $Y$ with a Gaussian PSF, reversing the scanner's optical blur (`rl_*.wgsl`). + **Deconvolution**: Richardson-Lucy on linear luminance $Y$ with a Gaussian PSF (`rl_*.wgsl`). $$\hat{o}_{n+1} = \hat{o}_n \cdot \left(K \otimes \frac{o}{\max(K \otimes \hat{o}_n,\ \epsilon)}\right), \qquad \hat{o}_0 = o = Y$$ - Iterations are $\text{clamp}(\text{round}(10\cdot\text{radius}), 5, 20)$, so preview and export match. No early stop, no damping; the edge mask alone governs grain, as in RawTherapee. Applied as a chroma-preserving RGB ratio: + Iterations are $\text{clamp}(\text{round}(10\cdot\text{radius}), 5, 20)$, so preview and export match. No early stop or damping: the edge mask alone governs grain, as in RawTherapee. Applied as a chroma-preserving RGB ratio: $$\mathrm{RGB}_{out} = \mathrm{clamp}\left(\mathrm{RGB} \cdot \max\left(1 + \left(\frac{\hat{o}_N}{\max(o,\epsilon)} - 1\right) \cdot \mathrm{amount} \cdot m,\ 0\right),\ 0,\ 1\right)$$ -5. **Glow**: lens bloom, a print-side effect: blurred highlights added back in linear light. +5. **Glow**: blurred highlights added back in linear light. $$I_{out} = I + B_{glow} \cdot s_{glow}$$ $$B_{glow} = \text{GaussianBlur}(I \cdot m_{hl})$$ @@ -454,7 +456,7 @@ Mimics lab scanners such as Frontier or Noritsu. Steps, in order: * $m_{hl}$: **display-domain** highlight mask, linear ramp from code value 0.5 to 1.0. * Equal on all three channels; the sum is clamped at the stage output. -6. **Halation**: red scatter from light reflecting back through the film base. A larger Gaussian than Glow, additive in linear light, with the mask on **linear reflectance** ($t = 0.65$) so it follows scene exposure, not Grade and Density. +6. **Halation**: red scatter from the film base. A larger Gaussian than Glow, additive in linear light, with the mask on **linear reflectance** ($t = 0.65$) so it follows scene exposure, not Grade and Density. $$I_{out} = I + B_{hal} \cdot s_{hal}$$ $$B_{hal} = \text{GaussianBlur}(I_R \cdot m_{lin} \cdot C_{hal})$$ @@ -472,21 +474,21 @@ One optional process (B&W Negative mode only), between Lab and Toning so the ton ### 7.1 Lith -Two phases (Rudman's "New Rules", Moersch Lessons 1-6): a long low-gamma highlight branch fixed by exposure, then infectious development that drives the shadows to Dmax almost vertically, with no separation past it (Moersch's "lith-band"). On the print density $D_0$, with the paper's $D_{max}$: +Two phases (Rudman's "New Rules", Moersch Lessons 1-6): a long low-gamma highlight branch fixed by exposure, then infectious development that drives the shadows to Dmax almost vertically, with no separation past it. On the print density $D_0$, with the paper's $D_{max}$: $$D_0' = D_0 + 0.301 E, \qquad D_h = f_{max}\left(1 - e^{-f_{rate} (D_0 + 0.301 E v) / f_{max}}\right)$$ $$D = D_h + (D_{max} - D_h) \cdot \sigma\!\left(\frac{D_0' - K}{w}\right)$$ -* **Exposure** $E$ (stops) shifts the image up the exposure axis and brings the knee forward. The highlight branch takes only a fraction $v$ (`foot_veil`), so paper white does not fog by a full quarter-stop of density at the practitioner-standard +2. -* **Snatch Point** sets the knee density $K = D_{max}(\text{knee}_{\text{lo}} - \text{knee}_{\text{span}}\cdot\text{snatch})$, a development-time proxy. Later snatch widens the black band. -* **Abruptness** sets the knee width $w$ (Moersch: "an almost abrupt blackening sets in"). A hydroquinone-rich, low-sulphite bath (Solution A end) reaches $w \approx 0.03$. -* **Color** comes from the paper, with no strength slider: its $(a^{\ast}, b^{\ast})$ path on $u = D/D_{max}$, anchors at $u = 0.10/0.35/0.65/1.00$: peach, ochre, **olive**, neutral. Keep the olive knot: warmtone lith goes green between warm highlights and cold blacks. $L^{\ast}$ comes from the density, so the RGB→Lab transform is skipped. Hue tracks silver particle size, which tracks density (Kong & Shore, *J. Imaging Sci. Technol.* 51(3), 2007). The path is `PaperProfile.lith_path`, for the paper chosen in the Exposure panel. +* **Exposure** $E$ (stops) shifts the image up the exposure axis and brings the knee forward. The highlight branch takes only a fraction $v$ (`foot_veil`), so paper white does not fog by a full quarter-stop of density at +2. +* **Snatch Point** sets the knee density $K = D_{max}(\text{knee}_{\text{lo}} - \text{knee}_{\text{span}}\cdot\text{snatch})$. A later snatch widens the black band. +* **Abruptness** sets the knee width $w$; the sharpest end reaches $w \approx 0.03$. +* **Color** comes from the paper: its $(a^{\ast}, b^{\ast})$ path on $u = D/D_{max}$ (Kong & Shore, *J. Imaging Sci. Technol.* 51(3), 2007), with anchors at $u = 0.10/0.35/0.65/1.00$: peach, ochre, **olive**, neutral. Keep the olive knot: warmtone lith goes green between warm highlights and cold blacks. $L^{\ast}$ comes from the density, so the RGB→Lab transform is skipped. The path is `PaperProfile.lith_path`. -Not modelled: semiquinone and bromide diffusion halos, pepper fog, snowballs and other faults. +Not modeled: semiquinone and bromide diffusion halos, pepper fog, snowballs and other faults. ### 7.2 Cyanotype -An iron process: ferric salt, contact print under UV, wash. No development, so the print depends on the light through the negative and the sensitizer's density range, which is the contrast control (Ware: about 1.0 to 1.2 for Herschel, about 2.4 for his own; Simple Cyanotype variants at 1.8, 2.3 and 2.7). Midtones compress within that range, so the mid gamma is below one. +No development: the print depends on the light through the negative and on the sensitizer's density range, which is the contrast control (Ware). Midtones compress within that range, so the mid gamma is below one. On $D_0$, with exposure $E$ in stops and exposure scale $S$ in log D: @@ -497,48 +499,48 @@ $$D = D_{max}\,(1 + T g)\,u$$ * **Exposure** $E$: more UV drives more of the scale into blue. * **Exposure Scale** $S$: the negative density range printed, so the contrast. A short scale clips both ends of a normal negative. -* Midtone compression is the reverse-S term, $m = 0.45$, written $v|v|$ so both engines evaluate it identically, and blended with a straight line so the center slope is $1-m$ (a zero slope would posterize). -* **Bleach** $B$ (washing soda) strips pigment highlights-first, leaving the deepest shadow at $\beta = 0.15$ of its density at full strength. **Tannin** $T$ re-develops the bleached iron as iron tannate, slightly past the blue ($r = 1.05$), with more covering power ($g = 0.15$). -* **Color**: Prussian blue absorbs mostly red (peak near 700nm), so it goes blue, never black. As in lith, an $(a^{\ast}, b^{\ast})$ path on $u$, anchors at $u = 0.00/0.15/0.55/1.00$: rag white, **green highlight stain** (residual yellow sensitizer, per Ware), mid blue, Prussian blue; $L^{\ast}$ from density. Tannin mixes toward an iron-tannate direction scaled by $u$, so a partial bleach splits blue-brown. **Sensitizer** picks $D_{max}$ and path: Classic (Herschel) 0.95 and greener, New (Ware) 1.40 and deeper (Ware's red-channel Dmax 0.55-1.05 for classic sensitisers, about 1.5 for a good modern print). +* Midtone compression is the reverse-S term, $m = 0.45$, written $v|v|$ so both engines evaluate it identically and blended with a straight line so the center slope is $1-m$ (a zero slope would posterize). +* **Bleach** $B$ strips pigment highlights-first, leaving the deepest shadow at $\beta = 0.15$ of its density at full strength. **Tannin** $T$ re-develops the bleached iron as iron tannate, slightly past the blue ($r = 1.05$), with more covering power ($g = 0.15$). +* **Color**: an $(a^{\ast}, b^{\ast})$ path on $u$, with anchors at $u = 0.00/0.15/0.55/1.00$: rag white, **green highlight stain**, mid blue, Prussian blue; $L^{\ast}$ from density. Tannin mixes toward an iron-tannate direction scaled by $u$, so a partial bleach splits blue-brown. **Sensitizer** picks $D_{max}$ and path: Classic (Herschel) 0.95 and greener, New (Ware) 1.40 and deeper. * No silver, so the six chemical toners are skipped. Split toning still applies. -Not modelled: **solarisation** (the reversal dries back to blue, so the finished print does not show it), bronzing, paper texture. +Not modeled: **solarization**, bronzing, paper texture. ### 7.3 Sabattier -A print re-exposed to white light part-way through development. Where silver has developed, the flash finds little fresh halide and exhausted developer, so the dense tones hold; where the print is still light the halide is fresh and develops after the flash, so the lighter the tone the more it reverses, and the scale folds at the re-exposure density. Bromide released by the developing dense areas diffuses into the adjoining light ones and restrains their re-development: the Mackie line, a light line along every edge between dark and light. On the print density $D$, with $R = \rho D_{max}$, strength $s$, fold width $w = 0.08$ and edge width $w_e = 0.10$ (`SABATTIER_CONSTANTS`): +A print re-exposed to white light part-way through development. Dense tones hold; light tones reverse, the lighter the more, so the scale folds at the re-exposure density. Bromide from the developing dense areas restrains re-development of the adjoining light ones: the Mackie line, a light line along every edge between dark and light. On the print density $D$, with $R = \rho D_{max}$, strength $s$, fold width $w = 0.08$ and edge width $w_e = 0.10$ (`SABATTIER_CONSTANTS`): $$g = \sigma\!\left(\frac{D - R}{w_e}\right), \qquad D' = D + s\,\big(1 - \ell\,\text{blur}_{\sigma_m}(g)\big)\, w \ln\!\big(1 + e^{(R - D)/w}\big)$$ -* **Strength** $s$ (0 to 2, default 1.3): the second exposure's length. Below 1 the light tones flatten toward the fold, at 1 they sit on it, above 1 they reverse, the lighter the darker; paper white comes back at $sR$, clamped to $D_{max}$. -* **Re-exposure** $\rho$ (0.1 to 0.9 of $D_{max}$, default 0.45): the density the fold sits at. Tones denser than it hold. -* **Agitation** $a$ (0 to 1, default 0.7): the tray's rocking after the flash, which sweeps the bromide off the edges. $\sigma_m = (1 - a)$ % of the frame's short side, the bromide's reach and so the Mackie lines' width; at 1 there are none ($\ell = 0$). The blur is the stage's one spatial term, a separable Gaussian in render pixels (`line_sigma_px`) whose taps come from `gaussian_kernel_1d` on both engines, so preview, export and the two engines draw the same line; a tiled export grows its halo by the kernel's half-width. +* **Strength** $s$ (0 to 2, default 1.3): below 1 the light tones flatten toward the fold, at 1 they sit on it, and above 1 they reverse. Paper white comes back at $sR$, clamped to $D_{max}$. +* **Re-exposure** $\rho$ (0.1 to 0.9 of $D_{max}$, default 0.45): the density the fold sits at. +* **Agitation** $a$ (0 to 1, default 0.7): $\sigma_m = (1 - a)$ % of the frame's short side, the Mackie lines' width; at 1 there are none ($\ell = 0$). The blur is the stage's one spatial term, a separable Gaussian in render pixels (`line_sigma_px`) whose taps come from `gaussian_kernel_1d` on both engines; a tiled export grows its halo by the kernel's half-width. * The print is neutral silver, so the toners act on it as on a plain print. -Not modelled: the warm second-development tone of chlorobromide papers, pseudo-solarization of the negative. +Not modeled: the warm second-development tone of chlorobromide papers, pseudo-solarization of the negative. --- ## 8. Toning **Code**: `negpy.features.toning` -* **Chemical Toning** (B&W Negative mode only): six baths (**Selenium**, **Sepia**, **Gold**, **Iron Blue**, **Copper**, **Vanadium Green**) modelled as a **silver ledger** in density space (`TONING_CONSTANTS`). The mean density $D_0$ is the silver reservoir; each toner converts a fraction, locked away from later baths (Rudman/Ilford: selenium-then-sepia split, "no silver left" exhaustion). +* **Chemical Toning** (B&W Negative mode only): six baths (**Selenium**, **Sepia**, **Gold**, **Iron Blue**, **Copper**, **Vanadium Green**) modeled as a **silver ledger** in density space (`TONING_CONSTANTS`). The mean density $D_0$ is the silver reservoir; each toner converts a fraction, locked away from later baths (Rudman, Ilford). * **Susceptibility**: $c_i$ is a pure function of $D_0$; sequence only decides who claims silver first. - * *Silver-proportional, shadows first*: Selenium $c = S \cdot (D_0/D_{ref})^{p}$. Iron Blue and Copper likewise, with $D_{ref} = 0.9$ so color reaches the mids. - * *Bleach-limited, highlights first*: Sepia, Gold, Vanadium $c = S \cdot (1 - D_0/D_{ref})^{p}$; the exponent sets the split-sepia character. + * *Silver-proportional, shadows first*: Selenium $c = S \cdot (D_0/D_{ref})^{p}$. Iron Blue and Copper likewise, with $D_{ref} = 0.9$. + * *Bleach-limited, highlights first*: Sepia, Gold, Vanadium $c = S \cdot (1 - D_0/D_{ref})^{p}$. Strength $> 1$ is a longer bath; conversion caps at 1. - * **The ledger**: untoned fraction $a$ starts at 1; in bath order (selenium → sepia → gold → blue → copper → vanadium) each claims $f_i = a \cdot c_i$, $a \mathrel{-}= f_i$. **Gold** also plates the sulfide (sepia) fraction: the gold-over-sepia orange-red shift. + * **The ledger**: untoned fraction $a$ starts at 1. In bath order (selenium → sepia → gold → blue → copper → vanadium) each claims $f_i = a \cdot c_i$, $a \mathrel{-}= f_i$. **Gold** also plates the sulfide (sepia) fraction. * **Covering power**: $D_{ch} = D_{0,ch} \cdot \left(a + \sum_i f_i \cdot g_{i,ch}\right)$, $I_{out} = 10^{-D_{ch}}$. Each channel uses its own input density, so existing color (a lith print) survives; on a gray print $D_{0,ch} = D_0$. - Gain triplets $g_i$: selenium all $\ge 1$ (Dmax boost, eggplant shadows), sepia lower covering power (lifts, warms), gold slight intensification (cool blue-black), Prussian blue net $> 1$ with G at exactly $1.00$ (so sepia-plus-blue splits green), copper net $< 1$ (bleaches while it tones: brick red), vanadium R/B absorbed with slight density loss (green print, black blacks). + Gain triplets $g_i$: selenium all $\ge 1$; sepia lower covering power; gold slight intensification; Prussian blue net $> 1$ with G at exactly $1.00$ (so sepia-plus-blue splits green); copper net $< 1$; vanadium R/B absorbed with slight density loss. -* **On a lith print** (`LITH_TONING_CONSTANTS`, when the Lith stage is on): fine lith silver moves further in the same bath. Only two baths change, and only they are exposed in the UI: - * **Selenium**: $D_{ref} = 1.2$, exponent $1.0$, so it reaches the mids; a green-led gain turns the green-black shadow magenta (Moersch, 1+5 for 20 s), mean well above 1 for Dmax lift. - * **Gold**: flat $c = S$ ("gold toner attacks all densities evenly"), gain blue-violet. - * Sepia, iron blue, copper and vanadium are unchanged and inert or redundant on lith, so the sidebar disables them. +* **On a lith print** (`LITH_TONING_CONSTANTS`, when the Lith stage is on): only two baths change: + * **Selenium**: $D_{ref} = 1.2$, exponent $1.0$; a green-led gain turns the green-black shadow magenta (Moersch), with a mean well above 1 for Dmax lift. + * **Gold**: flat $c = S$, gain blue-violet. + * Sepia, iron blue, copper and vanadium are unchanged. -* **Split Toning** (all modes): an additive tint in LAB ($a^{\ast}b^{\ast}$), so luminance, grain and detail are kept. With $L$ the CIELAB lightness ($0$ to $100$): +* **Split Toning** (all modes): an additive tint in LAB ($a^{\ast}b^{\ast}$). With $L$ the CIELAB lightness ($0$ to $100$): $$m_{shadow} = 1 - \text{smoothstep}(0,\ 50,\ L), \qquad m_{highlight} = \text{smoothstep}(50,\ 100,\ L)$$ For each region, with hue $\theta$, strength $S$ and mask $m$: $$a^{\ast} \mathrel{+}= \cos\theta \cdot 20 \cdot S \cdot m, \qquad b^{\ast} \mathrel{+}= \sin\theta \cdot 20 \cdot S \cdot m$$ @@ -550,21 +552,20 @@ Not modelled: the warm second-development tone of chlorobromide papers, pseudo-s Post-crop print finishing in scene-linear, before the output transform. Order: edge burn → filed carrier; layout extras run at compositing time. The uncropped tool previews (crop, analysis region, tilt/swing) skip the filed carrier and the layout on both engines. -* **Edge Burn (Vignette)**: $I_{out} = I \cdot 2^{-s \cdot m}$, $s$ the burn in stops (negative = hold back), $m$ a cosine falloff mask. **Roundness** morphs from radial (lens-like) to rectangular (card-like); **Size** sets the falloff midpoint. +* **Edge Burn (Vignette)**: $I_{out} = I \cdot 2^{-s \cdot m}$, with $s$ the burn in stops (negative = hold back) and $m$ a cosine falloff mask. **Roundness** morphs from radial to rectangular; **Size** sets the falloff midpoint. -* **Filed Carrier**: full-frame printing with a filed-out carrier. The clear rebate prints between two boundaries, framed by an unexposed margin ($0.7 \cdot w$) in the mat color (`PrintService.effective_paper_linear`, scene-linear since this stage precedes the output OETF and the mat does not). +* **Filed Carrier**: the clear rebate prints between two boundaries, framed by an unexposed margin ($0.7 \cdot w$) in the mat color (`PrintService.effective_paper_linear`, scene-linear since this stage precedes the output OETF and the mat does not). - The rebate is printed, not painted. `rebate_tone` pushes a neutral density ramp off the film base (the normalization's thin bound plus `REBATE_BASE_MARGIN` $= 0.15$ log units) through the frame's exposure kernel, saturation and toning into a 64-entry table over the exposure fraction $t$ that reaches the paper, sampled at $t = u^3$. The table is divided by its $t = 0$ entry and multiplies the mat color, so bare paper meets the mat exactly. The paper layers' different contrasts and the filtration put a hue into the toe, and with Paper Black on the rebate stops at the paper's D-max. Alternative processes, slides and flat masters use plain light ($1 - t$). Both engines read the one table (GPU storage buffer), so the shader has no print model. + `rebate_tone` pushes a neutral density ramp off the film base (the normalization's thin bound plus `REBATE_BASE_MARGIN` $= 0.15$ log units) through the frame's exposure kernel, saturation and toning into a 64-entry table over the exposure fraction $t$ that reaches the paper, sampled at $t = u^3$. The table is divided by its $t = 0$ entry and multiplies the mat color, so bare paper meets the mat exactly. With Paper Black on the rebate stops at the paper's D-max. Alternative processes, slides and flat masters use plain light ($1 - t$). Both engines read the one table (GPU storage buffer), so the shader has no print model. - The picture side is the camera gate: machine-cut, it only wobbles (`carrier_profiles()` rows 0-3, no slider), prints soft and has a small fixed corner radius. The paper side is the filed aperture: **Roughness** swings it off rows 4-7, straight file strokes with raised-cosine nicks weighted toward the corners, printing nearly crisp. **Corners** rounds only the aperture. The aperture is evaluated in its own frame, offset from the picture by `CARRIER_OFFSET_X/Y` of $w$, so the rebate is wider at the top and left. The aperture also gates the picture (weight $A_{in} A_{out}$). Profiles and offset are fixed-seed, so one carrier prints the whole roll. + The picture side is the camera gate: it only wobbles (`carrier_profiles()` rows 0-3, no slider), prints soft and has a small fixed corner radius. The paper side is the filed aperture: **Roughness** swings it off rows 4-7, straight file strokes with raised-cosine nicks weighted toward the corners. **Corners** rounds only the aperture. The aperture is evaluated in its own frame, offset from the picture by `CARRIER_OFFSET_X/Y` of $w$, so the rebate is wider at the top and left. The aperture also gates the picture (weight $A_{in} A_{out}$). Profiles and offset are fixed-seed, so one carrier prints the whole roll. - A fine **2-D fBm** (`carrier_noise`) displaces both boundaries' distance fields, adding the fibrous hairline and the flecks a 1-D profile cannot. It is hand-rolled hash value noise (u32 wrap-around only) so WGSL reproduces it bit for bit. Cell size scales with $w$. + A fine **2-D fBm** (`carrier_noise`) displaces both boundaries' distance fields. It is hand-rolled hash value noise (u32 wrap-around only), so WGSL reproduces it bit for bit. Cell size scales with $w$. - **Flare** is the bevel's reflection: exposure added to $t$, peaking on each filed edge over $0.25 \cdot w$ and gated by the other three edges, so it stops at the aperture's corners. It reads through the same table, so it stains the paper in the toe color, neutral in B&W. + **Flare**: exposure added to $t$, peaking on each filed edge over $0.25 \cdot w$ and gated by the other three edges, so it stops at the aperture's corners. It reads through the same table. -* **Layout extras** (`services/export/print.py` plus the `layout.wgsl` mirror): **bottom-weighted mat** (window-mat proportions) and **match paper white** (mat color from paper white run through the toning stack). +* **Layout extras** (`services/export/print.py` plus the `layout.wgsl` mirror): **bottom-weighted mat** and **match paper white** (mat color from paper white run through the toning stack). * **Export sharpening** (`apply_resize_sharpening`): a Print or Pixels export runs the Lab unsharp mask again on the scaled content, inside the mat, at a fixed $\sigma$ of `RESIZE_SHARPEN_RADIUS` exported pixels. It runs on the CPU after either engine's layout, so it needs no shader. An *Original* export and a flat master skip it. -* **Preview paper size**: an interactive render sizes the paper from the preview long edge (`preview_render_size`) instead of the export DPI, and resamples the content to fit, never above its own resolution, because the canvas quotes zoom against the pipeline's buffer and an upscale would make 1:1 read closer than one scan pixel per device pixel. The display shader magnifies instead. - +* **Preview paper size**: an interactive render sizes the paper from the preview long edge (`preview_render_size`), not the export DPI, and resamples the content to fit, never above its own resolution, because the canvas quotes zoom against the pipeline's buffer. The display shader magnifies instead. diff --git a/docs/PLUSTEK_WINDOWS.md b/docs/PLUSTEK_WINDOWS.md index 7ab5c2030..0a399902b 100644 --- a/docs/PLUSTEK_WINDOWS.md +++ b/docs/PLUSTEK_WINDOWS.md @@ -1,6 +1,6 @@ # Windows USB setup (Plustek OpticFilm) -NegPy's Plustek USB backend uses the external [pyopticfilm](https://github.com/jboneng/pyopticfilm) driver (libusb through PyUSB). Requires pyopticfilm **1.3.3** or later. The stock Plustek Windows driver must not own the device. +NegPy's Plustek USB backend uses the external [pyopticfilm](https://github.com/jboneng/pyopticfilm) driver (libusb through PyUSB), version **1.3.3** or later. The stock Plustek Windows driver must not own the device. ## Requirements @@ -37,7 +37,7 @@ uv sync --group plustek make run ``` -A Windows release build works too. It bundles pyopticfilm, PyUSB and libusb. In the Scan tab, set Backend to **pyOpticfilm (Plustek)**. Refresh the device list. The SE appears when WinUSB is bound. +A Windows release build also works. It bundles pyopticfilm, PyUSB and libusb. In the Scan tab, set Backend to **pyOpticfilm (Plustek)** and refresh the device list. The SE shows when WinUSB is bound. ## 4. Restore the vendor driver @@ -54,18 +54,18 @@ A Windows release build works too. It bundles pyopticfilm, PyUSB and libusb. In | `DriverBindingError` or access denied | WinUSB is not bound, or another app holds the handle | | `UsbError` or link failures | Cable or hub. Try a direct motherboard port | | UI hints at missing USB / PyUSB | Install the plustek group: `uv sync --group plustek` | -| The first scan at a DPI takes a few seconds | Normal. AFE plus one dark and one white shading strip, the same choreography as SilverFast. It is then cached per resolution | -| ASIC shading ready (preferred) | The log shows a color white mean of about 50–57k, `median_gain` about 1.1–1.7x, then `ASIC shading ready` and image `shading=True` / DVDSET on. Delete `plustek_calib` and rescan after a driver change so the calibration is measured again | -| Color white mean about 50–57k | Normal. At unity gain, DVDSET returns `raw − dark`, so a bright strip reads near full scale. The 11–13k figures in the captures are the *gains* SilverFast computed from it (`0x2000` = 1.0), not white levels | +| The first scan at a DPI takes a few seconds | Normal: AFE plus one dark and one white shading strip, as SilverFast does. The result is cached per resolution | +| ASIC shading ready (preferred) | The log shows a color white mean of about 50–57k, `median_gain` about 1.1–1.7x, then `ASIC shading ready` and image `shading=True` / DVDSET on. After a driver change, delete `plustek_calib` and rescan to measure the calibration again | +| Color white mean about 50–57k | Normal. At unity gain DVDSET returns `raw − dark`, so a bright strip reads near full scale. The 11–13k figures in the captures are the *gains* SilverFast computed from it (`0x2000` = 1.0), not white levels | | Median gain outside 1.02–3.0x | Below the band the strip is already at target, so there is nothing to flatten. Above it the light path is too dim. Check the lamp, the AFE gains, and that the head sits on clear home chrome | | Carriage not at home after shading or a scan abort | The white strip arms `AGOHOME` during the measure, and clearing SCAN parks it. If the park timed out, use SilverFast or power-cycle, then retry | -| Corners darker than SilverFast on the Full window | The Full window includes about 0.8 mm of holder chrome. Shading is per column only, so residual Y falloff at those edges is expected against a tighter SilverFast frame | -| The positive is very dark until you crop (white edges on the negative) | Holder chrome is brighter than film base, so the NegPy auto bounds latch onto it. Both paths clamp border highlights to the film inset (`border highlight clamp…`). On the ASIC path this is essential, because DVDSET maps that same chrome to full scale by construction. Also raise Process → **Analysis Buffer**, or crop before auto | -| Rainbow vertical "barcode" stripes | The uploaded table was indexed differently from the image. Either the measurement went into the gain slot (gain must be `0xFFFF × 0x2000 / white`, which is proportional to `1/white`), or the blob was packed without its block padding. The AHB table is 512-byte blocks of 126 `(dark, gain)` pairs plus two `gain = 0` pad pairs, so contiguous records slide 8 bytes per block. Delete `plustek_calib` and rescan | +| Corners darker than SilverFast on the Full window | The Full window includes about 0.8 mm of holder chrome. Shading is per column only, so some Y falloff at those edges is expected against a tighter SilverFast frame | +| The positive is very dark until you crop (white edges on the negative) | Holder chrome is brighter than film base, so the NegPy auto bounds latch onto it. Both paths clamp border highlights to the film inset (`border highlight clamp…`). The ASIC path needs this, because DVDSET maps that chrome to full scale by construction. Also raise Process → **Analysis Buffer**, or crop before auto | +| Rainbow vertical "barcode" stripes | The uploaded table was indexed differently from the image. Either the measurement went into the gain slot (gain must be `0xFFFF × 0x2000 / white`, proportional to `1/white`), or the blob was packed without its block padding. The AHB table is 512-byte blocks of 126 `(dark, gain)` pairs plus two `gain = 0` pad pairs, so contiguous records slide 8 bytes per block. Delete `plustek_calib` and rescan | | Diamond or sheared scene (objects lean) | (1) Image X shrank while USB still paced the full line. The shading table must cover every acquired column (the Full window at 1800 stays about 2592 px). (2) An odd crop USB width at 1800, for example 2455. The output width must stay even (`optical_span_alignment` plus an even pixel count). Delete `plustek_calib` (cache v9+) and rescan. The log must show an even `pixels=` | | Negative very dark, or positive washed bright, against SilverFast | Both paths reference *home* chrome, which is brighter than the light at the scan position. At 1800 dpi the film base lands near 42% of full scale, about 1.2 stops down, and NegPy meters a thin negative. `expose_film_base` lifts it with one scalar gain keyed to the brightest channel (`… exposure makeup gain=…`). The gain must stay scalar, or it neutralizes the orange mask that inversion needs | | Strong orange or pink cast on the positive | Check `AFE search done gains=…` for a channel at or near `AFE_GAIN_MAX` (511). At the rail that channel's dark term clips to 0 (`dark0=(0, …)`), so it loses its blacks and tints the whole frame. The search substitutes SF's session-04 code for any pegged channel. A persistent peg means the AFE gain target is unreachable. The target is an *AFE-strip* level, not a shading white, so do not raise it to match SF's ~50k probe mean | | Strong green cast on the positive | The border clamp ceiling must be **per channel**. A single joint percentile flattens the margin to neutral gray at a level the dimmest channel never reaches in the film (session 004: joint 27432 against green's own 19306), so auto Dmin meters green off chrome and lifts it 1.4x. Check `border highlight clamp peak_p99.7=(r,g,b)`. Each figure must sit just above that channel's own film peak | -| `white clipped at the rail` | The white strip is pinned near `0xFFFF`, so it carries no shape to flatten. Lower the AFE gains. DVDSET stays off and the host stretch runs | +| `white clipped at the rail` | The white strip is pinned near `0xFFFF`, so it has no shape to flatten. Lower the AFE gains. DVDSET stays off and the host stretch runs | | Scan fails: white mean below 20000 | The post-unity strip is too dim to reach target within the 4x gain clamp. Often a stale AHB strip: dark and AFE wait until the buffer has data **at home**, and a motor-busy `0xa5` is not ready there. Also check that the lamp is off for dark and that the head is on clear home chrome. Cache v6+ ignores collapsed calibration | | Scan fails: color ASIC shading / clear home field | The carriage is not on the clear home sensor. Park or power-cycle, then retry. The film may stay loaded | diff --git a/docs/TEMPLATING.md b/docs/TEMPLATING.md index a0b064ab2..f262fbd02 100644 --- a/docs/TEMPLATING.md +++ b/docs/TEMPLATING.md @@ -1,8 +1,6 @@ # Filename Templating -NegPy uses **Jinja2** for dynamic file naming in both the **Export** and **Scan** sidebars. - ---- +The **Export** and **Scan** sidebars name files with Jinja2 templates. ## Export Sidebar @@ -10,39 +8,39 @@ NegPy uses **Jinja2** for dynamic file naming in both the **Export** and **Scan* | Variable | Description | Example Output | | :--- | :--- | :--- | -| `{{ original_name }}` | Base filename of the source file (without extension). | `DSC0123` | -| `{{ colorspace }}` | Target export color space. | `sRGB`, `Adobe RGB` | +| `{{ original_name }}` | Source filename without extension. | `DSC0123` | +| `{{ colorspace }}` | Export color space. | `sRGB`, `Adobe RGB` | | `{{ format }}` | Export file format (JPEG, TIFF, …). | `JPEG`, `TIFF` | | `{{ paper_ratio }}` | Selected aspect ratio. | `3:2`, `Original` | -| `{{ size }}` | Print size in cm (Empty if "Original Resolution" is used). | `30cm` | -| `{{ dpi }}` | Export DPI (Empty if "Original Resolution" is used). | `300dpi` | -| `{{ target_px }}` | Target long-edge size in pixels (Empty unless Pixels mode). | `2048px` | -| `{{ border }}` | Inserts "border" if width > 0, else empty. | `border` | -| `{{ date }}` | Current date in YYYYMMDD format. | `20260125` | -| `{{ roll }}` | Scanlight capture roll name (Metadata → Roll), or parsed from a `{roll}_Frame{NNN}` stem. This is not the Roll Analysis card's roll name. | `Roll001` | -| `{{ frame }}` | Capture frame number (integer), or parsed from the stem. Unset (`none`) if unknown. Use `{{ frame\|pad(3) }}` or `{{ frame_padded }}` to zero-pad. `"%03d" % frame` works only when frame is set; if it is not, the whole pattern falls back to `original_name`. | `12` | +| `{{ size }}` | Print size in cm. Empty with "Original Resolution". | `30cm` | +| `{{ dpi }}` | Export DPI. Empty with "Original Resolution". | `300dpi` | +| `{{ target_px }}` | Long-edge size in pixels. Empty unless Pixels mode. | `2048px` | +| `{{ border }}` | "border" if the width is > 0, else empty. | `border` | +| `{{ date }}` | Current date, YYYYMMDD. | `20260125` | +| `{{ roll }}` | Scanlight capture roll name (Metadata → Roll), or parsed from a `{roll}_Frame{NNN}` stem. Not the Roll Analysis card's roll name. | `Roll001` | +| `{{ frame }}` | Capture frame number (integer), or parsed from the stem. Unset (`none`) if unknown. Zero-pad with `{{ frame\|pad(3) }}`. `"%03d" % frame` fails on an unset frame, and the whole pattern falls back to `original_name`. | `12` | | `{{ frame_padded }}` | Zero-padded frame (`012`), or empty if unknown. Same as `{{ frame\|pad(3) }}`. | `012` | | `{{ camera }}` | Camera make + model. | `Mamiya 7` | -| `{{ camera_make }}` / `{{ camera_model }}` | Camera make / model separately. | `Mamiya`, `7` | -| `{{ lens }}` | Lens model (or make if model is empty). | `80mm f/4` | -| `{{ lens_make }}` / `{{ lens_model }}` | Lens make / model separately. | | -| `{{ focal_length }}` | Lens focal length in mm. | `80` | +| `{{ camera_make }}` / `{{ camera_model }}` | Camera make / model. | `Mamiya`, `7` | +| `{{ lens }}` | Lens model, or make if the model is empty. | `80mm f/4` | +| `{{ lens_make }}` / `{{ lens_model }}` | Lens make / model. | | +| `{{ focal_length }}` | Focal length in mm. | `80` | | `{{ film }}` | Film stock name. | `Ektacolor Pro 400` | | `{{ film_iso }}` | Film ISO. | `400` | | `{{ film_manufacturer }}` | Film manufacturer. | `Kodak` | | `{{ film_color_type }}` | Film color type. | `Color negative` | -| `{{ film_format }}` | Film format (35mm, 120, …). Distinct from export `{{ format }}`. | `35mm` | +| `{{ film_format }}` | Film format (35mm, 120, …). Not the export `{{ format }}`. | `35mm` | | `{{ developer }}` | Developer. | `D-76` | | `{{ dilution }}` | Developer dilution. | `1+1` | | `{{ push_pull }}` | Push/pull as an integer (−3…+3, 0 = Normal). | `1` | -| `{{ development_time }}` | Development time, mm-ss (the colon is not filename-safe). | `9-30` | +| `{{ development_time }}` | Development time, mm-ss. | `9-30` | | `{{ development_temperature }}` | Development temperature in °C. | `20` | | `{{ scanning }}` | Scanning method note. | `DSLR copy-stand` | | `{{ exposure }}` | Exposure override text from Metadata. | `1/125s f/2.8` | -| `{{ capture_date }}` | Original capture date in YYYYMMDD. A partial date pads to the first day. Empty if unset. | `19980714` | +| `{{ capture_date }}` | Original capture date, YYYYMMDD. A partial date pads to the first day. Empty if unset. | `19980714` | | `{{ capture_year }}` | Original capture year. Empty if unset. | `1998` | -Gear and process values come from the **Metadata** panel, or from each file's saved metadata in a batch. An empty field renders as an empty string, so the separators around it collapse. NegPy strips path-unsafe characters from metadata values. +Gear and process values come from the **Metadata** panel, or from each file's saved metadata in a batch. NegPy strips path-unsafe characters from them. ### Examples @@ -55,18 +53,16 @@ Gear and process values come from the **Metadata** panel, or from each file's sa | `{{ roll }}_Frame{{ frame\|pad(3) }}_{{ film }}_{{ film_iso }}` | `Roll001_Frame012_Portra_400_400.jpg` | | `{{ film }}_{{ camera }}_{{ original_name }}` | `Portra_400_Mamiya_7_DSC0123.jpg` | ---- - ## Scan Sidebar ### Available Variables | Variable | Description | Example Output | | :--- | :--- | :--- | -| `{{ date }}` | Current date in YYYYMMDD format. | `20260125` | -| `{{ seq }}` | Sequence number (integer, auto-incremented to avoid overwriting). | `1`, `2`, … | +| `{{ date }}` | Current date, YYYYMMDD. | `20260125` | +| `{{ seq }}` | Sequence number (integer). | `1`, `2`, … | -To zero-pad the sequence number use Python's `%` format operator: `{{ "%03d" % seq }}`. +Zero-pad it with Python's `%` operator: `{{ "%03d" % seq }}`. ### Examples @@ -78,20 +74,18 @@ To zero-pad the sequence number use Python's `%` format operator: `{{ "%03d" % s ### Auto-increment -The sequence starts at `1` for each scan session. It increments until the filename does not yet exist on disk. NegPy **never overwrites** an existing file. - ---- +The sequence starts at `1` for each scan session and increments until the filename is free, so NegPy never overwrites a file. ## Filename cleanup -Both sidebars apply the same separator cleanup to the rendered template: +Both sidebars clean up separators: + +- Spaces, dashes and underscores between variables collapse into a **single underscore** (`_`). +- Leading and trailing separators are removed. +- The separators around an empty variable (`{{ border }}` with no border set) are removed. +- `{{ original_name }}` (export only) goes in verbatim, separators included. -* Spaces, dashes and underscores between variables collapse into a **single underscore** (`_`). -* Leading and trailing separators are removed. -* When a variable is empty (`{{ border }}` with no border set), the separators around it are cleaned up. -* `{{ original_name }}` (export only) is inserted verbatim. Dashes, spaces and underscores in the original filename are kept exactly. +Example pattern: `{{ original_name }} - {{ border }} - final` -**Example:** -Pattern: `{{ original_name }} - {{ border }} - final` -* With border: `DSC0123_border_final.jpg` -* No border: `DSC0123_final.jpg` +- With border: `DSC0123_border_final.jpg` +- No border: `DSC0123_final.jpg` diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 102741a61..0be87fdc2 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -1,6 +1,6 @@ # NegPy User Guide -NegPy turns film scans into positives with a non-destructive, darkroom-style pipeline. It never writes to your source files; edits live in a local database. +NegPy turns film scans into positives with a non-destructive, darkroom-style pipeline. It never writes to your source files. Edits live in a local database. For the pipeline order and math, read [PIPELINE.md](PIPELINE.md). @@ -10,43 +10,43 @@ For the pipeline order and math, read [PIPELINE.md](PIPELINE.md). ### Protected data folders on Windows -If Windows blocks the default data folder, NegPy suggests `%LOCALAPPDATA%\NegPy\data`; click the path to pick another, then **Use This Folder**. Nothing is copied: a new folder starts empty, an existing NegPy folder keeps its data. The choice, saved in `data-location.json`, stays even if Documents becomes writable; `NEGPY_USER_DIR` overrides it. +If Windows blocks the default data folder, NegPy suggests `%LOCALAPPDATA%\NegPy\data`. Click the path to pick another, then **Use This Folder**. Nothing is copied: a new folder starts empty, and an existing NegPy folder keeps its data. The choice is saved in `data-location.json` and stays even if Documents becomes writable. `NEGPY_USER_DIR` overrides it. ### Screen layout * **Left, the film strip**: your frames as a contact sheet, with import, sorting and triage tools. -* **Center, the canvas**: the live preview, where most tools act. Scroll or pinch to zoom, drag to pan. The bottom toolbar holds Previous/Next with the frame counter, the zoom readout (click it for **Fit to Window**, 25–400% and **HQ Preview**; 100% is one scan pixel per screen pixel, and with HQ off a **preview res · HQ off** pill marks a scaled-up preview), rotate/flip (on every selected frame), undo/redo, the **Print** menu (Before / After and the peeks below) and **Overlays** (Zone Overlay, Clipping Overlay, Printing Notes, Grain Focuser). A narrow canvas hides geometry first and navigation last. While a canvas tool is armed, the toolbar shows the tool's name, how to use it, the slider it is sized or tuned by, undo/redo and **Done** (finish the line or shape in progress and leave the tool). The **⋯** menu holds every action, including **Preferences…** (§15). Right-click the toolbar for **Edit Toolbar…** (put any menu item on the toolbar). Right-click the image for **Reset View**, **Sticky Zoom**, the pickers, copy/paste settings, **Reset Settings**, **Reset to Roll Settings** and **Unload** (drop the frame from the session; its edit stays). -* **Right, the controls**: tabs **Roll** / **Frame** / **Metadata** / **Gear** / **Export** / **Scan**. **Frame** has a pinned **Analysis** readout and its own row of tabs below it. Roll and Frame change the render; the other tabs do not. +* **Center, the canvas**: the live preview. Scroll or pinch to zoom, drag to pan. The bottom toolbar holds navigation, zoom (**Fit to Window**, 25–400%, **HQ Preview**), rotate/flip (on every selected frame), undo/redo, the **Print** menu (Before / After and the peeks below) and **Overlays** (Zone Overlay, Clipping Overlay, Printing Notes, Grain Focuser). An armed canvas tool shows its use, its slider and **Done** on the toolbar. The **⋯** menu holds every action, including **Preferences…** (§15). Right-click the toolbar for **Edit Toolbar…**. Right-click the image for **Reset View**, **Sticky Zoom**, the pickers, copy/paste settings, **Reset Settings**, **Reset to Roll Settings** and **Unload** (drop the frame from the session; its edit stays). +* **Right, the controls**: tabs **Roll** / **Frame** / **Metadata** / **Gear** / **Export** / **Scan**. **Frame** has a pinned **Analysis** readout and its own row of tabs. Only Roll and Frame change the render. -With nothing open, the canvas offers **Load Scans…** (import a folder as a roll, add files, or open the Scan tab) and **Take the Tour**. +With nothing open, the canvas offers **Load Scans…** and **Take the Tour**. -Drag a panel by its top edge (the strip above Session, or the margin around the **Find** box) to float it; its pin button docks it again. **Shift+H** hides and shows both panels. NegPy remembers the layout and each dialog's size and position; **Reset Panel Layout** (**⋯** menu) restores the default. +Drag a panel by its top edge to float it; its pin button docks it. **Shift+H** hides and shows both panels. **Reset Panel Layout** (**⋯** menu) restores the default layout. ### The tour -**Take the Tour** (**⋯** menu, or Find) walks through NegPy in eight chapters; the chapter menu on its card jumps between them. A step with a circle is a task (drag Print Density, press a view key); a check marks it done. With nothing open, **Load Demo Negative** opens a synthetic negative. **Read More…** opens the panel's guide. Esc ends the tour; the next one offers **Resume Where You Left Off**. +**Take the Tour** (**⋯** menu, or Find) has eight chapters. A circled step is a task. With nothing open, **Load Demo Negative** opens a synthetic negative. **Read More…** opens the panel's guide. Esc ends the tour; the next one offers **Resume Where You Left Off**. ### Find -**Ctrl+K** (or **Find Control or Action…** in the **⋯** menu) searches every slider, card and action, by name or by another editor's word: *contrast* finds **ISO-R Grade**, *white balance* **Filtration**, *exposure* **Print Density**. Enter opens the row on its tab; a slider row is the live control. Presets are listed by name; Enter opens **Apply Preset**. +**Ctrl+K** (or **Find Control or Action…** in the **⋯** menu) searches every slider, card, action and preset, by name or by another editor's word (*contrast* finds **ISO-R Grade**). Enter opens the row on its tab; on a preset it opens **Apply Preset**. ### Before / After -**Before / After** in the toolbar's **Print** menu (or `\`) splits the canvas: left is the auto baseline (same film process, crop and rotation, every creative control at default), right is your edit. Drag the divider. The split stays up while you edit; `\`, `Esc`, a frame change, a peek or the test strip closes it. +**Before / After** in the **Print** menu (or `\`) splits the canvas: left is the auto baseline (same film process, crop and rotation, every creative control at default), right is your edit. It stays up while you edit. `\`, `Esc`, a frame change, a peek or the test strip closes it. ### Reference view -**Shift+R** (or **Reference View** in the **⋯** menu) pins the current frame to a pane beside the canvas, to match other frames to it. The pane keeps the frame as pinned; press **Shift+R** twice to pin again. **✕** or **Shift+R** closes it. +**Shift+R** (or **Reference View** in the **⋯** menu) pins the current frame in a pane beside the canvas, to match other frames to it. Press **Shift+R** twice to pin again. **✕** or **Shift+R** closes it. ### Peek Negative -**Negative** in the toolbar's **Print** menu (or `N`) shows the scan as loaded: not inverted, not metered, no edits; crop, rotation and flip still apply. Use it to check density, mask color and scanner clipping. It is scaled to its brightest tone, so read density from the density histogram, not the brightness. It stays up while you drag a crop or rotation handle or the Fine Rotation slider, and through Export and Metadata settings. Releasing the handle or slider closes it, and so do a rotate, flip, straighten or keystone, any other edit and a frame change. +**Negative** in the **Print** menu (or `N`) shows the scan as loaded: not inverted, not metered, no edits; crop, rotation and flip still apply. Use it to check density, mask color and scanner clipping. It is scaled to its brightest tone, so read density from the density histogram. It stays up through Export and Metadata changes and while you drag a crop or rotation handle or Fine Rotation. Any other edit or a frame change closes it. ### Peek Embedded Preview -**Embedded** in the **Print** menu (or `P`) shows the camera's own JPEG at your crop and rotation. NegPy uses nothing from it. Files without one (scanner TIFFs, most converter DNGs) say so and stay on the print. It closes the same way as Peek Negative. +**Embedded** in the **Print** menu (or `P`) shows the camera's own JPEG at your crop and rotation. NegPy uses nothing from it. Scanner TIFFs and most converter DNGs have none. It closes like Peek Negative. -A peek shows a **NEGATIVE**, **EMBEDDED** or **FLAT SCAN** badge, and the **Print** menu shows its name. `Esc` closes any peek, the split or a test strip. +`Esc` closes any peek, the split or a test strip. ### The workflow @@ -54,7 +54,7 @@ A peek shows a **NEGATIVE**, **EMBEDDED** or **FLAT SCAN** badge, and the **Prin | Tab | Panels | What it is for | |-----|--------|---------------| -| **Roll** | Film Mode · Frame Assembly · Calibration · Crop · Roll Analysis · Metering · Raw Decode · Optics | Film type, capture color, crop shape, roll baselines, negative→positive metering, the scanning rig | +| **Roll** | Film Mode · Frame Assembly · Calibration · Crop · Roll Analysis · Metering · Raw Decode · Optics | Film type, capture color, crop shape, roll baselines, metering, the scanning rig | **Frame** tabs follow the pipeline order: @@ -76,115 +76,111 @@ Tabs that do not change the render: | **Gear** | Gear library | Cameras, lenses, films, processes, scan setups | | **Scan** | Scanner · the scanner's cards · Output | Direct capture from a film scanner or camera | -A slider row reads name, track and value: click the value to type one, drag the name to scrub (**Shift** for finer steps), double-click or **Ctrl**+click the track to reset. A thin rail joins controls that work together, such as Toe and Toe Width. A **dot** on a panel header or tab marks a non-default value. Each panel header has a **reset** action and an **ⓘ** that opens this guide there. Tabs that do not fit move into a **»** menu. +On a slider, click the value to type one, drag the name to scrub (**Shift** for finer steps), and double-click or **Ctrl**+click the track to reset. A **dot** on a panel header or tab marks a non-default value. Each panel header has a **reset** action and an **ⓘ** that opens this guide there. ### What carries to the next frame An unedited frame gets the rig and roll settings: film process, crop ratio, flips, calibration, paper stock, the Lab polish and export preferences. The look (density, filtration, tone curve, toning, dodge and burn) starts clean. -**Preferences → Session & Storage → Persistent Settings…** edits that list: tick a setting or a group to make it carry. **Carry settings between frames** is the master switch; off, new frames get bare defaults. +**Preferences → Session & Storage → Persistent Settings…** sets which settings carry. **Carry settings between frames** is the master switch; off, new frames get bare defaults. -An edited frame keeps its look; only export and metadata settings reach it. **Reset Settings** returns bare defaults and, in a roll, sets every Roll-tab card back to **Roll**. No reset, a card's arrow included, changes the scanning setup (Linear RAW, Narrowband, demosaic); **Scanning setup** sets those. +An edited frame keeps its look; only export and metadata settings reach it. **Reset Settings** returns bare defaults and, in a roll, sets every Roll-tab card back to **Roll**. No reset changes the scanning setup (Linear RAW, Narrowband, demosaic); **Scanning setup** sets those. ### Frame or roll: the scope pair -Each section header has **Frame** (picture, amber) and **Roll** (film roll, red) beside its reset arrow, which resets that card alone; **· 2** after the card name counts its non-default settings. The lit one shows where the card's values live; click the other to move them. A card with non-default values has a stripe in that color. Frames that are not one roll (search results, several folders) show only **Frame** until **Save as Roll…**. +Each section header has **Frame** (amber) and **Roll** (red) beside its reset arrow, which resets that card alone. The lit one shows where the card's values live; click the other to move them there. Frames that are not one roll (search results, several folders) show only **Frame** until **Save as Roll…**. -On a **Roll tab** or **Metadata** card the pair is a latch: on Roll, the card follows the roll and new frames inherit it; an edit flips it to Frame. **Roll** pushes this frame's value to the roll; **Frame** pins it to this frame. Right-click a Roll tab card's header for **Undo Apply to Roll**, which gives the roll back that card's values from before the last push from this frame. +On a **Roll tab** or **Metadata** card the pair is a latch. On Roll, the card follows the roll and new frames inherit it; an edit flips it to Frame. **Roll** pushes this frame's value to the roll; **Frame** pins it to this frame. Right-click the card header for **Undo Apply to Roll**, which undoes this frame's last push. -On a **frame** card (Geometry, Filtration, Tone, Lab, Alternative Processes, Toning, Retouch, Finishing), **Roll** opens the clone picker for that section, to apply your changes to the selection or the whole roll. After a whole-roll apply the card reads Roll until you touch a pushed setting. Right-click its header for **Copy Card Settings**: Paste (`Ctrl+V`) then offers that card's settings alone. +On a **frame** card (Geometry, Filtration, Tone, Lab, Alternative Processes, Toning, Retouch, Finishing), **Roll** opens the section's clone picker, to apply your changes to the selection or the whole roll. After a whole-roll apply the card reads Roll until you change a pushed setting. Right-click its header for **Copy Card Settings**; Paste (`Ctrl+V`) then pastes that card alone. -**Reset to Roll**, beside the reset arrow, appears once a card differs from the roll. It puts the roll's values back on that card as one undo step; a setting the roll never carried keeps this frame's value. +**Reset to Roll**, beside the reset arrow, shows once a card differs from the roll. It puts the roll's values back on the card as one undo step; settings the roll never carried stay. ### The tab header -Tabs with several cards (Roll, Exposure, Look, Finish, Metadata) have a bar reading **3 of 5 cards edited**. Its buttons act on every card: reset arrow (asks first), **Reset to Roll**, roll button (one picker for the whole tab) and double chevron (collapse/expand). +Tabs with several cards (Roll, Exposure, Look, Finish, Metadata) have a bar that reads **3 of 5 cards edited**. Its buttons act on every card: reset (asks first), **Reset to Roll**, one roll picker for the whole tab, and collapse/expand. ### Menu bar (macOS) -`Ctrl` in this guide is `⌘` on macOS, as the app shows it. +`Ctrl` in this guide is `⌘` on macOS. -* **NegPy**: Preferences… (`⌘,`), beside About and Quit. -* **Window**: Minimize (`⌘M`), Zoom, Close (`⌘W`), Bring All to Front, and a list of open NegPy windows (main, live view, calibration). +* **NegPy**: Preferences… (`⌘,`), About and Quit. +* **Window**: Minimize (`⌘M`), Zoom, Close (`⌘W`), Bring All to Front, and the open NegPy windows. * **Help**: Take the Tour, Keyboard Shortcuts, Customize Shortcuts, the Analysis panel guide, Report an Issue, Check for Updates. -Full screen is the green window button. Menus show only `⌘` keys; plain-key shortcuts such as `?` still work. `⌘W` on the main window closes NegPy. Windows and Linux have no menu bar. +Plain-key shortcuts such as `?` work but do not show in the menus. `⌘W` on the main window closes NegPy. Windows and Linux have no menu bar. --- ## 2. Film strip (left panel) -A green **Update Available** line tops the panel when a newer release is out ([§16](#16-updating-negpy)). **About NegPy…** in the **⋯** menu shows the version. - -Below it are the toolbar, the search box and two sections: **Library** (imported rolls) and **Film Strip** (open frames). Click a heading to fold it; drag the handle between them to resize. +A green **Update Available** line tops the panel when a newer release is out ([§16](#16-updating-negpy)). Below are the toolbar, the search box and two sections: **Library** (imported rolls) and **Film Strip** (open frames). ### Your library -**Library** lists every **roll** you have imported: a named group of frames, not a live view of a folder. **Ctrl+L** expands it. +**Library** lists every **roll** you imported: a named group of frames, not a live view of a folder. **Ctrl+L** expands it. * **+**: import a roll (below). -* **↻**: finds new roll folders under each parent imported with **Import Subfolders as Rolls…** and re-reads each roll's frame count. -* **Discovery Filters…**: folder names that importing and **↻** skip, with everything inside them, one per line (default `export`). A line matches any part of a name, ignoring case; a line with `*` must match the whole name (`raw_*`). Saving runs **↻**: rolls a filter now catches leave the list, and come back when the filter goes. +* **↻**: finds new roll folders under each parent imported with **Import Subfolders as Rolls…** and recounts each roll's frames. +* **Discovery Filters…**: folder names that importing and **↻** skip, with all their contents, one per line (default `export`). A line matches any part of a name, ignoring case; a line with `*` must match the whole name (`raw_*`). * **Sort**: Name or Date, ascending or descending. -Each row shows name and count ("36 photos"), where an assembled Trichrome triplet, stitch or HDR merge is one photo; a roll whose folder is gone shows **folder missing** in amber. Importing only records the folder; nothing is decoded until you open the roll. +A Trichrome triplet, stitch or HDR merge counts as one photo. A roll whose folder is gone shows **folder missing**. Importing only records the folder; nothing decodes until you open the roll. #### Importing **+** (or the list's right-click menu) offers: -* **Import Folder as a Roll…**: the folder becomes one roll and opens; a folder with no images of its own imports its subfolders instead. If its name matches a camera or film stock in your Gear library ("penf" finds "Pen F"), Roll Settings opens pre-filled. -* **Import Subfolders as Rolls…**: every folder below the chosen one that holds images becomes a roll, without opening; a roll folder's own subfolders (export output) do not. A roll is named by its path ("20260901/kentmere_400_1") and listed under a **20260901** folder row. Right-click a folder row for **Delete…**, which forgets every roll in it. +* **Import Folder as a Roll…**: the folder becomes one roll and opens. A folder with no images of its own imports its subfolders instead. If its name matches a camera or film stock in your Gear library ("penf" finds "Pen F"), Roll Settings opens pre-filled. +* **Import Subfolders as Rolls…**: every folder below the chosen one that holds images becomes a roll, without opening, except subfolders of a roll folder (export output). A roll is named by its path ("20260901/kentmere_400_1") under a folder row. Right-click a folder row for **Delete…**, which forgets every roll in it. -Importing never changes the folder. Reorganize on disk, then re-import (or **↻**); edits follow image content, so a moved file keeps its edit and marks. +Importing never changes the folder. Reorganize on disk, then import again (or **↻**). Edits follow image content, so a moved file keeps its edit and marks. #### Opening, renaming, deleting -**Double-click** a roll (or **Enter**) to open it; NegPy asks first, since loading reads every frame (**Always load without asking** skips the prompt). Opening replaces the Film Strip contents. +**Double-click** a roll (or **Enter**) to open it in the Film Strip. NegPy asks first, since loading reads every frame (**Always load without asking** skips the prompt). Right-click a roll for: -* **Close Roll…** (**loaded** roll only): empties the Film Strip and returns to the Library. Asks first; edits stay saved. -* **Rename…**: renames the roll. A folder roll also offers **Also rename the folder on disk** (off by default). In a cloud-sync folder (Dropbox, iCloud, OneDrive) the sync can treat that rename as a delete and re-upload. -* **Delete…**: forgets the roll only; folder, images and edits stay. **Import Folder as a Roll…** brings it back. **Clear Library** in *Manage Database* forgets all rolls. -* **Roll Analysis** (**loaded** roll only): runs Roll Analysis ([§10.5](#105-roll-analysis)) and stores the roll's baseline. +* **Close Roll…** (loaded roll only): empties the Film Strip. Edits stay saved. +* **Rename…**: renames the roll. A folder roll also offers **Also rename the folder on disk** (off by default). A cloud-sync folder (Dropbox, iCloud, OneDrive) can treat that rename as a delete and upload again. +* **Delete…**: forgets the roll only; folder, images and edits stay. **Clear Library** in *Manage Database* forgets all rolls. +* **Roll Analysis** (loaded roll only): runs Roll Analysis ([§10.5](#105-roll-analysis)). #### Rolls that are not folders -A roll can also be a hand-picked set, and one photo can be in several rolls (folder rolls show a **folder** icon, others a **magnifier**). Search the library or gather frames, then **Save as Roll…**. The set is fixed, not a live search: add frames while it is loaded, or Save as Roll again. +A roll can also be a hand-picked set, and one photo can be in several rolls. Search the library or gather frames, then **Save as Roll…**. The set is fixed: add frames while it is loaded, or Save as Roll again. A frame has one edit in every roll that holds it. On a frame in more than one roll, right-click → **Edit Independently in This Roll** gives it its own edit here; **Use the Shared Edit Again…** deletes that edit. ### Importing and managing files -**Nikon High Efficiency raw**: Z 8 and Z 9 NEFs in **HE** or **HE\*** use a licensed codec NegPy cannot decode. Shoot **Lossless Compressed**, or convert with Adobe DNG Converter. +**Nikon High Efficiency raw**: Z 8 and Z 9 NEFs in **HE** or **HE\*** use a licensed codec that NegPy cannot decode. Shoot **Lossless Compressed**, or convert with Adobe DNG Converter. -The **⋮** menu on the Film Strip header, beside its ⓘ guide: +The **⋮** menu on the Film Strip header: -* **New Roll…**: clears the film strip so you can drag in frames and keep them with **Save as Roll…**. -* **Close Roll…**: empties the film strip and returns to the Library; **Unload All…** when the frames are not a roll. Asks first; edits stay saved. -* **Reset Roll to Defaults…**: **Reset Settings** on every visible frame. Asks first; each reset is an undo step. +* **New Roll…**: clears the film strip, to drag in frames and keep them with **Save as Roll…**. +* **Close Roll…** (**Unload All…** when the frames are not a roll): empties the film strip; edits stay saved. +* **Reset Roll to Defaults…**: **Reset Settings** on every visible frame, each an undo step. -The grid button on the same header opens the **Light Table** (`Shift+G`): the roll as a full-window grid for culling, with the same selection, marks and menus. Double-click or **Enter** opens a frame; **Esc** or `Shift+G` goes back. It does not open while the strip shows no frames. +The grid button on the same header opens the **Light Table** (`Shift+G`): the roll as a full-window grid for culling, with the same selection, marks and menus. **Esc** or `Shift+G` goes back. The Film Strip button row: -* **Add** (import icon): **Add Files…** or **Add Folder…** (or drop a folder on the window). A folder with no images of its own imports its subfolders as rolls. +* **Add**: **Add Files…** or **Add Folder…** (or drop a folder on the window). A folder with no images of its own imports its subfolders as rolls. * **Hot Folder**: loads new files as they appear in the current folder, for a scanner or tethering app. * **Trichrome Mode** and **Half Frame Mode** are on the Roll tab's Frame Assembly card ([§10.2](#102-frame-assembly)). -* **Apply (clone)**: copies the current frame's settings, aspects chosen in a dialog, to selected frames or the whole roll. Crop and rotation stay per-image. -* **Roll Settings** (tag icon): writes gear, capture, place, process and scanning metadata to the frame, a selection or the whole roll. Fields start from the active frame; **Load** fills them from a metadata preset; tick the groups to write. Empty Analog Gear is matched from the folder name, as at import. -* **Save as Roll…** (red folder icon): keeps the loaded frames as a roll. See [Rolls that are not folders](#rolls-that-are-not-folders). -* **Unload…**: drops the active frame or selection. For the whole roll, use **Close Roll…**. -* **Show Scenes** (layers icon): edges each frame in its scene's color. See [Scenes](#scenes). -* **Sort** (arrows): Name or Date, or **Scene** once the loaded roll has one ([Scenes](#scenes)), ascending or descending. -* **Sheet filter** (funnel): *All Frames*, *Keepers Only*, *Hide Rejected* or *Unmarked Only*. **Advance After Marking** in the same menu moves to the next frame after a Keeper or Reject mark. `Home` and `End` jump to the first and last frame. - -Above both sections sit the **filter box**, a **`.*`** regex toggle, a **search-library** button and, once enabled in Preferences, a **search-by-meaning** toggle. The Film Strip has a **tally** ("Ektacolor Pro 400 — 36 frames · 12 keepers · 3 rejected", or **Collection** for frames that are not one roll) and a **thumbnail size** slider. With a filter active the tally names it; if it hides everything, **Show all frames** clears it. +* **Apply (clone)**: copies the chosen aspects of the current frame's settings to selected frames or the whole roll. Crop and rotation stay per image. +* **Roll Settings**: writes gear, capture, place, process and scanning metadata to the frame, a selection or the whole roll. **Load** fills the fields from a metadata preset; tick the groups to write. An empty Analog Gear is matched from the folder name. +* **Save as Roll…**: keeps the loaded frames as a roll ([Rolls that are not folders](#rolls-that-are-not-folders)). +* **Unload…**: drops the active frame or selection. +* **Show Scenes**: edges each frame in its scene's color ([Scenes](#scenes)). +* **Sort**: Name or Date, or **Scene** once the loaded roll has one, ascending or descending. +* **Sheet filter**: *All Frames*, *Keepers Only*, *Hide Rejected* or *Unmarked Only*. **Advance After Marking** in the same menu moves on after a Keeper or Reject mark. `Home` and `End` jump to the first and last frame. -Right-click empty space for **Add Files**, **Add Folder** and **Close Roll…**. Buttons that do not fit move into a **»** menu. +Above both sections are the **filter box**, a **`.*`** regex toggle, a **search-library** button and, once enabled in Preferences, a **search-by-meaning** toggle. The Film Strip has a **tally** of frames, keepers and rejects, and a **thumbnail size** slider. If a filter hides everything, **Show all frames** clears it. #### Filtering the sheet @@ -196,61 +192,61 @@ A plain word matches the filename. `field:value` terms match frame data: | `camera:"Nikon F3"` | quote anything with a space | | `iso:>=400` | numeric fields also take `>`, `>=`, `<`, `<=` (`iso`, `frame`, `push`, `devtime`, `temp`) | | `date:2024-03` · `date:>=2024` | by file date; a partial date is a prefix | -| `shot:1998` · `shot:>=1998-07` | by capture date from the Metadata panel, not the file date | +| `shot:1998` · `shot:>=1998-07` | by capture date from the Metadata panel | | `place:tokyo` | by capture city, state or country | -| `devtime:>=9` · `temp:20` | development time in minutes, and temperature in °C | +| `devtime:>=9` · `temp:20` | development time in minutes, temperature in °C | | `roll:` `developer:` `dilution:` `lens:` `format:` `scanning:` | the rest of the Metadata panel | | `name:` `path:` `ext:tif` | file identity | | `scene:beach` | frames in a scene of the loaded roll whose name contains "beach" | -| `keeper:` `rejected:` `edited:` | frames carrying that mark, or with a saved edit | +| `keeper:` `rejected:` `edited:` | frames with that mark, or with a saved edit | | `-rejected:` `-film:velvia` | a leading `-` negates any term | Terms combine with AND (`film:portra iso:>=400 -rejected:`). Metadata fields match only once filled in the **Metadata** panel. **`.*`** makes the box a plain filename regex. #### Searching by meaning -**Search by meaning** (Preferences → Performance) finds photos by content; it downloads a small model the first time. Turn on its toggle beside the filter box and describe the picture ("a photo of a dog") to narrow loaded frames to the clear matches, best first. Without an index it ranks only frames loaded this session. +**Search by meaning** (Preferences → Performance) finds photos by content; it downloads a small model the first time. Turn on its toggle beside the filter box and describe the picture ("a photo of a dog"). Without an index it ranks only frames loaded this session. #### Searching the whole library -The **magnifier-over-folder** button (or **Enter** in the box) runs the search across the whole library and loads the results; with **Search by meaning** on, across every indexed file. Keyword search reads only stored edits, so metadata matches only frames you filled in. +The **search-library** button (or **Enter** in the box) searches the whole library and loads the results; with **Search by meaning** on, every indexed file. Keyword search reads only stored edits, so metadata matches only frames you filled in. #### Indexing the library -With Search by meaning on, a **database** button beside the Library refresh button indexes every photo in your library for it. It runs only when you ask and can be aborted; the next run does only the rest. +With Search by meaning on, a **database** button beside the Library refresh button indexes every photo in your library. It runs only when you ask; an aborted run resumes where it stopped. #### Stitching a frame from several shots -For a negative captured in overlapping pieces, select them and right-click → **Stitch Selected Frames**. NegPy aligns the overlap, matches brightness, cuts a seam and shows one composite named *a+b (Stitch)*, kept across launches. **Unstitch** restores the parts with their edits. **Merge Frame to TIFF Negative…** ([§10.2](#102-frame-assembly)) replaces the parts with one file and ends the composite. +For a negative captured in overlapping pieces, select them and right-click → **Stitch Selected Frames**. NegPy aligns, matches and blends them into one composite, *a+b (Stitch)*. **Unstitch** restores the parts with their edits. **Merge Frame to TIFF Negative…** ([§10.2](#102-frame-assembly)) replaces the parts with one file. -For Trichrome, turn on **Trichrome Mode** first, then stitch the assembled frames; each part keeps its own three exposures. +For Trichrome, turn on **Trichrome Mode** first, then stitch the assembled frames. #### Merging bracketed exposures (HDR) -For a slide with more range than one exposure: bracket it a stop apart, select the shots and right-click → **Merge Exposures (HDR)**. They become one frame named *a +4 (HDR)*; **Unmerge Exposures** restores them. +For a slide with more range than one exposure, bracket it a stop apart, select the shots and right-click → **Merge Exposures (HDR)**. They become one frame, *a +4 (HDR)*; **Unmerge Exposures** restores them. -The merge is relative to the longest unclipped exposure, the **reference**, which is usually brighter than your metered shot. Choose the render exposure: +The merge is relative to the **reference**: the longest unclipped exposure, usually brighter than your metered shot. Choose the render exposure: -* **Render exposure** (right-click a merged frame): pick a shot, listed in stops from the reference (marked *(as captured)*). Only the reference and shorter exposures are listed. +* **Render exposure** (right-click a merged frame): pick a shot, listed in stops from the reference. Only the reference and shorter exposures are listed. * **Render Exposure** slider (Metering card): 0 EV (the reference) to −4 EV. It and a picked shot replace each other. -* **Bracket middle (auto)** (default): the bracket's middle exposure, right for a bracket centered on the metered shot. +* **Bracket middle (auto)** (default): the bracket's middle exposure, correct for a bracket centered on the metered shot. -A merge opens with **Shadows Density** above zero, from the shadow detail the bracket recovered; set it to zero to match the metered frame. **Reset Settings** restores that value. +A merge opens with **Shadows Density** above zero, from the shadow detail the bracket recovered; set it to zero to match the metered frame. -Bracket both ways, including the shot that already looks right: longer frames (+1, +2, +3 for deep shadows) extend the shadows; shorter ones (−1, −2) add render choices and keep a blown specular. Metered −2 through +3 is six frames; to drop some, drop long ones. +Bracket both ways, including the shot that already looks right. Longer frames (+1 to +3) extend the shadows; shorter ones (−1, −2) add render choices and keep a blown specular. To drop frames, drop long ones. -The merge takes its exposures' film process and exports under the first file's name with `-HDR` (`_DSC1715-HDR.jpg`). +The merge exports under the first file's name with `-HDR` (`_DSC1715-HDR.jpg`). -Merging is for transparencies and shows on Transparency frames only; B&W reversal (Scala, dr5, Fomapan R) is not supported yet. Merged, stitched and Trichrome frames cannot be merged. +Merging shows on Transparency frames only. B&W reversal (Scala, dr5, Fomapan R) is not supported. Merged, stitched and Trichrome frames cannot be merged. ### Triage (culling the roll) -Thumbnails are positives. An unopened frame shows a quick inversion of a reduced preview (a neutral square when the format has none) until you open it. Transparencies are not inverted. Applying settings to other frames re-renders their thumbnails in the background. +Thumbnails are positives. Until you open a frame, it shows a quick inversion of a reduced preview. Transparencies are not inverted. -Right-click a thumbnail, or use shortcuts, to mark frames (multi-selection works; marks persist): +Right-click a thumbnail, or use shortcuts, to mark frames (multi-selection works): * **Keep**: a check badge. -* **Reject**: a cross badge and dimming; batch exports and sidecar writes skip it. The file is never changed. +* **Reject**: a cross badge and dimming. Batch exports and sidecar writes skip it. The file never changes. #### Reading the badges @@ -258,100 +254,86 @@ Right-click a thumbnail, or use shortcuts, to mark frames (multi-selection works |---|---|---| | Bottom-right | check | keeper | | Bottom-right | cross, frame heavily dimmed | rejected | -| Bottom-left | *see below* | the frame was built from more than one file | +| Bottom-left | *see below* | the frame is built from more than one file | | Top-left | exclamation | the file failed to decode; click to retry | | Top-left | small amber dot | the thumbnail is older than the frame's settings; open the frame or use **Update Thumbnails** | -The gray bottom-left glyph shows the frame type: +The bottom-left glyph shows the frame type; its tooltip names it: | Glyph | Frame | |---|---| | Two overlapping panes | a stitched composite ([§Stitching](#stitching-a-frame-from-several-shots)) | | Three stacked bars | a merged bracket ([§Merging](#merging-bracketed-exposures-hdr)) | | Three red/green/blue dots | a Trichrome triplet | -| A split rectangle, one side filled | one half of a half-frame scan; the filled side is which half | +| A split rectangle, one side filled | one half of a half-frame scan | | A split rectangle, both sides filled | a diptych: the whole scan, each half with its own edit | -The tooltip names it (*HDR merge of 5 exposures*). - The right-click menu also has: -* **Copy/Paste Settings**, with or without normalization bounds (untick **Normalization bounds** in the paste picker to keep the frame's own). With several frames selected, Paste writes every one of them. -* **Reset Settings**; with several frames selected, **Reset N Frames…**, confirmed first. -* **Reset to Roll Settings**: **Reset to Roll** on every card that differs from the roll, as one undo step; the rest of the edit stays. +* **Copy/Paste Settings**, with or without normalization bounds (untick **Normalization bounds** in the paste picker to keep the frame's own). Paste writes every selected frame. +* **Reset Settings**; with several frames selected, **Reset N Frames…**. +* **Reset to Roll Settings**: **Reset to Roll** on every card that differs from the roll, as one undo step. * **Apply Settings…**. * **Sync Bounds…**: pushes only this frame's measured bounds (**Tonal span**, **Color balance**) to the selection or roll. -* **Update Thumbnail(s)**: re-renders the selection's thumbnails; **Update Thumbnails** on the toolbar re-renders every stale one in the roll, from this session or an earlier one. Changes a thumbnail cannot show (sharpening, chroma denoise, dust and heal repairs, demosaic, display profile, soft proof) do not make it stale. Both become **Cancel** while running. +* **Update Thumbnail(s)**: re-renders the selection's thumbnails. **Update Thumbnails** on the toolbar re-renders every stale one in the roll. Sharpening, chroma denoise, dust and heal repairs, demosaic, display profile and soft proof do not make a thumbnail stale. * **Reset Roll to Defaults…**, and per-frame export. -* **Edit Independently in This Roll** / **Use the Shared Edit Again**; see [Rolls that are not folders](#rolls-that-are-not-folders). +* **Edit Independently in This Roll** / **Use the Shared Edit Again** ([Rolls that are not folders](#rolls-that-are-not-folders)). #### Scenes A scene is a group of frames in one roll shot in the same light. It has its own baseline, so its frames match each other, not the rest of the roll. Scenes need a roll (**Save as Roll…** first). -* **Scene** (right-click menu): **Group as Scene…** makes the selection a scene; **Add to** *name* and **Remove from Scene** move frames in and out. On a scene's frames, **Analyze Scene…** runs Scene Analysis; **Rename Scene…** and **Delete Scene…** manage it. A frame is in one scene at most; deleting a scene keeps edits and baselines. -* **Sort → Scene**: each scene's frames as a block on a band of its color, frames in no scene last. Grouping a roll's first scene switches to it. +* **Scene** (right-click menu): **Group as Scene…** makes the selection a scene; **Add to** *name* and **Remove from Scene** move frames in and out. On a scene's frames, **Analyze Scene…** runs Scene Analysis; **Rename Scene…** and **Delete Scene…** manage it. A frame is in one scene at most. Deleting a scene keeps edits and baselines. +* **Sort → Scene**: each scene's frames as a block, frames in no scene last. --- ## 3. Analysis readout (always visible) -The readout sits above the tabs and describes the current frame as you edit; drag the divider to resize or collapse it. Top to bottom: +The readout sits above the tabs and describes the current frame as you edit. Top to bottom: #### Photometric curve -The paper characteristic (H&D) curve NegPy prints through: a model of the paper, not a curves editor. Horizontal is **negative density** (scene highlights to the right), vertical is **print tone**. Steeper means more contrast; the flat ends are the toe (shadows) and shoulder (highlights). +The paper characteristic (H&D) curve NegPy prints through: a model of the paper, not a curves editor. Horizontal is **negative density** (scene highlights to the right), vertical is **print tone**. Steeper means more contrast; the flat ends are the toe (shadows) and shoulder (highlights). The crosshair marks the **pivot** that contrast rotates around. -With a **Contrast Mask**, a violet band opens to a dashed edge: large flat areas print on the dashed edge, fine detail on the curve. Spatial tools (dodge/burn, CLAHE) do not show. - -The crosshair marks the **pivot** that contrast rotates around. A faint **ghost** shows the previous curve while you drag. Separate R/G/B traces show cast removal's correction. +With a **Contrast Mask**, a violet band shows where large flat areas print; fine detail prints on the curve. Spatial tools (dodge/burn, CLAHE) do not show. #### The two histograms -Along the bottom, one band holds both: the gray **negative density histogram** (the scan, on the curve's horizontal axis) and the colored **output histogram** (the print, black left to white right). Contrast cannot separate data on the flat toe: move the exposure onto the steep middle. - -Hovering the image draws the pixel's R, G and B print values as dashed lines on the output histogram's scale. +The gray **negative density histogram** shows the scan on the curve's horizontal axis; the colored **output histogram** shows the print, black left to white right. Contrast cannot separate data on the flat toe, so move the exposure onto the steep middle. In Peek Negative the density histogram splits into R, G, B and luminance: a spike at an edge is that channel clipping, a trace apart from the others a strong cast. #### Curve / Waveform / Vectorscope -Under the chart, left: which chart fills the space. **Curve** is the paper curve with its histograms, described above. **Waveform** plots the print's tones across the frame: left to right is the picture's width, height is print tone, and brightness is how many pixels of that column print there, each drawn in the mean color of those pixels. Dashed lines mark zones III, V and VII; a red tick on the bottom or top edge marks a column that reaches paper black or paper white. - -**Vectorscope** plots the print's colors on the CIELAB a\*b\* plane: neutral at the center cross, chroma growing outward, each spot drawn in its own hue and brighter where more pixels sit. Dashed rings mark chroma 5, 20 and 40; rim letters mark the R, Y, G, C, B and M directions. On a color frame, a cluster off the center is a cast. On a toned B&W print, a toner draws one arm toward its hue, and a split tone two. - -Hovering the image rings the pixel under the cursor on both: its column and print tone on the Waveform, its color on the Vectorscope. +The selector under the chart picks the chart. **Curve** is the paper curve and histograms above. **Waveform** plots print tone across the frame's width; a red tick marks a column that reaches paper black or white. **Vectorscope** plots the print's colors on the CIELAB a\*b\* plane, neutral at the center. On a color frame, a cluster off the center is a cast; on a toned B&W print, a toner draws an arm toward its hue. #### Lin / Log -Under the chart, right: the histograms' and the waveform's count scale. **Log** compresses tall peaks so thin tails show (to find clipping); **Lin** shows the bulk of the frame. On the Vectorscope it is the chroma scale instead, remembered separately for B&W and color frames: **Log** (the B&W default) opens up the near-neutral center where a toner sits, **Lin** (the color default) keeps a color frame's spread in proportion. +Sets the count scale of the histograms and the waveform: **Log** shows thin tails (to find clipping), **Lin** the bulk of the frame. On the Vectorscope it sets the chroma scale: **Log** (the B&W default) opens up the near-neutral center where a toner sits, **Lin** (the color default) keeps a color frame's spread in proportion. #### Clipping triangles -R, G and B triangles in the top corners: **top-left** for crushed shadows, **top-right** for blown highlights, once a channel passes 0.5% of the frame. One channel alone is a cast, not an exposure problem. +R, G and B triangles in the top corners show crushed shadows (left) or blown highlights (right) once a channel passes 0.5% of the frame. One channel alone is a cast, not an exposure problem. #### Clipping Overlay -The half-circle icon in the Analysis header marks on the photo every pixel with a channel at paper black or paper white (the same end bins the triangles and the **Clipping** row count), shadows and highlights in the two **Color vision** colors. +The half-circle icon in the Analysis header marks on the photo every pixel with a channel at paper black or paper white. #### Zone shading and zone ticks -The warm wash (left) and cool wash (right) mark the toe and shoulder, where separation is lost; their colors follow **Color vision** in Preferences. The ticks on the left edge are Adams zones I to IX on the print-tone axis, on the same scale as the zone strip; V is the longer one. +The warm and cool washes mark the toe and shoulder, where separation is lost. The ticks on the left edge are Adams zones I to IX. #### Step wedge -A 21-step wedge printed through the current curve, then the alternative process and toning, so it shows the print's own colors. Patches that merge into black or white are lost tones; the brackets mark the usable span, and a Sabattier wedge's span ends where its light tones fold back. +A 21-step wedge printed through the current curve, alternative process and toning. Patches that merge into black or white are lost tones; the brackets mark the usable span. #### Zone strip -Ten cells on the Adams scale (**0** paper black, **V** 18% mid-gray); opacity shows how much of the frame lands in each. The end cells turn **red** when shadows block up or highlights blow. - -Click a cell, then a spot on the photo, to place that tone (see Zone placement); Esc cancels. On a slide or a Positive frame the strip reads **Disabled for Slides/Reversal Film**. - -Double-click the strip to turn the canvas **Zone Overlay** on or off (also `Shift+Z` and **Overlays** on the canvas toolbar): every region of the print is outlined and labeled with its zone. +Ten cells on the Adams scale (**0** paper black, **V** 18% mid-gray); opacity shows how much of the frame lands in each. The end cells turn **red** when shadows block up or highlights blow. Click a cell to start a Zone placement. The strip is disabled on a slide or a Positive frame. -Hover a cell to light up the pixels that print in that zone on the photo, in magenta (a reddish purple or white under the other **Color vision** choices in Preferences), a color clear of foliage and sky. The highlighter icon in the Analysis header turns this on or off. +Double-click the strip, or press `Shift+Z`, to toggle the canvas **Zone Overlay**, which labels every region of the print with its zone. Hovering a cell lights up the pixels that print in that zone; the highlighter icon in the Analysis header toggles this. #### Probe @@ -359,28 +341,26 @@ A spot densitometer: hover the image for per-channel density above film base (Δ #### Zone placement -Works like an enlarging analyzer: click a zone on the strip, then a spot on the photo, and the print is solved so the spot prints on that zone. Up to three pins; each row shows the current zone and its **target**, which − and + trim in thirds. +Works like an enlarging analyzer: click a zone on the strip, then a spot on the photo, and NegPy solves the print so the spot prints on that zone. Up to three pins; − and + trim each pin's **target** in thirds. * One pin solves Print Density. * Two pins (usually shadow and highlight) solve Print Density and ISO-R Grade. -* A third pin adds **Shadows Grade**, **Highlights Grade** or **Snap**, whichever can move that tone; a line under the button names it. +* A third pin adds **Shadows Grade**, **Highlights Grade** or **Snap**, whichever can move that tone. -The result is a preview until **Place zones** (or **Enter**) commits it as one edit, turning off Auto Density (and Auto Grade with two or more pins). **Esc** discards the armed zone, then the pins; **✕** removes one pin. - -Drag a pin to move it; its caption reads `1 · IV⅓ → VI` (now → target) until they agree. A click with no zone armed only pins a reading. An unreachable target shows an amber `→ lands …` with the closest zone the print can make. +The result is a preview until **Place zones** (or **Enter**) commits it as one edit, which turns off Auto Density (and Auto Grade with two or more pins). **Esc** discards the armed zone, then the pins; **✕** removes one pin. Drag a pin to move it. An unreachable target shows the closest zone the print can make. Pins are proofs, not edits: any other edit or a frame change removes them. Zone placement is off on an as-captured slide or a Positive frame. #### Negative stats -Rows that measure the scan, not your edit; hover for details. A row with nothing to measure reads —. +Rows that measure the scan, not your edit; hover for details. -* **Negative**: relative density range (luminance) and development character: flat (≈N−1), normal, contrasty (≈N+1). Comparable across a roll; estimated from the normalized bounds, not a densitometer reading. -* **Exposure**: midtone in stops from neutral, approximate; positive is high-key, negative low-key. +* **Negative**: relative density range and development character: flat (≈N−1), normal, contrasty (≈N+1). Comparable across a roll; an estimate, not a densitometer reading. +* **Exposure**: approximate midtone in stops from neutral; positive is high-key, negative low-key. * **Clipping**: share of pixels crushed to black or blown to white, worst channel. Amber above 1%. -* **Scan clip**: share of source pixels at or above sensor white, per channel. On a negative no edit can recover it: expose the scan lower. Amber above 1%. +* **Scan clip**: share of source pixels at or above sensor white. On a negative no edit recovers it: expose the scan lower. Amber above 1%. * **Repair**: share of the scan rewritten by IR Restore, dust detection and heals. A large value means the threshold is redrawing the picture. Amber above 5%. -* **Gamut**: share of the frame the proof profile cannot print, while proofing to one. Zero is normal. Amber above 2%. +* **Gamut**: share of the frame the proof profile cannot print, while proofing. Amber above 2%. --- @@ -391,24 +371,24 @@ Rows that measure the scan, not your edit; hover for details. A row with nothing **Crop:** -* **Auto** (magic wand on the CROP header): the same Auto Crop as the Crop card's wand ([§10.4](#104-crop)), for this frame. -* **Ratio**: the roll's crop ratio, the same field as on the Crop card; the crop tool snaps to it. **1:1.4142** is the ISO A paper shape (A4, A6). -* **Crop** tool (crop icon on the CROP header): draw a crop rectangle; with **Ratio** at **Free**, an edge midpoint resizes one axis. It opens on the current crop; a manual change turns **Auto Crop** ([§10.4](#104-crop)) off for this frame. Drag past the viewport edge to pan. **Reset** (undo icon on the CROP header) clears the crop and turns auto-crop off. -* **Guide**: *Thirds*, *Phi Grid*, *Diagonals*, *Golden Triangles*, *Golden Spiral*, *Armature*, *Diagonal Method*, *Grid* or *Off*. The redo button rotates guides with orientations (spiral 8, triangles 2). +* **Auto** (magic wand on the CROP header): the Crop card's Auto Crop ([§10.4](#104-crop)), for this frame. +* **Ratio**: the roll's crop ratio, the same field as on the Crop card. **1:1.4142** is the ISO A paper shape (A4, A6). +* **Crop** tool: draw a crop rectangle; with **Ratio** at **Free**, an edge midpoint resizes one axis. A manual crop turns **Auto Crop** off for this frame. **Reset** clears the crop and turns auto-crop off. +* **Guide**: *Thirds*, *Phi Grid*, *Diagonals*, *Golden Triangles*, *Golden Spiral*, *Armature*, *Diagonal Method*, *Grid* or *Off*. The redo button rotates the guide. **Alignment:** -* **Crop by Default** (crop icon, right end of the ALIGNMENT header): crops the wedge Fine Rotation, Tilt and Swing leave, while no manual or auto crop is set. +* **Crop by Default**: crops the wedge that Fine Rotation, Tilt and Swing leave, while no manual or auto crop is set. * **Fine Rotation** (±45°): sub-degree rotation, positive clockwise. Applied after auto-crop. -* **Auto Skew** (square ruler, on the ALIGNMENT header): squares the frame to its film and frame edges, never to the picture, so a deliberately tilted photo stays tilted. It sets **Fine Rotation**, and **Tilt** or **Swing** where both edges of that pair show. A frame it cannot read confidently is left alone. -* **Straighten** tool (ruler, on the ALIGNMENT header): draw a line along a horizon or vertical edge to level or plumb it; drag past the viewport edge to pan. -* **Tilt and swing with reference lines** tool: drag a line along each rebate edge (top, bottom, left, right); once all four are marked, Tilt and Swing solve to make them parallel and square. Re-drag an edge to refine. -* **Tilt** (±15%): tip the easel about a horizontal axis to correct converging verticals. Positive stretches the top edge. The unit is percent of the frame, not an angle. +* **Auto Skew**: squares the frame to its film and frame edges, never to the picture, so a deliberately tilted photo stays tilted. It sets **Fine Rotation**, and **Tilt** or **Swing** where both edges of that pair show. +* **Straighten** tool: draw a line along a horizon or vertical edge to level or plumb it. +* **Tilt and swing with reference lines** tool: drag a line along each rebate edge; with all four marked, Tilt and Swing solve to make them parallel and square. +* **Tilt** (±15%): tips the easel about a horizontal axis to correct converging verticals. Positive stretches the top edge. The unit is percent of the frame, not an angle. * **Swing** (±15%): the same about a vertical axis, for converging horizontals. Positive stretches the left edge. Both leave a wedge along the squeezed edge: crop it or use **Crop by Default**. Crop first: on an uncropped scan a large correction pulls the rebate into the metering and darkens the print. -Scanning-lens distortion, chromatic aberration and flat field are in **Optics** on the Roll tab ([§10.8](#108-optics)). +Lens distortion, chromatic aberration and flat field are in **Optics** on the Roll tab ([§10.8](#108-optics)). --- @@ -419,12 +399,12 @@ Four panels set light, color and contrast in the print stage of the pipeline. ### 5.1 Filtration: white balance -Color timing, like enlarger dichroic filters. The **Global / Shadows / Highlights** selector applies the controls to the whole image or biases them to low- or high-density tones. +Color timing, like enlarger dichroic filters. The **Global / Shadows / Highlights** selector applies the controls to the whole image, or biases them to low- or high-density tones. -* **Pick WB** (eyedropper, right of the region selector): click a pixel that should be neutral gray; NegPy solves the CMY filtration for the selected region. -* **Temperature**: warm-to-cool lever on the magenta/yellow pair; cyan stays put. +* **Pick WB** (eyedropper): click a pixel that should be neutral gray, and NegPy solves the CMY filtration for the selected region. +* **Temperature**: warm-to-cool control on the magenta/yellow pair; cyan does not change. * **Cyan / Magenta / Yellow** (-1 to 1): Cyan↔Red, Magenta↔Green, Yellow↔Blue. -* **Ring-around** (target icon, or `Shift+F`): a 5×5 mosaic in 2cc steps to ±4cc on magenta and yellow, centered on neutral. Click a patch to keep its filtration; `Escape` or a second press clears it. See **Rotating a proof** below. +* **Ring-around** (`Shift+F`): a 5×5 mosaic in 2cc steps to ±4cc on magenta and yellow, centered on neutral. Click a patch to keep its filtration; `Escape` clears it. **Cast Removal** is on the Roll tab's Calibration card ([§10.3](#103-calibration-what-your-rig-does-to-the-colors)). @@ -433,41 +413,39 @@ Color timing, like enlarger dichroic filters. The **Global / Shadows / Highlight **Global / R / G / B** applies Grade and Split Grade to the shared curve, or as per-dye-layer trims for **crossover correction** (casts that differ between shadows and highlights). -**Automatic helpers**, in the **Auto** menu (magic-wand icon) beside the channel selector, on by default: +**Automatic helpers**, in the **Auto** menu, are on by default: * **Auto Density**: meters each frame's midtone and anchors print brightness there. -* **Auto Grade**: sets the grade partly from the frame's textured density range, harder when needed so textured shadows reach black (Shadow Reach), opens the shadows of a contrasty frame (Shadow Hold), and holds textured highlights off paper white (Highlight Hold). +* **Auto Grade**: sets the grade partly from the frame's textured density range, harder when textured shadows need it to reach black (Shadow Reach). It opens the shadows of a contrasty frame (Shadow Hold) and holds textured highlights off paper white (Highlight Hold). * **Auto Density and Grade**: turns both helpers on or off together. -* **Set Targets…** (last item in the Auto menu): the brightness and contrast the helpers aim for, for all frames. -* With a helper on, its sliders show what prints: **Print Density** the metered density, **ISO-R Grade** the grade the frame prints at, **Highlights Density** with the automatic burn. Moving one trims the helper, the tick marks the helper's own choice and a double-click returns to it. Turning a helper off drops its share, so the sliders show your own values; turning it on adds it back. - -**Test strip** (grid icon, or `Shift+T`): a 5×5 grid, Print Density rising left to right, ISO-R Grade softening top to bottom; one patch is your current setting. Click a patch to keep it; `Escape` or a second press clears it. +* **Set Targets…**: the brightness and contrast the helpers aim for, for all frames. +* With a helper on, its sliders show what prints: **Print Density** the metered density, **ISO-R Grade** the grade the frame prints at, **Highlights Density** with the automatic burn. Moving one trims the helper; a double-click returns to the helper's choice. -**Rotating a proof**: while a proof shows, the 90° **rotate** buttons and `[` / `]` turn the ladder, not the image. +**Test strip** (`Shift+T`): a 5×5 grid, Print Density rising left to right, ISO-R Grade softening top to bottom. Click a patch to keep it; `Escape` clears it. While a proof shows, the 90° **rotate** buttons and `[` / `]` turn the ladder, not the image. **White Point** and **Black Point** are on the **Metering** card ([§10.6](#106-metering-negative--positive)). **Exposure:** * **Print Density** (0.0 to 2.0): overall brightness (enlarger time). Lower is brighter. -* **ISO-R Grade** (50 to 180): contrast as paper ISO-R. R110 is about grade 2; **lower R is harder**. In R/G/B mode a **Grade** trim rotates one layer's slope about the midtone. -* **Shadows Density** / **Highlights Density** (±1.0 ΔD): brighten or darken only the shadows or highlights, within paper black and white. With Auto Grade on, each shows its automatic share (Shadow Hold's lift, Highlight Hold's burn). They also work on slides. +* **ISO-R Grade** (50 to 180): contrast as paper ISO-R. R110 is about grade 2; lower R is harder. In R/G/B mode a **Grade** trim rotates one layer's slope about the midtone. +* **Shadows Density** / **Highlights Density** (±1.0 ΔD): brighten or darken only the shadows or highlights, within paper black and white. With Auto Grade on, each shows its automatic share. They also work on slides. * **Shadows Grade** / **Highlights Grade** (split grade, ±50 ISO-R): local contrast in the deep shadows or highlights. -* **Preflash** (0 to 1): an even flash over the sheet, as a fraction of the paper's threshold exposure. It pulls highlight detail off paper white and softens the print slightly; bare paper stays white. Hidden on slides. -* **Diffusion** (0 to 1, hidden in Transparency): a diffuser under the enlarger lens, a Softar or a stocking. The value is how much light it scatters: a stronger diffuser, or more of the exposure given through it. Shadows bleed into highlights and edges soften; flat areas print as before. Grays out in R/G/B mode. -* **Contrast Mask** (±0.5, hidden in Transparency): a blurred mask sandwiched with the negative; the value is its signed gamma. Positive compresses the range by (1 − gamma) so a harder grade fits, keeping fine detail. Negative expands the range by (1 + gamma) for a negative too flat for Grade; past about −0.4, highlights clip. Past about ±0.2, strong edges get a halo. -* **Mask Spacer** (2 to 6%, default 4%): the gap between mask and negative, as percent of the frame. Thicker masks only broad masses; thinner reaches into detail and hazes shadows next to bright areas. Both mask controls gray out in R/G/B mode. +* **Preflash** (0 to 1): an even flash over the sheet, as a fraction of the paper's threshold exposure. It pulls highlight detail off paper white and softens the print slightly. Hidden on slides. +* **Diffusion** (0 to 1, hidden in Transparency): a diffuser under the enlarger lens (a Softar, a stocking). Shadows bleed into highlights and edges soften; flat areas print as before. Grays out in R/G/B mode. +* **Contrast Mask** (±0.5, hidden in Transparency): a blurred mask sandwiched with the negative. Positive compresses the range so a harder grade fits and fine detail stays; negative expands a negative too flat for Grade (past about −0.4, highlights clip). Past about ±0.2, strong edges get a halo. +* **Mask Spacer** (2 to 6%, default 4%): the gap between mask and negative. Thicker masks only broad masses; thinner reaches into detail and hazes shadows next to bright areas. Both mask controls gray out in R/G/B mode. ### 5.3 Paper Response: the print curve -The paper's characteristic curve, drawn faintly behind the card header. Its own **Global / R / G / B** selector applies the curve controls to all layers or as per-dye-layer trims. +The paper's characteristic curve. Its own **Global / R / G / B** selector applies the curve controls to all layers or as per-dye-layer trims. * **Paper**: a bundled paper (RA4 in Color Negative, B&W papers in B&W Negative) that sets the curve; the other controls trim on top. *Neutral* gives the defaults. Each B&W paper has its own lith color (§6.2). -* **Dye Separation** (0.5 to 1.5, hidden in B&W Negative): saturation in density space, through the paper's dyes, so it eases off at toe and shoulder. 1.0 is off; below pulls toward neutral. **Chroma** (Look tab) scales color evenly instead. - * **Separation Damping** (0 to 1): higher keeps the push on muted color and eases it on saturated color; below 1.0 separation, pastels go gray first. Grayed out at Dye Separation 1.0. -* **Paper White** (page icon, right of the channel selector): simulate paper base density, so whites print at about 0.93. -* **Paper Black** (circle icon, right of the channel selector): show the paper's slightly milky Dmax. Off (default) applies black-point compensation. +* **Dye Separation** (0.5 to 1.5, hidden in B&W Negative): saturation through the paper's dyes, easing off at toe and shoulder. 1.0 is off; lower values pull toward neutral. **Chroma** (Look tab) scales color evenly instead. + * **Separation Damping** (0 to 1): higher keeps the push on muted color and eases it on saturated color. Grayed out at Dye Separation 1.0. +* **Paper White**: simulates paper base density, so whites print at about 0.93. +* **Paper Black**: shows the paper's slightly milky Dmax. Off (default) applies black-point compensation. * **Snap** (-0.5 to 0.5): midtone gamma; paper white and black stay put. * **Toe** (-1 to 1) + **Toe Width** (0.1 to 5): shadow roll-off. Positive lifts shadows; negative deepens them and, with Paper Black off, reaches exact black. Width sets how far the knee reaches. * **Shoulder** (-1 to 1) + **Shoulder Width** (0.1 to 5): highlight roll-off. Positive compresses highlights; negative extends them and can clip. @@ -477,30 +455,21 @@ In R/G/B mode these become per-layer trims: **Toe** / **Shoulder** (±1), **Toe ### 5.4 Dodge & Burn: local exposure -Draw masks and lighten or darken only those areas. On a **Slide** the panel grays out, because the slide's transfer curve takes no masks; the frame keeps them. The **MASKS** header shows how many the frame has: - -* **Draw** (Draw Mask, the cut card): click to place vertices; double-click, Enter or click near the start to close; Esc cancels. To edit, select the mask, then drag a vertex, click an edge "+" to add a point, or right-click a vertex to delete it. -* **Oval** (the hole in the card, or a dodging wand): drag out an oval. The center handle moves it; the other two set each axis, so you can stretch and tilt it. -* **Card** (Card Edge, the graduated burn): drag from the full-exposure edge (solid line) to where it fades out (dashed). The gap is the softness, so **Feather does nothing on this shape**. +Draw masks to lighten or darken only those areas. On a **Slide** the panel grays out; the frame keeps its masks. -Handles can go into the gray area outside the frame. A tilted Card Edge usually must start past the corner it burns. +* **Draw** (Draw Mask): click to place vertices; double-click, Enter or a click near the start closes; Esc cancels. To edit, drag a vertex, click an edge "+" to add a point, or right-click a vertex to delete it. +* **Oval**: drag out an oval. The handles move, stretch and tilt it. +* **Card** (Card Edge, the graduated burn): drag from the full-exposure edge (solid line) to where it fades out (dashed). The gap is the softness, so Feather does nothing on this shape. A tilted Card Edge usually must start past the corner it burns. -* **Mask list**: shape icon, Dodge, Burn, Grade or Flash, and values. The shape icon enables or disables the mask; the yin-yang inverts it (it acts outside its shape); the eye toggles the outline (shown only on the Exposure tab); the trash deletes it. -* The canvas tint of the current mask, and of masks that intersect it, hides while you drag **Burn**, **Feather**, **Grade**, **Flash** or a vertex. -* **SELECTED MASK**: the controls below act on the mask selected in the list, and gray out with none selected. -* **Burn** (-2 to 2 stops, default 0): **positive burns** (darker), **negative dodges** (brighter), like Print Density and the Finishing edge burn. +* **Mask list**: each mask's shape, Dodge, Burn, Grade or Flash, and values. Its icons enable, invert (act outside the shape), show the outline of, or delete the mask. +* **SELECTED MASK**: the controls below act on the mask selected in the list. +* **Burn** (-2 to 2 stops, default 0): positive burns (darker), negative dodges (brighter). * **Feather** (0.0 to 0.15): edge softness, as a fraction of the frame's short side. -* **Grade** (-40 to 40 R): the mask's own contrast, in ISO-R points off the frame's Grade, negative harder (burn a sky at −20 R). It pivots on the region's midtone; overlapping grades add, within R50…R180. -* **Flash** (0 to 1, default 0): pre-exposes the paper under the mask, as a fraction of the threshold exposure like the Tone card's Preflash: the region's highlights compress without the density a burn adds (flash a sky through a card). Overlapping flashes add. Off on a tone-limited mask. -* **Tone Limit** (*All*, *Highlights*, *Shadows*) with **Tone Zone** (0 to 10, in thirds, default 6) and **Tone Softness** (⅓ to 3 zones): limits the mask to tones lighter or darker than a print zone, so a sky burn on *Highlights* at VI stops at the skyline. The tint shows the tones it selects. Up to four tone-limited masks per frame. - -**Printing Notes** (Export tab, or **Shift+N**) makes a marked-up work print: each mask outlined with its number and value in stops, and a corner card with the paper, exposure, grade, filtration, curve and dodge/burn list. - -* Burns are hatched, dodges left open. -* Numbers are exposure, not brightness: +1.00 st is `Burn +1`; values snap to ⅓, ½ and ¼ when close. -* A mask with a local **Grade** shows the grade it prints at: `Burn +1 @ R95` (−20 R on an R115 frame), or `Grade @ R95` for a grade-only mask. A tone-limited mask adds its zone: `Burn +1 on ≥VI`. +* **Grade** (-40 to 40 R): the mask's own contrast, in ISO-R points off the frame's Grade, negative harder (burn a sky at −20 R). Overlapping grades add, within R50…R180. +* **Flash** (0 to 1, default 0): pre-exposes the paper under the mask, like the Tone card's Preflash: the region's highlights compress without the density a burn adds. Overlapping flashes add. Off on a tone-limited mask. +* **Tone Limit** (*All*, *Highlights*, *Shadows*) with **Tone Zone** (0 to 10, in thirds, default 6) and **Tone Softness** (⅓ to 3 zones): limits the mask to tones lighter or darker than a print zone, so a sky burn on *Highlights* at VI stops at the skyline. Up to four tone-limited masks per frame. -Hidden outlines stay on the map; disabled masks do not. +**Printing Notes** (Export tab, or **Shift+N**) makes a marked-up work print: each mask outlined with its number and value in stops, and a card with the paper, exposure, grade, filtration, curve and dodge/burn list. Burns are hatched. Numbers are exposure, not brightness: +1.00 st is `Burn +1`, and values snap to ⅓, ½ and ¼ when close. A local **Grade** shows as `Burn +1 @ R95`, a tone limit as `Burn +1 on ≥VI`. Hidden outlines still show; disabled masks do not. --- @@ -513,22 +482,22 @@ What a lab scanner (Frontier or Noritsu) does automatically. **Color** (hidden in B&W Negative): -* **Chroma** (0.0 to 2.0): even color scale after decode; 1.0 is unchanged, 0 grayscale. Out-of-gamut pixels roll off softly, keeping hue. For print-like saturation use **Dye Separation**. -* **Skin Protection** (0.0 to 1.0, default 0.5): caps skin-hued chroma; 0.5 catches excessive chroma, 1.0 leaves skin matte, 0 is off. Red coats, sunsets and brick stay out; wood, tan leather and sand soften with it. +* **Chroma** (0.0 to 2.0): even color scale after decode; 1.0 is unchanged, 0 grayscale. For print-like saturation use **Dye Separation**. +* **Skin Protection** (0.0 to 1.0, default 0.5): caps skin-hued chroma; 1.0 leaves skin matte, 0 is off. Red coats, sunsets and brick stay out; wood, tan leather and sand soften with it. * **Chroma Denoise** (0.0 to 5.0): smooths color noise, mainly in shadows; luminance grain stays. **Detail:** -* **Sharpening** (0.0 to 1.0): amount, on the L (lightness) channel, so no color halos. - * **Method**: *Unsharp Mask* (edge contrast) or *Deconvolution* (Richardson-Lucy, reverses the scanner's blur; set Radius to the blur width). +* **Sharpening** (0.0 to 1.0): amount, on lightness only, so no color halos. + * **Method**: *Unsharp Mask* (edge contrast) or *Deconvolution* (Richardson-Lucy, reverses the scanner's blur). * **Radius** (0.5 to 3.0 px): blur width in full-size pixels; judge it at 100% zoom. For a resized export, add the Export panel's **Sharpening**. - * **Masking** (0.0 to 1.0): limit sharpening to edges to protect sky, skin and grain. -* **CLAHE** (0.0 to 1.0): local contrast without blowing highlights or crushing shadows. Near 1.0 it can look cartoonish. Runs before dust removal. + * **Masking** (0.0 to 1.0): limits sharpening to edges to protect sky, skin and grain. +* **CLAHE** (0.0 to 1.0): local contrast without blown highlights or crushed shadows. Near 1.0 it can look cartoonish. **Effects:** * **Glow** (0.0 to 1.0): lens bloom across all channels. -* **Halation** (0.0 to 1.0): red glow from light scattering back through the film base, highlights only. +* **Halation** (0.0 to 1.0): red glow from light that scatters back through the film base, highlights only. ### 6.2 Alternative Processes @@ -537,47 +506,47 @@ Pick **None / Lith / Cyanotype / Sabattier**; only that process's controls show. #### Lith -Heavily over-exposed lith paper in dilute developer, snatched part-way: creamy warm highlights and an abrupt drop into sooty blacks. The Exposure tab's paper sets the color: *Neutral* and the Ilford papers are almost colorless, Fomatone gives peach and olive. In Toning only Selenium and Gold stay, and act differently (§6.3). +Heavily over-exposed lith paper in dilute developer, snatched part-way: creamy warm highlights and an abrupt drop into sooty blacks. The Exposure tab's paper sets the color: *Neutral* and the Ilford papers are almost colorless, Fomatone gives peach and olive. In Toning only Selenium and Gold stay (§6.3). * **Exposure** (0 to 5 stops, default 2): over-exposure; real lith uses two to four stops. More gives warmer, more colorful highlights and softer gradation. -* **Snatch Point** (0.0 to 1.0, default 0.55): time in the developer. Higher gives deeper, colder blacks and more flat shadow; lower stays high-key and warm with weak blacks. -* **Abruptness** (0.0 to 1.0, default 0.6): how suddenly shadows go black (the developer's hydroquinone-to-alkali ratio). High blocks up the next zone down; low rolls off gently. +* **Snatch Point** (0.0 to 1.0, default 0.55): time in the developer. Higher gives deeper, colder blacks; lower stays high-key and warm with weak blacks. +* **Abruptness** (0.0 to 1.0, default 0.6): how suddenly shadows go black. High blocks up the next zone down; low rolls off gently. #### Cyanotype -UV contact print on iron-salt paper: Prussian blue instead of black, with green highlights from leftover sensitizer. Its short density range clips a normal negative at both ends. Chemical toners gray out (no silver); use Bleach and Tannin. Split toning still works. +UV contact print on iron-salt paper: Prussian blue instead of black, with green highlights. Its short density range clips a normal negative at both ends. Chemical toners gray out; use Bleach and Tannin. Split toning still works. -* **Sensitizer** (Classic or New, default Classic): *Classic (Herschel)*, ammonium ferric citrate, tops out at a light blue with a green highlight stain. *New (Ware)*, ferric oxalate, goes deeper and cleaner. +* **Sensitizer** (Classic or New, default Classic): *Classic (Herschel)* tops out at a light blue with a green highlight stain; *New (Ware)* goes deeper and cleaner. * **Exposure** (-2 to 4 stops, default 0): UV time. More moves more of the scale into blue. -* **Exposure Scale** (0.8 to 2.8 log D, default 1.4): the printable density range, the contrast control; shorter is more contrasty. Traditional cyanotype is about 1.0 to 1.2, Ware's new about 2.4. +* **Exposure Scale** (0.8 to 2.8 log D, default 1.4): the printable density range and so the contrast; shorter is more contrasty. Traditional cyanotype is about 1.0 to 1.2, Ware's new about 2.4. * **Bleach** (0.0 to 0.5, default 0): washing soda; removes blue, highlights first. -* **Tannin** (0.0 to 0.5, default 0): tea, coffee or tannic acid; turns bleached iron brown and a little deeper. Bleach first for full brown; Tannin alone for split blue-brown. +* **Tannin** (0.0 to 0.5, default 0): tea or tannic acid; turns bleached iron brown. Bleach first for full brown; Tannin alone for split blue-brown. #### Sabattier -The print is flashed with white light part-way through development: the light tones reverse and fold back toward gray, the dense ones hold, and a light Mackie line runs along every edge between dark and light. The print stays neutral silver, so every toner works on it. +A white-light flash part-way through development: the light tones reverse and fold back toward gray, the dense ones hold, and a light Mackie line runs along every dark-light edge. Every toner works on it. -* **Strength** (0 to 2, default 1.3): the second exposure's length. Below 1 the light tones flatten toward the fold; above 1 they reverse, the lighter the darker. +* **Strength** (0 to 2, default 1.3): the second exposure's length. Below 1 the light tones flatten toward the fold; above 1 they reverse. * **Re-exposure** (0.1 to 0.9, default 0.45): where the fold sits, as a fraction of the paper's Dmax. Tones denser than it hold. -* **Agitation** (0 to 1, default 0.7): rocking the tray after the flash. In a still bath (0) the shadows' bromide creeps into the light tones and draws wide Mackie lines; constant agitation (1) washes it away and draws none. +* **Agitation** (0 to 1, default 0.7): rocking the tray after the flash. A still bath (0) draws wide Mackie lines; constant agitation (1) draws none. --- ### 6.3 Toning -Chemical toners (B&W Negative only) and a split tint (any mode). On a lith print toners bite harder: only Selenium and Gold stay enabled. With Cyanotype all six gray out. +Chemical toners (B&W Negative only) and a split tint (any mode). On a lith print only Selenium and Gold stay enabled, and they bite harder. With Cyanotype all six gray out. -**Chemical Toning**, sequential baths in the order shown, each 0.0 to 2.0: +**Chemical Toning**: sequential baths in the order shown, each 0.0 to 2.0. -* **Selenium**: deeper blacks, cool eggplant shadows. On lith: further down the scale, strong Dmax lift, green-black shadows to magenta. -* **Sepia**: warms highlights first, and more strength reaches further into the mids; the deepest shadows stay black at any strength. -* **Gold**: blue-black on untoned silver; over sepia, orange-red highlights. On lith: all densities evenly, toward blue-violet. +* **Selenium**: deeper blacks, cool eggplant shadows. On lith: green-black shadows to magenta, with a strong Dmax lift. +* **Sepia**: warms highlights first, reaching further into the mids with strength; the deepest shadows stay black. +* **Gold**: blue-black on untoned silver; over sepia, orange-red highlights. On lith: all densities toward blue-violet. * **Iron Blue**: Prussian-blue shadows to navy blacks. * **Copper**: pink to brick-red, with the classic Dmax loss. * **Vanadium**: greens mids and highlights; deep shadows stay black. -**Split Toning** (all modes), an additive Lab tint that keeps grain and detail: +**Split Toning** (all modes): an additive Lab tint that keeps grain and detail. * **Shadow Strength** (0.0 to 1.0), with **Shadow Hue** (0 to 360°) under it. * **Highlight Strength** (0.0 to 1.0), with **Highlight Hue** (0 to 360°) under it. @@ -591,37 +560,39 @@ Chemical toners (B&W Negative only) and a split tint (any mode). On a lith print Spotting, as with a brush on a finished print. Marks are found by local contrast, by the scanner's IR channel or by hand, and the three stack. Each mark is rebuilt from the clean film around it, grain included. -**Overlay** (Off / Marked / IR, at the top): shows detections, green for Optical Removal, magenta for IR (**Color vision** in Preferences picks other colors). IR needs an IR plane. +**Overlay** (Off / Marked / IR): shows detections. IR needs an IR plane. -**Optical Removal** finds specks and hairs on the visible scan, with no IR needed: +**Optical Removal** finds specks and hairs on the visible scan, without IR: -* **Spot Threshold** (0.01 to 1.0): the bar for specks, measured against the film's grain. Lower catches more, with more false positives; 1.0 turns speck detection off. Dust in busy detail may need a lower value, IR or Heal. -* **Hair Threshold** (0.01 to 1.0): the same for hairs and long thin marks; it rises along tonal edges, so a bright rim is not taken for a hair. Lower it if a hair across busy detail is missed; 1.0 turns hair detection off. -* **Size** (2 to 8 px): max spot radius. A mark covers the whole speck or hair. -* To protect detail, right-drag on the canvas to paint an exclusion band (Brush Size wide, amber with the overlay on), or right-click → **Exclude from Optical Removal** for one spot. Toggling **Optical Removal** clears every band. -* The cursor button beside **Optical Removal** makes a plain right-click exclude the spot, with no menu; the canvas menu is then out of reach while removal is on. +* **Spot Threshold** (0.01 to 1.0): the bar for specks, against the film's grain. Lower catches more, with more false positives; 1.0 turns speck detection off. +* **Hair Threshold** (0.01 to 1.0): the same for hairs and long thin marks. Lower it if a hair across busy detail is missed; 1.0 turns hair detection off. +* **Size** (2 to 8 px): max spot radius. +* To protect detail, right-drag on the canvas to paint an exclusion band, or right-click → **Exclude from Optical Removal** for one spot. Toggling **Optical Removal** clears every band. +* The cursor button beside **Optical Removal** makes a plain right-click exclude the spot; the canvas menu is then out of reach while removal is on. -**IR Removal** uses the scanner's infrared channel; it is enabled only when the scan has one. +**IR Removal** uses the scanner's infrared channel, and is enabled only when the scan has one. * **IR Threshold** (0.05 to 0.95): lower catches more. -* **Method** (beside **IR Removal**): how the film under a defect is rebuilt. +* **Method**: how the film under a defect is rebuilt. * **NegPy** (default): divides semi-transparent dust back out and fills opaque cores from the clean film around them. - * **OpenICE**: keeps picture detail under a speck and gives solid defects Digital ICE's synthetic grain; clean film stays untouched. Better on fine detail but less proven across scanners: compare both on a frame you know. -* IR is read from 4-channel TIFFs and DNGs (VueScan, NegPy's scans), SilverFast iSRD TIFFs, 64-bit **HDRi RAW DNGs** (plain HDR has no IR) and `_IR.tif` sidecars. B&W and Kodachrome frames are skipped, since they block infrared like dust. + * **OpenICE**: keeps picture detail under a speck and gives solid defects synthetic grain. Better on fine detail but less proven across scanners: compare both on a frame you know. +* IR is read from 4-channel TIFFs and DNGs (VueScan, NegPy's scans), SilverFast iSRD TIFFs, 64-bit **HDRi RAW DNGs** and `_IR.tif` sidecars. B&W and Kodachrome frames are skipped, as they block infrared like dust. -**Manual Heal** (the header shows the spot count). The brush marks a search area, not a stamp: only pixels that stand out from the film around them are rewritten, so paint generously. +**Manual Heal**. The brush marks a search area, not a stamp: only pixels that stand out from the film around them are rewritten, so paint generously. * **Heal**: click dust spots to paint them out, or drag over a run of them. -* **Scratch**: click points along a scratch or hair, then double-click or press Enter. Esc cancels, Backspace removes the last point. Right-click an overlay to delete it. -* **Line** (Transport Line): for long straight transport scratches across the frame. Click once anywhere on the scratch to trace and repair the whole line; hovering shows the band it would repair. Right-click a placed line to delete it. -* **Line Sensitivity** (0.05 to 0.95, live with the Line tool): lower catches fainter lines with a wider band; raise it if a line picks up film on either side. Applies to placed lines too. -* **Brush Size** (2 to 64 px): diameter of the heal, scratch and exclusion brushes, for new strokes. It stays the same from frame to frame; strokes already made keep their own size. Hold `Alt` and scroll on the canvas, or pinch, while a brush is live. -* **Undo Last** / **Clear All** (undo and bin icons, on the MANUAL HEAL header): remove the last or all manual heals and traced lines; auto-detected dust is unaffected. +* **Scratch**: click points along a scratch or hair, then double-click or press Enter. Esc cancels, Backspace removes the last point. +* **Line** (Transport Line): for long straight transport scratches. Click once on the scratch to trace and repair the whole line. +* **Line Sensitivity** (0.05 to 0.95): lower catches fainter lines with a wider band; raise it if a line picks up film on either side. Applies to placed lines too. +* **Brush Size** (2 to 64 px): diameter of the heal, scratch and exclusion brushes, for new strokes. `Alt`+scroll on the canvas or pinch sets it. +* **Undo Last** / **Clear All**: remove the last or all manual heals and traced lines; auto-detected dust stays. -**Clone** (the header shows the stroke count): copies film from another area over a defect the heal cannot rebuild. +Right-click a stroke, scratch or line on the canvas to delete it. -* **Clone**: the first click picks the area to copy from; then click or paint over the defect. The source follows the brush at a fixed offset, shown as a dashed circle. Uses **Brush Size**. Right-click a stroke to delete it. -* **Set Source**: the next click picks a new area to copy from. `Alt`-click with the Clone tool, or **Pick New Source** in the right-click menu, does the same. +**Clone** copies film from another area over a defect the heal cannot rebuild. + +* **Clone**: the first click picks the area to copy from; then click or paint over the defect. The source follows the brush at a fixed offset. Uses **Brush Size**. +* **Set Source**: the next click picks a new area to copy from (or `Alt`-click with the Clone tool). * **Match Tone** (default on): keeps the source's grain but takes the destination's brightness and color. * **Strength** (0 to 100%, default 100%): how much of the source covers the destination. * **Feather** (0 to 100%, default 50%): the soft edge, as a share of the brush radius; 0 is hard. @@ -630,7 +601,7 @@ Spotting, as with a brush on a finished print. Marks are found by local contrast ### 7.2 Finishing: vignette, carrier, border -How the print is presented, at the end of the pipeline. The crop, analysis-region and tilt/swing tools show the frame without carrier and border. +How the print is presented, at the end of the pipeline. **Vignette** (printer's edge burn): @@ -638,26 +609,24 @@ How the print is presented, at the end of the pipeline. The crop, analysis-regio * **Size** (0.0 to 1.0): falloff radius, from tight in the corners to spread into the frame. * **Roundness** (0.0 to 1.0): 0 is radial (lens-like), 1 is a rectangular card burn along the print edges. -**Filed Carrier**: the clear rebate of a filed-out carrier prints black, framed by unexposed paper, through the frame's own curve and filtration. The film sits off center, so the top and left rebates print wider. +**Filed Carrier**: the clear rebate of a filed-out carrier prints black, framed by unexposed paper, through the frame's own curve and filtration. * **Width** (0.0 to 5.0 mm): black frame thickness. 0 is off. * **Roughness** (0.0 to 1.0): how raggedly the outer edge was filed. * **Flare** (0.0 to 1.0): light off the filed bevel fogs the paper just outside the edge. 0 is off. * **Corners** (0.0 to 1.0): how far the filed corners round off. -The paper margin takes the border color. - **Border:** * **Width** (0.0 to 2.5): thickness as a fraction of the image. 0 is no border. * **Bottom Weight** (1.0 to 2.0): thickens the bottom, for window-mat proportions. -* **Color** (*Paper White* or *Custom*): *Paper White* tints the mat with the toned paper white; *Custom* uses the color from the swatch button beside it. +* **Color** (*Paper White* or *Custom*): *Paper White* tints the mat with the toned paper white; *Custom* uses the swatch color. --- ## 8. Favorites tab -The sliders you use most, in one place. Empty until you fill it. +The sliders you use most, in one place; empty until you fill it. * **Edit Favorites**: tick sliders on the left, drag them into order on the right, then press **Apply**. * They are the same controls as in their home panels; a favorite hides when its original does. @@ -667,7 +636,7 @@ The sliders you use most, in one place. Empty until you fill it. Saves and recalls edit settings by name. -* **Apply** (or double-click a preset): apply the selected preset to the current image. +* **Apply** (or double-click a preset): applies the selected preset to the current image. * **Save…**: pick which of the current settings to store as a new preset. * **Pen** and **Trash**: edit or delete the selected preset. @@ -681,15 +650,15 @@ Two lists: the versions you chose to keep, above the record of every change. A **work print** is a named version of this frame, like the test prints kept on the way to the final one. -* **Save Work Print** (save icon on the WORK PRINTS header, or **Ctrl+Shift+S**) keeps the current edit under a name. Saving over a name asks first. +* **Save Work Print** (**Ctrl+Shift+S**) keeps the current edit under a name. * **Click** one to make it live. That is an edit, so **Ctrl+Z** restores the previous state. -* **Right-click** for **Export This Version…**, **Rename…** or **Delete**. Delete asks first. +* **Right-click** for **Export This Version…**, **Rename…** or **Delete**. -Work prints are never pruned, unlike the undo history. They belong to the frame and live in the database, not in `.negpy` sidecars. +Work prints are never pruned. They live in the database, not in `.negpy` sidecars. ### Edit history -Every edit step, the last 100 kept, newest on top. The current step is bold. +Every edit step, newest on top; NegPy keeps the last 100. * **Click** a step to jump to that state. * **Right-click** → **Export This Version…** to export a past state. @@ -698,52 +667,52 @@ Every edit step, the last 100 kept, newest on top. The current step is bold. ## 10. Roll tab -Every card here is shared by the roll, with the scope pair of [§1](#frame-or-roll-the-scope-pair): an edit makes the card **Frame** until it matches the roll again, and a push to the roll leaves frames that locked the card alone. The line above the cards names every card this frame overrides. +Every card here is shared by the roll, with the scope pair of [§1](#frame-or-roll-the-scope-pair): an edit makes the card **Frame** until it matches the roll again, and a push to the roll skips frames that locked the card. The line above the cards names every card this frame overrides. ### 10.1 Film Mode -Decides which cards apply: **Color** (C-41 negative), **B&W** (negative) or **Slide** (transparency, E-6 and similar). The wand button auto-detects the mode when a file loads. +Decides which cards apply: **Color** (C-41 negative), **B&W** (negative) or **Slide** (transparency, E-6 and similar). The wand button detects the mode when a file loads. -**Positive** (default off, **Slide** only): for a source that is already a positive (a scanned print, another app's export). NegPy reads its embedded profile (sRGB if none) and skips metering and inversion, so the tone controls shape the image directly. Leaving Slide turns it off. +**Positive** (default off, **Slide** only): for a source that is already a positive (a scanned print, another app's export). NegPy reads its embedded profile (sRGB if none) and skips metering and inversion, so the tone controls shape the image directly. ### 10.2 Frame Assembly -How the files become frames. Neither toggle has a scope pair. +How the files become frames. Neither toggle has a scope pair; each roll remembers its own state. #### Trichrome -* **Trichrome Mode** (three-exposure narrowband capture): assembles each frame from a red, green and blue exposure, grouped by capture time (else filename order), so shoot each frame's three back to back. Shots that are not one of each color of the same frame stay separate, for pairing by hand. An assembled frame has the three-dot badge ([Triage](#triage-culling-the-roll)). -* **Edit Triplet…** (pen icon): the **Edit RGB Triplet…** dialog for the current frame. **Align channels (sub-pixel)** registers green and blue to red, removing color fringes. -* **Merge Frame to TIFF Negative…** / **Merge Selected to TIFF Negative…** (right-click): replaces each triplet or stitch with one 16-bit linear TIFF negative beside its first source (`_RGB.tif`, `_STITCH.tif`). It renders the same and keeps the edit, marks and scene. A stitch also bakes in flat field and sensor correction and cannot be unstitched. **Move each merged frame's source files to the Trash** (on by default) trashes the sources once the file is verified. A frame already merged is left alone (delete its negative to merge again); brackets, slides and LinearRaw DNG sources are skipped. +* **Trichrome Mode** (three-exposure narrowband capture): assembles each frame from a red, green and blue exposure, grouped by capture time (else filename order), so shoot each frame's three back to back. Shots that do not group stay separate, for pairing by hand. +* **Edit Triplet…**: the **Edit RGB Triplet…** dialog for the current frame. **Align channels (sub-pixel)** registers green and blue to red, removing color fringes. +* **Merge Frame to TIFF Negative…** / **Merge Selected to TIFF Negative…** (right-click): replaces each triplet or stitch with one 16-bit linear TIFF negative beside its first source (`_RGB.tif`, `_STITCH.tif`). It renders the same and keeps the edit, marks and scene. A merged stitch has flat field and sensor correction baked in and cannot be unstitched. **Move each merged frame's source files to the Trash** (on by default) trashes the sources once the file is verified. Brackets, slides and LinearRaw DNG sources are skipped. -The line under the buttons names the two exposures the frame is assembled from. Each roll remembers its own Trichrome Mode; a new roll, or a batch that is not one roll, takes the mode last chosen. +A new roll, or a batch that is not one roll, takes the Trichrome Mode last chosen. #### Half Frame -**Half Frame Mode** splits each scan into two frames, for half-frame cameras; each half is edited and metered on its own. Turning it on detects the gutter, its direction and the film crop on every loaded scan. Each roll remembers its own state; it is disabled for a batch that is not one roll. +**Half Frame Mode** splits each scan into two frames, for half-frame cameras; each half is edited and metered on its own. Turning it on detects the gutter, its direction and the film crop on every loaded scan. A batch that is not one roll cannot use it. -* **Adjust…**: drag the green box to crop and the orange line to set the split; pick **Split direction** (*Vertical* cuts left/right, *Horizontal* top/bottom) and **Cut thickness** (the separator band to discard). **Auto-detect** re-finds all three. **Apply**'s ▾ applies to the current, selected or all frames (all sets the roll default). -* **Detect All**: re-runs the batch detection. -* **Unsplit** (enabled on a split frame): reverts it, as does its right-click item. +* **Adjust…**: drag the green box to crop and the orange line to set the split; pick **Split direction** (*Vertical* or *Horizontal*) and **Cut thickness** (the separator band to discard). **Auto-detect** finds all three again. **Apply** applies to the current, selected or all frames (all sets the roll default). +* **Detect All**: reruns the batch detection. +* **Unsplit** (on a split frame): makes it one plain frame and deletes both halves' edits. -Right-click a half for **Adjust Split for This Frame…** and, with an override, **Reset Split to Roll Default**. Heals and masks stay anchored to the film through a recrop or resplit. +Right-click a half for **Adjust Split for This Frame…** and **Reset Split to Roll Default**. Heals and masks stay anchored to the film through a resplit. -Turning Half Frame off keeps edited halves: the scan becomes a **diptych**, both halves side by side with their own edits (one edited half is used for both). A diptych exports as `-DIPTYCH`, a long-edge size applying to each half; turn Half Frame on to edit it. **Unsplit** makes it one plain frame and deletes both halves' edits. +Turning Half Frame off keeps edited halves as a **diptych**: both halves side by side with their own edits. A diptych exports as `-DIPTYCH`, a long-edge size applying to each half; turn Half Frame on to edit it. -Half Frame never splits a frame assembled from several files (a Trichrome triplet, a stitch, an HDR merge). +Half Frame never splits a frame assembled from several files. ### 10.3 Calibration: what your rig does to the colors -These controls correct the capture, not the look: sensor filters, film dyes and the light source each have their own control. +These controls correct the capture, not the look. **Capture**: -* **Scanning setup** (bulb icon on the CAPTURE header): a wizard (*how do you scan?*, *what light source?*) that sets Linear RAW and Narrowband. It runs once after the first-launch tour; reopen it when your rig changes. +* **Scanning setup**: a wizard (*how do you scan?*, *what light source?*) that sets Linear RAW and Narrowband. It runs once after the first-launch tour; open it again when your rig changes. * **Linear RAW** (default off): decodes RAW with neutral multipliers; off uses the as-shot white balance. Locked on for a Trichrome triplet. -* **Narrowband**: corrects the oversaturation of narrowband (RGB-LED) capture with a bundled input profile; leave it off for broadband light. An Input ICC in Export overrides it. Grayed out on Transparency. +* **Narrowband**: corrects the oversaturation of narrowband (RGB-LED) capture with a bundled input profile; leave it off for broadband light. An Input ICC in Export overrides it. What the wizard sets: @@ -756,68 +725,68 @@ What the wizard sets: Applying it sets the defaults for new files and rewrites every edited frame in the session (Ctrl+Z per frame). -**Single-Shot Narrowband Calibration**: for single-shot camera scans under narrowband light, where each color leaks into the others through the sensor's filters. +**Single-Shot Narrowband Calibration**: for single-shot camera scans under narrowband light, where each color leaks into the others through the sensor's filters. Needs **Linear RAW**; grayed out on a Trichrome triplet. **Re-run Roll Analysis** after changing it. * **Profile**: the sensor matrix. Custom `.toml` matrices go in `/NegPy/sensor/`. -* **Method**: how the matrix is applied. *Two-Scale* (default) is *Linear* wherever the calibration can be trusted; where a color is mostly leak, as in neon or deep blue, it stops the speckle and keeps the grain at the film's own. *Linear* subtracts the leak exactly and prints those colors as speckled, fully saturated color. *Density* applies the matrix to densities: no speckle, but strong colors come out slightly less vivid. Two-Scale and Density read the film base color from the frame; a scan clipped there uses Linear. -* **Calibrate** (vials icon on the header): build a profile from three bare-light R/G/B exposures. Pick them as files, or press **Capture from Camera…** to shoot and measure them with a tethered camera and a Scanlight, with no film in the holder; it asks before the first exposure, and the button is grayed out until both are connected. A Single Capture Scanlight preset can save one during its own calibration. +* **Method**: *Two-Scale* (default) subtracts the leak like *Linear* where the calibration can be trusted, and stops the speckle where a color is mostly leak (neon, deep blue). *Linear* subtracts the leak exactly and prints those colors speckled and fully saturated. *Density* has no speckle, but strong colors are slightly less vivid. A scan with a clipped film base uses Linear. +* **Calibrate**: builds a profile from three bare-light R/G/B exposures. Pick them as files, or press **Capture from Camera…** to shoot them with a tethered camera and a Scanlight, no film in the holder. A Single Capture Scanlight preset can save a profile during its own calibration. -Needs **Linear RAW**; grayed out on Transparency and on a Trichrome triplet. **Re-run Roll Analysis** after changing it. CAMERA_SCANNING.md has a workflow for each way to build and assign a profile. +CAMERA_SCANNING.md has a workflow for each way to build and assign a profile. -**Crosstalk** (hidden in B&W Negative): a channel unmix on the densities before inversion. Dyes, light and sensor all mix the channels, so a matrix describes your whole scanning setup. +**Crosstalk** (hidden in B&W Negative): a channel unmix on the densities before inversion. Dyes, light and sensor all mix the channels, so a matrix describes your whole scanning setup. **Re-run Roll Analysis** after changing it. -* **Matrix**: matrices for the current film process, grouped by source. *Generic C41* is built in; custom `.toml` matrices go in `/NegPy/crosstalk/` (see [CROSSTALK.md](CROSSTALK.md)). The slider button opens the editor, with **Type** and **Process** (where the matrix applies); **+** makes one for the current process. -* **Strength** (0.0 to 1.0): how much unmix to apply. **Re-run Roll Analysis** after changing it. +* **Matrix**: matrices for the current film process, grouped by source. *Generic C41* is built in; custom `.toml` matrices go in `/NegPy/crosstalk/` (see [CROSSTALK.md](CROSSTALK.md)). The slider button opens the editor; **+** makes one for the current process. +* **Strength** (0.0 to 1.0): how much unmix to apply. -> The bundled film matrices, marked *(approx)*, come from spec sheets and describe the dyes alone. They fully correct only a capture that reads each dye cleanly (a Narrowband Scanner, Trichrome, or a calibrated single-shot narrowband rig); for broadband light and a Bayer sensor they are a starting point. +> The bundled film matrices, marked *(approx)*, come from spec sheets and describe the dyes alone. They fully correct only a capture that reads each dye cleanly (a Narrowband Scanner, Trichrome, a calibrated single-shot narrowband rig); for broadband light and a Bayer sensor they are a starting point. -> No Transparency matrix ships. On slides, Matrix and Strength stay disabled until you make one with **+** or add a `.toml` with `process = "Transparency"`. Unmixing moves a slide away from its own look: treat it as a color-separation control. +> No Transparency matrix ships. On slides, Matrix and Strength stay disabled until you make one with **+** or add a `.toml` with `process = "Transparency"`. Treat unmixing on a slide as a color-separation control. To tune a matrix, adjust its six off-diagonal terms in the editor and save it as your own, named after the *combination* ("Gold 200 + Spectracolor"). Working profiles are welcome [upstream](CROSSTALK.md#contributing-a-matrix). -**Dye balance: Cast Removal** (0.0 to 1.0, color only): balances each layer against the frame's own grays so neutrals stay neutral from shadows to highlights. On Color Negative it removes the orange mask and starts at 1.0; on Transparency it starts at 0, for a faded slide's crossover. Hidden for B&W Negative. +**Dye balance: Cast Removal** (0.0 to 1.0, color only): balances each layer against the frame's own grays, so neutrals stay neutral from shadows to highlights. On Color Negative it removes the orange mask and starts at 1.0; on Transparency it starts at 0, for a faded slide's crossover. **Light source:** -* **Hue Trim** (-30° to 30°, default 0): rotates every hue by a fixed angle, for narrowband or odd-phosphor lights that turn yellows orange and greens olive; white balance cannot fix that. Judge it on a known color; leave it at 0 for broadband light. It carries to the next file. +* **Hue Trim** (-30° to 30°, default 0): rotates every hue, for narrowband or odd-phosphor lights that turn yellows orange and greens olive. Judge it on a known color; leave it at 0 for broadband light. It carries to the next file. #### Narrowband and slides -Narrowband and Single-Shot Narrowband Calibration do not apply to Transparency; they stay grayed with their values for the next negative. For slides on a narrowband rig, use **Hue Trim**. +Narrowband and Single-Shot Narrowband Calibration do not apply to Transparency; they stay grayed out with their values for the next negative. For slides on a narrowband rig, use **Hue Trim**. ### 10.4 Crop -The shape every frame is cut to and what the frame detector looks for, roll-wide. Each frame's rectangle is its own, drawn or found in **Geometry** ([§4.1](#41-geometry-crop-and-straighten)). +The roll-wide crop shape, and what the frame detector looks for. Each frame's rectangle is its own, drawn or found in **Geometry** ([§4.1](#41-geometry-crop-and-straighten)). -* **Ratio** (default `Free`): `Free`, `1:1`, `3:2`, `4:3`, `5:4`, `6:7`, `7:5`, `65:24`, `16:9`, `16:10`, `11:8.5`, each auto-oriented. On `Free` auto-crop uses the detected format (6x6, 645, 6x7, 35mm); a ratio forces every frame to it. -* **Detect** (crosshairs): snap the ratio to the closest standard. +* **Ratio** (default `Free`): `Free`, `1:1`, `3:2`, `4:3`, `5:4`, `6:7`, `7:5`, `65:24`, `16:9`, `16:10`, `11:8.5`, each auto-oriented. On `Free` auto-crop uses the detected format; a ratio forces every frame to it. +* **Detect**: snaps the ratio to the closest standard. **Auto Crop**: -* **Mode**: *Image only* (exposed area) or *Film edge* (full film, including rebate and sprockets). -* **Crop Offset** (-5 to 100 px): inset the detected edge; negative bleeds slightly outside. -* **Rebate Trim** (0 to 150%): 0% stops at the film edge, 100% at the detected image edge, above 100% cuts into the picture to clear a white border. *Image only*. -* **Auto Crop** (magic wand on the AUTO CROP header): detect the frame edge and crop to it; off clears the crop. New frames, such as the next scans, start with the last value set, also with an empty Film Strip. A hand-drawn crop turns it off for that frame alone, and never takes the roll's Auto Crop. **Roll** asks before it turns the roll's Auto Crop on or off for frames that follow the roll. -* **Auto-crop all frames** (layers icon on the AUTO CROP header, *Image only*): crops every frame in the Film Strip together, filtered out or not, calibrating weak detections from confident ones, and straightens each as **Auto Skew** does. Portrait frames are cropped alone. Hand-drawn, Film-edge and ambiguous frames, and frames that keep their own Auto Crop off, are left alone. The crop controls are locked until it finishes. +* **Mode**: *Image only* (exposed area) or *Film edge* (full film, with rebate and sprockets). +* **Crop Offset** (-5 to 100 px): insets the detected edge; negative bleeds slightly outside. +* **Rebate Trim** (0 to 150%, *Image only*): 0% stops at the film edge, 100% at the detected image edge, above 100% cuts into the picture to clear a white border. +* **Auto Crop** (magic wand): detects the frame edge and crops to it; off clears the crop. New frames start with the last value set. A hand-drawn crop turns it off for that frame alone. **Roll** asks before it changes the roll's Auto Crop for frames that follow the roll. +* **Auto-crop all frames** (*Image only*): crops every frame in the Film Strip together, calibrating weak detections from confident ones, and straightens each as **Auto Skew** does. Hand-drawn, Film-edge and ambiguous frames, and frames with their own Auto Crop off, are left alone. * **Mixing scans**: allowed. A frame that reads its own edge keeps its crop; a frame with no edge takes the roll's width and tilt. Frames from one camera, holder and format pool best. * **When auto-crop leaves a frame alone**: the scanner bed must be the brightest thing in the scan. A slide with highlights as bright as the bed, sprocket-exposed film, or a neighbor frame filling more than a tenth of one side stays uncropped. Crop by hand, or use *Film edge* and trim in. -A change re-detects every following frame cropped by **Auto**. Hand-drawn crops are kept, except that **Ratio** reshapes them around their center. +A change re-detects every following frame cropped by **Auto**. Hand-drawn crops stay, but **Ratio** reshapes them around their center. ### 10.5 Roll Analysis -Meter the roll once and share the result, so frames of one film match. The **Use average** toggles are this card's roll defaults. +Meters the roll once and shares the result, so frames of one film match. -* **Use average: Luma**: take the picked roll's tonal range; color stays per frame. Disables Luma Range Clip. -* **Use average: Color**: take the picked roll's color balance; tonal range stays per frame. Disables Color Clip. Luma and Color on gives a consistent roll; both off gives per-image auto-exposure. -* **Use average: Cast** (Color Negative only): take Cast Removal's gray balance from the roll or scene analysis, so frames under one light render alike. Analysis turns it on, except on frames far from the rest; grayed out until one has run. -* **Baseline** (line shown while either average is on): names the roll or scene analyzed, or the frame **Sync Bounds…** took it from, and warns when there is none. -* **Rolls** (picker): search every roll in your library; a ticked roll has a saved baseline. Defaults to the loaded roll. Picking one loads its baseline at once. -* **Reanalyze** (gauge, on the ROLLS header; also in the Library, [§2](#2-film-strip-left-panel)): averages density and color balance over the loaded roll, scene and locked frames excluded, and saves the roll's baseline. Frames far from the rest keep their own; the status message names them. Run **Auto-crop all frames** ([§10.4](#104-crop)) first for consistent crops. -* **Use This Frame** (crosshairs, on the ROLLS header): saves this frame's bounds as the roll's baseline, for a reference frame. -* **Scenes**: the loaded roll's [scenes](#scenes), ticked once analyzed. **Analyze** runs Scene Analysis (Reanalyze over the scene only); **Select** selects its frames; **Delete** forgets it. Roll Analysis skips scene frames. +* **Use average: Luma**: takes the picked roll's tonal range; color stays per frame. Disables Luma Range Clip. +* **Use average: Color**: takes the picked roll's color balance; tonal range stays per frame. Disables Color Clip. Luma and Color on gives a consistent roll; both off gives per-image auto-exposure. +* **Use average: Cast** (Color Negative only): takes Cast Removal's gray balance from the roll or scene analysis, so frames under one light render alike. An analysis turns it on, except on frames far from the rest. +* **Baseline**: names the roll, scene or frame the baseline came from, and warns when there is none. +* **Rolls** (picker): every roll in your library; a ticked roll has a saved baseline. Defaults to the loaded roll. +* **Reanalyze** (also in the Library, [§2](#2-film-strip-left-panel)): averages density and color balance over the loaded roll, without scene and locked frames, and saves the roll's baseline. Frames far from the rest keep their own. Run **Auto-crop all frames** ([§10.4](#104-crop)) first for consistent crops. +* **Use This Frame**: saves this frame's bounds as the roll's baseline. +* **Scenes**: the loaded roll's [scenes](#scenes). **Analyze** runs Scene Analysis (Reanalyze over the scene only), **Select** selects its frames, and **Delete** forgets it. ### 10.6 Metering: negative → positive @@ -826,27 +795,26 @@ How this frame is measured into a positive's tonal bounds. **Slides** (Transparency) render as captured, through the camera's color matrix and a fixed tonal window, as in Photoshop or Darktable. -* The paper controls and the normalization tuning hide. **Print Density**, **ISO-R Grade**, **Toe** / **Shoulder** and their widths, **Shadows Density** / **Highlights Density**, the R/G/B trims and white balance form a transfer curve, neutral at defaults. -* **Auto Density** and **Auto Grade** start off on a slide; turn them on to meter a faded or expired one. On a merged bracket they are grayed out (**Render exposure** sets the exposure). +* The paper controls and the normalization tuning hide. The tone controls, R/G/B trims and white balance form a transfer curve, neutral at defaults. +* **Auto Density** and **Auto Grade** start off on a slide; turn them on to meter a faded or expired one. On a merged bracket **Render exposure** sets the exposure instead. * Lightroom mapping: **Exposure** → Print Density (lower is brighter), **Contrast** → ISO-R Grade (180 is softest), **Shadows** → Shadows Density, **Highlights** → Highlights Density. Positive adds density, so negative Shadows Density opens shadows. **Whites** and **Blacks** have no equivalent. -* A source with no camera matrix (a scanner TIFF, a JPEG) passes straight through. -* **Linear RAW** is grayed out (it renders the same either way), except with **Positive** on. An Input ICC in Export replaces the camera matrix; the as-shot white balance still applies. -* **Narrowband** and **Single-Shot Narrowband Calibration** are grayed out ([Narrowband and slides](#narrowband-and-slides)). +* A source with no camera matrix (a scanner TIFF, a JPEG) passes straight through. An Input ICC in Export replaces the camera matrix. +* **Linear RAW** is grayed out except with **Positive** on. **Narrowband** and **Single-Shot Narrowband Calibration** are grayed out ([Narrowband and slides](#narrowband-and-slides)). **Analysis** sets where the black and white points are metered. -* **Analysis Buffer** (0.0 to 0.25): insets the measurement window so rebate, sprocket holes and scanner borders do not skew it. Raise it for wide borders. -* **Reanalyze Frame** (circular arrow, on the ANALYSIS header): measures this frame again from its current crop, buffer and region. Grayed out with Lock Bounds on or with both averages on. -* **Draw Region** / **Clear Region**: draw a freehand region to meter *exactly* that area, overriding the buffer. Double-click inside to confirm. -* **Clipping** (line under the ANALYSIS header): the print's shadow and highlight clipping shares, worst channel, the Analysis panel's row; amber above 1%. The card's header carries the print's luma histogram. -* **Lock Bounds** (lock icon, on the ANALYSIS header): freezes this frame's bounds against crop and slider changes and against every Roll Analysis run. +* **Analysis Buffer** (0.0 to 0.25): insets the measurement window so rebate, sprocket holes and scanner borders do not skew it. +* **Reanalyze Frame**: measures this frame again from its current crop, buffer and region. +* **Draw Region** / **Clear Region**: draw a freehand region to meter *exactly* that area, in place of the buffer. Double-click inside to confirm. +* **Clipping**: the print's shadow and highlight clipping shares, as in the Analysis panel's row. +* **Lock Bounds**: freezes this frame's bounds against crop and slider changes and against every Roll Analysis run. **Tonal Range**: -* **Luma Range Clip** (-100 to 100): how tightly the black/white-point span is set. Neutral applies a small robust clip. Positive tightens it, for dense or fogged negatives; negative pushes the bounds *outward*, for lifted blacks and unclipped highlights. +* **Luma Range Clip** (-100 to 100): how tightly the black/white-point span is set. Positive tightens it, for dense or fogged negatives; negative pushes the bounds *outward*, for lifted blacks and unclipped highlights. * **Color Clip** (-100 to 100): the per-channel color-balance clip (orange-mask removal). Positive tightens; negative samples nearer the extremes. -**White / Black Point** (-0.25 to 0.25), with a **Global** / **R** / **G** / **B** selector: offsets on the detected bounds; positive white point brightens, positive black point lifts blacks. In R/G/B they trim each layer's Dmin and Dmax. On a slide they offset its fixed window; elsewhere **Lock Bounds** disables them. +**White / Black Point** (-0.25 to 0.25), with a **Global** / **R** / **G** / **B** selector: offsets on the detected bounds; a positive white point brightens, a positive black point lifts blacks. In R/G/B they trim each layer's Dmin and Dmax. On a slide they offset its fixed window; elsewhere **Lock Bounds** disables them. ### 10.7 Raw Decode: turning the sensor mosaic into pixels @@ -855,30 +823,29 @@ The demosaic fills in the two colors each photosite misses, which affects sharpn **Demosaic**: -* **Preview** / **Export** (sticky, default **Auto**): *Auto* is a fast half-size decode on screen (full-size PPG on X-Trans) and AHD for export. For the preview, Auto and Linear are fastest; the others decode at full size. **AHD** is balanced, **VNG** smooth, **PPG** fast with clean edges, **DCB** and **DHT** favor fine detail, **AAHD** softens edges to suppress artifacts. -* A hint under them says so when the open frame is not a Bayer or X-Trans RAW. +* **Preview** / **Export** (default **Auto**): *Auto* is a fast half-size decode on screen and AHD for export. **AHD** is balanced, **VNG** smooth, **PPG** fast with clean edges, **DCB** and **DHT** favor fine detail, **AAHD** softens edges to suppress artifacts. **Highlights: Recovery** (Transparency only, default **Off**): rebuilds a clipped highlight on a camera RAW. **Off** leaves it flat (magenta if one channel clipped first); **Blend** makes a plausible neutral, right for sun, sky, chrome or glass; **Reconstruct** is stronger and can misjudge a saturated highlight. Grayed out on rendered files and merged brackets. ### 10.8 Optics -The scanning optics: one lens correction and one light correction for every frame of the rig. One scope pair covers both. +The scanning optics: one lens correction and one light correction for every frame of the rig. #### Lens Correction -* **Embedded** (the file's own lens profile for DNG, Sony ARW and Panasonic RW2, enabled when the file has one; RW2 carries distortion only): - * **Distortion**: straightens curved lines in place of the manual correction, scaling the image to fill the frame. Set it before cropping or retouching. - * **CA**: reduces color fringes. Works with or without **Distortion** and manual correction. -* **Distortion Correction** (-0.100 to 0.100, in steps of 0.001): positive corrects barrel, negative pincushion. Use the film rebate as a straight edge. Applied before Tilt and Swing. Grayed out while the embedded **Distortion** is on. +* **Embedded** (the file's own lens profile for DNG, Sony ARW and Panasonic RW2; RW2 carries distortion only): + * **Distortion**: straightens curved lines in place of the manual correction. Set it before cropping or retouching. + * **CA**: reduces color fringes. +* **Distortion Correction** (-0.100 to 0.100, in steps of 0.001): positive corrects barrel, negative pincushion. Use the film rebate as a straight edge. #### Flat Field Correction: even out the light -Corrects uneven illumination (vignetting, falloff) from a copy-stand or scanner light, using a shot of the bare light source. +Corrects uneven light (vignetting, falloff) from a copy-stand or scanner light, using a shot of the bare light source. -* **Profile**, with **+** and **trash** on the FLAT FIELD CORRECTION header: **+** bakes a reference image into a named profile in NegPy's `flatfield` folder, after which the image can be deleted. **Trash** asks first; every frame using the profile loses its correction. -* **Apply Flat Field** (bulb toggle beside the dropdown): apply the selected profile to this roll, enabled once a profile exists. -* **Check Flat Field** (eye toggle): shows how well the selected profile corrects its own reference, from any frame and without the reference file. A good profile shows an even gray; a band or patch shows where it is off, and the stronger it is, the larger the error: full white or black is 10% or more. The status line gives the spread. A profile saved before this check shows the light it corrects instead; save it again to check it. Saving a profile warns when its reference is clipped or does not correct itself evenly. +* **Profile**: **+** bakes a reference image into a named profile in NegPy's `flatfield` folder; the image can then be deleted. **Trash** asks first; every frame using the profile loses its correction. +* **Apply Flat Field**: applies the selected profile to this roll. +* **Check Flat Field**: shows how well the selected profile corrects its own reference. A good profile shows an even gray; a band or patch shows where it is off (full white or black is 10% or more). An older profile without check data shows the light it corrects instead; save it again to check it. Saving warns when the reference is clipped or does not correct itself evenly. A newly chosen profile becomes the rig's default for the next roll. @@ -886,18 +853,18 @@ A newly chosen profile becomes the rig's default for the next roll. ## 11. Metadata tab -Metadata for the original analog capture (camera, lens, film, process), written into every export as EXIF and XMP, so a DAM such as Lightroom shows your film gear, not the scanner. **Protect Original Metadata**, at the top, copies the source's own EXIF/XMP and resolution unchanged instead, and grays out the cards below. +Metadata for the original analog capture (camera, lens, film, process), written into every export as EXIF and XMP, so a DAM such as Lightroom shows your film gear, not the scanner. **Protect Original Metadata** copies the source's own EXIF/XMP and resolution unchanged instead. -**Analog Gear**, **Capture**, **Process**, **Scanning** and **Exposure** are roll-wide by default, with the scope pair ([§1](#frame-or-roll-the-scope-pair)). The frame number is always per frame. +**Analog Gear**, **Capture**, **Process**, **Scanning** and **Exposure** are roll-wide by default ([§1](#frame-or-roll-the-scope-pair)). The frame number is always per frame. ### Metadata Presets -Saved sets of metadata values in `~/NegPy/presets/metadata/`, separate from the Favorites tab's edit presets. You manage them on the **Gear** tab (§12). +Saved sets of metadata values in `~/NegPy/presets/metadata/`, managed on the **Gear** tab (§12). -* **Preset** + **Load**: write the preset's fields onto this frame. Other fields stay. Hover to see what a preset holds. +* **Preset** + **Load**: writes the preset's fields onto this frame; other fields stay. -Gear loads as one unit (camera, lens, film stock, format). Picking a film stock sets the format, so set a frame format such as `6×7` after the stock. Presets never store the frame number. +Gear loads as one unit (camera, lens, film stock, format). A film stock sets the format, so set a frame format such as `6×7` after the stock. Presets never store the frame number. ### Analog Gear @@ -906,13 +873,13 @@ Searches your own gear (§12). **Other…** opens the full built-in catalog. * **Camera / Lens / Film stock**: empty means not set. * **Clear**: empties all three. -* **Infer from folder name**: fills unset camera, film stock, ISO and capture date from the folder name; a field with two plausible readings stays empty. +* **Infer from folder name**: fills unset camera, film stock, ISO and capture date from the folder name; an ambiguous field stays empty. ### Capture -* **Date**: `1998`, `1998-07`, `1998-07-14` or `1998-07-14 16:30`, with an optional offset such as `+02:00`; an impossible date turns red. EXIF pads a partial date, XMP keeps the short form. The scan's own timestamp moves to `DateTimeDigitized`. -* **Place**: the map-pin button opens a map (search, click or paste coordinates); the field also takes a coordinate pair or an OpenStreetMap/Google Maps link, and ✕ empties it. A place replaces the source's GPS; with none set, a geotagged source keeps its own. The map contacts OpenStreetMap. +* **Date**: `1998`, `1998-07`, `1998-07-14` or `1998-07-14 16:30`, with an optional offset such as `+02:00`. EXIF pads a partial date; XMP keeps the short form. The scan's own timestamp moves to `DateTimeDigitized`. +* **Place**: the map-pin button opens a map; the field also takes a coordinate pair or an OpenStreetMap/Google Maps link. A place replaces the source's GPS; with none set, a geotagged source keeps its own. The map contacts OpenStreetMap. ### Process @@ -922,14 +889,14 @@ Searches your own gear (§12). **Other…** opens the full built-in catalog. * **Developer** and **Dilution**: for example `D-76` and `1+1`. * **Push / Pull**: `Push +3` … `Normal` … `Pull -3`. * **Time** and **Temperature (°C)**: time as `9:30` or minutes; searchable as `devtime:` and `temp:`. -* **Clear**: empties the saved process and its fields. Format stays (the film stock sets it). +* **Clear**: empties the saved process and its fields. Format stays. ### Scanning * **Saved setup**: a library digitizing setup that fills Scanning. Typing over it unlinks it. * **Scanning**: scan method or notes. EXIF `Software` is always `NegPy`. -* **Clear**: empties the saved setup and the note. Roll and Frame stay. +* **Clear**: empties the saved setup and the note. * **Roll / Frame**: the capture roll name and frame number, stamped on capture; filename template fields `{{ roll }}` and `{{ frame }}`. @@ -940,7 +907,7 @@ Optional original shutter, aperture and ISO. Click the lock to edit a free-text ### Metadata Preview -A live view of what will be embedded. **Description…** picks the fields joined into EXIF `ImageDescription` (default: camera, lens, film stock, ISO); the choice becomes the default for frames without their own. +A live view of what goes into the file. **Description…** picks the fields joined into EXIF `ImageDescription` (default: camera, lens, film stock, ISO); the choice becomes the default for frames without their own. Capture gear goes to standard EXIF and the digitizing rig to `negpy:Scan*` XMP tags; with gear unset, your scanner or DSLR stays in EXIF. Other XMP tags: `photoshop:DateCreated` (with `negpy:CaptureDatePrecision`), `photoshop:City`/`State`/`Country`, `exif:GPS*` (the only GPS in a TIFF), `negpy:DevelopmentDilution`/`DevelopmentTime`/`DevelopmentTemperature`, `negpy:CaptureRoll`/`CaptureFrame`. @@ -948,7 +915,7 @@ Capture gear goes to standard EXIF and the digitizing rig to `negpy:Scan*` XMP t ## 12. Gear tab -A gear library for Metadata (§11), Roll Settings and every gear picker. **My Gear** holds physical gear, **Presets** metadata field sets. +A gear library for Metadata (§11), Roll Settings and every gear picker. **My Gear** holds physical gear; **Presets** holds metadata field sets. ### My Gear @@ -956,16 +923,16 @@ A gear library for Metadata (§11), Roll Settings and every gear picker. **My Ge **Category**: **Cameras**, **Lenses**, **Film Stocks**, **Process** (a development recipe) or **Scanning** (a digitizing setup). Your gear is saved to `~/NegPy/gear/`. * **+**: copy an item from the built-in catalog into your list, or **Add Custom** to enter one by hand. -* **Catalog**: show the built-in reference models too. Off by default. -* **copy / trash**: duplicate or delete the selected item. Trash is disabled on built-in entries. +* **Catalog**: also shows the built-in reference models. Off by default. +* **copy / trash**: duplicate or delete the selected item. Built-in entries cannot be deleted. ### Presets -* **+**: store the current frame's metadata under a name. +* **+**: stores the current frame's metadata under a name. * **pen**: rename, or change which fields it stores. * **copy / trash**: duplicate or delete. -* Edit a preset's fields in place, with no frame open; per-frame fields (capture date, place, description) are read-only here. **Notes** is free text. +* Edit a preset's fields in place. Per-frame fields (capture date, place, description) are read-only here. **Notes** is free text. --- @@ -975,44 +942,40 @@ A gear library for Metadata (§11), Roll Settings and every gear picker. **My Ge * **Print** (default): the look you see on screen. * **Flat**: a neutral, low-contrast master for editing elsewhere. It skips the print look, effects, toning and vignette, and writes a 16-bit TIFF, or lossless JPEG XL when JXL is selected with sRGB, P3, Rec 2020 or Grayscale. - * **Preview Flat**: show the flat master on the canvas. Rotation and flip keep it up; any other edit or a frame change closes it. - * **Roll Analysis** (Roll tab): share one exposure baseline across all visible frames so flat masters match. Run it before a flat batch. -* **Linear**: write the decoded buffer as linear 16-bit, with rotation, flip and only the corrections turned on below. **TIFF** (default, untagged) or **JPEG XL** (lossless; always tagged with sRGB primaries, wrong for native primaries). **Effort** (1 to 9, default 7) trades JPEG XL speed for size. - * **Pakon RAW**: 4× expansion by default; F335 files (16-bit sensor) none. - * **LinearRaw DNG**: SilverFast HDRi (3-channel) and VueScan (4-channel RGB+IR). IR goes to a separate grayscale `_ir` file in the same Format. - * **Camera RAW**: demosaiced at unity white balance with the Raw Decode card's Export algorithm (§10.7); as-shot WB goes to XMP (`RAW-WB: R G B`). Trichrome triplets merge into one TIFF; stitches get Flat Field and sensor correction per part. + * **Preview Flat**: shows the flat master on the canvas. + * Run **Roll Analysis** (Roll tab) before a flat batch so the masters match. +* **Linear**: writes the decoded buffer as linear 16-bit, with rotation, flip and only the corrections turned on below. **TIFF** (default, untagged) or **JPEG XL** (lossless; tagged with sRGB primaries, which is wrong for native primaries). **Effort** (1 to 9, default 7) trades JPEG XL speed for size. + * **LinearRaw DNG**: SilverFast HDRi (3-channel) and VueScan (4-channel RGB+IR). IR goes to a separate grayscale `_ir` file. + * **Camera RAW**: demosaiced at unity white balance with the Raw Decode card's Export algorithm (§10.7); as-shot WB goes to XMP (`RAW-WB: R G B`). Trichrome triplets merge into one TIFF. * **Coolscan NEF**: not raw sensor data but the Nikon Scan output; the full-resolution RGB is written. * **Flextight FFF**: 16-bit RGB and LogLuv raw (`.3fr`/`.fff`); FlexColor metadata is kept. - * **Noritsu RAW**: headerless BGR 16-bit dumps; size detected from file size. 16× expansion by default. + * **Noritsu RAW**: headerless BGR 16-bit dumps; size detected from file size. * **TIFF**: IR (a 4th channel, an `_ir.tif` sidecar or a second page) goes to a separate `_ir` file. **Input gamma** (linear, 1.8, 2.2 or sRGB) linearizes the data. - * **Expansion**: scales the data before writing. Defaults: Pakon F135/F235 4×, Noritsu 16×, F335 and LinearRaw DNG off. Camera RAW, Coolscan NEF and Flextight FFF have none. + * **Expansion**: scales the data before writing. Defaults: Pakon RAW 4× (the 16-bit F335 off), Noritsu RAW 16×, LinearRaw DNG off. Camera RAW, Coolscan NEF and Flextight FFF have none. * **IR Dust Removal** (when IR exists): IR dust and scratch correction. Off by default. - * **Corrections** (camera RAW only, all off): **White Balance** (as-shot gains; grayed out for a Trichrome triplet or Single-Shot Narrowband capture), **Flat Field**, **Sensor Correction** (crosstalk unmixing). Stitch composites always get Flat Field and sensor correction per part. - * **Lens Correction** (off; shown when the frame has a lens profile or a **Distortion Correction**): applies the **Optics** correction (§10.8), resampling the pixels. The embedded profile needs a single camera RAW; Distortion Correction skips half-frame scans. + * **Corrections** (camera RAW only, all off): **White Balance** (as-shot gains), **Flat Field**, **Sensor Correction** (crosstalk unmixing). Stitch composites always get Flat Field and sensor correction per part. + * **Lens Correction** (off; shown when the frame has a lens profile or a **Distortion Correction**): applies the **Optics** correction (§10.8). The embedded profile needs a single camera RAW; Distortion Correction skips half-frame scans. - Files take `_linear` (then `_linear_2`… without **Overwrite Existing Files**), so a source is never overwritten. **Abort** stops after the current frame. - - The file carries no ICC profile or source color metadata; it keeps Make, Model and DateTime and describes the decode and corrections. + Files take `_linear` (then `_linear_2`… without **Overwrite Existing Files**), so a source is never overwritten. The file carries no ICC profile. **Abort** stops after the current frame. ### Format / Size / Color Management / Destination -* **Format**: `JPEG`, `TIFF`, `PNG`, `JPEG XL` or `WebP`, each with quality or effort options. **JPEG XL supports only `sRGB`, `P3 D65`, `Rec 2020` or `Grayscale`**; `Adobe RGB`, `ProPhoto RGB` and a custom Output ICC give an error. -* **Bit Depth**: `8-bit` or `16-bit` (TIFF, PNG, JPEG XL). Hidden for JPEG, WebP and a flat master (always 16-bit). +* **Format**: `JPEG`, `TIFF`, `PNG`, `JPEG XL` or `WebP`, each with quality or effort options. JPEG XL supports only `sRGB`, `P3 D65`, `Rec 2020` or `Grayscale`. +* **Bit Depth**: `8-bit` or `16-bit` (TIFF, PNG, JPEG XL). * **Compression** (TIFF): `Uncompressed`, `LZW` or `ZIP`, all lossless; ZIP is usually smallest. * **Compression** (PNG): `0` to `9`, lossless; higher is slower and smaller. * **Progressive** (JPEG): renders in passes while it downloads. -* **Input ICC**: treat an untagged source as this profile. Primaries only: a matrix profile's TRC is ignored; a LUT profile's input curves still apply. -* **Export profile**: the space the file is converted to and tagged with: `Same as Source` (Adobe RGB for an untagged scan), `sRGB`, `Adobe RGB`, `ProPhoto RGB`, `P3 D65`, `Rec 2020`, `Grayscale` (true B&W), or an imported printer or paper ICC, which is embedded in the file. -* **Import ICC** (folder button on the COLOR MANAGEMENT header): copies a `.icc`/`.icm` into `~/NegPy/icc/`, available at once. A file named after a built-in space (`sRGB.icc`) replaces that space everywhere, after a confirmation. -* **Proof on Screen** is in **Soft Proof** below. A warning shows here when nothing is proofed or the proof targets a different profile than the export. +* **Input ICC**: treats an untagged source as this profile (primaries only; a LUT profile's input curves still apply). +* **Export profile**: the space the file is converted to and tagged with: `Same as Source` (Adobe RGB for an untagged scan), `sRGB`, `Adobe RGB`, `ProPhoto RGB`, `P3 D65`, `Rec 2020`, `Grayscale` (true B&W), or an imported printer or paper ICC, embedded in the file. A warning shows when the soft proof targets a different profile than the export. +* **Import ICC**: copies a `.icc`/`.icm` into `~/NegPy/icc/`. A file named after a built-in space (`sRGB.icc`) replaces that space everywhere, after a confirmation. * **Paper Aspect Ratio**: final print ratio, or *Original* (no resize). -* **Resolution**: *Original* (full resolution), *Print* (long-edge **Size** in cm plus **DPI**) or *Pixels* (**Long edge** in px). The DPI tag is your value (*Print*), the one the long edge implies (*Pixels*), or the source's, else the **DPI** field (*Original*). -* **Sharpening** (0.0 to 1.0, *Print* and *Pixels*): sharpens the export at its final size, after the resize, to restore the edge contrast that scaling down removes. It adds to the Lab **Sharpening** and does not show in the preview. -* **Destination**: **Filename Pattern** (a Jinja2 template with export and Metadata fields; see [TEMPLATING.md](TEMPLATING.md)), **Overwrite Existing Files**, and the location: subfolder of source (default, `export`), same as source, or an **Export Path**. A roll with no single source folder exports under its own folder in NegPy's data folder. With **Linear**, only Destination shows. +* **Resolution**: *Original* (full resolution), *Print* (long-edge **Size** in cm plus **DPI**) or *Pixels* (**Long edge** in px). The DPI tag is your value (*Print*), the value the long edge implies (*Pixels*), or the source's, else the **DPI** field (*Original*). +* **Sharpening** (0.0 to 1.0, *Print* and *Pixels*): sharpens the export after the resize, to restore the edge contrast that scaling down removes. It adds to the Lab **Sharpening** and does not show in the preview. +* **Destination**: **Filename Pattern** (a Jinja2 template; see [TEMPLATING.md](TEMPLATING.md)), **Overwrite Existing Files**, and the location: subfolder of source (default, `export`), same as source, or an **Export Path**. A roll with no single source folder exports under its own folder in NegPy's data folder. ### Export button -**Export**, under the form; its chevron picks the scope: current frame (Ctrl+E), selected frames or all visible frames. For several formats or sizes in one run, use Export Presets. A batch holding frames without a saved edit asks for confirmation first: they export with the current settings and may not match their thumbnails. After the batch, a warning names exports that rendered almost black with no crop set, where the bright scan border drove automatic levels. +**Export**'s chevron picks the scope: current frame (Ctrl+E), selected frames or all visible frames. For several formats or sizes in one run, use Export Presets. A batch with unedited frames asks first, since they may not match their thumbnails. After the batch, a warning names exports that rendered almost black with no crop set, where the bright scan border drove automatic levels. ### Collapsible sections @@ -1024,71 +987,71 @@ Saved Format/Size/Color Management/**Destination**/filename recipes. Each preset #### Printing Notes -The printer's record for this frame: the numbered dodge/burn masks and a card with paper, density, grade, filtration and the burn list. **Preview** shows it on the canvas (**Shift+N**); **Export** writes it as an image beside the print. Its conventions are under Dodge & Burn. +The printer's record for this frame, as described under Dodge & Burn. **Preview** shows it on the canvas (**Shift+N**); **Export** writes it as an image beside the print. #### Sidecars -**Save on Export** writes a `.negpy` sidecar next to each source on export. **Export Sidecars** writes them for all visible frames now and reports failures in read-only folders. Edits always stay in the database too. A frame with no edit in the database takes its sidecar when its folder opens. **Load Edit from Sidecar…** (right-click a frame or the image) replaces the frame's edit with a chosen `.negpy`, as one undo step. +**Save on Export** writes a `.negpy` sidecar next to each source on export. **Export Sidecars** writes them for all visible frames at once. Edits always stay in the database too. A frame with no edit in the database takes its sidecar when its folder opens. **Load Edit from Sidecar…** (right-click a frame) replaces the frame's edit with a chosen `.negpy`, as one undo step. #### Contact Sheet -A darkroom proof of the roll: every visible frame at true size, as cut film strips on photographic paper, in capture order. The rebate, perforations and edge print follow the frames' **Film stock** and **Format** metadata. Each frame prints with its own edit, without the border. +A darkroom proof of the roll: every visible frame at true size, as cut film strips on photographic paper, in capture order. The rebate, perforations and edge print follow the frames' **Film stock** and **Format** metadata. * **Path**: the folder for the sheets. Empty follows the export destination. -* **Contact Sheet…**: opens the proof. Drag an edge or a corner of the paper to resize it; the strips refill to fit. A size near an Ilford sheet snaps to it. -* **Format**: 35mm, 35mm Half Frame or 120, read from the frames' metadata and the roll's Half Frame mode. **Frame size** sets the 120 camera's frame (6×4.5 to 6×17). -* **Paper**: an Ilford sheet size, or Custom. The turn button swaps width and height. The default, 24 × 30.5 cm, holds a whole 36-exposure roll. +* **Contact Sheet…**: opens the proof. Drag an edge or corner of the paper to resize it; a size near an Ilford sheet snaps to it. +* **Format**: 35mm, 35mm Half Frame or 120. **Frame size** sets the 120 camera's frame (6×4.5 to 6×17). +* **Paper**: an Ilford sheet size, or Custom. The default, 24 × 30.5 cm, holds a whole 36-exposure roll. * **Width** / **Height**: the paper size, 50–610 mm. * **DPI**: 150, 300 or 600, tagged so the sheet prints at true size. -* **Order**: **Date**, or **Scene** (once the roll has one), where each scene starts a new strip. -* **Print**: **As Edited**, or **Straight Proof**: the whole roll at one exposure on grade 2 from its Roll Analysis baseline (per scene in **Scene** order), so thin and dense negatives print light and dark. It needs the roll scanned at one exposure; positives from other software cannot be proofed. +* **Order**: **Date**, or **Scene**, where each scene starts a new strip. +* **Print**: **As Edited**, or **Straight Proof**: the whole roll at one exposure on grade 2 from its Roll Analysis baseline, so thin and dense negatives print light and dark. It needs the roll scanned at one exposure. * **Pick Frames**: click a frame to leave it out or put it back; rejected frames start out. -* **Edge Print**: the maker's markings on the film edge (stock name, frame numbers, DX barcode). Off prints plain film. -* **Roll Label**: the roll name, film, developer, camera and date above the strips, set in the edge print's capitals and ink. -* **White Paper**: white paper and a dark roll label instead of the darkroom black, to save ink on a home printer. The film strips print as before. -* **Film Base** (beside White Paper, on): prints the film base black, as a darkroom contact print. Off prints it as paper, with the strip outlined, the edge print and frame numbers in dark ink and the perforations as rings, so a home printer inks the frames, the markings, the outline and the rings alone. +* **Edge Print**: the maker's markings on the film edge (stock name, frame numbers, DX barcode). +* **Roll Label**: the roll name, film, developer, camera and date above the strips. +* **White Paper**: white paper in place of the darkroom black, to save ink on a home printer. +* **Film Base** (on): prints the film base black, as a darkroom contact print. Off prints it as paper with the strip outlined, to save more ink. Sheets are JPEGs named `contact_sheet.jpg` (`contact_sheet_1of2.jpg` and on when the roll needs more sheets), with the **JPEG Quality** and **Progressive** settings above. #### Soft Proof -Simulate the print on screen. See below. +Simulates the print on screen. See below. ### Soft Proof -Preview only; exports are never proofed. Paper is dimmer and has a smaller gamut than a screen, so a correct proof looks worse than the plain preview. Judge it in room light. +Preview only; exports are never proofed. Paper is dimmer and has a smaller gamut than a screen, so a correct proof looks worse than the plain preview. -* **Proof on Screen** (`Shift+P`, on by default): master switch. Off grays out the settings on the rail under it. +* **Proof on Screen** (`Shift+P`, on by default): master switch. * **Preset**: a saved printer and paper setup; **None** proofs the export target with no paper simulation. -* **Profile**: follows the **Export profile** until you pick a printer or paper, so you can proof a print while you export a web JPEG. Lists imported ICC profiles only. -* **Intent**: **Relative Colorimetric** keeps printable colors and clips the rest, so saturated areas can flatten. **Perceptual** compresses everything so color relations survive; printer profiles carry their own table, so try it on saturated frames. **Saturation** is for charts, not photographs. -* **Black Point Compensation** (off): map the darkest tone to the paper's black instead of clipping. -* **Simulate Paper White** (off): show the paper's white and tint. -* **Simulate Ink Black** (off): show the paper's real black; shadows lift. -* **Gamut Warning**: show unprintable colors as gray (the Analysis panel's **Gamut** row counts them). The edge fades, so read it as a region. +* **Profile**: follows the **Export profile** until you pick a printer or paper, so you can proof a print while you export a web JPEG. +* **Intent**: **Relative Colorimetric** keeps printable colors and clips the rest. **Perceptual** compresses everything so color relations survive; try it on saturated frames. **Saturation** is for charts, not photographs. +* **Black Point Compensation** (off): maps the darkest tone to the paper's black in place of clipping. +* **Simulate Paper White** (off): shows the paper's white and tint. +* **Simulate Ink Black** (off): shows the paper's real black; shadows lift. +* **Gamut Warning**: shows unprintable colors as gray. * **Display**: the monitor profile, auto-detected; set it by hand if detection fails. --- ## 14. Scan tab -Capture film directly into NegPy. The **Scanner** card picks the scanner; the tab shows its cards, the shared **Output** card and, at the bottom, the **Scan** button, which reads **Stop** while scanning. +Capture film directly into NegPy. The **Scanner** card picks the scanner; the tab shows its cards, the shared **Output** card and the **Scan** button. ### Scanner -* **Scanner** (Camera by default): **Film Scanner** drives a dedicated film scanner (Device, Film & Quality, Framing); **Camera** drives a camera on a copy stand (Camera, Preset & Light). Both write through the same Output card. +* **Scanner** (Camera by default): **Film Scanner** drives a dedicated film scanner (Device, Film & Quality, Framing); **Camera** drives a camera on a copy stand (Camera, Preset & Light). ### Device -**Backend**: **SANE** (Linux/macOS), **Nikon Coolscan (nkscan)** (direct Coolscan driver, Linux, Windows, macOS) or **pyOpticfilm (Plustek)** (OpticFilm 8200i SE and 8100 V2, all three OSes). Controls follow what the device reports; a row or card with nothing for the device is hidden. +**Backend**: **SANE** (Linux/macOS), **Nikon Coolscan (nkscan)** (Linux, Windows, macOS) or **pyOpticfilm (Plustek)** (all three OSes). Controls follow what the device reports. -* **Device**: the scanner; the arrows refresh the list. The eject button's menu holds **Eject Now** and, on a strip feeder, **Eject When Done** (on by default), which returns the strip after a batch. Off keeps it loaded with its picks and previews, so more frames scan without a new preview. A strip the scanner returns by itself (its idle timeout) counts as an eject: insert it again, then **Detect frames**. After 9 minutes with no scanner activity NegPy measures the strip again. -* **Debug log** (nkscan; Off, Debug, Trace): writes `nkscan.log` in the NegPy folder; the folder button opens it. For a bug report, set Trace, reproduce the problem and attach the file. +* **Device**: the scanner; the arrows refresh the list. The eject menu holds **Eject Now** and, on a strip feeder, **Eject When Done** (on by default), which returns the strip after a batch. Off keeps it loaded with its picks and previews. A strip the scanner returns by itself counts as an eject: insert it again, then **Detect frames**. After 9 minutes with no scanner activity NegPy measures the strip again. +* **Debug log** (nkscan; Off, Debug, Trace): writes `nkscan.log` in the NegPy folder. For a bug report, set Trace, reproduce the problem and attach the file. **pyOpticfilm (Plustek)**: the **OpticFilm 8200i SE** (`07b3:1825`) and **8100 V2** (`07b3:1824`) scan; for other models try **SANE** on Linux and macOS. **IR** comes in the same pass. On Windows, bind the device to **WinUSB** with Zadig first ([PLUSTEK_WINDOWS.md](PLUSTEK_WINDOWS.md)). From source, install with `uv sync --group plustek` or `pip install negpy[plustek]`. @@ -1098,33 +1061,33 @@ Capture film directly into NegPy. The **Scanner** card picks the scanner; the ta ### Film & Quality * **Film**: Color negative, B&W negative, Slide or Kodachrome. Sets frame detection, metering and whether IR and ICE are offered (not for B&W or Kodachrome). -* **Film format**: frame length (135, 66, 645 and so on). **Auto** where the holder narrows it; set it for loose film in a masked carrier. Shown only where the transport measures the film. +* **Film format**: frame length (135, 66, 645 and so on). **Auto** where the holder narrows it; set it for loose film in a masked carrier. * **Resolution**, **Bit depth**: the scan's dpi and bits per channel; type a resolution between the listed stops. -* **Scan mode** (Plustek; Single-Pass by default): **Single-Pass**, **Multi-Pass** (stacks repeated exposures to cut noise; **Passes** 2 to 9), **Adaptive Multi-Exposure** (fuses a short and a long exposure for more range) or **Adaptive Multi-Pass** (both). Each takes longer than Single-Pass; Multi-Pass excludes IR. +* **Scan mode** (Plustek; Single-Pass by default): **Single-Pass**, **Multi-Pass** (stacks repeated exposures to cut noise; **Passes** 2 to 9), **Adaptive Multi-Exposure** (fuses a short and a long exposure for more range) or **Adaptive Multi-Pass** (both). Multi-Pass excludes IR. * **Samples** (nkscan): reads per line averaged (1 to 16). Less shadow noise, proportionally slower. * **IR**: a separate infrared plane for Retouch's dust removal. **ICE** (nkscan): infrared dust and scratch removal baked into the file, color film only. **ICE** and **IR** exclude each other. * **Superfine** (nkscan): one line per pass. Slower, with no host-side line registration. -* **Autofocus**, **Auto-exposure**: run on the scanner before the scan, where it offers them. -* **Exposure** (µs): shown when the scanner has `scan-exposure-time` (some genesys devices); off while Auto-exposure is on. +* **Autofocus**, **Auto-exposure**: run on the scanner before the scan. +* **Exposure** (µs): shown when the scanner has `scan-exposure-time`; off while Auto-exposure is on. ### Framing -* **Frames**: `1-6`, `1,2,5`, or empty for all. The strip preview writes its picks here. The line above **Scan** states frame count, resolution, extra passes, approximate disk use and, in amber, an active exposure lock. -* **Batch** / **Window**: on a feeder, **Preview strip…** previews every frame, to set windows and pick frames; with a manual holder, **Preview…** sets one crop window. Only the window is scanned; **Clear** resets it. On nkscan the preview reads the whole strip in one pass; **Detect frames** reads it again after the film moves. +* **Frames**: `1-6`, `1,2,5`, or empty for all. The strip preview writes its picks here. +* **Batch** / **Window**: on a feeder, **Preview strip…** previews every frame, to set windows and pick frames; with a manual holder, **Preview…** sets one crop window. **Clear** resets it. **Detect frames** reads the strip again after the film moves. * **Exposure lock** (**Meter Frame…** / **Unlock**, nkscan): nkscan meters each frame alone, so an end frame can scan in a different color. **Meter Frame…** meters one frame inside the strip (such as frame 2), and every later scan reuses that exposure, across strips and restarts, until **Unlock**. Meter again for each roll. -* **Crop** (**Prescan…**, Plustek): a 1200 dpi full-window preview; drag a crop and leave with **Apply Crop** or **Scan Frame**, and the next scan reads only that area. **Clear** scans the full window. +* **Crop** (**Prescan…**, Plustek): a 1200 dpi full-window preview; drag a crop, and the next scan reads only that area. **Clear** scans the full window. ### Camera Copy-stand capture with a camera in **PC Remote** mode over USB (macOS/Linux): with a NegPy **Scanlight**, R/G/B triplets or one exposure with the three LEDs lit together, else one white-light exposure. Needs `python-gphoto2` (`pip install gphoto2`; no Windows build). Setup and troubleshooting: CAMERA_SCANNING.md. -* **Camera** / **Light**: connection status, found automatically; the light shows its LED temperature, amber once it runs warm. On Linux, the Scanlight's serial port needs your user in the `dialout` group (`sudo usermod -aG dialout $USER`, then log out and back in). -* **Live View**: click the image to aim the focus magnifier, again for the full frame. The **Focus meter** reads sharpness against the best seen: turn the focus ring past best focus, then back until it reads **at peak** (a click, or the body's own zoom change, resets it). ISO, shutter and aperture are set from its toolbar, or locked by a calibrated RGB preset. -* **Histogram** and **Zebras** (live view toolbar): the frame's R, G and B histogram under the image, and hatching on pixels at the top (red) and bottom (blue) of the preview range. The preview clips before the RAW does, so a zebra is an early warning. -* **Meter** (live view toolbar, white-light and camera-only scanning): one RAW probe at the current exposure sets the shutter so the region that must not clip lands just under clipping: a negative's clear base, or a slide's brightest highlights. The white-light preset's film type decides which (AUTO classifies the probe as an import would). The status line names the medium, the level read and the step's move; Scan waits for the body to confirm the write. A clipped reading steps down two stops; meter again. After a probe the red zebras hatch where the RAW will clip at the new shutter, while the exposure and the framed picture hold. -* **Scan** and **Retake**: **Scan** shoots the next frame, auto-numbered, and imports it; **Retake** shoots the last frame again. In the Live View window, `S` scans and `R` retakes; both can be rebound under Camera Live View in Keyboard Shortcuts. +* **Camera** / **Light**: connection status, found automatically; the light turns amber once it runs warm. On Linux, the Scanlight's serial port needs your user in the `dialout` group (`sudo usermod -aG dialout $USER`, then log out and back in). +* **Live View**: click the image to aim the focus magnifier. Turn the focus ring past best focus, then back until the **Focus meter** reads **at peak**. ISO, shutter and aperture are set from its toolbar, or locked by a calibrated RGB preset. +* **Histogram** and **Zebras**: the frame's RGB histogram, and hatching on pixels at the top (red) and bottom (blue) of the preview range. The preview clips before the RAW does, so a zebra is an early warning. +* **Meter** (white-light and camera-only scanning): one RAW probe sets the shutter so the region that must not clip (a negative's clear base, a slide's brightest highlights) lands just under clipping. The white-light preset's film type decides which. A clipped reading steps down two stops; meter again. +* **Scan** and **Retake**: **Scan** shoots the next frame, auto-numbered, and imports it; **Retake** shoots the last frame again. In the Live View window, `S` scans and `R` retakes. * **Narrowband**: RGB-lit scans render more saturated; the Calibration card's **Narrowband** toggle corrects this. @@ -1132,11 +1095,11 @@ Copy-stand capture with a camera in **PC Remote** mode over USB (macOS/Linux): w Shown while a Scanlight is connected. -* **Preset**: shows its RGB levels, ISO, shutter and aperture and forces them each frame. **+** calibrates: place the rectangle on clear film base, name it, run it; it solves a shutter and LED levels just under clipping, or says which way to adjust. **Create a manual preset…** sets one by hand, and the save button stores it. -* **Red**, **Green**, **Blue**, **White** (0 to 255): LED levels, editable while building a manual preset. **Light Off** turns every channel off. -* **Capture mode**: **Triplet** shoots one exposure per LED and merges them; **Single Capture** shoots one exposure with red, green and blue lit together and imports it as an ordinary RAW. Picked in the calibration window, or here while building a manual preset. -* **Create Sensor Profile** (calibration window, Single Capture only, default on): also saves a sensor profile under the preset's name; a roll scanned with the preset takes it and turns Linear RAW on. A preset's profile is not carried to other rolls. -* **ISO**, **Shutter**, **Aperture**: the preset's exposure, editable while building a manual preset. +* **Preset**: forces its RGB levels, ISO, shutter and aperture each frame. **+** calibrates: place the rectangle on clear film base, name it, run it; it solves a shutter and LED levels just under clipping. **Create a manual preset…** sets one by hand. +* **Red**, **Green**, **Blue**, **White** (0 to 255): LED levels, editable while you build a manual preset. **Light Off** turns every channel off. +* **Capture mode**: **Triplet** shoots one exposure per LED and merges them; **Single Capture** shoots one exposure with all three lit and imports it as an ordinary RAW. +* **Create Sensor Profile** (Single Capture only, default on): also saves a sensor profile under the preset's name; a roll scanned with the preset takes it and turns Linear RAW on. Other rolls do not get it. +* **ISO**, **Shutter**, **Aperture**: the preset's exposure, editable while you build a manual preset. * **Channel Delay** (0 to 5000 ms): pauses between R, G and B for bodies that lock up. Triplet presets only. @@ -1146,63 +1109,61 @@ Shared by both scanners. * **Folder**: where frames are written. * **Scan as Roll** (on by default): the output folder opens as a roll in the Library, so Half Frame, the Roll tab and Roll Analysis apply. Frames load as they are written. -* **Folder as Roll** (on by default): frames go into the output folder itself, which is the roll and gives it its name. Off writes into a **Roll** subfolder, and a new Roll name starts a new roll. A camera frame's file name starts with the roll name. -* **New Roll** (**+** beside Roll, with Folder as Roll off): steps the Roll name to the next one that has no subfolder yet. A trailing number that is zero-padded or follows `_`, `-`, a space or `.` counts up (`Roll001` to `Roll002`, `portra400_1` to `portra400_2`); any other name gains `_2` (`portra400` to `portra400_2`). +* **Folder as Roll** (on by default): frames go into the output folder itself, which is the roll. Off writes into a **Roll** subfolder, and a new Roll name starts a new roll. A camera frame's file name starts with the roll name. +* **New Roll** (**+** beside Roll): steps the Roll name to the next one that has no subfolder yet. A trailing number that is zero-padded or follows `_`, `-`, a space or `.` counts up (`Roll001` to `Roll002`, `portra400_1` to `portra400_2`); any other name gets `_2`. * **Format**, **Filename** (Film Scanner): `TIFF` or `TIFF (mono)` (one 16-bit gray plane, for B&W negatives), and the file name as a Jinja2 template of `{{ date }}` and `{{ seq }}`. ### Strip preview -Dialogs end with **Cancel**, **Apply** (keep the framing) and **Scan**. Apply reads **Apply Framing** on a strip, **Apply Window** on a single holder, **Apply Crop** after a Prescan. - -During a preview **Cancel** reads **Stop Preview** (keeps the tiles read so far). Negatives preview inverted. On nkscan, **Offset** (±10 mm) and **Drift** re-cut the tiles with no new scan. +Dialogs end with **Cancel**, **Apply** (keep the framing) and **Scan**. During a preview **Cancel** reads **Stop Preview** and keeps the tiles read so far. Negatives preview inverted. On nkscan, **Offset** (±10 mm) and **Drift** cut the tiles again with no new scan. -* **Cropping**: drag on a frame; corners resize, inside moves. **Clear Crops** removes all. +* **Cropping**: drag on a frame. **Clear Crops** removes all. * **Frame outline**: red box on the detected frame; offsets are measured from it. -* **Offset**: moves every frame along the film to clear the gap. The shaded band on the right is film the transport cannot deliver; a feeder goes one way only. +* **Offset**: moves every frame along the film to clear the gap. The shaded band is film the transport cannot deliver; a feeder goes one way only. * **Drift**: offset that grows (or shrinks) per frame position. -* **Per-frame offset**: the slider under a tile, on top of Offset and Drift. Double-click resets. -* **Size**: tile size, remembered. Double-click resets. +* **Per-frame offset**: the slider under a tile, on top of Offset and Drift. +* **Size**: tile size. * **Which frames**: tick tiles, or **All** / **None**. Eject or a restart clears ticks, crops and per-frame offsets. -* **Preview frame** (eye, beside the tick): scans that frame again; hidden where the tiles come from one strip pass. +* **Preview frame**: scans that frame again. --- ## 15. Preferences -Application-wide settings: canvas **⋯** menu → **Preferences…**, `Ctrl + ,`, or the macOS application menu. Changes apply at once; startup rows show a restart notice. +Application-wide settings: **⋯** menu → **Preferences…** or `Ctrl + ,`. Changes apply at once; startup rows show a restart notice. ### Interface * **UI scale** (80% to 120%): after a restart. * **Canvas background**: black, dark gray, mid gray (neutral for judging) or white (a print on a light table). -* **Immersive canvas**: toolbar floats over the image. -* **Sticky zoom**: keep the zoom when you switch frames. +* **Immersive canvas**: the toolbar floats over the image. +* **Sticky zoom**: keeps the zoom when you switch frames. * **Reverse scroll zoom**: scroll up zooms out. * **Customize Shortcuts…**, **Edit Toolbar…**, **Reset Panel Layout**: shortcut editor, canvas toolbar picker, default panel layout. ### Accessibility -* **Color vision** (default **Standard**): colors for marks told apart by color alone. The Retouch dust overlay is green and magenta under *Standard*, Dodge & Burn masks and Printing Notes amber (dodge) and blue (burn). *Protanopia / deuteranopia* uses sky blue and vermilion for dust, orange and sky blue for masks; *Tritanopia* vermilion and bluish green; *Achromatopsia* white and gray. The color-blind colors come from the Okabe–Ito palette. +* **Color vision** (default **Standard**): colors for marks told apart by color alone (dust overlay, dodge and burn masks, zone highlight, clipping overlay). *Protanopia / deuteranopia*, *Tritanopia* and *Achromatopsia* use colors from the Okabe–Ito palette. ### Performance -* **GPU acceleration**: render on the GPU (backend named below). Off uses the slower CPU pipeline, same image. If the GPU fails at launch, an amber line here says so. -* **Multi-core CPU rendering**: runs CPU rendering on all cores, effective at once; it helps exports and machines without a usable GPU. On by default except on macOS, where it can end the app; after such a crash NegPy offers to turn it off. `cpu_parallel` in `override.toml` overrides it. -* **Search by meaning**: find frames by description instead of `field:value` terms (§2). Downloads its model on first use. -* **Preview size** (512 to 8192 px): canvas long edge; higher is sharper and costs more memory. +* **GPU acceleration**: renders on the GPU. Off uses the slower CPU pipeline, same image. If the GPU fails at launch, an amber line here says so. +* **Multi-core CPU rendering**: runs CPU rendering on all cores; it helps exports and machines without a usable GPU. On by default except on macOS, where it can end the app; after such a crash NegPy offers to turn it off. `cpu_parallel` in `override.toml` overrides it. +* **Search by meaning**: find frames by description (§2). +* **Preview size** (512 to 8192 px): canvas long edge; higher is sharper and uses more memory. * **Preview cache** and **Preview cache limit**: photos kept decoded, and their memory limit. Lower both on low RAM. * **HQ buffers**: full-resolution preview buffers kept in memory. -* **Rendered frames**: frames kept for going back without a re-render. -* **GPU texture cap**: largest texture dimension. 0 lets the hardware decide. Lower it if exports run out of GPU memory. -* **Show GPU memory warning**: a message when an HQ preview is downsampled to fit the texture cap; off hides only the message. +* **Rendered frames**: frames kept for going back without a new render. +* **GPU texture cap**: largest texture dimension; 0 lets the hardware decide. Lower it if exports run out of GPU memory. +* **Show GPU memory warning**: a message when an HQ preview is downsampled to fit the texture cap. Rows from **Preview size** down need a restart. A value in `override.toml` wins and grays out its row. ### Session & Storage -* **Carry settings between frames**: apply your Persistent Settings to each newly opened file. Off, each file starts from its saved edit or defaults. -* **Persistent Settings…**: which edits carry over (§2). +* **Carry settings between frames**: applies your Persistent Settings to each newly opened file. +* **Persistent Settings…**: which edits carry over (§1). * **Manage Database…**: row counts and sizes, clear saved edits, library roots. ### Startup override (`override.toml`) @@ -1211,12 +1172,12 @@ For crashes on launch or rendering glitches: edit `Documents/NegPy/override.toml | Setting | Values | Effect | |---------|--------|--------| -| `rendering.backend` | `"auto"`, `"vulkan"`, `"dx12"`, `"metal"`, `"cpu"` | GPU backend for image processing. `"cpu"` disables GPU entirely. | +| `rendering.backend` | `"auto"`, `"vulkan"`, `"dx12"`, `"metal"`, `"cpu"` | GPU backend for image processing. `"cpu"` disables the GPU. | | `display.qt_rhi_backend` | `"auto"`, `"vulkan"`, `"d3d12"`, `"metal"`, `"opengl"`, `"software"` | Qt UI rendering backend. | | `display.qt_platform` | `"auto"`, `"xcb"`, `"wayland"` | Window system plugin (Linux only). | | `performance.force_hq_preview` | `true` / `false` (or absent) | Overrides the saved HQ preview toggle. | -| `performance.cpu_parallel` | `true` / `false` (or absent) | Multi-core CPU rendering kernels. Defaults on, except on macOS. | -| `logging.level` | `"debug"`, `"info"`, `"warning"`, `"error"` | Log verbosity. Use `"debug"` when reporting issues. | +| `performance.cpu_parallel` | `true` / `false` (or absent) | Multi-core CPU rendering. On by default, except on macOS. | +| `logging.level` | `"debug"`, `"info"`, `"warning"`, `"error"` | Log verbosity. Use `"debug"` when you report issues. | `max_texture_size`, `preview_render_size`, `preview_cache_max_bytes`, `preview_cache_max_entries`, `preview_cache_max_full_res_entries` and `render_memo_max_entries` take the same values as their Preferences rows. @@ -1231,15 +1192,15 @@ For crashes on launch or rendering glitches: edit `Documents/NegPy/override.toml ## 16. Updating NegPy -NegPy checks GitHub once at startup. A new release shows a green **⬇ Update Available: vX.Y.Z** line at the top of the left panel and a green dot on the **⋯** menu; click the line, or **Update to vX.Y.Z…** in the menu, for the release notes and the install button. **Check for Updates…** in the **⋯** menu checks by hand. +NegPy checks GitHub once at startup. A new release shows a green **⬇ Update Available: vX.Y.Z** line at the top of the left panel; click it, or **Update to vX.Y.Z…** in the **⋯** menu, for the release notes and the install button. **Check for Updates…** checks by hand. **Install Update** downloads the build for this install type, closes NegPy, installs and reopens. Nothing is replaced until NegPy exits, so a failure leaves your install as it was. | Install | What NegPy fetches | How it installs | |---------|--------------------|-----------------| -| **Windows** | the `-Setup.exe` installer | Runs it silently over your install. Approve the administrator prompt *before* NegPy closes. | -| **macOS** | the `.dmg` for your chip (Apple silicon or Intel) | Mounts the image and replaces the `NegPy.app` bundle where it currently sits, then reopens it. | -| **Linux** | the `.AppImage` | Replaces the AppImage file you launched, keeps it executable, and relaunches it. | +| **Windows** | the `-Setup.exe` installer | Runs it silently. Approve the administrator prompt *before* NegPy closes. | +| **macOS** | the `.dmg` for your chip (Apple silicon or Intel) | Replaces the `NegPy.app` bundle where it is, then reopens it. | +| **Linux** | the `.AppImage` | Replaces the AppImage file you launched and relaunches it. | Edits, presets, settings and library live in `Documents/NegPy` and the database, so updates do not touch them. @@ -1249,9 +1210,8 @@ The button reads **"Open Releases Page"** when NegPy cannot update itself: a sou ## Additional Info -* **GPU acceleration**: turn it off in **Preferences → Performance**, or force a backend in `override.toml`, if you suspect a driver issue. * **Database**: edits live in a local SQLite database keyed by file hash, so files can move or be renamed. Optional `.negpy` sidecars mirror them. -* **Saving edits**: written on export, on frame switch, or on save. Closing mid-edit before any of these loses unsaved changes. +* **Saving edits**: written on export, on frame switch, or on save; closing mid-edit before any of these loses unsaved changes. * **Keyboard shortcuts**: [KEYBOARD.md](KEYBOARD.md) * **Filename templating**: [TEMPLATING.md](TEMPLATING.md) * **The pipeline in depth**: [PIPELINE.md](PIPELINE.md)