Skip to content

Commit 9fccba9

Browse files
committed
enh: fetch QuickView image and trace data in EventGetterThread
1 parent a09ea90 commit 9fccba9

21 files changed

Lines changed: 670 additions & 540 deletions

CHANGELOG

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
2.26.3
2+
- enh: fetch QuickView image and trace data in `EventGetterThread`
3+
- ref: new `qv_event_getter` and `qv_image_vis` submodules
14
2.26.2
25
- reg: filters not applied in final plots since 2.25.1
36
- fix: prevent `pipeline.lock` deadlocks via Qt signal cluttering

dcscope/gui/main.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,11 +390,18 @@ def add_plot_window(self, plot_id):
390390
self.subwindows_plots[plot_id] = sub
391391
sub.show()
392392

393+
def close(self):
394+
if self.widget_quick_view is not None:
395+
self.widget_quick_view.close()
396+
return super(DCscope, self).close()
397+
393398
@QtCore.pyqtSlot(QtCore.QEvent)
394399
def closeEvent(self, event):
395400
"""Determine what happens when the user wants to quit"""
396401
if self.pipeline.slots or self.pipeline.filters:
397402
if self.on_action_clear():
403+
if self.widget_quick_view is not None:
404+
self.widget_quick_view.close()
398405
event.accept()
399406
else:
400407
event.ignore()
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import logging
2+
import threading
3+
import time
4+
import traceback
5+
6+
import dclab
7+
from dclab.rtdc_dataset import RTDCBase
8+
import numpy as np
9+
from PyQt6 import QtCore
10+
11+
12+
class EventGetterThread(QtCore.QThread):
13+
new_event_data = QtCore.pyqtSignal(dict)
14+
15+
def __init__(self, parent):
16+
super(EventGetterThread, self).__init__(parent)
17+
self.worker_lock = threading.Lock()
18+
self.event_abort = threading.Event()
19+
self.request = (None, None)
20+
self.prev_request = (None, None)
21+
self.logger = logging.getLogger(__name__)
22+
23+
def close(self):
24+
self.event_abort.set()
25+
while self.isRunning():
26+
time.sleep(0.1)
27+
28+
@QtCore.pyqtSlot(RTDCBase, int)
29+
def request_event_data(self, ds: RTDCBase, event_index: int):
30+
with self.worker_lock:
31+
self.request = (ds, event_index)
32+
33+
def run(self):
34+
while not self.event_abort.is_set():
35+
with self.worker_lock:
36+
ds, event_index = self.request
37+
38+
if self.prev_request == (ds, event_index):
39+
time.sleep(0.05)
40+
continue
41+
42+
self.prev_request = (ds, event_index)
43+
44+
if ds is not None and event_index is not None:
45+
try:
46+
event_data = self.get_event_data(ds, event_index)
47+
self.new_event_data.emit(event_data)
48+
except BaseException:
49+
self.logger.error(traceback.format_exc())
50+
else:
51+
time.sleep(0.01)
52+
53+
def get_event_data(self, ds: RTDCBase, event_index: int):
54+
"""Return all event data relevant for QuickView visualization"""
55+
data = {}
56+
data["index"] = event_index
57+
try:
58+
# Image data
59+
for feat in ["image", "image_bg", "mask", "qpi_amp", "qpi_pha"]:
60+
if feat in ds:
61+
data[feat] = ds[feat][event_index]
62+
63+
# Trace data
64+
if "trace" in ds:
65+
try:
66+
data["traces"] = self.get_event_traces(ds, event_index)
67+
except BaseException:
68+
self.logger.error(traceback.format_exc())
69+
except IndexError:
70+
if event_index != 0:
71+
data = self.get_event_data(ds, 0)
72+
else:
73+
self.logger.error(traceback.format_exc())
74+
return data
75+
76+
def get_event_traces(self,
77+
ds: RTDCBase,
78+
event_index: int,
79+
):
80+
"""Return all trace data"""
81+
tdata = {}
82+
# time axis
83+
flsamples = ds.config["fluorescence"]["samples per event"]
84+
flrate = ds.config["fluorescence"]["sample rate"]
85+
fltime = np.arange(flsamples) / flrate * 1e6
86+
tdata["time"] = fltime
87+
88+
# fluorescence traces and pos/width/max features
89+
range_fl = [0, 0]
90+
for name in dclab.dfn.FLUOR_TRACES:
91+
flid = name.split("_")[0]
92+
if name in ds["trace"]:
93+
# show the trace information
94+
tracey = ds["trace"][name][event_index] # trace data
95+
range_fl[0] = min(range_fl[0], tracey.min())
96+
range_fl[1] = max(range_fl[1], tracey.max())
97+
tdata[name] = tracey
98+
for which in ["pos", "width", "max"]:
99+
feat = f"{flid}_{which}"
100+
if feat not in tdata:
101+
tdata[feat] = ds[feat][event_index]
102+
103+
return tdata
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
from typing import Literal
2+
3+
import numpy as np
4+
import pyqtgraph as pg
5+
from scipy.ndimage import binary_erosion
6+
7+
8+
cmap_pha: pg.ColorMap = pg.colormap.get('CET-D1A', skipCache=True)
9+
cmap_pha_with_black: pg.ColorMap = pg.colormap.get('CET-D1A', skipCache=True)
10+
cmap_pha_with_black.color[0] = [0, 0, 0, 1]
11+
12+
13+
@staticmethod
14+
def convert_to_rgb(cell_img):
15+
"""Add a third axis of length 3 with copies"""
16+
cell_img = cell_img.reshape(
17+
cell_img.shape[0], cell_img.shape[1], 1)
18+
return np.repeat(cell_img, 3, axis=2)
19+
20+
21+
def get_rgb_image(data: dict,
22+
feat: str,
23+
zoom: bool = False,
24+
draw_contour: bool = False,
25+
auto_contrast: bool = False,
26+
subtract_background: bool = False,
27+
) -> tuple[np.ndarray, float, float, pg.ColorMap | None]:
28+
"""Return a pretty visualization of image data"""
29+
if feat == "image":
30+
cmap = None
31+
cell_img, vmin, vmax = prepare_event_image_image(
32+
data,
33+
zoom=zoom,
34+
draw_contour=draw_contour,
35+
auto_contrast=auto_contrast,
36+
subtract_background=subtract_background,
37+
)
38+
elif feat == "qpi_amp":
39+
cmap = None
40+
cell_img, vmin, vmax = prepare_event_image_qpi_amp(
41+
data,
42+
zoom=zoom,
43+
draw_contour=draw_contour,
44+
auto_contrast=auto_contrast,
45+
)
46+
elif feat == "qpi_pha":
47+
cell_img, vmin, vmax, cmap = prepare_event_image_qpi_pha(
48+
data,
49+
zoom=zoom,
50+
draw_contour=draw_contour,
51+
auto_contrast=auto_contrast,
52+
)
53+
else:
54+
raise KeyError(f"Unknown image feature '{feat}'")
55+
56+
return cell_img, vmin, vmax, cmap
57+
58+
59+
def image_insert_contour(cell_img: np.ndarray,
60+
mask: np.ndarray,
61+
cmap_levels: tuple[float, float],
62+
contour_style: Literal["red", "lowest-level"],
63+
):
64+
"""Insert contour data in an image"""
65+
# Compute contour image from mask. If you are wondering
66+
# whether this is kosher, please take a look at issue #76:
67+
# https://github.com/DC-analysis/dclab/issues/76
68+
cont = mask ^ binary_erosion(mask)
69+
if contour_style == "red":
70+
vmin, vmax = cmap_levels
71+
# draw red contour for grayscale images
72+
ch_red = vmin + (vmax - vmin) * 0.7
73+
ch_other = vmin
74+
# assign channel values for contour
75+
cell_img[cont, 0] = ch_red
76+
cell_img[cont, 1] = ch_other
77+
cell_img[cont, 2] = ch_other
78+
elif contour_style == "lowest-level":
79+
# use the lowest value from the colormap
80+
# (used for e.g. phase images)
81+
cell_img[cont] = cmap_levels[0]
82+
83+
return cell_img
84+
85+
86+
def image_zoom(cell_img, mask):
87+
"""Zoom in on the image"""
88+
xv, yv = np.where(mask)
89+
idminx = xv.min() - 5
90+
idminy = yv.min() - 5
91+
idmaxx = xv.max() + 5
92+
idmaxy = yv.max() + 5
93+
idminx = idminx if idminx >= 0 else 0
94+
idminy = idminy if idminy >= 0 else 0
95+
shx, shy = mask.shape
96+
idmaxx = idmaxx if idmaxx < shx else shx
97+
idmaxy = idmaxy if idmaxy < shy else shy
98+
return cell_img[idminx:idmaxx, idminy:idmaxy]
99+
100+
101+
def prepare_event_image_image(
102+
data,
103+
zoom: bool = False,
104+
draw_contour: bool = False,
105+
auto_contrast: bool = False,
106+
subtract_background: bool = False,
107+
) -> tuple[np.ndarray, float, float]:
108+
"""Prepare to draw a regular image event"""
109+
cell_img = data["image"]
110+
111+
if zoom and "mask" in data:
112+
cell_img = image_zoom(cell_img, data["mask"])
113+
114+
# apply background correction
115+
if subtract_background and "image_bg" in data:
116+
117+
bgimg = data["image_bg"].astype(np.int16)
118+
if zoom and "mask" in data:
119+
bgimg = image_zoom(bgimg, data["mask"])
120+
121+
cell_img = cell_img.astype(np.int16)
122+
cell_img = cell_img - bgimg + int(np.mean(bgimg))
123+
124+
# automatic contrast
125+
if auto_contrast:
126+
vmin, vmax = cell_img.min(), cell_img.max()
127+
else:
128+
vmin, vmax = (0, 255)
129+
130+
cell_img = convert_to_rgb(cell_img)
131+
132+
if draw_contour and "mask" in data:
133+
mask = data["mask"]
134+
if zoom:
135+
mask = image_zoom(mask, mask)
136+
137+
cell_img = image_insert_contour(
138+
cell_img,
139+
mask,
140+
cmap_levels=(vmin, vmax),
141+
contour_style="red",
142+
)
143+
144+
return cell_img, vmin, vmax
145+
146+
147+
def prepare_event_image_qpi_amp(
148+
data,
149+
zoom: bool = False,
150+
draw_contour: bool = False,
151+
auto_contrast: bool = False,
152+
) -> tuple[np.ndarray, float, float]:
153+
"""Prepare to draw a QPI amplitude event image"""
154+
cell_img = data["qpi_amp"]
155+
156+
if zoom and "mask" in data:
157+
cell_img = image_zoom(cell_img, data["mask"])
158+
159+
if auto_contrast:
160+
vmin, vmax = cell_img.min(), cell_img.max()
161+
else:
162+
vmin, vmax = (0, 2)
163+
164+
cell_img = convert_to_rgb(cell_img)
165+
166+
if draw_contour and "mask" in data:
167+
mask = data["mask"]
168+
if zoom:
169+
mask = image_zoom(mask, mask)
170+
171+
cell_img = image_insert_contour(
172+
cell_img,
173+
mask,
174+
cmap_levels=(vmin, vmax),
175+
contour_style="red",
176+
)
177+
178+
return cell_img, vmin, vmax
179+
180+
181+
def prepare_event_image_qpi_pha(
182+
data,
183+
zoom: bool = False,
184+
draw_contour: bool = False,
185+
auto_contrast: bool = False,
186+
) -> tuple[np.ndarray, float, float, pg.ColorMap]:
187+
"""Prepare to draw a QPI phase event image"""
188+
cell_img = np.copy(data["qpi_pha"])
189+
190+
if zoom and "mask" in data:
191+
cell_img = image_zoom(cell_img, data["mask"])
192+
193+
if auto_contrast:
194+
# phase values centered around zero
195+
vmin_abs, vmax_abs = np.abs(cell_img.min()), np.abs(cell_img.max())
196+
v_largest = max(vmax_abs, vmin_abs)
197+
vmin, vmax = -v_largest, v_largest
198+
else:
199+
vmin, vmax = (-3.14, 3.14)
200+
201+
if draw_contour and "mask" in data:
202+
# offset required for auto-contrast with contour
203+
# two times the contrast range, divided by the cmap length
204+
# this essentially adds a cmap point for our contour
205+
offset = 2 * ((vmax - vmin) / len(cmap_pha.color))
206+
vmin -= offset
207+
208+
mask = data["mask"]
209+
if zoom:
210+
mask = image_zoom(mask, mask)
211+
212+
cell_img = image_insert_contour(
213+
cell_img,
214+
mask,
215+
cmap_levels=(vmin, vmax),
216+
contour_style="lowest-level",
217+
)
218+
cmap = cmap_pha_with_black
219+
else:
220+
cmap = cmap_pha
221+
222+
return cell_img, vmin, vmax, cmap

0 commit comments

Comments
 (0)