From 4e8a51ee01d68767fec228798258d0df40480e37 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sun, 1 Nov 2026 19:27:30 +0100 Subject: [PATCH] feat(metering): pick Dmin and Dmax by hand; move Film Base to Readouts Pick by Hand fold in the Metering card: Dmin (thinnest spot) and Dmax (densest spot) each replace one end of the frame's bounds, per channel. The picks route through resolve_bounds_detailed, so CPU and GPU share them; roll Use Average still wins. The canvas shows labelled marks while a pick tool is armed. Luma and color clip gray out when both ends are picked. Film Base moves to an icon toggle on the Analysis Readouts header, next to the numbers it changes, with a dot and a clear button. Reanalyze Frame moves down beside Draw Region; Lock stays on the ANALYSIS header. header_actions() puts sub-header buttons at a fixed share of the row and is used for the short-label rows across the panels. Sub-headers and fold headers use body size and text_secondary. Roll Analysis loses its darker body box. --- docs/PIPELINE.md | 1 + docs/USER_GUIDE.md | 14 +- negpy/desktop/controller.py | 37 ++++ negpy/desktop/session.py | 6 +- negpy/desktop/view/canvas/overlay.py | 53 ++++-- negpy/desktop/view/canvas/toolbar.py | 7 +- negpy/desktop/view/canvas/widget.py | 2 + negpy/desktop/view/keyboard_shortcuts.py | 15 +- negpy/desktop/view/shortcut_registry.py | 2 + negpy/desktop/view/sidebar/controls_panel.py | 12 +- negpy/desktop/view/sidebar/flatfield.py | 4 +- negpy/desktop/view/sidebar/process.py | 72 +++++--- negpy/desktop/view/sidebar/retouch.py | 16 +- negpy/desktop/view/sidebar/right_panel.py | 35 +++- negpy/desktop/view/sidebar/roll.py | 14 +- negpy/desktop/view/sidebar/scanlight.py | 11 +- negpy/desktop/view/styles/templates.py | 30 +++- .../view/widgets/export_settings_form.py | 16 +- negpy/desktop/view/widgets/fold.py | 15 +- .../view/widgets/gear_library_panel.py | 6 +- negpy/desktop/view/widgets/stats.py | 11 +- negpy/features/exposure/normalization.py | 2 + negpy/features/process/models.py | 10 ++ .../services/assets/thumbnail_fingerprint.py | 15 +- negpy/services/rendering/gpu_engine.py | 2 + negpy/translations/USER_GUIDE.de.md | 18 +- negpy/translations/USER_GUIDE.en.md | 14 +- negpy/translations/USER_GUIDE.ja.md | 18 +- negpy/translations/USER_GUIDE.ko.md | 18 +- negpy/translations/USER_GUIDE.zh_CN.md | 18 +- negpy/translations/de.json | 23 ++- negpy/translations/ja.json | 23 ++- negpy/translations/ko.json | 23 ++- negpy/translations/zh_CN.json | 23 ++- tests/test_canvas_toolbar.py | 2 +- tests/test_film_base.py | 27 +++ tests/test_fold.py | 12 ++ tests/test_header_actions.py | 34 ++++ tests/test_manual_bounds_pick.py | 165 ++++++++++++++++++ tests/test_process_sidebar.py | 9 +- tests/test_roll_sidebar.py | 5 +- 41 files changed, 687 insertions(+), 153 deletions(-) create mode 100644 tests/test_header_actions.py create mode 100644 tests/test_manual_bounds_pick.py diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 0710038b0..c830c78fb 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -48,6 +48,7 @@ Pixel values are linear transmittance. The source corrections run in that domain `_sample_log_bounds` samples two independent axes. A color pass reads each channel's floor and ceiling references; a luma pass sets how far the window reaches past them, as one shared fraction of each channel's span $s_c$: $f_c = r_c^f + (h_f + k)\,s_c$, $h_f = \sum_c (l_c^f - r_c^f) / \sum_c s_c$, and the ceiling alike. A film whose channels differ in range keeps a gray scale neutral. $k$ is the base's mean-minus-median channel offset over the mean span. Spans of mixed sign take one log reach for all channels. * **Luma Range Clip** (`luma_range_clip`): the luminance window. Positive tightens it; zero uses outlier-resistant extremes (a block-median prefilter and a small base clip); negative pushes the bounds outward as headroom. * **Color Clip** (`color_range_clip`): the sampling depth for per-channel deviation, neutral at `base_color_clip` ($1.0$), log-interpolated either side. The thin end uses per-channel percentiles. The dense end reads one shared, chroma-gated set (the lowest-chroma part of the luma-extreme band), so colored highlights cannot pass as film cast; without trustworthy neutrals, and always in Transparency, it uses per-channel percentiles. + * **Pick Points** (`manual_dmin` / `manual_dmax`): per-channel log values read from a picked patch's median. In `resolve_bounds_detailed` Dmin replaces the per-frame base's ceilings and Dmax its floors, before the luma/color mix, so both engines and a roll average see the same base. * **White and Black Point**: offsets on the detected bounds, plus per-layer trims (`white_point_trim_*` / `black_point_trim_*`; `per_channel_point_offsets`, single source; Transparency negates). * **Roll baseline and locks**: Roll Analysis measures every frame with its own crop, region and crosstalk. A frame whose luma-free color offsets lie further than `POOL_OUTLIER_DISTANCE` log density from the median is an outlier and keeps its own bounds on every apply of the baseline. The baseline pools the rest: luma as the median of luma-weighted floors and ceilings, color as the mean offset. `use_luma_average` / `use_color_average` apply it per axis; `lock_bounds` exempts a frame. A scene pools and writes only its members, and the roll baseline skips them. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 11e9fc0a3..6f211ec32 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -181,18 +181,18 @@ A scene is a group of frames shot in the same light. It gets its own Roll Analys ## 3. Analysis readout -The readout above the Roll and Frame tabs describes the current frame while you edit. +This readout describes the current frame while you edit. #### Chart -The buttons above the chart pick one view: +Pick a chart view: -* **Curve**: the paper's curve, from the negative (left to right) to the print (bottom to top). Steeper means more contrast. The gray histogram is the negative, the colored one the print. +* **Curve**: the paper's curve, from the negative (across) to the print (up). Steeper means more contrast. The gray histogram is the negative, the colored one the print. * **Waveform**: print brightness across the frame. * **Parade**: red, green and blue side by side; a gray subject sits at one height in all three. **Parade Overlay** puts the three on one plot. * **Vectorscope**: the print's colors around a center point. A cluster off center is a color cast. -**Log** shows small areas, such as clipped ends. +**Log** shows small areas, like clipping. #### Clipping @@ -208,10 +208,12 @@ Click a zone on the strip, then a spot on the picture, and NegPy sets the print The **Readouts** fold holds numbers about the scan. An amber value is a warning; hover a row for details. +**Film Base** (the crosshair): click the clear film between frames to set the base the readouts measure from. + * **Negative**: the negative's density range (Δ). Hover for flat, normal or contrasty. * **Exposure**: bright (positive) or dark (negative) frame, in stops. * **Clipping**: share of the print that is pure black, then pure white. -* **Scan clip**: share of each channel, red, green and blue, at the scanner's maximum. No edit fixes it; expose the scan lower. +* **Scan clip**: share of each channel at the scanner's maximum. No edit fixes it; expose the scan lower. * **Repair**: share of the picture that dust removal rewrote. * **Gamut**: share the proof printer cannot print, while proofing. @@ -539,12 +541,12 @@ To turn a negative into a positive, NegPy measures its darkest and brightest par * **Reanalyze Frame**: measures this frame, or each selected frame, again from its current crop. The frame then uses its own bounds, not the roll or scene baseline. * **Draw Region**: draw an area, and NegPy measures only that area. Double-click inside or press `Enter` to confirm, and `Esc` puts back the region you had before. **×** clears it. -* **Film Base**: click the clear film between frames to set the film base for the Analysis readout. It is the roll's base, or this frame's own with no roll open. **×** clears it. * **Analysis Buffer**: ignores a border around the frame when measuring, so film edges and holders do not count. * **Clipping**: how much of the print is pure black or pure white. * **Lock Bounds**: keeps this frame's black and white points, even when the crop or Roll Analysis changes. * **Luma Range Clip** (fold): how tightly black and white are set. Positive gives more contrast, for dense or foggy negatives. Negative keeps more shadow and highlight detail. * **Color Clip** (fold): how tightly the color balance is set. +* **Dmin** / **Dmax** (Pick by Hand fold): for a frame that NegPy measures badly. Click **Dmin**, then the film base or the deepest shadow, to set the black point. Click **Dmax**, then the brightest highlight, to set the white point. Each **×** clears its own point. * **White Point** / **Black Point** (fold): move the measured white and black points. A positive white point brightens the print; a positive black point lifts the blacks. **R / G / B** moves one color only. A **slide** needs no inversion: the tone controls shape the picture directly. Auto Density and Auto Grade start off; turn them on for a faded slide. In Lightroom terms, Exposure is Print Density (lower is brighter), Contrast is ISO-R Grade, and Shadows and Highlights are Shadows and Highlights Density. diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 0785e31b5..3e71a1a87 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -3530,6 +3530,10 @@ def handle_canvas_clicked(self, nx: float, ny: float) -> None: self._handle_zone_pin(nx, ny) elif self.state.active_tool == ToolMode.FILM_BASE: self._handle_film_base_pick(nx, ny) + elif self.state.active_tool == ToolMode.DMIN_PICK: + self._handle_bounds_pick(nx, ny, "manual_dmin") + elif self.state.active_tool == ToolMode.DMAX_PICK: + self._handle_bounds_pick(nx, ny, "manual_dmax") def set_active_tool(self, mode: ToolMode) -> None: if mode != ToolMode.NONE and self._diptych_blocks_canvas(): @@ -3787,6 +3791,39 @@ def _handle_film_base_pick(self, nx: float, ny: float) -> None: self.set_status(tr("Film Base: this frame's roll is not in the library"), 4000, "warning") self.set_active_tool(ToolMode.NONE) # leaving the uncropped tool view re-renders + def _handle_bounds_pick(self, nx: float, ny: float, field: str) -> None: + """Set one picked end (manual_dmin, the thin end, or manual_dmax, the dense end) from the + patch median, read back through the window that rendered it, so it lands in the bounds' + own unmixed log space. One click: the tool disarms.""" + from negpy.features.exposure.normalization import log_from_val + + bounds = self.state.last_metrics.get("final_bounds") + patch = self._sample_normalized_patch(nx, ny, radius=4) + if bounds is None or patch is None: + self.set_status(tr("Pick by Hand: click once the frame has rendered"), 3000, "warning") + return + logs = np.asarray([log_from_val(tuple(px), bounds) for px in patch], dtype=np.float64) + picked = tuple(float(v) for v in np.median(logs, axis=0)) + self._set_bounds_picks(**{field: picked, f"{field}_at": (float(nx), float(ny))}) + self.set_status(tr("Dmin set for this frame") if field == "manual_dmin" else tr("Dmax set for this frame"), 3000) + self.set_active_tool(ToolMode.NONE) + + def _set_bounds_picks(self, **picks: Optional[tuple]) -> None: + # The persisted base holds the picks once rendered, so it is measured again even under Lock Bounds. + conf = self.state.config + proc = replace(conf.process, **picks, local_floors=(0.0, 0.0, 0.0), local_ceils=(0.0, 0.0, 0.0)) + self.apply_config(replace(conf, process=proc), persist=True) + + def has_bounds_picks(self) -> bool: + proc = self.state.config.process + return proc.manual_dmin is not None or proc.manual_dmax is not None + + def clear_bounds_pick(self, field: str) -> None: + if getattr(self.state.config.process, field) is None: + return + self._set_bounds_picks(**{field: None, f"{field}_at": None}) + self.tool_sync_requested.emit() + def film_base_for(self, asset: dict, process: Any) -> Optional[tuple]: """The clear film base a frame's readouts measure from. In an open roll, the roll's picked base wins over the frame's own; with no roll open, the frame's own wins over diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index c24d40878..00ef77ef9 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -71,11 +71,15 @@ class ToolMode(Enum): KEYSTONE_LINES = auto() ZONE_PLACE = auto() FILM_BASE = auto() + DMIN_PICK = auto() + DMAX_PICK = auto() # These tools' renders carry crop_preview_full, which skips the crop, the border and the filed carrier. # Film Base shows the whole scan too: the rebate it reads lies outside the crop. -UNCROPPED_PREVIEW_TOOLS = frozenset({ToolMode.CROP_MANUAL, ToolMode.ANALYSIS_DRAW, ToolMode.KEYSTONE_LINES, ToolMode.FILM_BASE}) +UNCROPPED_PREVIEW_TOOLS = frozenset( + {ToolMode.CROP_MANUAL, ToolMode.ANALYSIS_DRAW, ToolMode.KEYSTONE_LINES, ToolMode.FILM_BASE, ToolMode.DMIN_PICK, ToolMode.DMAX_PICK} +) # Esc puts back what these changed since they opened: each adjusts one thing as often as it likes. REVERTIBLE_TOOLS = frozenset({ToolMode.CROP_MANUAL, ToolMode.ANALYSIS_DRAW}) diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index 22b03938d..23b2447c2 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -194,6 +194,28 @@ def draw_view_badge(painter: QPainter, text: str, x: float, y: float, width: Opt painter.drawText(badge.adjusted(left, 0, -THEME.space_lg, 0), Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, text) +def draw_point_mark(painter: QPainter, p: QPointF, label: str, color: QColor, radius: float = _PIN_RADIUS_PX) -> None: + """A probed spot on the photo: a ring on a mark_shadow edge, a center dot and a caption to its right.""" + shadow = with_alpha(THEME.mark_shadow, 160) + painter.save() + painter.setFont(_overlay_label_font(painter)) + painter.setBrush(Qt.BrushStyle.NoBrush) + for pen_color, width in ((shadow, 3.5), (color, 1.5)): + pen = QPen(pen_color, width) + pen.setCosmetic(True) + painter.setPen(pen) + painter.drawEllipse(p, radius, radius) + # Centre dot: the ring alone leaves the probed pixel to guesswork. + painter.drawEllipse(p, width * 0.25, width * 0.25) + box = QRectF(p.x() + radius + 4.0, p.y() - 11.0, 140.0, 22.0) + align = Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft + painter.setPen(shadow) + painter.drawText(box.translated(1.0, 1.0), align, label) + painter.setPen(color) + painter.drawText(box, align, label) + painter.restore() + + def probe_lines(reading: Any) -> Tuple[str, str]: """The cursor probe's two lines: zone and print density, then ΔD per channel.""" dd = reading.dd_rgb @@ -1157,6 +1179,9 @@ def _draw_ui(self, painter: QPainter) -> None: if self._tool_mode == ToolMode.ANALYSIS_DRAW: self._draw_analysis_tool(painter) + if self._tool_mode in (ToolMode.DMIN_PICK, ToolMode.DMAX_PICK): + self._draw_bounds_picks(painter) + if ( self._buffer_overlay_visible and self._buffer_overlay_ratio > 1e-4 @@ -1895,28 +1920,18 @@ def _update_zone_hover(self, pos: QPointF) -> None: def _draw_zone_pins(self, painter: QPainter) -> None: """Zone-placement pins: a numbered ring per probed spot with its caption.""" - shadow = with_alpha(THEME.mark_shadow, 160) - painter.save() - painter.setFont(_overlay_label_font(painter)) - painter.setBrush(Qt.BrushStyle.NoBrush) for i, (pin, p) in enumerate(zip(self.state.zone_pins, self._zone_pin_screen_points())): color = QColor(PIN_COLORS[i]) if i < len(PIN_COLORS) else with_alpha(THEME.mark_photo, 230) radius = _PIN_RADIUS_PX + (2.0 if i == self._pin_drag_index else 0.0) - for pen_color, width in ((shadow, 3.5), (color, 1.5)): - pen = QPen(pen_color, width) - pen.setCosmetic(True) - painter.setPen(pen) - painter.drawEllipse(p, radius, radius) - # Centre dot: the ring alone leaves the probed pixel to guesswork. - painter.drawEllipse(p, width * 0.25, width * 0.25) - label = zone_pin_caption(i, pin) - tr = QRectF(p.x() + radius + 4.0, p.y() - 11.0, 140.0, 22.0) - align = Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft - painter.setPen(shadow) - painter.drawText(tr.translated(1.0, 1.0), align, label) - painter.setPen(color) - painter.drawText(tr, align, label) - painter.restore() + draw_point_mark(painter, p, zone_pin_caption(i, pin), color, radius) + + def _draw_bounds_picks(self, painter: QPainter) -> None: + """Where Dmin and Dmax were picked, while either pick tool shows the uncropped frame.""" + proc, rect = self.state.config.process, self._content_view_rect() + for at, label in ((proc.manual_dmin_at, tr("Dmin")), (proc.manual_dmax_at, tr("Dmax"))): + if at is not None and not rect.isEmpty(): + p = QPointF(rect.x() + at[0] * rect.width(), rect.y() + at[1] * rect.height()) + draw_point_mark(painter, p, label, QColor(THEME.mark_photo)) def _draw_grain_loupe(self, painter: QPainter) -> None: """The darkroom grain magnifier: a circular window at the cursor showing the frame's diff --git a/negpy/desktop/view/canvas/toolbar.py b/negpy/desktop/view/canvas/toolbar.py index 43a2ca51f..24da2554f 100644 --- a/negpy/desktop/view/canvas/toolbar.py +++ b/negpy/desktop/view/canvas/toolbar.py @@ -241,6 +241,8 @@ class ToolbarItem: ToolMode.LOCAL_GRADIENT: (tr("Card Edge"), tr("Drag from the full-exposure edge to where it fades out"), ""), ToolMode.ANALYSIS_DRAW: (tr("Draw Region"), tr("Drag the area the meters read"), ""), ToolMode.FILM_BASE: (tr("Film Base"), tr("Click the clear rebate to set the roll's film base"), ""), + ToolMode.DMIN_PICK: (tr("Dmin"), tr("Click the thinnest spot of the negative: the film base or the deepest shadow"), ""), + ToolMode.DMAX_PICK: (tr("Dmax"), tr("Click the densest spot of the negative: the brightest highlight"), ""), ToolMode.STRAIGHTEN: (tr("Level"), tr("Draw a line along a horizon or an edge to level the frame"), _FINE_ROTATION), ToolMode.KEYSTONE_LINES: ( tr("Keystone Lines"), @@ -254,6 +256,8 @@ class ToolbarItem: # The Tools menu, in panel order: a panel section's title, then its tools as (tool, action id, sidebar button # path from MainWindow). Zone Placement has no entry: it arms only with a zone picked on the zone strip. _CANVAS_TOOLS: tuple[tuple[ToolMode, str, str] | str, ...] = ( + tr("Analysis"), + (ToolMode.FILM_BASE, "film_base", "right_panel.film_base_btn"), tr("Crop"), (ToolMode.CROP_MANUAL, "manual_crop", "controls_panel.autocrop_sidebar.manual_crop_btn"), tr("Straighten"), @@ -261,7 +265,8 @@ class ToolbarItem: (ToolMode.KEYSTONE_LINES, "keystone_lines", "controls_panel.geometry_sidebar.keystone_lines_btn"), tr("Metering"), (ToolMode.ANALYSIS_DRAW, "analysis_draw", "controls_panel.process_sidebar.analysis_region_btn"), - (ToolMode.FILM_BASE, "film_base", "controls_panel.process_sidebar.film_base_btn"), + (ToolMode.DMIN_PICK, "dmin_pick", "controls_panel.process_sidebar.dmin_btn"), + (ToolMode.DMAX_PICK, "dmax_pick", "controls_panel.process_sidebar.dmax_btn"), tr("Filtration"), (ToolMode.WB_PICK, "pick_wb", "controls_panel.color_sidebar.pick_wb_btn"), tr("Dodge & Burn"), diff --git a/negpy/desktop/view/canvas/widget.py b/negpy/desktop/view/canvas/widget.py index 53caeb14d..7fa443651 100644 --- a/negpy/desktop/view/canvas/widget.py +++ b/negpy/desktop/view/canvas/widget.py @@ -55,6 +55,8 @@ def clamp_canvas_zoom_level(zoom: float) -> float: ToolMode.LOCAL_GRADIENT: Qt.CursorShape.CrossCursor, ToolMode.ANALYSIS_DRAW: Qt.CursorShape.CrossCursor, ToolMode.FILM_BASE: Qt.CursorShape.CrossCursor, + ToolMode.DMIN_PICK: Qt.CursorShape.CrossCursor, + ToolMode.DMAX_PICK: Qt.CursorShape.CrossCursor, ToolMode.STRAIGHTEN: Qt.CursorShape.CrossCursor, ToolMode.KEYSTONE_LINES: Qt.CursorShape.CrossCursor, ToolMode.ZONE_PLACE: Qt.CursorShape.CrossCursor, diff --git a/negpy/desktop/view/keyboard_shortcuts.py b/negpy/desktop/view/keyboard_shortcuts.py index 8637016fa..3a6cc849c 100644 --- a/negpy/desktop/view/keyboard_shortcuts.py +++ b/negpy/desktop/view/keyboard_shortcuts.py @@ -174,16 +174,19 @@ def _context_cancel(controller, window) -> None: controller.revert_active_tool() -def _toggle_tool_button(window, tab_key: str, button) -> None: +def _toggle_tool_button(window, tab_key: str | None, button) -> None: """Reveal the tool's tab first: the tab-switch suspend/restore logic only runs on switches, so activating a tool while its tab is hidden would leave it live - with its controls off-screen.""" - window.right_panel.show_tab_by_key(tab_key) + with its controls off-screen. None for a tool above the tabs.""" + if tab_key is not None: + window.right_panel.show_tab_by_key(tab_key) # toggle() bypasses the enabled state: a gated tool must not arm, but an armed one always goes down. if button.isChecked() or (button.isEnabled() and not hidden_by_gating(button)): button.toggle() else: - window.controller.set_status(tr("{value} not available", value=button.text().strip()), 1500, kind="warning") + # An icon-only tool names itself in its tooltip ("Film Base: ..."). + name = button.text().strip() or getattr(button, "plain_tooltip", "").split(":")[0] + window.controller.set_status(tr("{value} not available", value=name), 1500, kind="warning") def _slider_name(slider: object, group: SliderShortcutGroup) -> str: @@ -544,7 +547,9 @@ def _build_actions(self) -> dict[str, Callable[[], None]]: "local_oval": lambda: _toggle_tool_button(self.window, "tone", controls.local_sidebar.oval_btn), "local_gradient": lambda: _toggle_tool_button(self.window, "tone", controls.local_sidebar.gradient_btn), "analysis_draw": lambda: _toggle_tool_button(self.window, "roll", controls.process_sidebar.analysis_region_btn), - "film_base": lambda: _toggle_tool_button(self.window, "roll", controls.process_sidebar.film_base_btn), + "film_base": lambda: _toggle_tool_button(self.window, None, self.window.right_panel.film_base_btn), + "dmin_pick": lambda: _toggle_tool_button(self.window, "roll", controls.process_sidebar.dmin_btn), + "dmax_pick": lambda: _toggle_tool_button(self.window, "roll", controls.process_sidebar.dmax_btn), "open_tools_menu": lambda: self.window.toolbar.open_tools_menu(), "toggle_flat_peek": controller.toggle_flat_peek, "toggle_negative_peek": controller.toggle_negative_peek, diff --git a/negpy/desktop/view/shortcut_registry.py b/negpy/desktop/view/shortcut_registry.py index 8534fe1b4..e743c7863 100644 --- a/negpy/desktop/view/shortcut_registry.py +++ b/negpy/desktop/view/shortcut_registry.py @@ -81,6 +81,8 @@ class ShortcutEntry: "local_gradient": ShortcutEntry("", tr("Toggle dodge & burn card-edge mask draw"), tr("Tools")), "analysis_draw": ShortcutEntry("Ctrl+R", tr("Toggle analysis region draw"), tr("Tools")), "film_base": ShortcutEntry("", tr("Toggle film base pick"), tr("Tools")), + "dmin_pick": ShortcutEntry("", tr("Toggle Dmin pick"), tr("Tools")), + "dmax_pick": ShortcutEntry("", tr("Toggle Dmax pick"), tr("Tools")), "open_tools_menu": ShortcutEntry("", tr("Open the Tools menu: every canvas tool"), tr("Tools")), "toggle_flat_peek": ShortcutEntry("M", tr("Peek flat scan (digital intermediate); hold to peek only while down"), tr("Tools")), "toggle_negative_peek": ShortcutEntry("N", tr("Peek negative (source as loaded); hold to peek only while down"), tr("Tools")), diff --git a/negpy/desktop/view/sidebar/controls_panel.py b/negpy/desktop/view/sidebar/controls_panel.py index 156dcda08..33903fe85 100644 --- a/negpy/desktop/view/sidebar/controls_panel.py +++ b/negpy/desktop/view/sidebar/controls_panel.py @@ -10,7 +10,7 @@ from negpy.desktop.controller import AppController from negpy.desktop.view.shortcut_registry import label_with_shortcut, tooltip_with_shortcut from negpy.desktop.view.styles.color_vision import palette_for -from negpy.desktop.view.styles.templates import gradient_icon, header_row, hint_label, section_subheader, set_hint_kind, wrap_tooltip +from negpy.desktop.view.styles.templates import gradient_icon, header_actions, hint_label, section_subheader, set_hint_kind, wrap_tooltip from negpy.desktop.view.widgets.collapsible import NO_ROLL_SCOPE_HINT, CollapsibleSection, make_section from negpy.desktop.view.widgets.charts import MiniCurveWidget, MiniHistogramWidget, MiniRGBHistogramWidget from negpy.desktop.view.styles.theme import THEME @@ -92,6 +92,10 @@ _METERING_FIELDS = ( "analysis_buffer", "analysis_rect", + "manual_dmin", + "manual_dmax", + "manual_dmin_at", + "manual_dmax_at", "lock_bounds", "luma_range_clip", "color_range_clip", @@ -197,7 +201,9 @@ def _init_ui(self) -> None: optics_layout.addWidget(section_subheader(tr("LENS CORRECTION"))) optics_layout.addWidget(self.lens_sidebar) optics_layout.addLayout( - header_row(section_subheader(tr("FLAT FIELD CORRECTION")), self.flatfield_sidebar.add_btn, self.flatfield_sidebar.delete_btn) + header_actions( + section_subheader(tr("FLAT FIELD CORRECTION")), self.flatfield_sidebar.add_btn, self.flatfield_sidebar.delete_btn + ) ) optics_layout.addWidget(self.flatfield_sidebar) self.optics_section = self._make_section( @@ -1298,7 +1304,7 @@ def _sync_modified_dots(self) -> None: proc = cfg.process film_count = sum(getattr(proc, f) != getattr(_proc, f) for f in _FILM_FIELDS) - process_count = sum(getattr(proc, f) != getattr(_proc, f) for f in _METERING_FIELDS) + process_count = sum(getattr(proc, f) != getattr(_proc, f) for f in _METERING_FIELDS if not f.endswith("_at")) demosaic_count = sum(getattr(proc, f) != getattr(_proc, f) for f in _DEMOSAIC_FIELDS) sensor_count = sum(getattr(proc, f) != getattr(_proc, f) for f in _SENSOR_FIELDS) diff --git a/negpy/desktop/view/sidebar/flatfield.py b/negpy/desktop/view/sidebar/flatfield.py index 7009b216e..07a2c7aff 100644 --- a/negpy/desktop/view/sidebar/flatfield.py +++ b/negpy/desktop/view/sidebar/flatfield.py @@ -52,8 +52,8 @@ def _init_ui(self) -> None: self.check_btn.setFixedWidth(ICON_BUTTON_WIDTH) row.addWidget(self.check_btn) - self.add_btn = self._icon_action("fa5s.plus", tr("Pick a reference image and save it as a named profile")) - self.delete_btn = self._icon_action("fa5s.trash", tr("Remove the selected profile")) + self.add_btn = self._labeled_action("fa5s.plus", tr("Add"), tr("Pick a reference image and save it as a named profile")) + self.delete_btn = self._labeled_action("fa5s.trash", tr("Remove"), tr("Remove the selected profile")) # add_btn and delete_btn go on the card's FLAT FIELD CORRECTION header (ControlsPanel). self.layout.addLayout(row) diff --git a/negpy/desktop/view/sidebar/process.py b/negpy/desktop/view/sidebar/process.py index fcee1f8ce..77a476c51 100644 --- a/negpy/desktop/view/sidebar/process.py +++ b/negpy/desktop/view/sidebar/process.py @@ -14,11 +14,11 @@ from negpy.desktop.view.sidebar.base import BaseSidebar, SliderPage from negpy.desktop.view.sidebar.tone import _CH_LABEL, _CH_SUFFIX, channel_pages, channel_selector from negpy.desktop.view.styles.templates import ( - ICON_BUTTON_WIDTH, + channel_markup, clearable_tool_toggle, field_row, + header_actions, hint_label, - header_row, section_subheader, set_hint_kind, wrap_tooltip, @@ -97,6 +97,14 @@ def _color_value_to_slider(v: float) -> float: return -100.0 * (lv - ln) / (math.log10(_COLOR_CLIP_MIN) - ln) +def _picked_densities(picked: tuple | None) -> str: + """A picked end as per-channel density (minus log10 transmittance), or Auto.""" + if picked is None: + return tr("Auto") + values = channel_markup([f"{name} {-v:.2f}" for name, v in zip("RGB", picked)]) + return tr("Picked: {values}", values=values) + + class ProcessSidebar(BaseSidebar): """ Panel for core film processing, normalization, and roll management. @@ -152,7 +160,7 @@ def _init_ui(self) -> None: self.lock_bounds_btn = self._small_toggle( "fa5s.lock", - "", + tr("Lock"), False, tr("Lock Bounds: keep this frame's measured black and white points, even when the crop or Roll Analysis changes."), ) @@ -167,10 +175,11 @@ def _init_ui(self) -> None: analysis_col.setContentsMargins(0, 0, 0, 0) analysis_col.setSpacing(THEME.space_sm) self.analysis_buffer_slider = CompactSlider(tr("Analysis Buffer"), 0.0, 0.25, conf.analysis_buffer) - self.reanalyze_frame_btn = self._icon_action("fa5s.redo", tr("Reanalyze Frame: measure this frame again from its current crop.")) - self.lock_bounds_btn.setFixedWidth(ICON_BUTTON_WIDTH) + self.reanalyze_frame_btn = self._labeled_action( + "fa5s.redo", tr("Reanalyze Frame"), tr("Reanalyze Frame: measure this frame again from its current crop.") + ) self.analysis_header = section_subheader(tr("ANALYSIS")) - analysis_col.addLayout(header_row(self.analysis_header, self.reanalyze_frame_btn, self.lock_bounds_btn)) + analysis_col.addLayout(header_actions(self.analysis_header, self.lock_bounds_btn)) self.clipping_hint = hint_label("") self.clipping_hint.setToolTip(wrap_tooltip(CLIPPING_TOOLTIP)) analysis_col.addWidget(self.clipping_hint) @@ -183,16 +192,9 @@ def _init_ui(self) -> None: tr("Clear Region: measure with the Analysis Buffer again."), ) self.clear_analysis_region_btn = self.analysis_region_btn.clear_btn - self.film_base_btn = clearable_tool_toggle( - "fa5s.crosshairs", - tr("Film Base"), - tr("Film Base: click the clear film between frames to set the film base of the open roll, or of this frame without one."), - tr("Clear Film Base: forget the film base this frame uses."), - ) - self.clear_film_base_btn = self.film_base_btn.clear_btn tools_row = QHBoxLayout() + tools_row.addWidget(self.reanalyze_frame_btn, 1) tools_row.addWidget(self.analysis_region_btn, 1) - tools_row.addWidget(self.film_base_btn, 1) analysis_col.addLayout(tools_row) initial_luma_slider_val = _luma_range_value_to_slider(conf.luma_range_clip) @@ -207,6 +209,26 @@ def _init_ui(self) -> None: analysis_col.addWidget(self.analysis_buffer_slider) analysis_col.addWidget(self.clip_fold) + self.dmin_btn = clearable_tool_toggle( + "mdi6.circle-outline", + tr("Dmin: Thinnest Spot"), + tr("Dmin: click the film base or the deepest shadow to set this frame's black point by hand."), + tr("Clear Dmin: measure the black point automatically again."), + ) + self.dmax_btn = clearable_tool_toggle( + "mdi6.circle", + tr("Dmax: Densest Spot"), + tr("Dmax: click the brightest highlight to set this frame's white point by hand."), + tr("Clear Dmax: measure the white point automatically again."), + ) + self.dmin_hint = hint_label("") + self.dmax_hint = hint_label("") + pick_row, self.pick_fold = group_fold( + "metering_picks", tr("Pick by Hand"), self.dmin_btn, self.dmin_hint, self.dmax_btn, self.dmax_hint + ) + analysis_col.addLayout(pick_row) + analysis_col.addWidget(self.pick_fold) + self.ch_btn = channel_selector( tr("Global changes all colors, and R, G or B changes one color layer."), ) @@ -285,8 +307,9 @@ def _connect_signals(self) -> None: self.analysis_buffer_slider.dragEnded.connect(lambda: self.controller.analysis_buffer_drag_changed.emit(False)) self.analysis_region_btn.toggled.connect(self._on_analysis_region_toggled) self.clear_analysis_region_btn.clicked.connect(self.controller.clear_analysis_region) - self.film_base_btn.toggled.connect(lambda on: self.controller.set_active_tool(ToolMode.FILM_BASE if on else ToolMode.NONE)) - self.clear_film_base_btn.clicked.connect(self.controller.clear_film_base) + for btn, mode, field in ((self.dmin_btn, ToolMode.DMIN_PICK, "manual_dmin"), (self.dmax_btn, ToolMode.DMAX_PICK, "manual_dmax")): + btn.toggled.connect(lambda on, mode=mode: self.controller.set_active_tool(mode if on else ToolMode.NONE)) + btn.clear_btn.clicked.connect(lambda _=False, field=field: self.controller.clear_bounds_pick(field)) self.luma_range_clip_slider.valueChanged.connect(lambda v: self._on_luma_range_clip_changed(v, persist=False)) self.luma_range_clip_slider.valueCommitted.connect(lambda v: self._on_luma_range_clip_changed(v, persist=True)) @@ -502,14 +525,18 @@ def sync_ui(self) -> None: has_region = conf.analysis_rect is not None self.analysis_region_btn.setChecked(self.state.active_tool == ToolMode.ANALYSIS_DRAW) self.clear_analysis_region_btn.setVisible(has_region) - has_base = self.controller.clearable_film_base() - self.film_base_btn.setChecked(self.state.active_tool == ToolMode.FILM_BASE) - self.clear_film_base_btn.setVisible(has_base) + for btn, hint, mode, picked in ( + (self.dmin_btn, self.dmin_hint, ToolMode.DMIN_PICK, conf.manual_dmin), + (self.dmax_btn, self.dmax_hint, ToolMode.DMAX_PICK, conf.manual_dmax), + ): + btn.setChecked(self.state.active_tool == mode) + btn.clear_btn.setVisible(picked is not None) + hint.setText(_picked_densities(picked)) for w in ( self.analysis_buffer_slider, self.analysis_region_btn, - self.film_base_btn, + self.pick_fold.content, self.avg_header, self.use_luma_avg_btn, self.use_color_avg_btn, @@ -552,8 +579,9 @@ def sync_ui(self) -> None: # region overrides it entirely. self.analysis_buffer_slider.setEnabled(not locked and not has_region and not (conf.use_luma_average and conf.use_color_average)) self._sync_reanalyze_btn(locked) - self.luma_range_clip_slider.setEnabled(not locked and not conf.use_luma_average) - self.color_range_clip_slider.setEnabled(not locked and not conf.use_color_average) + both_picked = conf.manual_dmin is not None and conf.manual_dmax is not None + self.luma_range_clip_slider.setEnabled(not locked and not conf.use_luma_average and not both_picked) + self.color_range_clip_slider.setEnabled(not locked and not conf.use_color_average and not both_picked) finally: self.block_signals(False) diff --git a/negpy/desktop/view/sidebar/retouch.py b/negpy/desktop/view/sidebar/retouch.py index 2bc5c77bf..83d810520 100644 --- a/negpy/desktop/view/sidebar/retouch.py +++ b/negpy/desktop/view/sidebar/retouch.py @@ -4,7 +4,7 @@ from negpy.desktop.view.widgets.sliders import CompactSlider, SliderGroup from negpy.desktop.view.sidebar.base import BaseSidebar from negpy.desktop.session import ToolMode -from negpy.desktop.view.styles.templates import ICON_BUTTON_WIDTH, field_row, header_row, hint_label, section_subheader, wrap_tooltip +from negpy.desktop.view.styles.templates import ICON_BUTTON_WIDTH, field_row, header_actions, hint_label, section_subheader, wrap_tooltip from negpy.features.retouch.models import HEAL_SIZE_MAX, HEAL_SIZE_MIN _IR_REMOVAL_TIP = tr("IR Removal: remove dust and scratches with the scanner's infrared channel.") @@ -79,9 +79,11 @@ def _init_ui(self) -> None: tr("Line"), tr("Line: click once on a long straight scratch, and the whole line is found and repaired."), ) - self.undo_btn = self._icon_action("fa5s.undo", tr("Undo Last: remove the most recent manual heal")) - self.clear_btn = self._icon_action("fa5s.trash-alt", tr("Clear All: remove all manual heals and keep the automatic dust removal.")) - self.layout.addLayout(header_row(self.heals_subheader, self.undo_btn, self.clear_btn)) + self.undo_btn = self._labeled_action("fa5s.undo", tr("Undo"), tr("Undo Last: remove the most recent manual heal")) + self.clear_btn = self._labeled_action( + "fa5s.trash-alt", tr("Clear"), tr("Clear All: remove all manual heals and keep the automatic dust removal.") + ) + self.layout.addLayout(header_actions(self.heals_subheader, self.undo_btn, self.clear_btn)) tools_row = QHBoxLayout() for btn in (self.pick_dust_btn, self.pick_scratch_btn, self.pick_line_btn): tools_row.addWidget(btn, 1) @@ -107,9 +109,9 @@ def _init_ui(self) -> None: conf.clone_match_tone, tr("Keep the copied grain and detail, but match the brightness and color around the destination"), ) - self.clone_undo_btn = self._icon_action("fa5s.undo", tr("Undo Last: remove the most recent clone stroke")) - self.clone_clear_btn = self._icon_action("fa5s.trash-alt", tr("Clear All: remove all clone strokes")) - self.layout.addLayout(header_row(self.clone_subheader, self.clone_undo_btn, self.clone_clear_btn)) + self.clone_undo_btn = self._labeled_action("fa5s.undo", tr("Undo"), tr("Undo Last: remove the most recent clone stroke")) + self.clone_clear_btn = self._labeled_action("fa5s.trash-alt", tr("Clear"), tr("Clear All: remove all clone strokes")) + self.layout.addLayout(header_actions(self.clone_subheader, self.clone_undo_btn, self.clone_clear_btn)) self.clone_source_btn = self._tool_toggle( "fa5s.crosshairs", tr("Set Source"), diff --git a/negpy/desktop/view/sidebar/right_panel.py b/negpy/desktop/view/sidebar/right_panel.py index 8bdad9672..665894ae2 100644 --- a/negpy/desktop/view/sidebar/right_panel.py +++ b/negpy/desktop/view/sidebar/right_panel.py @@ -22,7 +22,7 @@ from negpy.desktop.view.sidebar.library_tree import LibraryTree from negpy.desktop.view.sidebar.metadata import MetadataSidebar from negpy.desktop.view.styles.color_vision import palette_for -from negpy.desktop.view.styles.templates import clipping_sweep, gradient_icon, tool_toggle +from negpy.desktop.view.styles.templates import ICON_BUTTON_WIDTH, clipping_sweep, gradient_icon, icon_button, tool_toggle from negpy.desktop.view.styles.theme import THEME from negpy.desktop.view.widgets.segmented_choice import SegmentedChoice from negpy.desktop.view.widgets.sliders import align_slider_columns @@ -45,6 +45,9 @@ from negpy.desktop.view.widgets.overflow_bar import OverflowBar from negpy.desktop.view.widgets.panel_tab import PanelTab +_FILM_BASE_TIP = tr( + "Film Base: click the clear film between frames to set the base these readouts measure from, for the open roll or this frame." +) _SCAN_BASE_TIP = tr("Base Relative: line up each channel at its film base, so the channels compare at one height.") # The Roll tab's cards that own settings, for its header's Apply picker. @@ -315,7 +318,12 @@ def _build_frame_page(self, wrap_scroll) -> QWidget: analysis_layout.addLayout(chart_row) analysis_layout.addWidget(ChartWell(self.chart_stack, self.step_wedge, self.zone_strip), 1) analysis_layout.addWidget(self.zone_placement, 0) - readouts_header, self.readouts_fold = group_fold("analysis_readouts", tr("Readouts"), self.stats_widget) + self.film_base_btn = tool_toggle("fa5s.crosshairs", "", _FILM_BASE_TIP) + self.film_base_btn.setFixedWidth(ICON_BUTTON_WIDTH) + self.clear_film_base_btn = icon_button("fa5s.times", tr("Clear Film Base: forget the film base this frame uses.")) + readouts_header, self.readouts_fold = group_fold( + "analysis_readouts", tr("Readouts"), self.stats_widget, buttons=(self.clear_film_base_btn, self.film_base_btn) + ) self.stats_widget.warning_changed.connect(self.readouts_fold.set_warning) analysis_layout.addLayout(readouts_header) analysis_layout.addWidget(self.readouts_fold, 0) @@ -760,6 +768,29 @@ def _connect_signals(self) -> None: self._sync_debounce.timeout.connect(self.metadata_sidebar.sync_ui) self.controller.config_updated.connect(self._sync_debounce.start) + from negpy.desktop.session import ToolMode + + self.film_base_btn.toggled.connect(lambda on: self.controller.set_active_tool(ToolMode.FILM_BASE if on else ToolMode.NONE)) + self.clear_film_base_btn.clicked.connect(self.controller.clear_film_base) + self.controller.config_updated.connect(self._sync_film_base) + self.controller.tool_sync_requested.connect(self._sync_film_base) + self._sync_film_base() + + def _sync_film_base(self) -> None: + """Film Base sits on the Readouts header: armed state, the dot and × for a picked base, and none on a slide.""" + from negpy.desktop.session import ToolMode + from negpy.features.process.path import RenderPath, render_path + + state = self.controller.state + negative = render_path(state.config.process) is RenderPath.PRINT + has_base = negative and self.controller.clearable_film_base() + self.film_base_btn.blockSignals(True) + self.film_base_btn.setChecked(state.active_tool == ToolMode.FILM_BASE) + self.film_base_btn.blockSignals(False) + self.film_base_btn.setVisible(negative) + self.film_base_btn.edited_dot.set_active(has_base) + self.clear_film_base_btn.setVisible(has_base) + def _sync_tab_edited(self) -> None: """Mark control-group tabs whose sections have edits (corner dot, like edited sliders).""" for i, attrs in self._tab_sections.items(): diff --git a/negpy/desktop/view/sidebar/roll.py b/negpy/desktop/view/sidebar/roll.py index f6b1d357f..073e88298 100644 --- a/negpy/desktop/view/sidebar/roll.py +++ b/negpy/desktop/view/sidebar/roll.py @@ -8,7 +8,7 @@ from negpy.desktop.view.sidebar.base import BaseSidebar from negpy.desktop.view.styles.templates import ( field_label, - header_row, + header_actions, hint_label, labeled_action, section_subheader, @@ -55,6 +55,8 @@ def _init_ui(self) -> None: self.no_roll_hint = hint_label(ROLL_ANALYSIS_NEEDS_A_ROLL) outer.addWidget(self.no_roll_hint) self.body = QWidget() + # Nested in a card body, so it takes the body's transparent rule, not the global QWidget fill. + self.body.setObjectName("collapsible_content_body") self.layout = QVBoxLayout(self.body) self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setSpacing(outer.spacing()) @@ -62,9 +64,9 @@ def _init_ui(self) -> None: self.roll_combo = SearchableGearCombo(placeholder=tr("Search rolls…")) self.roll_combo.setToolTip(wrap_tooltip(tr("Picking a roll loads its saved baseline onto the loaded files."))) - self.reanalyze_btn = self._icon_action("fa5s.tachometer-alt", BATCH_ANALYSIS_TOOLTIP) - self.from_frame_btn = self._icon_action("fa5s.crosshairs", FROM_FRAME_TOOLTIP) - self.layout.addLayout(header_row(section_subheader(tr("ROLLS")), self.reanalyze_btn, self.from_frame_btn)) + self.reanalyze_btn = self._labeled_action("fa5s.tachometer-alt", tr("Reanalyze"), BATCH_ANALYSIS_TOOLTIP) + self.from_frame_btn = self._labeled_action("fa5s.crosshairs", tr("Use Frame"), FROM_FRAME_TOOLTIP) + self.layout.addLayout(header_actions(section_subheader(tr("ROLLS")), self.reanalyze_btn, self.from_frame_btn)) self.layout.addWidget(hint_label(tr("Roll Analysis first crops the frames that wait for Auto Crop."), "muted")) self.layout.addWidget(self.roll_combo) @@ -72,8 +74,8 @@ def _init_ui(self) -> None: self.layout.addWidget(self.roll_status_hint) self.scenes_header = section_subheader(tr("SCENES")) - self.detect_scenes_btn = self._icon_action("mdi6.lightbulb-group-outline", DETECT_SCENES_TOOLTIP) - self.layout.addLayout(header_row(self.scenes_header, self.detect_scenes_btn)) + self.detect_scenes_btn = self._labeled_action("mdi6.lightbulb-group-outline", tr("Detect"), DETECT_SCENES_TOOLTIP) + self.layout.addLayout(header_actions(self.scenes_header, self.detect_scenes_btn)) self.scene_rows = QWidget() self._scene_rows_layout = QVBoxLayout(self.scene_rows) self._scene_rows_layout.setContentsMargins(0, 0, 0, 0) diff --git a/negpy/desktop/view/sidebar/scanlight.py b/negpy/desktop/view/sidebar/scanlight.py index 0e9b7fe29..8c88d68f6 100644 --- a/negpy/desktop/view/sidebar/scanlight.py +++ b/negpy/desktop/view/sidebar/scanlight.py @@ -38,14 +38,16 @@ from negpy.desktop.view.sidebar.scan_output import ScanOutputPanel from negpy.desktop.view.styles.color_vision import palette_for from negpy.desktop.view.styles.templates import ( + ICON_BUTTON_WIDTH, + SCAN_BUTTON_HEIGHT, field_row, + header_actions, header_row, hint_label, - ICON_BUTTON_WIDTH, icon_button, icon_text, + labeled_action, labeled_toggle, - SCAN_BUTTON_HEIGHT, section_subheader, set_hint_kind, StatusStrip, @@ -465,11 +467,12 @@ def _init_ui(self) -> None: _exp = QVBoxLayout(self._exposure_widget) _exp.setContentsMargins(0, 0, 0, 0) _exp.setSpacing(THEME.space_md) - self.meter_btn = icon_button( + self.meter_btn = labeled_action( "fa5s.tachometer-alt", + tr("Meter"), tr("Meter: set the shutter so the film base lands just under clipping, at this ISO and aperture."), ) - _exp.addLayout(header_row(section_subheader(tr("CAMERA EXPOSURE")), self.meter_btn)) + _exp.addLayout(header_actions(section_subheader(tr("CAMERA EXPOSURE")), self.meter_btn)) self.capture_btn = StepCombo.of(CAPTURE_MODES, CAPTURE_MODE_TOOLTIP) self.capture_btn.setEnabled(False) # the preset's mode, editable only while building a manual preset self.capture_mode_row = QWidget() # a white-light preset has one exposure, so the row hides there diff --git a/negpy/desktop/view/styles/templates.py b/negpy/desktop/view/styles/templates.py index 3dff3017b..55d2a3b6a 100644 --- a/negpy/desktop/view/styles/templates.py +++ b/negpy/desktop/view/styles/templates.py @@ -321,6 +321,14 @@ class Sweep: # Each sweep shows what its tool changes or paints. # Red to green to blue, for a tool that works on the whole image at once (Pick Better). RGB_SWEEP = Sweep((THEME.channel_red_text, THEME.channel_green_text, THEME.channel_blue_text)) + + +def channel_markup(parts: Iterable[str]) -> str: + """Rich text for an R · G · B readout, each part in its channel's text color.""" + colors = (THEME.channel_red_text, THEME.channel_green_text, THEME.channel_blue_text) + return " · ".join(f'{part}' for color, part in zip(colors, parts)) + + # The two filters a ring-around varies. RING_AROUND_SWEEP = Sweep((THEME.filter_magenta, THEME.filter_yellow)) # Light to dark on the diagonal: a test strip's density runs across it and its grade down it. @@ -624,13 +632,29 @@ def header_row(header: QLabel, *buttons: QWidget) -> QHBoxLayout: return row +def header_actions(header: QLabel, *buttons: QPushButton) -> QHBoxLayout: + """header_row for labeled actions (labeled_action, labeled_toggle): a pair shares the row's + right half and a lone action takes a quarter, in line with a pair above it.""" + row = header_row(header) + row.setStretch(0, 2 * max(len(buttons), 2) - len(buttons)) + # Never narrower than the longest label: a _LabeledButton below its size hint drops its text. + widest = max(btn.sizeHint().width() for btn in buttons) + for btn in buttons: + # Ignored: the stretch alone sets the width, so every action in a card lines up. + btn.setSizePolicy(QSizePolicy.Policy.Ignored, btn.sizePolicy().verticalPolicy()) + btn.setMinimumWidth(widest) + row.addWidget(btn, 1) + return row + + def section_subheader(text: str) -> QLabel: - """Small all-caps label for section grouping in sidebars; wraps in a panel too narrow for it.""" + """All-caps label for section grouping in sidebars; wraps in a panel too narrow for it. + FoldButton draws a folding sub-header in the same size and color.""" lbl = QLabel(tr(text).upper()) lbl.setWordWrap(True) lbl.setStyleSheet( - f"font-size: {THEME.font_size_small}px; " - f"color: {THEME.text_hint}; " + f"font-size: {THEME.font_size_base}px; " + f"color: {THEME.text_secondary}; " f"font-weight: {THEME.weight_semibold}; " f"margin-top: {THEME.space_xl}px;" ) diff --git a/negpy/desktop/view/widgets/export_settings_form.py b/negpy/desktop/view/widgets/export_settings_form.py index 02f625597..3c478d6c5 100644 --- a/negpy/desktop/view/widgets/export_settings_form.py +++ b/negpy/desktop/view/widgets/export_settings_form.py @@ -18,7 +18,15 @@ ) from negpy.desktop.view.widgets.segmented_choice import SegmentedChoice -from negpy.desktop.view.styles.templates import field_row, header_row, hint_label, icon_button, section_subheader, tool_toggle +from negpy.desktop.view.styles.templates import ( + field_row, + header_actions, + hint_label, + icon_button, + labeled_action, + section_subheader, + tool_toggle, +) from negpy.desktop.view.styles.theme import THEME from negpy.desktop.view.widgets.sliders import CompactSlider, SliderGroup, align_slider_columns from negpy.desktop.view.widgets.step_combo import StepCombo @@ -306,11 +314,11 @@ def _build_color(self, parent: QVBoxLayout) -> None: root.setSpacing(10) parent.addWidget(self._color_section) - self.icc_import_btn = icon_button( - "fa5s.folder-open", tr("Import an ICC profile into {user_icc_dir}", user_icc_dir=APP_CONFIG.user_icc_dir) + self.icc_import_btn = labeled_action( + "fa5s.folder-open", tr("Import"), tr("Import an ICC profile into {user_icc_dir}", user_icc_dir=APP_CONFIG.user_icc_dir) ) self.icc_import_btn.clicked.connect(self._import_icc) - root.addLayout(header_row(section_subheader(tr("COLOR MANAGEMENT")), self.icc_import_btn)) + root.addLayout(header_actions(section_subheader(tr("COLOR MANAGEMENT")), self.icc_import_btn)) self.input_combo = StepCombo() constrain_combo(self.input_combo) diff --git a/negpy/desktop/view/widgets/fold.py b/negpy/desktop/view/widgets/fold.py index 0577155f6..786678896 100644 --- a/negpy/desktop/view/widgets/fold.py +++ b/negpy/desktop/view/widgets/fold.py @@ -33,7 +33,7 @@ def __init__(self, text: str = "", parent=None) -> None: self._marked = False self._warning = False self._font = QFont(self.font()) - self._font.setPixelSize(THEME.font_size_small) + self._font.setPixelSize(THEME.font_size_base) self._font.setWeight(QFont.Weight(THEME.weight_semibold)) if not text: self.setFixedWidth(self.GUTTER) @@ -66,7 +66,7 @@ def leaveEvent(self, event) -> None: def paintEvent(self, event) -> None: p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) - color = THEME.text_primary if self.underMouse() else THEME.text_hint + color = THEME.text_primary if self.underMouse() else THEME.text_secondary if self.text() else THEME.text_hint icon = qta.icon("fa5s.chevron-down" if self.isChecked() else "fa5s.chevron-right", color=color) top = (self.height() - _CHEVRON) // 2 p.drawPixmap(0, top, icon.pixmap(_CHEVRON, _CHEVRON)) @@ -113,8 +113,8 @@ def __init__(self, key: str, content: QWidget, button: FoldButton, parent=None, layout.setSpacing(0) layout.addWidget(content) content.installEventFilter(self) - for dot in content.findChildren(EditedDot): - dot.installEventFilter(self) + for mark in self._marks(): + mark.installEventFilter(self) self.setVisible(False) self._add(button) self.set_open(default_open) @@ -162,8 +162,13 @@ def set_warning(self, warning: bool) -> None: self._warning = warning self._sync_mark() + def _marks(self) -> list[QWidget]: + """What marks the closed fold as edited: an edited dot, or a clearable tool's × (clearable_tool_toggle).""" + clears = [b.clear_btn for b in self.content.findChildren(QAbstractButton) if getattr(b, "clear_btn", None) is not None] + return [*self.content.findChildren(EditedDot), *clears] + def _sync_mark(self) -> None: - edited = not self._open and any(not dot.isHidden() for dot in self.content.findChildren(EditedDot)) + edited = not self._open and any(not mark.isHidden() for mark in self._marks()) warning = not self._open and self._warning and not edited for button in self.buttons: button.set_marked(edited or warning, warning) diff --git a/negpy/desktop/view/widgets/gear_library_panel.py b/negpy/desktop/view/widgets/gear_library_panel.py index 851c55869..905417ca9 100644 --- a/negpy/desktop/view/widgets/gear_library_panel.py +++ b/negpy/desktop/view/widgets/gear_library_panel.py @@ -47,7 +47,7 @@ from negpy.desktop.view.shortcut_registry import tooltip_with_shortcut from negpy.desktop.view.styles.templates import ( field_label, - header_row, + header_actions, hint_label, icon_button, labeled_action, @@ -766,9 +766,9 @@ def _init_ui(self) -> None: root.addLayout(apply_row) self._apply_allowed = True - self.edit_btn = icon_button("fa5s.pen", tr("Rename the preset, or change which fields it stores")) + self.edit_btn = labeled_action("fa5s.pen", tr("Edit"), tr("Rename the preset, or change which fields it stores")) self.edit_btn.clicked.connect(self._edit_preset) - root.addLayout(header_row(section_subheader(tr("DETAILS")), self.edit_btn)) + root.addLayout(header_actions(section_subheader(tr("DETAILS")), self.edit_btn)) self.preset_panel = QWidget() preset_layout = QVBoxLayout(self.preset_panel) diff --git a/negpy/desktop/view/widgets/stats.py b/negpy/desktop/view/widgets/stats.py index 7667c0b01..5238718c2 100644 --- a/negpy/desktop/view/widgets/stats.py +++ b/negpy/desktop/view/widgets/stats.py @@ -5,7 +5,7 @@ import qtawesome as qta from PyQt6.QtWidgets import QFrame, QGridLayout, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget -from negpy.desktop.view.styles.templates import header_button, labeled_action +from negpy.desktop.view.styles.templates import channel_markup, header_button, labeled_action from negpy.desktop.view.styles.theme import THEME from negpy.features.exposure.stats import StatRow @@ -21,12 +21,6 @@ } -def _channel_markup(value: str) -> str: - """R · G · B in the channel colors; a warning stays all amber, so the amber reads.""" - colors = (THEME.channel_red_text, THEME.channel_green_text, THEME.channel_blue_text) - return " · ".join(f'{part}' for color, part in zip(colors, value.split(" · "))) - - # One per zone-placement pin, in pin order. The canvas overlay draws its rings in these # same colors, so the sidebar row and the pin on the photo match by eye. PIN_COLORS = (THEME.mark_photo, THEME.channel_blue, THEME.status_success) @@ -230,7 +224,8 @@ def update_stats(self, rows: List[StatRow]) -> None: if row.detail: tip = f"{tip}\n\n{row.detail}" if tip else row.detail self._names[i].setText(tr(row.name)) - self._values[i].setText(_channel_markup(row.value) if row.per_channel and not row.warn else row.value) + # A warning stays all amber, so the amber reads. + self._values[i].setText(channel_markup(row.value.split(" · ")) if row.per_channel and not row.warn else row.value) self._values[i].setStyleSheet(self._warn_css if row.warn else self._value_css) # Tooltip per stat (hover its name or value), not merged across the pair. self._names[i].setToolTip(tip) diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index e32f8a38a..65b74616e 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -1046,6 +1046,8 @@ def resolve_bounds_detailed(process, analyze_fn) -> tuple[LogNegativeBounds, Log if roll_luma and roll_color: return locked, locked base = LogNegativeBounds(process.local_floors, process.local_ceils) if process.is_local_initialized else analyze_fn() + if process.manual_dmin is not None or process.manual_dmax is not None: + base = LogNegativeBounds(process.manual_dmax or base.floors, process.manual_dmin or base.ceils) final = mix_luma_color_bounds(locked if roll_luma else base, locked if roll_color else base) return final, base diff --git a/negpy/features/process/models.py b/negpy/features/process/models.py index 9c736c479..7e518e5d5 100644 --- a/negpy/features/process/models.py +++ b/negpy/features/process/models.py @@ -168,6 +168,13 @@ class ProcessConfig: # transmittance; a roll's picked base (rolls.roll_film_base) wins inside that roll. Only the # preview's base readouts use it. film_base: Optional[tuple] = None + # Manual Pick Points: per-channel log10 in the bounds (unmixed) space. Dmin replaces the + # analyzed thin end (ceils), Dmax the dense end (floors). None keeps that end auto. + manual_dmin: Optional[tuple] = None + manual_dmax: Optional[tuple] = None + # Where each end was picked, content-normalized in the uncropped pick-tool view; only the canvas marks read it. + manual_dmin_at: Optional[tuple] = None + manual_dmax_at: Optional[tuple] = None # Two normalization clip axes: luma sets the black/white-point span, color sets # the per-channel balance clip (orange-mask cast removal). luma_range_clip: float = 0.0 @@ -265,6 +272,9 @@ def __post_init__(self) -> None: object.__setattr__(self, "meter_rect", tuple(self.meter_rect)) if self.film_base is not None: object.__setattr__(self, "film_base", tuple(float(v) for v in self.film_base)) + for name in ("manual_dmin", "manual_dmax", "manual_dmin_at", "manual_dmax_at"): + if getattr(self, name) is not None: + object.__setattr__(self, name, tuple(float(v) for v in getattr(self, name))) @property def is_local_initialized(self) -> bool: diff --git a/negpy/services/assets/thumbnail_fingerprint.py b/negpy/services/assets/thumbnail_fingerprint.py index 10bfcdb64..09ae5b5fa 100644 --- a/negpy/services/assets/thumbnail_fingerprint.py +++ b/negpy/services/assets/thumbnail_fingerprint.py @@ -24,10 +24,21 @@ # Fields left out of a hashed section: export-only, labels, detail too fine for a thumbnail, the # meter rect, which the source and geometry decide and whose effect lands in the stored bounds, -# and a frame's picked film base, which only the preview's readouts use. +# a frame's picked film base, which only the preview's readouts use, and where Dmin/Dmax were picked. _UNHASHED_FIELDS: dict[str, frozenset[str]] = { "process": frozenset( - {"demosaic_export", "roll_name", "baseline_source", "demosaic_preview", "crosstalk_profile", "meter_rect", "meter_key", "film_base"} + { + "demosaic_export", + "roll_name", + "baseline_source", + "demosaic_preview", + "crosstalk_profile", + "meter_rect", + "meter_key", + "film_base", + "manual_dmin_at", + "manual_dmax_at", + } ), "lab": frozenset({"sharpen", "sharpen_method", "sharpen_radius", "sharpen_masking", "sharpen_auto_masking", "chroma_denoise"}), } diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index 22a817f0d..d6e97fe62 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -257,6 +257,8 @@ def _analysis_cache_key(settings: WorkspaceConfig, analysis_source_hash: str, fi p.locked_ceils, p.local_floors, p.local_ceils, + p.manual_dmin, + p.manual_dmax, p.crosstalk_strength, p.crosstalk_matrix, p.crosstalk_process, diff --git a/negpy/translations/USER_GUIDE.de.md b/negpy/translations/USER_GUIDE.de.md index 878c17066..6c5e1d710 100644 --- a/negpy/translations/USER_GUIDE.de.md +++ b/negpy/translations/USER_GUIDE.de.md @@ -51,21 +51,21 @@ Ein einfaches Wort passt auf den Dateinamen. Begriffe der Form `field:value` pas Begriffe lassen sich kombinieren: `film:portra iso:>=400 -rejected:`. Metadaten-Begriffe passen nur auf Bilder, deren Metadaten du ausgefüllt hast. **Suche nach Bedeutung** (aktiviere sie unter Einstellungen → Leistung) findet Bilder über eine Beschreibung, zum Beispiel "ein Hund am Strand". - + ## 3. Analyse-Anzeige -Die Anzeige über den Tabs Rolle und Bild beschreibt das aktuelle Bild, während du bearbeitest. +Diese Anzeige beschreibt das aktuelle Bild, während du bearbeitest. #### Diagramm -Die Schaltflächen über dem Diagramm wählen eine Ansicht: +Wähle eine Diagrammansicht: -* **Kurve**: die Kurve des Papiers, vom Negativ (links nach rechts) zum Abzug (unten nach oben). Steiler bedeutet mehr Kontrast. Das graue Histogramm zeigt das Negativ, das farbige den Abzug. +* **Kurve**: die Kurve des Papiers, vom Negativ (quer) zum Abzug (nach oben). Steiler bedeutet mehr Kontrast. Das graue Histogramm zeigt das Negativ, das farbige den Abzug. * **Waveform**: Helligkeit des Abzugs über die Bildbreite. * **Parade**: Rot, Grün und Blau nebeneinander. Ein graues Motiv liegt in allen drei auf einer Höhe. **Parade-Überlagerung** legt die drei in einem Diagramm übereinander. * **Vektorskop**: die Farben des Abzugs rund um einen Mittelpunkt. Eine Gruppe neben der Mitte ist ein Farbstich. -**Log** zeigt kleine Bereiche, zum Beispiel beschnittene Enden. +**Log** zeigt kleine Bereiche, wie Beschneidung. #### Beschneidung @@ -81,10 +81,12 @@ Klicke auf eine Zone im Streifen, dann auf eine Stelle im Bild. NegPy stellt den Die Falte **Messwerte** enthält Zahlen zum Scan. Ein bernsteinfarbener Wert ist eine Warnung. Bewege den Mauszeiger über eine Zeile für Details. +**Filmbasis** (das Fadenkreuz): Klicke auf den klaren Film zwischen den Bildern, um die Basis festzulegen, von der die Messwerte messen. + * **Negativ**: der Dichteumfang des Negativs (Δ). Der Mauszeiger zeigt flach, normal oder kontrastreich. * **Belichtung**: helles (positiv) oder dunkles (negativ) Bild, in Blenden. * **Beschneidung**: Anteil des Abzugs in reinem Schwarz, dann in reinem Weiß. -* **Scan-Beschneidung**: Anteil jedes Kanals, Rot, Grün und Blau, am Maximum des Scanners. Keine Bearbeitung behebt das. Belichte den Scan niedriger. +* **Scan-Beschneidung**: Anteil jedes Kanals am Maximum des Scanners. Keine Bearbeitung behebt das. Belichte den Scan niedriger. * **Reparatur**: Anteil des Bildes, den die Staubentfernung neu geschrieben hat. * **Gamut**: Anteil, den der Proof-Drucker nicht drucken kann, solange du einen Softproof nutzt. @@ -362,19 +364,19 @@ Gibt einem ausgeblichenen Dia seine Farbstoffschichten zurück. Danach entfernt * **Grün-Erhalt** / **Blau-Erhalt**: wie viel grüner und blauer Farbstoff übrig ist, im Vergleich zu Rot. Unter 1,0 ist diese Schicht stärker ausgeblichen als Rot. * **Schätzen**: stellt die Regler aus den Grautönen dieses Bildes ein. **Mitteltöne** stellt auch den Rot-Erhalt ein und passt zu einem stark ausgeblichenen Dia. **Grauumfang** passt zu einer leichten Ausbleichung mit tiefen Schatten. - + ### 10.5 Messung: Negativ → Positiv Um ein Negativ in ein Positiv umzuwandeln, misst NegPy die dunkelsten und hellsten Stellen (Schwarz- und Weißpunkt) und die Farbe des Films. Diese Regler bestimmen, was NegPy misst. * **Bild neu analysieren**: misst dieses Bild oder jedes ausgewählte Bild erneut anhand seines aktuellen Zuschnitts. Das Bild nutzt dann eigene Grenzen, nicht die Basislinie der Rolle oder Szene. * **Region zeichnen**: Zeichne einen Bereich, dann misst NegPy nur diesen Bereich. Doppelklicke hinein oder drücke `Enter` zum Bestätigen. `Esc` stellt die vorherige Region wieder her. **×** löscht sie. -* **Filmbasis**: Klicke auf den klaren Film zwischen den Bildern, um die Filmbasis für die Analyseanzeige festzulegen. Es ist die Basis der Rolle, ohne geöffnete Rolle die des Bildes selbst. **×** löscht sie. * **Analysepuffer**: ignoriert beim Messen einen Rand um das Bild, damit Filmkanten und Halter nicht zählen. * **Beschneidung**: wie viel des Abzugs reines Schwarz oder reines Weiß ist. * **Grenzen sperren**: behält Schwarz- und Weißpunkt dieses Bildes, auch wenn sich der Zuschnitt oder die Rollenanalyse ändert. * **Luma-Bereichsbeschnitt** (Klappbereich): wie eng Schwarz und Weiß gesetzt werden. Positive Werte geben mehr Kontrast, für dichte oder verschleierte Negative. Negative Werte erhalten mehr Details in Schatten und Lichtern. * **Farbbeschnitt** (Klappbereich): wie eng die Farbbalance gesetzt wird. +* **Dmin** / **Dmax** (Klappbereich Von Hand wählen): für ein Bild, das NegPy schlecht misst. Klicke **Dmin**, dann die Filmbasis oder den tiefsten Schatten, um den Schwarzpunkt zu setzen. Klicke **Dmax**, dann das hellste Licht, um den Weißpunkt zu setzen. Jedes **×** löscht seinen eigenen Punkt. * **Weißpunkt** / **Schwarzpunkt** (Klappbereich): verschieben den gemessenen Weiß- und Schwarzpunkt. Ein positiver Weißpunkt hellt den Abzug auf. Ein positiver Schwarzpunkt hebt die Schwarztöne an. **R / G / B** verschiebt nur eine Farbe. Ein **Dia** braucht keine Umkehrung: Die Tonwertregler formen das Bild direkt. Auto-Dichte und Auto-Gradation sind aus. Schalte sie für ein verblasstes Dia ein. In Lightroom-Begriffen ist Belichtung die Abzugsdichte (niedriger ist heller), Kontrast die ISO-R-Gradation, und Schatten und Lichter sind die Dichte von Schatten und Lichtern. diff --git a/negpy/translations/USER_GUIDE.en.md b/negpy/translations/USER_GUIDE.en.md index 11e9fc0a3..6f211ec32 100644 --- a/negpy/translations/USER_GUIDE.en.md +++ b/negpy/translations/USER_GUIDE.en.md @@ -181,18 +181,18 @@ A scene is a group of frames shot in the same light. It gets its own Roll Analys ## 3. Analysis readout -The readout above the Roll and Frame tabs describes the current frame while you edit. +This readout describes the current frame while you edit. #### Chart -The buttons above the chart pick one view: +Pick a chart view: -* **Curve**: the paper's curve, from the negative (left to right) to the print (bottom to top). Steeper means more contrast. The gray histogram is the negative, the colored one the print. +* **Curve**: the paper's curve, from the negative (across) to the print (up). Steeper means more contrast. The gray histogram is the negative, the colored one the print. * **Waveform**: print brightness across the frame. * **Parade**: red, green and blue side by side; a gray subject sits at one height in all three. **Parade Overlay** puts the three on one plot. * **Vectorscope**: the print's colors around a center point. A cluster off center is a color cast. -**Log** shows small areas, such as clipped ends. +**Log** shows small areas, like clipping. #### Clipping @@ -208,10 +208,12 @@ Click a zone on the strip, then a spot on the picture, and NegPy sets the print The **Readouts** fold holds numbers about the scan. An amber value is a warning; hover a row for details. +**Film Base** (the crosshair): click the clear film between frames to set the base the readouts measure from. + * **Negative**: the negative's density range (Δ). Hover for flat, normal or contrasty. * **Exposure**: bright (positive) or dark (negative) frame, in stops. * **Clipping**: share of the print that is pure black, then pure white. -* **Scan clip**: share of each channel, red, green and blue, at the scanner's maximum. No edit fixes it; expose the scan lower. +* **Scan clip**: share of each channel at the scanner's maximum. No edit fixes it; expose the scan lower. * **Repair**: share of the picture that dust removal rewrote. * **Gamut**: share the proof printer cannot print, while proofing. @@ -539,12 +541,12 @@ To turn a negative into a positive, NegPy measures its darkest and brightest par * **Reanalyze Frame**: measures this frame, or each selected frame, again from its current crop. The frame then uses its own bounds, not the roll or scene baseline. * **Draw Region**: draw an area, and NegPy measures only that area. Double-click inside or press `Enter` to confirm, and `Esc` puts back the region you had before. **×** clears it. -* **Film Base**: click the clear film between frames to set the film base for the Analysis readout. It is the roll's base, or this frame's own with no roll open. **×** clears it. * **Analysis Buffer**: ignores a border around the frame when measuring, so film edges and holders do not count. * **Clipping**: how much of the print is pure black or pure white. * **Lock Bounds**: keeps this frame's black and white points, even when the crop or Roll Analysis changes. * **Luma Range Clip** (fold): how tightly black and white are set. Positive gives more contrast, for dense or foggy negatives. Negative keeps more shadow and highlight detail. * **Color Clip** (fold): how tightly the color balance is set. +* **Dmin** / **Dmax** (Pick by Hand fold): for a frame that NegPy measures badly. Click **Dmin**, then the film base or the deepest shadow, to set the black point. Click **Dmax**, then the brightest highlight, to set the white point. Each **×** clears its own point. * **White Point** / **Black Point** (fold): move the measured white and black points. A positive white point brightens the print; a positive black point lifts the blacks. **R / G / B** moves one color only. A **slide** needs no inversion: the tone controls shape the picture directly. Auto Density and Auto Grade start off; turn them on for a faded slide. In Lightroom terms, Exposure is Print Density (lower is brighter), Contrast is ISO-R Grade, and Shadows and Highlights are Shadows and Highlights Density. diff --git a/negpy/translations/USER_GUIDE.ja.md b/negpy/translations/USER_GUIDE.ja.md index 9f0e10883..aeff8d62f 100644 --- a/negpy/translations/USER_GUIDE.ja.md +++ b/negpy/translations/USER_GUIDE.ja.md @@ -55,21 +55,21 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 フィルムストリップのフレームをロールとして保存するには、フィルムストリップを使います。**フォルダーのロールに保存**は、すべて一つのフォルダーから来たフレームをそのフォルダーのロールにします。フォルダーの他のファイルは除外済みとして表示されます。**仮想ロールとして保存…**は、どのフォルダーのものでも、これらのフレームだけを残します。フォルダーのロールに保存は、一つのフォルダーの仮想ロールをそのフォルダーのロールにもします。新しいロールは、開いているロールの設定、カード ロック、およびそのロールだけの編集を引き継ぎます。一つのフレームは、それを含むすべてのロールで同じ編集を持ちます。右クリック →**このロール内で個別に編集**で、一つのロールだけの編集にできます。 - + ## 解析表示 -ロールとフレームのタブの上にある解析表示は、編集中のフレームの状態を示します。 +この解析表示は、編集中のフレームの状態を示します。 ### チャート -チャートの上のボタンで表示を選びます: +チャートの表示を選びます: -- **曲線**:印画紙のカーブ。左から右がネガ、下から上がプリントです。傾きが急なほどコントラストが強くなります。灰色のヒストグラムはネガ、色付きはプリントです。 +- **曲線**:印画紙のカーブ。横方向がネガ、上方向がプリントです。傾きが急なほどコントラストが強くなります。灰色のヒストグラムはネガ、色付きはプリントです。 - **波形**:フレーム全体のプリントの明るさ。 - **パレード**:赤・緑・青を並べて表示し、グレーの被写体は 3 つとも同じ高さになります。**パレード重ね表示**は 3 つを 1 つのグラフに重ねます。 - **ベクトルスコープ**:プリントの色を中心のまわりに表示します。中心から外れた集まりは色かぶりです。 -**Log** は白飛びや黒つぶれのような小さな部分を表示します。 +**Log** はクリッピングのような小さな部分を表示します。 ### クリッピング @@ -85,10 +85,12 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 **読み取り値**の折りたたみには、スキャンに関する数値があります。琥珀色の値は警告です。行にポインターを合わせると詳細が出ます。 +**フィルムベース**(照準):フレームの間の透明なフィルムをクリックし、読み取り値の測定基準となるベースを設定します。 + - **ネガ**:ネガの濃度域 (Δ)。ホバーで軟調・標準・硬調の別を示します。 - **露出**:明るい(正)か暗い(負)フレームか、段で表します。 - **クリッピング**:プリントのうち純粋な黒、次に純粋な白の割合。 -- **スキャンクリップ**:赤・緑・青の各チャンネルのうち、スキャナーの最大値に達した割合。編集では直せないので、スキャンの露光を下げてください。 +- **スキャンクリップ**:各チャンネルのうち、スキャナーの最大値に達した割合。編集では直せないので、スキャンの露光を下げてください。 - **修復**:ほこり除去で書き換えた割合。 - **色域**:プルーフのプリンターで印刷できない割合(プルーフ中のみ)。 @@ -371,19 +373,19 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 - **緑の残存率** / **青の残存率**:赤と比べて残っている緑と青の色素の量。1.0 未満では、その層が赤より強く退色しています。 - **推定**:このフレームのグレーからスライダーを設定します。**中間調**は赤の残存率も設定し、強く退色したスライドに向いています。**グレーの幅**は深いシャドウのある軽い退色に向いています。 - + ## 測光:ネガからポジへ ネガをポジにするため、NegPy は最も暗い部分と明るい部分(黒点と白点)、そしてフィルムの色を測定します。これらの調整で測定する内容を決めます。 - **フレームを再解析**:このフレーム、または選択した各フレームを、現在のトリミングでもう一度測定します。その後、フレームはロールやシーンの基準ではなく、独自の範囲を使います。 - **範囲を描画**:範囲を描くと、NegPy はその範囲だけを測定します。内側をダブルクリックするか `Enter` で確定し、`Esc` で前の範囲に戻します。**×** で消去します。 -- **フィルムベース**:フレームの間の透明なフィルムをクリックし、解析表示用のフィルムベースを設定します。ロールのベースになり、ロールを開いていないときはこのフレームだけのベースになります。**×** で消去します。 - **分析バッファ**:測定時にフレームの周囲を無視し、フィルムの縁やホルダーが影響しないようにします。 - **クリッピング**:プリントのうち純粋な黒または白になる量。 - **範囲を固定**:トリミングやロール解析が変わっても、このフレームの黒点と白点を保ちます。 - **ルミナンス範囲クリップ**(折りたたみ):黒と白をどれだけ詰めて設定するか。正の値でコントラストが強くなり、濃いネガやかぶったネガに向きます。負の値でシャドウとハイライトの細部が多く残ります。 - **カラークリップ**(折りたたみ):カラーバランスをどれだけ強く合わせるか。 +- **Dmin** / **Dmax**(「手動で指定」の折りたたみ):NegPy がうまく測定できないフレーム用です。**Dmin** を押してフィルムベースか最も深いシャドウをクリックすると、黒点を設定します。**Dmax** を押して最も明るいハイライトをクリックすると、白点を設定します。各 **×** はそれぞれの点を消去します。 - **白点**/**黒点**(折りたたみ):測定した白点と黒点を動かします。白点を正にするとプリントが明るくなり、黒点を正にすると黒が浮きます。**R/G/B** は一色だけを動かします。 **スライド**は反転が不要で、トーンの調整がそのまま写真を変えます。自動濃度と自動グレードは最初はオフです。退色したスライドではオンにします。Lightroom で言えば、露光量はプリント濃度(低いほど明るい)、コントラストは ISO-R グレード、シャドウとハイライトはシャドウ濃度とハイライト濃度です。 diff --git a/negpy/translations/USER_GUIDE.ko.md b/negpy/translations/USER_GUIDE.ko.md index 142325ce5..ef79b70a5 100644 --- a/negpy/translations/USER_GUIDE.ko.md +++ b/negpy/translations/USER_GUIDE.ko.md @@ -55,21 +55,21 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 필름 스트립의 프레임을 롤로 저장하려면 필름 스트립을 사용하세요. **폴더 롤에 저장**은 모두 한 폴더에서 온 프레임을 그 폴더의 롤로 만들며, 폴더의 다른 파일은 제거됨으로 표시됩니다. **가상 롤로 저장…**은 어느 폴더의 것이든 이 프레임만 그대로 보관합니다. 폴더 롤에 저장은 한 폴더의 가상 롤을 그 폴더의 롤로 바꾸기도 합니다. 새 롤은 열려 있는 롤의 설정, 카드 잠금 및 그 롤에서만 한 편집으로 시작합니다. 한 프레임은 그것을 담은 모든 롤에서 같은 편집을 가집니다. 오른쪽 클릭 → **이 롤에서 독립적으로 편집**하면 한 롤에서만 쓰는 편집을 가집니다. - + ## 분석 표시 -롤과 프레임 탭 위의 분석 표시는 편집하는 동안 현재 프레임을 설명합니다. +이 분석 표시는 편집하는 동안 현재 프레임을 설명합니다. ### 차트 -차트 위의 버튼으로 보기를 고릅니다: +차트 보기를 고릅니다: -- **곡선**: 인화지의 곡선입니다. 왼쪽에서 오른쪽은 네거티브, 아래에서 위는 인화입니다. 가파를수록 대비가 강합니다. 회색 히스토그램은 네거티브, 색 히스토그램은 인화입니다. +- **곡선**: 인화지의 곡선입니다. 가로는 네거티브, 위쪽은 인화입니다. 가파를수록 대비가 강합니다. 회색 히스토그램은 네거티브, 색 히스토그램은 인화입니다. - **파형**: 프레임 전체의 인화 밝기입니다. - **퍼레이드**: 빨강, 초록, 파랑을 나란히 보여주며, 회색 피사체는 셋 모두 같은 높이에 있습니다. **퍼레이드 겹치기**는 셋을 한 그래프에 놓습니다. - **벡터스코프**: 인화의 색을 중심점 둘레에 보여줍니다. 중심에서 벗어난 무리는 색 편향입니다. -**Log**는 날아가거나 뭉친 끝처럼 작은 부분을 보여줍니다. +**Log**는 클리핑처럼 작은 부분을 보여줍니다. ### 클리핑 @@ -85,10 +85,12 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 **판독값** 접기에는 스캔에 대한 수치가 있습니다. 호박색 값은 경고입니다. 줄에 포인터를 올리면 자세한 내용이 나옵니다. +**필름 베이스**(조준선): 프레임 사이의 투명한 필름을 클릭해 판독값이 측정 기준으로 삼는 베이스를 정합니다. + - **네거티브**: 네거티브의 농도 범위(Δ)입니다. 마우스를 올리면 연조, 보통, 경조를 보여줍니다. - **노출**: 밝은(양수) 또는 어두운(음수) 프레임인지를 스톱으로 보여줍니다. - **클리핑**: 인화에서 순수한 검정, 다음으로 순수한 흰색의 비율입니다. -- **스캔 클립**: 빨강, 초록, 파랑 각 채널이 스캐너 최댓값에 닿은 비율입니다. 편집으로 고칠 수 없으니 스캔 노출을 낮추세요. +- **스캔 클립**: 각 채널이 스캐너 최댓값에 닿은 비율입니다. 편집으로 고칠 수 없으니 스캔 노출을 낮추세요. - **복구**: 먼지 제거가 다시 쓴 비율입니다. - **색역**: 교정 중에 교정용 프린터가 인쇄할 수 없는 비율입니다. @@ -371,19 +373,19 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 - **초록 잔존율** / **파랑 잔존율**: 빨강에 비해 남아 있는 초록과 파랑 염료의 양입니다. 1.0보다 작으면 그 층이 빨강보다 더 바랬습니다. - **추정**: 이 프레임의 회색으로 슬라이더를 설정합니다. **중간톤**은 빨강 잔존율도 설정하며 심하게 바랜 슬라이드에 맞습니다. **회색 범위**는 깊은 그림자가 있는 가벼운 퇴색에 맞습니다. - + ## 측광: 네거티브에서 포지티브로 네거티브를 포지티브로 바꾸려고 NegPy는 가장 어두운 부분과 가장 밝은 부분(검정점과 흰점), 그리고 필름의 색을 측정합니다. 이 조정으로 측정할 내용을 정합니다. - **프레임 다시 분석**: 이 프레임 또는 선택한 각 프레임을 현재 자르기로 다시 측정합니다. 그 후 프레임은 롤이나 장면의 기준 대신 자기 범위를 사용합니다. - **영역 그리기**: 영역을 그리면 NegPy가 그 영역만 측정합니다. 안쪽을 더블클릭하거나 `Enter`를 눌러 확정하고, `Esc`는 이전 영역으로 되돌립니다. **×**로 지웁니다. -- **필름 베이스**: 프레임 사이의 투명한 필름을 클릭해 분석 표시용 필름 베이스를 정합니다. 롤의 베이스가 되며, 열린 롤이 없으면 이 프레임만의 베이스가 됩니다. **×**로 지웁니다. - **분석 버퍼**: 측정할 때 프레임 둘레를 무시해 필름 가장자리와 홀더가 영향을 주지 않게 합니다. - **클리핑**: 인화에서 순수한 검정이나 흰색이 되는 양입니다. - **범위 고정**: 자르기나 롤 분석이 바뀌어도 이 프레임의 검정점과 흰점을 유지합니다. - **루마 범위 클립**(접기): 검정과 흰색을 얼마나 촘촘히 정할지입니다. 양수는 대비가 강해져 짙거나 흐린 네거티브에 알맞습니다. 음수는 어두운 영역과 밝은 영역의 세부를 더 남깁니다. - **컬러 클립**(접기): 색 균형을 얼마나 강하게 맞출지입니다. +- **Dmin** / **Dmax** (직접 지정 접기): NegPy가 잘 측정하지 못하는 프레임용입니다. **Dmin**을 누르고 필름 베이스 또는 가장 깊은 섀도를 클릭해 블랙 포인트를 정합니다. **Dmax**를 누르고 가장 밝은 하이라이트를 클릭해 화이트 포인트를 정합니다. 각 **×**는 자기 포인트를 지웁니다. - **화이트 포인트** / **블랙 포인트**(접기): 측정한 흰점과 검정점을 옮깁니다. 화이트 포인트를 양수로 하면 인화가 밝아지고, 블랙 포인트를 양수로 하면 검정이 밝아집니다. **R / G / B**는 한 색만 옮깁니다. **슬라이드**는 반전이 필요 없으며 톤 조정이 사진을 바로 바꿉니다. 자동 농도와 자동 등급은 처음에 꺼져 있습니다. 바랜 슬라이드에는 켜세요. Lightroom으로 말하면 노출은 인화 농도(낮을수록 밝음), 대비는 ISO-R 등급, 어두운 영역과 밝은 영역은 어두운 영역 농도와 밝은 영역 농도입니다. diff --git a/negpy/translations/USER_GUIDE.zh_CN.md b/negpy/translations/USER_GUIDE.zh_CN.md index b9cbcc7a3..ad5ca17d6 100644 --- a/negpy/translations/USER_GUIDE.zh_CN.md +++ b/negpy/translations/USER_GUIDE.zh_CN.md @@ -55,21 +55,21 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 要把胶片条中的画面保存为胶卷,请使用胶片条。**保存为文件夹胶卷**会把全部来自同一文件夹的画面设为该文件夹的胶卷,文件夹中的其他文件显示为已移除。**另存为虚拟胶卷…**只保留这些画面,不论来自哪些文件夹。保存为文件夹胶卷也能把只含一个文件夹画面的虚拟胶卷变为该文件夹的胶卷。新胶卷沿用当前打开胶卷的设置、卡片锁定以及仅在该卷中的编辑。一帧在包含它的所有胶卷中共用一份编辑;右键 → **在此胶卷中独立编辑**可让它在某一卷中有自己的编辑。 - + ## 分析读数 -卷和帧选项卡上方的分析读数在编辑时描述当前帧。 +此分析读数在编辑时描述当前帧。 ### 图表 -图表上方的按钮选择视图: +选择图表视图: -- **曲线**:相纸曲线,从左到右是负片,从下到上是照片。越陡反差越大。灰色直方图是负片,彩色直方图是照片。 +- **曲线**:相纸曲线,横向是负片,向上是照片。越陡反差越大。灰色直方图是负片,彩色直方图是照片。 - **波形图**:整幅画面的照片亮度。 - **并列图**:并排显示红、绿、蓝;灰色主体在三者中高度一致。**并列图叠加**把三者画在同一张图上。 - **矢量示波器**:把照片颜色画在中心点周围。偏离中心的一团表示色偏。 -**Log** 显示小面积区域,如截断的两端。 +**Log** 显示小面积区域,如截断。 ### 截断 @@ -85,10 +85,12 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 **读数**折叠区中有关于扫描的数值。琥珀色的值是警告;把指针停在某行上可查看详情。 +**片基**(准星):点击帧之间的透明胶片,设置读数的测量基准片基。 + - **负片**:负片的密度范围 (Δ)。悬停可查看属于平淡、正常还是强烈。 - **曝光**:画面偏亮(正)或偏暗(负),以挡表示。 - **截断**:照片中纯黑、再纯白的比例。 -- **扫描截断**:红、绿、蓝各通道达到扫描仪最大值的比例。编辑无法修正,请降低扫描曝光。 +- **扫描截断**:各通道达到扫描仪最大值的比例。编辑无法修正,请降低扫描曝光。 - **修复**:除尘改写的比例。 - **色域**:软打样时校样打印机无法印出的比例。 @@ -371,19 +373,19 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 - **绿色残留率** / **蓝色残留率**:相对红色,剩余的绿色和蓝色染料量。低于 1.0 表示该层比红色褪得更多。 - **估算**:根据此画面中的灰色设置滑块。**中间调**同时设置红色残留率,适合严重褪色的反转片。**灰色范围**适合阴影较深的轻度褪色。 - + ## 测光:从负片到正片 为了把负片变成正片,NegPy 会测量最暗和最亮的部分(黑点和白点)以及胶片的颜色。这些控件决定测量什么。 - **重新分析此帧**:按当前裁切重新测量此帧或每个所选帧。之后,该帧使用自己的范围,而不是胶卷或场景的基准。 - **绘制区域**:画出一个区域,NegPy 只测量该区域。在区域内双击或按 `Enter` 确认,`Esc` 恢复之前的区域。**×** 清除。 -- **片基**:点击帧之间的透明胶片,为分析显示设置片基。它是整卷的片基;没有打开胶卷时,则是此帧自己的片基。**×** 清除。 - **分析缓冲区**:测量时忽略画面四周的一圈,使胶片边缘和片夹不被计入。 - **截断**:照片中纯黑或纯白的比例。 - **锁定范围**:即使裁切或胶卷分析改变,也保持此帧的黑点和白点。 - **亮度范围截断**(折叠区):黑白点设定的松紧。正值反差更大,适合厚密或有灰雾的负片;负值保留更多暗部和高光细节。 - **颜色截断**(折叠区):色彩平衡设定的强弱。 +- **Dmin** / **Dmax**(“手动选取”折叠):用于 NegPy 测量不准的帧。点击 **Dmin**,再点击片基或最深的阴影,设置黑点。点击 **Dmax**,再点击最亮的高光,设置白点。每个 **×** 只清除自己的点。 - **白点** / **黑点**(折叠区):移动测得的白点和黑点。白点为正时照片变亮,黑点为正时黑色变浅。**R / G / B** 只移动一种颜色。 **反转片**不需要反转,色调控件直接调整画面。自动密度和自动反差一开始是关闭的;褪色的反转片可以打开它们。用 Lightroom 的说法:曝光对应印相密度(越低越亮),对比度对应 ISO-R 反差等级,阴影和高光对应暗部密度和高光密度。 diff --git a/negpy/translations/de.json b/negpy/translations/de.json index c3a929074..c3edcdcf3 100644 --- a/negpy/translations/de.json +++ b/negpy/translations/de.json @@ -451,6 +451,8 @@ "Clear All: remove all manual heals and keep the automatic dust removal.": "Alle leeren: alle manuellen Reparaturen entfernen und die automatische Staubentfernung behalten.", "Clear Crop": "Zuschnitt löschen", "Clear Crops": "Zuschnitte löschen", + "Clear Dmax: measure the white point automatically again.": "Dmax leeren: den Weißpunkt wieder automatisch messen.", + "Clear Dmin: measure the black point automatically again.": "Dmin leeren: den Schwarzpunkt wieder automatisch messen.", "Clear Film Base: forget the film base this frame uses.": "Filmbasis löschen: Vergisst die Filmbasis, die dieses Bild nutzt.", "Clear Library": "Bibliothek leeren", "Clear Library…": "Bibliothek leeren…", @@ -489,11 +491,13 @@ "Click something in the picture that should be gray, and the color is corrected to make it gray.": "Klicke auf etwas im Bild, das grau sein soll. Die Farbe wird so korrigiert, dass es grau wird.", "Click the clear film base (crosshair) first": "Klicke zuerst auf die klare Filmbasis (Fadenkreuz)", "Click the clear rebate to set the roll's film base": "Klicke auf den klaren Filmrand, um die Filmbasis der Rolle festzulegen", + "Click the densest spot of the negative: the brightest highlight": "Klicken Sie auf die dichteste Stelle des Negativs: das hellste Licht", "Click the photo to pick the area to copy from": "Klicke auf das Foto, um den Bereich zum Kopieren zu wählen", "Click the photo to pick the area to copy from first": "Klicke zuerst auf das Foto, um den Bereich zum Kopieren zu wählen", "Click the photo to pick the area to copy from.": "Klicke auf das Foto, um den Bereich zum Kopieren zu wählen.", "Click the photo to pin a spot; drag a pin to move it": "Klicke auf das Foto, um eine Stelle zu markieren. Ziehe einen Pin, um ihn zu verschieben", "Click the photo to place zone {value}": "Klicke auf das Foto, um Zone {value} zu setzen", + "Click the thinnest spot of the negative: the film base or the deepest shadow": "Klicken Sie auf die dünnste Stelle des Negativs: die Filmbasis oder den tiefsten Schatten", "Click to enable or disable this mask's effect on the render": "Klicke, um die Wirkung dieser Maske auf das Ergebnis ein- oder auszuschalten", "Click to place vertices; a click near the start closes the mask": "Klicke, um Eckpunkte zu setzen. Ein Klick nahe dem Start schließt die Maske", "Clipping": "Beschneidung", @@ -696,6 +700,7 @@ "Describe the photo to find…": "Beschreibe das gesuchte Foto…", "Description Fields": "Beschreibungsfelder", "Description…": "Beschreibung…", + "Detect": "Erkennen", "Detect All": "Alle erkennen", "Detect Frames": "Bilder erkennen", "Detect Scenes": "Szenen erkennen", @@ -740,6 +745,14 @@ "Distortion": "Verzeichnung", "Distortion Correction": "Verzeichnungskorrektur", "Distortion and CA are off in Optics": "Verzeichnung und CA sind in Optik ausgeschaltet", + "Dmax": "Dmax", + "Dmax set for this frame": "Dmax für dieses Bild gesetzt", + "Dmax: Densest Spot": "Dmax: Dichteste Stelle", + "Dmax: click the brightest highlight to set this frame's white point by hand.": "Dmax: Klicken Sie auf das hellste Licht, um den Weißpunkt dieses Bildes von Hand zu setzen.", + "Dmin": "Dmin", + "Dmin set for this frame": "Dmin für dieses Bild gesetzt", + "Dmin: Thinnest Spot": "Dmin: Dünnste Stelle", + "Dmin: click the film base or the deepest shadow to set this frame's black point by hand.": "Dmin: Klicken Sie auf die Filmbasis oder den tiefsten Schatten, um den Schwarzpunkt dieses Bildes von Hand zu setzen.", "Do Not Ask Again": "Nicht mehr fragen", "Do Not Show Again": "Nicht mehr anzeigen", "Dock controls panel to right": "Regler-Bereich rechts andocken", @@ -960,7 +973,7 @@ "Film": "Film", "Film & Quality": "Film & Qualität", "Film Base": "Filmbasis", - "Film Base: click the clear film between frames to set the film base of the open roll, or of this frame without one.": "Filmbasis: Klicke auf den klaren Film zwischen den Bildern, um die Filmbasis der offenen Rolle zu setzen, oder die dieses Bildes, wenn keine Rolle offen ist.", + "Film Base: click the clear film between frames to set the base these readouts measure from, for the open roll or this frame.": "Filmbasis: Klicken Sie auf den klaren Film zwischen den Bildern, um die Basis festzulegen, von der diese Messwerte messen, für die offene Rolle oder dieses Bild.", "Film Base: click the clear rebate once the frame has rendered": "Filmbasis: Klicke auf den klaren Filmrand, sobald das Bild berechnet ist", "Film Base: that spot is not flat film; click the clear rebate": "Filmbasis: Diese Stelle ist kein ebener Film. Klicke auf den klaren Filmrand", "Film Base: this frame's roll is not in the library": "Filmbasis: Die Rolle dieses Bildes ist nicht in der Bibliothek", @@ -1439,6 +1452,7 @@ "Loaded edit from {value}": "Bearbeitung aus {value} geladen", "Loaded {value} for this session only. The folder roll “{name}” holds only files from its own folder. Use Save as Virtual Roll… to keep them in a new roll.": "{value} nur für diese Sitzung geladen. Die Ordnerrolle „{name}“ enthält nur Dateien aus ihrem eigenen Ordner. Mit „Als virtuelle Rolle speichern…“ behältst du sie in einer neuen Rolle.", "Loading live view…": "Live View wird geladen…", + "Lock": "Sperren", "Lock Bounds: keep this frame's measured black and white points, even when the crop or Roll Analysis changes.": "Grenzen sperren: Die gemessenen Schwarz- und Weißpunkte dieses Bildes bleiben erhalten, auch wenn sich Zuschnitt oder Rollenanalyse ändern.", "Locked from frame {exposure_lock_frame}{when}. Every scan reuses it.": "Gesperrt ab Bild {exposure_lock_frame}{when}. Jeder Scan verwendet es erneut.", "Locked to the preset: pick Set by Hand under + to set it by hand.": "An die Vorgabe gebunden: Wähle unter + „Von Hand einstellen“, um es manuell festzulegen.", @@ -1826,6 +1840,8 @@ "Pick a preset: one of your own, or a built-in one added under +.": "Wähle eine Vorgabe: eine eigene oder eine integrierte, die unter + hinzugefügt wurde.", "Pick a print stock other than Digital and compare the print.": "Wähle ein anderes Papier als Digital und vergleiche den Abzug.", "Pick a reference image and save it as a named profile": "Wähle ein Referenzbild und speichere es als benanntes Profil", + "Pick by Hand": "Von Hand wählen", + "Pick by Hand: click once the frame has rendered": "Von Hand wählen: Klicken Sie, sobald das Bild berechnet ist", "Pick on a map, or paste coordinates": "Auf einer Karte wählen oder Koordinaten einfügen", "Pick the capture place on a map (contacts OpenStreetMap)": "Den Aufnahmeort auf einer Karte wählen (kontaktiert OpenStreetMap)", "Pick the custom border color…": "Benutzerdefinierte Randfarbe wählen…", @@ -1835,6 +1851,7 @@ "Pick three bare-light exposures, red only, green only and blue only, with no film in the holder and the light and camera settings you scan with. Expose just below clipping. With a tethered camera and a Scanlight, Capture from Camera… shoots them for you.": "Wähle drei Blanklicht-Belichtungen: nur Rot, nur Grün und nur Blau, ohne Film im Halter und mit den Licht- und Kameraeinstellungen, mit denen du scannst. Belichte knapp unter der Beschneidung. Mit einer angeschlossenen Kamera und einem Scanlight nimmt Von Kamera aufnehmen… sie für dich auf.", "Pick three different files for red, green and blue": "Wähle drei verschiedene Dateien für Rot, Grün und Blau", "Pick which of the current settings to store as a new preset": "Wähle, welche der aktuellen Einstellungen als neue Vorgabe gespeichert werden", + "Picked: {values}": "Gewählt: {values}", "Picking a roll loads its saved baseline onto the loaded files.": "Beim Wählen einer Rolle wird ihre gespeicherte Basislinie auf die geladenen Dateien angewendet.", "Pin {value} · reads {measured}": "Pin {value} · liest {measured}", "Pixels": "Pixel", @@ -1989,6 +2006,7 @@ "Readouts": "Messwerte", "Reads per line the scanner averages": "Lesevorgänge pro Zeile, über die der Scanner mittelt", "Reads the scan linear, with no white balance.": "Liest den Scan linear, ohne Weißabgleich.", + "Reanalyze": "Neu messen", "Reanalyze Frame": "Bild neu analysieren", "Reanalyze Frame: measure this frame again from its current crop.": "Bild neu analysieren: Dieses Bild aus seinem aktuellen Zuschnitt erneut messen.", "Reanalyze the frame or the selection": "Das Bild oder die Auswahl neu analysieren", @@ -2793,6 +2811,8 @@ "Toggle Auto Masking": "Auto-Maskierung umschalten", "Toggle Controls Panel": "Regelbereich umschalten", "Toggle Crop Empty Corners": "Leere Ecken zuschneiden umschalten", + "Toggle Dmax pick": "Dmax-Auswahl umschalten", + "Toggle Dmin pick": "Dmin-Auswahl umschalten", "Toggle Folder as Roll": "Ordner als Rolle umschalten", "Toggle Hold Black (a shadow lift keeps the black point)": "Schwarz halten umschalten (eine Schattenanhebung behält den Schwarzpunkt)", "Toggle IR Removal": "IR-Entfernung umschalten", @@ -2930,6 +2950,7 @@ "Use Average Color": "Mittlere Farbe nutzen", "Use Average Luma": "Mittlere Luma nutzen", "Use Export profile": "Exportprofil nutzen", + "Use Frame": "Bild nutzen", "Use Own Measurement": "Eigene Messung verwenden", "Use Shared Edit": "Gemeinsame Bearbeitung nutzen", "Use This Frame: make this frame's measurement the roll's baseline.": "Dieses Bild nutzen: macht die Messung dieses Bildes zur Basis der Rolle.", diff --git a/negpy/translations/ja.json b/negpy/translations/ja.json index e3b1ea2c2..414dc1425 100644 --- a/negpy/translations/ja.json +++ b/negpy/translations/ja.json @@ -451,6 +451,8 @@ "Clear All: remove all manual heals and keep the automatic dust removal.": "すべてクリア: 手動の修復をすべて取り除き、自動のほこり除去は残します。", "Clear Crop": "トリミングを解除", "Clear Crops": "トリミングを消去", + "Clear Dmax: measure the white point automatically again.": "Dmax をクリア:白点を再び自動で測定します。", + "Clear Dmin: measure the black point automatically again.": "Dmin をクリア:黒点を再び自動で測定します。", "Clear Film Base: forget the film base this frame uses.": "フィルムベースをクリア:このフレームが使うフィルムベースを消去します。", "Clear Library": "ライブラリをクリアする", "Clear Library…": "ライブラリをクリア…", @@ -489,11 +491,13 @@ "Click something in the picture that should be gray, and the color is corrected to make it gray.": "写真の中でグレーであるべき部分をクリックすると、そこがグレーになるよう色が補正されます。", "Click the clear film base (crosshair) first": "まず透明なフィルムのベース(十字線)をクリックします", "Click the clear rebate to set the roll's film base": "フィルムの透明な縁をクリックし、ロールのフィルムベースを設定します", + "Click the densest spot of the negative: the brightest highlight": "ネガの最も濃い部分をクリック:最も明るいハイライト", "Click the photo to pick the area to copy from": "写真をクリックしてコピー元の領域を選択します", "Click the photo to pick the area to copy from first": "まず写真をクリックしてコピー元の領域を選択します", "Click the photo to pick the area to copy from.": "写真をクリックしてコピー元の領域を選択します。", "Click the photo to pin a spot; drag a pin to move it": "写真をクリックしてスポットを固定します。ピンをドラッグして移動します", "Click the photo to place zone {value}": "写真をクリックしてゾーン {value} を配置します", + "Click the thinnest spot of the negative: the film base or the deepest shadow": "ネガの最も薄い部分をクリック:フィルムベースか最も深いシャドウ", "Click to enable or disable this mask's effect on the render": "クリックして、レンダリングに対するこのマスクの効果を有効または無効にします。", "Click to place vertices; a click near the start closes the mask": "クリックして頂点を配置します。開始点付近をクリックするとマスクが閉じます", "Clipping": "クリッピング", @@ -696,6 +700,7 @@ "Describe the photo to find…": "探しているものについて説明してください…", "Description Fields": "説明フィールド", "Description…": "説明…", + "Detect": "検出", "Detect All": "すべて検出", "Detect Frames": "フレームを検出", "Detect Scenes": "シーンを検出", @@ -740,6 +745,14 @@ "Distortion": "ディストーション", "Distortion Correction": "歪み補正", "Distortion and CA are off in Optics": "光学系ではディストーションとCAがオフになっています", + "Dmax": "Dmax", + "Dmax set for this frame": "このフレームの Dmax を設定しました", + "Dmax: Densest Spot": "Dmax:最も濃い部分", + "Dmax: click the brightest highlight to set this frame's white point by hand.": "Dmax:最も明るいハイライトをクリックし、このフレームの白点を手動で設定します。", + "Dmin": "Dmin", + "Dmin set for this frame": "このフレームの Dmin を設定しました", + "Dmin: Thinnest Spot": "Dmin:最も薄い部分", + "Dmin: click the film base or the deepest shadow to set this frame's black point by hand.": "Dmin:フィルムベースか最も深いシャドウをクリックし、このフレームの黒点を手動で設定します。", "Do Not Ask Again": "今後確認しない", "Do Not Show Again": "今後表示しない", "Dock controls panel to right": "コントロールパネルを右側にドックします", @@ -960,7 +973,7 @@ "Film": "フィルム", "Film & Quality": "フィルムと品質", "Film Base": "フィルムベース", - "Film Base: click the clear film between frames to set the film base of the open roll, or of this frame without one.": "フィルムベース:フレームの間の透明なフィルムをクリックして、開いているロールのフィルムベースを設定します。ロールがなければこのフレームのものになります。", + "Film Base: click the clear film between frames to set the base these readouts measure from, for the open roll or this frame.": "フィルムベース:フレームの間の透明なフィルムをクリックし、これらの読み取り値の測定基準となるベースを、開いているロールかこのフレームに設定します。", "Film Base: click the clear rebate once the frame has rendered": "フィルムベース:描画が完了してからフィルムの透明な縁をクリックしてください", "Film Base: that spot is not flat film; click the clear rebate": "フィルムベース:ここは均一なフィルムではありません。フィルムの透明な縁をクリックしてください", "Film Base: this frame's roll is not in the library": "フィルムベース:このフレームのロールがライブラリーにありません", @@ -1439,6 +1452,7 @@ "Loaded edit from {value}": "{value} から編集をロードしました", "Loaded {value} for this session only. The folder roll “{name}” holds only files from its own folder. Use Save as Virtual Roll… to keep them in a new roll.": "{value}をこのセッションだけ読み込みました。フォルダーのロール「{name}」が保持するのは、そのフォルダー内のファイルだけです。新しいロールに残すには「仮想ロールとして保存…」を使用してください。", "Loading live view…": "ライブビューを読み込み中…", + "Lock": "固定", "Lock Bounds: keep this frame's measured black and white points, even when the crop or Roll Analysis changes.": "範囲を固定:トリミングやロール解析が変わっても、このフレームの測定した黒点と白点を保ちます。", "Locked from frame {exposure_lock_frame}{when}. Every scan reuses it.": "フレーム {exposure_lock_frame}{when} からロックされました。すべてのスキャンでそれが再利用されます。", "Locked to the preset: pick Set by Hand under + to set it by hand.": "プリセットにロックされています: 手動で設定するには + の下の「手動で設定」を選びます。", @@ -1826,6 +1840,8 @@ "Pick a preset: one of your own, or a built-in one added under +.": "プリセットを選びます:自分のもの、または + で追加した内蔵のもの。", "Pick a print stock other than Digital and compare the print.": "デジタル以外のプリント素材を選び、プリントを比較してください。", "Pick a reference image and save it as a named profile": "参照画像を選択し、名前付きプロファイルとして保存します", + "Pick by Hand": "手動で指定", + "Pick by Hand: click once the frame has rendered": "手動で指定:フレームの描画後にクリックしてください", "Pick on a map, or paste coordinates": "地図上で選択するか、座標を貼り付けます", "Pick the capture place on a map (contacts OpenStreetMap)": "地図上で撮影場所を選びます(OpenStreetMap に接続します)", "Pick the custom border color…": "カスタムの余白の色を選択…", @@ -1835,6 +1851,7 @@ "Pick three bare-light exposures, red only, green only and blue only, with no film in the holder and the light and camera settings you scan with. Expose just below clipping. With a tethered camera and a Scanlight, Capture from Camera… shoots them for you.": "光源だけを撮った 3 枚(赤のみ、緑のみ、青のみ)を選びます。ホルダーにフィルムを入れず、スキャン時と同じ光源とカメラの設定で撮ります。白飛びのすぐ手前に露出します。テザー接続のカメラと Scanlight があれば、カメラから撮影…が代わりに撮影します。", "Pick three different files for red, green and blue": "赤・緑・青に異なる 3 つのファイルを選んでください", "Pick which of the current settings to store as a new preset": "現在の設定のうちどれを新しいプリセットとして保存するかを選択します", + "Picked: {values}": "指定値:{values}", "Picking a roll loads its saved baseline onto the loaded files.": "ロールを選択すると、保存されているベースラインがロードされたファイルにロードされます。", "Pin {value} · reads {measured}": "ピン {value} · 読み取り値 {measured}", "Pixels": "ピクセル", @@ -1989,6 +2006,7 @@ "Readouts": "読み取り値", "Reads per line the scanner averages": "スキャナーが平均する 1 行あたりの読み取り回数", "Reads the scan linear, with no white balance.": "ホワイトバランスをかけずに、スキャンをリニアで読み込みます。", + "Reanalyze": "再解析", "Reanalyze Frame": "フレームを再解析", "Reanalyze Frame: measure this frame again from its current crop.": "フレームを再解析:現在のトリミングでこのフレームをもう一度測定します。", "Reanalyze the frame or the selection": "フレームまたは選択範囲を再解析", @@ -2793,6 +2811,8 @@ "Toggle Auto Masking": "自動マスキングを切り替え", "Toggle Controls Panel": "コントロールパネルを切り替え", "Toggle Crop Empty Corners": "「空の角をトリミング」を切り替え", + "Toggle Dmax pick": "Dmax のピッカーを切り替え", + "Toggle Dmin pick": "Dmin のピッカーを切り替え", "Toggle Folder as Roll": "フォルダーをロールとして扱う設定を切り替え", "Toggle Hold Black (a shadow lift keeps the black point)": "黒点を維持を切り替え(暗部を持ち上げても黒点を保持)", "Toggle IR Removal": "IR 除去を切り替え", @@ -2930,6 +2950,7 @@ "Use Average Color": "平均の色を使用する", "Use Average Luma": "平均輝度を使用する", "Use Export profile": "エクスポートプロファイルを使用する", + "Use Frame": "フレーム使用", "Use Own Measurement": "自分の測定値を使用", "Use Shared Edit": "共有編集を使用する", "Use This Frame: make this frame's measurement the roll's baseline.": "このフレームを使用:このフレームの測定値をロールの基準にします。", diff --git a/negpy/translations/ko.json b/negpy/translations/ko.json index 540fd1ac8..272616685 100644 --- a/negpy/translations/ko.json +++ b/negpy/translations/ko.json @@ -451,6 +451,8 @@ "Clear All: remove all manual heals and keep the automatic dust removal.": "모두 지우기: 수동 복구를 모두 없애고 자동 먼지 제거는 유지합니다.", "Clear Crop": "자르기 지우기", "Clear Crops": "자르기 영역 지우기", + "Clear Dmax: measure the white point automatically again.": "Dmax 지우기: 화이트 포인트를 다시 자동으로 측정합니다.", + "Clear Dmin: measure the black point automatically again.": "Dmin 지우기: 블랙 포인트를 다시 자동으로 측정합니다.", "Clear Film Base: forget the film base this frame uses.": "필름 베이스 지우기: 이 프레임이 쓰는 필름 베이스를 지웁니다.", "Clear Library": "라이브러리 지우기", "Clear Library…": "라이브러리 지우기…", @@ -489,11 +491,13 @@ "Click something in the picture that should be gray, and the color is corrected to make it gray.": "사진에서 회색이어야 할 부분을 클릭하면 그 부분이 회색이 되도록 색이 보정됩니다.", "Click the clear film base (crosshair) first": "먼저 투명 필름 베이스(십자선)를 클릭하세요.", "Click the clear rebate to set the roll's film base": "투명한 가장자리를 클릭하여 롤의 필름 베이스를 설정합니다", + "Click the densest spot of the negative: the brightest highlight": "네거티브의 가장 짙은 곳을 클릭: 가장 밝은 하이라이트", "Click the photo to pick the area to copy from": "사진을 클릭하여 복사할 영역을 선택하세요.", "Click the photo to pick the area to copy from first": "먼저 사진을 클릭해 복사할 영역을 고르세요.", "Click the photo to pick the area to copy from.": "사진을 클릭하여 복사할 영역을 선택하세요.", "Click the photo to pin a spot; drag a pin to move it": "특정 지점을 고정하려면 사진을 클릭하세요. 핀을 드래그하여 이동하세요.", "Click the photo to place zone {value}": "존 {value}을(를) 배치하려면 사진을 클릭하세요.", + "Click the thinnest spot of the negative: the film base or the deepest shadow": "네거티브의 가장 얇은 곳을 클릭: 필름 베이스 또는 가장 깊은 섀도", "Click to enable or disable this mask's effect on the render": "렌더링에 대한 이 마스크의 효과를 활성화하거나 비활성화하려면 클릭하세요.", "Click to place vertices; a click near the start closes the mask": "정점을 배치하려면 클릭하세요. 시작 부분 근처를 클릭하면 마스크가 닫힙니다.", "Clipping": "클리핑", @@ -696,6 +700,7 @@ "Describe the photo to find…": "당신이 찾고 있는 것이 무엇인지 설명하세요…", "Description Fields": "설명 필드", "Description…": "설명…", + "Detect": "감지", "Detect All": "모두 감지", "Detect Frames": "프레임 감지", "Detect Scenes": "장면 감지", @@ -740,6 +745,14 @@ "Distortion": "왜곡", "Distortion Correction": "왜곡 보정", "Distortion and CA are off in Optics": "광학에서는 왜곡과 CA가 꺼져 있습니다.", + "Dmax": "Dmax", + "Dmax set for this frame": "이 프레임의 Dmax를 정했습니다", + "Dmax: Densest Spot": "Dmax: 가장 짙은 곳", + "Dmax: click the brightest highlight to set this frame's white point by hand.": "Dmax: 가장 밝은 하이라이트를 클릭해 이 프레임의 화이트 포인트를 직접 정합니다.", + "Dmin": "Dmin", + "Dmin set for this frame": "이 프레임의 Dmin을 정했습니다", + "Dmin: Thinnest Spot": "Dmin: 가장 얇은 곳", + "Dmin: click the film base or the deepest shadow to set this frame's black point by hand.": "Dmin: 필름 베이스 또는 가장 깊은 섀도를 클릭해 이 프레임의 블랙 포인트를 직접 정합니다.", "Do Not Ask Again": "다시 묻지 않음", "Do Not Show Again": "다시 표시하지 않음", "Dock controls panel to right": "컨트롤 패널을 오른쪽에 도킹", @@ -960,7 +973,7 @@ "Film": "필름", "Film & Quality": "필름 및 품질", "Film Base": "필름 베이스", - "Film Base: click the clear film between frames to set the film base of the open roll, or of this frame without one.": "필름 베이스: 프레임 사이의 투명한 필름을 클릭해 열린 롤의 필름 베이스를 정합니다. 열린 롤이 없으면 이 프레임의 것이 됩니다.", + "Film Base: click the clear film between frames to set the base these readouts measure from, for the open roll or this frame.": "필름 베이스: 프레임 사이의 투명한 필름을 클릭해 이 판독값이 측정 기준으로 삼는 베이스를 열린 롤 또는 이 프레임에 정합니다.", "Film Base: click the clear rebate once the frame has rendered": "필름 베이스: 프레임 렌더링 후 투명한 가장자리를 클릭하세요", "Film Base: that spot is not flat film; click the clear rebate": "필름 베이스: 이 지점은 균일한 필름이 아닙니다. 투명한 가장자리를 클릭하세요", "Film Base: this frame's roll is not in the library": "필름 베이스: 이 프레임의 롤이 라이브러리에 없습니다", @@ -1439,6 +1452,7 @@ "Loaded edit from {value}": "{value}의 수정사항을 로드했습니다.", "Loaded {value} for this session only. The folder roll “{name}” holds only files from its own folder. Use Save as Virtual Roll… to keep them in a new roll.": "{value}을(를) 이 세션에만 불러왔습니다. 폴더 롤 “{name}”에는 해당 폴더의 파일만 들어갑니다. 새 롤에 보관하려면 가상 롤로 저장…을 사용하십시오.", "Loading live view…": "실시간 보기 로드 중…", + "Lock": "고정", "Lock Bounds: keep this frame's measured black and white points, even when the crop or Roll Analysis changes.": "범위 고정: 자르기나 롤 분석이 바뀌어도 이 프레임의 측정한 검정점과 흰점을 유지합니다.", "Locked from frame {exposure_lock_frame}{when}. Every scan reuses it.": "{exposure_lock_frame}{when} 프레임에서 잠겼습니다. 모든 스캔은 이를 재사용합니다.", "Locked to the preset: pick Set by Hand under + to set it by hand.": "사전 설정에 잠겨 있습니다: 수동으로 설정하려면 + 아래의 수동 설정을 선택하세요.", @@ -1826,6 +1840,8 @@ "Pick a preset: one of your own, or a built-in one added under +.": "사전 설정을 고릅니다: 내가 만든 것, 또는 +로 추가한 내장 사전 설정입니다.", "Pick a print stock other than Digital and compare the print.": "디지털 이외의 프린트 소재를 선택하고 결과를 비교하세요.", "Pick a reference image and save it as a named profile": "참조 이미지를 선택하고 이름이 지정된 프로필로 저장합니다.", + "Pick by Hand": "직접 지정", + "Pick by Hand: click once the frame has rendered": "직접 지정: 프레임이 렌더링된 뒤 클릭하세요", "Pick on a map, or paste coordinates": "지도에서 선택하거나 좌표를 붙여넣으세요.", "Pick the capture place on a map (contacts OpenStreetMap)": "지도에서 촬영 장소를 고릅니다(OpenStreetMap에 연결)", "Pick the custom border color…": "사용자 정의 테두리 색상을 선택하세요…", @@ -1835,6 +1851,7 @@ "Pick three bare-light exposures, red only, green only and blue only, with no film in the holder and the light and camera settings you scan with. Expose just below clipping. With a tethered camera and a Scanlight, Capture from Camera… shoots them for you.": "광원만 찍은 세 장(빨강만, 초록만, 파랑만)을 고르세요. 홀더에 필름 없이, 스캔할 때와 같은 광원과 카메라 설정으로 찍습니다. 클리핑 바로 아래로 노출하세요. 테더링한 카메라와 Scanlight가 있으면 카메라로 촬영…이 대신 찍습니다.", "Pick three different files for red, green and blue": "빨강, 초록, 파랑에 서로 다른 파일 세 개를 고르세요", "Pick which of the current settings to store as a new preset": "현재 설정 중 새 사전 설정으로 저장할 설정을 선택하세요.", + "Picked: {values}": "지정값: {values}", "Picking a roll loads its saved baseline onto the loaded files.": "롤을 선택하면 저장된 기준선이 로드된 파일에 로드됩니다.", "Pin {value} · reads {measured}": "핀 {value} · 측정값 {measured}", "Pixels": "픽셀", @@ -1989,6 +2006,7 @@ "Readouts": "판독값", "Reads per line the scanner averages": "스캐너가 평균한 줄당 읽기", "Reads the scan linear, with no white balance.": "화이트 밸런스 없이 스캔을 선형으로 읽습니다.", + "Reanalyze": "다시 분석", "Reanalyze Frame": "프레임 다시 분석", "Reanalyze Frame: measure this frame again from its current crop.": "프레임 다시 분석: 현재 자르기로 이 프레임을 다시 측정합니다.", "Reanalyze the frame or the selection": "프레임 또는 선택 항목 다시 분석", @@ -2793,6 +2811,8 @@ "Toggle Auto Masking": "자동 마스킹 전환", "Toggle Controls Panel": "컨트롤 패널 전환", "Toggle Crop Empty Corners": "빈 모서리 자르기 전환", + "Toggle Dmax pick": "Dmax 선택기 전환", + "Toggle Dmin pick": "Dmin 선택기 전환", "Toggle Folder as Roll": "'폴더를 롤로' 전환", "Toggle Hold Black (a shadow lift keeps the black point)": "블랙 포인트 유지 전환(어두운 영역을 밝혀도 블랙 포인트 유지)", "Toggle IR Removal": "IR 제거 전환", @@ -2930,6 +2950,7 @@ "Use Average Color": "평균 색상 사용", "Use Average Luma": "평균 루마 사용", "Use Export profile": "내보내기 프로필 사용", + "Use Frame": "프레임 사용", "Use Own Measurement": "자체 측정값 사용", "Use Shared Edit": "공유 편집 사용", "Use This Frame: make this frame's measurement the roll's baseline.": "이 프레임 사용: 이 프레임의 측정값을 롤의 기준으로 삼습니다.", diff --git a/negpy/translations/zh_CN.json b/negpy/translations/zh_CN.json index 89cb312ae..1e880a457 100644 --- a/negpy/translations/zh_CN.json +++ b/negpy/translations/zh_CN.json @@ -451,6 +451,8 @@ "Clear All: remove all manual heals and keep the automatic dust removal.": "全部清除:移除所有手动修复,保留自动除尘。", "Clear Crop": "清除裁切", "Clear Crops": "清除裁切", + "Clear Dmax: measure the white point automatically again.": "清除 Dmax:重新自动测量白点。", + "Clear Dmin: measure the black point automatically again.": "清除 Dmin:重新自动测量黑点。", "Clear Film Base: forget the film base this frame uses.": "清除片基:忘记此帧使用的片基。", "Clear Library": "清空图库", "Clear Library…": "清空图库…", @@ -489,11 +491,13 @@ "Click something in the picture that should be gray, and the color is corrected to make it gray.": "点击画面中本应是灰色的地方,颜色会被校正使其变灰。", "Click the clear film base (crosshair) first": "请先点击透明片基(十字准星)", "Click the clear rebate to set the roll's film base": "点击透明边带,设置胶卷的片基", + "Click the densest spot of the negative: the brightest highlight": "点击负片最密处:最亮的高光", "Click the photo to pick the area to copy from": "点击照片选择要复制的区域", "Click the photo to pick the area to copy from first": "请先点击照片,选择要复制的源区域", "Click the photo to pick the area to copy from.": "点击照片选择要复制的区域。", "Click the photo to pin a spot; drag a pin to move it": "点击照片固定采样点,拖动定位点移动", "Click the photo to place zone {value}": "点击照片,放置区域 {value}", + "Click the thinnest spot of the negative: the film base or the deepest shadow": "点击负片最薄处:片基或最深的阴影", "Click to enable or disable this mask's effect on the render": "点击启用或禁用此蒙版的渲染效果", "Click to place vertices; a click near the start closes the mask": "点击放置顶点,点击起点附近闭合蒙版", "Clipping": "截断", @@ -696,6 +700,7 @@ "Describe the photo to find…": "描述要查找的内容…", "Description Fields": "描述字段", "Description…": "描述…", + "Detect": "检测", "Detect All": "检测全部", "Detect Frames": "检测帧", "Detect Scenes": "检测场景", @@ -740,6 +745,14 @@ "Distortion": "畸变", "Distortion Correction": "畸变校正", "Distortion and CA are off in Optics": "光学中的畸变与色差校正均已关闭", + "Dmax": "Dmax", + "Dmax set for this frame": "已为此帧设置 Dmax", + "Dmax: Densest Spot": "Dmax:最密处", + "Dmax: click the brightest highlight to set this frame's white point by hand.": "Dmax:点击最亮的高光,手动设置此帧的白点。", + "Dmin": "Dmin", + "Dmin set for this frame": "已为此帧设置 Dmin", + "Dmin: Thinnest Spot": "Dmin:最薄处", + "Dmin: click the film base or the deepest shadow to set this frame's black point by hand.": "Dmin:点击片基或最深的阴影,手动设置此帧的黑点。", "Do Not Ask Again": "不再询问", "Do Not Show Again": "不再显示", "Dock controls panel to right": "将控制面板停靠到右侧", @@ -960,7 +973,7 @@ "Film": "胶片", "Film & Quality": "胶片与质量", "Film Base": "片基", - "Film Base: click the clear film between frames to set the film base of the open roll, or of this frame without one.": "片基:点击帧之间的透明胶片,为当前打开的胶卷设置片基;没有打开胶卷时则为此帧设置。", + "Film Base: click the clear film between frames to set the base these readouts measure from, for the open roll or this frame.": "片基:点击帧之间的透明胶片,为打开的胶卷或此帧设置这些读数的测量基准片基。", "Film Base: click the clear rebate once the frame has rendered": "片基:请在画面渲染完成后点击透明边带", "Film Base: that spot is not flat film; click the clear rebate": "片基:此处不是均匀胶片,请点击透明边带", "Film Base: this frame's roll is not in the library": "片基:此画面的胶卷不在资料库中", @@ -1439,6 +1452,7 @@ "Loaded edit from {value}": "已从 {value} 加载编辑", "Loaded {value} for this session only. The folder roll “{name}” holds only files from its own folder. Use Save as Virtual Roll… to keep them in a new roll.": "已仅为本次会话加载 {value}。文件夹胶卷“{name}”只包含其自身文件夹中的文件。使用“另存为虚拟胶卷…”可将它们保存到新胶卷中。", "Loading live view…": "正在加载实时取景…", + "Lock": "锁定", "Lock Bounds: keep this frame's measured black and white points, even when the crop or Roll Analysis changes.": "锁定范围:即使裁切或胶卷分析改变,也保持此帧测得的黑点和白点。", "Locked from frame {exposure_lock_frame}{when}. Every scan reuses it.": "已从第 {exposure_lock_frame} 帧锁定{when},每次扫描均复用。", "Locked to the preset: pick Set by Hand under + to set it by hand.": "已锁定为预设值:在 + 下选择“手动设置”可手动调整。", @@ -1826,6 +1840,8 @@ "Pick a preset: one of your own, or a built-in one added under +.": "选择预设:你自己的,或在 + 下添加的内置预设。", "Pick a print stock other than Digital and compare the print.": "选择数字之外的印相材料,比较印相效果。", "Pick a reference image and save it as a named profile": "选择参考图并保存为命名配置", + "Pick by Hand": "手动选取", + "Pick by Hand: click once the frame has rendered": "手动选取:请在画面渲染完成后点击", "Pick on a map, or paste coordinates": "在地图上选择或粘贴坐标", "Pick the capture place on a map (contacts OpenStreetMap)": "在地图上选择拍摄地点,会连接 OpenStreetMap", "Pick the custom border color…": "选择自定义边框颜色…", @@ -1835,6 +1851,7 @@ "Pick three bare-light exposures, red only, green only and blue only, with no film in the holder and the light and camera settings you scan with. Expose just below clipping. With a tethered camera and a Scanlight, Capture from Camera… shoots them for you.": "选择三张只拍光源的照片:只有红、只有绿、只有蓝,片夹中不放胶片,光源和相机设置与扫描时相同。曝光刚好低于截断。使用联机相机和 Scanlight 时,从相机采集…会替你拍摄。", "Pick three different files for red, green and blue": "请为红、绿、蓝选择三个不同的文件", "Pick which of the current settings to store as a new preset": "选择当前设置中要保存到新预设的项目", + "Picked: {values}": "已选取:{values}", "Picking a roll loads its saved baseline onto the loaded files.": "选择胶卷会将其已保存基准加载到当前文件。", "Pin {value} · reads {measured}": "定位点 {value} · 读数 {measured}", "Pixels": "像素", @@ -1989,6 +2006,7 @@ "Readouts": "读数", "Reads per line the scanner averages": "扫描仪对每行取平均的读取次数", "Reads the scan linear, with no white balance.": "以线性方式读取扫描,不做白平衡。", + "Reanalyze": "重新分析", "Reanalyze Frame": "重新分析此帧", "Reanalyze Frame: measure this frame again from its current crop.": "重新分析此帧:按当前裁切重新测量此帧。", "Reanalyze the frame or the selection": "重新分析此帧或所选帧", @@ -2793,6 +2811,8 @@ "Toggle Auto Masking": "切换自动蒙版", "Toggle Controls Panel": "切换控制面板", "Toggle Crop Empty Corners": "切换裁掉空白角", + "Toggle Dmax pick": "切换 Dmax 取样工具", + "Toggle Dmin pick": "切换 Dmin 取样工具", "Toggle Folder as Roll": "切换文件夹作为胶卷", "Toggle Hold Black (a shadow lift keeps the black point)": "切换保持黑点(提亮暗部时保持黑点)", "Toggle IR Removal": "切换红外除尘", @@ -2930,6 +2950,7 @@ "Use Average Color": "使用平均颜色", "Use Average Luma": "使用平均亮度", "Use Export profile": "使用导出配置", + "Use Frame": "用此帧", "Use Own Measurement": "使用自身测量", "Use Shared Edit": "使用共享编辑", "Use This Frame: make this frame's measurement the roll's baseline.": "使用此帧:把此帧的测量值作为整卷基准。", diff --git a/tests/test_canvas_toolbar.py b/tests/test_canvas_toolbar.py index 6cf3c31ae..fb9e52819 100644 --- a/tests/test_canvas_toolbar.py +++ b/tests/test_canvas_toolbar.py @@ -963,7 +963,7 @@ def test_the_menu_is_grouped_under_panel_section_titles(self): tb = _make_toolbar() headers = [a.defaultWidget().text() for a in tb.btn_tools.choice_menu.actions() if isinstance(a, QWidgetAction)] - self.assertEqual(headers, ["CROP", "STRAIGHTEN", "METERING", "FILTRATION", "DODGE & BURN", "RETOUCH"]) + self.assertEqual(headers, ["ANALYSIS", "CROP", "STRAIGHTEN", "METERING", "FILTRATION", "DODGE & BURN", "RETOUCH"]) def test_tools_is_on_the_stock_row_and_collapses(self): from negpy.desktop.view.canvas.toolbar import _COLLAPSE_ORDER diff --git a/tests/test_film_base.py b/tests/test_film_base.py index 7fac2b5e7..33d4b1aa0 100644 --- a/tests/test_film_base.py +++ b/tests/test_film_base.py @@ -244,3 +244,30 @@ def test_with_no_roll_open_clear_leaves_the_library_rolls_base_alone(): ctrl.clear_film_base() assert rolls.roll_film_base(repo, roll_id) == ROLL_BASE assert _controller(repo, roll_id=roll_id).clearable_film_base() + + +def test_the_film_base_tool_sits_on_the_readouts_header_and_hides_on_a_slide(qapp): + from dataclasses import replace + from unittest.mock import MagicMock + + from negpy.desktop.session import AppState, ToolMode + from negpy.desktop.view.sidebar.right_panel import RightPanel + + controller = MagicMock() + controller.state = AppState() + controller.session.repo.get_global_setting.side_effect = lambda key, default=None: default + controller.clearable_film_base.return_value = True + panel = RightPanel(controller) + panel._sync_film_base() + header = panel.readouts_fold.button.parentWidget() + assert panel.film_base_btn.parentWidget() is header + assert not panel.film_base_btn.isHidden() and not panel.clear_film_base_btn.isHidden() + assert not panel.film_base_btn.edited_dot.isHidden() + + panel.film_base_btn.click() + controller.set_active_tool.assert_called_with(ToolMode.FILM_BASE) + + cfg = controller.state.config + controller.state.config = replace(cfg, process=replace(cfg.process, process_mode=ProcessMode.E6, positive_source=True)) + panel._sync_film_base() + assert panel.film_base_btn.isHidden() and panel.clear_film_base_btn.isHidden() diff --git a/tests/test_fold.py b/tests/test_fold.py index c4e6d7765..ff936c99a 100644 --- a/tests/test_fold.py +++ b/tests/test_fold.py @@ -225,3 +225,15 @@ def test_the_analysis_stats_report_a_warning(qapp): stats.update_stats([StatRow("Clipping", "Sh 3.0%", warn=True)]) stats.update_stats([StatRow("Clipping", "Sh 0.0%")]) assert seen == [True, False] + + +def test_a_clearable_tools_visible_clear_marks_the_closed_fold(): + from negpy.desktop.view.styles.templates import clearable_tool_toggle + + btn = clearable_tool_toggle("fa5s.crosshairs", "Dmin", "tip", "clear") + _row, fold = group_fold("test_picks", "Pick by Hand", btn) + assert not fold.button.is_marked() + btn.clear_btn.setVisible(True) + assert fold.button.is_marked() + fold.set_open(True) + assert not fold.button.is_marked() diff --git a/tests/test_header_actions.py b/tests/test_header_actions.py new file mode 100644 index 000000000..2f23bb613 --- /dev/null +++ b/tests/test_header_actions.py @@ -0,0 +1,34 @@ +import pytest +from PyQt6.QtWidgets import QVBoxLayout, QWidget + +from negpy.desktop.view.styles.templates import header_actions, labeled_action, section_subheader + + +@pytest.mark.parametrize("count", [1, 2]) +def test_actions_take_a_quarter_of_the_row_each(qapp, count): + host = QWidget() + col = QVBoxLayout(host) + col.setContentsMargins(0, 0, 0, 0) + buttons = [labeled_action("fa5s.redo", f"Action {i}", "tip") for i in range(count)] + col.addLayout(header_actions(section_subheader("ANALYSIS"), *buttons)) + host.resize(400, 80) + host.show() + qapp.processEvents() + for btn in buttons: + assert btn.width() == pytest.approx(100, abs=8) + assert btn.geometry().right() <= 400 + assert buttons[-1].geometry().right() == pytest.approx(399, abs=2) + + +def test_a_narrow_row_keeps_every_label(qapp): + host = QWidget() + col = QVBoxLayout(host) + col.setContentsMargins(0, 0, 0, 0) + buttons = [labeled_action("fa5s.redo", "Reanalyze", "tip"), labeled_action("fa5s.crosshairs", "Use Frame", "tip")] + col.addLayout(header_actions(section_subheader("ROLLS"), *buttons)) + host.resize(300, 80) + host.show() + qapp.processEvents() + for btn in buttons: + assert btn.width() >= btn.sizeHint().width() + assert not btn._compact() diff --git a/tests/test_manual_bounds_pick.py b/tests/test_manual_bounds_pick.py new file mode 100644 index 000000000..dbe93804e --- /dev/null +++ b/tests/test_manual_bounds_pick.py @@ -0,0 +1,165 @@ +import numpy as np +import pytest + +from negpy.features.exposure.normalization import LogNegativeBounds, resolve_bounds_detailed +from negpy.features.process.models import ProcessConfig + +_AUTO = LogNegativeBounds((-2.0, -2.1, -2.2), (-0.3, -0.4, -0.5)) +_DMIN = (-0.1, -0.2, -0.6) +_DMAX = (-1.8, -1.9, -2.4) + + +def _t(b: LogNegativeBounds) -> tuple: + return tuple(b.floors), tuple(b.ceils) + + +def _resolve(**kw) -> tuple: + final, base = resolve_bounds_detailed(ProcessConfig(**kw), lambda: _AUTO) + return _t(final), _t(base) + + +@pytest.mark.parametrize( + ("picks", "expected"), + [ + ({}, (_AUTO.floors, _AUTO.ceils)), + ({"manual_dmin": _DMIN}, (_AUTO.floors, _DMIN)), + ({"manual_dmax": list(_DMAX)}, (_DMAX, _AUTO.ceils)), + ({"manual_dmin": _DMIN, "manual_dmax": _DMAX}, (_DMAX, _DMIN)), + ], +) +def test_a_picked_end_replaces_that_end_of_the_analysis(picks, expected): + final, base = _resolve(**picks) + assert base == expected + assert final == expected + + +def test_roll_average_on_both_axes_wins_over_picks(): + locked = LogNegativeBounds((-1.5, -1.5, -1.5), (-0.2, -0.2, -0.2)) + final, _ = _resolve( + manual_dmin=_DMIN, + manual_dmax=_DMAX, + use_luma_average=True, + use_color_average=True, + locked_floors=locked.floors, + locked_ceils=locked.ceils, + ) + assert final == _t(locked) + + +def _controller(): + from dataclasses import replace + from unittest.mock import MagicMock + + from negpy.desktop.controller import AppController + from negpy.desktop.session import AppState + + ctrl = AppController.__new__(AppController) + ctrl.state = AppState() + ctrl.state.config = replace(ctrl.state.config, process=replace(ctrl.state.config.process, local_floors=(-1.0,) * 3, lock_bounds=True)) + ctrl.state.last_metrics = {"final_bounds": LogNegativeBounds((-1.0, -1.0, -1.0), (0.0, 0.0, 0.0))} + ctrl.set_status = MagicMock() + ctrl.set_active_tool = MagicMock() + ctrl.tool_sync_requested = MagicMock() + ctrl.apply_config = lambda config, **_k: setattr(ctrl.state, "config", config) + return ctrl + + +def _click(ctrl, field, value): + from unittest.mock import MagicMock + + ctrl._sample_normalized_patch = MagicMock(return_value=[value] * 8 + [(1.0, 1.0, 1.0)]) + ctrl._handle_bounds_pick(0.5, 0.5, field) + + +def test_each_tool_sets_its_own_end_and_disarms(): + from negpy.desktop.session import ToolMode + + ctrl = _controller() + _click(ctrl, "manual_dmax", (0.1, 0.2, 0.3)) + proc = ctrl.state.config.process + assert proc.manual_dmax == pytest.approx((-0.9, -0.8, -0.7)) and proc.manual_dmin is None + assert proc.local_floors == (0.0, 0.0, 0.0) # measured again even under Lock Bounds + ctrl.set_active_tool.assert_called_once_with(ToolMode.NONE) + + _click(ctrl, "manual_dmin", (0.9, 0.8, 0.7)) + assert ctrl.state.config.process.manual_dmin == pytest.approx((-0.1, -0.2, -0.3)) + assert ctrl.state.config.process.manual_dmin_at == (0.5, 0.5) + + +def test_clear_forgets_only_its_own_end(): + ctrl = _controller() + _click(ctrl, "manual_dmin", (0.9, 0.8, 0.7)) + _click(ctrl, "manual_dmax", (0.1, 0.2, 0.3)) + ctrl.clear_bounds_pick("manual_dmin") + proc = ctrl.state.config.process + assert proc.manual_dmin is None and proc.manual_dmin_at is None + assert proc.manual_dmax is not None and proc.manual_dmax_at is not None + assert ctrl.has_bounds_picks() + + +def test_the_tools_are_registered(): + from negpy.desktop.session import UNCROPPED_PREVIEW_TOOLS, ToolMode + from negpy.desktop.view.canvas.toolbar import _CANVAS_TOOLS + from negpy.desktop.view.shortcut_registry import REGISTRY + + for action, mode in (("dmin_pick", ToolMode.DMIN_PICK), ("dmax_pick", ToolMode.DMAX_PICK)): + assert action in REGISTRY + assert mode in UNCROPPED_PREVIEW_TOOLS + assert any(not isinstance(spec, str) and spec[0] is mode for spec in _CANVAS_TOOLS) + + +def test_both_engines_render_with_the_picked_bounds(): + from dataclasses import replace + + from negpy.domain.interfaces import PipelineContext + from negpy.domain.models import WorkspaceConfig + from negpy.features.process.models import ProcessMode + from negpy.infrastructure.gpu.device import GPUDevice + from negpy.infrastructure.simulated.images import film + + if not GPUDevice.get().is_available: + pytest.skip("GPU not available") + from negpy.services.rendering.engine import DarkroomEngine + from negpy.services.rendering.gpu_engine import GPUEngine + + img = np.ascontiguousarray(film(600, 900, 3, 24)[0], dtype=np.float32) + cfg = WorkspaceConfig() + cfg = replace(cfg, process=replace(cfg.process, manual_dmin=(-0.2, -0.3, -0.5), manual_dmax=(-1.6, -1.7, -2.0))) + engine = GPUEngine() + try: + _tex, gpu = engine.process_to_texture( + img, cfg, scale_factor=1.0, apply_layout=False, readback_metrics=True, source_hash="pick", analysis_source_hash="pick" + ) + finally: + engine.destroy_all() + context = PipelineContext(scale_factor=1.0, original_size=(900, 600), process_mode=ProcessMode.C41) + DarkroomEngine().process(img, cfg, "pick", context=context) + + for engine_bounds in (gpu["log_bounds_base"], context.metrics["log_bounds_base"]): + floors, ceils = _t(engine_bounds) + assert floors == pytest.approx((-1.6, -1.7, -2.0)) and ceils == pytest.approx((-0.2, -0.3, -0.5)) + + +def test_the_readout_colors_each_channel(): + from negpy.desktop.view.sidebar.process import _picked_densities + from negpy.desktop.view.styles.theme import THEME + + text = _picked_densities((-0.21, -0.48, -0.83)) + for color, part in ((THEME.channel_red_text, "R 0.21"), (THEME.channel_green_text, "G 0.48"), (THEME.channel_blue_text, "B 0.83")): + assert f'{part}' in text + assert _picked_densities(None) == "Auto" + + +def test_a_pick_location_never_reaches_the_thumbnail(): + from dataclasses import replace + + from negpy.domain.models import WorkspaceConfig + from negpy.services.assets.thumbnail_fingerprint import thumbnail_fingerprint + + cfg = WorkspaceConfig() + moved = replace(cfg, process=replace(cfg.process, manual_dmin_at=(0.1, 0.2))) + + def fp(c): + return thumbnail_fingerprint(c, workspace_color_space="Adobe RGB", input_icc_path=None) + + assert fp(cfg) == fp(moved) diff --git a/tests/test_process_sidebar.py b/tests/test_process_sidebar.py index 6b90947c0..919171724 100644 --- a/tests/test_process_sidebar.py +++ b/tests/test_process_sidebar.py @@ -127,9 +127,8 @@ def test_the_analysis_bar_holds_everything_that_meters_this_frame(qapp): ] assert order == sorted(order) assert _row_index_containing(col, sidebar.analysis_header) == 0 - assert _row_index_containing(col, sidebar.film_base_btn) == _row_index_containing(col, sidebar.analysis_region_btn) + assert _row_index_containing(col, sidebar.reanalyze_frame_btn) == _row_index_containing(col, sidebar.analysis_region_btn) assert sidebar.clear_analysis_region_btn.parentWidget() is sidebar.analysis_region_btn - assert sidebar.clear_film_base_btn.parentWidget() is sidebar.film_base_btn def test_the_region_buttons_carry_their_names(qapp): @@ -147,13 +146,13 @@ def test_average_toggles_ride_the_baseline_bar_not_the_analysis(qapp): assert not sidebar.baseline_bar.isAncestorOf(widget) -def test_reanalyze_and_lock_bounds_sit_on_the_analysis_header(qapp): +def test_lock_bounds_sits_on_the_analysis_header(qapp): _, sidebar = _sidebar() col = sidebar.analysis_bar.layout() header_i = _row_index_containing(col, sidebar.analysis_header) - assert _row_index_containing(col, sidebar.reanalyze_frame_btn) == header_i assert _row_index_containing(col, sidebar.lock_bounds_btn) == header_i - assert sidebar.lock_bounds_btn.text() == "", "an icon-only toggle" + assert sidebar.reanalyze_frame_btn.text().strip() == "Reanalyze Frame" + assert sidebar.lock_bounds_btn.text().strip() == "Lock" def test_the_point_header_folds_the_channel_row_and_its_sliders(qapp): diff --git a/tests/test_roll_sidebar.py b/tests/test_roll_sidebar.py index a0dcee08b..8eb85b95e 100644 --- a/tests/test_roll_sidebar.py +++ b/tests/test_roll_sidebar.py @@ -111,10 +111,11 @@ def test_the_baseline_buttons_sit_on_the_rolls_header(qapp): assert widgets[0].text() == "ROLLS" -def test_the_baseline_icon_buttons_name_themselves_in_the_tooltip(qapp): +def test_the_baseline_actions_carry_a_short_label_and_their_full_name_in_the_tooltip(qapp): _, sidebar, ids = _sidebar(roll_names=["Portra 400"], active_name="Portra 400") sidebar._on_roll_picked(ids["Portra 400"]) - assert sidebar.reanalyze_btn.text() == "" + assert sidebar.reanalyze_btn.text().strip() == "Reanalyze" + assert sidebar.from_frame_btn.text().strip() == "Use Frame" assert "Roll Analysis" in sidebar.reanalyze_btn.toolTip() assert "Use This Frame" in sidebar.from_frame_btn.toolTip()