From 813785eca694f83a1f7d54dccf265b2dda2a67eb Mon Sep 17 00:00:00 2001 From: Michael Dolan Date: Wed, 17 Apr 2024 13:12:00 -0400 Subject: [PATCH] ocioview image and PySide6 updates (#1912) * Updates for PySide6 compatibility Signed-off-by: Michael Dolan * Remove QT3D_RENDERER override Signed-off-by: Michael Dolan * Message router name update Signed-off-by: Michael Dolan --------- Signed-off-by: Michael Dolan Co-authored-by: Thomas Mansencal --- src/apps/ocioview/main.py | 2 +- src/apps/ocioview/ocioview/constants.py | 2 +- .../ocioview/ocioview/inspect/__init__.py | 1 + .../ocioview/inspect/code_inspector.py | 4 +- .../ocioview/inspect/curve_inspector.py | 10 +- src/apps/ocioview/ocioview/inspect_dock.py | 3 +- .../ocioview/items/config_item_edit.py | 4 +- src/apps/ocioview/ocioview/message_router.py | 85 ++++-- src/apps/ocioview/ocioview/utils.py | 10 + src/apps/ocioview/ocioview/viewer/__init__.py | 1 + .../ocioview/ocioview/viewer/image_plane.py | 245 ++++++++---------- .../ocioview/ocioview/viewer/image_viewer.py | 27 +- src/apps/ocioview/ocioview/viewer/utils.py | 89 +++++++ .../ocioview/ocioview/widgets/combo_box.py | 4 +- .../ocioview/ocioview/widgets/list_widget.py | 1 + .../ocioview/ocioview/widgets/structure.py | 6 +- 16 files changed, 299 insertions(+), 195 deletions(-) create mode 100644 src/apps/ocioview/ocioview/viewer/utils.py diff --git a/src/apps/ocioview/main.py b/src/apps/ocioview/main.py index 609ef73d8..2c2b1bffd 100644 --- a/src/apps/ocioview/main.py +++ b/src/apps/ocioview/main.py @@ -7,7 +7,7 @@ from pathlib import Path import PyOpenColorIO as ocio -from PySide6 import QtCore, QtGui, QtWidgets, QtOpenGL +from PySide6 import QtCore, QtGui, QtWidgets import ocioview.log_handlers # Import to initialize logging from ocioview.main_window import OCIOView diff --git a/src/apps/ocioview/ocioview/constants.py b/src/apps/ocioview/ocioview/constants.py index 18b7f0909..4862bdbb8 100644 --- a/src/apps/ocioview/ocioview/constants.py +++ b/src/apps/ocioview/ocioview/constants.py @@ -11,7 +11,7 @@ # Sizes ICON_SIZE_ITEM = QtCore.QSize(20, 20) -ICON_SIZE_BUTTON = QtCore.QSize(24, 24) +ICON_SIZE_BUTTON = QtCore.QSize(20, 20) ICON_SCALE_FACTOR = 1.15 MARGIN_WIDTH = 13 # Pixels diff --git a/src/apps/ocioview/ocioview/inspect/__init__.py b/src/apps/ocioview/ocioview/inspect/__init__.py index 6d5f42f92..1d32c405e 100644 --- a/src/apps/ocioview/ocioview/inspect/__init__.py +++ b/src/apps/ocioview/ocioview/inspect/__init__.py @@ -2,4 +2,5 @@ # Copyright Contributors to the OpenColorIO Project. from .code_inspector import CodeInspector +from .curve_inspector import CurveInspector from .log_inspector import LogInspector diff --git a/src/apps/ocioview/ocioview/inspect/code_inspector.py b/src/apps/ocioview/ocioview/inspect/code_inspector.py index 3fa1e81e3..22ee1e396 100644 --- a/src/apps/ocioview/ocioview/inspect/code_inspector.py +++ b/src/apps/ocioview/ocioview/inspect/code_inspector.py @@ -40,7 +40,9 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): html_css = HtmlFormatter(style="material").get_style_defs() # Update line number colors to match palette - html_css = html_css.replace("#263238", palette.color(palette.ColorRole.Base).name()) + html_css = html_css.replace( + "#263238", palette.color(palette.ColorRole.Base).name() + ) html_css = html_css.replace( "#37474F", palette.color(palette.ColorRole.Text).darker(150).name() ) diff --git a/src/apps/ocioview/ocioview/inspect/curve_inspector.py b/src/apps/ocioview/ocioview/inspect/curve_inspector.py index 9a483a1cc..05373742e 100644 --- a/src/apps/ocioview/ocioview/inspect/curve_inspector.py +++ b/src/apps/ocioview/ocioview/inspect/curve_inspector.py @@ -249,21 +249,21 @@ def __init__( # Initialize self._update_x_samples() msg_router = MessageRouter.get_instance() - msg_router.cpu_processor_ready.connect(self._on_cpu_processor_ready) + msg_router.processor_ready.connect(self._on_processor_ready) 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_cpu_processor_updates_allowed(True) + msg_router.set_processor_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_cpu_processor_updates_allowed(False) + msg_router.set_processor_updates_allowed(False) def resizeEvent(self, event: QtGui.QResizeEvent) -> None: """Re-fit graph on resize, to always be centered.""" @@ -549,7 +549,7 @@ def _update_curves(self) -> None: """ self._update_x_samples() if self._prev_cpu_proc is not None: - self._on_cpu_processor_ready(self._prev_cpu_proc) + self._on_processor_ready(self._prev_cpu_proc) def _update_x_samples(self): """ @@ -603,7 +603,7 @@ def _fit(self) -> None: self.update() @QtCore.Slot(ocio.CPUProcessor) - def _on_cpu_processor_ready(self, cpu_proc: ocio.CPUProcessor) -> None: + def _on_processor_ready(self, cpu_proc: ocio.CPUProcessor) -> None: """ Update curves from sampled OCIO CPU processor. diff --git a/src/apps/ocioview/ocioview/inspect_dock.py b/src/apps/ocioview/ocioview/inspect_dock.py index 39c352cb1..084ac84b1 100644 --- a/src/apps/ocioview/ocioview/inspect_dock.py +++ b/src/apps/ocioview/ocioview/inspect_dock.py @@ -5,8 +5,7 @@ from PySide6 import QtCore, QtWidgets -from .inspect.curve_inspector import CurveInspector -from .inspect import LogInspector, CodeInspector +from .inspect import CodeInspector, CurveInspector, LogInspector from .utils import get_glyph_icon from .widgets.structure import TabbedDockWidget diff --git a/src/apps/ocioview/ocioview/items/config_item_edit.py b/src/apps/ocioview/ocioview/items/config_item_edit.py index a49fbdc65..e28069a6e 100644 --- a/src/apps/ocioview/ocioview/items/config_item_edit.py +++ b/src/apps/ocioview/ocioview/items/config_item_edit.py @@ -47,7 +47,9 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): if self.__has_transforms__: self.__has_tabs__ = True - no_tf_color = palette.color(palette.ColorGroup.Disabled, palette.ColorRole.Text) + no_tf_color = palette.color( + palette.ColorGroup.Disabled, palette.ColorRole.Text + ) self._from_ref_icon = get_glyph_icon("mdi6.layers-plus") self._no_from_ref_icon = get_glyph_icon( "mdi6.layers-plus", color=no_tf_color diff --git a/src/apps/ocioview/ocioview/message_router.py b/src/apps/ocioview/ocioview/message_router.py index 668cb219b..64ee20bec 100644 --- a/src/apps/ocioview/ocioview/message_router.py +++ b/src/apps/ocioview/ocioview/message_router.py @@ -9,6 +9,7 @@ from typing import Any, Optional from queue import Empty, SimpleQueue +import numpy as np import PyOpenColorIO as ocio from PySide6 import QtCore, QtGui, QtWidgets @@ -30,9 +31,10 @@ class MessageRunner(QtCore.QObject): error_logged = QtCore.Signal(str) debug_logged = QtCore.Signal(str) - cpu_processor_ready = QtCore.Signal(ocio.CPUProcessor) config_html_ready = QtCore.Signal(str) ctf_html_ready = QtCore.Signal(str, ocio.GroupTransform) + image_ready = QtCore.Signal(np.ndarray) + processor_ready = QtCore.Signal(ocio.CPUProcessor) shader_html_ready = QtCore.Signal(str, ocio.GPUProcessor) LOOP_INTERVAL = 0.5 # In seconds @@ -64,11 +66,13 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): self._gpu_language = ocio.GPU_LANGUAGE_GLSL_4_0 self._prev_config = None - self._prev_proc = None + self._prev_cpu_proc = None + self._prev_image_array = None - self._cpu_processor_updates_allowed = False self._config_updates_allowed = False self._ctf_updates_allowed = False + self._image_updates_allowed = False + self._processor_updates_allowed = False self._shader_updates_allowed = False def get_gpu_language(self) -> ocio.GpuLanguage: @@ -76,9 +80,9 @@ def get_gpu_language(self) -> ocio.GpuLanguage: def set_gpu_language(self, gpu_language: ocio.GpuLanguage) -> None: self._gpu_language = gpu_language - if self._shader_updates_allowed and self._prev_proc is not None: + if self._shader_updates_allowed and self._prev_cpu_proc is not None: # Rebroadcast last processor record - message_queue.put_nowait(self._prev_proc) + message_queue.put_nowait(self._prev_cpu_proc) def config_updates_allowed(self) -> bool: return self._config_updates_allowed @@ -89,32 +93,41 @@ def set_config_updates_allowed(self, allowed: bool) -> None: # Rebroadcast last config record message_queue.put_nowait(self._prev_config) - def cpu_processor_updates_allowed(self) -> bool: - return self._cpu_processor_updates_allowed - - def set_cpu_processor_updates_allowed(self, allowed: bool) -> None: - self._cpu_processor_updates_allowed = allowed - if allowed and self._prev_config is not None: - # Rebroadcast last config record - message_queue.put_nowait(self._prev_config) - def ctf_updates_allowed(self) -> bool: return self._ctf_updates_allowed def set_ctf_updates_allowed(self, allowed: bool) -> None: self._ctf_updates_allowed = allowed - if allowed and self._prev_proc is not None: + if allowed and self._prev_cpu_proc is not None: # Rebroadcast last processor record - message_queue.put_nowait(self._prev_proc) + message_queue.put_nowait(self._prev_cpu_proc) + + def image_updates_allowed(self) -> bool: + return self._image_updates_allowed + + def set_image_updates_allowed(self, allowed: bool) -> None: + self._image_updates_allowed = allowed + if allowed and self._prev_image_array is not None: + # Rebroadcast last image record + message_queue.put_nowait(self._prev_image_array) + + def processor_updates_allowed(self) -> bool: + return self._processor_updates_allowed + + def set_processor_updates_allowed(self, allowed: bool) -> None: + self._processor_updates_allowed = allowed + if allowed and self._prev_config is not None: + # Rebroadcast last config record + message_queue.put_nowait(self._prev_config) def shader_updates_allowed(self) -> bool: return self._shader_updates_allowed def set_shader_updates_allowed(self, allowed: bool) -> None: self._shader_updates_allowed = allowed - if allowed and self._prev_proc is not None: + if allowed and self._prev_cpu_proc is not None: # Rebroadcast last processor record - message_queue.put_nowait(self._prev_proc) + message_queue.put_nowait(self._prev_cpu_proc) def is_routing(self) -> bool: """Whether runner is routing messages.""" @@ -144,14 +157,20 @@ def start_routing(self) -> None: # OCIO processor elif isinstance(msg_raw, ocio.Processor): - self._prev_proc = msg_raw + self._prev_cpu_proc = msg_raw if ( - self._cpu_processor_updates_allowed + self._processor_updates_allowed or self._ctf_updates_allowed or self._shader_updates_allowed ): self._handle_processor_message(msg_raw) + # Image array + elif isinstance(msg_raw, np.ndarray): + self._prev_image_array = msg_raw + if self._image_updates_allowed: + self._handle_image_message(msg_raw) + # Python or OCIO log record else: self._handle_log_message(msg_raw) @@ -162,7 +181,7 @@ def _handle_config_message(self, config: ocio.Config) -> None: """ Handle OCIO config received in the message queue. - :config: OCIO config instance + :param config: OCIO config instance """ try: config_html_data = config_to_html(config) @@ -171,22 +190,22 @@ def _handle_config_message(self, config: ocio.Config) -> None: # Pass error to log self._handle_log_message(str(e), force_level=self.LOG_LEVEL_WARNING) - def _handle_processor_message(self, processor: ocio.Processor) -> None: + def _handle_processor_message(self, cpu_proc: ocio.Processor) -> None: """ Handle OCIO processor received in the message queue. - :config: OCIO processor instance + :param cpu_proc: OCIO processor instance """ try: - if self._cpu_processor_updates_allowed: - self.cpu_processor_ready.emit(processor.getDefaultCPUProcessor()) + if self._processor_updates_allowed: + self.processor_ready.emit(cpu_proc.getDefaultCPUProcessor()) if self._ctf_updates_allowed: - ctf_html_data, group_tf = processor_to_ctf_html(processor) + ctf_html_data, group_tf = processor_to_ctf_html(cpu_proc) self.ctf_html_ready.emit(ctf_html_data, group_tf) if self._shader_updates_allowed: - gpu_proc = processor.getDefaultGPUProcessor() + gpu_proc = cpu_proc.getDefaultGPUProcessor() shader_html_data = processor_to_shader_html( gpu_proc, self._gpu_language ) @@ -196,6 +215,18 @@ def _handle_processor_message(self, processor: ocio.Processor) -> None: # Pass error to log self._handle_log_message(str(e), force_level=self.LOG_LEVEL_WARNING) + def _handle_image_message(self, image_array: np.ndarray) -> None: + """ + Handle image buffer received in the message queue. + + :param image_array: Image array + """ + try: + self.image_ready.emit(image_array) + except Exception as e: + # Pass error to log + self._handle_log_message(str(e), force_level=self.LOG_LEVEL_WARNING) + def _handle_log_message( self, log_record: str, force_level: Optional[str] = None ) -> None: diff --git a/src/apps/ocioview/ocioview/utils.py b/src/apps/ocioview/ocioview/utils.py index 31091a8fe..f4a975b1f 100644 --- a/src/apps/ocioview/ocioview/utils.py +++ b/src/apps/ocioview/ocioview/utils.py @@ -242,3 +242,13 @@ def increase_html_lineno_padding(html: str) -> str: r"\1\2  \3", html, ) + + +def float_to_uint8(value: float) -> int: + """ + Convert float value to an 8-bit clamped unsigned integer value. + + :param value: Float value + :return: Integer value + """ + return max(0, min(255, int(value * 255))) diff --git a/src/apps/ocioview/ocioview/viewer/__init__.py b/src/apps/ocioview/ocioview/viewer/__init__.py index 4cdf95137..fe3f9a82e 100644 --- a/src/apps/ocioview/ocioview/viewer/__init__.py +++ b/src/apps/ocioview/ocioview/viewer/__init__.py @@ -2,3 +2,4 @@ # Copyright Contributors to the OpenColorIO Project. from .image_viewer import ViewerChannels, ImageViewer +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 7b06fd4cb..f70252923 100644 --- a/src/apps/ocioview/ocioview/viewer/image_plane.py +++ b/src/apps/ocioview/ocioview/viewer/image_plane.py @@ -11,19 +11,16 @@ from functools import partial from pathlib import Path from typing import Any, Optional -import sys import numpy as np from OpenGL import GL -try: - import OpenImageIO as oiio -except: - import imageio as iio + import PyOpenColorIO as ocio from PySide6 import QtCore, QtGui, QtWidgets, QtOpenGLWidgets from ..log_handlers import message_queue from ..ref_space_manager import ReferenceSpaceManager +from .utils import load_image logger = logging.getLogger(__name__) @@ -103,6 +100,7 @@ class ImagePlane(QtOpenGLWidgets.QOpenGLWidget): def __init__(self, parent: Optional[QtWidgets.QWidget] = None): super().__init__(parent) + self.setMinimumSize(QtCore.QSize(10, 10)) # Clicking on/tabbing to widget restores focus self.setFocusPolicy(QtCore.Qt.StrongFocus) @@ -165,6 +163,8 @@ def initializeGL(self) -> None: """ self._gl_ready = True + self.makeCurrent() + # Init image texture self._image_tex = GL.glGenTextures(1) GL.glActiveTexture(GL.GL_TEXTURE0) @@ -172,11 +172,11 @@ def initializeGL(self) -> None: GL.glTexImage2D( GL.GL_TEXTURE_2D, 0, - GL.GL_RGBA32F, + GL.GL_RGB32F, self._image_size[0], self._image_size[1], 0, - GL.GL_RGBA, + GL.GL_RGB, GL.GL_FLOAT, ctypes.c_void_p(0), ) @@ -264,31 +264,6 @@ def initializeGL(self) -> None: self._build_program() - def _orthographicProjMatrix(self, near, far, left, right, top, bottom): - rightPlusLeft = right + left - rightMinusLeft = right - left - - topPlusBottom = top + bottom - topMinusBottom = top - bottom - - farPlusNear = far + near - farMinusNear = far - near - - tx = -rightPlusLeft / rightMinusLeft - ty = -topPlusBottom / topMinusBottom - tz = -farPlusNear / farMinusNear - - A = 2 / rightMinusLeft - B = 2 / topMinusBottom - C = -2 / farMinusNear - - return np.array([ - [A, 0, 0, tx], - [0, B, 0, ty], - [0, 0, C, tz], - [0, 0, 0, 1 ] - ]) - def resizeGL(self, w: int, h: int) -> None: """ Called whenever the widget is resized. @@ -296,18 +271,21 @@ def resizeGL(self, w: int, h: int) -> None: :param w: Window width :param h: Window height """ + self.makeCurrent() + GL.glViewport(0, 0, w, h) # Center image plane - # fmt: on - self._proj_mat = self._orthographicProjMatrix( - -1.0, # Near - 1.0, # Far + # fmt: off + self._proj_mat = self._orthographic_proj_matrix( + -1.0, # Near + 1.0, # Far -w / 2.0, # Left w / 2.0, # Right h / 2.0, # Top -h / 2.0, # Bottom ) + # fmt: on self._update_model_view_mat() @@ -316,6 +294,8 @@ def paintGL(self) -> None: Called whenever a repaint is needed. Calling ``update()`` will schedule a repaint. """ + self.makeCurrent() + GL.glClearColor(0.0, 0.0, 0.0, 1.0) GL.glClear(GL.GL_COLOR_BUFFER_BIT) @@ -328,9 +308,7 @@ def paintGL(self) -> None: # Set uniforms mvp_mat = self._proj_mat @ self._model_view_mat mvp_mat_loc = GL.glGetUniformLocation(self._shader_program, "mvpMat") - GL.glUniformMatrix4fv( - mvp_mat_loc, 1, GL.GL_FALSE, mvp_mat.T - ) + GL.glUniformMatrix4fv(mvp_mat_loc, 1, GL.GL_FALSE, mvp_mat.T) image_tex_loc = GL.glGetUniformLocation(self._shader_program, "imageTex") GL.glUniform1i(image_tex_loc, 0) @@ -347,55 +325,6 @@ def paintGL(self) -> None: GL.glBindVertexArray(0) - def load_oiio(self, image_path: Path) -> np.ndarray: - image_buf = oiio.ImageBuf(image_path.as_posix()) - spec = image_buf.spec() - - # Convert to RGBA, filling missing color channels with 0.0, and a - # missing alpha with 1.0. - if spec.nchannels < 4: - image_buf = oiio.ImageBufAlgo.channels( - image_buf, - tuple( - list(range(spec.nchannels)) - + ([0.0] * (4 - spec.nchannels - 1)) - + [1.0] - ), - newchannelnames=("R", "G", "B", "A"), - ) - elif spec.nchannels > 4: - image_buf = oiio.ImageBufAlgo.channels( - image_buf, (0, 1, 2, 3), newchannelnames=("R", "G", "B", "A") - ) - - # Get pixels as 32-bit float NumPy array - return image_buf.get_pixels(oiio.FLOAT) - - def load_iio(self, image_path: Path) -> np.ndarray: - data = iio.imread(image_path.as_posix()) - - # Convert to 32-bit float - if not np.issubdtype(data.dtype, np.floating): - data = data.astype(np.float32) / np.iinfo(data.dtype).max - if data.dtype != np.float32: - data = data.astype(np.float32) - - # Convert to RGBA, filling missing color channels with 0.0, and a - # missing alpha with 1.0. - nchannels = 1 - if len(data.shape) == 3: - nchannels = data.shape[-1] - - while nchannels < 3: - data = np.dstack((data, np.zeros(data.shape[:2]))) - nchannels += 1 - if nchannels < 4: - data = np.dstack((data, np.ones(data.shape[:2]))) - if nchannels > 4: - data = data[..., :4] - - return data - def load_image(self, image_path: Path) -> None: """ Load an image into the image plane texture. @@ -416,16 +345,13 @@ def load_image(self, image_path: Path) -> None: color_space_name = ocio.ROLE_DEFAULT self._ocio_input_color_space = color_space_name - if "OpenImageIO" in sys.modules: - self._image_array = self.load_oiio(image_path) - else: - self._image_array = self.load_iio(image_path) + # Load image data via an available image library + self._image_array = load_image(image_path) width = self._image_array.shape[1] height = self._image_array.shape[0] # Stash image size for pan/zoom calculations - self._image_pos = np.array([0, 1], dtype=np.float64) self._image_size = np.array([width, height], dtype=np.float64) @@ -436,11 +362,11 @@ def load_image(self, image_path: Path) -> None: GL.glTexImage2D( GL.GL_TEXTURE_2D, 0, - GL.GL_RGBA32F, + GL.GL_RGB32F, width, height, 0, - GL.GL_RGBA, + GL.GL_RGB, GL.GL_FLOAT, self._image_array.ravel(), ) @@ -452,6 +378,17 @@ def load_image(self, image_path: Path) -> None: self.update_ocio_proc(input_color_space=self._ocio_input_color_space) self.fit() + # Log image change after load and render + self.broadcast_image() + + def broadcast_image(self) -> None: + """ + Broadcast current image array, if one is loaded, through the + message queue for other app components. + """ + if self._image_array is not None: + message_queue.put_nowait(self._image_array) + def input_color_space(self) -> str: """ :return: Current input OCIO color space name @@ -680,10 +617,10 @@ def leaveEvent(self, event: QtCore.QEvent) -> None: def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: self._mouse_pressed = True - self._mouse_last_pos = event.pos() + self._mouse_last_pos = event.position() def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: - pos = event.pos() + pos = event.position() if self._mouse_pressed: offset = np.array([*(pos - self._mouse_last_pos).toTuple()]) @@ -696,20 +633,26 @@ def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: # Trace mouse position through the inverse MVP matrix to update sampled # pixel. - screen_pos = np.array([ - pos.x() / widget_w * 2.0 - 1.0, - (widget_h - pos.y() - 1) / widget_h * 2.0 - 1.0, - 0.0, - 1.0 - ]) - model_pos = np.linalg.inv(self._proj_mat @ self._model_view_mat) @ screen_pos - pixel_pos = np.array([model_pos[0] + 0.5, model_pos[1] + 0.5]) * self._image_size + screen_pos = np.array( + [ + pos.x() / widget_w * 2.0 - 1.0, + (widget_h - pos.y() - 1) / widget_h * 2.0 - 1.0, + 0.0, + 1.0, + ] + ) + model_pos = ( + np.linalg.inv(self._proj_mat @ self._model_view_mat) @ screen_pos + ) + pixel_pos = ( + np.array([model_pos[0] + 0.5, model_pos[1] + 0.5]) * self._image_size + ) # Broadcast sample position if ( self._image_array is not None - and 0 <= pixel_pos[0] <= self._image_size[0] - and 0 <= pixel_pos[1] <= self._image_size[1] + and 0 <= pixel_pos[0] < self._image_size[0] + and 0 <= pixel_pos[1] < self._image_size[1] ): pixel_x = math.floor(pixel_pos[0]) pixel_y = math.floor(pixel_pos[1]) @@ -745,24 +688,10 @@ def wheelEvent(self, event: QtGui.QWheelEvent) -> None: # Fill frame with 1 pixel with 0.5 pixel overscan max_scale = max(w, h) * 1.5 - delta = event.angleDelta().y() / 360.0 - scale = max(0.01, self._image_scale - delta) - - if scale > 1.0: - if delta > 0.0: - # Exponential zoom out - scale = pow(scale, 1.0 / 1.01) - else: - # Exponential zoom in - scale = pow(scale, 1.01) - - scale = min(max_scale, max(min_scale, scale)) - - if scale < 1.0: - # Half zoom in/out - scale = (self._image_scale + scale) / 2.0 + delta = event.angleDelta().y() / 360.0 * self._image_scale + scale = min(max_scale, max(min_scale, self._image_scale - delta)) - self.zoom(event.pos(), scale, update=True, absolute=True) + self.zoom(event.position(), scale, update=True, absolute=True) def pan( self, offset: np.ndarray, update: bool = True, absolute: bool = False @@ -776,10 +705,11 @@ def pan( absolute position to translate the viewport from its origin. """ - if absolute: - self._image_pos = offset / self._image_scale - else: - self._image_pos += offset / self._image_scale + if self._image_scale > 0: + if absolute: + self._image_pos = offset / self._image_scale + else: + self._image_pos += offset / self._image_scale self._update_model_view_mat(update=update) @@ -856,9 +786,7 @@ def _install_shortcuts(self) -> None: # Ctrl + Number keys = Power of 2 scale: 1 = x1, 2 = x2, 3 = x4, ... for i in range(9): - scale_shortcut = QtGui.QShortcut( - QtGui.QKeySequence(f"Ctrl+{i + 1}"), self - ) + scale_shortcut = QtGui.QShortcut(QtGui.QKeySequence(f"Ctrl+{i + 1}"), self) scale_shortcut.activated.connect( lambda exponent=i: self.zoom( self.rect().center(), float(2**exponent), absolute=True @@ -882,6 +810,8 @@ def _compile_shader( enum adhering to the formatting ``GL_*_SHADER``. :return: Shader object ID, or None if shader compilation fails """ + self.makeCurrent() + shader = GL.glCreateShader(shader_type) GL.glShaderSource(shader, glsl_src) GL.glCompileShader(shader) @@ -958,6 +888,38 @@ def _build_program(self, force: bool = False) -> None: # Store cache ID to detect reuse self._ocio_shader_cache_id = shader_cache_id + def _orthographic_proj_matrix( + self, + near: float, + far: float, + left: float, + right: float, + top: float, + bottom: float, + ) -> np.ndarray: + """ + Build orthographic projection matrix array from camera frustum + parameters. + """ + right_plus_left = right + left + right_minus_left = right - left + + top_plus_bottom = top + bottom + top_minus_bottom = top - bottom + + far_plus_near = far + near + far_minus_near = far - near + + tx = -right_plus_left / right_minus_left + ty = -top_plus_bottom / top_minus_bottom + tz = -far_plus_near / far_minus_near + + a = 2 / right_minus_left + b = 2 / top_minus_bottom + c = -2 / far_minus_near + + return np.array([[a, 0, 0, tx], [0, b, 0, ty], [0, 0, c, tz], [0, 0, 0, 1]]) + def _update_model_view_mat(self, update: bool = True) -> None: """ Re-calculate the model view matrix, which needs to be updated @@ -965,15 +927,16 @@ def _update_model_view_mat(self, update: bool = True) -> None: :param bool update: Optionally redraw the window """ - size = np.array([self.width(), self.height()]) - self._model_view_mat = np.eye(4) # Flip Y to account for different OIIO/OpenGL image origin self._model_view_mat *= [1.0, -1.0, 1.0, 1.0] self._model_view_mat *= [self._image_scale, self._image_scale, 1.0, 1.0] - self._model_view_mat[:2, -1] += self._image_pos / size * 2.0 + self._model_view_mat[:2, -1] += [ + self._image_pos[0] * self._image_scale, + -self._image_pos[1] * self._image_scale, + ] self._model_view_mat *= self._image_size.tolist() + [1.0, 1.0] @@ -996,6 +959,8 @@ def _set_ocio_tex_params( :param tex_type: OpenGL texture type (GL_TEXTURE_1/2/3D) :param interpolation: Interpolation enum value """ + self.makeCurrent() + if interpolation == ocio.INTERP_NEAREST: GL.glTexParameteri(tex_type, GL.GL_TEXTURE_MIN_FILTER, GL.GL_NEAREST) GL.glTexParameteri(tex_type, GL.GL_TEXTURE_MAG_FILTER, GL.GL_NEAREST) @@ -1110,6 +1075,8 @@ def _del_ocio_tex(self) -> None: """ Delete all OCIO textures from the GPU. """ + self.makeCurrent() + for tex, tex_name, sampler_name, tex_type, tex_index in self._ocio_tex_ids: GL.glDeleteTextures([tex]) del self._ocio_tex_ids[:] @@ -1118,6 +1085,8 @@ def _use_ocio_tex(self) -> None: """ Bind all OCIO textures to the shader program. """ + self.makeCurrent() + for tex, tex_name, sampler_name, tex_type, tex_index in self._ocio_tex_ids: GL.glActiveTexture(GL.GL_TEXTURE0 + tex_index) GL.glBindTexture(tex_type, tex) @@ -1140,6 +1109,8 @@ def _use_ocio_uniforms(self) -> None: if not self._ocio_shader_desc or not self._shader_program: return + self.makeCurrent() + for name, uniform_data in self._ocio_shader_desc.getUniforms(): if name not in self._ocio_uniform_ids: uid = GL.glGetUniformLocation(self._shader_program, name) @@ -1180,13 +1151,13 @@ def _update_ocio_channel_hot(self, channel: int) -> None: """ # If index is in range, and we are viewing all channels, or a channel # other than index, isolate channel at index. - if channel < 4 and ( + if channel < 3 and ( all(self._ocio_channel_hot) or not self._ocio_channel_hot[channel] ): - for i in range(4): + for i in range(3): self._ocio_channel_hot[i] = 1 if i == channel else 0 # Otherwise show all channels else: - for i in range(4): + for i in range(3): self._ocio_channel_hot[i] = 1 diff --git a/src/apps/ocioview/ocioview/viewer/image_viewer.py b/src/apps/ocioview/ocioview/viewer/image_viewer.py index 11436a06c..f46ec7181 100644 --- a/src/apps/ocioview/ocioview/viewer/image_viewer.py +++ b/src/apps/ocioview/ocioview/viewer/image_viewer.py @@ -11,7 +11,7 @@ from ..transform_manager import TransformManager from ..config_cache import ConfigCache from ..constants import GRAY_COLOR, R_COLOR, G_COLOR, B_COLOR -from ..utils import get_glyph_icon, SignalsBlocked +from ..utils import float_to_uint8, get_glyph_icon, SignalsBlocked from ..widgets import ComboBox, CallbackComboBox from .image_plane import ImagePlane @@ -269,7 +269,7 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): TransformManager.subscribe_to_transform_subscription_init( self._on_transform_subscription_init ) - self.update() + self.update(force=True) self._on_sample_precision_changed(self.sample_precision_box.value()) self._on_sample_changed(-1, -1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) @@ -283,13 +283,15 @@ def update(self, force: bool = False) -> None: """ self._update_input_color_spaces(update=False) - self.image_plane.makeCurrent() self.image_plane.update_ocio_proc( input_color_space=self.input_color_space(), force_update=force ) super().update() + # Broadcast this viewer's image data for other app components + self.image_plane.broadcast_image() + def reset(self) -> None: """Reset viewer parameters without unloading the current image.""" self.image_plane.reset_ocio_proc(update=False) @@ -531,13 +533,6 @@ def _on_transform_subscription_init(self, slot: int) -> None: if index != -1: self.tf_box.setCurrentIndex(index) - def _float_to_uint8(self, value: float) -> int: - """ - :param value: Float value - :return: 8-bit clamped unsigned integer value - """ - return max(0, min(255, int(value * 255))) - @QtCore.Slot(int) def _on_transform_changed(self, index: int) -> None: if index == 0: @@ -610,9 +605,9 @@ def _on_sample_changed( ) self.input_sample_swatch.setStyleSheet( self.FMT_SWATCH_CSS.format( - r=self._float_to_uint8(r_input), - g=self._float_to_uint8(g_input), - b=self._float_to_uint8(b_input), + r=float_to_uint8(r_input), + g=float_to_uint8(g_input), + b=float_to_uint8(b_input), ) ) @@ -628,9 +623,9 @@ def _on_sample_changed( ) self.output_sample_swatch.setStyleSheet( self.FMT_SWATCH_CSS.format( - r=self._float_to_uint8(r_output), - g=self._float_to_uint8(g_output), - b=self._float_to_uint8(b_output), + r=float_to_uint8(r_output), + g=float_to_uint8(g_output), + b=float_to_uint8(b_output), ) ) diff --git a/src/apps/ocioview/ocioview/viewer/utils.py b/src/apps/ocioview/ocioview/viewer/utils.py new file mode 100644 index 000000000..5a2d4599d --- /dev/null +++ b/src/apps/ocioview/ocioview/viewer/utils.py @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright Contributors to the OpenColorIO Project. + +from pathlib import Path + +import numpy as np + +try: + import OpenImageIO as oiio + + HAS_OIIO = True +except (ImportError, ModuleNotFoundError): + HAS_OIIO = False + oiio = None + +if not HAS_OIIO: + # NOTE: This will raise if not available, since one of the supported image + # libraries is required. + import imageio as iio +else: + iio = None + + +def load_image(image_path: Path) -> np.ndarray: + """ + Load RGB image data via an available image library. + + :param image_path: Path to image to load + :return: NumPy array + """ + if HAS_OIIO: + return _load_oiio(image_path) + else: + return _load_iio(image_path) + + +def _load_oiio(image_path: Path) -> np.ndarray: + """ + Load RGB image data via OpenImageIO. + + :param image_path: Path to image to load + :return: NumPy array + """ + image_buf = oiio.ImageBuf(image_path.as_posix()) + spec = image_buf.spec() + + # Convert to RGB, filling missing color channels with 0.0 + if spec.nchannels < 3: + image_buf = oiio.ImageBufAlgo.channels( + image_buf, + tuple(list(range(spec.nchannels)) + ([0.0] * (3 - spec.nchannels))), + newchannelnames=("R", "G", "B"), + ) + elif spec.nchannels > 3: + image_buf = oiio.ImageBufAlgo.channels( + image_buf, (0, 1, 2), newchannelnames=("R", "G", "B") + ) + + # Get pixels as 32-bit float NumPy array + return image_buf.get_pixels(oiio.FLOAT) + + +def _load_iio(image_path: Path) -> np.ndarray: + """ + Load RGB image data via imageio. + + :param image_path: Path to image to load + :return: NumPy array + """ + data = iio.imread(image_path.as_posix()) + + # Convert to 32-bit float + if not np.issubdtype(data.dtype, np.floating): + data = data.astype(np.float32) / np.iinfo(data.dtype).max + if data.dtype != np.float32: + data = data.astype(np.float32) + + # Convert to RGB, filling missing color channels with 0.0 + nchannels = 1 + if len(data.shape) == 3: + nchannels = data.shape[-1] + + while nchannels < 3: + data = np.dstack((data, np.zeros(data.shape[:2]))) + nchannels += 1 + if nchannels > 3: + data = data[..., :3] + + return data diff --git a/src/apps/ocioview/ocioview/widgets/combo_box.py b/src/apps/ocioview/ocioview/widgets/combo_box.py index 5ddc2b308..b2a95fe26 100644 --- a/src/apps/ocioview/ocioview/widgets/combo_box.py +++ b/src/apps/ocioview/ocioview/widgets/combo_box.py @@ -12,7 +12,9 @@ class ComboBox(QtWidgets.QComboBox): def __init__(self, parent: Optional[QtCore.QObject] = None): super().__init__(parent=parent) - self.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToMinimumContentsLengthWithIcon) + self.setSizeAdjustPolicy( + QtWidgets.QComboBox.AdjustToMinimumContentsLengthWithIcon + ) # DataWidgetMapper user property interface @QtCore.Property(str, user=True) diff --git a/src/apps/ocioview/ocioview/widgets/list_widget.py b/src/apps/ocioview/ocioview/widgets/list_widget.py index 8fc686058..bec4eb682 100644 --- a/src/apps/ocioview/ocioview/widgets/list_widget.py +++ b/src/apps/ocioview/ocioview/widgets/list_widget.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from ..items.config_item_model import BaseConfigItemModel + class StringListWidget(BaseItemView): """ Simple string list widget with filter edit and add and remove diff --git a/src/apps/ocioview/ocioview/widgets/structure.py b/src/apps/ocioview/ocioview/widgets/structure.py index dc1492a2e..d248f88d8 100644 --- a/src/apps/ocioview/ocioview/widgets/structure.py +++ b/src/apps/ocioview/ocioview/widgets/structure.py @@ -6,7 +6,7 @@ from PySide6 import QtCore, QtGui, QtWidgets -from ..constants import ICON_SIZE_BUTTON, BORDER_COLOR_ROLE +from ..constants import ICON_SIZE_BUTTON, ICON_SIZE_ITEM, BORDER_COLOR_ROLE from ..style import apply_top_tool_bar_style from ..utils import get_icon @@ -31,12 +31,12 @@ def __init__( # Widgets self.icon = QtWidgets.QLabel() - self.icon.setPixmap(icon.pixmap(ICON_SIZE_BUTTON)) + self.icon.setPixmap(icon.pixmap(ICON_SIZE_ITEM)) self.title = QtWidgets.QLabel(title) # Layout inner_layout = QtWidgets.QHBoxLayout() - inner_layout.setContentsMargins(10, 8, 10, 8) + inner_layout.setContentsMargins(4, 5, 4, 5) inner_layout.setSpacing(5) inner_layout.addWidget(self.icon) inner_layout.addWidget(self.title)