Skip to content

Fix ±1px shift in _render_instance for objects_to_segmentations#7006

Open
v-nayjack wants to merge 2 commits intodevelopfrom
fix/objects-to-segmentations-pixel-shift
Open

Fix ±1px shift in _render_instance for objects_to_segmentations#7006
v-nayjack wants to merge 2 commits intodevelopfrom
fix/objects-to-segmentations-pixel-shift

Conversation

@v-nayjack
Copy link
Copy Markdown

@v-nayjack v-nayjack commented Feb 13, 2026

What changes are proposed in this pull request?

Fix ±1px pixel shift in objects_to_segmentations() caused by _render_instance independently flooring both start and end bounding box coordinates via int().

When converting normalized bbox coordinates to pixel positions, the previous implementation delegated to etai.render_instance_mask, which calls BoundingBox.coords_in(). This independently floors both corners:

x_start = int(bbox_x * image_width)              # floor
x_end   = int((bbox_x + bbox_w) * image_width)   # floor independently
target_width = x_end - x_start                    # can be ±1 vs mask.shape[1]

Due to floating-point arithmetic, floor(a + b) != floor(a) + floor(b). When the computed target size differs from the actual mask dimensions, the mask is resized using interpolation, shifting pixels by 1-2px. The shift is intermittent — it only affects detections with fractional pixel coordinates.

Fix: Replace _render_instance to bypass etai.render_instance_mask entirely:

  1. Compute start position using round() instead of int()
  2. Place the mask at its actual dimensions — no resize needed
  3. Clip to image bounds for edge cases

How is this patch tested? If it is not, please explain why.

Tested with 10 synthetic cases covering:

  • Fractional coords that trigger floor(a+b) != floor(a) + floor(b) on both axes
  • Exact integer coords (no-op, confirms backward compatibility)
  • Masks near all image boundaries (top-left, bottom-right, corners)
  • Masks extending to image edges (right overflow, bottom overflow, corner overflow)
  • Zero origin and full image width span

9 out of 10 synthetic cases had ±1px mismatch with the old int() approach. All 10 pass with the fix.

Release Notes

Is this a user-facing change that should be mentioned in the release notes?

  • No. You can skip the rest of this section.
  • Yes. Give a description of this change to be included in the release
    notes for FiftyOne users.

Fixed ±1px pixel shift in objects_to_segmentations().

What areas of FiftyOne does this PR affect?

  • App: FiftyOne application changes
  • Build: Build and test infrastructure changes
  • Core: Core fiftyone Python library changes
  • Documentation: FiftyOne documentation changes
  • Other

Summary by CodeRabbit

  • Bug Fixes
    • Improved detection mask rendering and placement across images. Masks now align more accurately with detections, handle image edges and boundaries robustly, and correctly apply to both color and grayscale patches—reducing visual artifacts and misaligned segmentations near image borders.

@v-nayjack v-nayjack requested a review from a team as a code owner February 13, 2026 22:57
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 13, 2026

Walkthrough

The change replaces a library-based mask rendering call with a manual, coordinate-based placement routine in the labels module. The new code computes the detection bounding-box origin (rounded), converts to absolute pixel coordinates, clips coordinates to image bounds, slices both the detection mask and the target image patch to the clipped region, converts the detection mask to a boolean object mask, and assigns mask values into the clipped patch. The same clipping-and-slicing approach is applied for both 3-channel (color) and single-channel (grayscale) patches.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (8 files):

⚔️ app/packages/components/src/components/SmartForm/RJSF/templates/FieldTemplate.tsx (content)
⚔️ app/packages/core/src/components/Modal/Sidebar/Annotate/Actions.tsx (content)
⚔️ app/packages/core/src/components/Modal/Sidebar/Annotate/GroupEntry.tsx (content)
⚔️ docs/source/getting_started/index.rst (content)
⚔️ docs/source/tutorials/cosmos-transfer-integration.ipynb (content)
⚔️ e2e-pw/src/oss/fixtures/index.ts (content)
⚔️ e2e-pw/src/shared/python-runner/python-runner.ts (content)
⚔️ fiftyone/core/labels.py (content)

