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:
parent
bc7b9fc69e
commit
68e63f9e39
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue