diff --git a/docs/visualize_show_spectra.md b/docs/visualize_show_spectra.md new file mode 100644 index 000000000..168ddcf55 --- /dev/null +++ b/docs/visualize_show_spectra.md @@ -0,0 +1,27 @@ +## Show Spectra for Selected Pixels (Developing) + +`ShowSpectra` is a class that is capable of handling user's selecting and clicking behavior by showing spectra of user selected pixels and storing selected coordinates as well as spectra. + +*class* **plantcv.visualize.ShowSpectra(spectral_data, figsize=(12,6))** +- To initialize the ShowSpectra class, the only required parameter is `spectral_data`, which is of type `__main__.Spectral_data`. +- Another optional parameter is the desired figure size `figsize`, by default `figsize=(12,6)`. + +### Attributes +**spectral_data** (`__main__.Spectral_data`, required): input hyperspectral image. + +**spectra** (`list`): spectra for all selected pixels. + +**points** (`list`): list of coordinates of selected pixels. + +```python + +from plantcv import plantcv as pcv + +show_spectra = pcv.visualize.ShowSpectra(spectral_data=array) + +``` + +Check out this video for a sample usage: + + +**Source Code:** [Here](https://github.com/danforthcenter/plantcv/blob/master/plantcv/plantcv/visualize/show_spectra.py) diff --git a/plantcv/plantcv/visualize/__init__.py b/plantcv/plantcv/visualize/__init__.py index 9ebef870d..2b2667ab2 100644 --- a/plantcv/plantcv/visualize/__init__.py +++ b/plantcv/plantcv/visualize/__init__.py @@ -9,6 +9,7 @@ from plantcv.plantcv.visualize.obj_sizes import obj_sizes from plantcv.plantcv.visualize.obj_size_ecdf import obj_size_ecdf from plantcv.plantcv.visualize.hyper_histogram import hyper_histogram +from plantcv.plantcv.visualize.show_spectra import ShowSpectra __all__ = ["pseudocolor", "colorize_masks", "histogram", "clustered_contours", "colorspaces", "auto_threshold_methods", - "overlay_two_imgs", "colorize_label_img", "obj_size_ecdf", "obj_sizes", "hyper_histogram"] + "overlay_two_imgs", "colorize_label_img", "obj_size_ecdf", "obj_sizes", "hyper_histogram", "ShowSpectra"] diff --git a/plantcv/plantcv/visualize/show_spectra.py b/plantcv/plantcv/visualize/show_spectra.py new file mode 100644 index 000000000..914445ea3 --- /dev/null +++ b/plantcv/plantcv/visualize/show_spectra.py @@ -0,0 +1,154 @@ +# Show spectral or spectra of mouse selected pixel(s) for a given hyperspectral image + +from scipy.spatial import distance +import numpy as np +import matplotlib.pyplot as plt +import matplotlib.patches as patches +from matplotlib.widgets import Slider +import cv2 + + +def _find_closest(pt, pts): + """ Given coordinates of a point and a list of coordinates of a bunch of points, find the point that has the smallest Euclidean to the given point + + :param pt: (tuple) coordinates of a point + :param pts: (a list of tuples) coordinates of a list of points + :return: index of the closest point and the coordinates of that point + """ + if pt in pts: + return pt + dists = distance.cdist([pt], pts, 'euclidean') + idx = np.argmin(dists) + return idx, pts[idx] + + +class ShowSpectra(object): + """ + An interactive visualization tool that shows spectral (spectra) for selected pixel(s). + """ + + def __init__(self, spectral_data, figsize=(12, 8)): + """ + Initialization + :param spectral_data: hyperspectral image data + :param figsize: desired figure size, (12,8) by default + """ + print("Warning: this tool is under development and is expected to have updates frequently, please check the documentation page to make sure you are using the correct version!") + + # initialize the pseudocolor rgb data (convert from BGR to RGB) + self.pseudo_rgb = cv2.cvtColor(spectral_data.pseudo_rgb, cv2.COLOR_BGR2RGB) + + self.fig, self.axes = plt.subplots(1, 2, figsize=figsize) + self.axes[0].imshow(self.pseudo_rgb) + self.axes[0].set_title("Please click on interested pixels\n Right click to remove") + + self.axes[1].set_xlabel("wavelength (nm)") + self.axes[1].set_ylabel("reflectance") + self.axes[1].set_title("Spectra") + self.axes[1].set_ylim([0, 1]) + + # adjust the main plot to make room for the sliders + plt.subplots_adjust(left=0.25, bottom=0.25) + + # make a horizontal slider to control the radius + axradius = self.fig.add_axes([0.15, 0.035, 0.3, 0.035], facecolor='lightgoldenrodyellow') # [left, bottom, width, height] + self.radius_slider = Slider( + ax=axradius, + label="radius", + valmin=0, + valmax=500, + valinit=1, + orientation="horizontal" + ) + + # Set useblit=True on most backends for enhanced performance. + # cursor = Cursor(axes[0], horizOn=True, vertOn=True, useblit=True, color='red', linewidth=2) + + self.points = [] + self.spectra = [] + self.events = [] + + self.array_data = spectral_data.array_data + self.wvs = [k for k in spectral_data.wavelength_dict.keys()] + + self.spectral_std = None + self.spectral_mean = None + + # initialize radius + self.r = 0 + self.x = None + self.y = None + self.rectangle = None + + self.fig.canvas.mpl_connect('button_press_event', self.onclick) + self.radius_slider.on_changed(self.update) + + def spectra_roi(self): + """Pull out the spectra inside a square ROI + """ + r = int(self.r) + y = int(self.y) + x = int(self.x) + kernel_ = np.ones((2 * r + 1, 2 * r + 1)) / (4 * r * r + 4 * r + 1) + + square = self.array_data[(y - r):(y + r + 1), (x - r):(x + r + 1), :] + + num_bands = square.shape[2] + + kernel = np.repeat(kernel_[:, :, np.newaxis], num_bands, axis=2) + + multiplied = np.multiply(square, kernel) + multiplied_spectra = np.reshape(multiplied, (-1, num_bands)) + + self.spectral_mean = multiplied_spectra.sum(axis=0) + self.spectral_std = multiplied_spectra.std(axis=0) + # only prepare the patch for plotting if r>1 (not only one pixel, but a square of pixels) + if r > 0: + self.rectangle = patches.Rectangle((x - r, y - r), 2 * r, 2 * r, edgecolor="red", fill=False) + + def onclick(self, event): + self.events.append(event) + if str(event.inaxes._subplotspec) == 'GridSpec(1, 2)[0:1, 0:1]': + if event.button == 1: + self.x, self.y = event.xdata, event.ydata + self.axes[0].plot(event.xdata, event.ydata, 'x', c='red') + self.spectra_roi() + if self.r > 1: + self.axes[0].add_patch(self.rectangle) + + self.axes[1].errorbar(self.wvs, self.spectral_mean, xerr=self.spectral_std / 2) + self.points.append((event.xdata, event.ydata)) + else: + idx_remove, _ = _find_closest((event.xdata, event.ydata), self.points) + # remove the last added point + # idx_remove = -1 + + # remove the closest point to the user right clicked one + self.points.pop(idx_remove) + if len(self.points) > 0: + self.x, self.y = self.points[-1] + ax0plots = self.axes[0].lines + ax0patches = self.axes[0].patches + ax1plots = self.axes[1].lines + self.axes[0].lines.remove(ax0plots[idx_remove]) + if len(ax0patches) > 0: + self.axes[0].patches.remove(ax0patches[idx_remove]) + self.axes[1].lines.remove(ax1plots[idx_remove]) + self.fig.canvas.draw() + + def update(self, val): + self.r = self.radius_slider.val + self.spectra_roi() + + # remove old plots + idx_remove = -1 + ax0patches = self.axes[0].patches + if len(ax0patches) > 0: + self.axes[0].patches.remove(ax0patches[idx_remove]) + ax1plots = self.axes[1].lines + self.axes[1].lines.remove(ax1plots[idx_remove]) + + # add new plots + self.axes[0].add_patch(self.rectangle) + self.axes[1].errorbar(self.wvs, self.spectral_mean, xerr=self.spectral_std / 2) + self.fig.canvas.draw_idle() diff --git a/tests/tests.py b/tests/tests.py index 078b10e44..d48fb1fb3 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -6492,6 +6492,68 @@ def test_plantcv_visualize_overlay_two_imgs_bad_alpha(): with pytest.raises(RuntimeError): _ = pcv.visualize.overlay_two_imgs(img1=img1, img2=img2, alpha=alpha) +def test_plantcv_visualize_show_spectra(): + # read hypersectral data + spectral_filename = os.path.join(HYPERSPECTRAL_TEST_DATA, HYPERSPECTRAL_DATA) + array_data = pcv.hyperspectral.read_data(filename=spectral_filename) + + # initialization + show_spectra = pcv.visualize.ShowSpectra(array_data, figsize=(12, 6)) + assert len(show_spectra.events) == 0 + + # create mock events + e1 = matplotlib.backend_bases.MouseEvent(name="button_press_event", canvas=show_spectra.fig.canvas, x=0, y=0,button=1) + e1.inaxes = show_spectra.axes[0] + e1.inaxes._subplotspec = show_spectra.axes[0]._subplotspec + e1.xdata = 0 + e1.ydata = 0 + + e2 = matplotlib.backend_bases.MouseEvent(name="button_press_event", canvas=show_spectra.fig.canvas, x=0, y=0, button=3) + e2.inaxes = show_spectra.axes[0] + e2.inaxes._subplotspec = show_spectra.axes[0]._subplotspec + e2.xdata = 0 + e2.ydata = 0 + + e1_ = matplotlib.backend_bases.MouseEvent(name="button_press_event", canvas=show_spectra.fig.canvas, x=0, y=0,button=1) + e1_.inaxes = show_spectra.axes[0] + e1_.inaxes._subplotspec = show_spectra.axes[0]._subplotspec + e1_.xdata = 0 + e1_.ydata = 0 + + e3 = matplotlib.backend_bases.MouseEvent(name="button_press_event", canvas=show_spectra.fig.canvas, x=1, y=0, button=3) + e3.inaxes = show_spectra.axes[0] + e3.inaxes._subplotspec = show_spectra.axes[0]._subplotspec + e3.xdata = 1 + e3.ydata = 0 + + show_spectra.onclick(e1) + show_spectra.onclick(e2) + show_spectra.onclick(e1_) + show_spectra.onclick(e1_) + show_spectra.onclick(e3) + + assert len(show_spectra.events) == 5 + + # test for updating + # initialization + array_d = array_data.array_data + array_data.array_data = np.concatenate((array_d, array_d, array_d, array_d, array_d), axis=0) + show_spectra = pcv.visualize.ShowSpectra(array_data, figsize=(12, 6)) + e1 = matplotlib.backend_bases.MouseEvent(name="button_press_event", canvas=show_spectra.fig.canvas, x=1, y=1,button=1) + e1.inaxes = show_spectra.axes[0] + e1.inaxes._subplotspec = show_spectra.axes[0]._subplotspec + e1.xdata = 2 + e1.ydata = 2 + show_spectra.onclick(e1) + val = 2 + show_spectra.radius_slider.val = val + show_spectra.update(val) + show_spectra.onclick(e1) + show_spectra.update(val) + show_spectra.onclick(e2) + assert len(show_spectra.axes[0].patches) > 0 + show_spectra.onclick(e1) + assert len(show_spectra.axes[0].patches) > 0 def test_plantcv_visualize_overlay_two_imgs_size_mismatch(): pcv.params.debug = None