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
27 changes: 24 additions & 3 deletions negpy/desktop/view/sidebar/live_view_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
50 changes: 35 additions & 15 deletions negpy/desktop/view/sidebar/scanlight.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
REFERENCE_LEVELS,
normalize_start_point,
scan_ladder,
shutter_display,
usable_ladder,
)
from negpy.services.assets.sensor import SensorProfiles
Expand Down Expand Up @@ -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),
Expand All @@ -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.")
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -1368,16 +1380,20 @@ 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(
tr(
"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,
),
Expand All @@ -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,
),
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {}
Expand Down Expand Up @@ -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 = ""
Expand Down
22 changes: 17 additions & 5 deletions negpy/features/metadata/exif_read.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -37,18 +38,29 @@ 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] = []

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")

Expand Down
87 changes: 86 additions & 1 deletion negpy/services/capture/calibration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading