Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 8 additions & 6 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,18 +181,18 @@ A scene is a group of frames shot in the same light. It gets its own Roll Analys
<!-- panel:analysis -->
## 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

Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})

Expand Down
53 changes: 34 additions & 19 deletions negpy/desktop/view/canvas/overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion negpy/desktop/view/canvas/toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -254,14 +256,17 @@ 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"),
(ToolMode.STRAIGHTEN, "straighten", "controls_panel.geometry_sidebar.straighten_btn"),
(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"),
Expand Down
2 changes: 2 additions & 0 deletions negpy/desktop/view/canvas/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 10 additions & 5 deletions negpy/desktop/view/keyboard_shortcuts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions negpy/desktop/view/shortcut_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down
Loading
Loading