MIDRC-MELODY 0.3.3__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.
- MIDRC_MELODY/__init__.py +0 -0
- MIDRC_MELODY/__main__.py +4 -0
- MIDRC_MELODY/common/__init__.py +0 -0
- MIDRC_MELODY/common/data_loading.py +199 -0
- MIDRC_MELODY/common/data_preprocessing.py +134 -0
- MIDRC_MELODY/common/edit_config.py +156 -0
- MIDRC_MELODY/common/eod_aaod_metrics.py +292 -0
- MIDRC_MELODY/common/generate_eod_aaod_spiders.py +69 -0
- MIDRC_MELODY/common/generate_qwk_spiders.py +56 -0
- MIDRC_MELODY/common/matplotlib_spider.py +425 -0
- MIDRC_MELODY/common/plot_tools.py +132 -0
- MIDRC_MELODY/common/plotly_spider.py +217 -0
- MIDRC_MELODY/common/qwk_metrics.py +244 -0
- MIDRC_MELODY/common/table_tools.py +230 -0
- MIDRC_MELODY/gui/__init__.py +0 -0
- MIDRC_MELODY/gui/config_editor.py +200 -0
- MIDRC_MELODY/gui/data_loading.py +157 -0
- MIDRC_MELODY/gui/main_controller.py +154 -0
- MIDRC_MELODY/gui/main_window.py +545 -0
- MIDRC_MELODY/gui/matplotlib_spider_widget.py +204 -0
- MIDRC_MELODY/gui/metrics_model.py +62 -0
- MIDRC_MELODY/gui/plotly_spider_widget.py +56 -0
- MIDRC_MELODY/gui/qchart_spider_widget.py +272 -0
- MIDRC_MELODY/gui/shared/__init__.py +0 -0
- MIDRC_MELODY/gui/shared/react/__init__.py +0 -0
- MIDRC_MELODY/gui/shared/react/copyabletableview.py +100 -0
- MIDRC_MELODY/gui/shared/react/grabbablewidget.py +406 -0
- MIDRC_MELODY/gui/tqdm_handler.py +210 -0
- MIDRC_MELODY/melody.py +102 -0
- MIDRC_MELODY/melody_gui.py +111 -0
- MIDRC_MELODY/resources/MIDRC.ico +0 -0
- midrc_melody-0.3.3.dist-info/METADATA +151 -0
- midrc_melody-0.3.3.dist-info/RECORD +37 -0
- midrc_melody-0.3.3.dist-info/WHEEL +5 -0
- midrc_melody-0.3.3.dist-info/entry_points.txt +4 -0
- midrc_melody-0.3.3.dist-info/licenses/LICENSE +201 -0
- midrc_melody-0.3.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Copyright (c) 2025 Medical Imaging and Data Resource Center (MIDRC).
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
"""Qt-friendly Matplotlib spider-plot widget
|
|
15
|
+
|
|
16
|
+
This revision exposes the NavigationToolbar so callers can show/hide it at runtime.
|
|
17
|
+
Cropping issues are handled by constrained layout + tight_layout on resize.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import List, Optional
|
|
22
|
+
import warnings
|
|
23
|
+
|
|
24
|
+
import matplotlib as mpl
|
|
25
|
+
from matplotlib.backends.backend_qtagg import (
|
|
26
|
+
FigureCanvasQTAgg as FigureCanvas,
|
|
27
|
+
NavigationToolbar2QT as NavigationToolbar,
|
|
28
|
+
)
|
|
29
|
+
from PySide6.QtWidgets import QTabWidget, QVBoxLayout, QWidget
|
|
30
|
+
|
|
31
|
+
from MIDRC_MELODY.common.matplotlib_spider import plot_spider_chart
|
|
32
|
+
from MIDRC_MELODY.common.plot_tools import SpiderPlotData
|
|
33
|
+
from MIDRC_MELODY.gui.shared.react.grabbablewidget import GrabbableWidgetMixin
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"MatplotlibSpiderWidget",
|
|
37
|
+
"display_spider_charts_in_tabs_matplotlib",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MatplotlibSpiderWidget(QWidget):
|
|
42
|
+
"""Embed a Matplotlib *spider chart* (a.k.a. radar chart) in a Qt widget.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
spider_data
|
|
47
|
+
Data structure consumed by :func:`plot_spider_chart`.
|
|
48
|
+
show_toolbar
|
|
49
|
+
If *True*, display the Matplotlib *navigation toolbar*; if *False*, hide it.
|
|
50
|
+
You can also toggle visibility at runtime via `set_toolbar_visible()`.
|
|
51
|
+
pad
|
|
52
|
+
Extra padding (in figure‐fraction units) passed to ``tight_layout`` so
|
|
53
|
+
that very large tick labels have room.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
spider_data: SpiderPlotData,
|
|
59
|
+
parent: Optional[QWidget] = None,
|
|
60
|
+
*,
|
|
61
|
+
show_toolbar: bool = False,
|
|
62
|
+
pad: float = 0.4,
|
|
63
|
+
) -> None:
|
|
64
|
+
super().__init__(parent)
|
|
65
|
+
self._grabbable_mixin = GrabbableWidgetMixin(self, "MIDRC-MELODY Spider Chart ")
|
|
66
|
+
_set_spider_chart_copyable_data(self, spider_data)
|
|
67
|
+
|
|
68
|
+
self._pad = pad
|
|
69
|
+
|
|
70
|
+
# ───────────────────────────────────────────────── build the Figure
|
|
71
|
+
with mpl.rc_context({"figure.constrained_layout.use": True}):
|
|
72
|
+
# Suppress only the “The figure layout has changed to tight” warning
|
|
73
|
+
with warnings.catch_warnings():
|
|
74
|
+
warnings.filterwarnings(
|
|
75
|
+
"ignore",
|
|
76
|
+
"The figure layout has changed to tight",
|
|
77
|
+
category=UserWarning,
|
|
78
|
+
)
|
|
79
|
+
fig = plot_spider_chart(spider_data)
|
|
80
|
+
fig.set_constrained_layout(True)
|
|
81
|
+
with warnings.catch_warnings():
|
|
82
|
+
warnings.filterwarnings(
|
|
83
|
+
"ignore",
|
|
84
|
+
"The figure layout has changed to tight",
|
|
85
|
+
category=UserWarning,
|
|
86
|
+
)
|
|
87
|
+
fig.tight_layout(pad=self._pad)
|
|
88
|
+
|
|
89
|
+
# ────────────────────────────────────────── wrap figure in a FigureCanvas
|
|
90
|
+
self._canvas = FigureCanvas(fig)
|
|
91
|
+
|
|
92
|
+
# ─────────────────────────────────── create the NavigationToolbar
|
|
93
|
+
self._toolbar = NavigationToolbar(self._canvas, self)
|
|
94
|
+
self._toolbar.setVisible(show_toolbar)
|
|
95
|
+
|
|
96
|
+
# ────────────────────────────────────── lay out canvas and (optional) toolbar
|
|
97
|
+
layout = QVBoxLayout(self)
|
|
98
|
+
layout.addWidget(self._toolbar)
|
|
99
|
+
layout.addWidget(self._canvas)
|
|
100
|
+
|
|
101
|
+
# ────────────────────────────────────── auto-adjust on resize events
|
|
102
|
+
self._canvas.mpl_connect("resize_event", self._on_resize)
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------- API
|
|
105
|
+
def figure(self):
|
|
106
|
+
"""Return the underlying :class:`matplotlib.figure.Figure`."""
|
|
107
|
+
return self._canvas.figure
|
|
108
|
+
|
|
109
|
+
def canvas(self) -> FigureCanvas:
|
|
110
|
+
return self._canvas
|
|
111
|
+
|
|
112
|
+
def set_toolbar_visible(self, visible: bool) -> None:
|
|
113
|
+
"""Show or hide the Matplotlib navigation toolbar."""
|
|
114
|
+
self._toolbar.setVisible(visible)
|
|
115
|
+
|
|
116
|
+
# -------------------------------------------------------------- events
|
|
117
|
+
def resizeEvent(self, event): # Qt event → keep layout fresh
|
|
118
|
+
super().resizeEvent(event)
|
|
119
|
+
self._on_resize()
|
|
120
|
+
|
|
121
|
+
# --------------------------------------------------------------- intern
|
|
122
|
+
def _on_resize(self, *_):
|
|
123
|
+
"""Re-run layout manager so annotations never get clipped."""
|
|
124
|
+
if not self._canvas or self._canvas.isHidden():
|
|
125
|
+
return
|
|
126
|
+
fig = self._canvas.figure
|
|
127
|
+
if getattr(fig, "canvas", None) is None:
|
|
128
|
+
return
|
|
129
|
+
with warnings.catch_warnings():
|
|
130
|
+
warnings.filterwarnings(
|
|
131
|
+
"ignore",
|
|
132
|
+
"(?s)(?=.*[Tt]ight)(?=.*[Ll]ayout).*", # Ignore tight layout warnings
|
|
133
|
+
category=UserWarning,
|
|
134
|
+
)
|
|
135
|
+
fig.tight_layout(pad=self._pad)
|
|
136
|
+
self._canvas.draw_idle()
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def copyable_data(self) -> str:
|
|
140
|
+
"""
|
|
141
|
+
Get the copyable data for the chart view.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
str: The data to be copied to the clipboard when requested.
|
|
145
|
+
"""
|
|
146
|
+
return self._grabbable_mixin.copyable_data
|
|
147
|
+
|
|
148
|
+
@copyable_data.setter
|
|
149
|
+
def copyable_data(self, data: str):
|
|
150
|
+
"""
|
|
151
|
+
Set the copyable data for the chart view.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
data (str): The data to be copied to the clipboard when requested.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
None
|
|
158
|
+
"""
|
|
159
|
+
self._grabbable_mixin.copyable_data = data
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# Bulk helper: one tab per model (mirrors Plotly counterpart)
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def display_spider_charts_in_tabs_matplotlib(
|
|
167
|
+
spider_data_list: List[SpiderPlotData], *, show_toolbar: bool = False, pad: float = 0.4
|
|
168
|
+
) -> QTabWidget:
|
|
169
|
+
"""Assemble a ``QTabWidget`` containing one spider chart per entry."""
|
|
170
|
+
|
|
171
|
+
tab_widget = QTabWidget()
|
|
172
|
+
tab_widget.setDocumentMode(True)
|
|
173
|
+
tab_widget.setTabPosition(QTabWidget.North)
|
|
174
|
+
|
|
175
|
+
for spider_data in spider_data_list:
|
|
176
|
+
container = QWidget()
|
|
177
|
+
layout = QVBoxLayout(container)
|
|
178
|
+
|
|
179
|
+
mpl_widget = MatplotlibSpiderWidget(
|
|
180
|
+
spider_data,
|
|
181
|
+
parent=container,
|
|
182
|
+
show_toolbar=show_toolbar,
|
|
183
|
+
pad=pad,
|
|
184
|
+
)
|
|
185
|
+
layout.addWidget(mpl_widget)
|
|
186
|
+
|
|
187
|
+
tab_widget.addTab(container, spider_data.model_name)
|
|
188
|
+
return tab_widget
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _set_spider_chart_copyable_data(widget: GrabbableWidgetMixin|QWidget, spider_data: SpiderPlotData) -> None:
|
|
192
|
+
"""
|
|
193
|
+
Set the copyable data for the spider chart.
|
|
194
|
+
|
|
195
|
+
:arg widget: A GrabbalbeWidgetMixin or a QWidget that forwards the copyable_data property to a GrabbableWidgetMixin.
|
|
196
|
+
:arg spider_data: SpiderPlotData containing the data to be displayed.
|
|
197
|
+
"""
|
|
198
|
+
if widget and spider_data:
|
|
199
|
+
headers = ['Model', 'Metric', 'Category', 'Group', 'Value']
|
|
200
|
+
formatted_text = "\t".join(headers) + "\n"
|
|
201
|
+
for group, value in zip(spider_data.groups, spider_data.values):
|
|
202
|
+
c, g = group.split(': ', 1) if ': ' in group else (group, group)
|
|
203
|
+
formatted_text += f"{spider_data.model_name}\t{spider_data.metric}\t{c}\t{g}\t{value}\n"
|
|
204
|
+
widget.copyable_data = formatted_text
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Copyright (c) 2025 Medical Imaging and Data Resource Center (MIDRC).
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
from MIDRC_MELODY.common.qwk_metrics import (
|
|
17
|
+
calculate_delta_kappa,
|
|
18
|
+
calculate_kappas_and_intervals,
|
|
19
|
+
)
|
|
20
|
+
from MIDRC_MELODY.common.eod_aaod_metrics import (
|
|
21
|
+
binarize_scores,
|
|
22
|
+
calculate_eod_aaod,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from PySide6.QtGui import QColor
|
|
26
|
+
from MIDRC_MELODY.common.table_tools import GLOBAL_COLORS, build_eod_aaod_tables_gui
|
|
27
|
+
from dataclasses import replace
|
|
28
|
+
|
|
29
|
+
def compute_qwk_metrics(test_data, plot_config=None):
|
|
30
|
+
# Compute QWK metrics and prepare table rows and plot args.
|
|
31
|
+
delta_kappas = calculate_delta_kappa(test_data)
|
|
32
|
+
all_rows = []
|
|
33
|
+
filtered_rows = []
|
|
34
|
+
maroon = QColor(*GLOBAL_COLORS['kappa_negative'])
|
|
35
|
+
green = QColor(*GLOBAL_COLORS['kappa_positive'])
|
|
36
|
+
for category, model_data in delta_kappas.items():
|
|
37
|
+
for model, groups in model_data.items():
|
|
38
|
+
for group, (delta, (lower_ci, upper_ci)) in groups.items():
|
|
39
|
+
qualifies = (lower_ci > 0 or upper_ci < 0)
|
|
40
|
+
color = green if qualifies and delta >= 0 else (maroon if qualifies and delta < 0 else None)
|
|
41
|
+
row = [model, category, group, f"{delta:.4f}", f"{lower_ci:.4f}", f"{upper_ci:.4f}"]
|
|
42
|
+
all_rows.append((row, color))
|
|
43
|
+
if qualifies:
|
|
44
|
+
filtered_rows.append((row, color))
|
|
45
|
+
kappas, intervals = calculate_kappas_and_intervals(test_data)
|
|
46
|
+
kappas_rows = []
|
|
47
|
+
for model in sorted(kappas.keys()):
|
|
48
|
+
row = [model, f"{kappas[model]:.4f}", f"{intervals[model][0]:.4f}", f"{intervals[model][1]:.4f}"]
|
|
49
|
+
kappas_rows.append((row, None))
|
|
50
|
+
p_c = plot_config if plot_config else {}
|
|
51
|
+
plot_args = (delta_kappas, test_data.test_cols, p_c)
|
|
52
|
+
return all_rows, filtered_rows, kappas_rows, plot_args
|
|
53
|
+
|
|
54
|
+
def compute_eod_aaod_metrics(test_data, threshold, plot_config: dict=None):
|
|
55
|
+
# Binzarize the scores and compute EOD/AAOD metrics, then build tables and plot args.
|
|
56
|
+
binarized = binarize_scores(test_data.matched_df, test_data.truth_col, test_data.test_cols, threshold=threshold)
|
|
57
|
+
new_data = replace(test_data, matched_df=binarized)
|
|
58
|
+
eod_aaod = calculate_eod_aaod(new_data)
|
|
59
|
+
all_eod_rows, all_aaod_rows, filtered_rows = build_eod_aaod_tables_gui(eod_aaod)
|
|
60
|
+
p_c = plot_config if plot_config else {}
|
|
61
|
+
plot_args = (eod_aaod, new_data.test_cols, p_c)
|
|
62
|
+
return all_eod_rows, all_aaod_rows, filtered_rows, plot_args
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Copyright (c) 2025 Medical Imaging and Data Resource Center (MIDRC).
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
from typing import List
|
|
17
|
+
|
|
18
|
+
from PySide6.QtWidgets import QWidget, QVBoxLayout, QTabWidget
|
|
19
|
+
from PySide6.QtWebEngineWidgets import QWebEngineView
|
|
20
|
+
from MIDRC_MELODY.common.plot_tools import SpiderPlotData
|
|
21
|
+
from MIDRC_MELODY.common.plotly_spider import spider_to_html
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PlotlySpiderWidget(QWidget):
|
|
25
|
+
def __init__(self, spider_data: SpiderPlotData, parent=None):
|
|
26
|
+
super().__init__(parent)
|
|
27
|
+
|
|
28
|
+
# 1) Generate the HTML <div> string from Plotly
|
|
29
|
+
html_div: str = spider_to_html(spider_data)
|
|
30
|
+
|
|
31
|
+
# 2) Create a QWebEngineView and load that HTML
|
|
32
|
+
self._view = QWebEngineView(self)
|
|
33
|
+
# Note: setHtml defaults to UTF-8. If you need local resources, pass baseUrl.
|
|
34
|
+
self._view.setHtml(html_div)
|
|
35
|
+
|
|
36
|
+
# 3) Put the QWebEngineView inside a vertical layout
|
|
37
|
+
layout = QVBoxLayout(self)
|
|
38
|
+
layout.addWidget(self._view)
|
|
39
|
+
self.setLayout(layout)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def display_spider_charts_in_tabs_plotly(spider_data_list: List[SpiderPlotData]) -> QTabWidget:
|
|
43
|
+
"""
|
|
44
|
+
Replaces the old QtCharts polar approach with a Plotly-based QWebEngineView.
|
|
45
|
+
"""
|
|
46
|
+
tab_widget = QTabWidget()
|
|
47
|
+
for spider_data in spider_data_list:
|
|
48
|
+
container = QWidget()
|
|
49
|
+
layout = QVBoxLayout(container)
|
|
50
|
+
|
|
51
|
+
# Use Plotly to draw the spider chart and embed as HTML
|
|
52
|
+
plotly_widget = PlotlySpiderWidget(spider_data)
|
|
53
|
+
layout.addWidget(plotly_widget)
|
|
54
|
+
|
|
55
|
+
tab_widget.addTab(container, spider_data.model_name)
|
|
56
|
+
return tab_widget
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# Copyright (c) 2025 Medical Imaging and Data Resource Center (MIDRC).
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
from typing import List
|
|
17
|
+
from PySide6.QtWidgets import QWidget, QTabWidget, QVBoxLayout
|
|
18
|
+
from PySide6.QtCharts import QPolarChart, QLineSeries, QValueAxis, QCategoryAxis, QPieSeries
|
|
19
|
+
from PySide6.QtCore import Qt
|
|
20
|
+
from PySide6.QtGui import QPainter, QPen, QBrush, QColor
|
|
21
|
+
from MIDRC_MELODY.common.plot_tools import SpiderPlotData # reuse data class from common module
|
|
22
|
+
from MIDRC_MELODY.gui.shared.react.grabbablewidget import GrabbableChartView
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fill_bounds(
|
|
26
|
+
chart: QPolarChart,
|
|
27
|
+
angles: List[float],
|
|
28
|
+
lower_bounds: List[float],
|
|
29
|
+
upper_bounds: List[float],
|
|
30
|
+
cat_axis: QCategoryAxis,
|
|
31
|
+
radial_axis: QValueAxis,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""
|
|
34
|
+
Draw upper and lower bound lines on the spider chart (no filled area).
|
|
35
|
+
"""
|
|
36
|
+
# Ensure lists align; if not, skip
|
|
37
|
+
if not angles or len(lower_bounds) != len(angles) or len(upper_bounds) != len(angles):
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
# Draw lower bound line
|
|
41
|
+
lower_series = QLineSeries()
|
|
42
|
+
for angle, lo in zip(angles, lower_bounds):
|
|
43
|
+
lower_series.append(angle, lo)
|
|
44
|
+
# Close the loop
|
|
45
|
+
lower_series.append(360, lower_bounds[0])
|
|
46
|
+
pen_lo = QPen(QColor('steelblue'))
|
|
47
|
+
pen_lo.setStyle(Qt.DashLine)
|
|
48
|
+
pen_lo.setWidth(2)
|
|
49
|
+
pen_lo.setColor(QColor(70, 130, 180, 128)) # semi-transparent steelblue
|
|
50
|
+
lower_series.setPen(pen_lo)
|
|
51
|
+
chart.addSeries(lower_series)
|
|
52
|
+
lower_series.attachAxis(cat_axis)
|
|
53
|
+
lower_series.attachAxis(radial_axis)
|
|
54
|
+
|
|
55
|
+
# Draw upper bound line
|
|
56
|
+
upper_series = QLineSeries()
|
|
57
|
+
for angle, hi in zip(angles, upper_bounds):
|
|
58
|
+
upper_series.append(angle, hi)
|
|
59
|
+
upper_series.append(360, upper_bounds[0])
|
|
60
|
+
pen_hi = QPen(QColor('steelblue'))
|
|
61
|
+
pen_hi.setStyle(Qt.DashLine)
|
|
62
|
+
pen_hi.setWidth(2)
|
|
63
|
+
pen_hi.setColor(QColor(70, 130, 180, 128))
|
|
64
|
+
upper_series.setPen(pen_hi)
|
|
65
|
+
chart.addSeries(upper_series)
|
|
66
|
+
upper_series.attachAxis(cat_axis)
|
|
67
|
+
upper_series.attachAxis(radial_axis)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _apply_metric_overlay(
|
|
71
|
+
chart: QPolarChart,
|
|
72
|
+
angles: List[float],
|
|
73
|
+
values: List[float],
|
|
74
|
+
lower_bounds: List[float],
|
|
75
|
+
upper_bounds: List[float],
|
|
76
|
+
cat_axis: QCategoryAxis,
|
|
77
|
+
radial_axis: QValueAxis,
|
|
78
|
+
spider_data: SpiderPlotData,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""
|
|
81
|
+
Apply metric-specific overlays (baselines, shaded regions, threshold markers) to the given QPolarChart.
|
|
82
|
+
"""
|
|
83
|
+
metric = spider_data.metric.upper()
|
|
84
|
+
# Precompute full-circle angles for continuous lines/fills
|
|
85
|
+
full_angles_deg = [360 * i / 99 for i in range(100)]
|
|
86
|
+
|
|
87
|
+
# Determine small angle delta for threshold line markers
|
|
88
|
+
step_size = angles[1] - angles[0] if len(angles) > 1 else 360
|
|
89
|
+
delta = step_size * 0.2
|
|
90
|
+
|
|
91
|
+
# Configuration for each metric
|
|
92
|
+
overlay_config = {
|
|
93
|
+
'QWK': {
|
|
94
|
+
'baseline': {'type': 'line', 'y': 0, 'style': Qt.DashLine, 'color': 'seagreen', 'linewidth': 3, 'alpha': 0.8},
|
|
95
|
+
'thresholds': [
|
|
96
|
+
(lower_bounds, lambda v: v > 0, 'maroon'),
|
|
97
|
+
(upper_bounds, lambda v: v < 0, 'red'),
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
'EOD': {
|
|
101
|
+
'fill': {'lo': -0.1, 'hi': 0.1, 'color': 'lightgreen', 'alpha': 0.5},
|
|
102
|
+
'thresholds': [
|
|
103
|
+
(values, lambda v: v > 0.1, 'maroon'),
|
|
104
|
+
(values, lambda v: v < -0.1, 'red'),
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
'AAOD': {
|
|
108
|
+
'fill': {'lo': 0, 'hi': 0.1, 'color': 'lightgreen', 'alpha': 0.5},
|
|
109
|
+
'baseline': {'type': 'ylim', 'lo': 0},
|
|
110
|
+
'thresholds': [
|
|
111
|
+
(values, lambda v: v > 0.1, 'maroon'),
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
cfg = overlay_config.get(metric)
|
|
116
|
+
if not cfg:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
# --- Baseline rendering ---
|
|
120
|
+
if 'baseline' in cfg:
|
|
121
|
+
base = cfg['baseline']
|
|
122
|
+
if base['type'] == 'line':
|
|
123
|
+
baseline_series = QLineSeries()
|
|
124
|
+
for angle_deg in full_angles_deg:
|
|
125
|
+
baseline_series.append(angle_deg, base['y'])
|
|
126
|
+
pen = QPen(QColor(base['color']))
|
|
127
|
+
pen.setWidth(base['linewidth'])
|
|
128
|
+
pen.setStyle(base['style'])
|
|
129
|
+
color = QColor(base['color'])
|
|
130
|
+
color.setAlphaF(base['alpha'])
|
|
131
|
+
pen.setColor(color)
|
|
132
|
+
baseline_series.setPen(pen)
|
|
133
|
+
chart.addSeries(baseline_series)
|
|
134
|
+
baseline_series.attachAxis(cat_axis)
|
|
135
|
+
baseline_series.attachAxis(radial_axis)
|
|
136
|
+
elif base['type'] == 'ylim':
|
|
137
|
+
# Adjust radial axis minimum
|
|
138
|
+
y_max = spider_data.ylim_max[spider_data.metric]
|
|
139
|
+
radial_axis.setRange(base['lo'], y_max)
|
|
140
|
+
|
|
141
|
+
# --- Fill region if specified ---
|
|
142
|
+
if 'fill' in cfg:
|
|
143
|
+
f = cfg['fill']
|
|
144
|
+
pie_series = QPieSeries()
|
|
145
|
+
slice = pie_series.append("", 360)
|
|
146
|
+
color = QColor(f['color'])
|
|
147
|
+
color.setAlphaF(f['alpha'])
|
|
148
|
+
slice.setBrush(QBrush(color))
|
|
149
|
+
slice.setPen(QPen(Qt.NoPen))
|
|
150
|
+
chart.addSeries(pie_series)
|
|
151
|
+
# Note: QPieSeries does not support attaching axes like QAreaSeries.
|
|
152
|
+
|
|
153
|
+
# --- Threshold markers as short perpendicular lines ---
|
|
154
|
+
line_series_list = []
|
|
155
|
+
for data_list, cond, color_name in cfg.get('thresholds', []):
|
|
156
|
+
pen = QPen(QColor(color_name))
|
|
157
|
+
pen.setWidth(2)
|
|
158
|
+
for i, v in enumerate(data_list):
|
|
159
|
+
if cond(v):
|
|
160
|
+
angle_deg = angles[i]
|
|
161
|
+
radius = v
|
|
162
|
+
d = delta * (spider_data.ylim_max[spider_data.metric] - radius) / (spider_data.ylim_max[spider_data.metric] - spider_data.ylim_min[spider_data.metric])
|
|
163
|
+
# Create a small line segment around the threshold point
|
|
164
|
+
line_series = QLineSeries()
|
|
165
|
+
line_series.setPen(pen)
|
|
166
|
+
if (angle_deg - d) >= 0 and (angle_deg + d) < 360: # no wrap around
|
|
167
|
+
line_series.append(angle_deg - d, radius)
|
|
168
|
+
line_series.append(angle_deg + d, radius)
|
|
169
|
+
else:
|
|
170
|
+
line_series.append((angle_deg - d) % 360, radius)
|
|
171
|
+
line_series.append(360, radius)
|
|
172
|
+
# create a line_series_2 for to cover the wrap around
|
|
173
|
+
line_series_2 = QLineSeries()
|
|
174
|
+
line_series_2.setPen(pen)
|
|
175
|
+
line_series_2.append(0, radius)
|
|
176
|
+
line_series_2.append((angle_deg + d) % 360, radius)
|
|
177
|
+
line_series_list.append(line_series_2)
|
|
178
|
+
line_series_list.append(line_series)
|
|
179
|
+
for line_series in line_series_list:
|
|
180
|
+
chart.addSeries(line_series)
|
|
181
|
+
line_series.attachAxis(cat_axis)
|
|
182
|
+
line_series.attachAxis(radial_axis)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def create_spider_chart(spider_data: SpiderPlotData) -> QPolarChart:
|
|
186
|
+
"""
|
|
187
|
+
Create a QPolarChart based on the SpiderPlotData, including metric-specific overlays.
|
|
188
|
+
"""
|
|
189
|
+
chart = QPolarChart()
|
|
190
|
+
chart.setTitle(f"{spider_data.model_name} - {spider_data.metric}")
|
|
191
|
+
series = QLineSeries()
|
|
192
|
+
|
|
193
|
+
labels = spider_data.groups
|
|
194
|
+
values = spider_data.values
|
|
195
|
+
lower_bounds = spider_data.lower_bounds
|
|
196
|
+
upper_bounds = spider_data.upper_bounds
|
|
197
|
+
|
|
198
|
+
# Compute angles in degrees for QtCharts
|
|
199
|
+
step_size: float = 360 / len(labels)
|
|
200
|
+
angles: List[float] = [step_size * i for i in range(len(labels))]
|
|
201
|
+
|
|
202
|
+
# Add points for each group and close the loop
|
|
203
|
+
for angle, value in zip(angles, values):
|
|
204
|
+
series.append(angle, value)
|
|
205
|
+
# Close the loop by repeating the first point at 360°
|
|
206
|
+
if series.points():
|
|
207
|
+
series.append(angles[0] + 360, series.points()[0].y())
|
|
208
|
+
|
|
209
|
+
chart.addSeries(series)
|
|
210
|
+
|
|
211
|
+
# Create and configure the angular axis (categories for group labels)
|
|
212
|
+
cat_axis = QCategoryAxis()
|
|
213
|
+
cat_axis.setRange(0, 360)
|
|
214
|
+
cat_axis.setLabelsPosition(QCategoryAxis.AxisLabelsPositionOnValue)
|
|
215
|
+
for label, angle in zip(labels, angles):
|
|
216
|
+
cat_axis.append(label, angle)
|
|
217
|
+
chart.addAxis(cat_axis, QPolarChart.PolarOrientationAngular)
|
|
218
|
+
series.attachAxis(cat_axis)
|
|
219
|
+
|
|
220
|
+
# Create and configure the radial axis
|
|
221
|
+
radial_axis = QValueAxis()
|
|
222
|
+
radial_axis.setRange(spider_data.ylim_min[spider_data.metric],
|
|
223
|
+
spider_data.ylim_max[spider_data.metric])
|
|
224
|
+
radial_axis.setLabelFormat("%.2f")
|
|
225
|
+
chart.addAxis(radial_axis, QPolarChart.PolarOrientationRadial)
|
|
226
|
+
series.attachAxis(radial_axis)
|
|
227
|
+
|
|
228
|
+
# Fill the area between lower and upper bounds
|
|
229
|
+
_fill_bounds(
|
|
230
|
+
chart,
|
|
231
|
+
angles,
|
|
232
|
+
lower_bounds,
|
|
233
|
+
upper_bounds,
|
|
234
|
+
cat_axis,
|
|
235
|
+
radial_axis,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Apply metric-specific overlay via helper function
|
|
239
|
+
_apply_metric_overlay(
|
|
240
|
+
chart,
|
|
241
|
+
angles,
|
|
242
|
+
values,
|
|
243
|
+
lower_bounds,
|
|
244
|
+
upper_bounds,
|
|
245
|
+
cat_axis,
|
|
246
|
+
radial_axis,
|
|
247
|
+
spider_data,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
chart.legend().hide()
|
|
251
|
+
return chart
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def display_spider_charts_in_tabs(spider_data_list: List[SpiderPlotData]) -> QTabWidget:
|
|
255
|
+
"""
|
|
256
|
+
Given a list of SpiderPlotData objects, create a QTabWidget where each tab displays
|
|
257
|
+
the corresponding spider chart in a QChartView.
|
|
258
|
+
"""
|
|
259
|
+
tab_widget = QTabWidget()
|
|
260
|
+
for spider_data in spider_data_list:
|
|
261
|
+
widget = QWidget()
|
|
262
|
+
layout = QVBoxLayout(widget)
|
|
263
|
+
chart = create_spider_chart(spider_data)
|
|
264
|
+
chart_view: GrabbableChartView = GrabbableChartView(
|
|
265
|
+
chart,
|
|
266
|
+
save_file_prefix=f"MIDRC-MELODY_{spider_data.metric}_{spider_data.model_name}_spider_chart",
|
|
267
|
+
)
|
|
268
|
+
set_spider_chart_copyable_data(chart_view, spider_data)
|
|
269
|
+
chart_view.setRenderHint(QPainter.Antialiasing) # ensure smooth rendering
|
|
270
|
+
layout.addWidget(chart_view)
|
|
271
|
+
tab_widget.addTab(widget, spider_data.model_name)
|
|
272
|
+
return tab_widget
|
|
File without changes
|
|
File without changes
|