Skip to content

Commit

Permalink
Implement initial chromaticity inspector.
Browse files Browse the repository at this point in the history
  • Loading branch information
KelSolaar committed May 12, 2024
1 parent 5e9182c commit 750b9f8
Show file tree
Hide file tree
Showing 9 changed files with 534 additions and 14 deletions.
1 change: 1 addition & 0 deletions src/apps/ocioview/ocioview/inspect/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright Contributors to the OpenColorIO Project.

from .chromaticities_inspector import ChromaticitiesInspector
from .code_inspector import CodeInspector
from .curve_inspector import CurveInspector
from .log_inspector import LogInspector
316 changes: 316 additions & 0 deletions src/apps/ocioview/ocioview/inspect/chromaticities_inspector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright Contributors to the OpenColorIO Project.

from itertools import groupby
from typing import Optional

import colour
import pygfx as gfx
import numpy as np
import PyOpenColorIO as ocio
from colour_visuals import (
VisualChromaticityDiagramCIE1931,
VisualGrid,
VisualRGBColourspace2D,
VisualRGBScatter3D,
)
from colour import CCS_ILLUMINANTS, XYZ_to_RGB
from colour.utilities import set_default_float_dtype
from PySide6 import QtCore, QtGui, QtWidgets

from ..config_cache import ConfigCache
from ..message_router import MessageRouter
from ..processor_context import ProcessorContext
from ..utils import get_glyph_icon, subsampling_factor
from ..viewer import WgpuCanvasOffScreenViewer


class ChromaticitiesInspector(QtWidgets.QWidget):
@classmethod
def label(cls) -> str:
return "Chromaticities"

@classmethod
def icon(cls) -> QtGui.QIcon:
return get_glyph_icon("mdi6.grain")

def __init__(self, parent: Optional[QtCore.QObject] = None):
super().__init__(parent=parent)

# Setting "Colour" float processing precision to Float32.
set_default_float_dtype(np.float32)

self._context = None
self._processor = None
self._image_array = np.atleast_3d([0, 0, 0]).astype(np.float32)

# Chromaticity Diagram Working Space
self._working_whitepoint = CCS_ILLUMINANTS[
"CIE 1931 2 Degree Standard Observer"
]["D65"]
working_space = colour.RGB_Colourspace(
"CIE-XYZ-D65",
colour.XYZ_to_xy(np.identity(3)),
self._working_whitepoint,
"D65",
use_derived_matrix_RGB_to_XYZ=True,
use_derived_matrix_XYZ_to_RGB=True,
)
colour.RGB_COLOURSPACES[working_space.name] = working_space
self._working_space = working_space.name

self._root = None
self._visuals = {}

# Widgets
self._wgpu_viewer = WgpuCanvasOffScreenViewer()
self._conversion_chain_label = QtWidgets.QLabel()
self._conversion_chain_label.setStyleSheet(
".QLabel { font-size: 10pt;qproperty-alignment: AlignCenter;}"
)

self._chromaticities_color_spaces_label = QtWidgets.QLabel(
"Chromaticities Color Space"
)
self._chromaticities_color_spaces_combobox = QtWidgets.QComboBox()

self._draw_input_color_space_checkbox = QtWidgets.QCheckBox()
self._draw_input_color_space_checkbox.setChecked(True)
self._draw_chromaticities_color_space_checkbox = QtWidgets.QCheckBox()
self._draw_chromaticities_color_space_checkbox.setChecked(True)

# Layout
wgpu_layout = QtWidgets.QVBoxLayout()
spacer = QtWidgets.QSpacerItem(
20,
20,
QtWidgets.QSizePolicy.Expanding,
QtWidgets.QSizePolicy.Expanding,
)
wgpu_layout.addItem(spacer)
wgpu_layout.addWidget(self._conversion_chain_label)
self._wgpu_viewer.setLayout(wgpu_layout)

hbox_layout = QtWidgets.QHBoxLayout()
hbox_layout.addWidget(self._chromaticities_color_spaces_label)
hbox_layout.addWidget(self._chromaticities_color_spaces_combobox)

hbox_layout.addWidget(self._draw_input_color_space_checkbox)
hbox_layout.addWidget(self._draw_chromaticities_color_space_checkbox)

vbox_layout = QtWidgets.QVBoxLayout()
vbox_layout.addLayout(hbox_layout)
vbox_layout.addWidget(self._wgpu_viewer)
self.setLayout(vbox_layout)

msg_router = MessageRouter.get_instance()
msg_router.config_html_ready.connect(self._on_config_html_ready)
msg_router.processor_ready.connect(self._on_processor_ready)
msg_router.image_ready.connect(self._on_image_ready)

