From 55cfe592474054cc04d5511c3f313a0a6c75f7d4 Mon Sep 17 00:00:00 2001 From: Thomas Mansencal Date: Sun, 9 Jun 2024 11:00:23 +1200 Subject: [PATCH] PR: ocioview - Chromaticities Inspector (#1914) * Add initial `pyproject.toml` file to setup *ocioview* development environment. Signed-off-by: Thomas Mansencal * Implement initial chromaticity inspector. Signed-off-by: Thomas Mansencal * Rename `ocioview.ocioview.utils.color_space_to_RGB_Colourspace` definition to `color_space_to_rgb_colourspace`. Signed-off-by: Thomas Mansencal * Removed unneeded string formatting in `ocioview.ocioview.utils.py` module. Signed-off-by: Thomas Mansencal * Add `wgpu` to direct dependencies. Signed-off-by: Thomas Mansencal --------- Signed-off-by: Thomas Mansencal --- .gitignore | 2 + .../ocioview/ocioview/inspect/__init__.py | 1 + .../inspect/chromaticities_inspector.py | 595 ++++++++++++++++++ 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 | 62 ++ src/apps/ocioview/ocioview/viewer/__init__.py | 1 + .../ocioview/ocioview/viewer/image_plane.py | 12 +- .../ocioview/viewer/offscreen_viewer.py | 160 +++++ src/apps/ocioview/pyproject.toml | 176 ++++++ src/apps/ocioview/requirements.txt | 13 +- 12 files changed, 1027 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/pyproject.toml diff --git a/.gitignore b/.gitignore index 166dfd9e19..53ca1bc274 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ mastercopy *.pyc !*.yml .vscode + +src/apps/ocioview/poetry.lock diff --git a/src/apps/ocioview/ocioview/inspect/__init__.py b/src/apps/ocioview/ocioview/inspect/__init__.py index 1d32c405ea..6e021885bb 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 0000000000..188e166745 --- /dev/null +++ b/src/apps/ocioview/ocioview/inspect/chromaticities_inspector.py @@ -0,0 +1,595 @@ +# 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, + VisualRGBColourspace3D, + VisualRGBScatter3D, + VisualSpectralLocus2D, +) +from colour import CCS_ILLUMINANTS, RGB_Colourspace, XYZ_to_RGB +from PySide6 import QtCore, QtGui, QtWidgets + +from ..config_cache import ConfigCache +from ..constants import ICON_SIZE_TAB +from ..message_router import MessageRouter +from ..processor_context import ProcessorContext +from ..utils import ( + color_space_to_RGB_Colourspace, + get_glyph_icon, + subsampling_factor, +) +from ..viewer import WgpuCanvasOffScreenViewer + + +class ChromaticitiesInspector(QtWidgets.QWidget): + """ + Widget for inspecting chromaticities of the loaded image. + + The image processing from its input to chromaticities display is as follows: + + 1. The current active OCIO processor is applied to the image, converting + from the *input color space* to the *output transform color space*. + 2. The image is converted from the *chromaticities color space* to the + *CIE-XYZ-D65 interchange color space. This space must be defined in the + config. + 3. The resulting image is then converted from the *CIE-XYZ-D65 interchange + color space* to the internal :class:`VisualRGBScatter3D` class instance + working space. + + The *input and chromaticities color space* 2D and 3D gamuts are automatically + generated from the config by transforming to the *CIE-XYZ-D65 interchange + color space* and producing a :class:`colour.RGB_Colourspace` class instance. + """ + + MAXIMUM_IMAGE_SAMPLES_COUNT = 1e6 + """Maximum number of samples from the image to display.""" + COLOR_BACKGROUND = np.array([0.18, 0.18, 0.18]) + """Background color of the *WebGPU* viewer.""" + COLOR_RGB_COLORSPACE_INPUT = np.array([1, 0.5, 0.25]) + """Color associated with the input RGB colorspace.""" + COLOR_RGB_COLORSPACE_CHROMATICITIES = np.array([0.25, 1, 0.5]) + """Color associated with the chromaticities RGB colorspace.""" + + @classmethod + def label(cls) -> str: + return "Chromaticities" + + @classmethod + def icon(cls) -> QtGui.QIcon: + return get_glyph_icon("mdi6.grain", size=ICON_SIZE_TAB) + + def __init__(self, parent: Optional[QtCore.QObject] = None) -> None: + super().__init__(parent=parent) + + colour.utilities.filter_warnings(*[True] * 4) + 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 = 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 + + # 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._chromaticities_color_spaces_combobox.setToolTip( + "Chromaticities Color Space" + ) + + self._method_label = get_glyph_icon("mdi.grid", as_widget=True) + self._method_label.setToolTip("Method") + self._method_combobox = QtWidgets.QComboBox() + self._method_combobox.addItems( + ["CIE 1931", "CIE 1960 UCS", "CIE 1976 UCS"] + ) + self._method_combobox.setToolTip("Method") + + self._draw_input_color_space_pushbutton = QtWidgets.QPushButton() + self._draw_input_color_space_pushbutton.setIcon( + get_glyph_icon("mdi.import") + ) + self._draw_input_color_space_pushbutton.setCheckable(True) + self._draw_input_color_space_pushbutton.setToolTip( + "Draw Input Color Space" + ) + + self._draw_chromaticities_color_space_pushbutton = ( + QtWidgets.QPushButton() + ) + self._draw_chromaticities_color_space_pushbutton.setIcon( + get_glyph_icon("mdi.set-none") + ) + self._draw_chromaticities_color_space_pushbutton.setCheckable(True) + self._draw_chromaticities_color_space_pushbutton.setToolTip( + "Draw Chromaticities Color Space" + ) + + self._use_3d_visuals_pushbutton = QtWidgets.QPushButton() + self._use_3d_visuals_pushbutton.setIcon( + get_glyph_icon("mdi.cube-outline") + ) + self._use_3d_visuals_pushbutton.setCheckable(True) + self._use_3d_visuals_pushbutton.setToolTip("Use 3D Visuals") + + self._use_orthographic_projection_pushbutton = QtWidgets.QPushButton() + self._use_orthographic_projection_pushbutton.setIcon( + get_glyph_icon("mdi.camera-control") + ) + self._use_orthographic_projection_pushbutton.setCheckable(True) + self._use_orthographic_projection_pushbutton.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_label) + hbox_layout.addWidget(self._method_combobox) + + hbox_layout.addWidget(self._draw_input_color_space_pushbutton) + hbox_layout.addWidget(self._draw_chromaticities_color_space_pushbutton) + hbox_layout.addWidget(self._use_3d_visuals_pushbutton) + hbox_layout.addWidget(self._use_orthographic_projection_pushbutton) + 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) + + # Visuals + self._wgpu_viewer.wgpu_scene.add( + gfx.Background(None, gfx.BackgroundMaterial(self.COLOR_BACKGROUND)) + ) + + method = "CIE 1931" + self._visuals = { + "grid": VisualGrid(size=4), + "spectral_locus": VisualSpectralLocus2D( + method=method, + ), + "chromaticity_diagram": VisualChromaticityDiagram( + method=method, + opacity=0.25, + ), + "rgb_color_space_input_2d": VisualRGBColourspace2D( + self._working_space, + colour=self.COLOR_RGB_COLORSPACE_INPUT, + thickness=2, + ), + "rgb_color_space_input_3d": VisualRGBColourspace3D( + self._working_space, + wireframe=True, + segments=24, + ), + "rgb_color_space_chromaticities_2d": VisualRGBColourspace2D( + self._working_space, + colour=self.COLOR_RGB_COLORSPACE_CHROMATICITIES, + thickness=2, + ), + "rgb_color_space_chromaticities_3d": VisualRGBColourspace3D( + self._working_space, + wireframe=True, + segments=24, + ), + "rgb_scatter_3d": VisualRGBScatter3D( + np.zeros(3), self._working_space, model=method, size=4 + ), + } + + self._root = gfx.Group() + for visual in self._visuals.values(): + self._root.add(visual) + + self._setup_widgets() + self._setup_visuals() + self._setup_notifications() + + @property + def wgpu_viewer(self) -> WgpuCanvasOffScreenViewer: + """ + Getter property for the *WebGPU* viewer. + + :return: *WebGPU* viewer. + """ + + return self._wgpu_viewer + + def reset(self) -> None: + """Resets the widgets and *Visuals* to their initial state.""" + + self._setup_widgets() + self._setup_visuals() + + 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 + + def _set_rgb_color_space_input_visuals_visibility(self) -> None: + """Set the visibility of the *input color space* related *Visuals*.""" + + use_3d_visuals = self._use_3d_visuals_pushbutton.isChecked() + draw_input_color_space = ( + self._draw_input_color_space_pushbutton.isChecked() + ) + + self._visuals["rgb_color_space_input_2d"].visible = ( + not use_3d_visuals + ) * draw_input_color_space + self._visuals["rgb_color_space_input_3d"].visible = ( + use_3d_visuals * draw_input_color_space + ) + + self._wgpu_viewer.render() + + def _set_rgb_color_space_chromaticities_visuals_visibility(self) -> None: + """Set the visibility of the *chromaticities color space* related *Visuals*.""" + + use_3d_visuals = self._use_3d_visuals_pushbutton.isChecked() + draw_chromaticities_color_space = ( + self._draw_chromaticities_color_space_pushbutton.isChecked() + ) + + self._visuals["rgb_color_space_chromaticities_2d"].visible = ( + not use_3d_visuals + ) * draw_chromaticities_color_space + self._visuals["rgb_color_space_chromaticities_3d"].visible = ( + use_3d_visuals * draw_chromaticities_color_space + ) + + self._wgpu_viewer.render() + + def _on_draw_input_color_space_pushbutton_clicked( + self, state: bool + ) -> None: + """ + Slot triggered when the `draw_input_color_space_pushbutton` widget + is clicked. + """ + + self._set_rgb_color_space_input_visuals_visibility() + + def _on_draw_chromaticities_color_space_pushbutton_clicked( + self, state: bool + ) -> None: + """ + Slot triggered when the `draw_chromaticities_color_space_pushbutton` + widget is clicked. + """ + + self._set_rgb_color_space_chromaticities_visuals_visibility() + + def _on_use_3d_visuals_pushbutton_clicked(self, state: bool) -> None: + """ + Slot triggered when the `use_3d_visuals_pushbutton` widget is clicked. + """ + + self._set_rgb_color_space_input_visuals_visibility() + self._set_rgb_color_space_chromaticities_visuals_visibility() + + def _on_use_orthographic_projection_pushbutton_clicked( + self, state: bool + ) -> None: + """ + Slot triggered when the `use_orthographic_projection_pushbutton` widget + is clicked. + """ + + self._wgpu_viewer.wgpu_camera.fov = 0 if state else 50 + + self._wgpu_viewer.render() + + def _setup_widgets(self) -> None: + """Setup the widgets initial state.""" + + self._draw_input_color_space_pushbutton.setChecked(True) + self._draw_chromaticities_color_space_pushbutton.setChecked(True) + self._use_3d_visuals_pushbutton.setChecked(False) + self._use_orthographic_projection_pushbutton.setChecked(True) + + self._set_rgb_color_space_input_visuals_visibility() + self._set_rgb_color_space_chromaticities_visuals_visibility() + + def _setup_visuals(self) -> None: + """Setup the *Visuals* initial state.""" + + self._visuals["rgb_color_space_input_2d"].visible = False + self._visuals["rgb_color_space_input_2d"].local.position = np.array( + [0, 0, 0.000025] + ) + self._visuals["rgb_color_space_input_3d"].visible = False + self._visuals["rgb_color_space_chromaticities_2d"].visible = False + self._visuals["rgb_color_space_chromaticities_2d"].local.position = ( + np.array([0, 0, 0.00005]) + ) + self._visuals["rgb_color_space_chromaticities_3d"].visible = False + self._visuals["rgb_scatter_3d"].visible = False + + self._wgpu_viewer.wgpu_scene.add(self._root) + + self._reset_camera() + + def _setup_notifications(self) -> None: + """Setup the widgets notifications, i.e., signals and slots.""" + + self._chromaticities_color_spaces_combobox.textActivated.connect( + self._update_visuals + ) + self._method_combobox.textActivated.connect(self._update_visuals) + self._draw_input_color_space_pushbutton.clicked.connect( + self._on_draw_input_color_space_pushbutton_clicked + ) + self._draw_chromaticities_color_space_pushbutton.clicked.connect( + self._on_draw_chromaticities_color_space_pushbutton_clicked + ) + self._use_3d_visuals_pushbutton.clicked.connect( + self._on_use_3d_visuals_pushbutton_clicked + ) + self._use_orthographic_projection_pushbutton.clicked.connect( + self._on_use_orthographic_projection_pushbutton_clicked + ) + self._reset_camera_pushbutton.clicked.connect(self._reset_camera) + + @QtCore.Slot(str) + def _on_config_html_ready(self, record: str) -> None: + """Slot triggered when the *Config* html is updated.""" + + 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_pushbutton.setEnabled( + has_role_interchange_display + ) + self._set_rgb_color_space_input_visuals_visibility() + self._visuals[ + "rgb_color_space_input_2d" + ].visible *= has_role_interchange_display + self._visuals[ + "rgb_color_space_input_3d" + ].visible *= has_role_interchange_display + + self._draw_chromaticities_color_space_pushbutton.setEnabled( + has_role_interchange_display + ) + self._set_rgb_color_space_chromaticities_visuals_visibility() + self._visuals[ + "rgb_color_space_chromaticities_2d" + ].visible *= has_role_interchange_display + self._visuals[ + "rgb_color_space_chromaticities_3d" + ].visible *= has_role_interchange_display + + @QtCore.Slot(ocio.CPUProcessor) + def _on_processor_ready( + self, proc_context: ProcessorContext, cpu_proc: ocio.CPUProcessor + ) -> None: + """ + Slot triggered when the *OCIO* processor is ready. + + The processor and context are stored and can then be used to process + the image when it is itself ready. + """ + + 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: + """ + Slot triggered when the image is ready. + """ + + sub_sampling_factor = int( + np.sqrt( + subsampling_factor( + image_array, self.MAXIMUM_IMAGE_SAMPLES_COUNT + ) + ) + ) + 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): + """ + Update the *Visuals* to the desired state. + """ + + 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_2d"].colourspace = ( + rgb_colourspace + ) + self._visuals["rgb_color_space_input_3d"].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_2d" + ].colourspace = rgb_colourspace + self._visuals[ + "rgb_color_space_chromaticities_3d" + ].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_2d"].method = method + self._visuals["rgb_color_space_input_3d"].model = method + self._visuals["rgb_color_space_chromaticities_2d"].method = method + self._visuals["rgb_color_space_chromaticities_3d"].model = method + self._visuals["rgb_scatter_3d"].model = 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() diff --git a/src/apps/ocioview/ocioview/inspect_dock.py b/src/apps/ocioview/ocioview/inspect_dock.py index 084ac84b1a..c9ef01a6cb 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 efb2c9677a..a18da91260 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 804a649542..832d63858d 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 274686ce4b..32b46ee925 100644 --- a/src/apps/ocioview/ocioview/utils.py +++ b/src/apps/ocioview/ocioview/utils.py @@ -8,12 +8,15 @@ from typing import Optional, Union import PyOpenColorIO as ocio +import numpy as np import qtawesome +from colour import RGB_Colourspace, XYZ_to_xy from pygments import highlight from pygments.lexers import GLShaderLexer, HLSLShaderLexer, XmlLexer, YamlLexer from pygments.formatters import HtmlFormatter from PySide6 import QtCore, QtGui, QtWidgets +from .config_cache import ConfigCache from .constants import ICON_SCALE_FACTOR, ICON_SIZE_BUTTON @@ -254,3 +257,62 @@ 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 + + +def color_space_to_rgb_colourspace(color_space: str) -> RGB_Colourspace | None: + """ + Convert a color space name from the current Config to a + :class:`RGB_Colourspace` class instance. + + :param color_space: Color space name from the current Config. + :return: :class:`RGB_Colourspace` class instance. + """ + + 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 RGB_Colourspace( + color_space, + XYZ_to_xy(XYZ), + XYZ_to_xy(XYZ_w), + color_space, + use_derived_matrix_RGB_to_XYZ=True, + use_derived_matrix_XYZ_to_RGB=True, + ) diff --git a/src/apps/ocioview/ocioview/viewer/__init__.py b/src/apps/ocioview/ocioview/viewer/__init__.py index fe3f9a82ef..7784469072 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 7ceb11b5af..aa128bf0f5 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 0000000000..ca785ab7a5 --- /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/pyproject.toml b/src/apps/ocioview/pyproject.toml new file mode 100644 index 0000000000..ca3e2453fc --- /dev/null +++ b/src/apps/ocioview/pyproject.toml @@ -0,0 +1,176 @@ +[tool.poetry] +name = "ocioview" +version = "0.1.0" +description = "OpenColorIO config visual editor application" +license = "BSD-3-Clause" +authors = ["Contributors to the OpenColorIO Project"] + +[tool.poetry.dependencies] +python = ">= 3.9, < 3.12" +colour-science = {git = "https://github.com/colour-science/colour.git"} +colour-visuals = {git = "https://github.com/colour-science/colour-visuals.git"} +imageio = ">= 2, < 3" +networkx = ">= 2.7, < 3" +numpy = ">= 1.22, < 2" +opencolorio = "*" +qtawesome = "*" +pygfx = "*" +wpgu = "*" +pygments = "*" +pyopengl = "*" +pyside6 = "*" +scipy = ">= 1.8, < 2" + +[tool.poetry.group.dev.dependencies] +black = "*" +blackdoc = "*" +coverage = "!= 6.3" +coveralls = "*" +flynt = "*" +invoke = "*" +jupyter = "*" +pre-commit = "*" +pyright = "*" +pytest = "*" +pytest-cov = "*" +pytest-xdist = "*" +ruff = "*" +toml = "*" +twine = "*" + +[tool.poetry.group.docs.dependencies] +pydata-sphinx-theme = "*" +restructuredtext-lint = "*" +sphinx = "*" +sphinxcontrib-bibtex = "*" + +[tool.black] +line-length = 79 +exclude = ''' +/( + \.git + | build + | dist +)/ +''' + +[tool.flynt] +line_length = 999 + +[tool.pyright] +reportMissingImports = false +reportMissingModuleSource = false +reportUnboundVariable = false +reportUnnecessaryCast = true +reportUnnecessaryTypeIgnoreComment = true +reportUnsupportedDunderAll = false +reportUnusedExpression = false + +[tool.pytest.ini_options] +addopts = "-n auto --dist=loadscope --durations=5" +filterwarnings = [ + "ignore::RuntimeWarning", + "ignore::pytest.PytestCollectionWarning", + "ignore:Implicit None on return values is deprecated:DeprecationWarning", + "ignore:Jupyter is migrating its paths:DeprecationWarning", + "ignore:the imp module is deprecated:DeprecationWarning", + "ignore:Method Nelder-Mead does not use gradient information:RuntimeWarning", + "ignore:More than 20 figures have been opened:RuntimeWarning", + "ignore:divide by zero encountered:RuntimeWarning", + "ignore:invalid value encountered in:RuntimeWarning", + "ignore:overflow encountered in:RuntimeWarning", + "ignore:Matplotlib is currently using agg:UserWarning", + "ignore:override the edgecolor or facecolor properties:UserWarning", +] + +[tool.ruff] +target-version = "py39" +line-length = 88 +select = [ + "A", # flake8-builtins + "ARG", # flake8-unused-arguments + # "ANN", # flake8-annotations + "B", # flake8-bugbear + # "BLE", # flake8-blind-except + "C4", # flake8-comprehensions + # "C90", # mccabe + # "COM", # flake8-commas + "DTZ", # flake8-datetimez + "D", # pydocstyle + "E", # pydocstyle + # "ERA", # eradicate + # "EM", # flake8-errmsg + "EXE", # flake8-executable + "F", # flake8 + # "FBT", # flake8-boolean-trap + "G", # flake8-logging-format + "I", # isort + "ICN", # flake8-import-conventions + "INP", # flake8-no-pep420 + "ISC", # flake8-implicit-str-concat + "N", # pep8-naming + # "PD", # pandas-vet + "PIE", # flake8-pie + "PGH", # pygrep-hooks + "PL", # pylint + # "PT", # flake8-pytest-style + # "PTH", # flake8-use-pathlib [Enable] + "Q", # flake8-quotes + "RET", # flake8-return + "RUF", # Ruff + "S", # flake8-bandit + "SIM", # flake8-simplify + "T10", # flake8-debugger + "T20", # flake8-print + # "TCH", # flake8-type-checking + "TID", # flake8-tidy-imports + "TRY", # tryceratops + "UP", # pyupgrade + "W", # pydocstyle + "YTT" # flake8-2020 +] +ignore = [ + "B008", + "B905", + "D104", + "D200", + "D202", + "D205", + "D301", + "D400", + "I001", + "N801", + "N802", + "N803", + "N806", + "N813", + "N815", + "N816", + "PGH003", + "PIE804", + "PLE0605", + "PLR0911", + "PLR0912", + "PLR0913", + "PLR0915", + "PLR2004", + "RET504", + "RET505", + "RET506", + "RET507", + "RET508", + "TRY003", + "TRY300", +] +typing-modules = ["colour.hints"] +fixable = ["B", "C", "E", "F", "PIE", "RUF", "SIM", "UP", "W"] + +[tool.ruff.pydocstyle] +convention = "numpy" + +[tool.ruff.per-file-ignores] +"docs/*" = ["INP"] + +[build-system] +requires = ["poetry_core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/src/apps/ocioview/requirements.txt b/src/apps/ocioview/requirements.txt index 2da54714fb..55d31e852e 100644 --- a/src/apps/ocioview/requirements.txt +++ b/src/apps/ocioview/requirements.txt @@ -1,7 +1,12 @@ -imageio -numpy -pygments +OpenColorIO PyOpenGL PySide6 QtAwesome -OpenColorIO \ No newline at end of file +colour-science @ git+https://github.com/colour-science/colour.git +colour-visuals @ git+https://github.com/colour-science/colour-visuals.git +imageio +networkx +numpy +pygfx +pygments +wgpu