Skip to content

Commit 488f68c

Browse files
authored
Increase mask accuracy by introducing super-sampling
This gets around the limitation of OpenCV supporting only integer values for coordinates on the image plane.
1 parent 9b6735f commit 488f68c

1 file changed

Lines changed: 39 additions & 38 deletions

File tree

pyesapi/__init__.py

Lines changed: 39 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -297,21 +297,22 @@ def set_fluence_nparray(beam, shaped_fluence, beamlet_size_mm=2.5):
297297
fluence = Fluence(_buffer, x_origin, y_origin)
298298
beam.SetOptimalFluence(fluence)
299299

300-
def make_contour_mask_fast(structure_set, structure_obj, image_like):
300+
def make_contour_mask_fast(structure_set, structure_obj, image_like, super_sample=8):
301301
'''Generate a 3D mask from structure contours on an image-like object grid (e.g., Dose or Image).
302302
This function uses OpenCV to efficiently create a mask from the contours of a structure set.
303303
However, it does not exactly match the segment volume represented in Eclipse. For a more accurate representation,
304-
use the `np_mask_like` method of the structure object.
304+
use the `np_mask_like` method of the structure object. Built with the help of Google Gemini and Claude.
305305
306306
Args:
307307
structure_set (pyesapi.StructureSet): The structure set containing the contours.
308308
structure_obj (pyesapi.Structure): The specific structure to create a mask for.
309309
image_like (pyesapi.Image or pyesapi.Dose): The image-like object that defines the grid for the mask.
310+
super_sample (int): The intermediate super-sampling factor to increase mask accuracy. Default is 8, where 1 means no super-sampling (but fastest).
310311
Returns:
311312
np.ndarray: A 3D boolean mask where True indicates the presence of the structure.
312313
'''
313314

314-
# Import cv2 only when needed for mask generation
315+
# Import cv2 when needed for mask generation
315316
try:
316317
import cv2
317318
except ImportError:
@@ -332,9 +333,9 @@ def make_contour_mask_fast(structure_set, structure_obj, image_like):
332333
total_volume_mm3 = 0
333334
mask_3d = np.zeros((nz, ny, nx), dtype=bool)
334335

335-
all_contrours_per_source_slice = []
336+
all_contrours_per_source_slice = []
336337

337-
# note: the CT data (source) can be different from the requested mask geometry (sample), so we use the StructureSet's Image geo directly to extract contours
338+
# The CT data (source) can be different from the requested mask geometry (sample), so we use the StructureSet's Image geo directly to extract contours
338339

339340
# first loop over the Z slices of the structure set image and grab contours and signed areas
340341
for source_z_idx in range(structure_set.Image.ZSize):
@@ -353,67 +354,67 @@ def make_contour_mask_fast(structure_set, structure_obj, image_like):
353354
print(f"WARNING: Skipping contour with < 3 points on slice index {source_z_idx}")
354355
continue
355356

356-
# contour_xy = ]contour_xyz[:, :2] # Extract X, Y coordinates
357+
# Extract X, Y coordinates
357358
x = np.array([v[0] for v in contour_xyz])
358359
y = np.array([v[1] for v in contour_xyz])
359360

360361
# Calculate SIGNED area using Shoelace formula
361-
signed_area = - 0.5 * (np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1))) # sign is flipped relative to this formulation
362+
signed_area = - 0.5 * (np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1))) # sign is flipped relative to this formulation (coordinate system?)
362363
total_signed_area_on_slice_mm2 += signed_area
363364

364365
# Store polygon for mask generation
365366
slice_polygons.append((x, y, signed_area))
366367

367368
all_contrours_per_source_slice.append(slice_polygons)
368369

369-
# Loop over the Z slices of the requested mask geometry
370+
# Now, loop over the Z slices of the requested mask geometry
370371
for sample_z_idx in range(nz):
371-
sample_z_position = sample_z_idx * z_res + origin_z
372+
373+
sample_z_position = sample_z_idx * z_res + origin_z - z_res / 2 # Center the sample slice position
372374

373375
# Find the closest Z slice in the source image index space
374-
source_z_idx = np.floor((sample_z_position - structure_set.Image.Origin.z)/structure_set.Image.ZRes).astype(int)
376+
source_z_idx = ((sample_z_position - structure_set.Image.Origin.z + structure_set.Image.ZRes/2)/structure_set.Image.ZRes + np.finfo(np.float64).tiny).astype(int)
375377

