Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 96 additions & 56 deletions segmenter/planktoscope/segmenter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,74 +331,70 @@ def _get_color_info(self, bgr_img, mask):
# "object_maxValue": v_quartiles[6],
}

def _extract_metadata_from_regionprop(self, prop):
def _extract_metadata_from_regionprop(self, prop, pixel_size_um=None):
"""Extract morphological metadata from a scikit-image regionprop.

Args:
prop: scikit-image regionprop object
pixel_size_um (float or None): pixel size in µm/pixel (process_pixel).
If provided, linear measurements are in µm and area measurements in µm².
If None, all measurements remain in pixel units.
"""
# Scale factors: linear (µm/px) and area (µm²/px²)
px = pixel_size_um if pixel_size_um and pixel_size_um > 0 else 1.0
px2 = px * px

return {
"label": prop.label,
# width of the smallest rectangle enclosing the object
"width": prop.bbox[3] - prop.bbox[1],
# height of the smallest rectangle enclosing the object
"height": prop.bbox[2] - prop.bbox[0],
# X coordinates of the top left point of the smallest rectangle enclosing the object
# width of the smallest rectangle enclosing the object (µm if calibrated)
"width": (prop.bbox[3] - prop.bbox[1]) * px,
# height of the smallest rectangle enclosing the object (µm if calibrated)
"height": (prop.bbox[2] - prop.bbox[0]) * px,
# X coordinates of the top left point of the smallest rectangle enclosing the object (pixels)
"bx": prop.bbox[1],
# Y coordinates of the top left point of the smallest rectangle enclosing the object
# Y coordinates of the top left point of the smallest rectangle enclosing the object (pixels)
"by": prop.bbox[0],
# circularity : (4∗π ∗Area)/Perim^2 a value of 1 indicates a perfect circle, a value approaching 0 indicates an increasingly elongated polygon
# circularity : (4∗π ∗Area)/Perim^2 — dimensionless ratio, unaffected by scaling
"circ.": (4 * np.pi * prop.filled_area) / prop.perimeter**2,
# Surface area of the object excluding holes, in square pixels (=Area*(1-(%area/100))
"area_exc": prop.area,
# Surface area of the object in square pixels
"area": prop.filled_area,
# Percentage of object’s surface area that is comprised of holes, defined as the background grey level
# Surface area of the object excluding holes (µm² if calibrated)
"area_exc": prop.area * px2,
# Surface area of the object (µm² if calibrated)
"area": prop.filled_area * px2,
# Percentage of object’s surface area that is comprised of holes — dimensionless
"%area": 1 - (prop.area / prop.filled_area),
# Primary axis of the best fitting ellipse for the object
"major": prop.major_axis_length,
# Secondary axis of the best fitting ellipse for the object
"minor": prop.minor_axis_length,
# Y position of the center of gravity of the object
# Primary axis of the best fitting ellipse for the object (µm if calibrated)
"major": prop.major_axis_length * px,
# Secondary axis of the best fitting ellipse for the object (µm if calibrated)
"minor": prop.minor_axis_length * px,
# Y position of the center of gravity of the object (pixels)
"y": prop.centroid[0],
# X position of the center of gravity of the object
# X position of the center of gravity of the object (pixels)
"x": prop.centroid[1],
# The area of the smallest polygon within which all points in the object fit
"convex_area": prop.convex_area,
# # Minimum grey value within the object (0 = black)
# "min": prop.min_intensity,
# # Maximum grey value within the object (255 = white)
# "max": prop.max_intensity,
# # Average grey value within the object ; sum of the grey values of all pixels in the object divided by the number of pixels
# "mean": prop.mean_intensity,
# # Integrated density. The sum of the grey values of the pixels in the object (i.e. = Area*Mean)
# "intden": prop.filled_area * prop.mean_intensity,
# The length of the outside boundary of the object
"perim.": prop.perimeter,
# major/minor
# The area of the smallest convex polygon enclosing the object (µm² if calibrated)
"convex_area": prop.convex_area * px2,
# The length of the outside boundary of the object (µm if calibrated)
"perim.": prop.perimeter * px,
# major/minor — dimensionless ratio
"elongation": np.divide(prop.major_axis_length, prop.minor_axis_length),
# max-min
# "range": prop.max_intensity - prop.min_intensity,
# perim/area_exc
"perimareaexc": prop.perimeter / prop.area,
# perim/major
# perim/area_exc — units: 1/µm if calibrated (scales as 1/px)
"perimareaexc": prop.perimeter / prop.area * (1.0 / px),
# perim/major — dimensionless ratio
"perimmajor": prop.perimeter / prop.major_axis_length,
# (4 ∗ π ∗ Area_exc)/perim 2
# (4 ∗ π ∗ Area_exc)/perim^2 — dimensionless ratio
"circex": np.divide(4 * np.pi * prop.area, prop.perimeter**2),
# Angle between the primary axis and a line parallel to the x-axis of the image
"angle": prop.orientation / np.pi * 180 + 90,
# # X coordinate of the top left point of the image
# 'xstart': data_object['raw_img']['meta']['xstart'],
# # Y coordinate of the top left point of the image
# 'ystart': data_object['raw_img']['meta']['ystart'],
# Maximum feret diameter, i.e. the longest distance between any two points along the object boundary
# 'feret': data_object['raw_img']['meta']['feret'],
# feret/area_exc
# 'feretareaexc': data_object['raw_img']['meta']['feret'] / property.area,
# perim/feret
# 'perimferet': property.perimeter / data_object['raw_img']['meta']['feret'],
"bounding_box_area": prop.bbox_area,
# Bounding box area (µm² if calibrated)
"bounding_box_area": prop.bbox_area * px2,
"eccentricity": prop.eccentricity,
"equivalent_diameter": prop.equivalent_diameter,
# Equivalent spherical diameter (µm if calibrated)
"equivalent_diameter": prop.equivalent_diameter * px,
"euler_number": prop.euler_number,
# extent — dimensionless ratio (area / bounding_box_area)
"extent": prop.extent,
"local_centroid_col": prop.local_centroid[1],
"local_centroid_row": prop.local_centroid[0],
# solidity — dimensionless ratio (area / convex_area)
"solidity": prop.solidity,
}

