diff --git a/src/apps/ocioview/ocioview/README.md b/src/apps/ocioview/README.md similarity index 100% rename from src/apps/ocioview/ocioview/README.md rename to src/apps/ocioview/README.md diff --git a/src/apps/ocioview/ocioview/inspect/curve_inspector.py b/src/apps/ocioview/ocioview/inspect/curve_inspector.py index 41d920e2d..6e5a078d6 100644 --- a/src/apps/ocioview/ocioview/inspect/curve_inspector.py +++ b/src/apps/ocioview/ocioview/inspect/curve_inspector.py @@ -17,11 +17,22 @@ class SampleType(enum.Enum): + """Enum of curve sampling types for representing OCIO transforms.""" + LINEAR = "linear" + """Linear scale.""" + LOG = "log" + """Log scale with a user-defined base.""" class CurveInspector(QtWidgets.QWidget): + """ + Widget for inspecting OCIO transform tone curves, which updates + asynchronously when visible, to reduce unnecessary background + processing. + """ + @classmethod def label(cls) -> str: return "Curve" @@ -43,11 +54,11 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): self.input_range_edit.setToolTip(self.input_range_label.toolTip()) self.input_range_edit.value_changed.connect(self._on_input_range_changed) - self.sample_size_label = get_glyph_icon("ph.line-segments", as_widget=True) - self.sample_size_label.setToolTip("Sample size") - self.sample_size_edit = IntEdit(default=CurveView.SAMPLE_SIZE_DEFAULT) - self.sample_size_edit.setToolTip(self.sample_size_label.toolTip()) - self.sample_size_edit.value_changed.connect(self._on_sample_size_changed) + self.sample_count_label = get_glyph_icon("ph.line-segments", as_widget=True) + self.sample_count_label.setToolTip("Sample count") + self.sample_count_edit = IntEdit(default=CurveView.SAMPLE_COUNT_DEFAULT) + self.sample_count_edit.setToolTip(self.sample_count_label.toolTip()) + self.sample_count_edit.value_changed.connect(self._on_sample_count_changed) self.sample_type_label = get_glyph_icon("mdi6.function-variant", as_widget=True) self.sample_type_label.setToolTip("Sample type") @@ -70,16 +81,16 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): option_layout = QtWidgets.QHBoxLayout() option_layout.addWidget(self.input_range_label) option_layout.addWidget(self.input_range_edit) - option_layout.setStretch(1, 2) - option_layout.addWidget(self.sample_size_label) - option_layout.addWidget(self.sample_size_edit) - option_layout.setStretch(3, 1) + option_layout.setStretch(1, 3) + option_layout.addWidget(self.sample_count_label) + option_layout.addWidget(self.sample_count_edit) + option_layout.setStretch(3, 2) option_layout.addWidget(self.sample_type_label) option_layout.addWidget(self.sample_type_combo) - option_layout.setStretch(5, 1) + option_layout.setStretch(5, 3) option_layout.addWidget(self.log_base_label) option_layout.addWidget(self.log_base_edit) - option_layout.setStretch(7, 1) + option_layout.setStretch(7, 2) layout = QtWidgets.QVBoxLayout() layout.addLayout(option_layout) @@ -88,83 +99,137 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): self.setLayout(layout) def reset(self) -> None: + """Clear rendered curves.""" self.view.reset() @QtCore.Slot(str, float) def _on_input_range_changed(self, label: str, value: float) -> None: + """ + Triggered when the user changes the input range, which defines + the input values to be sampled through the transform. + + :param label: Float edit label + :param value: Float edit value + """ self.view.set_input_range( self.input_range_edit.component_value("min"), self.input_range_edit.component_value("max"), ) @QtCore.Slot(int) - def _on_sample_size_changed(self, sample_size: int) -> None: - if sample_size >= CurveView.SAMPLE_SIZE_MIN: - self.view.set_sample_size(sample_size) + def _on_sample_count_changed(self, sample_count: int) -> None: + """ + Triggered when the user changes the number of samples to be + processed within the input range through the transform. + + :param sample_count: Number of samples. Typically, a power of 2 + number. + """ + if sample_count >= CurveView.SAMPLE_COUNT_MIN: + self.view.set_sample_count(sample_count) else: - with SignalsBlocked(self.sample_size_edit): - self.sample_size_edit.set_value(CurveView.SAMPLE_SIZE_MIN) - self.view.set_sample_size(CurveView.SAMPLE_SIZE_MIN) + with SignalsBlocked(self.sample_count_edit): + self.sample_count_edit.set_value(CurveView.SAMPLE_COUNT_MIN) + self.view.set_sample_count(CurveView.SAMPLE_COUNT_MIN) @QtCore.Slot(int) def _on_sample_type_changed(self, index: int) -> None: + """ + Triggered when the user changes the sample type, which defines + how samples are distributed within the input range. + + :param index: Curve type index + """ sample_type = self.sample_type_combo.member() self.log_base_edit.setEnabled(sample_type == SampleType.LOG) self.view.set_sample_type(sample_type) @QtCore.Slot(int) def _on_log_base_changed(self, log_base: int) -> None: + """ + Triggered when the user changes the base for the log sample + type. + + :param log_base: Log scale base + """ self.view.set_log_base(log_base) class CurveView(QtWidgets.QGraphicsView): + """ + Widget for rendering OCIO transform tone curves. + """ - SAMPLE_SIZE_MIN = 2**8 - SAMPLE_SIZE_DEFAULT = 2**10 + SAMPLE_COUNT_MIN = 2**8 + SAMPLE_COUNT_DEFAULT = 2**10 INPUT_MIN_DEFAULT = 0.0 INPUT_MAX_DEFAULT = 1.0 + LOG_BASE_DEFAULT = 2 CURVE_SCALE = 100 FONT_HEIGHT = 4 # The curve viewer only shows 5 digit decimal precision, so this should work fine # as a minimum when input min is 0. - LOG_EPSILON = 1e-5 - LOG_BASE_DEFAULT = 2 + EPSILON = 1e-5 def __init__( self, input_min: float = INPUT_MIN_DEFAULT, input_max: float = INPUT_MAX_DEFAULT, - sample_size: int = SAMPLE_SIZE_DEFAULT, + sample_count: int = SAMPLE_COUNT_DEFAULT, sample_type: SampleType = SampleType.LINEAR, log_base: int = LOG_BASE_DEFAULT, parent: Optional[QtWidgets.QWidget] = None, ): + """ + :param input_min: Input range minimum value + :param input_max: Input range maximum value + :param sample_count: Number of samples in the input range, + which will typically be a power of 2 value. + :param sample_type: Sample scale/distribution type + :param log_base: Log scale base when sample_type is + `SampleType.LOG`. + """ super().__init__(parent=parent) self.setRenderHint(QtGui.QPainter.Antialiasing, True) self.setViewportUpdateMode(QtWidgets.QGraphicsView.FullViewportUpdate) self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setMouseTracking(True) - self.scale(1, -1) + self.scale(1, -1) # Flip to make origin the bottom-left corner + # Input range if input_max <= input_min: input_min = self.INPUT_MIN_DEFAULT input_max = self.INPUT_MAX_DEFAULT - self._log_base = max(2, log_base) self._input_min = input_min self._input_max = input_max - self._sample_size = max(self.SAMPLE_SIZE_MIN, sample_size) + + # Sample characteristics + self._sample_count = max(self.SAMPLE_COUNT_MIN, sample_count) self._sample_type = sample_type + self._log_base = max(2, log_base) + + # Cached sample data self._sample_ellipse: Optional[QtWidgets.QGraphicsEllipseItem] = None self._sample_text: Optional[QtWidgets.QGraphicsTextItem] = None self._sample_rect: Optional[QtCore.QRectF] = None - self._samples_x_lin: np.ndarray = None - self._samples_x_log: np.ndarray = None - self._sample_y_min: float = self._input_min - self._sample_y_max: float = self._input_max + self._samples: dict[str, np.ndarray] = {} + self._nearest_samples: dict[str, tuple[float, float, float]] = {} + + self._x_lin: np.ndarray = np.array([], dtype=np.float32) + self._x_log: np.ndarray = np.array([], dtype=np.float32) + self._x_min: float = self._input_min + self._x_max: float = self._input_max + self._y_min: float = self._input_min + self._y_max: float = self._input_max + + # Cached curve data + self._curve_init = False + self._curve_tf = QtGui.QTransform() + self._curve_tf_inv = QtGui.QTransform() self._curve_paths: dict[str, QtGui.QPainterPath] = {} self._curve_items: dict[str, QtWidgets.QGraphicsPathItem] = {} self._curve_rect = QtCore.QRectF( @@ -173,10 +238,11 @@ def __init__( self._input_max - self._input_min, self._input_max - self._input_min, ) - self._nearest_samples: dict[str, tuple[float, float, float]] = {} + + # Cached processor from which the OCIO transform is derived self._prev_cpu_proc = None - self._curve_init = False + # Graphics scene self._scene = QtWidgets.QGraphicsScene() self.setScene(self._scene) @@ -200,8 +266,8 @@ def hideEvent(self, event: QtGui.QHideEvent) -> None: msg_router.set_cpu_processor_updates_allowed(False) def resizeEvent(self, event: QtGui.QResizeEvent) -> None: + """Re-fit graph on resize, to always be centered.""" super().resizeEvent(event) - self._fit() def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: @@ -213,37 +279,52 @@ def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: self._nearest_samples.clear() - pos = self.mapToScene(event.pos()) / self.CURVE_SCALE + pos = self._curve_tf_inv.map(self.mapToScene(event.pos())) pos_arr = np.array([pos.x(), pos.y()], dtype=np.float32) for color_name, samples in self._samples.items(): - all_dist = np.linalg.norm(samples - pos_arr, axis=2) + all_dist = np.linalg.norm(samples - pos_arr, axis=1) nearest_dist_index = np.argmin(all_dist) self._nearest_samples[color_name] = ( ( - self._samples_x_lin[nearest_dist_index] + self._x_lin[nearest_dist_index] if self._sample_type == SampleType.LINEAR - else self._samples_x_log[nearest_dist_index] + else self._x_log[nearest_dist_index] ), - samples[0][nearest_dist_index][0], - samples[0][nearest_dist_index][1], + samples[nearest_dist_index][0], + samples[nearest_dist_index][1], ) self._invalidate() + def wheelEvent(self, event: QtGui.QWheelEvent) -> None: + """ + Ignore wheel events to prevent scrolling around graphics scene. + """ + event.ignore() + def reset(self) -> None: """Clear all curves.""" self._samples.clear() - self._curve_items.clear() - self._curve_paths.clear() self._nearest_samples.clear() - self._prev_cpu_proc = None + self._curve_init = False + self._curve_paths.clear() + self._curve_items.clear() + + self._prev_cpu_proc = None self._scene.clear() self._invalidate() def set_input_range(self, input_min: float, input_max: float) -> None: + """ + Set the input range, which defines the input values to be + sampled through the transform. + + :param input_min: Input range minimum value + :param input_max: Input range maximum value + """ if ( input_min != self._input_min or input_max != self._input_max ) and input_max > input_min: @@ -252,31 +333,62 @@ def set_input_range(self, input_min: float, input_max: float) -> None: self._update_curves() def set_input_min(self, input_min: float) -> None: + """ + Set the input minimum, which defines the lowest value to be + sampled through the transform. + + :param input_min: Input range minimum value + """ if input_min != self._input_min and input_min < self._input_max: self._input_min = input_min self._update_curves() def set_input_max(self, input_max: float) -> None: + """ + Set the input maximum, which defines the highest value to be + sampled through the transform. + + :param input_max: Input range minimum value + """ if input_max != self._input_max and input_max > self._input_min: self._input_max = input_max self._update_curves() - def set_sample_size(self, sample_size: int) -> None: - if sample_size != self._sample_size and sample_size >= self.SAMPLE_SIZE_MIN: - self._sample_size = sample_size + def set_sample_count(self, sample_count: int) -> None: + """ + Set the number of samples to be processed within the input + range through the transform. + + :param sample_count: Number of samples. Typically, a power of 2 + number. + """ + if sample_count != self._sample_count and sample_count >= self.SAMPLE_COUNT_MIN: + self._sample_count = sample_count self._update_curves() def set_sample_type(self, sample_type: SampleType) -> None: + """ + Set the sample type, which defines how samples are distributed + within the input range. + + :param sample_type: Curve type index + """ if sample_type != self._sample_type: self._sample_type = sample_type self._update_curves() def set_log_base(self, log_base: int) -> None: + """ + Set the base for the log sample type. + + :param log_base: Log scale base + """ if log_base != self._log_base and log_base >= 2: self._log_base = log_base self._update_curves() def drawBackground(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None: + """Draw curve grid and axis values.""" # Flood fill background painter.setPen(QtCore.Qt.NoPen) painter.setBrush(QtCore.Qt.black) @@ -285,101 +397,41 @@ def drawBackground(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None: if not self._curve_init: return - # Calculate grid rect - curve_t, curve_b, curve_l, curve_r = ( - self._curve_rect.top(), - self._curve_rect.bottom(), - self._curve_rect.left(), - self._curve_rect.right(), - ) - - # Round to nearest 10 for grid bounds - grid_t = curve_t // 10 * 10 - if grid_t > math.ceil(curve_t): - grid_t -= 10 - grid_b = curve_b // 10 * 10 - if grid_b < math.floor(curve_b): - grid_b += 10 - grid_l = curve_l // 10 * 10 - if grid_l > math.ceil(curve_l): - grid_l -= 10 - grid_r = curve_r // 10 * 10 - if grid_r < math.floor(curve_r): - grid_r += 10 + font = painter.font() + font.setPixelSize(self.FONT_HEIGHT) + painter.setFont(font) text_pen = QtGui.QPen(GRAY_COLOR) - grid_pen = QtGui.QPen(GRAY_COLOR.darker(200)) grid_pen.setWidthF(0) - font = painter.font() - font.setPixelSize(self.FONT_HEIGHT) - + painter.setPen(grid_pen) painter.setBrush(QtCore.Qt.NoBrush) - painter.setFont(font) - - # Calculate samples to display - sample_step = math.ceil(self._sample_size / 10.0) - min_x = max_x = min_y = max_y = None - - if self._sample_type == SampleType.LINEAR: - sample_x_values = self._samples_x_lin - else: # SampleType.LOG - sample_x_values = self._samples_x_log - - sample_y_data = [] - for i, sample_y in enumerate( - np.linspace(self._sample_y_min, self._sample_y_max, 11, dtype=np.float32) - ): - pos_y = sample_y * self.CURVE_SCALE - sample_y_data.append((pos_y, sample_y)) - - if min_y is None or pos_y < min_y: - min_y = pos_y - if max_y is None or pos_y > max_y: - max_y = pos_y - - sample_x_data = [] - for i, sample_x in enumerate(sample_x_values): - if not (i % sample_step == 0 or i == self._sample_size - 1): - continue - - pos_x = self._samples_x_lin[i] * self.CURVE_SCALE - sample_x_data.append((pos_x, sample_x)) - - if min_x is None or pos_x < min_x: - min_x = pos_x - if max_x is None or pos_x > max_x: - max_x = pos_x - if min_x is None: - min_x = grid_l - if max_x is None: - max_x = grid_r - if min_y is None: - min_y = grid_t - if max_x is None: - max_y = grid_b - - self._sample_rect = QtCore.QRectF( - max_x + 5, min_y, 40, len(self._curve_items) * 20 - ) + # Draw grid border + painter.drawRect(self._curve_rect) # Draw grid rows y_text_origin = QtGui.QTextOption(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) y_text_origin.setWrapMode(QtGui.QTextOption.NoWrap) - for pos_y, sample_y in sample_y_data: - painter.setPen(grid_pen) - painter.drawLine(QtCore.QLineF(min_x, pos_y, max_x, pos_y)) + for i, y in enumerate( + np.linspace(self._y_min, self._y_max, 11, dtype=np.float32) + ): + p1 = self._curve_tf.map(QtCore.QPointF(self._x_min, y)) + p2 = self._curve_tf.map(QtCore.QPointF(self._x_max, y)) + + if self._y_min < y < self._y_max: + painter.setPen(grid_pen) + painter.drawLine(QtCore.QLineF(p1, p2)) - if pos_y > grid_t: - label_value = round(sample_y, 2) + if y > self._y_min: + label_value = round(y, 2) if label_value == 0.0: label_value = abs(label_value) painter.save() - painter.translate(QtCore.QPointF(min_x, pos_y)) + painter.translate(p1) painter.scale(1, -1) painter.setPen(text_pen) painter.drawText( @@ -391,28 +443,39 @@ def drawBackground(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None: x_text_origin = QtGui.QTextOption(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter) x_text_origin.setWrapMode(QtGui.QTextOption.NoWrap) - for pos_x, sample_x in sample_x_data: - painter.setPen(grid_pen) - painter.drawLine(QtCore.QLineF(pos_x, min_y, pos_x, max_y)) + sample_step = math.ceil(self._sample_count / 10.0) + + for i, x in enumerate(self._x_lin): + if not (i % sample_step == 0 or i == self._sample_count - 1): + continue + + p1 = self._curve_tf.map(QtCore.QPointF(x, self._y_min)) + p2 = self._curve_tf.map(QtCore.QPointF(x, self._y_max)) - if pos_x > grid_l: + if self._x_min < x < self._x_max: + painter.setPen(grid_pen) + painter.drawLine(QtCore.QLineF(p1, p2)) + + if x > self._x_min: label_value = round( - sample_x, 5 if self._sample_type == SampleType.LOG else 2 + x if self._sample_type == SampleType.LINEAR else self._x_log[i], + 2 if self._sample_type == SampleType.LINEAR else 5, ) if label_value == 0.0: label_value = abs(label_value) painter.save() - painter.translate(QtCore.QPointF(pos_x, min_y)) + painter.translate(p1) painter.scale(1, -1) painter.rotate(90) painter.setPen(text_pen) painter.drawText( - QtCore.QRect(2.5 + 1, -10, 40, 20), str(label_value), x_text_origin + QtCore.QRectF(2.5 + 1, -10, 40, 20), str(label_value), x_text_origin ) painter.restore() def drawForeground(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None: + """Draw nearest sample point and coordinates.""" if not self._curve_init: return @@ -436,16 +499,18 @@ def drawForeground(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None: painter.setBrush(QtGui.QColor(color_name)) painter.drawEllipse( - QtCore.QPointF( - nearest_sample[1] * self.CURVE_SCALE, - nearest_sample[2] * self.CURVE_SCALE, + self._curve_tf.map( + QtCore.QPointF( + nearest_sample[1], + nearest_sample[2], + ) ), - 1.25, - 1.25, + 1.5, + 1.5, ) + # Draw sample values if sample_l is not None: - # Draw sample values painter.setBrush(QtCore.Qt.NoBrush) x_label_value = f"{nearest_sample[0]:.05f}" @@ -464,24 +529,53 @@ def drawForeground(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None: painter.setPen(palette.color(palette.Text)) else: painter.setPen(QtGui.QColor(color_name)) + painter.drawText( QtCore.QRectF(5, -20, 35, 10), x_label_value, text_origin ) painter.drawText( QtCore.QRectF(5, -10, 35, 10), y_label_value, text_origin ) - painter.restore() + def _invalidate(self) -> None: + """Force repaint of visible region of graphics scene.""" + self._scene.invalidate(QtCore.QRectF(self.visibleRegion().boundingRect())) + def _update_curves(self) -> None: + """ + Update X-axis samples and rebuild curves from any cached + processor. + """ self._update_x_samples() if self._prev_cpu_proc is not None: self._on_cpu_processor_ready(self._prev_cpu_proc) + def _update_x_samples(self): + """ + Update linear and log X-axis samples from input and sample + parameters. + """ + self._x_lin = np.linspace( + self._input_min, self._input_max, self._sample_count, dtype=np.float32 + ) + + log_min = math.log(max(self.EPSILON, self._input_min)) + log_max = max(log_min + 0.00001, math.log(self._input_max, self._log_base)) + self._x_log = np.logspace( + log_min, log_max, self._sample_count, base=self._log_base, dtype=np.float32 + ) + + self._x_min = self._x_lin.min() + self._x_max = self._x_lin.max() + def _fit(self) -> None: + """Fit and center graph.""" if not self._curve_init: return + # Use font metrics to calculate text padding, based on estimated maximum + # number lengths. font = self.font() font.setPixelSize(self.FONT_HEIGHT) fm = QtGui.QFontMetrics(font) @@ -503,29 +597,15 @@ def _fit(self) -> None: fit_rect = self._curve_rect.adjusted(-pad_l, -pad_t, pad_r, pad_b) + # Fit and center on calculated rectangle self.fitInView(fit_rect, QtCore.Qt.KeepAspectRatio) self.centerOn(fit_rect.center()) self.update() - def _invalidate(self) -> None: - self._scene.invalidate(QtCore.QRectF(self.visibleRegion().boundingRect())) - - def _update_x_samples(self): - self._samples_x_lin = np.linspace( - self._input_min, self._input_max, self._sample_size, dtype=np.float32 - ) - self._samples_x_log = np.logspace( - math.log(max(self.LOG_EPSILON, self._input_min)), - math.log(max(self.LOG_EPSILON, self._input_max)), - self._sample_size, - base=self._log_base, - dtype=np.float32, - ) - @QtCore.Slot(ocio.CPUProcessor) def _on_cpu_processor_ready(self, cpu_proc: ocio.CPUProcessor) -> None: """ - Update curves from OCIO CPU processor. + Update curves from sampled OCIO CPU processor. :param cpu_proc: CPU processor of currently viewed transform """ @@ -535,12 +615,12 @@ def _on_cpu_processor_ready(self, cpu_proc: ocio.CPUProcessor) -> None: # Get input samples if self._sample_type == SampleType.LOG: - samples = self._samples_x_log + x_samples = self._x_log else: # LINEAR - samples = self._samples_x_lin + x_samples = self._x_lin # Interleave samples per channel - rgb_samples = np.repeat(samples, 3) + rgb_samples = np.repeat(x_samples, 3) # Apply processor to samples cpu_proc.applyRGB(rgb_samples) @@ -550,28 +630,21 @@ def _on_cpu_processor_ready(self, cpu_proc: ocio.CPUProcessor) -> None: g_samples = rgb_samples[1::3] b_samples = rgb_samples[2::3] - # Build painter path from sample data - if np.array_equal(r_samples, g_samples) and np.array_equal( - r_samples, b_samples + # Collect sample pairs and min/max Y sample values + if np.allclose(r_samples, g_samples, atol=self.EPSILON) and np.allclose( + r_samples, b_samples, atol=self.EPSILON ): palette = self.palette() color_name = palette.color(palette.Text).name() - self._samples[color_name] = np.dstack((self._samples_x_lin, r_samples)) - self._sample_y_min = r_samples.min() - self._sample_y_max = r_samples.max() + self._samples[color_name] = np.stack((self._x_lin, r_samples), axis=-1) - curve = QtGui.QPainterPath( - QtCore.QPointF(self._samples_x_lin[0], r_samples[0]) - ) - curve.reserve(samples.size) - for i in range(1, samples.size): - curve.lineTo(QtCore.QPointF(self._samples_x_lin[i], r_samples[i])) - self._curve_paths[color_name] = curve + self._y_min = r_samples.min() + self._y_max = r_samples.max() else: - sample_y_min = None - sample_y_max = None + y_min = None + y_max = None for i, (color, channel_samples) in enumerate( [ @@ -582,43 +655,71 @@ def _on_cpu_processor_ready(self, cpu_proc: ocio.CPUProcessor) -> None: ): color_name = color.name() - self._samples[color_name] = np.dstack( - (self._samples_x_lin, channel_samples) + self._samples[color_name] = np.stack( + (self._x_lin, channel_samples), axis=-1 ) - channel_sample_y_min = channel_samples.min() - if sample_y_min is None or channel_sample_y_min < sample_y_min: - sample_y_min = channel_sample_y_min - channel_sample_y_max = channel_samples.max() - if sample_y_max is None or channel_sample_y_max > sample_y_max: - sample_y_max = channel_sample_y_max + channel_y_min = channel_samples.min() + if y_min is None or channel_y_min < y_min: + y_min = channel_y_min + channel_y_max = channel_samples.max() + if y_max is None or channel_y_max > y_max: + y_max = channel_y_max + + self._y_min = y_min + self._y_max = y_max + + # Transform to scale/translate curve so that it fits in a square and + # has its origin at (0, 0). + curve_tf = QtGui.QTransform() + curve_tf.translate(-self._x_min, -self._y_min) + curve_tf.scale( + self.CURVE_SCALE / (self._x_max - self._x_min), + self.CURVE_SCALE / (self._y_max - self._y_min), + ) + self._curve_tf = curve_tf + self._curve_tf_inv, ok = curve_tf.inverted() + + # Curve square + self._curve_rect = QtCore.QRectF( + self._curve_tf.map(QtCore.QPointF(self._x_min, self._y_min)), + self._curve_tf.map(QtCore.QPointF(self._x_max, self._y_max)), + ) + + # Sample box rect + self._sample_rect = QtCore.QRectF( + self._curve_rect.right() + 5, + self._curve_rect.top(), + 40, + len(self._samples) * 20, + ) - curve = QtGui.QPainterPath( - QtCore.QPointF(self._samples_x_lin[0], channel_samples[0]) + # Build painter paths that fit in square from sample data + for color_name, channel_samples in self._samples.items(): + curve = QtGui.QPainterPath( + self._curve_tf.map( + QtCore.QPointF(channel_samples[0][0], channel_samples[0][1]) ) - curve.reserve(samples.size) - for j in range(1, samples.size): - curve.lineTo( - QtCore.QPointF(self._samples_x_lin[j], channel_samples[j]) + ) + curve.reserve(channel_samples.shape[0]) + for i in range(1, channel_samples.shape[0]): + curve.lineTo( + self._curve_tf.map( + QtCore.QPointF(channel_samples[i][0], channel_samples[i][1]) ) - self._curve_paths[color_name] = curve - - self._sample_y_min = sample_y_min - self._sample_y_max = sample_y_max + ) + self._curve_paths[color_name] = curve # Add curve(s) to scene - self._curve_rect = QtCore.QRectF() for color_name, curve in self._curve_paths.items(): pen = QtGui.QPen() pen.setColor(QtGui.QColor(color_name)) - pen.setWidthF(0) + pen.setWidthF(0.5) curve_item = self._scene.addPath( curve, pen, QtGui.QBrush(QtCore.Qt.NoBrush) ) - curve_item.setScale(self.CURVE_SCALE) self._curve_items[color_name] = curve_item - self._curve_rect = self._curve_rect.united(curve_item.sceneBoundingRect()) self._curve_init = True diff --git a/src/apps/ocioview/ocioview/transform_manager.py b/src/apps/ocioview/ocioview/transform_manager.py index 77d9e96c2..fb1078b10 100644 --- a/src/apps/ocioview/ocioview/transform_manager.py +++ b/src/apps/ocioview/ocioview/transform_manager.py @@ -99,12 +99,15 @@ def set_subscription( tf_agent.item_tf_changed.connect(partial(cls._on_item_tf_changed, slot)) cls._tf_subscriptions[slot] = tf_subscription - # Trigger immediate update to subscribers + # Inform menu subscribers of the menu change cls._update_menu_items() - cls._on_item_tf_changed( - slot, - *item_model.get_item_transforms(item_name), - ) + + # Inform init subscribers of the new subscription + for init_callback in cls._tf_subscribers.get(-1, []): + init_callback(slot) + + # Trigger immediate update to subscribers of this slot + cls._on_item_tf_changed(slot, *item_model.get_item_transforms(item_name)) # Repaint views for previous and new model if prev_item_model is not None: @@ -206,10 +209,27 @@ def subscribe_to_transform_menu(cls, menu_callback: Callable) -> None: menu_callback(cls.get_subscription_menu_items()) @classmethod - def subscribe_to_transforms(cls, slot: int, tf_callback: Callable) -> None: + def subscribe_to_transform_subscription_init(cls, init_callback: Callable) -> None: + """ + Subscribe to transform subscription initialization on all slots. + + :param init_callback: Transform subscription initialization + callback, which will be called whenever a new transform + subscription is initialized with the subscription slot + number. + """ + cls._tf_subscribers[-1].append(init_callback) + + # Trigger immediate update to init subscriber if a transform subscription + # exists. + for slot, tf_subscription in cls._tf_subscriptions.items(): + init_callback(slot) + break + + @classmethod + def subscribe_to_transforms_at(cls, slot: int, tf_callback: Callable) -> None: """ - Subscribe to transform and item name updates at the given slot - number. + Subscribe to transform updates at the given slot number. :param slot: Subscription slot number :param tf_callback: Transform callback, which will be called diff --git a/src/apps/ocioview/ocioview/viewer/image_viewer.py b/src/apps/ocioview/ocioview/viewer/image_viewer.py index 25e277c09..f687e7b13 100644 --- a/src/apps/ocioview/ocioview/viewer/image_viewer.py +++ b/src/apps/ocioview/ocioview/viewer/image_viewer.py @@ -266,6 +266,9 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): # Initialize TransformManager.subscribe_to_transform_menu(self._on_transform_menu_changed) + TransformManager.subscribe_to_transform_subscription_init( + self._on_transform_subscription_init + ) self.update() 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) @@ -515,6 +518,19 @@ def _on_transform_menu_changed( # Force update transform self._on_transform_changed(0) + def _on_transform_subscription_init(self, slot: int) -> None: + """ + If this viewer is not subscribed to a specific transform + subscription slot, subscribe to the first slot to receive a + transform subscription. + + :param slot: Transform subscription slot + """ + if self._tf_subscription_slot == -1: + index = self.tf_box.findData(slot) + if index != -1: + self.tf_box.setCurrentIndex(index) + def _float_to_uint8(self, value: float) -> int: """ :param value: Float value @@ -529,7 +545,7 @@ def _on_transform_changed(self, index: int) -> None: self.clear_transform() else: self._tf_subscription_slot = self.tf_box.currentData() - TransformManager.subscribe_to_transforms( + TransformManager.subscribe_to_transforms_at( self._tf_subscription_slot, self.set_transform )