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.
- vsview_histogram/__init__.py +15 -0
- vsview_histogram/cie/__init__.py +3 -0
- vsview_histogram/cie/ui.py +525 -0
- vsview_histogram/levels/__init__.py +3 -0
- vsview_histogram/levels/charts.py +331 -0
- vsview_histogram/levels/ui.py +91 -0
- vsview_histogram/luma/__init__.py +5 -0
- vsview_histogram/luma/numba_backend.py +71 -0
- vsview_histogram/luma/ui.py +86 -0
- vsview_histogram/plugin.py +617 -0
- vsview_histogram/py.typed +0 -0
- vsview_histogram/settings.py +50 -0
- vsview_histogram/utils.py +78 -0
- vsview_histogram/vectorscope/__init__.py +3 -0
- vsview_histogram/vectorscope/ui.py +400 -0
- vsview_histogram/waveform/__init__.py +3 -0
- vsview_histogram/waveform/ui.py +291 -0
- vsview_histogram-0.1.0.dist-info/METADATA +32 -0
- vsview_histogram-0.1.0.dist-info/RECORD +23 -0
- vsview_histogram-0.1.0.dist-info/WHEEL +4 -0
- vsview_histogram-0.1.0.dist-info/entry_points.txt +2 -0
- vsview_histogram-0.1.0.dist-info/licenses/LICENSE +21 -0
- vsview_histogram-0.1.0.dist-info/sboms/sbom.cdx.json +433 -0
|
@@ -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,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
|