Skip to content

Commit ccd788d

Browse files
committed
Implement initial chromaticity inspector.
1 parent 5e9182c commit ccd788d

File tree

9 files changed

+608
-14
lines changed

9 files changed

+608
-14
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# SPDX-License-Identifier: BSD-3-Clause
22
# Copyright Contributors to the OpenColorIO Project.
33

4+
from .chromaticities_inspector import ChromaticitiesInspector
45
from .code_inspector import CodeInspector
56
from .curve_inspector import CurveInspector
67
from .log_inspector import LogInspector
Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
# SPDX-License-Identifier: BSD-3-Clause
2+
# Copyright Contributors to the OpenColorIO Project.
3+
4+
from itertools import groupby
5+
from typing import Optional
6+
7+
import colour
8+
import pygfx as gfx
9+
import numpy as np
10+
import PyOpenColorIO as ocio
11+
from colour_visuals import (
12+
VisualChromaticityDiagramCIE1931,
13+
VisualGrid,
14+
VisualRGBColourspace2D,
15+
VisualRGBScatter3D,
16+
)
17+
from colour import CCS_ILLUMINANTS, XYZ_to_RGB
18+
from colour.utilities import set_default_float_dtype
19+
from PySide6 import QtCore, QtGui, QtWidgets
20+
21+
from ..config_cache import ConfigCache
22+
from ..message_router import MessageRouter
23+
from ..processor_context import ProcessorContext
24+
from ..utils import get_glyph_icon, subsampling_factor
25+
from ..viewer import WgpuCanvasOffScreenViewer
26+
27+
28+
class ChromaticitiesInspector(QtWidgets.QWidget):
29+
@classmethod
30+
def label(cls) -> str:
31+
return "Chromaticities"
32+
33+
@classmethod
34+
def icon(cls) -> QtGui.QIcon:
35+
return get_glyph_icon("mdi6.grain")
36+
37+
def __init__(self, parent: Optional[QtCore.QObject] = None):
38+
super().__init__(parent=parent)
39+
40+
# Setting "Colour" float processing precision to Float32.
41+
set_default_float_dtype(np.float32)
42+
colour.plotting.CONSTANTS_COLOUR_STYLE.font.size = 20
43+
44+
self._context = None
45+
self._processor = None
46+
self._image_array = np.atleast_3d([0, 0, 0]).astype(np.float32)
47+
48+
# Chromaticity Diagram Working Space
49+
self._working_whitepoint = CCS_ILLUMINANTS[
50+
"CIE 1931 2 Degree Standard Observer"
51+
]["D65"]
52+
working_space = colour.RGB_Colourspace(
53+
"CIE-XYZ-D65",
54+
colour.XYZ_to_xy(np.identity(3)),
55+
self._working_whitepoint,
56+
"D65",
57+
use_derived_matrix_RGB_to_XYZ=True,
58+
use_derived_matrix_XYZ_to_RGB=True,
59+
)
60+
colour.RGB_COLOURSPACES[working_space.name] = working_space
61+
self._working_space = working_space.name
62+
63+
self._root = None
64+
self._visuals = {}
65+
66+
# Widgets
67+
self._wgpu_viewer = WgpuCanvasOffScreenViewer()
68+
self._conversion_chain_label = QtWidgets.QLabel()
69+
self._conversion_chain_label.setStyleSheet(
70+
".QLabel { font-size: 10pt;qproperty-alignment: AlignCenter;}"
71+
)
72+
73+
self._chromaticities_color_spaces_label = QtWidgets.QLabel(
74+
"Chromaticities Color Space"
75+
)
76+
self._chromaticities_color_spaces_combobox = QtWidgets.QComboBox()
77+
78+
self._draw_input_color_space_checkbox = QtWidgets.QCheckBox()
79+
self._draw_input_color_space_checkbox.setChecked(True)
80+
self._draw_chromaticities_color_space_checkbox = QtWidgets.QCheckBox()
81+
self._draw_chromaticities_color_space_checkbox.setChecked(True)
82+
83+
# Layout
84+
wgpu_layout = QtWidgets.QVBoxLayout()
85+
spacer = QtWidgets.QSpacerItem(
86+
20,
87+
20,
88+
QtWidgets.QSizePolicy.Expanding,
89+
QtWidgets.QSizePolicy.Expanding,
90+
)
91+
wgpu_layout.addItem(spacer)
92+
wgpu_layout.addWidget(self._conversion_chain_label)
93+
self._wgpu_viewer.setLayout(wgpu_layout)
94+
95+
hbox_layout = QtWidgets.QHBoxLayout()
96+
hbox_layout.addWidget(self._chromaticities_color_spaces_label)
97+
hbox_layout.addWidget(self._chromaticities_color_spaces_combobox)
98+
99+
hbox_layout.addWidget(self._draw_input_color_space_checkbox)
100+
hbox_layout.addWidget(self._draw_chromaticities_color_space_checkbox)
101+
102+
vbox_layout = QtWidgets.QVBoxLayout()
103+
vbox_layout.addLayout(hbox_layout)
104+
vbox_layout.addWidget(self._wgpu_viewer)
105+
self.setLayout(vbox_layout)
106+
107+
msg_router = MessageRouter.get_instance()
108+
msg_router.config_html_ready.connect(self._on_config_html_ready)
109+
msg_router.processor_ready.connect(self._on_processor_ready)
110+
msg_router.image_ready.connect(self._on_image_ready)
111+
112+
self._setup_visuals()
113+
114+
# Signals / Slots
115+
self._chromaticities_color_spaces_combobox.textActivated.connect(
116+
self._update_visuals
117+
)
118+
self._draw_input_color_space_checkbox.checkStateChanged.connect(
119+
self._on_draw_input_color_space_checkbox_checkstatechanged
120+
)
121+
self._draw_chromaticities_color_space_checkbox.checkStateChanged.connect(
122+
self._on_draw_chromaticities_color_space_checkbox_checkstatechanged
123+
)
124+
125+
@property
126+
def wgpu_viewer(self):
127+
return self._wgpu_viewer
128+
129+
def _on_draw_input_color_space_checkbox_checkstatechanged(self, state):
130+
self._visuals["rgb_color_space_input"].visible = (
131+
state == QtGui.Qt.CheckState.Checked
132+
)
133+
134+
self._wgpu_viewer.render()
135+
136+
def _on_draw_chromaticities_color_space_checkbox_checkstatechanged(
137+
self, state
138+
):
139+
self._visuals["rgb_color_space_chromaticities"].visible = (
140+
state == QtGui.Qt.CheckState.Checked
141+
)
142+
143+
self._wgpu_viewer.render()
144+
145+
def _setup_visuals(self):
146+
self._wgpu_viewer.wgpu_scene.add(
147+
gfx.Background(
148+
None, gfx.BackgroundMaterial(np.array([0, 0, 0]))
149+
)
150+
)
151+
152+
self._visuals = {
153+
"grid": VisualGrid(size=2),
154+
"chromaticity_diagram": VisualChromaticityDiagramCIE1931(
155+
kwargs_visual_chromaticity_diagram={"opacity": 0.25}
156+
),
157+
"rgb_color_space_input": VisualRGBColourspace2D(
158+
self._working_space,
159+
colour=np.array([1, 0.5, 0.25]),
160+
thickness=2,
161+
),
162+
"rgb_color_space_chromaticities": VisualRGBColourspace2D(
163+
self._working_space,
164+
colour=np.array([0.25, 1, 0.5]),
165+
thickness=2,
166+
),
167+
"rgb_scatter_3d": VisualRGBScatter3D(
168+
np.zeros(3), self._working_space, size=4
169+
),
170+
}
171+
172+
self._root = gfx.Group()
173+
for visual in self._visuals.values():
174+
self._root.add(visual)
175+
176+
self._visuals["rgb_color_space_input"].visible = False
177+
self._visuals["rgb_color_space_input"].local.position = np.array(
178+
[0, 0, 0.000025])
179+
self._visuals["rgb_color_space_chromaticities"].visible = False
180+
self._visuals[
181+
"rgb_color_space_chromaticities"].local.position = np.array(
182+
[0, 0, 0.00005])
183+
self._wgpu_viewer.wgpu_scene.add(self._root)
184+
185+
self._wgpu_viewer.wgpu_camera.local.position = np.array([0.5, 0.5, 2])
186+
self._wgpu_viewer.wgpu_camera.show_pos(np.array([0.5, 0.5, 0.5]))
187+
188+
def reset(self) -> None:
189+
pass
190+
191+
def showEvent(self, event: QtGui.QShowEvent) -> None:
192+
"""Start listening for processor updates, if visible."""
193+
super().showEvent(event)
194+
195+
msg_router = MessageRouter.get_instance()
196+
# NOTE: We need to be able to receive notifications about config changes
197+
# and this is currently the only way to do that without connecting
198+
# to the `ConfigDock.config_changed` signal.
199+
msg_router.config_updates_allowed = True
200+
msg_router.processor_updates_allowed = True
201+
msg_router.image_updates_allowed = True
202+
203+
def hideEvent(self, event: QtGui.QHideEvent) -> None:
204+
"""Stop listening for processor updates, if not visible."""
205+
super().hideEvent(event)
206+
207+
msg_router = MessageRouter.get_instance()
208+
msg_router.config_updates_allowed = False
209+
msg_router.processor_updates_allowed = False
210+
msg_router.image_updates_allowed = False
211+
212+
@QtCore.Slot(str)
213+
def _on_config_html_ready(self, record: str) -> None:
214+
color_space_names = ConfigCache.get_color_space_names()
215+
216+
items = [
217+
self._chromaticities_color_spaces_combobox.itemText(i)
218+
for i in range(self._chromaticities_color_spaces_combobox.count())
219+
]
220+
221+
if items != color_space_names:
222+
self._chromaticities_color_spaces_combobox.clear()
223+
self._chromaticities_color_spaces_combobox.addItems(
224+
color_space_names
225+
)
226+
227+
config = ocio.GetCurrentConfig()
228+
has_role_interchange_display = config.hasRole(
229+
ocio.ROLE_INTERCHANGE_DISPLAY
230+
)
231+
self._chromaticities_color_spaces_combobox.setEnabled(
232+
has_role_interchange_display
233+
)
234+
235+
self._draw_input_color_space_checkbox.setEnabled(
236+
has_role_interchange_display
237+
)
238+
self._visuals["rgb_color_space_input"].visible = (
239+
has_role_interchange_display
240+
)
241+
self._draw_chromaticities_color_space_checkbox.setEnabled(
242+
has_role_interchange_display
243+
)
244+
self._visuals["rgb_color_space_chromaticities"].visible = (
245+
has_role_interchange_display
246+
)
247+
248+
@QtCore.Slot(ocio.CPUProcessor)
249+
def _on_processor_ready(
250+
self, proc_context: ProcessorContext, cpu_proc: ocio.CPUProcessor
251+
) -> None:
252+
self._context = proc_context
253+
self._processor = cpu_proc
254+
255+
self._update_visuals()
256+
257+
@QtCore.Slot(np.ndarray)
258+
def _on_image_ready(self, image_array: np.ndarray) -> None:
259+
sub_sampling_factor = int(
260+
np.sqrt(subsampling_factor(image_array, 1e6))
261+
)
262+
self._image_array = image_array[
263+
::sub_sampling_factor, ::sub_sampling_factor
264+
]
265+
266+
self._update_visuals()
267+
268+
def _update_visuals(self, *args):
269+
conversion_chain = []
270+
271+
image_array = np.copy(self._image_array)
272+
273+
# 1. Apply current active processor
274+
if self._processor is not None:
275+
if self._context.transform_item_name is not None:
276+
conversion_chain += [
277+
self._context.input_color_space,
278+
self._context.transform_item_name,
279+
]
280+
281+
rgb_colourspace = color_space_to_RGB_Colourspace(
282+
self._context.input_color_space
283+
)
284+
285+
if rgb_colourspace is not None:
286+
self._visuals["rgb_color_space_input"].colourspace = (
287+
rgb_colourspace
288+
)
289+
290+
self._processor.applyRGB(image_array)
291+
292+
# 2. Convert from chromaticities input space to "CIE-XYZ-D65" interchange
293+
config = ocio.GetCurrentConfig()
294+
input_color_space = (
295+
self._chromaticities_color_spaces_combobox.currentText()
296+
)
297+
if (
298+
config.hasRole(ocio.ROLE_INTERCHANGE_DISPLAY)
299+
and input_color_space in ConfigCache.get_color_space_names()
300+
):
301+
chromaticities_colorspace = (
302+
self._chromaticities_color_spaces_combobox.currentText()
303+
)
304+
conversion_chain += [
305+
chromaticities_colorspace,
306+
ocio.ROLE_INTERCHANGE_DISPLAY.replace(
307+
"cie_xyz_d65_interchange", "CIE-XYZ-D65"
308+
),
309+
]
310+
311+
rgb_colourspace = color_space_to_RGB_Colourspace(
312+
chromaticities_colorspace
313+
)
314+
315+
if rgb_colourspace is not None:
316+
self._visuals["rgb_color_space_chromaticities"].colourspace = (
317+
rgb_colourspace
318+
)
319+
320+
colorspace_transform = ocio.ColorSpaceTransform(
321+
src=chromaticities_colorspace,
322+
dst=ocio.ROLE_INTERCHANGE_DISPLAY,
323+
)
324+
processor = config.getProcessor(
325+
colorspace_transform, ocio.TRANSFORM_DIR_FORWARD
326+
).getDefaultCPUProcessor()
327+
processor.applyRGB(image_array)
328+
329+
# 3. Convert from "CIE-XYZ-D65" to "VisualRGBScatter3D" working space
330+
conversion_chain += ["CIE-XYZ-D65", self._working_space]
331+
image_array = XYZ_to_RGB(
332+
image_array,
333+
self._working_space,
334+
illuminant=self._working_whitepoint,
335+
)
336+
337+
conversion_chain = [
338+
color_space for color_space, _group in groupby(conversion_chain)
339+
]
340+
341+
if len(conversion_chain) == 1:
342+
conversion_chain = []
343+
344+
self._conversion_chain_label.setText(" → ".join(conversion_chain))
345+
346+
self._visuals["rgb_scatter_3d"].RGB = image_array
347+
348+
self._wgpu_viewer.render()
349+
350+
351+
def color_space_to_RGB_Colourspace(
352+
color_space: str,
353+
) -> colour.RGB_Colourspace | None:
354+
config = ocio.GetCurrentConfig()
355+
if (not config.hasRole(ocio.ROLE_INTERCHANGE_DISPLAY)) or (
356+
color_space not in ConfigCache.get_color_space_names()
357+
):
358+
return None
359+
360+
colorspace_transform = ocio.ColorSpaceTransform(
361+
src=color_space,
362+
dst=ocio.ROLE_INTERCHANGE_DISPLAY,
363+
)
364+
processor = config.getProcessor(
365+
colorspace_transform, ocio.TRANSFORM_DIR_FORWARD
366+
).getDefaultCPUProcessor()
367+
368+
XYZ = np.identity(3, dtype=np.float32)
369+
processor.applyRGB(XYZ)
370+
371+
XYZ_w = np.ones(3, dtype=np.float32)
372+
processor.applyRGB(XYZ_w)
373+
374+
return colour.RGB_Colourspace(
375+
color_space,
376+
colour.XYZ_to_xy(XYZ),
377+
colour.XYZ_to_xy(XYZ_w),
378+
f"{color_space}",
379+
use_derived_matrix_RGB_to_XYZ=True,
380+
use_derived_matrix_XYZ_to_RGB=True,
381+
)
382+
383+
384+
if __name__ == "__main__":
385+
application = QtWidgets.QApplication([])
386+
chromaticity_inspector = ChromaticitiesInspector()
387+
chromaticity_inspector.resize(800, 600)
388+
chromaticity_inspector.show()
389+
390+
application.exec()

0 commit comments

Comments
 (0)