From c9278c5159c32748dbc0d74a01595ede4bdcf6af 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 | 122 +++++++++++++ src/apps/ocioview/ocioview/inspect_dock.py | 9 +- src/apps/ocioview/ocioview/utils.py | 39 ++++- src/apps/ocioview/ocioview/viewer/__init__.py | 1 + .../ocioview/viewer/offscreen_viewer.py | 160 ++++++++++++++++++ 6 files changed, 327 insertions(+), 5 deletions(-) create mode 100644 src/apps/ocioview/ocioview/inspect/chromaticities_inspector.py create mode 100644 src/apps/ocioview/ocioview/viewer/offscreen_viewer.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..12d818d8c --- /dev/null +++ b/src/apps/ocioview/ocioview/inspect/chromaticities_inspector.py @@ -0,0 +1,122 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright Contributors to the OpenColorIO Project. + +import numpy as np +import pygfx as gfx +import PyOpenColorIO as ocio +from colour_visuals import * +from PySide6 import QtCore, QtGui, QtWidgets +from typing import Optional + +from ..viewer import WgpuCanvasOffScreenViewer +from ..message_router import MessageRouter +from ..utils import get_glyph_icon, subsampling_factor + + +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) + + self._cpu_proc = None + self._image_array = None + + self._wgpu_viewer = WgpuCanvasOffScreenViewer() + self._root = None + self._visuals = {} + self._setup_scene() + + # Layout + layout = QtWidgets.QHBoxLayout() + self.setLayout(layout) + layout.addWidget(self._wgpu_viewer) + + msg_router = MessageRouter.get_instance() + msg_router.processor_ready.connect(self._on_processor_ready) + msg_router.image_ready.connect(self._on_image_ready) + + @property + def wgpu_viewer(self): + return self._wgpu_viewer + + def _setup_scene(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_scatter_3d": VisualRGBScatter3D(np.zeros(3), "ACES2065-1", size=4), + } + + self._root = gfx.Group() + for visual in self._visuals.values(): + self._root.add(visual) + 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() + msg_router.set_processor_updates_allowed(True) + msg_router.set_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.set_processor_updates_allowed(False) + msg_router.set_image_updates_allowed(False) + + @QtCore.Slot(ocio.CPUProcessor) + def _on_processor_ready(self, cpu_proc: ocio.CPUProcessor) -> None: + print("ChromaticitiesInspector._on_processor_ready") + + self._cpu_proc = cpu_proc + # group_transform = self._cpu_proc.createGroupTransform() + print(dir(self._cpu_proc)) + + image_array = np.copy(self._image_array) + self._cpu_proc.applyRGB(image_array) + self._visuals["rgb_scatter_3d"].RGB = image_array + self._wgpu_viewer.render() + + @QtCore.Slot(np.ndarray) + def _on_image_ready(self, image_array: np.ndarray) -> None: + print("ChromaticitiesInspector._on_image_ready") + + sub_sampling_factor = int( + np.sqrt(subsampling_factor(image_array, 1e6))) + self._image_array = image_array[ + ::sub_sampling_factor, ::sub_sampling_factor, ... + ] + + if self._cpu_proc is None: + self._visuals["rgb_scatter_3d"].RGB = self._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() 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/utils.py b/src/apps/ocioview/ocioview/utils.py index f4a975b1f..e80863bcb 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 @@ -133,7 +134,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: @@ -176,7 +179,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() @@ -218,14 +223,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"), ) ) @@ -252,3 +263,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/offscreen_viewer.py b/src/apps/ocioview/ocioview/viewer/offscreen_viewer.py new file mode 100644 index 000000000..f5303dcf7 --- /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.25, -0.5, 2]) + self._wgpu_camera.show_pos(np.array([1 / 3, 1 / 3, 0.4])) + + # 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, + ) + )