These conflicts must be resolved before merging into develop.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title precisely describes the main fix: replacing int()-based flooring with round() to eliminate a ±1px pixel shift in the _render_instance function for objects_to_segmentations.
Description check ✅ Passed The description fully covers required sections: detailed technical explanation of the issue, comprehensive testing methodology with specific case counts, and release notes indicating user-facing impact on the Core library.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/objects-to-segmentations-pixel-shift
⚔️ Resolve merge conflicts (beta)
  • Auto-commit resolved conflicts to branch fix/objects-to-segmentations-pixel-shift
  • Create stacked PR with resolved conflicts
  • Post resolved changes as copyable diffs in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@fiftyone/core/labels.py`:
- Around line 1786-1787: Remove the redundant outer int() calls on the x0 and y0
assignments: use the result of round(...) directly (e.g., assign x0 =
round(bbox.top_left.x * img_w) and y0 = round(bbox.top_left.y * img_h)); update
the two lines referencing bbox.top_left.x, bbox.top_left.y, img_w and img_h to
drop int(...) so the values remain integers via round() in Python 3.
- Around line 1792-1797: The mask slicing drops the wrong side when bbox x0/y0
are negative because x0/y0 are clamped before slicing; capture the original
unclamped offsets (e.g., orig_x0 = x0, orig_y0 = y0) or compute x_off = max(0,
-x0) and y_off = max(0, -y0) before clamping, then clamp x0/y0 and x1/y1 as done
now, and slice obj_mask using those offsets and the cropped size: obj_mask =
obj_mask[y_off : y_off + (y1 - y0), x_off : x_off + (x1 - x0)]; this preserves
the correct interior of the mask when the box extends past the top/left edges.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@fiftyone/core/labels.py`:
- Around line 1801-1811: The slice assigned to variable patch in the mask
editing block (inside the mask.ndim == 3 branch and the else branch) is a view
of mask, so the final write-backs mask[y0:y1, x0:x1, :] = patch and mask[y0:y1,
x0:x1] = patch are redundant; remove those final assignments and rely on the
in-place mutations patch[obj_mask, ...] = target to update mask. Update the code
in the function/block that contains mask, patch, obj_mask, y0, y1, x0, x1 to
delete only the redundant write-back lines while keeping the existing
slice-to-patch and patch[obj_mask,...] assignments intact.

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.

@v-nayjack thanks for proposing a fix. I think there should be a way to apply the round() vs floor() logic in etai.render_instance_mask() rather than bypassing it in FiftyOne in a way that avoids the +1px shift that you're seeing while also maintaining the intended behavior in the other possible use cases (like I've described below).

bbox = dobj.bounding_box
img_h, img_w = mask.shape[:2]

# Use round() instead of int() for the start position to avoid a ±1px
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.

Can you apply the necessary fix inside etai.render_instance_mask() rather than bypassing it here? The code is here.

# Use round() instead of int() for the start position to avoid a ±1px
# shift caused by independently flooring both the start and end
# coordinates. The mask is placed at its actual dimensions with no
# resize, and clipped to image bounds.
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.

I don't think it is possible to avoid the mask resize that etai.render_instance_mask() does in general.

_render_instance() needs to be able to inscribe an instance segmentation defined by detection into a mask that represents the whole image regardless of the resolutions of the two masks. For example, we could have:

  • User wants to render a full image mask with relatively small resolution, say mask.shape = (16, 16)
  • The instance segmentation could be much higher resolution, say: detection.bounding_box=[0, 0, 0.5, 0.5] (upper left quadrant) but detection.mask.shape = (512, 512)

That's going to require a resize, because the 512x512 mask only represents the 8x8 quadrant of mask.

For reference, in FiftyOne's data model, we have:

Masks can be of any size; they are stretched as necessary to fill the object’s bounding box when visualizing in the App.

@v-nayjack
Copy link
Copy Markdown
Author

Thanks @brimoor, that makes sense. The resize is necessary when the mask resolution differs from the target region. I'll apply the fix in etai.render_instance_mask() instead and open a PR against voxel51/eta — will link it here.

@v-nayjack
Copy link
Copy Markdown
Author

@brimoor updated etai.render_instance_mask() and here's the PR: voxel51/eta#693

This applies round() inside etai.render_instance_mask() while preserving the resize behavior for resolution mismatches.

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.

2 participants