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+ )
239243from DisplayCAL .ui import message_box
240244from 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+
712771def _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