Skip to content

Commit 4cd5e61

Browse files
add plugin example for isolevel contour line image representation
1 parent 05b6b37 commit 4cd5e61

1 file changed

Lines changed: 270 additions & 0 deletions

File tree

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
# -*- coding: utf-8 -*-
2+
3+
"""
4+
Contour plot plugin for DataLab.
5+
6+
This plugin adds a simple user-facing contour-plot view for the currently
7+
selected image. It opens a separate PlotPy dialog and overlays automatically
8+
generated isocontours on top of the image.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from typing import TYPE_CHECKING
14+
15+
import guidata.dataset as gds
16+
import numpy as np
17+
from plotpy.config import CONF
18+
from plotpy.items import AnnotatedPolygon
19+
from plotpy.items.contour import compute_contours
20+
from plotpy.plot import PlotDialog
21+
from plotpy.styles import AnnotationParam
22+
23+
from datalab.adapters_plotpy import create_adapter_from_object
24+
from datalab.adapters_plotpy.objects.image import get_obj_coords
25+
from datalab.config import _
26+
from datalab.objectmodel import get_uuid
27+
from datalab.plugins import PluginBase, PluginInfo
28+
29+
if TYPE_CHECKING:
30+
from sigima.objects import ImageObj
31+
32+
33+
class _ContourAnnotatedPolygon(AnnotatedPolygon):
34+
"""AnnotatedPolygon with label placed on the contour perimeter."""
35+
36+
LABEL_ANCHOR = "C"
37+
38+
def __init__(self, *args, label_fraction: float = 0.0, **kwargs) -> None:
39+
self._label_fraction = label_fraction
40+
super().__init__(*args, **kwargs)
41+
42+
def set_label_position(self) -> None:
43+
"""Place the label on the contour perimeter at *_label_fraction*."""
44+
pts = self.shape.get_points()
45+
if pts is None or len(pts) < 2:
46+
super().set_label_position()
47+
return
48+
# Pick the vertex at the given fraction of the perimeter
49+
idx = int(self._label_fraction * len(pts)) % len(pts)
50+
x, y = float(pts[idx, 0]), float(pts[idx, 1])
51+
self.label.set_pos(x, y)
52+
53+
54+
def _nice_step(data_range: float, target_count: int = 10) -> float:
55+
"""Compute a human-friendly step size for *data_range* / *target_count*.
56+
57+
Returns a value from the set {1, 2, 5} × 10^n that yields
58+
approximately *target_count* intervals.
59+
"""
60+
if data_range <= 0 or target_count < 1:
61+
return 1.0
62+
raw = data_range / target_count
63+
magnitude = 10 ** np.floor(np.log10(raw))
64+
residual = raw / magnitude
65+
if residual <= 1.0:
66+
nice = 1.0
67+
elif residual <= 2.0:
68+
nice = 2.0
69+
elif residual <= 5.0:
70+
nice = 5.0
71+
else:
72+
nice = 10.0
73+
return float(nice * magnitude)
74+
75+
76+
class ContourPlotParam(gds.DataSet):
77+
"""Parameters for contour-plot visualization."""
78+
79+
minimum = gds.FloatItem(_("Minimum value"), default=0.0)
80+
maximum = gds.FloatItem(_("Maximum value"), default=1.0)
81+
step = gds.FloatItem(_("Step between levels"), default=1.0, min=1e-12)
82+
show_image = gds.BoolItem(_("Overlay image"), default=True)
83+
show_labels = gds.BoolItem(_("Show level labels"), default=True)
84+
85+
86+
class ContourPlotPlugin(PluginBase):
87+
"""DataLab plugin exposing PlotPy contour plots for images."""
88+
89+
PLUGIN_INFO = PluginInfo(
90+
name=_("Contour isoline plot"),
91+
version="1.0.0",
92+
description=_(
93+
"Display isolines (contour lines) overlaid on the selected image, "
94+
"with configurable level range, step, and optional value labels"
95+
),
96+
)
97+
98+
def __init__(self) -> None:
99+
super().__init__()
100+
self._dialogs: list[PlotDialog] = []
101+
102+
def _get_selected_image(self) -> ImageObj | None:
103+
"""Return the single selected image object."""
104+
objects = self.imagepanel.objview.get_sel_objects(include_groups=False)
105+
if len(objects) != 1:
106+
return None
107+
return objects[0]
108+
109+
@staticmethod
110+
def _get_finite_range(obj: ImageObj) -> tuple[np.ndarray, float, float]:
111+
"""Return cleaned image data and its finite value range."""
112+
data = np.asarray(obj.data.real, dtype=np.float64)
113+
mask = obj.maskdata
114+
if mask is not None:
115+
finite = np.asarray(np.ma.array(data, mask=mask).compressed(), dtype=float)
116+
else:
117+
finite = data[np.isfinite(data)]
118+
if finite.size == 0:
119+
raise ValueError("Image has no finite data")
120+
min_value = float(finite.min())
121+
max_value = float(finite.max())
122+
cleaned = np.nan_to_num(
123+
data,
124+
nan=min_value,
125+
posinf=max_value,
126+
neginf=min_value,
127+
)
128+
if mask is not None:
129+
cleaned = np.where(mask, min_value, cleaned)
130+
return cleaned, min_value, max_value
131+
132+
@staticmethod
133+
def _build_levels(param: ContourPlotParam) -> np.ndarray:
134+
"""Create contour levels from plugin parameters."""
135+
start = param.minimum + param.step
136+
stop = param.maximum
137+
if start >= stop:
138+
return np.array([param.minimum + (param.maximum - param.minimum) / 2])
139+
levels = np.arange(start, stop, param.step, dtype=np.float64)
140+
# Round to avoid floating-point drift (e.g. 30.000000000000004)
141+
decimals = max(0, -int(np.floor(np.log10(param.step))) + 10)
142+
return np.round(levels, decimals)
143+
144+
@staticmethod
145+
def _make_contour_items(
146+
contour_data: np.ndarray,
147+
levels: np.ndarray,
148+
grid_x: np.ndarray,
149+
grid_y: np.ndarray,
150+
show_labels: bool,
151+
) -> list[AnnotatedPolygon]:
152+
"""Create annotated contour items with embedded level labels."""
153+
clines = compute_contours(contour_data, levels, grid_x, grid_y)
154+
items: list[AnnotatedPolygon] = []
155+
# Use the golden angle to spread labels around the perimeter
156+
golden_angle = (np.sqrt(5) - 1) / 2 # ≈ 0.618
157+
for idx, cline in enumerate(clines):
158+
level_text = f"{cline.level:g}"
159+
160+
def _info_cb(ann: AnnotatedPolygon, text: str = level_text) -> str:
161+
return f"Z = {text}"
162+
163+
ann_param = AnnotationParam(_("Annotation"), icon="annotation.png")
164+
ann_param.read_config(CONF, "plot", "shape/label")
165+
fraction = (idx * golden_angle) % 1.0
166+
item = _ContourAnnotatedPolygon(
167+
points=cline.vertices,
168+
closed=True,
169+
annotationparam=ann_param,
170+
info_callback=_info_cb,
171+
label_fraction=fraction,
172+
)
173+
item.set_style("plot", "shape/contour")
174+
item.setTitle(_("Contour") + f" Z={level_text}")
175+
item.set_label_visible(show_labels)
176+
items.append(item)
177+
return items
178+
179+
def _release_dialog(self, dialog: PlotDialog) -> None:
180+
"""Release a closed dialog reference."""
181+
if dialog in self._dialogs:
182+
self._dialogs.remove(dialog)
183+
dialog.deleteLater()
184+
185+
def show_contour_plot(self) -> None:
186+
"""Open a separate contour-plot view for the selected image."""
187+
obj = self._get_selected_image()
188+
if obj is None:
189+
self.show_warning(_("Select a single image first."))
190+
return
191+
192+
try:
193+
contour_data, data_min, data_max = self._get_finite_range(obj)
194+
except ValueError:
195+
self.show_warning(_("Selected image does not contain finite data."))
196+
return
197+
198+
if np.isclose(data_min, data_max):
199+
self.show_warning(_("Selected image is constant: no contour can be drawn."))
200+
return
201+
202+
current_item = self.imagepanel.plothandler.get(get_uuid(obj))
203+
default_min, default_max = data_min, data_max
204+
if current_item is not None:
205+
lut_min, lut_max = current_item.get_lut_range()
206+
if np.isfinite(lut_min) and np.isfinite(lut_max) and lut_min < lut_max:
207+
default_min, default_max = lut_min, lut_max
208+
209+
param = ContourPlotParam(_("Contour plot parameters"))
210+
param.minimum = default_min
211+
param.maximum = default_max
212+
param.step = _nice_step(default_max - default_min)
213+
if not param.edit(self.main):
214+
return
215+
216+
if param.maximum <= param.minimum:
217+
self.show_warning(_("Maximum value must be strictly greater than minimum."))
218+
return
219+
220+
levels = self._build_levels(param)
221+
xcoords, ycoords = get_obj_coords(obj)
222+
grid_x, grid_y = np.meshgrid(xcoords, ycoords)
223+
contour_items = self._make_contour_items(
224+
contour_data, levels, grid_x, grid_y, param.show_labels
225+
)
226+
if not contour_items:
227+
self.show_warning(_("No contour was found for the selected level range."))
228+
return
229+
230+
dlg = self.imagepanel.create_new_dialog(
231+
title=f"{obj.title} - {self.PLUGIN_INFO.name}",
232+
edit=False,
233+
name=f"{obj.PREFIX}_contour_plot",
234+
options={
235+
"show_contrast": True,
236+
"show_itemlist": True,
237+
"lock_aspect_ratio": True,
238+
"curve_antialiasing": False,
239+
},
240+
)
241+
if dlg is None:
242+
return
243+
244+
plot = dlg.get_plot()
245+
if param.show_image:
246+
adapter = create_adapter_from_object(obj)
247+
image_item = adapter.make_item(update_from=current_item)
248+
plot.add_item(image_item, z=0)
249+
plot.set_active_item(image_item)
250+
image_item.unselect()
251+
252+
for item in contour_items:
253+
plot.add_item(item)
254+
255+
plot.replot()
256+
self._dialogs.append(dlg)
257+
dlg.finished.connect(lambda _result, dialog=dlg: self._release_dialog(dialog))
258+
dlg.show()
259+
dlg.raise_()
260+
dlg.activateWindow()
261+
262+
def create_actions(self) -> None:
263+
"""Create plugin menu actions."""
264+
acth = self.imagepanel.acthandler
265+
with acth.new_menu(self.PLUGIN_INFO.name):
266+
acth.new_action(
267+
_("Show contour plot..."),
268+
triggered=self.show_contour_plot,
269+
select_condition="exactly_one",
270+
)

0 commit comments

Comments
 (0)