diff --git a/negpy/desktop/view/sidebar/live_view_window.py b/negpy/desktop/view/sidebar/live_view_window.py index 8c1b000db..e7a79db2a 100644 --- a/negpy/desktop/view/sidebar/live_view_window.py +++ b/negpy/desktop/view/sidebar/live_view_window.py @@ -33,6 +33,7 @@ from negpy.desktop.view.widgets.dialog_geometry import remember_dialog_geometry from negpy.desktop.view.widgets.floating_panel import float_over_app from negpy.desktop.view.widgets.step_combo import StepCombo +from negpy.services.capture.calibration import shutter_displays #: Progress-bar chunk color per triplet channel. The live view freezes during a triplet, #: because the capture holds the camera without gaps, so the bar shows the R to G to B switch. @@ -42,14 +43,19 @@ _FOCUS_AT_PEAK = 0.99 +# The camera's own label of an item, which a write sends; the item text is only its display. +_LABEL_ROLE = Qt.ItemDataRole.UserRole + 1 + + class SettingStepper(StepCombo): """StepCombo for a camera setting, with no list: every ISO, shutter or aperture step would fill the screen. The arrows stop at the ends, so a step never wraps from the slowest - shutter to the fastest.""" + shutter to the fastest. `shutter=True` shows each label as a camera shows it ('1/30').""" - def __init__(self, parent=None) -> None: + def __init__(self, parent=None, *, shutter: bool = False) -> None: super().__init__(parent) self.setPlaceholderText(tr("—")) + self._shutter = shutter self._last_step = 0.0 # monotonic time of the last step (see stepping) self.activated.connect(self._on_stepped) @@ -59,9 +65,24 @@ def _on_stepped(self, _index: int) -> None: def addItem(self, label: str, raw=None) -> None: # noqa: N802 """A placeholder keeps QComboBox at no choice; the first option is the shown value.""" super().addItem(label, raw) + self.setItemData(self.count() - 1, label, _LABEL_ROLE) + if self._shutter: + # Every item, because the whole ladder decides third or half stops. + ladder = [self.label_at(i) for i in range(self.count())] + for i, text in enumerate(shutter_displays(ladder)): + self.setItemText(i, text) if self.currentIndex() < 0: self.setCurrentIndex(0) + def label_at(self, index: int) -> str: + return str(self.itemData(index, _LABEL_ROLE) or "") + + def current_label(self) -> str: + return self.label_at(self.currentIndex()) if self.currentIndex() >= 0 else "" + + def find_label(self, label: str) -> int: + return next((i for i in range(self.count()) if self.label_at(i) == label), -1) + def _step_target(self, step: int) -> int: target = self.currentIndex() + step return target if 0 <= target < self.count() else -1 @@ -200,7 +221,7 @@ def __init__(self, parent=None, *, repo=None) -> None: settings_row = QHBoxLayout(self.settings_widget) settings_row.setContentsMargins(0, 0, 0, 0) self.iso_stepper = SettingStepper() - self.shutter_stepper = SettingStepper() + self.shutter_stepper = SettingStepper(shutter=True) self.aperture_stepper = SettingStepper() # No white-balance control: the scan decodes RAW with a fixed neutral WB, so the camera's # WB only tints the preview, never the result. diff --git a/negpy/desktop/view/sidebar/scanlight.py b/negpy/desktop/view/sidebar/scanlight.py index b962bfbce..5ba2b1d14 100644 --- a/negpy/desktop/view/sidebar/scanlight.py +++ b/negpy/desktop/view/sidebar/scanlight.py @@ -65,6 +65,7 @@ REFERENCE_LEVELS, normalize_start_point, scan_ladder, + shutter_display, usable_ladder, ) from negpy.services.assets.sensor import SensorProfiles @@ -445,7 +446,7 @@ def _init_ui(self) -> None: self.capture_mode_row.setLayout(field_row(tr("Capture mode"), self.capture_btn)) _exp.addWidget(self.capture_mode_row) self.iso_stepper = SettingStepper() - self.shutter_stepper = SettingStepper() + self.shutter_stepper = SettingStepper(shutter=True) self.aperture_stepper = SettingStepper() for _tag_text, _which, _stepper in ( ("ISO", "iso", self.iso_stepper), @@ -464,7 +465,7 @@ def _init_ui(self) -> None: self._ir_exposure_widget.setObjectName("collapsible_content_body") _ir = QVBoxLayout(self._ir_exposure_widget) _ir.setContentsMargins(0, 0, 0, 0) - self.ir_shutter_stepper = SettingStepper() + self.ir_shutter_stepper = SettingStepper(shutter=True) self.ir_shutter_stepper.setToolTip( tr("Shutter for the infrared exposure, set by hand and usually much slower than the visible exposure.") ) @@ -693,7 +694,7 @@ def _show_lone(self, stepper, label: str) -> None: @staticmethod def _stepper_label(stepper) -> str: """The stepper's current label as a clean value ('' for the empty “—” placeholder).""" - text = stepper.currentText().strip() + text = stepper.current_label().strip() return "" if text == tr("—") else text def _apply_preset_exposure(self, iso: str, aperture: str) -> None: @@ -738,7 +739,7 @@ def _apply_preset(self, preset) -> None: (self.iso_stepper, preset.iso), (self.aperture_stepper, preset.aperture), ): - idx = stepper.findText(label) if label else -1 + idx = stepper.find_label(label) if label else -1 if idx >= 0: stepper.setCurrentIndex(idx) elif label: @@ -1279,7 +1280,13 @@ def _on_meter(self) -> None: self._set_status(tr("Meter: this body publishes no writable shutter")) return if shutter not in candidates: # Bulb, a bad label, or past the ladder's span - self._set_status(tr("Meter: set the shutter between {value} and {value1} first", value=candidates[0], value1=candidates[-1])) + self._set_status( + tr( + "Meter: set the shutter between {value} and {value1} first", + value=self._shutter_text(candidates[0]), + value1=self._shutter_text(candidates[-1]), + ) + ) return from negpy.desktop.workers.capture_worker import ExposureProbeRequest @@ -1339,9 +1346,9 @@ def _on_meter_finished(self, reading) -> None: if label is None: self._apply_gating() limit = ( - tr("{fastest}, the fastest the Scanlight meters cleanly", fastest=self._meter_fastest) + tr("{fastest}, the fastest the Scanlight meters cleanly", fastest=self._shutter_text(self._meter_fastest)) if self._rgb_mode - else self._meter_fastest + else self._shutter_text(self._meter_fastest) ) self._set_status( tr( @@ -1356,7 +1363,12 @@ def _on_meter_finished(self, reading) -> None: if raw is None: self._apply_gating() self._set_status( - tr("Meter: {where} at {level}; {label} is not a step this body offers", where=where, level=level, label=label), + tr( + "Meter: {where} at {level}; {label} is not a step this body offers", + where=where, + level=level, + label=self._shutter_text(label), + ), pinned=pinned, ) return @@ -1368,7 +1380,11 @@ def _on_meter_finished(self, reading) -> None: index = self.lv_window.shutter_stepper.findData(raw) if index >= 0: self.lv_window.shutter_stepper.setCurrentIndex(index) - note = tr("; the preset scans at {label} until you pick it again", label=label) if self._adopt_metered_shutter(label, raw) else "" + note = ( + tr("; the preset scans at {label} until you pick it again", label=self._shutter_text(label)) + if self._adopt_metered_shutter(label, raw) + else "" + ) self._apply_gating() if reading.clipped: self._set_status( @@ -1376,8 +1392,8 @@ def _on_meter_finished(self, reading) -> None: "Meter: {where} clipped on {clipped_fraction:.1%} of the picture at {shutter}; set {label} ({stops:+.1f} st), meter again{note}", where=where, clipped_fraction=reading.clipped_fraction, - shutter=reading.shutter, - label=label, + shutter=self._shutter_text(reading.shutter), + label=self._shutter_text(label), stops=reading.stops, note=note, ), @@ -1389,7 +1405,7 @@ def _on_meter_finished(self, reading) -> None: "Meter: {where} at {level} → {label} ({stops:+.1f} st){note}", where=where, level=level, - label=label, + label=self._shutter_text(label), stops=reading.stops, note=note, ), @@ -1621,7 +1637,7 @@ def _on_camera_setting(self, which: str, combo) -> None: if raw is not None: # Buffer the latest value and write once the user pauses, so rapid stepping does not # queue a slow verified write per intermediate step. - self._cam_pending[which] = (int(raw), combo.currentText()) + self._cam_pending[which] = (int(raw), combo.current_label()) self._cam_setting_debounce.start() def _flush_camera_settings(self) -> None: @@ -1720,7 +1736,7 @@ def _refresh_ir_shutter(self, info) -> None: def _select_ir_shutter(self) -> bool: """Point the IR stepper at the saved IR shutter; False when none is saved or offered.""" - idx = self.ir_shutter_stepper.findText(self._settings.shutter_ir) if self._settings.shutter_ir else -1 + idx = self.ir_shutter_stepper.find_label(self._settings.shutter_ir) if self._settings.shutter_ir else -1 if idx >= 0: self.ir_shutter_stepper.setCurrentIndex(idx) return idx >= 0 @@ -1886,6 +1902,10 @@ def _last_frame_number(self, folder: str, roll: str) -> int: return 0 return hi + def _shutter_text(self, label: str) -> str: + """A shutter label for a message, as the steppers show it.""" + return shutter_display(label, self._shutter_labels()) + def _shutter_labels(self) -> tuple[str, ...]: """Every shutter label the body publishes, the slow end included.""" info = self._settings_json().get("shutter") or {} @@ -1951,7 +1971,7 @@ def _start_capture(self, retake: bool) -> None: self._set_status( tr( "IR Shutter {shutter} is not a step this camera offers; the infrared frame shoots at the camera's shutter", - shutter=shutter_ir, + shutter=self._shutter_text(shutter_ir), ) ) shutter_ir = "" diff --git a/negpy/features/metadata/exif_read.py b/negpy/features/metadata/exif_read.py index 140c871cb..429147a68 100644 --- a/negpy/features/metadata/exif_read.py +++ b/negpy/features/metadata/exif_read.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from fractions import Fraction from typing import Any, Optional import xml.etree.ElementTree as ET @@ -37,6 +38,19 @@ def rational_to_int(value: Any) -> Optional[int]: return int(round(f)) if f is not None else None +def _format_exposure_time(num: int, den: int) -> str: + """An EXIF exposure rational as a camera marks it ('1/30s', '1/2.5s', '0.4s'). A body + may store 1/30 as 10/300, so the fraction is reduced first.""" + if not num or not den: + return "" + t = Fraction(num, den) + if t.numerator == 1: + return f"1/{t.denominator}s" + if t < Fraction(29, 100): + return f"1/{round(t.denominator / t.numerator, 1):g}s" + return f"{round(float(t), 1):g}s" + + def format_exposure(exif_tags: dict) -> str: """Format scan exposure time, f-number, and ISO from an Exif IFD dict.""" parts: list[str] = [] @@ -44,11 +58,9 @@ def format_exposure(exif_tags: dict) -> str: exposure_time = exif_tags.get(piexif.ExifIFD.ExposureTime) if exposure_time is not None: if isinstance(exposure_time, tuple) and len(exposure_time) == 2: - num, den = exposure_time - if num == 1: - parts.append(f"1/{den}s") - elif num: - parts.append(f"{num}/{den}s") + text = _format_exposure_time(*exposure_time) + if text: + parts.append(text) elif isinstance(exposure_time, (int, float)): parts.append(f"{exposure_time}s") diff --git a/negpy/services/capture/calibration.py b/negpy/services/capture/calibration.py index ebb0fd418..879adb83a 100644 --- a/negpy/services/capture/calibration.py +++ b/negpy/services/capture/calibration.py @@ -5,11 +5,13 @@ from __future__ import annotations +import math import re +import statistics import time from dataclasses import dataclass from functools import lru_cache -from typing import Callable, Optional +from typing import Callable, Optional, Sequence import numpy as np @@ -153,6 +155,89 @@ def shutter_seconds(label: str) -> float: return float(label) +# Marked speeds, in seconds: what a camera's own display shows on a third- or half-stop ladder. +_THIRD_STOP_SPEEDS = ( + tuple(1 / d for d in (8000, 6400, 5000, 4000, 3200, 2500, 2000, 1600, 1250, 1000, 800, 640, 500, 400, 320, 250, 200, 160)) + + tuple(1 / d for d in (125, 100, 80, 60, 50, 40, 30, 25, 20, 15, 13, 10, 8, 6, 5, 4)) + + ( + 0.3, + 0.4, + 0.5, + 0.6, + 0.8, + 1, + 1.3, + 1.6, + 2, + 2.5, + 3.2, + 4, + 5, + 6, + 8, + 10, + 13, + 15, + 20, + 25, + 30, + ) +) +_HALF_STOP_SPEEDS = tuple( + 1 / d for d in (8000, 6000, 4000, 3000, 2000, 1500, 1000, 750, 500, 350, 250, 180, 125, 90, 60, 45, 30, 20, 15, 10, 8, 6, 4) +) + (0.3, 0.5, 0.7, 1, 1.5, 2, 3, 4, 6, 8, 10, 15, 20, 30) +# A body that publishes decimal seconds rounds them to four places (Nikon: "0.0333s" for 1/30). +_DECIMAL_SLACK_S = 0.00005 + + +def _marked_speeds(ladder: Sequence[str]) -> tuple[tuple[float, ...], ...]: + """The marked speeds to snap to, most likely first. Four-place decimals cannot tell 1/800 + from 1/750, so the body's own step decides; an unknown spacing tries third stops first.""" + rungs = sorted(set(shutter_seconds(label) for label in usable_ladder(tuple(ladder)))) + if len(rungs) >= 3: + step = statistics.median(math.log2(b / a) for a, b in zip(rungs, rungs[1:])) + if abs(step - 1 / 3) < 0.08: + return (_THIRD_STOP_SPEEDS,) + if abs(step - 1 / 2) < 0.08: + return (_HALF_STOP_SPEEDS,) + return (_THIRD_STOP_SPEEDS, _HALF_STOP_SPEEDS) + + +def shutter_display(label: str, ladder: Sequence[str] = ()) -> str: + """A shutter label as a camera shows it: '1/30' below 0.3 s, '0.4"' and '2"' from there up. + A '1/N' label stays as published. `ladder` is the body's list of labels, which tells a + third-stop body from a half-stop one. + + Display only: the camera's own label is the value a write sends. A label that does not parse + (Bulb, Time) is returned as it is.""" + return _display(label, _marked_speeds(ladder)) + + +def shutter_displays(ladder: Sequence[str]) -> list[str]: + """`shutter_display` of every label in the body's ladder.""" + speeds = _marked_speeds(ladder) + return [_display(label, speeds) for label in ladder] + + +def _display(label: str, marked_speeds: tuple[tuple[float, ...], ...]) -> str: + if re.fullmatch(r"\s*1/\d+\s*", label): + return label.strip() # already as the camera marks it: a body may show 1/2 where another shows 0.5" + try: + seconds = shutter_seconds(label) + except (TypeError, ValueError): + return label + if seconds <= 0: + return label + for speeds in marked_speeds: + marked = min(speeds, key=lambda m: abs(m - seconds)) + if abs(marked - seconds) <= _DECIMAL_SLACK_S + 0.02 * marked: + seconds = marked + break + if seconds < 0.29: + return f"1/{round(1 / seconds)}" + return f'{round(seconds, 1):g}"' + + def usable_ladder(candidates: tuple[str, ...]) -> tuple[str, ...]: """The parseable, ascending subset of a body's shutter labels; empty if none are usable. diff --git a/tests/test_capture_calibration.py b/tests/test_capture_calibration.py index ea09acc1c..6b60456e0 100644 --- a/tests/test_capture_calibration.py +++ b/tests/test_capture_calibration.py @@ -670,3 +670,41 @@ def test_sensor_response_refuses_a_light_too_dim_to_measure(): def test_scan_ladder_keeps_a_body_inside_the_built_in_span(): assert scan_ladder(["1/8000", "Bulb", " 1/60 ", "1/250", "30", "2", "1/0"]) == ("1/250", "1/60", "2") + + +_NIKON_THIRDS = [f"{1 / d:.4f}s" for d in (1000, 800, 640, 500, 250, 125, 60, 30, 15, 13, 8, 4)] + [ + f"{s:.4f}s" for s in (0.3, 0.4, 1, 1.3, 2.5, 30) +] +_NIKON_HALVES = [f"{1 / d:.4f}s" for d in (1000, 750, 500, 350, 250, 180, 125, 90, 60, 45, 30)] + [f"{s:.4f}s" for s in (0.7, 1.5)] + + +def test_shutter_display_shows_a_decimal_ladder_as_the_camera_marks_it(): + from negpy.services.capture.calibration import shutter_displays + + assert shutter_displays(_NIKON_THIRDS) == [ + "1/1000", "1/800", "1/640", "1/500", "1/250", "1/125", "1/60", "1/30", "1/15", "1/13", "1/8", "1/4", + '0.3"', '0.4"', '1"', '1.3"', '2.5"', '30"', + ] # fmt: skip + assert shutter_displays(_NIKON_HALVES) == [ + "1/1000", "1/750", "1/500", "1/350", "1/250", "1/180", "1/125", "1/90", "1/60", "1/45", "1/30", '0.7"', '1.5"', + ] # fmt: skip + + +@pytest.mark.parametrize( + ("label", "shown"), + [ + ("1/30", "1/30"), + ("1/2", "1/2"), + ("4/10", '0.4"'), + ("0.4", '0.4"'), + ("1", '1"'), + ("0.0013s", "1/800"), + ("Bulb", "Bulb"), + ("1/0", "1/0"), + ("", ""), + ], +) +def test_shutter_display_of_one_label(label, shown): + from negpy.services.capture.calibration import shutter_display + + assert shutter_display(label) == shown diff --git a/tests/test_metadata_writer.py b/tests/test_metadata_writer.py index c5160ab99..4625ce573 100644 --- a/tests/test_metadata_writer.py +++ b/tests/test_metadata_writer.py @@ -387,3 +387,20 @@ def test_preserve_embed_plan_keeps_source_xmp_packet() -> None: if __name__ == "__main__": pytest.main([__file__, "-v"]) + + +@pytest.mark.parametrize( + ("rational", "shown"), + [((10, 300), "1/30s"), ((1, 30), "1/30s"), ((10, 25), "0.4s"), ((13, 10), "1.3s"), ((10, 75), "1/7.5s"), ((5, 0), "")], +) +def test_the_exposure_time_shows_as_the_camera_marks_it_and_parses_back(rational, shown): + from fractions import Fraction + + from negpy.features.metadata.exif_read import format_exposure + from negpy.features.metadata.writer import _parse_exposure_str + + text = format_exposure({piexif.ExifIFD.ExposureTime: rational}) + + assert text == shown + if shown: + assert Fraction(*_parse_exposure_str(text)[piexif.ExifIFD.ExposureTime]) == Fraction(*rational) diff --git a/tests/test_scanlight_sidebar.py b/tests/test_scanlight_sidebar.py index fc69781ba..258b6183c 100644 --- a/tests/test_scanlight_sidebar.py +++ b/tests/test_scanlight_sidebar.py @@ -2726,6 +2726,28 @@ def test_a_missed_poll_keeps_the_white_presets_offered(): assert not any(_white_rows_hidden(w)) +def test_a_decimal_shutter_ladder_shows_fractions_and_keeps_the_camera_label(tmp_path, monkeypatch): + import json + + import negpy.desktop.view.sidebar.scanlight as sl + + options = [{"raw": i, "label": label} for i, label in enumerate(("0.0167s", "0.0200s", "0.0250s", "0.0333s", "0.4000s"))] + p = tmp_path / "settings.json" + p.write_text(json.dumps({"shutter": {"cur": 3, "writable": True, "options": options}})) + monkeypatch.setattr(sl, "default_settings_path", lambda: str(p)) + w = _sidebar() + w._camera_verified = True + w._set_manual_mode(True) + + stepper = w.shutter_stepper + assert [stepper.itemText(i) for i in range(stepper.count())] == ["1/60", "1/50", "1/40", "1/30", '0.4"'] + assert w.lv_window.shutter_stepper.currentText() == "1/30" + stepper.setCurrentIndex(stepper.find_label("0.4000s")) + w._on_sidebar_exposure_changed("shutter", stepper) + assert w._settings.shutter_r == "0.4000s" # the scan sends the camera's own label + assert w._shutter_text("0.0333s") == "1/30" + + def test_the_light_toggle_turns_the_framing_light_on_and_shows_the_echo(): w = _sidebar() w.controller.set_scanlight_color.reset_mock()