376-
# Only process if this Z sample slice is within the source z slices
378+
# Only process if this Z sample slice is within the source Z slices
377379
if source_z_idx < structure_set.Image.ZSize and source_z_idx >= 0:
378380
# Create mask for this slice
379381
slice_mask = np.zeros((ny, nx), dtype=bool)
382+
380383
slice_polygons_list = all_contrours_per_source_slice[source_z_idx]
381384
if slice_polygons_list is not None:
382-
try:
383-
for x, y, signed_area in slice_polygons_list: # Direct unpacking
384-
if len(x) == 0:
385-
continue
386-
# Convert contour to pixel coordinates using non-isotropic resolution
387-
contour_pixels = np.zeros((len(x), 2), dtype=np.int32)
388-
contour_pixels[:, 0] = np.round((x - origin_x) / x_res) # x pixel coords
389-
contour_pixels[:, 1] = np.round((y - origin_y) / y_res) # y pixel coords
385+
for x, y, signed_area in slice_polygons_list:
386+
if len(x) == 0:
387+
continue
388+
# Convert contour to pixel coordinates using non-isotropic resolution
389+
contour_pixels = np.zeros((len(x), 2), dtype=np.int32)
390+
391+
if super_sample > 1:
392+
# Apply super-sampling to improve contour resolution
393+
contour_pixels[:, 0] = np.round((x - origin_x + x_res/2) / x_res * super_sample) # x pixel coords
394+
contour_pixels[:, 1] = np.round((y - origin_y + y_res/2) / y_res * super_sample) # y pixel coords
390395

396+
# Create a temporary mask for this contour using cv2.fillPoly
397+
temp_mask_hires = np.zeros((ny * super_sample, nx * super_sample), dtype=np.uint8)
398+
cv2.fillPoly(temp_mask_hires, [contour_pixels], 1, lineType=cv2.LINE_8)
399+
temp_mask = cv2.resize(temp_mask_hires, (nx, ny), interpolation=cv2.INTER_AREA)
400+
temp_mask_bool = temp_mask.astype(bool)
401+
else:
402+
# Convert to pixel coordinates without super-sampling
403+
contour_pixels[:, 0] = np.round((x - origin_x + x_res/2) / x_res).astype(np.int32) # x pixel coords
404+
contour_pixels[:, 1] = np.round((y - origin_y + y_res/2) / y_res).astype(np.int32) # y pixel coords
391405
# Create a temporary mask for this contour using cv2.fillPoly
392406
temp_mask = np.zeros((ny, nx), dtype=np.uint8)
393407
cv2.fillPoly(temp_mask, [contour_pixels], 1, lineType=cv2.LINE_8)
394408
temp_mask_bool = temp_mask.astype(bool)
395-
396-
# Add or subtract based on winding direction (signed area)
397-
if signed_area > 0: # Positive area - add to mask
398-
slice_mask |= temp_mask_bool
399-
else: # Negative area - subtract from mask (hole)
400-
slice_mask &= ~temp_mask_bool
401-
except Exception as e:
402-
# print(f"Error processing contour on slice {sample_z_idx} with source index {source_z_idx}: {e}")
403-
print(f"Contour data: {slice_polygons_list}")
404-
raise Exception(f"Failed to process contour on slice {sample_z_idx} with source index {source_z_idx}: {e}")
409+
410+
# Add or subtract based on winding direction (signed area)
411+
if signed_area > 0: # Positive area - add to mask
412+
slice_mask |= temp_mask_bool
413+
else: # Negative area - subtract from mask (hole)
414+
slice_mask &= ~temp_mask_bool
405415

406416
mask_3d[sample_z_idx] = slice_mask
407417

408-
# The final net area for the slice is the absolute value of the sum of signed areas
409-
# total_area_on_slice_mm2 = np.abs(total_signed_area_on_slice_mm2)
410-
total_area_on_slice_mm2 = total_signed_area_on_slice_mm2
411-
412-
total_volume_mm3 += total_area_on_slice_mm2 * structure_set.Image.ZRes
413-
414-
# Convert total volume from mm^3 to cm^3
415-
total_volume_cc = total_volume_mm3 / 1000.0
416-
417418
return mask_3d.T
418419

419420
## where the magic happens ##

0 commit comments

Comments
 (0)