[Bug]: NumPy fallback draws thick rectangle borders inward instead of centering them on the edge

Open
#2,674 8 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

@px12811 is already working on this.

Since Oct 16, 2026.

  • #2688 by @dajiaohuang — closed without merging

Assessment

Difficulty
3/5
Estimated time
1-2 days
Newbie friendliness
52/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
numpy, python

Research direction

Start in src/supervision/_cv2/_drawing.py at _rectangle, which uses Pillow ImageDraw.rectangle with width= that only grows inward. Compare against OpenCV stroke centering using the MRE in the issue (BoxAnnotator default thickness 2, with and without opencv-python). Tighten or add tests beyond test_fallback_line_draws_and_matches_opencv_extent so a 1px-per-side error at thickness 2 fails. Done when fallback painted bbox and pixel count match OpenCV for stroked rectangles; do not expand to _line/_circle/_polylines/_ellipse unless maintainers confirm scope.

Written by the indexing model from the issue text.

Description

bug
Bug

What's the bug?

The NumPy fallback draws a stroked rectangle entirely inside the rectangle, while OpenCV centers the stroke on the rectangle's edges. Any box drawn with thickness >= 2 therefore comes out thinner and smaller when opencv-python is not installed.

Since OpenCV is no longer a dependency, the fallback is what a plain pip install supervision uses, and BoxAnnotator's default thickness is 2, so this is the default path. CropAnnotator (default border_thickness=2), PercentageBarAnnotator's border and sv.draw_rectangle (default thickness=2) reach the same call. The filled calls in ColorAnnotator and LabelAnnotator are unaffected.

Expected behavior: the fallback matches OpenCV, per the intent stated in #2433 and #2439.

Actual behavior: the border is drawn inward only. thickness=1 and filled (thickness=-1) rectangles are unaffected.

backend painted px painted bbox
opencv 596 x 19-81, y 19-61
fallback 392 x 20-80, y 20-60

For a (3,3) to (11,8) rectangle at thickness=2, OpenCV paints rows 2 to 9 and columns 2 to 12, its left band covering columns 2 to 4. The fallback paints rows 3 to 8 and columns 3 to 11, its left band covering only columns 3 and 4. The stroke is one pixel narrower and sits one pixel inside OpenCV's on every side.

Cause: _rectangle in src/supervision/_cv2/_drawing.py uses Pillow's ImageDraw.rectangle(outline=1, width=thickness), and Pillow's width= only grows inward. OpenCV strokes each edge as a band centered on it and rounds the four joints with a filled disc, so the border spreads outside the rectangle as well as inside.

This was not caught because test_fallback_line_draws_and_matches_opencv_extent compares only the raster bounding box with atol=1, and the error at thickness=2 is exactly 1 pixel per side.

Environment
  • Supervision: 0.31.0.dev0 (develop, 09e0b53)
  • Python: 3.12
  • OS: Windows 11

Reproduced identically against opencv-python 4.10.0 and 5.0.0.

Minimal Reproducible Example

Run once with opencv-python installed and once without.

import numpy as np
import supervision as sv
from supervision._cv2 import BACKEND_NAME

detections = sv.Detections(
    xyxy=np.array([[20.0, 20.0, 80.0, 60.0]]),
    class_id=np.array([0]),
    confidence=np.array([0.9]),
)
scene = np.zeros((80, 100, 3), dtype=np.uint8)
painted = sv.BoxAnnotator().annotate(scene, detections).any(axis=2)
ys, xs = np.where(painted)

print(f"backend        : {BACKEND_NAME}")
print(f"painted pixels : {int(painted.sum())}")
print(f"painted bbox   : x {xs.min()}-{xs.max()}, y {ys.min()}-{ys.max()}")
Are you willing to submit a PR?

Yes. I have a fix for rectangle that matches OpenCV exactly on 1310 randomized and edge cases (degenerate, reversed, negative and out-of-bounds corners, thickness 1 to 25) on both OpenCV 4.10 and 5.0, with a test that fails on develop and passes with it.

Two scoping questions before I open it:

  1. _line, _circle, _polylines and _ellipse have the same inward-stroke cause. Do you want one PR for all five, or rectangle first and the rest separately?
  2. _circle and _ellipse also differ slightly when filled or at thickness=1, because Pillow's ellipse rasterizer is not OpenCV's. OpenCV's filled circle is exactly the integer disc x^2 + y^2 <= r^2, so that is cheap to match. Worth doing in the same pass, or is a separate issue better?
Dominant language
Python
Stars
51.6k
Forks
5k
Avg merge
1d 17h
Merged PRs (30d)
191

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from roboflow/supervision

All issues in roboflow/supervision

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.