|
| 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