Skip to content

Fix ±1px shift in render_instance_mask by using round() instead of int()#693

Open
v-nayjack wants to merge 1 commit intodevelopfrom
fix/render-instance-mask-round
Open

Fix ±1px shift in render_instance_mask by using round() instead of int()#693
v-nayjack wants to merge 1 commit intodevelopfrom
fix/render-instance-mask-round

Conversation

@v-nayjack
Copy link
Copy Markdown

Rationale

  1. The render_instance_mask() computes the target region for an instance mask by calling bounding_box.coords_in(), which independently floors both the start and end coordinates using int().
  2. Due to floating-point arithmetic, floor(a + b) != floor(a) + floor(b), causing the computed target size to be ±1px off from the actual mask dimensions.
  3. When this mismatch occurs, the mask is resized with interpolation, shifting pixels by 1-2px.
  4. This affects FiftyOne's objects_to_segmentations(), where segmentation labels appear shifted by 1-2px for detections with fractional pixel coordinates.

Changes

  • Updated render_instance_mask() to compute pixel coordinates using round() instead of delegating to coords_in() (which uses int()).
  • The resize behavior is preserved for cases where the mask resolution genuinely differs from the target region.

Testing

Tested with 11 synthetic cases via FiftyOne's objects_to_segmentations():

  • 9 fractional coordinate cases that triggered ±1px mismatch with int() — all pass with round()
  • 1 exact integer coordinate case — no change in behavior
  • 1 resolution mismatch case (512x512 mask into 8x8 target region) — resize works correctly

Related

tlx, tly, width, height = bounding_box.coords_in(
frame_size=frame_size, shape=shape, img=img
)
w, h = to_frame_size(frame_size=frame_size, shape=shape, img=img)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we need to follow the rabbit hole another layer deeper and apply the fix directly in the coords_in() methods.

I'm sure it wasn't intentional to use the floor-based rounding we get via int(). int(round()) is a more accurate way of converting to pixel coordinates for all use cases.

@v-nayjack v-nayjack force-pushed the fix/render-instance-mask-round branch from 6cee9b4 to 4509eca Compare February 18, 2026 21:47
@v-nayjack
Copy link
Copy Markdown
Author

@brimoor moved the fix to RelativePoint.coords_in() in geometry.py and reverted the render_instance_mask() change. All tests still pass with this change.

@v-nayjack
Copy link
Copy Markdown
Author

@brimoor checking in - this is ready for review whenever you get a chance.

Copy link
Copy Markdown
Contributor

@swheaton swheaton left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems right to me. Not sure why the 1.0 was in there previously

Copy link
Copy Markdown
Member

@brimoor brimoor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a quick search of the codebase and there are other cases of using int() to round when we possibly want int(round()).

Also, we should consider how the coords_in() and from_abs_coords() methods are implemented, as it strikes me that we have the possibility of off-by-one issues there too because we are converting relative points in [0, 1] to image coordinates in [0, w], but the image pixel range is only [0, w - 1].

Here are some changes that seem sensible to me (untested, which is why I haven't added a commit for these yet):

$ git diff
diff --git a/eta/core/geometry.py b/eta/core/geometry.py
index 7611b906..14fcb9aa 100644
--- a/eta/core/geometry.py
+++ b/eta/core/geometry.py
@@ -447,7 +447,7 @@ class RelativePoint(Serializable):
             the absolute (x, y) coordinates of this point
         """
         w, h = _to_frame_size(frame_size=frame_size, shape=shape, img=img)
-        return int(w * 1.0 * self.x), int(h * 1.0 * self.y)
+        return int(round(w * self.x)), int(round(h * self.y))
 
     @staticmethod
     def clamp(x, y):
@@ -636,7 +636,7 @@ def _make_square(x, y, w, h):
     # subimage is now always skinny
 
     def pad(z, dz, zmax):
-        dz1 = int(0.5 * dz)
+        dz1 = int(round(0.5 * dz))
         dz2 = dz - dz1
         ddz = max(0, dz1 - z.start) - max(0, z.stop + dz2 - zmax)
         return slice(z.start - dz1 + ddz, z.stop + dz2 + ddz)
diff --git a/eta/core/keypoints.py b/eta/core/keypoints.py
index 53359c9d..ae374134 100644
--- a/eta/core/keypoints.py
+++ b/eta/core/keypoints.py
@@ -182,7 +182,10 @@ class Keypoints(etal.Labels):
             a list of (x, y) keypoints in pixels
         """
         w, h = _to_frame_size(frame_size=frame_size, shape=shape, img=img)
-        return [(int(round(x * w)), int(round(y * h))) for x, y in self.points]
+        return [
+            (int(round(x * (w - 1))), int(round(y * (h - 1))))
+            for x, y in self.points
+        ]
 
     def filter_by_schema(self, schema, allow_none_label=False):
         """Filters the keypoints by the given schema.
@@ -265,8 +268,8 @@ class Keypoints(etal.Labels):
 
         rpoints = []
         for x, y in points:
-            xr = x / w
-            yr = y / h
+            xr = x / (w - 1)
+            yr = y / (h - 1)
             if clamp:
                 xr = max(0, min(xr, 1))
                 yr = max(0, min(yr, 1))
diff --git a/eta/core/polylines.py b/eta/core/polylines.py
index 356d7ae6..5d8e1e88 100644
--- a/eta/core/polylines.py
+++ b/eta/core/polylines.py
@@ -202,7 +202,10 @@ class Polyline(etal.Labels):
         """
         w, h = _to_frame_size(frame_size=frame_size, shape=shape, img=img)
         return [
-            [(int(round(x * w)), int(round(y * h))) for x, y in shape]
+            [
+                (int(round(x * (w - 1))), int(round(y * (h - 1))))
+                for x, y in shape
+            ]
             for shape in self.points
         ]
 
@@ -295,8 +298,8 @@ class Polyline(etal.Labels):
         for shape in points:
             rshape = []
             for x, y in shape:
-                xr = x / w
-                yr = y / h
+                xr = x / (w - 1)
+                yr = y / (h - 1)
                 if clamp:
                     xr = max(0, min(xr, 1))
                     yr = max(0, min(yr, 1))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants