Skip to content

Commit 385540b

Browse files
authored
Merge pull request #869 from eoyilmaz/844-qt-luminance-measure-patch
[#844] Add on-screen white/black patch buttons for direct luminance measurement
2 parents 6c16ce1 + aa1382a commit 385540b

6 files changed

Lines changed: 461 additions & 47 deletions

File tree

DisplayCAL/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.10.0.dev44
1+
3.10.0.dev45

DisplayCAL/ui/main_window.py

Lines changed: 227 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,11 @@
235235
header_banner_pixmap,
236236
header_continuation_pixmap,
237237
)
238-
from DisplayCAL.ui.measure_frame import MeasureFrame
238+
from DisplayCAL.ui.measure_frame import (
239+
MeasureFrame,
240+
default_measureframe_size,
241+
resolve_screen_size_mm,
242+
)
239243
from DisplayCAL.ui import message_box
240244
from DisplayCAL.ui.measurement_flow import (
241245
MeasurementFlow,
@@ -709,6 +713,61 @@ def rows(self) -> int:
709713
return int(self._rows_combo.currentText())
710714

711715

716+
class _LuminancePatchWindow(QWidget):
717+
"""On-screen white/black patch for direct luminance measurement.
718+
719+
Qt port of the ad-hoc ``wx.Frame`` wx's ``luminance_measure_handler``
720+
builds: a plain full-colour panel with a "Measure" button the user
721+
positions over the instrument. Kept as its own lightweight floating
722+
tool window (no menu bar, no geometry persistence) rather than reusing
723+
:class:`~DisplayCAL.ui.measure_frame.MeasureFrame`, which is wired to
724+
the dispcal/dispread subprocess flow instead of a one-shot ``spotread``
725+
reading. Pattern-generator support (wx's ``setup_patterngenerator``)
726+
isn't reproduced, matching the rest of this port's ambient/whitepoint
727+
measure buttons.
728+
"""
729+
730+
measure_requested = Signal()
731+
732+
def __init__(self, parent: QWidget, color: QColor) -> None:
733+
super().__init__(parent, Qt.Tool)
734+
self.setWindowTitle(lang.getstr("measureframe.title"))
735+
self._color = color
736+
size = self._default_size()
737+
self.resize(size, size)
738+
measure_btn = QPushButton(lang.getstr("measure"), self)
739+
measure_btn.clicked.connect(self.measure_requested)
740+
layout = QVBoxLayout(self)
741+
layout.setContentsMargins(12, 12, 12, 12)
742+
# Empty row above absorbs all growth, matching wx's FlexGridSizer(2,
743+
# 3) with only the top row growable: the button sits on the bottom
744+
# edge, horizontally centred, not in the middle of the patch.
745+
layout.addStretch(1)
746+
layout.addWidget(measure_btn, 0, Qt.AlignHCenter)
747+
748+
def _default_size(self) -> int:
749+
"""100 mm square in pixels, matching wx's ad-hoc frame sizing.
750+
751+
Mirrors ``wx_measure_frame.get_default_size()`` via the same
752+
physical-size resolution :class:`~DisplayCAL.ui.measure_frame
753+
.MeasureFrame` uses, so the patch opens at a sensible on-screen size
754+
instead of an arbitrary small default.
755+
"""
756+
screen = self.screen()
757+
if screen is not None:
758+
geo = screen.geometry()
759+
geometry = (geo.x(), geo.y(), geo.width(), geo.height())
760+
size_mm = resolve_screen_size_mm(screen, geometry)
761+
if size_mm:
762+
return default_measureframe_size((geo.width(), geo.height()), size_mm)
763+
return int(DEFAULTS.get("size.measureframe", 300))
764+
765+
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 (Qt override)
766+
painter = QPainter(self)
767+
painter.fillRect(self.rect(), self._color)
768+
super().paintEvent(event)
769+
770+
712771
def _as_float(value: object) -> float | None:
713772
"""Best-effort float coercion (``None`` when not numeric)."""
714773
try:
@@ -1152,6 +1211,11 @@ def __init__(self, worker: Worker | None = None) -> None:
11521211
self._visual_whitepoint_editor_window: VisualWhitepointEditorWindow | None = (
11531212
None
11541213
)
1214+
#: On-screen white/black patch windows for the luminance measure
1215+
#: buttons, created lazily on first click (see
1216+
#: :meth:`_luminance_measure_btn_handler`).
1217+
self._luminance_patch_window: _LuminancePatchWindow | None = None
1218+
self._black_luminance_patch_window: _LuminancePatchWindow | None = None
11551219
#: 3D LUT input-colorspace combo: description -> profile path,
11561220
#: mirroring wx's ``MainFrame.input_profiles`` (populated once from
11571221
#: the bundled reference profiles, see ``_lut3d_init_input_profiles``).
@@ -3496,9 +3560,21 @@ def _build_calibration_tab(self) -> QWidget:
34963560
self.luminance_textctrl.setSuffix(" cd/m²")
34973561
self.luminance_textctrl.valueChanged.connect(self._luminance_changed)
34983562
self.luminance_ctrl.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
3563+
self.luminance_measure_btn = self._tool_button(
3564+
"palette-white",
3565+
"measure",
3566+
lambda: self._luminance_measure_btn_handler("luminance_measure_btn"),
3567+
)
3568+
self.ambient_luminance_measure_btn = self._tool_button(
3569+
"stock_3d-color-picker",
3570+
"ambient.measure",
3571+
lambda: self._ambient_measure_btn_handler("ambient_luminance_measure_btn"),
3572+
)
34993573
luminance_row = QHBoxLayout()
35003574
luminance_row.addWidget(self.luminance_ctrl, 1)
35013575
luminance_row.addWidget(self.luminance_textctrl)
3576+
luminance_row.addWidget(self.luminance_measure_btn)
3577+
luminance_row.addWidget(self.ambient_luminance_measure_btn)
35023578
form.addRow(lang.getstr("calibration.luminance"), self._wrap(luminance_row))
35033579

35043580
# Black level (black luminance).
@@ -3519,9 +3595,17 @@ def _build_calibration_tab(self) -> QWidget:
35193595
self.black_luminance_ctrl.setSizePolicy(
35203596
QSizePolicy.Expanding, QSizePolicy.Fixed
35213597
)
3598+
self.black_luminance_measure_btn = self._tool_button(
3599+
"palette-black",
3600+
"measure",
3601+
lambda: self._luminance_measure_btn_handler(
3602+
"black_luminance_measure_btn"
3603+
),
3604+
)
35223605
black_luminance_row = QHBoxLayout()
35233606
black_luminance_row.addWidget(self.black_luminance_ctrl, 1)
35243607
black_luminance_row.addWidget(self.black_luminance_textctrl)
3608+
black_luminance_row.addWidget(self.black_luminance_measure_btn)
35253609
self._black_luminance_row_widget = self._wrap(black_luminance_row)
35263610
form.addRow(
35273611
lang.getstr("calibration.black_luminance"),
@@ -6135,19 +6219,21 @@ def _visual_whitepoint_editor_btn_handler(self) -> None:
61356219
def _ambient_measure_btn_handler(self, evtobjname: str) -> None:
61366220
"""Whitepoint/ambient "measure" button handler.
61376221
6138-
Qt port of wx's ``ambient_measure_handler`` for the two buttons this
6139-
port has (``whitepoint_measure_btn``, ``ambient_measure_btn``) --
6140-
both drive Argyll's ``spotread`` directly in ambient mode (using the
6141-
instrument's diffuser, no on-screen patch). Not reproduced: the
6142-
white/black luminance measure buttons (need an on-screen patch
6143-
window wx builds ad hoc) and the visual-whitepoint-editor's own
6144-
measure button (a separate, editor-embedded flow).
6222+
Qt port of wx's ``ambient_measure_handler`` for the three buttons
6223+
this port has (``whitepoint_measure_btn``, ``ambient_measure_btn``,
6224+
``ambient_luminance_measure_btn``) -- all three drive Argyll's
6225+
``spotread`` directly in ambient mode (using the instrument's
6226+
diffuser, no on-screen patch). The white/black luminance measure
6227+
buttons pop an on-screen patch instead and are handled separately
6228+
by :meth:`_luminance_measure_btn_handler`. Not reproduced: the
6229+
visual-whitepoint-editor's own measure button (a separate,
6230+
editor-embedded flow).
61456231
61466232
Args:
6147-
evtobjname: Which button was clicked (``"whitepoint_measure_btn"``
6148-
or ``"ambient_measure_btn"``), threaded through to the
6149-
consumer exactly like wx threads ``event.GetEventObject()
6150-
.Name``.
6233+
evtobjname: Which button was clicked (``"whitepoint_measure_btn"``,
6234+
``"ambient_measure_btn"`` or ``"ambient_luminance_measure_btn"``),
6235+
threaded through to the consumer exactly like wx threads
6236+
``event.GetEventObject().Name``.
61516237
"""
61526238
if not check_set_argyll_bin():
61536239
return
@@ -6162,6 +6248,7 @@ def _ambient_measure_btn_handler(self, evtobjname: str) -> None:
61626248
lambda result: self._ambient_measure_consumer(result, evtobjname),
61636249
progress_msg=lang.getstr("ambient.measure"),
61646250
pauseable=False,
6251+
interactive_frame="ambient",
61656252
)
61666253

61676254
def _ambient_measure_producer(self) -> str | bool | Exception:
@@ -6187,9 +6274,9 @@ def _ambient_measure_consumer(
61876274
) -> None:
61886275
"""Parse ``spotread`` output and update the whitepoint/ambient fields.
61896276
6190-
Qt port of ``ambient_measure_consumer``, scoped to the two buttons
6191-
:meth:`_ambient_measure_btn_handler` drives (the luminance and
6192-
visual-whitepoint-editor branches of the wx consumer don't apply).
6277+
Qt port of ``ambient_measure_consumer``, scoped to the three buttons
6278+
:meth:`_ambient_measure_btn_handler` drives (the visual-whitepoint-
6279+
editor branch of the wx consumer doesn't apply).
61936280
"""
61946281
if not result or isinstance(result, Exception):
61956282
if isinstance(result, Exception):
@@ -6210,7 +6297,14 @@ def _ambient_measure_consumer(
62106297
r"Yxy: (\d+(?:\.\d+)) (\d+(?:\.\d+)) (\d+(?:\.\d+))", text
62116298
)
62126299
lux_match = re.search(r"Ambient = (\d+(?:\.\d+)) Lux", text, re.I)
6213-
if not (k_match or yxy_match or lux_match):
6300+
# XYZ / monochrome Y: only relevant for ambient_luminance_measure_btn,
6301+
# which (like wx) may fill in the white luminance field when the
6302+
# instrument reports it alongside (or instead of) an ambient level.
6303+
xyz_match = re.search(
6304+
r"XYZ: (\d+(?:\.\d+)) (\d+(?:\.\d+)) (\d+(?:\.\d+))", text
6305+
)
6306+
y_match = re.search(r"Y: (\d+(?:\.\d+))", text)
6307+
if not (k_match or yxy_match or lux_match or xyz_match or y_match):
62146308
message_box.critical(self, APPNAME, text + lang.getstr("failure"))
62156309
return
62166310
k = float(k_match.group(1)) if k_match else None
@@ -6252,6 +6346,10 @@ def _ambient_measure_consumer(
62526346
QMessageBox.No,
62536347
)
62546348
set_whitepoint = answer == QMessageBox.Yes
6349+
elif evtobjname == "ambient_luminance_measure_btn" and (xyz_match or y_match):
6350+
y = float(xyz_match.group(2) if xyz_match else y_match.group(1))
6351+
self.luminance_ctrl.setCurrentIndex(1)
6352+
self.luminance_textctrl.setValue(max(y, 40))
62556353

62566354
if not set_whitepoint:
62576355
return
@@ -6275,6 +6373,119 @@ def _ambient_measure_consumer(
62756373
self.whitepoint_y_ctrl.setValue(round(float(y), 4))
62766374
self._whitepoint_changed()
62776375

6376+
def _luminance_measure_btn_handler(self, evtobjname: str) -> None:
6377+
"""Open the on-screen white/black patch for direct luminance measurement.
6378+
6379+
Qt port of wx's ``luminance_measure_handler`` for the
6380+
``luminance_measure_btn`` / ``black_luminance_measure_btn`` controls:
6381+
pops a full-colour patch window (:class:`_LuminancePatchWindow`)
6382+
with its own "Measure" button, reused on repeat clicks like the
6383+
``_visual_whitepoint_editor_window`` singleton precedent elsewhere
6384+
on this window. Pattern-generator support (wx's
6385+
``setup_patterngenerator``) isn't reproduced.
6386+
6387+
Args:
6388+
evtobjname: Which button was clicked (``"luminance_measure_btn"``
6389+
or ``"black_luminance_measure_btn"``), threaded through to
6390+
:meth:`_luminance_measure_consumer`.
6391+
"""
6392+
white = evtobjname == "luminance_measure_btn"
6393+
window = (
6394+
self._luminance_patch_window
6395+
if white
6396+
else self._black_luminance_patch_window
6397+
)
6398+
if window is None:
6399+
window = _LuminancePatchWindow(
6400+
self, QColor(Qt.white) if white else QColor(Qt.black)
6401+
)
6402+
window.measure_requested.connect(
6403+
lambda: self._luminance_patch_measure_handler(evtobjname)
6404+
)
6405+
if white:
6406+
self._luminance_patch_window = window
6407+
else:
6408+
self._black_luminance_patch_window = window
6409+
window.show()
6410+
window.raise_()
6411+
window.activateWindow()
6412+
6413+
def _luminance_patch_measure_handler(self, evtobjname: str) -> None:
6414+
""""Measure" button handler inside the on-screen luminance patch.
6415+
6416+
Qt port of the branch of wx's ``ambient_measure_handler`` reached
6417+
from the ad-hoc patch frame's own Measure button
6418+
(``interactive_frame == "luminance"``): runs ``spotread`` in
6419+
emissive mode against the visible on-screen patch rather than the
6420+
instrument's ambient diffuser.
6421+
"""
6422+
if not check_set_argyll_bin():
6423+
return
6424+
if sys.platform == "win32" and sys.getwindowsversion() < (5, 1):
6425+
message_box.critical(
6426+
self, APPNAME, lang.getstr("windows.version.unsupported")
6427+
)
6428+
return
6429+
controller = self._ensure_run_controller()
6430+
controller.run(
6431+
self._luminance_measure_producer,
6432+
lambda result: self._luminance_measure_consumer(result, evtobjname),
6433+
progress_msg=lang.getstr("measure"),
6434+
pauseable=False,
6435+
interactive_frame="luminance",
6436+
)
6437+
6438+
def _luminance_measure_producer(self) -> str | bool | Exception:
6439+
"""Run ``spotread`` in emissive mode for a white/black luminance patch.
6440+
6441+
Qt port of ``ambient_measure_producer``'s emissive (``"-e"``) branch,
6442+
reached from wx's ad-hoc luminance patch window.
6443+
"""
6444+
cmd = get_argyll_util("spotread")
6445+
args = ["-v", "-e", "-x"]
6446+
if getcfg("extra_args.spotread").strip():
6447+
args += parse_argument_string(getcfg("extra_args.spotread"))
6448+
result = self.worker.add_measurement_features(
6449+
args, False, allow_nondefault_observer=True, ambient=False
6450+
)
6451+
if isinstance(result, Exception):
6452+
return result
6453+
return self.worker.exec_cmd(cmd, args, capture_output=True, skip_scripts=True)
6454+
6455+
def _luminance_measure_consumer(
6456+
self, result: str | bool | Exception, evtobjname: str
6457+
) -> None:
6458+
"""Parse ``spotread`` output and update the white/black luminance field.
6459+
6460+
Qt port of ``ambient_measure_consumer``'s XYZ/monochrome-Y branch,
6461+
scoped to the two on-screen patch buttons
6462+
:meth:`_luminance_measure_btn_handler` drives.
6463+
"""
6464+
if not result or isinstance(result, Exception):
6465+
if isinstance(result, Exception):
6466+
message_box.critical(self, APPNAME, str(result))
6467+
return
6468+
text = re.sub(
6469+
r"[^\t\n\r\x20-\x7f]", "", "".join(self.worker.output)
6470+
).strip()
6471+
xyz_match = re.search(
6472+
r"XYZ: (\d+(?:\.\d+)) (\d+(?:\.\d+)) (\d+(?:\.\d+))", text
6473+
)
6474+
y_match = re.search(r"Y: (\d+(?:\.\d+))", text) # Monochrome, e.g. Spyder4/5
6475+
if not (xyz_match or y_match):
6476+
message_box.critical(self, APPNAME, text + lang.getstr("failure"))
6477+
return
6478+
y = float(xyz_match.group(2) if xyz_match else y_match.group(1))
6479+
if evtobjname == "luminance_measure_btn":
6480+
# Force minimum luminance of 40 cd/m2, suitable for dark
6481+
# viewing. See Mantiuk et al, "Display Considerations for Night
6482+
# and Low-Illumination Viewing".
6483+
self.luminance_ctrl.setCurrentIndex(1)
6484+
self.luminance_textctrl.setValue(max(y, 40))
6485+
else:
6486+
self.black_luminance_ctrl.setCurrentIndex(1)
6487+
self.black_luminance_textctrl.setValue(y)
6488+
62786489
def _calibration_quality_changed(self, value: int) -> None:
62796490
"""Persist the calibration quality and refresh its labels."""
62806491
self._update_calibration_quality_label()

0 commit comments

Comments
 (0)