vsview-histogram 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ from typing import Any
2
+
3
+ from vsview.api import WidgetPluginBase, hookimpl
4
+
5
+ from .plugin import HistogramPlugin
6
+
7
+
8
+ @hookimpl
9
+ def vsview_register_toolpanel() -> type[WidgetPluginBase[Any, Any]]:
10
+ return HistogramPlugin
11
+
12
+
13
+ @hookimpl
14
+ def vsview_register_tooldock() -> type[WidgetPluginBase[Any, Any]]:
15
+ return HistogramPlugin
@@ -0,0 +1,3 @@
1
+ from .ui import CIEDiagramContainerWidget
2
+
3
+ __all__ = ["CIEDiagramContainerWidget"]
@@ -0,0 +1,525 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+ from functools import cache
5
+ from logging import getLogger
6
+ from typing import Literal, NamedTuple, Self, override
7
+
8
+ import numpy as np
9
+ import vapoursynth as vs
10
+ from jetpytools import cachedproperty
11
+ from PySide6.QtCore import QPointF, QRect, Qt
12
+ from PySide6.QtGui import QColor, QContextMenuEvent, QImage, QPainter, QPainterPath, QPaintEvent, QPen, QPolygonF
13
+ from PySide6.QtWidgets import QFrame, QVBoxLayout, QWidget
14
+
15
+ from vsview.api import PluginAPI, PluginSettings
16
+
17
+ from ..settings import GlobalSettings
18
+ from ..utils import CustomContextMenu, write_to_qimage
19
+
20
+ logger = getLogger(__name__)
21
+
22
+ MAX_VAL_X = 0.85
23
+ MAX_VAL_Y = 0.85
24
+
25
+ SPECTRAL_LOCUS_XY = np.array(
26
+ [
27
+ [0.1741, 0.0050], # 380nm
28
+ [0.1740, 0.0050], # 385nm
29
+ [0.1738, 0.0049], # 390nm
30
+ [0.1736, 0.0049], # 395nm
31
+ [0.1733, 0.0048], # 400nm
32
+ [0.1730, 0.0048], # 405nm
33
+ [0.1726, 0.0048], # 410nm
34
+ [0.1721, 0.0048], # 415nm
35
+ [0.1714, 0.0051], # 420nm
36
+ [0.1703, 0.0058], # 425nm
37
+ [0.1689, 0.0069], # 430nm
38
+ [0.1669, 0.0086], # 435nm
39
+ [0.1644, 0.0109], # 440nm
40
+ [0.1611, 0.0138], # 445nm
41
+ [0.1566, 0.0177], # 450nm
42
+ [0.1510, 0.0227], # 455nm
43
+ [0.1440, 0.0297], # 460nm
44
+ [0.1355, 0.0399], # 465nm
45
+ [0.1241, 0.0578], # 470nm
46
+ [0.1096, 0.0868], # 475nm
47
+ [0.0913, 0.1327], # 480nm
48
+ [0.0687, 0.2007], # 485nm
49
+ [0.0454, 0.2950], # 490nm
50
+ [0.0235, 0.4127], # 495nm
51
+ [0.0082, 0.5384], # 500nm
52
+ [0.0039, 0.6548], # 505nm
53
+ [0.0139, 0.7502], # 510nm
54
+ [0.0389, 0.8120], # 515nm
55
+ [0.0743, 0.8338], # 520nm
56
+ [0.1142, 0.8262], # 525nm
57
+ [0.1547, 0.8059], # 530nm
58
+ [0.1929, 0.7816], # 535nm
59
+ [0.2296, 0.7543], # 540nm
60
+ [0.2658, 0.7243], # 545nm
61
+ [0.3016, 0.6923], # 550nm
62
+ [0.3374, 0.6588], # 555nm
63
+ [0.3731, 0.6245], # 560nm
64
+ [0.4087, 0.5896], # 565nm
65
+ [0.4441, 0.5547], # 570nm
66
+ [0.4788, 0.5202], # 575nm
67
+ [0.5125, 0.4866], # 580nm
68
+ [0.5448, 0.4544], # 585nm
69
+ [0.5752, 0.4242], # 590nm
70
+ [0.6029, 0.3965], # 595nm
71
+ [0.6270, 0.3725], # 600nm
72
+ [0.6482, 0.3514], # 605nm
73
+ [0.6658, 0.3340], # 610nm
74
+ [0.6801, 0.3197], # 615nm
75
+ [0.6915, 0.3083], # 620nm
76
+ [0.7006, 0.2993], # 625nm
77
+ [0.7079, 0.2920], # 630nm
78
+ [0.7140, 0.2859], # 635nm
79
+ [0.7190, 0.2809], # 640nm
80
+ [0.7230, 0.2769], # 645nm
81
+ [0.7260, 0.2740], # 650nm
82
+ [0.7283, 0.2717], # 655nm
83
+ [0.7300, 0.2700], # 660nm
84
+ [0.7311, 0.2689], # 665nm
85
+ [0.7320, 0.2680], # 670nm
86
+ [0.7327, 0.2673], # 675nm
87
+ [0.7334, 0.2666], # 680nm
88
+ [0.7340, 0.2660], # 685nm
89
+ [0.7344, 0.2656], # 690nm
90
+ [0.7346, 0.2654], # 695nm
91
+ [0.7347, 0.2653], # 700nm
92
+ ],
93
+ dtype=np.float32,
94
+ )
95
+ """
96
+ Standard CIE 1931 2-degree Standard Observer xy chromaticity coordinates for the spectral locus boundary.
97
+ """
98
+
99
+ XYZ_TO_SRGB_MAT = np.array(
100
+ [
101
+ [3.2404542, -1.5371385, -0.4985314],
102
+ [-0.9692660, 1.8760108, 0.0415560],
103
+ [0.0556434, -0.2040259, 1.0572252],
104
+ ],
105
+ dtype=np.float32,
106
+ )
107
+ """
108
+ http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html
109
+ """
110
+
111
+
112
+ class Point(NamedTuple):
113
+ x: float
114
+ y: float
115
+
116
+
117
+ class GamutRGB(NamedTuple):
118
+ r: Point
119
+ g: Point
120
+ b: Point
121
+
122
+
123
+ class GamutData(NamedTuple):
124
+ rec709: GamutRGB
125
+ rec601: GamutRGB
126
+ dcip3: GamutRGB
127
+ rec2020: GamutRGB
128
+ d65: Point
129
+
130
+
131
+ class Gamut(enum.StrEnum):
132
+ CIE_1931 = (
133
+ "cie1931",
134
+ GamutData(
135
+ rec709=GamutRGB(Point(0.640, 0.330), Point(0.300, 0.600), Point(0.150, 0.060)),
136
+ rec601=GamutRGB(Point(0.630, 0.340), Point(0.310, 0.595), Point(0.155, 0.070)),
137
+ dcip3=GamutRGB(Point(0.680, 0.320), Point(0.2651, 0.690), Point(0.150, 0.060)),
138
+ rec2020=GamutRGB(Point(0.708, 0.292), Point(0.170, 0.797), Point(0.131, 0.046)),
139
+ d65=Point(0.3127, 0.3290),
140
+ ),
141
+ )
142
+ """
143
+ Gamut Primary Vertices in CIE 1931 xy.
144
+
145
+ References:
146
+ - Rec. 709 / sRGB: ITU-R Recommendation BT.709-6 (https://www.itu.int/rec/R-REC-BT.709/en)
147
+ - Rec. 601 / SMPTE 170M: ITU-R Recommendation BT.601 (https://www.itu.int/rec/R-REC-BT.601/en)
148
+ - DCI-P3: SMPTE RP 431-2 / Wikipedia definition (https://en.wikipedia.org/wiki/DCI-P3)
149
+ - Rec. 2020: ITU-R Recommendation BT.2020-2 (https://www.itu.int/rec/R-REC-BT.2020/en)
150
+ - Illuminant D65 (Standard Daylight): CIE 15:2004 (x=0.3127, y=0.3290)
151
+ """
152
+
153
+ CIE_1976 = (
154
+ "cie1976",
155
+ GamutData(
156
+ rec709=GamutRGB(Point(0.4507, 0.5229), Point(0.1250, 0.5625), Point(0.1754, 0.1579)),
157
+ rec601=GamutRGB(Point(0.4330, 0.5258), Point(0.1303, 0.5625), Point(0.1756, 0.1785)),
158
+ dcip3=GamutRGB(Point(0.4964, 0.5255), Point(0.0991, 0.5794), Point(0.1754, 0.1579)),
159
+ rec2020=GamutRGB(Point(0.5584, 0.5202), Point(0.0663, 0.5818), Point(0.1594, 0.1227)),
160
+ d65=Point(0.1978, 0.4683),
161
+ ),
162
+ )
163
+ """
164
+ Gamut Primary Vertices in CIE 1976 u'v' Uniform Chromaticity Scale (UCS) space.
165
+ Calculated from the 1931 xy coordinates using standard conversion.
166
+
167
+ References:
168
+ - https://en.wikipedia.org/wiki/CIE_1976_color_space
169
+ - https://en.wikipedia.org/wiki/CIELUV#The_forward_transformation
170
+ u' = 4x / (-2x + 12y + 3)
171
+ v' = 9y / (-2x + 12y + 3)
172
+ """
173
+
174
+ REC_709 = "rec709", None, QColor(100, 255, 100), "Rec.709"
175
+ REC_601 = "rec601", None, QColor(100, 180, 255), "Rec.601"
176
+ DCI_P3 = "dcip3", None, QColor(255, 200, 50), "DCI-P3"
177
+ REC_2020 = "rec2020", None, QColor(255, 100, 100), "Rec.2020"
178
+
179
+ data: GamutData
180
+ color: QColor
181
+ label: str
182
+
183
+ def __new__(
184
+ cls,
185
+ value: str,
186
+ data: GamutData | None = None,
187
+ color: QColor | None = None,
188
+ label: str | None = None,
189
+ ) -> Self:
190
+ obj = str.__new__(cls, value)
191
+ obj._value_ = value
192
+ if data:
193
+ obj.data = data
194
+ if color:
195
+ obj.color = color
196
+ if label:
197
+ obj.label = label
198
+ return obj
199
+
200
+
201
+ class CIEDiagramWidget(QWidget):
202
+ def __init__(self, parent: QWidget | None, api: PluginAPI, settings: PluginSettings[GlobalSettings, None]) -> None:
203
+ super().__init__(parent)
204
+ self.api = api
205
+ self.settings = settings
206
+ self.setMinimumSize(128, 128)
207
+
208
+ self.scope_image = QImage(128, 128, QImage.Format.Format_RGBA8888)
209
+ self.scope_image.fill(0)
210
+
211
+ self.context_menu = CustomContextMenu(self, self.api)
212
+
213
+ self._error_reason: str | None = None
214
+
215
+ @cachedproperty
216
+ def color_table(self) -> list[int]:
217
+ """Neon cyan/blue phosphor color table"""
218
+ colors = list[int]()
219
+ for i in range(256):
220
+ r = max(0, i - 192) * 4
221
+ g = min(255, int(i * 1.1))
222
+ b = min(255, int(i * 1.3))
223
+ colors.append(QColor(r, g, b).rgba())
224
+ return colors
225
+
226
+ @override
227
+ def contextMenuEvent(self, event: QContextMenuEvent) -> None:
228
+ self.context_menu.exec(event.globalPos())
229
+
230
+ @override
231
+ def paintEvent(self, event: QPaintEvent) -> None:
232
+ painter = QPainter(self)
233
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
234
+ painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
235
+
236
+ painter.fillRect(self.rect(), QColor(20, 20, 20))
237
+
238
+ if self._error_reason:
239
+ painter.setPen(QPen(QColor(220, 80, 80), 1))
240
+ font = painter.font()
241
+ font.setPointSize(20)
242
+ painter.setFont(font)
243
+ painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, self._error_reason)
244
+ return
245
+
246
+ # Draw square CIE diagram centering inside widget bounds
247
+ side = min(self.width(), self.height())
248
+ target_rect = QRect((self.width() - side) // 2, (self.height() - side) // 2, side, side)
249
+
250
+ # Draw pre-rendered outline background horseshoe
251
+ render_mode = self.settings.global_.cie.render_mode
252
+ painter.drawImage(target_rect, get_cached_background(self.settings.global_.cie.mode, render_mode))
253
+
254
+ if render_mode == "chroma_wheel":
255
+ painter.save()
256
+ painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Plus)
257
+ painter.drawImage(target_rect, self.scope_image)
258
+ painter.restore()
259
+ else:
260
+ painter.drawImage(target_rect, self.scope_image)
261
+
262
+ # Draw gamut triangles and labels
263
+ self.draw_graticules(painter, target_rect)
264
+
265
+ def draw_graticules(self, painter: QPainter, rect: QRect) -> None:
266
+ painter.save()
267
+ painter.translate(rect.left(), rect.top())
268
+ painter.scale(rect.width() / 1024.0, rect.height() / 1024.0)
269
+
270
+ gamuts = Gamut(self.settings.global_.cie.mode).data
271
+
272
+ font = painter.font()
273
+ font.setPointSize(16)
274
+ painter.setFont(font)
275
+
276
+ # Draw Axes/Grid Border
277
+ painter.setPen(QPen(QColor(120, 120, 120), 2))
278
+ painter.drawRect(0, 0, 1024, 1024)
279
+
280
+ # Grid coordinates labels
281
+ painter.setPen(QColor(150, 150, 150))
282
+ for val in (val for val in [0.2, 0.4, 0.6, 0.8] if val < MAX_VAL_X):
283
+ pos_x = int((val / MAX_VAL_X) * 1024.0)
284
+ painter.drawLine(pos_x, 1016, pos_x, 1024)
285
+ painter.drawText(pos_x - 20, 1004, f"{val:.1f}")
286
+ for val in (val for val in [0.2, 0.4, 0.6, 0.8] if val < MAX_VAL_Y):
287
+ pos_y = int((1.0 - (val / MAX_VAL_Y)) * 1024.0)
288
+ painter.drawLine(0, pos_y, 8, pos_y)
289
+ painter.drawText(16, pos_y + 8, f"{val:.1f}")
290
+
291
+ # Gamuts to draw
292
+ settings = self.settings.global_.cie
293
+ to_draw = list[Gamut]()
294
+ if settings.show_rec709:
295
+ to_draw.append(Gamut.REC_709)
296
+ if settings.show_rec601:
297
+ to_draw.append(Gamut.REC_601)
298
+ if settings.show_dcip3:
299
+ to_draw.append(Gamut.DCI_P3)
300
+ if settings.show_rec2020:
301
+ to_draw.append(Gamut.REC_2020)
302
+
303
+ # Draw all gamut polygons first
304
+ for draw in to_draw:
305
+ pts: GamutRGB = getattr(gamuts, draw.value)
306
+ poly = QPolygonF()
307
+ # Map points (x, y) -> (x / MAX_VAL_X * 1024, (1 - y / MAX_VAL_Y) * 1024)
308
+ poly.append(QPointF((pts.r.x / MAX_VAL_X) * 1024.0, (1.0 - (pts.r.y / MAX_VAL_Y)) * 1024.0))
309
+ poly.append(QPointF((pts.g.x / MAX_VAL_X) * 1024.0, (1.0 - (pts.g.y / MAX_VAL_Y)) * 1024.0))
310
+ poly.append(QPointF((pts.b.x / MAX_VAL_X) * 1024.0, (1.0 - (pts.b.y / MAX_VAL_Y)) * 1024.0))
311
+
312
+ painter.setPen(QPen(draw.color, 1.5, Qt.PenStyle.SolidLine))
313
+ painter.setBrush(Qt.BrushStyle.NoBrush)
314
+ painter.drawPolygon(poly)
315
+
316
+ # Draw D65 White Point crosshair
317
+ w_pt = QPointF((gamuts.d65.x / MAX_VAL_X) * 1024.0, (1.0 - (gamuts.d65.y / MAX_VAL_Y)) * 1024.0)
318
+ painter.setPen(QPen(QColor(255, 255, 255, 200), 1.5))
319
+ painter.drawLine(int(w_pt.x()) - 10, int(w_pt.y()), int(w_pt.x()) + 10, int(w_pt.y()))
320
+ painter.drawLine(int(w_pt.x()), int(w_pt.y()) - 10, int(w_pt.x()), int(w_pt.y()) + 10)
321
+
322
+ # Draw all labels last so they are above all lines/polygons
323
+ for draw in to_draw:
324
+ pts: GamutRGB = getattr(gamuts, draw.value) # type: ignore[no-redef]
325
+ # Draw labels near green primary vertices
326
+ painter.setPen(draw.color)
327
+ painter.drawText(
328
+ int((pts.g.x / MAX_VAL_X) * 1024.0) - 30, int((1.0 - (pts.g.y / MAX_VAL_Y)) * 1024.0) - 16, draw.label
329
+ )
330
+
331
+ # Draw D65 label
332
+ painter.setPen(QColor(255, 255, 255, 200))
333
+ painter.drawText(int(w_pt.x()) + 12, int(w_pt.y()) + 8, "D65")
334
+
335
+ painter.restore()
336
+
337
+ def update_frame(self, linear_frame: vs.VideoFrame, xyz_frame: vs.VideoFrame) -> None:
338
+ self._error_reason = None
339
+
340
+ size = xyz_frame.height if (res := self.settings.global_.cie.res) == 0 else res
341
+ xyz = np.asarray(xyz_frame)
342
+
343
+ # Apply safety stride for very large resolutions (e.g. 4K 4:4:4)
344
+ _, h, w = xyz.shape
345
+ stride = max(1, int(np.sqrt(total_elements / 2_000_000))) if (total_elements := h * w) > 2_000_000 else 1
346
+ xyz_sliced = xyz[:, ::stride, ::stride].reshape(3, -1)
347
+
348
+ # Calculate target coordinates
349
+ x, y, z = xyz_sliced
350
+ if self.settings.global_.cie.mode == "cie1976":
351
+ denom = (x + 15.0 * y + 3.0 * z).clip(1e-6, None)
352
+ x_coord = 4.0 * x / denom
353
+ y_coord = 9.0 * y / denom
354
+ else: # cie1931
355
+ denom = (x + y + z).clip(1e-6, None)
356
+ x_coord = x / denom
357
+ y_coord = y / denom
358
+
359
+ # Map to size-based pixel grid coordinates
360
+ x_pixel = ((x_coord / MAX_VAL_X) * size).clip(0.0, size - 1.0).astype(np.int32)
361
+ y_pixel = ((1.0 - (y_coord / MAX_VAL_Y)) * size).clip(0.0, size - 1.0).astype(np.int32)
362
+ indices = y_pixel * size + x_pixel
363
+
364
+ # Generate 2D color cloud density counts
365
+ counts = np.bincount(indices, minlength=(size * size))
366
+ counts_2d = counts.reshape((size, size))
367
+
368
+ render_mode = self.settings.global_.cie.render_mode
369
+
370
+ if render_mode == "pixel_color":
371
+ # Extract and normalize original colors in linear space
372
+ lrgb = np.asarray(linear_frame)
373
+ lrgb_sliced = lrgb[:, ::stride, ::stride].reshape(3, -1)
374
+ lrgb_sliced_clipped = lrgb_sliced.clip(0.0, 1.0)
375
+
376
+ # Accumulate linear R, G, B colors of the pixels falling into each bin
377
+ # Multiply by 255.0 to get standard range values
378
+ rgb_sum = np.stack(
379
+ [np.bincount(indices, weights=p * 255.0, minlength=(size * size)) for p in lrgb_sliced_clipped]
380
+ )
381
+ rgb_grid = rgb_sum.reshape((3, size, size))
382
+
383
+ # Normalize to [0, 1], apply Gamma 2.2 correction on the size^2 canvas,
384
+ # and scale to 255.0 (counts cancel out during division)
385
+ max_channel = rgb_grid.max(axis=0)
386
+ max_channel_safe = np.where(max_channel == 0.0, 1.0, max_channel)
387
+ rgb_norm_color = ((rgb_grid / max_channel_safe) ** (1 / 2.2)) * 255.0
388
+
389
+ # Scale pixel brightness by density log-scale
390
+ if (max_count := counts_2d.max()) > 0:
391
+ # density_scale is in range [0.3, 1.0] for populated bins to ensure visibility
392
+ density_scale = (0.3 + 0.7 * (np.log1p(counts_2d) / np.log1p(max_count))) * (counts_2d > 0)
393
+
394
+ rgb_final = (
395
+ (rgb_norm_color * density_scale * self.settings.global_.cie.luma).clip(0, 255).astype(np.uint8)
396
+ )
397
+ alpha = (counts_2d > 0).astype(np.uint8) * np.uint8(255)
398
+
399
+ rgba = np.empty((size, size, 4), dtype=np.uint8)
400
+ rgba[..., :3] = rgb_final.transpose(1, 2, 0)
401
+ rgba[..., 3] = alpha
402
+ else:
403
+ rgba = np.zeros((size, size, 4), dtype=np.uint8)
404
+
405
+ self.scope_image = write_to_qimage(self.scope_image, rgba, QImage.Format.Format_RGBA8888)
406
+
407
+ elif render_mode == "density":
408
+ if (max_count := counts_2d.max()) > 0:
409
+ scale = 255.0 / np.log1p(max_count)
410
+ grid_img = (np.log1p(counts_2d) * scale).astype(np.uint8)
411
+ else:
412
+ grid_img = counts_2d.astype(np.uint8)
413
+
414
+ self.scope_image = write_to_qimage(
415
+ self.scope_image,
416
+ grid_img,
417
+ QImage.Format.Format_Indexed8,
418
+ self.color_table,
419
+ )
420
+
421
+ elif render_mode == "chroma_wheel":
422
+ if (max_count := counts_2d.max()) > 0:
423
+ scale = 255.0 / np.log1p(max_count)
424
+ density_val = np.log1p(counts_2d) * scale
425
+ density_val = (density_val * self.settings.global_.cie.luma).clip(0, 255).astype(np.uint8)
426
+
427
+ rgba = np.empty((size, size, 4), dtype=np.uint8)
428
+ rgba[..., :3] = 255 # White glow
429
+ rgba[..., 3] = density_val
430
+ else:
431
+ rgba = np.zeros((size, size, 4), dtype=np.uint8)
432
+
433
+ self.scope_image = write_to_qimage(self.scope_image, rgba, QImage.Format.Format_RGBA8888)
434
+
435
+ self.update()
436
+
437
+ def paint_error(self, message: str) -> None:
438
+ self._error_reason = message
439
+ logger.warning(message)
440
+ self.update()
441
+
442
+
443
+ class CIEDiagramContainerWidget(QFrame):
444
+ def __init__(self, parent: QWidget, api: PluginAPI, settings: PluginSettings[GlobalSettings, None]) -> None:
445
+ super().__init__(parent)
446
+ self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Sunken)
447
+
448
+ self.current_layout = QVBoxLayout(self)
449
+ self.current_layout.setContentsMargins(0, 0, 0, 0)
450
+
451
+ self.cie_diagram = CIEDiagramWidget(self, api, settings)
452
+ self.current_layout.addWidget(self.cie_diagram)
453
+
454
+ def update_histogram(self, linear_frame: vs.VideoFrame, xyz_frame: vs.VideoFrame) -> None:
455
+ self.cie_diagram.update_frame(linear_frame, xyz_frame)
456
+
457
+
458
+ @cache
459
+ def get_cached_background(
460
+ mode: Literal["cie1931", "cie1976"],
461
+ render_mode: Literal["density", "chroma_wheel", "pixel_color"],
462
+ size: int = 1024,
463
+ ) -> QImage:
464
+ points = list[QPointF]()
465
+ if mode == "cie1976":
466
+ for x, y in SPECTRAL_LOCUS_XY:
467
+ denom_l = -2.0 * x + 12.0 * y + 3.0
468
+ u = 4.0 * x / denom_l
469
+ v = 9.0 * y / denom_l
470
+ points.append(QPointF((u / MAX_VAL_X) * size, (1.0 - (v / MAX_VAL_Y)) * size))
471
+ else:
472
+ for x, y in SPECTRAL_LOCUS_XY:
473
+ points.append(QPointF((x / MAX_VAL_X) * size, (1.0 - (y / MAX_VAL_Y)) * size))
474
+
475
+ bg_img = QImage(size, size, QImage.Format.Format_ARGB32)
476
+ bg_img.fill(Qt.GlobalColor.transparent)
477
+
478
+ with QPainter(bg_img) as painter:
479
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
480
+
481
+ poly = QPolygonF(points)
482
+
483
+ # Draw colored background inside the polygon if chroma_wheel mode is selected
484
+ if render_mode == "chroma_wheel":
485
+ col = np.arange(size, dtype=np.float32)
486
+ row = np.arange(size, dtype=np.float32)
487
+
488
+ if mode == "cie1976":
489
+ u_coord, v_coord = np.meshgrid((col / size) * MAX_VAL_X, (1.0 - (row / size)) * MAX_VAL_Y)
490
+ denom_uv = 6.0 * u_coord - 16.0 * v_coord + 12.0
491
+ denom_uv = np.where(np.abs(denom_uv) < 1e-6, 1e-6, denom_uv)
492
+ x_coord = 9.0 * u_coord / denom_uv
493
+ y_coord = 4.0 * v_coord / denom_uv
494
+ else:
495
+ x_coord, y_coord = np.meshgrid((col / size) * MAX_VAL_X, (1.0 - (row / size)) * MAX_VAL_Y)
496
+
497
+ denom = np.maximum(y_coord, 1e-6)
498
+ x = x_coord / denom
499
+ y = np.ones_like(x)
500
+ z = (1.0 - x_coord - y_coord) / denom
501
+
502
+ xyz = np.dstack([x, y, z])
503
+ rgb_linear = xyz @ XYZ_TO_SRGB_MAT.T
504
+ # Darken the background slightly (scale by 0.25) to make the density cloud pop
505
+ rgb_srgb = np.clip(rgb_linear * 0.25, 0.0, 1.0) ** (1.0 / 2.2)
506
+
507
+ rgba = np.empty((size, size, 4), dtype=np.uint8)
508
+ rgba[..., :3] = (rgb_srgb * 255.0).astype(np.uint8)
509
+ rgba[..., 3] = 255
510
+
511
+ fmt = QImage.Format.Format_RGBX8888
512
+ grad_img = write_to_qimage(QImage(size, size, fmt), rgba, fmt)
513
+
514
+ painter.save()
515
+ path = QPainterPath()
516
+ path.addPolygon(poly)
517
+ painter.setClipPath(path)
518
+ painter.drawImage(0, 0, grad_img)
519
+ painter.restore()
520
+
521
+ # Thin gray boundary outline
522
+ painter.setPen(QPen(QColor(200, 200, 200, 255), 1.5))
523
+ painter.drawPolygon(poly)
524
+
525
+ return bg_img
@@ -0,0 +1,3 @@
1
+ from .ui import HistogramContainerWidget
2
+
3
+ __all__ = ["HistogramContainerWidget"]