From bbce54421ea38320cc4c667089dc7b4a7837d21b Mon Sep 17 00:00:00 2001 From: Thomas Mansencal Date: Sun, 26 Nov 2023 16:49:46 +1300 Subject: [PATCH] Implement initial chromaticity inspector. --- .../ocioview/ocioview/inspect/__init__.py | 1 + .../inspect/chromaticities_inspector.py | 473 ++++++++++++++++++ src/apps/ocioview/ocioview/inspect_dock.py | 9 +- src/apps/ocioview/ocioview/message_router.py | 2 +- .../ocioview/ocioview/processor_context.py | 8 +- src/apps/ocioview/ocioview/utils.py | 39 +- src/apps/ocioview/ocioview/viewer/__init__.py | 1 + .../ocioview/ocioview/viewer/image_plane.py | 12 +- .../ocioview/viewer/offscreen_viewer.py | 160 ++++++ .../ocioview/ocioview/widgets/__init__.py | 1 + .../ocioview/ocioview/widgets/separator.py | 23 + 11 files changed, 715 insertions(+), 14 deletions(-) create mode 100644 src/apps/ocioview/ocioview/inspect/chromaticities_inspector.py create mode 100644 src/apps/ocioview/ocioview/viewer/offscreen_viewer.py create mode 100644 src/apps/ocioview/ocioview/widgets/separator.py diff --git a/src/apps/ocioview/ocioview/inspect/__init__.py b/src/apps/ocioview/ocioview/inspect/__init__.py index 1d32c405e..6e021885b 100644 --- a/src/apps/ocioview/ocioview/inspect/__init__.py +++ b/src/apps/ocioview/ocioview/inspect/__init__.py @@ -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 diff --git a/src/apps/ocioview/ocioview/inspect/chromaticities_inspector.py b/src/apps/ocioview/ocioview/inspect/chromaticities_inspector.py new file mode 100644 index 000000000..5d146c2c1 --- /dev/null +++ b/src/apps/ocioview/ocioview/inspect/chromaticities_inspector.py @@ -0,0 +1,473 @@ +# 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 ( + VisualChromaticityDiagram, + VisualGrid, + VisualRGBColourspace2D, + VisualRGBScatter3D, + VisualSpectralLocus2D, +) +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 +from ..widgets import VerticalSeparator + + +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) + colour.plotting.CONSTANTS_COLOUR_STYLE.font.size = 20 + + 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._method_combobox = QtWidgets.QComboBox() + self._method_combobox.addItems( + ["CIE 1931", "CIE 1960 UCS", "CIE 1976 UCS"] + ) + + self._draw_input_color_space_label = get_glyph_icon( + "mdi.import", as_widget=True + ) + self._draw_input_color_space_label.setToolTip("Draw Input Color Space") + self._draw_input_color_space_checkbox = QtWidgets.QCheckBox() + self._draw_input_color_space_checkbox.setChecked(True) + self._draw_input_color_space_checkbox.setToolTip( + "Draw Input Color Space" + ) + + self._draw_chromaticities_color_space_label = get_glyph_icon( + "mdi.set-none", as_widget=True + ) + self._draw_chromaticities_color_space_label.setToolTip( + "Draw Chromaticities Color Space" + ) + self._draw_chromaticities_color_space_checkbox = QtWidgets.QCheckBox() + self._draw_chromaticities_color_space_checkbox.setChecked(True) + self._draw_chromaticities_color_space_checkbox.setToolTip( + "Draw Chromaticities Color Space" + ) + + self._use_orthographic_projection_label = get_glyph_icon( + "mdi.camera-control", as_widget=True + ) + self._use_orthographic_projection_label.setToolTip( + "Orthographic Projection" + ) + self._use_orthographic_projection_checkbox = QtWidgets.QCheckBox() + self._use_orthographic_projection_checkbox.setChecked(True) + self._use_orthographic_projection_checkbox.setToolTip( + "Orthographic Projection" + ) + + self._reset_camera_pushbutton = QtWidgets.QPushButton() + self._reset_camera_pushbutton.setIcon(get_glyph_icon("mdi.restart")) + self._reset_camera_pushbutton.setToolTip("Reset Camera") + + # Layout + vbox_layout = QtWidgets.QVBoxLayout() + vbox_layout.addWidget(self._conversion_chain_label) + spacer = QtWidgets.QSpacerItem( + 20, + 20, + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding, + ) + vbox_layout.addItem(spacer) + self._wgpu_viewer.setLayout(vbox_layout) + + hbox_layout = QtWidgets.QHBoxLayout() + hbox_layout.addWidget(self._chromaticities_color_spaces_label) + hbox_layout.addWidget(self._chromaticities_color_spaces_combobox) + hbox_layout.setStretch(1, 1) + + hbox_layout.addWidget(self._method_combobox) + + hbox_layout.addWidget(self._draw_input_color_space_label) + hbox_layout.addWidget(self._draw_input_color_space_checkbox) + + hbox_layout.addWidget(VerticalSeparator()) + hbox_layout.addWidget(self._draw_chromaticities_color_space_label) + hbox_layout.addWidget(self._draw_chromaticities_color_space_checkbox) + + hbox_layout.addWidget(VerticalSeparator()) + hbox_layout.addWidget(self._use_orthographic_projection_label) + hbox_layout.addWidget(self._use_orthographic_projection_checkbox) + + hbox_layout.addWidget(VerticalSeparator()) + hbox_layout.addWidget(self._reset_camera_pushbutton) + + 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() + + # Signals / Slots + self._chromaticities_color_spaces_combobox.textActivated.connect( + self._update_visuals + ) + self._method_combobox.textActivated.connect(self._update_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._on_draw_chromaticities_color_space_checkbox_checkstatechanged + ) + self._use_orthographic_projection_checkbox.checkStateChanged.connect( + self._on_use_orthographic_projection_checkbox_checkstatechanged + ) + self._reset_camera_pushbutton.clicked.connect(self._reset_camera) + + @property + def wgpu_viewer(self): + return self._wgpu_viewer + + def reset(self): ... + + 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 _on_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 _on_use_orthographic_projection_checkbox_checkstatechanged( + self, state + ): + use_orthographic_projection = state == QtGui.Qt.CheckState.Checked + + self._wgpu_viewer.wgpu_camera.fov = ( + 0 if use_orthographic_projection else 50 + ) + + self._wgpu_viewer.render() + + def _setup_visuals(self): + self._wgpu_viewer.wgpu_scene.add( + gfx.Background(None, gfx.BackgroundMaterial(np.array([0, 0, 0]))) + ) + + self._visuals = { + "grid": VisualGrid(size=4), + "spectral_locus": VisualSpectralLocus2D(), + "chromaticity_diagram": VisualChromaticityDiagram(opacity=0.25), + "rgb_color_space_input": VisualRGBColourspace2D( + self._working_space, + colour=np.array([1, 0.5, 0.25]), + thickness=2, + ), + "rgb_color_space_chromaticities": VisualRGBColourspace2D( + self._working_space, + colour=np.array([0.25, 1, 0.5]), + thickness=2, + ), + "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_input"].local.position = np.array( + [0, 0, 0.000025] + ) + self._visuals["rgb_color_space_chromaticities"].visible = False + self._visuals["rgb_color_space_chromaticities"].local.position = ( + np.array([0, 0, 0.00005]) + ) + self._visuals["rgb_scatter_3d"].visible = False + + self._wgpu_viewer.wgpu_scene.add(self._root) + + self._reset_camera() + + 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._visuals["rgb_scatter_3d"].visible = True + + 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, + ] + + rgb_colourspace = color_space_to_RGB_Colourspace( + self._context.input_color_space + ) + + if rgb_colourspace is not None: + self._visuals["rgb_color_space_input"].colourspace = ( + rgb_colourspace + ) + + 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" + ), + ] + + rgb_colourspace = color_space_to_RGB_Colourspace( + chromaticities_colorspace + ) + + if rgb_colourspace is not None: + self._visuals["rgb_color_space_chromaticities"].colourspace = ( + rgb_colourspace + ) + + 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 + + method = self._method_combobox.currentText() + self._visuals["spectral_locus"].method = method + self._visuals["chromaticity_diagram"].method = method + self._visuals["rgb_color_space_input"].method = method + self._visuals["rgb_color_space_chromaticities"].method = method + + self._wgpu_viewer.render() + + def _reset_camera(self) -> None: + self._wgpu_viewer.wgpu_camera.fov = 0 + self._wgpu_viewer.wgpu_camera.local.position = np.array([0.5, 0.5, 2]) + self._wgpu_viewer.wgpu_camera.show_pos(np.array([0.5, 0.5, 0.5])) + + self._wgpu_viewer.render() + + +def color_space_to_RGB_Colourspace( + color_space: str, +) -> colour.RGB_Colourspace | None: + config = ocio.GetCurrentConfig() + if (not config.hasRole(ocio.ROLE_INTERCHANGE_DISPLAY)) or ( + color_space not in ConfigCache.get_color_space_names() + ): + return None + + colorspace_transform = ocio.ColorSpaceTransform( + src=color_space, + dst=ocio.ROLE_INTERCHANGE_DISPLAY, + ) + processor = config.getProcessor( + colorspace_transform, ocio.TRANSFORM_DIR_FORWARD + ).getDefaultCPUProcessor() + + XYZ = np.identity(3, dtype=np.float32) + processor.applyRGB(XYZ) + + XYZ_w = np.ones(3, dtype=np.float32) + processor.applyRGB(XYZ_w) + + return colour.RGB_Colourspace( + color_space, + colour.XYZ_to_xy(XYZ), + colour.XYZ_to_xy(XYZ_w), + f"{color_space}", + use_derived_matrix_RGB_to_XYZ=True, + use_derived_matrix_XYZ_to_RGB=True, + ) + + +if __name__ == "__main__": + application = QtWidgets.QApplication([]) + chromaticity_inspector = ChromaticitiesInspector() + chromaticity_inspector.resize(800, 600) + chromaticity_inspector.show() + + application.exec() diff --git a/src/apps/ocioview/ocioview/inspect_dock.py b/src/apps/ocioview/ocioview/inspect_dock.py index 084ac84b1..c9ef01a6c 100644 --- a/src/apps/ocioview/ocioview/inspect_dock.py +++ b/src/apps/ocioview/ocioview/inspect_dock.py @@ -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 @@ -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(), @@ -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() diff --git a/src/apps/ocioview/ocioview/message_router.py b/src/apps/ocioview/ocioview/message_router.py index efb2c9677..a18da9126 100644 --- a/src/apps/ocioview/ocioview/message_router.py +++ b/src/apps/ocioview/ocioview/message_router.py @@ -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 diff --git a/src/apps/ocioview/ocioview/processor_context.py b/src/apps/ocioview/ocioview/processor_context.py index 804a64954..832d63858 100644 --- a/src/apps/ocioview/ocioview/processor_context.py +++ b/src/apps/ocioview/ocioview/processor_context.py @@ -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 @@ -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 diff --git a/src/apps/ocioview/ocioview/utils.py b/src/apps/ocioview/ocioview/utils.py index 274686ce4..d2c3f03ff 100644 --- a/src/apps/ocioview/ocioview/utils.py +++ b/src/apps/ocioview/ocioview/utils.py @@ -8,6 +8,7 @@ from typing import Optional, Union import PyOpenColorIO as ocio +import numpy as np import qtawesome from pygments import highlight from pygments.lexers import GLShaderLexer, HLSLShaderLexer, XmlLexer, YamlLexer @@ -135,7 +136,9 @@ def item_type_label(item_type: type) -> str: :param item_type: Config item type :return: Friendly type name """ - return " ".join(filter(None, re.split(r"([A-Z]+[a-z]+)", item_type.__name__))) + return " ".join( + filter(None, re.split(r"([A-Z]+[a-z]+)", item_type.__name__)) + ) def m44_to_m33(m44: list) -> list: @@ -178,7 +181,9 @@ def config_to_html(config: ocio.Config) -> str: ) -def processor_to_ctf_html(processor: ocio.Processor) -> tuple[str, ocio.GroupTransform]: +def processor_to_ctf_html( + processor: ocio.Processor, +) -> tuple[str, ocio.GroupTransform]: """Return processor CTF formatted as HTML.""" config = ocio.GetCurrentConfig() group_tf = processor.createGroupTransform() @@ -220,14 +225,20 @@ def processor_to_shader_html( Return processor shader in the requested language, formatted as HTML. """ - gpu_shader_desc = ocio.GpuShaderDesc.CreateShaderDesc(language=gpu_language) + gpu_shader_desc = ocio.GpuShaderDesc.CreateShaderDesc( + language=gpu_language + ) gpu_proc.extractGpuShaderInfo(gpu_shader_desc) shader_data = gpu_shader_desc.getShaderText() return increase_html_lineno_padding( highlight( shader_data, - (GLShaderLexer if "GLSL" in gpu_language.name else HLSLShaderLexer)(), + ( + GLShaderLexer + if "GLSL" in gpu_language.name + else HLSLShaderLexer + )(), HtmlFormatter(linenos="inline"), ) ) @@ -254,3 +265,23 @@ def float_to_uint8(value: float) -> int: :return: Integer value """ return max(0, min(255, int(value * 255))) + + +def subsampling_factor(a: np.ndarray, maximum_size: float) -> int: + """ + Return the best factor to sub-sample given :math:`a` array and have its + size less or equal to given maximum size. + + :param a: Array :math:`a` to find the best sub-sample factor. + :param maximum_size: Maximum size of the sub-sampled array :math:`a`. + :return: Sub-sampling factor. + """ + + size = a.size + + sub_sampling_factor = 1 + while True: + if size / sub_sampling_factor <= maximum_size: + return sub_sampling_factor + + sub_sampling_factor += 1 diff --git a/src/apps/ocioview/ocioview/viewer/__init__.py b/src/apps/ocioview/ocioview/viewer/__init__.py index fe3f9a82e..778446907 100644 --- a/src/apps/ocioview/ocioview/viewer/__init__.py +++ b/src/apps/ocioview/ocioview/viewer/__init__.py @@ -2,4 +2,5 @@ # Copyright Contributors to the OpenColorIO Project. from .image_viewer import ViewerChannels, ImageViewer +from .offscreen_viewer import WgpuCanvasOffScreenViewer from .utils import load_image diff --git a/src/apps/ocioview/ocioview/viewer/image_plane.py b/src/apps/ocioview/ocioview/viewer/image_plane.py index 7ceb11b5a..aa128bf0f 100644 --- a/src/apps/ocioview/ocioview/viewer/image_plane.py +++ b/src/apps/ocioview/ocioview/viewer/image_plane.py @@ -118,7 +118,7 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): self._ocio_exposure = 0.0 self._ocio_gamma = 1.0 self._ocio_channel_hot = [1, 1, 1, 1] - self._ocio_proc_context = ProcessorContext(None, None, None) + self._ocio_proc_context = ProcessorContext() self._ocio_proc = None self._ocio_proc_cpu = None self._ocio_proc_cache_id = None @@ -352,10 +352,10 @@ def load_image(self, image_path: Path) -> None: color_space_name, self._ocio_proc_context.transform_item_type, self._ocio_proc_context.transform_item_name, - self._ocio_proc_context.direction, + self._ocio_proc_context.transform_direction, ) else: - proc_context = ProcessorContext(color_space_name, None, None) + proc_context = ProcessorContext(color_space_name) # Load image data via an available image library self._image_array = load_image(image_path) @@ -420,9 +420,12 @@ def clear_transform(self) -> None: """ Clear current OCIO transform, passing through the input image. """ + self._ocio_tf = None - self.update_ocio_proc(force_update=True) + self.update_ocio_proc( + ProcessorContext(self._ocio_proc_context.input_color_space), + force_update=True) def reset_ocio_proc(self, update: bool = False) -> None: """ @@ -460,6 +463,7 @@ def update_ocio_proc( :param force_update: Set to True to update the viewport even when the processor has not been updated. """ + # Update processor parameters if proc_context is not None: self._ocio_proc_context = proc_context diff --git a/src/apps/ocioview/ocioview/viewer/offscreen_viewer.py b/src/apps/ocioview/ocioview/viewer/offscreen_viewer.py new file mode 100644 index 000000000..ca785ab7a --- /dev/null +++ b/src/apps/ocioview/ocioview/viewer/offscreen_viewer.py @@ -0,0 +1,160 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright Contributors to the OpenColorIO Project. + +import numpy as np +import pygfx as gfx +from PySide6 import QtCore, QtGui, QtWidgets +from wgpu.gui.offscreen import WgpuCanvas +from wgpu.gui.qt import BUTTON_MAP, MODIFIERS_MAP + + +class WgpuCanvasOffScreenViewer(QtWidgets.QGraphicsView): + def __init__(self): + super().__init__() + + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + + # WebGPU + self._wgpu_canvas = WgpuCanvas(size=self._viewport_size) + self._wgpu_renderer = gfx.renderers.WgpuRenderer(self._wgpu_canvas) + self._wgpu_camera = gfx.PerspectiveCamera(50, 16 / 9) + self._wgpu_controller = gfx.OrbitController(self._wgpu_camera) + self._wgpu_controller.register_events(self._wgpu_renderer) + + self._wgpu_scene = gfx.Scene() + + self._wgpu_canvas.request_draw( + lambda: self._wgpu_renderer.render( + self._wgpu_scene, self._wgpu_camera + ) + ) + + self._wgpu_camera.local.position = np.array([0.5, 0.5, 2]) + self._wgpu_camera.show_pos(np.array([0.5, 0.5, 0.5])) + + # QGraphicsView + self.setScene(QtWidgets.QGraphicsScene(self)) + self.setTransformationAnchor(self.ViewportAnchor.AnchorUnderMouse) + self.image_plane = QtWidgets.QGraphicsPixmapItem( + self._render_to_pixmap() + ) + self.scene().addItem(self.image_plane) + self.scale(0.5, 0.5) + + @property + def wgpu_canvas(self): + return self._wgpu_canvas + + @property + def wgpu_renderer(self): + return self._wgpu_renderer + + @property + def wgpu_camera(self): + return self._wgpu_camera + + @property + def wgpu_controller(self): + return self._wgpu_controller + + @property + def wgpu_scene(self): + return self._wgpu_scene + + @property + def _viewport_size(self): + return ( + self.viewport().size().width() * 2, + self.viewport().size().height() * 2, + ) + + def resizeEvent(self, event: QtGui.QResizeEvent) -> None: + super().resizeEvent(event) + + self._wgpu_canvas.set_logical_size(*self._viewport_size) + + self.render() + + def _mouse_event(self, event_type, event, touches=True): + button = BUTTON_MAP.get(event.button(), 0) + buttons = [ + BUTTON_MAP[button] + for button in BUTTON_MAP.keys() + if button & event.buttons() + ] + + modifiers = [ + MODIFIERS_MAP[mod] + for mod in MODIFIERS_MAP.keys() + if mod & event.modifiers() + ] + + wgpu_event = { + "event_type": event_type, + "x": event.pos().x(), + "y": event.pos().y(), + "button": button, + "buttons": buttons, + "modifiers": modifiers, + } + if touches: + wgpu_event.update( + { + "ntouches": 0, + "touches": {}, + } + ) + + self._wgpu_canvas.handle_event(wgpu_event) + + self.render() + + def mousePressEvent(self, event): + self._mouse_event("pointer_down", event) + + def mouseMoveEvent(self, event): + self._mouse_event("pointer_move", event) + + def mouseReleaseEvent(self, event): + self._mouse_event("pointer_up", event) + + def mouseDoubleClickEvent(self, event): + self._mouse_event("double_click", event, touches=False) + + def wheelEvent(self, event): + modifiers = [ + MODIFIERS_MAP[mod] + for mod in MODIFIERS_MAP.keys() + if mod & event.modifiers() + ] + + wgpu_event = { + "event_type": "wheel", + "dx": -event.angleDelta().x(), + "dy": -event.angleDelta().y(), + "x": event.position().x(), + "y": event.position().y(), + "modifiers": modifiers, + } + + self._wgpu_canvas.handle_event(wgpu_event) + + self.render() + + def render(self): + self.image_plane.setPixmap(self._render_to_pixmap()) + + def _render_to_pixmap(self): + render = np.array(self._wgpu_renderer.target.draw())[..., :3] + + height, width, _channel = render.shape + return QtGui.QPixmap.fromImage( + QtGui.QImage( + np.ascontiguousarray(render), + width, + height, + 3 * width, + QtGui.QImage.Format_RGB888, + ) + ) diff --git a/src/apps/ocioview/ocioview/widgets/__init__.py b/src/apps/ocioview/ocioview/widgets/__init__.py index 0ce176671..76eba2d6d 100644 --- a/src/apps/ocioview/ocioview/widgets/__init__.py +++ b/src/apps/ocioview/ocioview/widgets/__init__.py @@ -14,6 +14,7 @@ ) from .list_widget import StringListWidget, ItemModelListWidget from .log_view import LogView +from .separator import VerticalSeparator from .structure import TabbedDockWidget, ExpandingStackedWidget from .table_widget import StringMapTableWidget, ItemModelTableWidget from .text_edit import TextEdit, HtmlView diff --git a/src/apps/ocioview/ocioview/widgets/separator.py b/src/apps/ocioview/ocioview/widgets/separator.py new file mode 100644 index 000000000..d557aefc4 --- /dev/null +++ b/src/apps/ocioview/ocioview/widgets/separator.py @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright Contributors to the OpenColorIO Project. + +from typing import Optional + +from PySide6 import QtCore, QtWidgets + +class VerticalSeparator(QtWidgets.QFrame): + COLOR = "#353535" + MAX_HEIGHT = 15 + MAX_WIDTH = 1 + + def __init__(self, parent: Optional[QtCore.QObject] = None): + super().__init__(parent=parent) + + self.setFrameShape(QtWidgets.QFrame.VLine) + self.setStyleSheet( + '.QFrame[frameShape="4"], QFrame[frameShape="5"] ' + f"{{ border: none; " + f"background: {self.COLOR}; " + f"max-height: {self.MAX_HEIGHT}px; " + f"max-width: {self.MAX_WIDTH}px; }}" + )