self._setup_visuals()

self._draw_input_color_space_checkbox.checkStateChanged.connect(
self._on_draw_input_color_space_checkbox_checkstatechanged
)
self._draw_chromaticities_color_space_checkbox.checkStateChanged.connect(
self._draw_chromaticities_color_space_checkbox_checkstatechanged
)

@property
def wgpu_viewer(self):
return self._wgpu_viewer

def _on_draw_input_color_space_checkbox_checkstatechanged(self, state):
self._visuals[
"rgb_color_space_input"].visible = state == QtGui.Qt.CheckState.Checked

self._wgpu_viewer.render()

def _draw_chromaticities_color_space_checkbox_checkstatechanged(self,
state):
self._visuals[
"rgb_color_space_chromaticities"].visible = state == QtGui.Qt.CheckState.Checked

self._wgpu_viewer.render()

def _setup_visuals(self):
self._wgpu_viewer.wgpu_scene.add(
gfx.Background(
None, gfx.BackgroundMaterial(np.array([0.18, 0.18, 0.18]))
)
)

self._visuals = {
"grid": VisualGrid(size=2),
"chromaticity_diagram": VisualChromaticityDiagramCIE1931(
kwargs_visual_chromaticity_diagram={"opacity": 0.25}
),
"rgb_color_space_input": VisualRGBColourspace2D(
self._working_space,
),
"rgb_color_space_chromaticities": VisualRGBColourspace2D(
self._working_space,
),
"rgb_scatter_3d": VisualRGBScatter3D(
np.zeros(3), self._working_space, size=4
),
}

self._root = gfx.Group()
for visual in self._visuals.values():
self._root.add(visual)
self._visuals[
"rgb_color_space_input"].visible = False
self._visuals[
"rgb_color_space_chromaticities"].visible = False
self._wgpu_viewer.wgpu_scene.add(self._root)

def reset(self) -> None:
pass

def showEvent(self, event: QtGui.QShowEvent) -> None:
"""Start listening for processor updates, if visible."""
super().showEvent(event)

msg_router = MessageRouter.get_instance()
# NOTE: We need to be able to receive notifications about config changes
# and this is currently the only way to do that without connecting
# to the `ConfigDock.config_changed` signal.
msg_router.config_updates_allowed = True
msg_router.processor_updates_allowed = True
msg_router.image_updates_allowed = True

def hideEvent(self, event: QtGui.QHideEvent) -> None:
"""Stop listening for processor updates, if not visible."""
super().hideEvent(event)

msg_router = MessageRouter.get_instance()
msg_router.config_updates_allowed = False
msg_router.processor_updates_allowed = False
msg_router.image_updates_allowed = False

@QtCore.Slot(str)
def _on_config_html_ready(self, record: str) -> None:
color_space_names = ConfigCache.get_color_space_names()

items = [
self._chromaticities_color_spaces_combobox.itemText(i)
for i in range(self._chromaticities_color_spaces_combobox.count())
]

if items != color_space_names:
self._chromaticities_color_spaces_combobox.clear()
self._chromaticities_color_spaces_combobox.addItems(
color_space_names
)

config = ocio.GetCurrentConfig()
has_role_interchange_display = config.hasRole(
ocio.ROLE_INTERCHANGE_DISPLAY
)
self._chromaticities_color_spaces_combobox.setEnabled(
has_role_interchange_display
)

self._draw_input_color_space_checkbox.setEnabled(
has_role_interchange_display
)
self._visuals[
"rgb_color_space_input"].visible = has_role_interchange_display
self._draw_chromaticities_color_space_checkbox.setEnabled(
has_role_interchange_display
)
self._visuals[
"rgb_color_space_chromaticities"].visible = has_role_interchange_display

@QtCore.Slot(ocio.CPUProcessor)
def _on_processor_ready(
self, proc_context: ProcessorContext, cpu_proc: ocio.CPUProcessor
) -> None:
self._context = proc_context
self._processor = cpu_proc

self._update_visuals()

@QtCore.Slot(np.ndarray)
def _on_image_ready(self, image_array: np.ndarray) -> None:
sub_sampling_factor = int(
np.sqrt(subsampling_factor(image_array, 1e6))
)
self._image_array = image_array[
::sub_sampling_factor, ::sub_sampling_factor
]

self._update_visuals()

def _update_visuals(self, *args):
conversion_chain = []

image_array = np.copy(self._image_array)

