Source code for pyccapt.calibration.core.interactive_point_identification

"""Interactive peak annotation helpers for matplotlib plots."""

from __future__ import annotations

import math

import matplotlib.pyplot as plt


[docs] def distance(x1: float, x2: float, y1: float, y2: float) -> float: """Return Euclidean distance between two 2D points.""" return math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)
[docs] def distances(x1: float, x2: float, y1: float, y2: float) -> float: """Backward-compatible alias for :func:`distance`.""" return distance(x1, x2, y1, y2)
[docs] class AnnotationFinder: """ Matplotlib callback that selects and deselects nearest annotated peaks. Left click selects the nearest point within tolerance. Right click deselects it. """ def __init__(self, xdata, ydata, annotations, variables, ax=None, xtol=None, ytol=None): if len(xdata) != len(ydata) or len(xdata) != len(annotations): raise ValueError("xdata, ydata, and annotations must have matching lengths") if len(xdata) == 0: raise ValueError("xdata cannot be empty") self.data = list(zip(xdata, ydata, annotations)) if xtol is None: xtol = ((max(xdata) - min(xdata)) / float(len(xdata))) / 2 if ytol is None: ytol = ((max(ydata) - min(ydata)) / float(len(ydata))) / 2 self.xtol = xtol self.ytol = ytol self.ax = plt.gca() if ax is None else ax self.drawn_annotations = {} self.links = [] self.variables = variables @staticmethod def _annotation_to_index(annotation) -> int: return int(annotation) - 1
[docs] def annotate_plotter(self, event) -> None: """Handle matplotlib click events and update selected annotations.""" if not event.inaxes: return click_x = event.xdata click_y = event.ydata if (self.ax is not None) and (self.ax is not event.inaxes): return candidates = [] for x, y, annotation in self.data: if (click_x - self.xtol < x < click_x + self.xtol) and (click_y - self.ytol < y < click_y + self.ytol): candidates.append((distance(x, click_x, y, click_y), x, y, annotation)) if not candidates: return _, x, y, annotation = min(candidates, key=lambda item: item[0]) if event.button == 3: self.deselect_point(event.inaxes, x, y, annotation) else: self.draw_annotation(event.inaxes, x, y, annotation) for linked in self.links: linked.draw_specific_annotation(annotation)
[docs] def draw_annotation(self, ax, x, y, annotation) -> None: """Draw one annotation and register it in shared state.""" if (x, y) in self.drawn_annotations: return annotation_text = ax.text(x - 0.8, y, str(annotation), ha="right", va="center") marker = ax.scatter([x], [y], marker="H", c="r", zorder=100) self.drawn_annotations[(x, y)] = (annotation_text, marker) self.ax.figure.canvas.draw_idle() point_index = self._annotation_to_index(annotation) if x not in self.variables.peaks_x_selected: self.variables.peaks_x_selected.append(x) self.variables.peaks_x_selected.sort() if point_index not in self.variables.peaks_index_list: self.variables.peaks_index_list.append(point_index) self.variables.peaks_index_list.sort()
[docs] def deselect_point(self, ax, x, y, annotation) -> None: """Hide one annotation and remove it from shared state if present.""" if (x, y) in self.drawn_annotations: markers = self.drawn_annotations[(x, y)] for marker in markers: marker.set_visible(not marker.get_visible()) self.ax.figure.canvas.draw_idle() point_index = self._annotation_to_index(annotation) if x in self.variables.peaks_x_selected: self.variables.peaks_x_selected.remove(x) self.variables.peaks_x_selected.sort() if point_index in self.variables.peaks_index_list: self.variables.peaks_index_list.remove(point_index) self.variables.peaks_index_list.sort()
[docs] def draw_specific_annotation(self, annotation) -> None: """Draw annotation for every matching point label.""" annotations_to_draw = [(x, y, label) for x, y, label in self.data if label == annotation] for x, y, label in annotations_to_draw: self.draw_annotation(self.ax, x, y, label)
[docs] def annotates_plotter(self, event) -> None: """Backward-compatible wrapper for legacy method name.""" self.annotate_plotter(event)
[docs] def drawAnnote(self, ax, x, y, annotation) -> None: """Backward-compatible wrapper for legacy method name.""" self.draw_annotation(ax, x, y, annotation)
[docs] def deselectPoint(self, ax, x, y, annotation) -> None: """Backward-compatible wrapper for legacy method name.""" self.deselect_point(ax, x, y, annotation)
[docs] def drawSpecificAnnote(self, annotation) -> None: """Backward-compatible wrapper for legacy method name.""" self.draw_specific_annotation(annotation)
[docs] class AnnoteFinder(AnnotationFinder): """Backward-compatible class alias with legacy name."""