Expand Down Expand Up @@ -440,10 +436,29 @@ def __augment_slice(dim_slice, max_dims, size=10):

labels, nlabels = skimage.measure.label(mask, return_num=True)
regionprops = skimage.measure.regionprops(labels)

# Convert min ESD threshold from µm to pixels for filtering
# process_min_ESD is in µm; equivalent_diameter_area from regionprops is in pixels
pixel_size = self.__global_metadata.get("process_pixel", None)
try:
pixel_size = float(pixel_size) if pixel_size is not None else None
except (ValueError, TypeError):
pixel_size = None
if pixel_size and pixel_size > 0:
min_esd_pixels = self.__process_min_ESD / pixel_size
else:
# No calibration: assume process_min_ESD is already in pixels (legacy behavior)
min_esd_pixels = self.__process_min_ESD
logger.warning(
f"No valid process_pixel calibration — using min ESD of {min_esd_pixels} as pixels"
)
logger.debug(
f"Min ESD filter: {self.__process_min_ESD} µm = {min_esd_pixels:.1f} px "
f"(process_pixel={pixel_size})"
)

regionprops_filtered = [
region
for region in regionprops
if region.equivalent_diameter_area >= self.__process_min_ESD
region for region in regionprops if region.equivalent_diameter_area >= min_esd_pixels
]
object_number = len(regionprops_filtered)
logger.debug(f"Found {nlabels} labels, or {object_number} after size filtering")
Expand All @@ -460,12 +475,35 @@ def __augment_slice(dim_slice, max_dims, size=10):
# First extract to get all the metadata about the image
obj_image = img[region.slice]
colors = self._get_color_info(obj_image, region.filled_image)
metadata = self._extract_metadata_from_regionprop(region)
# Convert pixel measurements to physical units (µm / µm²) using process_pixel calibration
pixel_size_um = self.__global_metadata.get("process_pixel", None)
if pixel_size_um is not None:
try:
pixel_size_um = float(pixel_size_um)
except (ValueError, TypeError):
logger.warning(
f"Invalid process_pixel value: {pixel_size_um}, measurements will be in pixels"
)
pixel_size_um = None
if pixel_size_um is None or pixel_size_um <= 0:
logger.warning(
"No valid process_pixel calibration found — measurements will be in pixel units"
)
pixel_size_um = None
else:
# Flag that physical unit conversion was applied (for downstream consumers)
self.__global_metadata["process_pixel_applied"] = True
metadata = self._extract_metadata_from_regionprop(region, pixel_size_um=pixel_size_um)

# Calculate blur metric for this object (Laplacian variance)
blur_laplacian = planktoscope.segmenter.operations.calculate_blur(obj_image)
metadata["blur_laplacian"] = blur_laplacian

# Record the threshold value used to segment this image
threshold_value = planktoscope.segmenter.operations.get_last_threshold_value()
if threshold_value is not None:
metadata["threshold"] = threshold_value

# Second extract to get a bigger image for saving
obj_image = img[__augment_slice(region.slice, labels.shape, 10)]
object_id = f"{name}_{i}"
Expand Down Expand Up @@ -764,10 +802,12 @@ def segment_path(self, path, ecotaxa_export):
self.__global_metadata = json.load(config_file)
logger.debug(f"Configuration loaded is {self.__global_metadata}")

# Remove all the key,value pairs that don't start with acq, sample, object or process (for Ecotaxa)
# Remove all the key,value pairs that don't start with acq, sample, object, process, or calibration (for Ecotaxa)
self.__global_metadata = dict(
filter(
lambda item: item[0].startswith(("acq", "sample", "object", "process")),
lambda item: item[0].startswith(
("acq", "sample", "object", "process", "calibration")
),
self.__global_metadata.items(),
)
)
Expand Down
13 changes: 8 additions & 5 deletions segmenter/planktoscope/segmenter/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
from loguru import logger

__mask_to_remove = None
__last_threshold_value = None


def get_last_threshold_value():
"""Return the threshold value from the most recent simple_threshold() call."""
return __last_threshold_value


def adaptative_threshold(img):
Expand Down Expand Up @@ -65,16 +71,13 @@ def simple_threshold(img):
Returns:
cv2 img: binary mask
"""
# start = time.monotonic()
# logger.debug(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
global __last_threshold_value

logger.debug("Simple threshold calc")
# img_hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, mask = cv2.threshold(img_gray, 127, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_TRIANGLE)

# logger.debug(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
# logger.debug(time.monotonic() - start)
__last_threshold_value = float(ret)
logger.info(f"Threshold value used was {ret}")
logger.success("Simple threshold is done")
return mask
Expand Down
Loading