Skip to content

Commit f97f922

Browse files
authored
Merge pull request #3055 from alicevision/copilot/add-waveform-display-for-color-analysis
Add Waveform display for image color analysis
2 parents fc36b3b + 4fda714 commit f97f922

3 files changed

Lines changed: 335 additions & 4 deletions

File tree

meshroom/ui/qml/Viewer/HistogramView.qml

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,15 @@ FloatingPane {
109109
}
110110
}
111111

112+
// Activate solo mode for a channel button: turn it on and all others off.
113+
// Ctrl+click on any R/G/B/L button triggers this.
114+
function soloChannel(btn) {
115+
rBtn.checked = (btn === rBtn)
116+
gBtn.checked = (btn === gBtn)
117+
bBtn.checked = (btn === bBtn)
118+
lBtn.checked = (btn === lBtn)
119+
}
120+
112121
ColumnLayout {
113122
anchors.fill: parent
114123
spacing: 2
@@ -131,36 +140,68 @@ FloatingPane {
131140
text: "R"
132141
font.pointSize: 7
133142
padding: 2
134-
checkable: true
143+
checkable: false
135144
checked: true
136145
onCheckedChanged: histCanvas.requestPaint()
146+
TapHandler {
147+
acceptedModifiers: Qt.ControlModifier
148+
onTapped: root.soloChannel(rBtn)
149+
}
150+
TapHandler {
151+
acceptedModifiers: Qt.NoModifier
152+
onTapped: rBtn.checked = !rBtn.checked
153+
}
137154
}
138155
ToolButton {
139156
id: gBtn
140157
text: "G"
141158
font.pointSize: 7
142159
padding: 2
143-
checkable: true
160+
checkable: false
144161
checked: true
145162
onCheckedChanged: histCanvas.requestPaint()
163+
TapHandler {
164+
acceptedModifiers: Qt.ControlModifier
165+
onTapped: root.soloChannel(gBtn)
166+
}
167+
TapHandler {
168+
acceptedModifiers: Qt.NoModifier
169+
onTapped: gBtn.checked = !gBtn.checked
170+
}
146171
}
147172
ToolButton {
148173
id: bBtn
149174
text: "B"
150175
font.pointSize: 7
151176
padding: 2
152-
checkable: true
177+
checkable: false
153178
checked: true
154179
onCheckedChanged: histCanvas.requestPaint()
180+
TapHandler {
181+
acceptedModifiers: Qt.ControlModifier
182+
onTapped: root.soloChannel(bBtn)
183+
}
184+
TapHandler {
185+
acceptedModifiers: Qt.NoModifier
186+
onTapped: bBtn.checked = !bBtn.checked
187+
}
155188
}
156189
ToolButton {
157190
id: lBtn
158191
text: "L"
159192
font.pointSize: 7
160193
padding: 2
161-
checkable: true
194+
checkable: false
162195
checked: false
163196
onCheckedChanged: histCanvas.requestPaint()
197+
TapHandler {
198+
acceptedModifiers: Qt.ControlModifier
199+
onTapped: root.soloChannel(lBtn)
200+
}
201+
TapHandler {
202+
acceptedModifiers: Qt.NoModifier
203+
onTapped: lBtn.checked = !lBtn.checked
204+
}
164205
}
165206
ToolButton {
166207
id: logBtn

meshroom/ui/qml/Viewer/Viewer2D.qml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1227,6 +1227,19 @@ FocusScope {
12271227
floatImageViewer: floatImageViewerLoader.item
12281228
}
12291229

1230+
// Waveform overlay Pane
1231+
Loader {
1232+
id: waveformLoader
1233+
active: displayWaveform.checked
1234+
anchors {
1235+
top: parent.top
1236+
left: parent.left
1237+
}
1238+
sourceComponent: WaveformView {
1239+
floatImageViewer: floatImageViewerLoader.item
1240+
}
1241+
}
1242+
12301243
ColorCheckerPane {
12311244
id: colorCheckerPane
12321245
width: 250
@@ -1618,6 +1631,27 @@ FocusScope {
16181631
}
16191632
}
16201633

1634+
MaterialToolButton {
1635+
id: displayWaveform
1636+
1637+
font.family: MaterialIcons.fontFamily
1638+
text: MaterialIcons.waves
1639+
1640+
ToolTip.text: "Waveform"
1641+
ToolTip.visible: hovered
1642+
1643+
font.pointSize: 14
1644+
padding: 2
1645+
smooth: false
1646+
flat: true
1647+
checkable: true
1648+
enabled: floatImageViewerLoader.item !== null
1649+
onEnabledChanged: {
1650+
if (!enabled)
1651+
checked = false
1652+
}
1653+
}
1654+
16211655
MaterialToolButton {
16221656
id: displayLensDistortionViewer
16231657
property int numberChanges: 0
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import QtQuick
2+
import QtQuick.Controls
3+
import QtQuick.Layouts
4+
5+
import Controls 1.0
6+
7+
/**
8+
* WaveformView displays the waveform (signal level vs. horizontal image position) of
9+
* the current image. Pixel values are sampled from the FloatImageViewer via
10+
* pixelValueAt() and plotted at their horizontal image position against their
11+
* per-channel intensity on a Canvas. The result mirrors the layout of a classical
12+
* broadcast waveform monitor: the X axis represents image columns (left → right) and
13+
* the Y axis represents pixel intensity (bottom = 0, top = 1).
14+
*/
15+
16+
FloatingPane {
17+
id: root
18+
19+
// The AliceVision FloatImageViewer item to sample pixels from
20+
property var floatImageViewer: null
21+
22+
width: 280
23+
height: 160
24+
clip: true
25+
padding: 4
26+
27+
// Prevent mouse/wheel events from passing through to the image below
28+
MouseArea {
29+
anchors.fill: parent
30+
acceptedButtons: Qt.AllButtons
31+
onWheel: function(wheel) { wheel.accepted = true }
32+
}
33+
34+
// Sampled pixel data: each entry has { xRatio, r, g, b, lum }
35+
property var _waveData: []
36+
property bool _dataReady: false
37+
38+
function computeWaveform() {
39+
_dataReady = false
40+
41+
if (!visible) return
42+
if (!floatImageViewer) return
43+
if (floatImageViewer.imageStatus !== Image.Ready) return
44+
45+
var imgW = floatImageViewer.sourceSize.width
46+
var imgH = floatImageViewer.sourceSize.height
47+
if (imgW <= 0 || imgH <= 0) return
48+
49+
var newData = []
50+
51+
// Sample ~5000 pixels across the image.
52+
// Use separate step sizes per axis proportional to image dimensions
53+
// so that sampling remains spatially uniform on images with extreme aspect ratios.
54+
var targetSamples = 5000
55+
var stepsX = Math.max(1, Math.round(Math.sqrt(targetSamples * imgW / imgH)))
56+
var stepsY = Math.max(1, Math.round(Math.sqrt(targetSamples * imgH / imgW)))
57+
var stepX = Math.max(1, Math.floor(imgW / stepsX))
58+
var stepY = Math.max(1, Math.floor(imgH / stepsY))
59+
60+
for (var y = 0; y < imgH; y += stepY) {
61+
for (var x = 0; x < imgW; x += stepX) {
62+
var px = floatImageViewer.pixelValueAt(x, y)
63+
if (!px) continue
64+
65+
var r = px.x
66+
var g = px.y
67+
var b = px.z
68+
// Luminance using standard coefficients (Rec. 709)
69+
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b
70+
71+
newData.push({ xRatio: x / imgW, r: r, g: g, b: b, lum: lum })
72+
}
73+
}
74+
75+
_waveData = newData
76+
_dataReady = true
77+
waveCanvas.requestPaint()
78+
}
79+
80+
// Recompute when the viewer item is replaced
81+
onFloatImageViewerChanged: computeWaveform()
82+
83+
// Recompute when the panel becomes visible (image may already be loaded)
84+
onVisibleChanged: {
85+
if (visible)
86+
computeWaveform()
87+
}
88+
89+
// Recompute when the image finishes loading
90+
Connections {
91+
target: root.floatImageViewer
92+
function onImageStatusChanged() {
93+
if (root.floatImageViewer && root.floatImageViewer.imageStatus === Image.Ready)
94+
root.computeWaveform()
95+
}
96+
}
97+
98+
// Activate solo mode for a channel button: turn it on and all others off.
99+
// Ctrl+click on any R/G/B/L button triggers this.
100+
function soloChannel(btn) {
101+
rBtn.checked = (btn === rBtn)
102+
gBtn.checked = (btn === gBtn)
103+
bBtn.checked = (btn === bBtn)
104+
lBtn.checked = (btn === lBtn)
105+
}
106+
107+
ColumnLayout {
108+
anchors.fill: parent
109+
spacing: 2
110+
111+
// Header row: label and per-channel toggle buttons
112+
RowLayout {
113+
spacing: 0
114+
115+
Label {
116+
text: "Waveform"
117+
font.bold: true
118+
font.pointSize: 8
119+
leftPadding: 2
120+
}
121+
122+
Item { Layout.fillWidth: true }
123+
124+
ToolButton {
125+
id: rBtn
126+
text: "R"
127+
font.pointSize: 7
128+
padding: 2
129+
checkable: false
130+
checked: true
131+
onCheckedChanged: waveCanvas.requestPaint()
132+
TapHandler {
133+
acceptedModifiers: Qt.ControlModifier
134+
onTapped: root.soloChannel(rBtn)
135+
}
136+
TapHandler {
137+
acceptedModifiers: Qt.NoModifier
138+
onTapped: rBtn.checked = !rBtn.checked
139+
}
140+
}
141+
ToolButton {
142+
id: gBtn
143+
text: "G"
144+
font.pointSize: 7
145+
padding: 2
146+
checkable: false
147+
checked: true
148+
onCheckedChanged: waveCanvas.requestPaint()
149+
TapHandler {
150+
acceptedModifiers: Qt.ControlModifier
151+
onTapped: root.soloChannel(gBtn)
152+
}
153+
TapHandler {
154+
acceptedModifiers: Qt.NoModifier
155+
onTapped: gBtn.checked = !gBtn.checked
156+
}
157+
}
158+
ToolButton {
159+
id: bBtn
160+
text: "B"
161+
font.pointSize: 7
162+
padding: 2
163+
checkable: false
164+
checked: true
165+
onCheckedChanged: waveCanvas.requestPaint()
166+
TapHandler {
167+
acceptedModifiers: Qt.ControlModifier
168+
onTapped: root.soloChannel(bBtn)
169+
}
170+
TapHandler {
171+
acceptedModifiers: Qt.NoModifier
172+
onTapped: bBtn.checked = !bBtn.checked
173+
}
174+
}
175+
ToolButton {
176+
id: lBtn
177+
text: "L"
178+
font.pointSize: 7
179+
padding: 2
180+
checkable: false
181+
checked: false
182+
onCheckedChanged: waveCanvas.requestPaint()
183+
TapHandler {
184+
acceptedModifiers: Qt.ControlModifier
185+
onTapped: root.soloChannel(lBtn)
186+
}
187+
TapHandler {
188+
acceptedModifiers: Qt.NoModifier
189+
onTapped: lBtn.checked = !lBtn.checked
190+
}
191+
}
192+
}
193+
194+
// Canvas for drawing the waveform
195+
Canvas {
196+
id: waveCanvas
197+
Layout.fillWidth: true
198+
Layout.fillHeight: true
199+
200+
onPaint: {
201+
var ctx = getContext("2d")
202+
var w = width
203+
var h = height
204+
ctx.clearRect(0, 0, w, h)
205+
206+
// Background
207+
ctx.fillStyle = "rgba(20, 20, 20, 0.85)"
208+
ctx.fillRect(0, 0, w, h)
209+
210+
// Graticule: horizontal dashed lines at 25 % / 50 % / 75 % intensity
211+
ctx.strokeStyle = "rgba(80, 80, 80, 0.5)"
212+
ctx.lineWidth = 0.5
213+
ctx.setLineDash([3, 3])
214+
for (var pct of [0.25, 0.5, 0.75]) {
215+
var gy = Math.round(h - pct * h) + 0.5
216+
ctx.beginPath()
217+
ctx.moveTo(0, gy)
218+
ctx.lineTo(w, gy)
219+
ctx.stroke()
220+
}
221+
ctx.setLineDash([])
222+
223+
if (!root._dataReady) return
224+
225+
var data = root._waveData
226+
for (var i = 0; i < data.length; i++) {
227+
var pt = data[i]
228+
var posX = pt.xRatio * w - 0.5
229+
230+
// Draw channels back to front for better blending.
231+
// Y axis: bottom = intensity 0, top = intensity 1
232+
if (lBtn.checked) {
233+
var ly = h - Math.min(1, Math.max(0, pt.lum)) * h - 0.5
234+
ctx.fillStyle = "rgba(220, 220, 220, 0.35)"
235+
ctx.fillRect(posX, ly, 1.5, 1.5)
236+
}
237+
if (bBtn.checked) {
238+
var by = h - Math.min(1, Math.max(0, pt.b)) * h - 0.5
239+
ctx.fillStyle = "rgba(50, 100, 255, 0.4)"
240+
ctx.fillRect(posX, by, 1.5, 1.5)
241+
}
242+
if (gBtn.checked) {
243+
var gy2 = h - Math.min(1, Math.max(0, pt.g)) * h - 0.5
244+
ctx.fillStyle = "rgba(50, 200, 50, 0.4)"
245+
ctx.fillRect(posX, gy2, 1.5, 1.5)
246+
}
247+
if (rBtn.checked) {
248+
var ry = h - Math.min(1, Math.max(0, pt.r)) * h - 0.5
249+
ctx.fillStyle = "rgba(255, 80, 50, 0.4)"
250+
ctx.fillRect(posX, ry, 1.5, 1.5)
251+
}
252+
}
253+
}
254+
}
255+
}
256+
}

0 commit comments

Comments
 (0)