diff --git a/docs/Points.md b/docs/Points.md index 0d1d169..db17d90 100644 --- a/docs/Points.md +++ b/docs/Points.md @@ -1,6 +1,6 @@ ## Interactive Point Annotation Tool -Using [Jupyter Notebooks](https://plantcv.readthedocs.io/en/stable/jupyter/) it is possible to interactively click to collect coordinates from an image, which can be used in various downstream applications. Left click on the image to collect a point. Right click removes the +Using [Jupyter Notebooks](https://plantcv.readthedocs.io/en/stable/jupyter/) it is possible to interactively click on an image to collect coordinates, which can be used in various downstream applications. Left click on the image to collect a point. Right click removes the closest collected point. **plantcv.annotate.Points**(*img, figsize=(12,6), label="dafault"*) @@ -13,9 +13,9 @@ closest collected point. - label - The current label (default = "default") - **Attributes:** - - coords - dictionary of all coordinates per group label + - coords - dictionary of all coordinates per sample label - events - includes right and left click events - - count - dictionary that save the counts of different groups (labels) + - count - dictionary that save the counts of different groups (sample_labels) - label - the current label - sample_labels - list of all sample labels, one to one with coordinates collected - view_all - flag indicating whether or not to view all labels @@ -25,10 +25,10 @@ closest collected point. - **Context:** - Used to define a list of coordinates of interest. - - + - Can be helpful to ground truth counting algorithms, and exported coordinates could be used in other image analysis workflows. - **Example use:** - - (pcv.roi.multi) - - (pcv.roi.custom) + - [pcv.roi.multi](https://plantcv.readthedocs.io/en/stable/roi_multi/) + - Shown below [pcv.roi.custom](https://plantcv.readthedocs.io/en/stable/roi_custom/) ```python @@ -36,7 +36,7 @@ import plantcv.plantcv as pcv import plantcv.annotate as an # Create an instance of the Points class -marker = an.Points(img=img, figsize=(12,6)) +marker = an.Points(img=img, figsize=(12,6), label='default') # Click on the plotted image to collect coordinates @@ -45,5 +45,71 @@ roi = pcv.roi.custom(img=img, vertices=marker.coords['default']) ``` +## Methods +### Correct a Mask using Point Annotations + +Using [Jupyter Notebooks](https://plantcv.readthedocs.io/en/stable/jupyter/) it is possible to interactively click to collect coordinates from an image, then use these coordinate to remove and recover objects from a binary mask. + +**plantcv.annotate.Points.correct_mask**(*mask*) + +**returns** corrected_mask, lbls, num + +- **Parameters:** + - mask - binary or labeled mask image, filtered mask image with selected objects, or the output from [`plantcv.watershed`](https://plantcv.readthedocs.io/en/stable/watershed/) + +- **Returns:** + - corrected_mask - A labeled mask with non-annotated objects filtered out, and unresolvable annotations marked with a labeled pixel. + - lbls - A list of class labels ordered the same as the corrected mask object IDs + - num - The number of unique objects in the `corrected_mask`. + +- **Context:** + - Filters objects from the `bin_mask` if they do not overlap with an annotation in the `Points` class instance. + - Adds a labeled pixel to the corrected mask if an object cannot be resolved for any annotations (false negatives can be counted but cannot have their size measured downstream). + - Returns the number of unique objects in the `corrected_mask` which is useful for downstream analysis. + - Debug image is a colorized representation of the labeled mask. The "unresolved" annotation replicates are plotted with a radius of `pcv.params.line_thickness` (default = 5). + - Hint: set `pcv.params.text_size=0` to skip ID labeling and instead only plot the annotation coordinate. + +- **Example use:** + - Remove noise from a microscopy image that is otherwise difficult to filter out with traditional computer vision + techniques, and recover stomata that were filtered out during mask cleaning. + +**bin_img** + +![Screenshot](img/documentation_images/points_correct_mask/bin_mask.png) + +**Original Image with "auto-detected" Annotations** + +![Screenshot](img/documentation_images/points_correct_mask/auto_annotated_stomata.png) + +```python +import plantcv.plantcv as pcv +import plantcv.annotate as pcvan + +# Create an instance of the Points class +img, path, name = pcv.readimage("stomata.tif") + +# Segmentation & mask clean up, get_centroids steps here + +# Create an instance of the Points class & click on stomata +marker = pcvan.Points(img=img, figsize=(12,6)) +marker.import_list(coords=centroid_coords, label="stomata") + +# Filter the binary mask based on corrected annotations +corrected_mask, lbls, num = marker.correct_mask(mask=bin_mask) + +# Analysis steps here +size_img = pcv.analyze.size(img=img, labeled_mask=corrected_mask, n_labels=num, label=lbls) +``` +**Annotations After Human Corrections** + +![Screenshot](img/documentation_images/points_correct_mask/annotated_stomata.png) + +**Corrected & Labeled Mask** + +![Screenshot](img/documentation_images/points_correct_mask/colorized_label_img.png) + +**[Size](https://plantcv.readthedocs.io/en/stable/analyze_size/) Analysis Image** + +![Screenshot](img/documentation_images/points_correct_mask/shape_img.png) **Source Code:** [Here](https://github.com/danforthcenter/plantcv-annotate/blob/main/plantcv/annoate/classes.py) diff --git a/docs/img/documentation_images/points_correct_mask/annotated_stomata.png b/docs/img/documentation_images/points_correct_mask/annotated_stomata.png new file mode 100644 index 0000000..56402d0 Binary files /dev/null and b/docs/img/documentation_images/points_correct_mask/annotated_stomata.png differ diff --git a/docs/img/documentation_images/points_correct_mask/auto_annotated_stomata.png b/docs/img/documentation_images/points_correct_mask/auto_annotated_stomata.png new file mode 100644 index 0000000..1c61523 Binary files /dev/null and b/docs/img/documentation_images/points_correct_mask/auto_annotated_stomata.png differ diff --git a/docs/img/documentation_images/points_correct_mask/bin_mask.png b/docs/img/documentation_images/points_correct_mask/bin_mask.png new file mode 100644 index 0000000..f797d9a Binary files /dev/null and b/docs/img/documentation_images/points_correct_mask/bin_mask.png differ diff --git a/docs/img/documentation_images/points_correct_mask/colorized_label_img.png b/docs/img/documentation_images/points_correct_mask/colorized_label_img.png new file mode 100644 index 0000000..689f101 Binary files /dev/null and b/docs/img/documentation_images/points_correct_mask/colorized_label_img.png differ diff --git a/docs/img/documentation_images/points_correct_mask/shape_img.png b/docs/img/documentation_images/points_correct_mask/shape_img.png new file mode 100644 index 0000000..a37e239 Binary files /dev/null and b/docs/img/documentation_images/points_correct_mask/shape_img.png differ diff --git a/plantcv/annotate/classes.py b/plantcv/annotate/classes.py index 455701e..a261b46 100644 --- a/plantcv/annotate/classes.py +++ b/plantcv/annotate/classes.py @@ -1,12 +1,17 @@ # Class helpers # Imports +import os import cv2 import json +import numpy as np from math import floor import matplotlib.pyplot as plt from plantcv.plantcv.annotate.points import _find_closest_pt -from plantcv.plantcv import warn +from plantcv.plantcv import warn, params, dilate +from plantcv.plantcv._debug import _debug +from plantcv.plantcv import create_labels, apply_mask +from plantcv.plantcv.visualize import colorize_label_img class Points: @@ -158,3 +163,392 @@ class label, by default "default" else: for (x, y) in self.coords[self.label]: self.ax.plot(x, y, marker='x', c=self.color) + + def _create_pts_mask(self, mask, labelnames): + """Fitler a binary mask based on annotations. + + Parameters + ---------- + mask : numpy.ndarray + mask for image size + + Returns + ---------- + pts_mask : numpy.ndarray + binary mask of annotations + """ + pts_mask = np.zeros(np.shape(mask), np.uint8) + # Create points mask from all annotations + pts_all = sum(self.coords.values(), []) + for pt in pts_all: + (x, y) = pt + # Draw pt annotations onto a blank mask + pts_mask = cv2.circle(pts_mask, (int(x), int(y)), radius=0, color=(255), thickness=-1) + + return pts_mask + + def correct_mask(self, mask): + """Fitler a binary mask and make a labeled mask for analysis. + + Parameters + ---------- + mask : numpy.ndarray + binary image or labeled mask, mask to get corrected + + Returns + ---------- + final_mask : numpy.ndarray + corrected and labeled mask with recovered and removed objects + analysis_labels : list + list of analysis labels in the same order of the objects in the final_mask + num : int + number of objects represented within the labeled mask + """ + debug = params.debug + params.debug = None + # Initialize lists and arrays + all_class_coords = self.coords.items() + unrecovered_ids = [] + debug_coords = [] + debug_labels = [] + added_obj_labels = [] + analysis_labels = [] + labelnames = list(self.count) + final_mask = np.zeros(np.shape(mask), np.uint32) + debug_img = np.zeros(np.shape(mask), np.uint8) + debug_img_duplicates = debug_img.copy() + pts_all = sum(self.coords.values(), []) + labels_all = [] + for pt in pts_all: + coord_class_label = [k for k, v in all_class_coords if pt in v] + labels_all.append(coord_class_label) + # Remove unannotated objects from mask + bin_mask = np.where(mask > 0, 255, 0).astype(np.uint8) + pts_mask = self._create_pts_mask(bin_mask, labelnames) + labeled_mask_all, debug_img_removed = _remove_unannotated_objects(pts_mask, mask) + + # Create a new labeled annotation mask to determine number of annotation per object + masked_image2 = apply_mask(img=labeled_mask_all, mask=pts_mask, mask_color='black') + keep_pixel_vals, keep_object_count = np.unique(masked_image2, return_counts=True) + # Initialize object count + object_id_count = 1 + # coords in Points class used for recovering and labeling + for p, current_pt in enumerate(pts_all): + x = int(current_pt[0]) + y = int(current_pt[1]) + names = labels_all[p] + mask_pixel_value = labeled_mask_all[y, x] + # Check if current annotation can be resolved to an object in the mask + if mask_pixel_value == 0: + warn(f"Object could not be resolved at coordinate: x = {x}, y = {y}") + unrecovered_ids.append(object_id_count) + added_obj_labels.append(object_id_count) + analysis_labels.append(names) + # Add ID to debug img + debug_labels, debug_coords = _add_debug_id(debug_labels, debug_coords, + object_id_count, (x, y)) + # Add the unresolved object to labeled mask and debug img + debug_img, final_mask, object_id_count = _draw_unresolved_object(debug_img, + final_mask, + obj_number=object_id_count, + coord=(x, y)) + else: + # An object is resolved, check if there are other annotations associated with an object + mask_pixel_index = np.where(keep_pixel_vals == mask_pixel_value)[0] + associated_count = keep_object_count[mask_pixel_index] + if associated_count == 1: + # New object getting added + added_obj_labels.append(mask_pixel_value) + analysis_labels.append(names) + # Add to labeled mask and debug img + debug_labels, debug_coords = _add_debug_id(debug_labels, debug_coords, + object_id_count, (x, y)) + debug_img, final_mask, object_id_count = _draw_resolved(debug_img, final_mask, labeled_mask_all, + mask_pixel_value, object_id_count) + if associated_count > 1: + # More than one annotation for given object, has object been handled already? + if mask_pixel_value not in added_obj_labels: + # Find all associated annotations + associated_coords = np.where(masked_image2 == mask_pixel_value) + associated_coords = tuple(zip(*associated_coords)) + first_coord = (associated_coords[0][1], associated_coords[0][0]) + coord_labels = [] + # Find all class labels for each annotation + coord_class_label, coord_labels = _find_all_labels(associated_coords, all_class_coords, coord_labels) + # Is there more than one class label associated with the given object? + flat_coord_labels = np.concatenate(coord_labels) + re, lbl_counts = np.unique(flat_coord_labels, return_counts=True) + if len(re) == 1: + # Labels are duplicated e.g. "total", "total" + # Draw the ghost of objects removed + debug_img_duplicates = np.where(labeled_mask_all == mask_pixel_value, + (255), debug_img_duplicates) + # Fill in the duplicate object in the labeled mask, replace with pixel annotations + final_mask = np.where(labeled_mask_all == mask_pixel_value, (0), final_mask) + added_obj_labels.append(mask_pixel_value) + for dup_coord in associated_coords: + # Draw each pixel in the final mask + final_mask[dup_coord] = object_id_count + analysis_labels.append(names) + # Add a thicker pixel where unresolved annotation to the debug img + cv2.circle(debug_img, (dup_coord[1], dup_coord[0]), radius=params.line_thickness, + color=(object_id_count), thickness=-1) + # Add debug ID + debug_labels, debug_coords = _add_debug_id(debug_labels, debug_coords, + object_id_count, (dup_coord[1], dup_coord[0])) + # Increment object count up so each pixel drawn in labeled mask is unique + object_id_count += 1 + if len(re) > 1: + # Is there duplication within each class label for the given object? + labels_are_unique = all(x == 1 for x in lbl_counts) + if labels_are_unique: + # If no duplication, Concat with "_" delimiter + concat_lbl = "_".join(list(re)) + warn(f"labels getting concatenated to '{concat_lbl}' at {first_coord}") + # Adding the object + added_obj_labels.append(mask_pixel_value) + analysis_labels.append(concat_lbl) + debug_labels, debug_coords = _add_debug_id(debug_labels, debug_coords, + object_id_count, first_coord) + debug_img, final_mask, object_id_count = _draw_resolved( + debug_img, final_mask, labeled_mask_all, mask_pixel_value, object_id_count) + else: + # e.g. "total", "total", "germinated" is too complex to measure + warn(f"The object at {first_coord} was removed for being too complex. " + "It was associated with the following labels: {flat_coord_labels}") + added_obj_labels.append(mask_pixel_value) + # Draw the ghost of objects removed + debug_img_duplicates = np.where(labeled_mask_all == mask_pixel_value, + (255), debug_img_duplicates) + # Fill in the duplicate object in the labeled mask + final_mask = np.where(labeled_mask_all == mask_pixel_value, (0), final_mask) + # ADD PIXEL ANNOTATIONS TO FINAL MASK AND TO DEBUG + # Can count but cannot measure (likely touching objects) + for i, dup_coord in enumerate(associated_coords): + final_mask[dup_coord] = object_id_count + analysis_labels.append(coord_labels[i]) + cv2.circle(debug_img, (dup_coord[1], dup_coord[0]), + radius=params.line_thickness, color=(object_id_count), + thickness=-1) + debug_labels, debug_coords = _add_debug_id(debug_labels, debug_coords, + object_id_count, + (dup_coord[1], dup_coord[0])) + object_id_count += 1 + # Combine and colorize components of the debug image + debug_img_duplicates_rgb = _draw_ghost_of_duplicates_removed(debug_img_duplicates) + debug_img = colorize_label_img(debug_img) + debug_img = debug_img + debug_img_removed + debug_img_duplicates_rgb + + _write_label_ids(debug_labels, debug_img, debug_coords) + params.debug = debug + _debug(visual=final_mask, + filename=os.path.join(params.debug_outdir, + f"{params.device}_annotation-corrected.png")) + _debug(visual=debug_img, + filename=os.path.join(params.debug_outdir, + f"{params.device}_annotation-corrected-debug.png")) + # Count the number of objects in the final mask + num = len(np.unique(final_mask)) - 1 + + return final_mask, analysis_labels, num + + +def _write_label_ids(debug_labels, debug_img, debug_coords): + """Write ID labels on debug image. + + Parameters + ---------- + debug_labels : list + binary image, mask with all annotations plotted as pixels + debug_img : numpy.ndarray + debug image + debug_coords : list + list of coordinates of annotations + + Returns + ---------- + """ + # Write ID labels + for id, id_label in enumerate(debug_labels): + cv2.putText(img=debug_img, text=id_label, org=debug_coords[id], fontFace=cv2.FONT_HERSHEY_SIMPLEX, + fontScale=params.text_size, color=(150, 150, 150), thickness=params.text_thickness) + + +def _find_all_labels(associated_coords, all_class_coords, coord_labels): + """Find all class labels for each annotation + + Parameters + ---------- + associated_coords : list + list of coordinates associated with a given object + all_class_coords : list + all coordinates stored in the annotation object class + coord_labels : list + list of object labels + + Returns + ---------- + coord_class_label : list + coordinate class labels + coord_labels : list + coordinate labels + """ + for dup_coord in associated_coords: + # Flip x & y for numpy, and find the associated class label with each coordinate + coord_class_label = [k for k, v in all_class_coords if (dup_coord[1], dup_coord[0]) in v] + coord_labels.append(coord_class_label) + return coord_class_label, coord_labels + + +def _remove_unannotated_objects(pts_mask, mask): + """Fitler a mask based on annotations, handles both binary and labeled masks. + + Parameters + ---------- + pts_mask : numpy.ndarray + binary image, mask with all annotations plotted as pixels + mask : numpy.ndarray + input mask image, mask to get corrected + + Returns + ---------- + filetered_mask : numpy.ndarray + corrected mask + debug_img_removed : numpy.ndarray + binary mask of objects that were removed + """ + debug_img_removed = cv2.cvtColor(pts_mask.copy(), cv2.COLOR_GRAY2RGB) + # Create a labeled mask from the input mask + input_type = len(np.unique(mask)) + if input_type == 2: + labeled_mask, total_obj_num = create_labels(mask=mask) + if input_type > 2: + labeled_mask = np.copy(mask) + total_obj_num = input_type + # Objects that overlap with one or more annotations get kept + masked_image = apply_mask(img=labeled_mask, mask=pts_mask, mask_color='black') + keep_object_ids = np.unique(masked_image) + + # Fill in objects that are not overlapping with an annotation + for i in range(1, total_obj_num + 1): + if i not in keep_object_ids: + labeled_mask[np.where(labeled_mask == i)] = 0 + debug_img_removed[np.where(labeled_mask == i)] = (50, 50, 50) + + return labeled_mask, debug_img_removed + + +def _draw_unresolved_object(debug_img, final_mask, obj_number, coord): + """Draw unresolved objects as a labeled pixel + + Parameters + ---------- + debug_img : numpy.ndarray + debug image of objects, unresolved annotations, etc + final_mask : numpy.ndarray + corrected and labeled mask with recovered and removed objects + obj_number : int + ID number for the current object getting added + coord : tuple + coordinate of the unresolved object annotation + + Returns + ---------- + debug_img : numpy.ndarray + debug image of objects, unresolved annotations, etc + final_mask : numpy.ndarray + corrected and labeled mask with recovered and removed objects + obj_number : int + ID number for the current object getting added + """ + (x, y) = coord + # Add a pixel where unresolved annotation to the mask + final_mask[y, x] = obj_number + # Add a thicker pixel where unresolved annotation to the debug img + cv2.circle(debug_img, (x, y), radius=params.line_thickness, color=(obj_number), thickness=-1) + # Increment ID number up by one + obj_number += 1 + return debug_img, final_mask, obj_number + + +def _draw_resolved(debug_img, final_mask, pre_lbls_mask, mask_pixel_value, obj_number): + """Draw resolved/measurable objects + + Parameters + ---------- + debug_img : numpy.ndarray + debug image of objects, unresolved annotations, etc + final_mask : numpy.ndarray + corrected and labeled mask with recovered and removed objects + pre_lbls_mask : numpy.ndarray + labeled mask of all resolvable objects + mask_pixel_value : int + ID number for the current resolved object in pre_lbls_mask + obj_number : int + ID number for the current object getting added + + Returns + ---------- + debug_img : numpy.ndarray + debug image of objects, unresolved annotations, etc + final_mask : numpy.ndarray + corrected and labeled mask with recovered and removed objects + obj_number : int + ID number for the current object getting added + """ + # Add a pixel where unresolved annotation to the mask + # Draw on labeled mask + final_mask = np.where(pre_lbls_mask == mask_pixel_value, obj_number, final_mask) + debug_img = np.where(pre_lbls_mask == mask_pixel_value, obj_number, debug_img) + # Increment ID number up by one + obj_number += 1 + return debug_img, final_mask, obj_number + + +def _draw_ghost_of_duplicates_removed(dupes_mask): + """Fitler a binary mask based on annotations. + + Parameters + ---------- + dupes_mask : numpy.ndarray + binary image, mask with all removed (because duplicate annotations) objects + removed_mask : numpy.ndarray + binary image, mask of unannotated and removed objects + + Returns + ---------- + removed_mask : numpy.ndarray + combined mask with all removed objects for debug visualization + """ + # Dilate duplicate objs and subtract the object itself to leave just a halo around + debug_img_duplicates = dilate(dupes_mask, ksize=params.line_thickness+2, i=1) + debug_img_duplicates = debug_img_duplicates - dupes_mask + debug_img_duplicates = cv2.cvtColor(debug_img_duplicates, cv2.COLOR_GRAY2RGB) + return debug_img_duplicates + + +def _add_debug_id(debug_labels_list, debug_coords_list, id, coord): + """Updates the list of IDs and coordinates for labeling things + in the debugging image + + Parameters + ---------- + debug_labels_list : list + list of ID labels for the debug image + debug_coords_list : list + list of coordinates for ID labels in the debug image + id : int + current object ID number + coord : tuple + coordinate resovled to the current object + + Returns + ---------- + debug_labels_list : list + updated corrected mask + debug_coords_list : list + updated list of coordinates for ID labels in the debug image + """ + debug_labels_list.append(str(id)) + debug_coords_list.append(coord) + return debug_labels_list, debug_coords_list diff --git a/pyproject.toml b/pyproject.toml index 5abfaf1..1204781 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "napari", "PyQt5" ] -requires-python = ">=3.6" +requires-python = ">=3.8" authors = [ {name = "PlantCV Team", email = "plantcv@danforthcenter.org"}, ] diff --git a/tests/conftest.py b/tests/conftest.py index 5ce1fa6..55df946 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,7 +22,11 @@ def __init__(self): self.pollen_coords = os.path.join(self.datadir, "points_file_import.coords") # Kmeans Clustered Gray image self.kmeans_seed_gray_img = os.path.join(self.datadir, "silphium_seed_labeled_example.png") - # Small Hyperspectral image + # Binary mask including all pollen grainss + self.pollen_all = os.path.join(self.datadir, "pollen_all_mask.png") + # Binary mask Eccentricity filtered objects + self.pollen_discs = os.path.join(self.datadir, "pollen_detectdisc_mask.png") + # Small HSI filename_hyper = "corn-kernel-hyperspectral.raw" self.envi_sample_data = os.path.join(self.datadir, filename_hyper) # Coordinates File diff --git a/tests/test_annotate_points.py b/tests/test_annotate_points.py index d7828b3..51c8aae 100644 --- a/tests/test_annotate_points.py +++ b/tests/test_annotate_points.py @@ -2,6 +2,7 @@ import os import cv2 import matplotlib +import numpy as np from plantcv.annotate.classes import Points @@ -155,5 +156,36 @@ def test_points_view_warn(test_data): e1.xdata, e1.ydata = point1 drawer_rgb.onclick(e1) drawer_rgb.view(label="new", color='r') - assert str(drawer_rgb.fig) == "Figure(500x500)" + + +def test_plantcv_annotate_points_correct_mask(test_data): + """Test for PlantCV-Annotate""" + allmask = cv2.imread(test_data.pollen_all, -1) + discs = cv2.imread(test_data.pollen_discs, -1) + totalpoints1 = [(157, 529), + (235, 438), + (268, 394), + (295, 451), + (511, 349), + (525, 277), + (290, 97), + (3,3)] + dupe_pts = [(281, 100), (525, 274)] + counter = Points(allmask, figsize=(8, 6)) + counter.import_list(totalpoints1, label="total") + counter.import_list(dupe_pts, label="dupe") + + corrected_mask, _, _ = counter.correct_mask(mask=allmask) + assert np.count_nonzero(corrected_mask) < np.count_nonzero(discs) + assert np.count_nonzero(corrected_mask) < np.count_nonzero(allmask) + +def test_plantcv_annotate_points_correct_mask_labeled(test_data): + """Test for PlantCV-Annotate""" + lbl_mask = cv2.imread(test_data.kmeans_seed_gray_img, -1) + dupe_pts = [(281, 100), (525, 274)] + counter = Points(lbl_mask, figsize=(8, 6)) + counter.import_list(dupe_pts, label="total") + corrected_mask, _, _ = counter.correct_mask(mask=lbl_mask) + assert np.count_nonzero(corrected_mask) < np.count_nonzero(lbl_mask) + diff --git a/tests/testdata/pollen_all_mask.png b/tests/testdata/pollen_all_mask.png new file mode 100644 index 0000000..6f42290 Binary files /dev/null and b/tests/testdata/pollen_all_mask.png differ diff --git a/tests/testdata/pollen_detectdisc_mask.png b/tests/testdata/pollen_detectdisc_mask.png new file mode 100644 index 0000000..e8fcbb7 Binary files /dev/null and b/tests/testdata/pollen_detectdisc_mask.png differ