fixed precision converting annotations with "force_mask=True"#1746
fixed precision converting annotations with "force_mask=True"#17460xD4rky wants to merge 8 commits intoroboflow:developfrom
"force_mask=True"#1746Conversation
|
Hi @0xD4rky 👋🏻 thanks a lot for your interest in our library. It's true that the YOLO format requires normalization of box coordinates and masks, and loading and re-saving the dataset can lead to distortions, and we would like to minimize the level of these distortions. However, before we decide to introduce any changes to supervision datasets, I need to see that your proposed solution actually minimizes the distortions. The test you attached only shows that the masks processed in two different ways are different. However, there is no reference point to the source polygon. That is, we don't know if and by how much the output polygon differs from the input one. I would like to see a test where we have the source |
|
Thanks @SkalskiP for pointing out the need to verify that change. I forgot to add the verification to it. I created a sample label file to notice how polygon's coordinates used to change before the change and how does the change handle the polygon rounding. The below is the piece of code I used to analyze the changes in polygon's observed coordinates. We start with a known polygon in normalized YOLO coordinates. After loading and saving via
You can see how the processed polygon coordinates are similar to the original coordinates after we have taken the changes into consideration.
|
|
hello @SkalskiP please review the changes once you have time, thanks! |
sure, I undestabd that :) |
"force_mask=True"
There was a problem hiding this comment.
Pull request overview
This PR attempts to fix precision loss when loading YOLO polygon annotations with force_mask=True. The issue occurs when normalized polygon coordinates are converted to pixel coordinates and back, causing rounding errors that result in misaligned masks. The proposed solution is to delay integer conversion until the last moment before calling cv2.fillPoly.
Changes:
- Modified
_polygons_to_masksfunction to inline the mask creation logic instead of callingpolygon_to_mask - Removed import of
polygon_to_maskfrom converters module - Added blank line in docstring formatting
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| polygon_int = np.round(polygon).astype(np.int32) | ||
| mask = np.zeros((resolution_wh[1], resolution_wh[0]), dtype=np.uint8) | ||
|
|
||
| cv2.fillPoly(mask, [polygon_int], 1) |
There was a problem hiding this comment.
The module cv2 is used here but not imported. An import statement for cv2 is required at the top of the file.
| def _polygons_to_masks( | ||
| polygons: list[np.ndarray], resolution_wh: tuple[int, int] | ||
| polygon: list[np.ndarray], resolution_wh: tuple[int, int] | ||
| ) -> np.ndarray: | ||
| return np.array( | ||
| [ | ||
| polygon_to_mask(polygon=polygon, resolution_wh=resolution_wh) | ||
| for polygon in polygons | ||
| ], | ||
| dtype=bool, | ||
| ) | ||
| polygon_int = np.round(polygon).astype(np.int32) | ||
| mask = np.zeros((resolution_wh[1], resolution_wh[0]), dtype=np.uint8) | ||
|
|
||
| cv2.fillPoly(mask, [polygon_int], 1) | ||
| mask = mask[None, ...] | ||
| return mask.astype(bool) |
There was a problem hiding this comment.
The function signature indicates it accepts a list of polygons (polygon: list[np.ndarray]), but the implementation treats it as a single polygon. The function attempts to call np.round(polygon) and cv2.fillPoly with [polygon_int], which assumes polygon is a single array, not a list of arrays. This will fail when multiple polygons are passed. The function should either loop over all polygons in the list to create multiple masks, or the parameter type should be changed to np.ndarray if only single polygons are expected.
| @@ -120,7 +119,7 @@ def yolo_annotations_to_detections( | |||
| np.round(polygon * np.array(resolution_wh, dtype=np.float32)).astype(int) | |||
There was a problem hiding this comment.
The rounding and casting to int happen before passing polygons to _polygons_to_masks, which defeats the purpose of this PR. The PR description states the fix is to "keep floats until mask creation" to avoid precision loss, but here the conversion to int occurs at line 119 before the mask creation function is called. The rounding should be removed from this location and only performed inside _polygons_to_masks just before calling cv2.fillPoly.
| np.round(polygon * np.array(resolution_wh, dtype=np.float32)).astype(int) | |
| polygon * np.array(resolution_wh, dtype=np.float32) |
| mask = mask[None, ...] | ||
| return mask.astype(bool) |
There was a problem hiding this comment.
The function always returns a mask array with shape (1, H, W) regardless of how many polygons are in the input list. The mask should have shape (N, H, W) where N is the number of polygons. The original implementation correctly handled multiple polygons by iterating over them and stacking the results. The new implementation needs to loop over the list of polygons and create a mask for each one, then stack them into a single array.
|
|
||
| def _polygons_to_masks( | ||
| polygons: list[np.ndarray], resolution_wh: tuple[int, int] | ||
| polygon: list[np.ndarray], resolution_wh: tuple[int, int] |
There was a problem hiding this comment.
The parameter name "polygon" is misleading since it accepts a list of polygons, not a single polygon. The parameter should be renamed to "polygons" to match the expected input type and improve code clarity.


Description
When we use supervision to load YOLO annotations with force_masks=True, it internally converts normalized polygon coordinates from your YOLO text files into pixel coordinates (multiplying by image width/height) and then back into normalized coordinates when saving them out. During this round-trip, integer casting or rounding may occur, causing slight shifts in the polygon coordinates. This leads to “crooked” or misaligned masks.
Type of change
Please delete options that are not relevant.
How has this change been tested, please provide a testcase or example of how you tested the change?
YOUR_ANSWER
Minimal Reproducible Code:
Docs
The Docs haven't been updated yet, I need to check the validity of the PR with the maintainers first!