refactor(cv2): replace Hershey text with Pillow (#2440)

Text fallback now renders through Pillow with the DejaVu Sans face
resolved via matplotlib font_manager, replacing the Hershey stroke-font
reader; getTextSize metrics derive from the same font and differ from
OpenCV within the documented visual-divergence tier.

Remove the packaged Hershey glyph data (hershey_fonts.json, provenance,
license) and its _cv2/data package-data entry.

Delete unused fallbacks: _geometry _fill_poly and _point_in_polygon
(live fillPoly is the Pillow one in _drawing) and _common _unavailable.

Replace test_hershey with Pillow-oriented test_text, drop test_common,
and point test_contours/test_geometry at _drawing._fill_poly. Document
the fallback text-backend change in the changelog.

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
Jirka Borovec 2026-07-17 10:22:32 +02:00 committed by GitHub
parent bc7b9fc69e
commit 68e63f9e39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 329 additions and 5107 deletions

View File

@ -22,6 +22,7 @@ date_modified: 2026-07-16
- The cv2-free fallback's `copyMakeBorder` now fills only channel 0 for a scalar border `value` on multichannel images, matching OpenCV's `Scalar(v)` semantics instead of broadcasting the value to every channel.
- The cv2-free fallback's `addWeighted` now raises `ValueError` for a non-default `dtype` instead of silently ignoring it.
- The cv2-free fallback's `approxPolyDP` now uses an O(N) farthest-point heuristic for closed contours instead of an O(N^2) full distance matrix, avoiding memory blowups on large contours.
- The cv2-free fallback now renders text with Pillow and the bundled DejaVu Sans face instead of reproducing OpenCV's Hershey stroke fonts. This drops the packaged 143 KB glyph table and its loader in favor of an existing dependency. Text drawn without cv2 now uses a proportional TrueType face, so glyph shapes and `getTextSize` metrics differ from OpenCV within the documented visual-divergence tier; the OpenCV path is unchanged. All Hershey font faces remain accepted for API compatibility but map to the same face (the italic modifier selects the oblique variant).
- Fixed [#2427](https://github.com/roboflow/supervision/issues/2427): size-bucketed `sv.Precision` and `sv.F1Score` no longer count out-of-bucket detections as false positives. `sv.Recall` now matches only targets in the requested bucket, and all three metrics prioritize in-bucket targets during matching, matching COCO evaluation and `sv.MeanAveragePrecision`. A pixel-perfect detector now scores 1.0 in every bucket.
- `sv.hex_to_rgba` now rejects multiple leading `#` characters instead of silently normalizing them, matching `sv.is_valid_hex` and the documented single optional prefix.
- `sv.box_iou_batch` now upcasts box corners to `float64` before computing areas and intersections, returning `float32`. This fixes integer-dtype overflow (e.g. `int32` coordinates around `50_000` could previously wrap to a negative area and produce an incorrect `0.0` IoU) and gives full `float64` precision to callers that pass `float64`/`int64` coordinates directly. It does not recover precision already lost when coordinates are stored as `float32` before this function is called (e.g. `Detections.xyxy`, which is `float32` throughout the library) — such callers must upcast their own arrays to `float64`/`int64` before calling `box_iou_batch` to benefit from this fix. Results for small-coordinate inputs are unchanged.

View File

@ -101,7 +101,7 @@ build = [
packages.find.where = [ "src" ]
packages.find.include = [ "supervision*" ]
include-package-data = false
package-data.supervision = [ "_cv2/data/*", "py.typed" ]
package-data.supervision = [ "py.typed" ]
# exclude = [ "docs*", "tests*", "examples*" ]
[tool.ruff]

View File

@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Any, NoReturn
from typing import Any
import numpy as np
import numpy.typing as npt
@ -12,14 +12,6 @@ class BackendUnavailableError(RuntimeError):
"""Raised when an OpenCV operation is used without an available backend."""
def _unavailable(*args: object, **kwargs: object) -> NoReturn:
"""Fail clearly until a later domain PR provides the fallback operation."""
del args, kwargs
raise BackendUnavailableError(
"OpenCV is not installed and this operation has no fallback yet."
)
def _cast_array_like_opencv(
values: npt.NDArray[Any], dtype: np.dtype[Any]
) -> npt.NDArray[Any]:

View File

@ -8,32 +8,6 @@ import numpy as np
import numpy.typing as npt
def _point_in_polygon(point: tuple[int, int], polygon: npt.NDArray[np.float64]) -> bool:
"""Return whether an integer pixel lies inside or on a polygon boundary."""
x, y = point
inside = False
previous = polygon[-1]
for current in polygon:
x_current, y_current = current
x_previous, y_previous = previous
edge = current - previous
relative = np.array([x - x_previous, y - y_previous], dtype=np.float64)
if (
edge[0] * relative[1] - edge[1] * relative[0] == 0
and min(x_previous, x_current) <= x <= max(x_previous, x_current)
and min(y_previous, y_current) <= y <= max(y_previous, y_current)
):
return True
if (y_current > y) != (y_previous > y):
intersection = (x_previous - x_current) * (y - y_current) / (
y_previous - y_current
) + x_current
if x < intersection:
inside = not inside
previous = current
return inside
def _as_points(contour: npt.NDArray[Any]) -> npt.NDArray[np.float64]:
"""Normalize an OpenCV contour to an ``(N, 2)`` float64 array."""
points = np.asarray(contour)
@ -189,38 +163,3 @@ def _intersect_convex_convex(
dtype = np.asarray(first).dtype
result_dtype = dtype if np.issubdtype(dtype, np.floating) else np.float32
return area, output.astype(result_dtype, copy=False).reshape(-1, 1, 2)
def _fill_poly(
image: npt.NDArray[Any],
polygons: list[npt.NDArray[Any]],
color: Any,
line_type: int = 8,
shift: int = 0,
offset: tuple[int, int] = (0, 0),
) -> None:
"""Fill integer polygons for the mask and polygon conversion consumers."""
if shift != 0:
raise ValueError("Only unshifted polygon coordinates are supported")
if image.ndim not in (2, 3):
raise ValueError("fillPoly expects a two- or three-dimensional image")
del line_type
values = np.asarray(image)
for polygon in polygons:
points = _as_points(polygon)
if len(points) < 3:
continue
points = points + np.asarray(offset, dtype=np.float64)
min_x = max(0, int(np.floor(points[:, 0].min())))
max_x = min(values.shape[1] - 1, int(np.ceil(points[:, 0].max())))
min_y = max(0, int(np.floor(points[:, 1].min())))
max_y = min(values.shape[0] - 1, int(np.ceil(points[:, 1].max())))
for y in range(min_y, max_y + 1):
for x in range(min_x, max_x + 1):
if not _point_in_polygon((x, y), points):
continue
if values.ndim == 2:
values[y, x] = color[0] if np.ndim(color) else color
else:
values[y, x] = color

View File

@ -1,201 +1,68 @@
"""Private Hershey text fallbacks for the OpenCV compatibility facade."""
"""Private Pillow-based text fallback for the OpenCV compatibility facade.
OpenCV renders text with built-in Hershey stroke fonts. The fallback instead
draws a proportional TrueType face (DejaVu Sans, shipped with Matplotlib, an
existing required dependency), so glyph shapes and text metrics differ from
OpenCV within the documented visual-divergence tier. ``getTextSize`` derives
its box from the same font ``putText`` renders with, so the reported rectangle
always encloses the drawn text.
"""
from __future__ import annotations
import hashlib
import json
from collections.abc import Iterator
from functools import lru_cache
from importlib.resources import files
from itertools import pairwise
from typing import Any, cast
from functools import cache
from typing import Any
import numpy.typing as npt
from supervision._cv2._drawing import _line
from supervision._cv2.constants import (
_FONT_HERSHEY_COMPLEX,
_FONT_HERSHEY_COMPLEX_SMALL,
_FONT_HERSHEY_DUPLEX,
_FONT_HERSHEY_PLAIN,
_FONT_HERSHEY_SCRIPT_COMPLEX,
_FONT_HERSHEY_SCRIPT_SIMPLEX,
_FONT_HERSHEY_SIMPLEX,
_FONT_HERSHEY_TRIPLEX,
_FONT_ITALIC,
_LINE_8,
)
from supervision._cv2._drawing import _drawing_mask, _paint
from supervision._cv2.constants import _FONT_ITALIC, _LINE_8
_ImageArray = npt.NDArray[Any]
_FontData = tuple[int, ...]
_FONT_NAMES = {
_FONT_HERSHEY_SIMPLEX: "HersheySimplex",
_FONT_HERSHEY_PLAIN: "HersheyPlain",
_FONT_HERSHEY_DUPLEX: "HersheyDuplex",
_FONT_HERSHEY_COMPLEX: "HersheyComplex",
_FONT_HERSHEY_TRIPLEX: "HersheyTriplex",
_FONT_HERSHEY_COMPLEX_SMALL: "HersheyComplexSmall",
_FONT_HERSHEY_SCRIPT_SIMPLEX: "HersheyScriptSimplex",
_FONT_HERSHEY_SCRIPT_COMPLEX: "HersheyScriptComplex",
}
_ITALIC_FONT_NAMES = {
_FONT_HERSHEY_PLAIN: "HersheyPlainItalic",
_FONT_HERSHEY_COMPLEX: "HersheyComplexItalic",
_FONT_HERSHEY_TRIPLEX: "HersheyTriplexItalic",
_FONT_HERSHEY_COMPLEX_SMALL: "HersheyComplexSmallItalic",
}
_XY_SHIFT = 16
_XY_ONE = 1 << _XY_SHIFT
_ASCII_FIRST = ord(" ")
_ASCII_LAST = ord("~")
_COORDINATE_ORIGIN = ord("R")
# OpenCV's font_scale is unit-relative rather than a pixel size. This factor
# maps it to a Pillow point size whose cap height lands near OpenCV's Hershey
# Simplex at the same scale; it is a readability choice, not an exact metric
# match (which no proportional TrueType face can provide).
_PIXELS_PER_SCALE = 32
def _normalize_resource_bytes(resource_bytes: bytes) -> bytes:
"""Normalize text-resource line endings before checksum validation."""
return resource_bytes.replace(b"\r\n", b"\n")
@cache
def _font_path(italic: bool) -> str:
"""Locate a bundled DejaVu Sans face through Matplotlib's font manager."""
from matplotlib import font_manager
style = "italic" if italic else "normal"
properties = font_manager.FontProperties(family="DejaVu Sans", style=style)
return str(font_manager.findfont(properties))
@lru_cache(maxsize=1)
def _load_font_data() -> tuple[tuple[str, ...], dict[str, _FontData]]:
"""Load and verify the packaged OpenCV-derived glyph tables once."""
resource_dir = files("supervision._cv2").joinpath("data")
glyph_resource = resource_dir.joinpath("hershey_fonts.json")
provenance_resource = resource_dir.joinpath("hershey_provenance.json")
glyph_bytes = _normalize_resource_bytes(glyph_resource.read_bytes())
provenance = cast(
dict[str, str],
json.loads(provenance_resource.read_text(encoding="utf-8")),
)
digest = hashlib.sha256(glyph_bytes).hexdigest()
if digest != provenance["data_sha256"]:
raise RuntimeError("Packaged Hershey glyph data failed its checksum")
@cache
def _load_font(font_face: int, font_scale: float) -> Any:
"""Return a cached Pillow font sized for an OpenCV font scale."""
from PIL import ImageFont
payload = cast(dict[str, Any], json.loads(glyph_bytes))
glyphs = tuple(cast(list[str], payload["glyphs"]))
faces = {
name: tuple(values)
for name, values in cast(dict[str, list[int]], payload["faces"]).items()
}
return glyphs, faces
def _font_data(font_face: int) -> _FontData:
"""Return the OpenCV Hershey index table selected by a font face."""
base_face = font_face & 15
try:
regular_name = _FONT_NAMES[base_face]
except KeyError as error:
raise ValueError(f"Unsupported Hershey font face: {font_face}") from error
name = regular_name
if font_face & _FONT_ITALIC:
name = _ITALIC_FONT_NAMES.get(base_face, regular_name)
_, faces = _load_font_data()
return faces[name]
def _iter_text_bytes(text: str, font_face: int) -> Iterator[int]:
"""Yield OpenCV-compatible glyph code points from UTF-8 text."""
encoded = text.encode("utf-8", errors="replace")
index = 0
while index < len(encoded):
code = encoded[index]
index += 1
left_boundary = _ASCII_FIRST
right_boundary = _ASCII_LAST + 1
if code >= 0x80 and font_face == _FONT_HERSHEY_COMPLEX:
# OpenCV's Complex face maps two UTF-8 Cyrillic ranges into its
# extended glyph table; other faces render unsupported bytes as '?'.
if code == 0xD0 and index < len(encoded) and 0x90 <= encoded[index] <= 0xBF:
code = encoded[index] - 17
index += 1
right_boundary = 175
elif (
code == 0xD1 and index < len(encoded) and 0x80 <= encoded[index] <= 0x8F
):
code = encoded[index] + 47
index += 1
left_boundary = 175
right_boundary = 191
else:
index += _utf8_continuation_count(code, encoded, index)
code = ord("?")
elif code >= 0x80:
code = ord("?")
if code < left_boundary or code >= right_boundary:
code = ord("?")
yield code
def _utf8_continuation_count(code: int, encoded: bytes, index: int) -> int:
"""Return how many UTF-8 continuation bytes OpenCV skips for a lead byte."""
if code < 0xC0:
return 0
expected = 1
if code >= 0xF0:
expected = 3
elif code >= 0xE0:
expected = 2
return min(expected, len(encoded) - index)
def _glyphs_for_text(text: str, font_face: int) -> Iterator[str]:
"""Yield glyph stroke strings selected by a font face and text."""
glyphs, _ = _load_font_data()
face = _font_data(font_face)
for code in _iter_text_bytes(text, font_face):
glyphs_index = face[code - _ASCII_FIRST + 1]
yield glyphs[glyphs_index]
def _round_fixed(value: float) -> int:
"""Round a coordinate to the fixed-point precision used by OpenCV."""
return round(value * _XY_ONE)
def _text_metrics(
font_face: int, text: str, font_scale: float, thickness: int
) -> tuple[int, int, int]:
"""Compute OpenCV Hershey cap height, baseline, and text width."""
font = _font_data(font_face)
cap_line = (font[0] >> 4) & 15
base_line = font[0] & 15
height = round((cap_line + base_line) * font_scale + (thickness + 1) // 2)
width = sum(
(ord(glyph[1]) - ord(glyph[0])) * font_scale
for glyph in _glyphs_for_text(text, font_face)
)
baseline = round(base_line * font_scale + thickness * 0.5)
return round(width + thickness), height, baseline
size = max(1, round(font_scale * _PIXELS_PER_SCALE))
return ImageFont.truetype(_font_path(bool(font_face & _FONT_ITALIC)), size)
def _get_text_size(
text: str,
fontFace: int,
fontScale: float,
thickness: int,
text: str, fontFace: int, fontScale: float, thickness: int
) -> tuple[tuple[int, int], int]:
"""Return OpenCV-compatible Hershey text dimensions and baseline."""
width, height, baseline = _text_metrics(fontFace, text, fontScale, thickness)
"""Return an OpenCV-shaped ``((width, height), baseline)`` for the face.
Height is the font ascent and baseline the descent, both string-independent
like OpenCV's contract, so consumers get stable row heights. Thickness pads
the box the way OpenCV widens strokes.
"""
font = _load_font(fontFace, fontScale)
ascent, descent = font.getmetrics()
width = round(font.getlength(text)) + max(0, thickness)
height = ascent + (thickness + 1) // 2
baseline = descent + thickness // 2
return (width, height), baseline
def _stroke_segments(glyph: str) -> Iterator[tuple[tuple[int, int], ...]]:
"""Decode the space-separated coordinate strokes in one glyph string."""
for stroke in glyph[2:].split():
points = tuple(
(
ord(stroke[index]) - _COORDINATE_ORIGIN,
ord(stroke[index + 1]) - _COORDINATE_ORIGIN,
)
for index in range(0, len(stroke), 2)
)
if len(points) > 1:
yield points
def _put_text(
img: _ImageArray,
text: str,
@ -207,32 +74,31 @@ def _put_text(
lineType: int = _LINE_8,
bottomLeftOrigin: bool = False,
) -> _ImageArray:
"""Render OpenCV Hershey strokes into an image using the fallback line primitive."""
"""Render text with a Pillow face, anchored at OpenCV's baseline origin.
Thickness maps to a Pillow stroke width to emulate OpenCV's bolder strokes.
``bottomLeftOrigin`` (an inverted-axis mode no Supervision caller uses) is
rejected rather than silently ignored.
"""
del lineType
if bottomLeftOrigin:
raise ValueError("bottomLeftOrigin is not supported by the fallback")
if not text:
return img
scale = _round_fixed(fontScale)
vertical_scale = -scale if bottomLeftOrigin else scale
font = _font_data(fontFace)
baseline = -(font[0] & 15)
view_x = org[0] << _XY_SHIFT
view_y = (org[1] << _XY_SHIFT) + baseline * vertical_scale
font = _load_font(fontFace, fontScale)
stroke_width = max(0, thickness - 1)
x, y = round(org[0]), round(org[1])
for glyph in _glyphs_for_text(text, fontFace):
left = ord(glyph[0]) - _COORDINATE_ORIGIN
right = ord(glyph[1]) - _COORDINATE_ORIGIN
advance = right * scale
view_x -= left * scale
for segment in _stroke_segments(glyph):
points = [
(
(x * scale + view_x) >> _XY_SHIFT,
(y * vertical_scale + view_y) >> _XY_SHIFT,
)
for x, y in segment
]
for start, end in pairwise(points):
_line(img, start, end, color, thickness, lineType)
view_x += advance
def draw_text(draw: Any) -> None:
"""Draw the string onto the one-bit mask at the baseline anchor."""
draw.text(
(x, y),
text,
fill=1,
font=font,
anchor="ls",
stroke_width=stroke_width,
)
return img
return _paint(img, _drawing_mask(img, draw_text), color)

View File

@ -1,27 +0,0 @@
Copyright (C) 2000-2008, Intel Corporation, all rights reserved.
Copyright (C) 2009, Willow Garage Inc., all rights reserved.
Third party copyrights are property of their respective owners.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* The name of the copyright holders may not be used to endorse or promote products
derived from this software without specific prior written permission.
This software is provided by the copyright holders and contributors "as is"
and any express or implied warranties, including, but not limited to, the
implied warranties of merchantability and fitness for a particular purpose are
disclaimed. In no event shall the Intel Corporation or contributors be liable
for any direct, indirect, incidental, special, exemplary, or consequential
damages (including, but not limited to, procurement of substitute goods or
services; loss of use, data, or profits; or business interruption) however
caused and on any theory of liability, whether in contract, strict liability,
or tort (including negligence or otherwise) arising in any way out of the use
of this software, even if advised of the possibility of such damage.

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +0,0 @@
{
"data_sha256": "aa127877926d50d9e5996913b450c14c0f1e19412fded1b9229f00e52b9fbdcd",
"checksum_normalization": "CRLF-to-LF",
"license": "Intel-Willow-Garage-BSD-style",
"source": "https://raw.githubusercontent.com/opencv/opencv/4.11.0/modules/imgproc/src/hershey_fonts.cpp"
}

View File

@ -1,13 +0,0 @@
"""Tests for shared private OpenCV fallback helpers."""
from __future__ import annotations
import pytest
from supervision._cv2._common import BackendUnavailableError, _unavailable
def test_unavailable_operation_raises_actionable_error() -> None:
"""Explain which backend is missing when an operation is not implemented."""
with pytest.raises(BackendUnavailableError, match="OpenCV is not installed"):
_unavailable()

View File

@ -13,7 +13,8 @@ from supervision._cv2._components import (
_connected_components_with_stats,
)
from supervision._cv2._contours import _find_contours
from supervision._cv2._geometry import _fill_poly, _intersect_convex_convex
from supervision._cv2._drawing import _fill_poly
from supervision._cv2._geometry import _intersect_convex_convex
from supervision._cv2.constants import _CHAIN_APPROX_SIMPLE, _RETR_TREE
try:

View File

@ -133,6 +133,19 @@ def test_fallback_fill_poly_fills_multiple_channels() -> None:
assert tuple(image[4, 5]) == (4, 5, 6)
@requires_cv2
def test_fallback_fill_poly_matches_opencv_for_mask_conversion() -> None:
"""Fill an integer polygon with the same inclusive mask boundary as OpenCV."""
polygon = np.array([[2, 2], [6, 2], [6, 6], [2, 6]], dtype=np.int32)
actual = np.zeros((10, 10), dtype=np.uint8)
expected = np.zeros((10, 10), dtype=np.uint8)
_fill_poly(actual, [polygon], color=(1,))
cv2.fillPoly(expected, [polygon], color=(1,))
np.testing.assert_array_equal(actual, expected)
def test_fallback_draw_contours_fills_selected_contour() -> None:
"""Fill the selected contour when drawContours receives a negative thickness."""
image = np.zeros((12, 12, 3), dtype=np.uint8)

View File

@ -10,7 +10,6 @@ import pytest
from supervision._cv2._geometry import (
_approx_poly_dp,
_contour_area,
_fill_poly,
_intersect_convex_convex,
)
@ -130,15 +129,3 @@ def test_intersect_convex_convex_matches_opencv(
atol=1e-6,
rtol=0,
)
def test_fill_poly_matches_opencv_for_mask_conversion() -> None:
"""Fill an integer polygon with the same inclusive mask boundary as OpenCV."""
polygon = np.array([[2, 2], [6, 2], [6, 6], [2, 6]], dtype=np.int32)
actual = np.zeros((10, 10), dtype=np.uint8)
expected = np.zeros((10, 10), dtype=np.uint8)
_fill_poly(actual, [polygon], color=(1,))
cv2.fillPoly(expected, [polygon], color=(1,))
np.testing.assert_array_equal(actual, expected)

View File

@ -1,228 +0,0 @@
"""Tests for the fallback OpenCV Hershey text implementation."""
from __future__ import annotations
import hashlib
import importlib
import json
import os
import subprocess
import sys
from importlib.resources import files
from pathlib import Path
import numpy as np
import pytest
from supervision._cv2._text import (
_get_text_size,
_normalize_resource_bytes,
_put_text,
)
try:
cv2 = importlib.import_module("cv2")
except (ImportError, OSError):
pytest.skip(
"OpenCV is required as the reference implementation for this test module",
allow_module_level=True,
)
FONT_FACES = [
pytest.param(cv2.FONT_HERSHEY_SIMPLEX, id="simplex"),
pytest.param(cv2.FONT_HERSHEY_PLAIN, id="plain"),
pytest.param(cv2.FONT_HERSHEY_DUPLEX, id="duplex"),
pytest.param(cv2.FONT_HERSHEY_COMPLEX, id="complex"),
pytest.param(cv2.FONT_HERSHEY_TRIPLEX, id="triplex"),
pytest.param(cv2.FONT_HERSHEY_COMPLEX_SMALL, id="complex-small"),
pytest.param(cv2.FONT_HERSHEY_SCRIPT_SIMPLEX, id="script-simplex"),
pytest.param(cv2.FONT_HERSHEY_SCRIPT_COMPLEX, id="script-complex"),
]
def _run_without_opencv(source: str) -> subprocess.CompletedProcess[str]:
"""Run a Python snippet with imports of cv2 blocked."""
environment = os.environ.copy()
source_path = str(Path(__file__).resolve().parents[2] / "src")
environment["PYTHONPATH"] = os.pathsep.join(
filter(None, (source_path, environment.get("PYTHONPATH")))
)
return subprocess.run(
[sys.executable, "-c", source],
capture_output=True,
text=True,
env=environment,
)
@pytest.mark.parametrize("font_face", FONT_FACES)
@pytest.mark.parametrize("italic", [False, True], ids=["regular", "italic"])
@pytest.mark.parametrize(
"text",
["", "OpenCV 123", "Tg!?"],
ids=["empty", "mixed", "punctuation"],
)
def test_fallback_text_metrics_match_opencv(
font_face: int, italic: bool, text: str
) -> None:
"""Match OpenCV text dimensions and baseline for every accepted font face."""
actual_font_face = font_face | (cv2.FONT_ITALIC if italic else 0)
actual = _get_text_size(text, actual_font_face, 0.75, 2)
expected = cv2.getTextSize(text, actual_font_face, 0.75, 2)
assert actual == expected
@pytest.mark.parametrize(
"font_face",
[
pytest.param(cv2.FONT_HERSHEY_SIMPLEX, id="simplex"),
pytest.param(cv2.FONT_HERSHEY_COMPLEX, id="complex"),
pytest.param(
cv2.FONT_HERSHEY_COMPLEX | cv2.FONT_ITALIC,
id="complex-italic",
),
],
)
@pytest.mark.parametrize(
"text",
["é", "Ж", "\N{GREEK SMALL LETTER ALPHA}", "😀"],
ids=["latin", "cyrillic", "greek", "emoji"],
)
def test_fallback_text_metrics_match_opencv_for_unicode(
font_face: int, text: str
) -> None:
"""Match OpenCV's supported and replacement-glyph Unicode behavior."""
actual = _get_text_size(text, font_face, 0.75, 2)
expected = cv2.getTextSize(text, font_face, 0.75, 2)
assert actual == expected
@pytest.mark.parametrize("font_face", FONT_FACES)
@pytest.mark.parametrize("italic", [False, True], ids=["regular", "italic"])
def test_fallback_text_renders_every_font_face(font_face: int, italic: bool) -> None:
"""Render visible text for every accepted font and italic combination."""
image = np.zeros((96, 256, 3), dtype=np.uint8)
actual_font_face = font_face | (cv2.FONT_ITALIC if italic else 0)
result = _put_text(
image,
"Supervision",
(8, 60),
actual_font_face,
0.75,
(255, 255, 255),
1,
cv2.LINE_AA,
)
assert result is image
assert np.any(image)
def test_fallback_text_data_has_verified_provenance() -> None:
"""Verify the packaged glyph data against its source manifest."""
package_data = files("supervision._cv2").joinpath("data")
glyph_data = _normalize_resource_bytes(
package_data.joinpath("hershey_fonts.json").read_bytes()
)
provenance = json.loads(
package_data.joinpath("hershey_provenance.json").read_text(encoding="utf-8")
)
assert hashlib.sha256(glyph_data).hexdigest() == provenance["data_sha256"]
assert provenance["source"].endswith("modules/imgproc/src/hershey_fonts.cpp")
assert provenance["license"] == "Intel-Willow-Garage-BSD-style"
assert provenance["checksum_normalization"] == "CRLF-to-LF"
def test_fallback_text_works_when_opencv_is_blocked() -> None:
"""Exercise production-shaped text calls in a cv2-free subprocess."""
completed = _run_without_opencv(
"""
import sys
import numpy as np
class BlockCv2:
def find_spec(self, fullname, path=None, target=None):
if fullname == "cv2":
raise ModuleNotFoundError("blocked for test")
return None
sys.meta_path.insert(0, BlockCv2())
from supervision import _cv2
assert _cv2.BACKEND_NAME == "fallback"
size, baseline = _cv2.getTextSize(
"Fallback", _cv2.FONT_HERSHEY_COMPLEX | _cv2.FONT_ITALIC, 0.75, 2
)
assert size[0] > 0
assert baseline > 0
image = np.zeros((96, 256, 3), dtype=np.uint8)
result = _cv2.putText(
image,
"Fallback",
(8, 60),
_cv2.FONT_HERSHEY_COMPLEX | _cv2.FONT_ITALIC,
0.75,
(255, 255, 255),
1,
_cv2.LINE_AA,
)
assert result is image
assert np.any(image)
"""
)
assert completed.returncode == 0, completed.stderr
def test_draw_text_consumer_uses_fallback_without_opencv() -> None:
"""Exercise the public draw_text consumer in a cv2-free subprocess."""
completed = _run_without_opencv(
"""
import sys
import numpy as np
class BlockCv2:
def find_spec(self, fullname, path=None, target=None):
if fullname == "cv2":
raise ModuleNotFoundError("blocked for test")
return None
sys.meta_path.insert(0, BlockCv2())
from supervision.draw.color import Color
from supervision.draw.utils import draw_text
from supervision.geometry.core import Point
image = np.zeros((96, 256, 3), dtype=np.uint8)
result = draw_text(
image,
"Fallback",
Point(80, 40),
text_font=7,
text_scale=0.75,
text_color=Color.WHITE,
)
assert result is image
assert np.any(image)
"""
)
assert completed.returncode == 0, completed.stderr
def test_fallback_text_rejects_unknown_font_face() -> None:
"""Reject a font face outside the supported Hershey family."""
with pytest.raises(ValueError, match="font"):
_get_text_size("text", 8, 1.0, 1)

243
tests/cv2/test_text.py Normal file
View File

@ -0,0 +1,243 @@
"""Tests for the Pillow-based fallback OpenCV text implementation."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
from supervision._cv2._text import _get_text_size, _put_text
from supervision._cv2.constants import (
_FONT_HERSHEY_COMPLEX,
_FONT_HERSHEY_COMPLEX_SMALL,
_FONT_HERSHEY_DUPLEX,
_FONT_HERSHEY_PLAIN,
_FONT_HERSHEY_SCRIPT_COMPLEX,
_FONT_HERSHEY_SCRIPT_SIMPLEX,
_FONT_HERSHEY_SIMPLEX,
_FONT_HERSHEY_TRIPLEX,
_FONT_ITALIC,
)
FONT_FACES = [
pytest.param(_FONT_HERSHEY_SIMPLEX, id="simplex"),
pytest.param(_FONT_HERSHEY_PLAIN, id="plain"),
pytest.param(_FONT_HERSHEY_DUPLEX, id="duplex"),
pytest.param(_FONT_HERSHEY_COMPLEX, id="complex"),
pytest.param(_FONT_HERSHEY_TRIPLEX, id="triplex"),
pytest.param(_FONT_HERSHEY_COMPLEX_SMALL, id="complex-small"),
pytest.param(_FONT_HERSHEY_SCRIPT_SIMPLEX, id="script-simplex"),
pytest.param(_FONT_HERSHEY_SCRIPT_COMPLEX, id="script-complex"),
]
def _run_without_opencv(source: str) -> subprocess.CompletedProcess[str]:
"""Run a Python snippet with imports of cv2 blocked."""
environment = os.environ.copy()
source_path = str(Path(__file__).resolve().parents[2] / "src")
environment["PYTHONPATH"] = os.pathsep.join(
filter(None, (source_path, environment.get("PYTHONPATH")))
)
return subprocess.run(
[sys.executable, "-c", source],
capture_output=True,
text=True,
env=environment,
)
class TestGetTextSize:
"""Text-metric contract of the Pillow fallback getTextSize."""
@pytest.mark.parametrize("font_face", FONT_FACES)
@pytest.mark.parametrize("italic", [False, True], ids=["regular", "italic"])
def test_returns_positive_metrics_for_every_face(
self, font_face: int, italic: bool
) -> None:
"""Report positive width, height, and baseline for every accepted face."""
actual_font_face = font_face | (_FONT_ITALIC if italic else 0)
(width, height), baseline = _get_text_size(
"Supervision", actual_font_face, 0.75, 2
)
assert width > 0
assert height > 0
assert baseline > 0
def test_height_is_string_independent(self) -> None:
"""Match OpenCV's contract of a content-independent line height."""
(_, short_height), _ = _get_text_size("i", _FONT_HERSHEY_SIMPLEX, 1.0, 1)
(_, tall_height), _ = _get_text_size("Ag|", _FONT_HERSHEY_SIMPLEX, 1.0, 1)
assert short_height == tall_height
def test_width_grows_with_text_length(self) -> None:
"""Report a wider box for a longer string at the same scale."""
(short_width, _), _ = _get_text_size("ab", _FONT_HERSHEY_SIMPLEX, 1.0, 1)
(long_width, _), _ = _get_text_size("abcdef", _FONT_HERSHEY_SIMPLEX, 1.0, 1)
assert long_width > short_width
def test_metrics_grow_with_scale(self) -> None:
"""Report a larger box as the font scale increases."""
(small_width, small_height), _ = _get_text_size(
"text", _FONT_HERSHEY_SIMPLEX, 0.5, 1
)
(large_width, large_height), _ = _get_text_size(
"text", _FONT_HERSHEY_SIMPLEX, 2.0, 1
)
assert large_width > small_width
assert large_height > small_height
class TestPutText:
"""Rendering behavior of the Pillow fallback putText."""
@pytest.mark.parametrize("font_face", FONT_FACES)
@pytest.mark.parametrize("italic", [False, True], ids=["regular", "italic"])
def test_renders_every_font_face_in_place(
self, font_face: int, italic: bool
) -> None:
"""Draw visible text in place for every accepted font and italic combo."""
image = np.zeros((96, 256, 3), dtype=np.uint8)
actual_font_face = font_face | (_FONT_ITALIC if italic else 0)
result = _put_text(
image, "Supervision", (8, 60), actual_font_face, 0.75, (255, 255, 255), 1
)
assert result is image
assert np.any(image)
def test_empty_text_leaves_image_untouched(self) -> None:
"""Leave the scene unchanged when the string is empty."""
image = np.zeros((32, 64, 3), dtype=np.uint8)
result = _put_text(image, "", (4, 20), _FONT_HERSHEY_SIMPLEX, 1.0, (255, 0, 0))
assert result is image
assert not np.any(image)
def test_rendered_text_stays_within_reported_box(self) -> None:
"""Keep drawn pixels within the getTextSize rectangle above the baseline."""
image = np.zeros((120, 320, 3), dtype=np.uint8)
org = (20, 80)
(width, height), baseline = _get_text_size(
"person 0.87", _FONT_HERSHEY_SIMPLEX, 1.0, 2
)
_put_text(
image, "person 0.87", org, _FONT_HERSHEY_SIMPLEX, 1.0, (255, 255, 255), 2
)
rows, columns = np.nonzero(np.any(image, axis=2))
assert columns.min() >= org[0]
assert columns.max() <= org[0] + width
assert rows.min() >= org[1] - height
assert rows.max() <= org[1] + baseline
def test_rejects_bottom_left_origin(self) -> None:
"""Reject the unsupported inverted-axis origin mode."""
image = np.zeros((32, 64, 3), dtype=np.uint8)
with pytest.raises(ValueError, match="bottomLeftOrigin"):
_put_text(
image,
"x",
(4, 20),
_FONT_HERSHEY_SIMPLEX,
1.0,
(255, 255, 255),
bottomLeftOrigin=True,
)
class TestFallbackWithoutOpenCV:
"""Production-shaped text calls with OpenCV imports blocked."""
def test_facade_text_works_when_opencv_is_blocked(self) -> None:
"""Exercise the facade getTextSize and putText in a cv2-free subprocess."""
completed = _run_without_opencv(
"""
import sys
class BlockCv2:
def find_spec(self, fullname, path=None, target=None):
if fullname == "cv2":
raise ModuleNotFoundError("blocked for test")
return None
sys.meta_path.insert(0, BlockCv2())
import numpy as np
from supervision import _cv2
assert _cv2.BACKEND_NAME == "fallback"
size, baseline = _cv2.getTextSize(
"Fallback", _cv2.FONT_HERSHEY_COMPLEX | _cv2.FONT_ITALIC, 0.75, 2
)
assert size[0] > 0
assert baseline > 0
image = np.zeros((96, 256, 3), dtype=np.uint8)
result = _cv2.putText(
image,
"Fallback",
(8, 60),
_cv2.FONT_HERSHEY_COMPLEX | _cv2.FONT_ITALIC,
0.75,
(255, 255, 255),
1,
)
assert result is image
assert np.any(image)
"""
)
assert completed.returncode == 0, completed.stderr
def test_draw_text_consumer_uses_fallback_without_opencv(self) -> None:
"""Exercise the public draw_text consumer in a cv2-free subprocess."""
completed = _run_without_opencv(
"""
import sys
class BlockCv2:
def find_spec(self, fullname, path=None, target=None):
if fullname == "cv2":
raise ModuleNotFoundError("blocked for test")
return None
sys.meta_path.insert(0, BlockCv2())
import numpy as np
from supervision.draw.color import Color
from supervision.draw.utils import draw_text
from supervision.geometry.core import Point
image = np.zeros((96, 256, 3), dtype=np.uint8)
result = draw_text(
image,
"Fallback",
Point(80, 40),
text_font=7,
text_scale=0.75,
text_color=Color.WHITE,
)
assert result is image
assert np.any(image)
"""
)
assert completed.returncode == 0, completed.stderr