# 1. Apply current active processor
if self._processor is not None:
if self._context.transform_item_name is not None:
conversion_chain += [
self._context.input_color_space,
self._context.transform_item_name,
]
self._processor.applyRGB(image_array)

# 2. Convert from chromaticities input space to "CIE-XYZ-D65" interchange
config = ocio.GetCurrentConfig()
input_color_space = (
self._chromaticities_color_spaces_combobox.currentText()
)
if (
config.hasRole(ocio.ROLE_INTERCHANGE_DISPLAY)
and input_color_space in ConfigCache.get_color_space_names()
):
chromaticities_colorspace = (
self._chromaticities_color_spaces_combobox.currentText()
)
conversion_chain += [
chromaticities_colorspace,
ocio.ROLE_INTERCHANGE_DISPLAY.replace(
"cie_xyz_d65_interchange", "CIE-XYZ-D65"
),
]
colorspace_transform = ocio.ColorSpaceTransform(
src=chromaticities_colorspace,
dst=ocio.ROLE_INTERCHANGE_DISPLAY,
)
processor = config.getProcessor(
colorspace_transform, ocio.TRANSFORM_DIR_FORWARD
).getDefaultCPUProcessor()
processor.applyRGB(image_array)

# 3. Convert from "CIE-XYZ-D65" to "VisualRGBScatter3D" working space
conversion_chain += ["CIE-XYZ-D65", self._working_space]
image_array = XYZ_to_RGB(
image_array,
self._working_space,
illuminant=self._working_whitepoint,
)

conversion_chain = [
color_space for color_space, _group in groupby(conversion_chain)
]

if len(conversion_chain) == 1:
conversion_chain = []

self._conversion_chain_label.setText(" → ".join(conversion_chain))

self._visuals["rgb_scatter_3d"].RGB = image_array

self._wgpu_viewer.render()


if __name__ == "__main__":
application = QtWidgets.QApplication([])
chromaticity_inspector = ChromaticitiesInspector()
chromaticity_inspector.resize(800, 600)
chromaticity_inspector.show()

application.exec()
9 changes: 8 additions & 1 deletion src/apps/ocioview/ocioview/inspect_dock.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from PySide6 import QtCore, QtWidgets

from .inspect import CodeInspector, CurveInspector, LogInspector
from .inspect import ChromaticitiesInspector, CodeInspector, CurveInspector, LogInspector
from .utils import get_glyph_icon
from .widgets.structure import TabbedDockWidget

Expand All @@ -25,11 +25,17 @@ def __init__(self, parent: Optional[QtCore.QObject] = None):
self.tabs.setTabPosition(QtWidgets.QTabWidget.West)

# Widgets
self.chromaticities_inspector = ChromaticitiesInspector()
self.curve_inspector = CurveInspector()
self.code_inspector = CodeInspector()
self.log_inspector = LogInspector()

# Layout
self.add_tab(
self.chromaticities_inspector,
self.chromaticities_inspector.label(),
self.chromaticities_inspector.icon(),
)
self.add_tab(
self.curve_inspector,
self.curve_inspector.label(),
Expand All @@ -48,6 +54,7 @@ def __init__(self, parent: Optional[QtCore.QObject] = None):

def reset(self) -> None:
"""Reset data for all inspectors."""
self.chromaticities_inspector.reset()
self.curve_inspector.reset()
self.code_inspector.reset()
self.log_inspector.reset()
2 changes: 1 addition & 1 deletion src/apps/ocioview/ocioview/message_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ def start_routing(self) -> None:
elif (
isinstance(msg_raw, tuple)
and len(msg_raw) == 2
and isinstance(msg_raw[0], ProcessorContext)
and isinstance(msg_raw[0], (str, ProcessorContext))
and isinstance(msg_raw[1], ocio.Processor)
):
self._prev_proc_data = msg_raw
Expand Down
8 changes: 4 additions & 4 deletions src/apps/ocioview/ocioview/processor_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from __future__ import annotations

from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Type

import PyOpenColorIO as ocio
Expand All @@ -15,13 +15,13 @@ class ProcessorContext:
Data about current config items that constructed a processor.
"""

input_color_space: str | None
input_color_space: str | None = field(default_factory=lambda: None)
"""Input color space name."""

transform_item_type: Type | None
transform_item_type: Type | None = field(default_factory=lambda: None)
"""Transform source config item type."""

transform_item_name: str | None
transform_item_name: str | None = field(default_factory=lambda: None)
"""Transform source config item name."""

transform_direction: ocio.TransformDirection = ocio.TRANSFORM_DIR_FORWARD
Expand Down
Loading

0 comments on commit 750b9f8

Please sign in to comment.