Modern image-processing and computer-vision utilities for Python, NumPy and OpenCV.
improcv provides small, well-typed, well-tested helpers for common OpenCV tasks, supporting
both OpenCV 4.x and OpenCV 5.x.
pip install improcv alone installs NumPy but not OpenCV — import improcv will fail with a
clear error telling you to install one. Pick exactly one variant:
pip install "improcv[cv]" # opencv-python
pip install "improcv[cv-headless]" # opencv-python-headless
pip install "improcv[cv-contrib]" # opencv-contrib-python
pip install "improcv[cv-contrib-headless]" # opencv-contrib-python-headlessAlready have one of these installed under a different name, or building OpenCV yourself? Just
pip install improcv and install/keep your existing OpenCV — improcv only needs cv2 importable,
it doesn't care how it got there.
improcv.visualization (matplotlib-based display helpers) needs the separate viz extra, on top
of one of the OpenCV extras above:
pip install "improcv[cv-headless,viz]"import improcv never imports matplotlib — only import improcv.visualization does, and it
raises a clear error if the viz extra isn't installed.
import cv2
import improcv as im
image = cv2.imread("photo.jpg")
resized = im.resize(image, width=640)Finding and sorting contours:
import cv2
import improcv as im
mask = im.threshold(im.ensure_gray(image), method="otsu")
contours, hierarchy = im.find_contours(mask, retrieval_mode="external")
contours, boxes = im.sort_contours(contours, order="left-to-right")
# `Contour` keeps OpenCV's own (N, 1, 2) int32 shape, so results pass
# straight into any cv2.* function that expects a contour — no conversion.
cv2.drawContours(image, contours, -1, (0, 255, 0), 2)Connected components and flood fill:
import improcv as im
mask = im.threshold(im.ensure_gray(image), method="otsu")
num_labels, labels, stats, centroids = im.connected_components_with_stats(mask)
# stats[0]/centroids[0] describe the background label (0); inspect
# stats[label, 4] (area) before trusting a component's other statistics.
result = im.flood_fill(image, seed_point=(10, 10), new_value=(0, 255, 0))
print(result.filled_count, result.bounding_box)Histogram and template matching:
import improcv as im
gray = im.ensure_gray(image)
hist = im.histogram(gray) # channel=0, bins=256, value_range=(0.0, 256.0) by default
result = im.match_template(gray, template, method="ccoeff_normed")
match = im.min_max_loc(result)
print(match.max_loc) # (x, y) of the best matchSegmentation and inpainting:
import improcv as im
markers = im.watershed(image, seed_markers)
# Positive values = regions, -1 = boundaries; 0 may remain unassigned.
foreground_mask = im.grabcut_rect(image, im.BoundingBox(x=20, y=20, width=200, height=150))
restored = im.inpaint(image, damage_mask, radius=3.0, method="telea")Feature detection and description:
import cv2
import improcv as im
features = im.detect_and_compute(im.ensure_gray(image), method="orb")
print(len(features.keypoints), features.descriptors.shape, features.norm)
# features.keypoints are real cv2.KeyPoint objects -- pass them straight
# into any cv2.* function that expects them, no conversion needed.
annotated = cv2.drawKeypoints(image, features.keypoints, None)Matching features between two images:
import cv2
import improcv as im
query = im.detect_and_compute(im.ensure_gray(image1), method="orb")
train = im.detect_and_compute(im.ensure_gray(image2), method="orb")
matches = im.match_features(query, train)
# matches is a plain list[cv2.DMatch], sorted by distance (best match
# first) -- pass it straight into cv2.drawMatches, no conversion needed.
annotated = cv2.drawMatches(image1, query.keypoints, image2, train.keypoints, matches, None)
# Or filter with Lowe's ratio test instead of match_features' cross-check:
ratio_matches = im.match_features_ratio(query, train, ratio=0.75)
# Estimate a RANSAC homography from the matches:
result = im.find_homography(query, train, ratio_matches)
if result.homography is not None:
print(result.homography, result.inlier_mask.sum(), "inliers")Hough transform shape detection:
import improcv as im
edges = im.auto_canny(im.ensure_gray(image))
lines = im.hough_lines(edges, threshold=100)
segments = im.hough_line_segments(edges, threshold=50, min_line_length=30, max_line_gap=10)
# hough_circles takes a grayscale image directly, not a binary edge mask.
circles = im.hough_circles(im.ensure_gray(image), min_dist=20, param2=30)
for circle in circles:
print(circle.x, circle.y, circle.radius)QR code decoding:
import improcv as im
code = im.decode_qr_code(image)
if code is not None:
print(code.data, code.points) # data is None if detected but undecodable
# For images that may contain more than one QR code:
for code in im.decode_qr_codes(image):
print(code.data, code.points)Barcode decoding (EAN-8, EAN-13, UPC-A):
import improcv as im
for barcode in im.decode_barcodes(image):
print(barcode.data, barcode.barcode_type, barcode.points)
# data/barcode_type are both None if detected but undecodableAnnotation drawing:
import improcv as im
mask = im.threshold(im.ensure_gray(image), method="otsu")
contours, _ = im.find_contours(mask)
boxes = im.bounding_boxes(contours)
annotated = im.draw_contours(image, contours, color=(0, 255, 0), thickness=2)
annotated = im.draw_bounding_boxes(annotated, boxes, color=(255, 0, 0), thickness=2)
# Both return a new array; `image` itself is never modified.
# Tiling several images into one grid:
grid = im.montage([image, annotated], tile_width=200, tile_height=200)Point/region detectors:
import cv2
import improcv as im
gray = im.ensure_gray(image)
fast_keypoints = im.detect_fast_keypoints(gray)
blob_keypoints = im.detect_blob_keypoints(gray)
annotated = cv2.drawKeypoints(image, fast_keypoints, None)
annotated = cv2.drawKeypoints(annotated, blob_keypoints, None)
mser_regions = im.detect_mser_regions(gray)
# region.points is every pixel belonging to the region as an unordered
# set -- not an ordered boundary, so don't pass it to draw_contours.
# Use the region's bounding box instead:
annotated = im.draw_bounding_boxes(annotated, [region.bounding_box for region in mser_regions])Visualization (optional, requires pip install "improcv[viz]"):
import improcv.visualization as viz
viz.show_image(image, title="input") # handles BGR->RGB, hides axes by default
viz.plot_histogram(image) # one line per channel (B/G/R or grayscale)Image quality metrics:
import improcv as im
error = im.mse(original, compressed)
quality_db = im.psnr(original, compressed) # math.inf if the images are identical
similarity = im.ssim(original, compressed) # 1.0 for identical images, not clamped otherwise
# float images need an explicit data_range; uint8 and uint16 infer it automatically:
similarity = im.ssim(original_f32, compressed_f32, data_range=1.0)
# gmsd is grayscale-only (2D, or 3D with exactly 1 channel) -- convert first
# with im.ensure_gray. Unlike ssim/psnr, lower is better: 0.0 for identical
# images; higher values generally indicate greater distortion according to
# GMSD.
distortion = im.gmsd(im.ensure_gray(original), im.ensure_gray(compressed))Perceptual hashing:
import improcv as im
# uint8 only: grayscale (H, W)/(H, W, 1), BGR (H, W, 3), or BGRA (H, W, 4).
# For color input, alpha (if present) is ignored, not composited.
a = im.average_hash(original)
b = im.phash(compressed) # a different algorithm -- see below
c = im.phash(compressed_but_blurred)
distance = c.distance(im.phash(compressed)) # Hamming distance, an int -- lower means more similar
print(c) # fixed-width, lowercase hex
# a.distance(b) would raise ValueError: average_hash and phash are different,
# non-comparable algorithms even though both produce 64-bit hashes by default.
# round-tripping through hex requires the algorithm and hash_size explicitly --
# a hex string alone can't reveal which algorithm produced it:
restored = im.PerceptualHash.from_hex(str(c), algorithm=im.PerceptualHashAlgorithm.PHASH)
assert restored == cA perceptual hash is not a cryptographic one: collisions are expected, and a
smaller Hamming distance usually (not always) means more visually similar
images. average_hash/phash reproduce cv2.img_hash's own bit decisions
for hash_size=8 -- but not its packed-byte serialization, and not other
libraries' (e.g. ImageHash) genuinely different, incompatible variants of
the same algorithm names.
Photo/creative single-image effects:
import improcv as im
# uint8 only, exactly 3-channel BGR -- no automatic grayscale/BGRA handling.
bgr = im.ensure_bgr(gray) # convert grayscale first if needed
sketch = im.pencil_sketch(bgr) # sketch.grayscale: (H, W); sketch.color: (H, W, 3)
stylized = im.stylize(bgr)
enhanced = im.detail_enhance(bgr)
# BGRA must be handled explicitly before calling -- ensure_bgr does not
# accept it, since there's no single correct way to turn alpha into BGR:
bgr_from_bgra = bgra[..., :3] # drop alpha, or
# bgr_from_bgra = your_own_alpha_compositing(bgra) # composite onto a backgroundsigma_s/sigma_r (and pencil_sketch's shade_factor) are restricted to the ranges OpenCV's
own API documents (0 < sigma_s <= 200, 0 < sigma_r <= 1, 0 <= shade_factor <= 0.1) --
verified directly that sigma_r=0 leads to division by zero internally, and that values beyond
these ranges are unsupported by OpenCV's own contract (the parameters are stored as a C++ float,
so extreme values can silently degrade to a useless result).
Seamless cloning (Poisson image editing):
import improcv as im
result = im.seamless_clone(
source,
destination,
mask,
center=(x, y),
mode="normal",
)source/destination are uint8 BGR (H, W, 3) -- no automatic conversion, and they don't need
to be the same size. mask is a uint8 (H, W) array matching source's spatial size, with only
0/255 accepted. OpenCV always ignores mask's outermost 1-pixel border, and the bounding box of
what remains (after that border is zeroed) must be at least 3x3 pixels. center=(x, y) is in
destination's coordinate system and is where that bounding box's center is placed -- not the
center of all of source or all of mask. There is no automatic alpha handling. Seamless cloning
reconstructs the pasted region from gradients, not by copying pixels -- so a flat-colored source
region can produce little or no visible change, unlike the "cut and paste" effect of alpha blending.
"mixed" picks whichever of source's/destination's gradient is stronger at each pixel (useful
for loosely-drawn masks); "monochrome_transfer" transfers source's luminance structure rather
than its color. The result always has destination's shape and dtype.
HDR-related operations (improcv.hdr) are split into distinct techniques, not one "HDR" feature:
Mertens exposure fusion directly combines aligned LDR images into a display-oriented float32
result without reconstructing physical radiance; radiance HDR merge reconstructs an actual HDR
radiance map from a stack plus its exposure times; camera-response calibration estimates the
response curve that merge can use instead of its default fixed linear one; tone mapping then
compresses that radiance map's dynamic range back down for display. All four are implemented below.
Exposure fusion:
import numpy as np
import improcv as im
fused = im.fuse_exposures(images) # images: list/tuple of uint8, same shape, at least 2
# fused is float32, nominally close to [0, 1] but not clipped to it --
# convert explicitly before saving/displaying as uint8:
display = np.clip(fused, 0.0, 1.0)
display_u8 = np.round(display * 255.0).astype(np.uint8)fuse_exposures wraps OpenCV's Mertens exposure fusion: it blends the stack directly (in the domain
of a Laplacian pyramid, weighted by local contrast/saturation/well-exposedness), producing a single
well-exposed image -- it does not need exposure times and does not produce a physical HDR
radiance map, so its result does not need (and should not go through) tone mapping. images must be
a real Sequence (list/tuple, or another collections.abc.Sequence) of at least 2 uint8 images,
either all 2D grayscale or all 3D BGR (H, W, 3) with identical shape -- a single stacked array,
(H, W, 1), 2-channel, and BGRA are all rejected, with no automatic conversion. contrast_weight/
saturation_weight/exposure_weight must be non-negative and finite; 0 is legal for all three
(it's exposure_weight's own default). Repeated calls with identical input are not guaranteed to be
bit-for-bit identical -- OpenCV's implementation uses internal parallel summation.
Radiance HDR merge -- without calibration (OpenCV's fixed linear response):
import improcv as im
hdr = im.merge_hdr_debevec(images, exposure_times) # or im.merge_hdr_robertson(...)With a calibrated response curve:
response = im.calibrate_camera_response_debevec(images, exposure_times)
hdr = im.merge_hdr_debevec(images, exposure_times, response_curve=response)
# or the Robertson equivalent:
response = im.calibrate_camera_response_robertson(images, exposure_times)
hdr = im.merge_hdr_robertson(images, exposure_times, response_curve=response)images[i] and exposure_times[i] are paired by index -- neither is ever reordered. All exposure
times must use one consistent unit (conventionally seconds): uniformly rescaling every time by a
constant factor rescales the entire output radiance map by the reciprocal of that factor. Not passing
response_curve does not calibrate anything -- calibration is always an explicit, separate step
you call yourself; OpenCV uses a fixed linear response instead if you don't. The output is a raw
radiance map (float32, typically ranging far beyond [0, 1], not clipped or normalized) -- it is
not display-ready and needs tone mapping (see below) before it can be saved or shown; do not
write it directly as a uint8 image. Both merge functions accept BGR only -- grayscale is
not supported by either. merge_hdr_robertson raises a raw cv2.error for grayscale regardless of
dtype, verified directly. merge_hdr_debevec's own default (no explicit response_curve)
linear-response construction has a confirmed bug in OpenCV's own C++ source that corrupts memory for
a genuinely 1-channel array -- undefined behavior that happens not to crash on some platforms but
crashed the process outright (a non-catchable native abort) in this project's own CI on another, so
grayscale is rejected unconditionally for both merge functions rather than only in the specific
triggering case. uint8, uint16, and float32 are all accepted for BGR merge (float32 values
must be finite and within [0, 1]). uint16/float32 additionally require an OpenCV build that
supports them for HDR merge -- verified directly that OpenCV 4.9.0 (this project's documented
minimum) only supports uint8 here; a clear ValueError is raised instead of a raw OpenCV error on
an older build. Calibration itself is always uint8-only (for both algorithms), so its output is
always a 256-entry curve -- it pairs directly with a uint8 merge, not automatically with a
uint16/float32 one. calibrate_camera_response_debevec accepts grayscale or BGR;
calibrate_camera_response_robertson is BGR only (raises a clear error pointing at the Debevec
calibrator for a grayscale stack). calibrate_camera_response_debevec's random_sampling=True
samples pixel locations randomly with no seed control in OpenCV's own API -- its result is not
guaranteed reproducible across calls. calibrate_camera_response_robertson needs a reasonably
diverse intensity histogram to produce a finite curve at all: verified directly that an all-black or
all-white image stack (or one with very few distinct intensity values) deterministically raises
RuntimeError here, regardless of image size. calibrate_camera_response_debevec is generally more
robust to sparse or degenerate intensity histograms because of its smoothness regularization, but a
finite result is not guaranteed on every supported OpenCV build. Non-finite or otherwise
merge-incompatible curves raise RuntimeError. Neither calibrator performs exposure alignment or
ghost removal -- the input stack is assumed already aligned, for both calibration and merge.
Tone mapping -- compressing a radiance map down to a display-ready image:
import numpy as np
import improcv as im
response = im.calibrate_camera_response_debevec(images, exposure_times)
hdr = im.merge_hdr_debevec(
images,
exposure_times,
response_curve=response,
)
tone_mapped = im.tone_map_reinhard(hdr)
display_u8 = np.round(
np.clip(tone_mapped, 0.0, 1.0) * 255.0
).astype(np.uint8)A radiance map from merge_hdr_debevec/merge_hdr_robertson is not display-ready -- its values
typically extend far beyond [0, 1] and are not clipped or normalized. Tone mapping compresses that
dynamic range back down to something a display or uint8 file can represent. Four operators are
provided, wrapping OpenCV's own cv2.createTonemap/createTonemapDrago/createTonemapReinhard/
createTonemapMantiuk: tone_map (simple linear normalization with gamma correction), tone_map_drago
(adaptive logarithmic compression), tone_map_reinhard (photographic, local/global adaptation blend),
and tone_map_mantiuk (contrast-domain compression via an iterative solver -- markedly more
expensive than the other three). Each has its own parameters and produces a visibly different look;
none is a drop-in replacement for another. All four return raw float32 -- none of them clip,
normalize, or quantize their output, even though OpenCV's own documentation describes the result as
[0, 1]: verified directly that a spatially constant input (e.g. a flat, saturated region) can
produce output well outside that range. Always clip explicitly (np.clip(..., 0.0, 1.0)) before
converting to uint8, as in the example above. hdr must be float32, shape (H, W, 3) (BGR --
tone mapping never converts to RGB), finite, and non-empty for all four functions; negative values are
allowed, since neither merge function guarantees non-negative radiance. tone_map_reinhard and
tone_map_mantiuk additionally reject a spatially constant hdr (ValueError) -- verified directly,
in OpenCV's own C++ source, that both divide by a quantity that is exactly zero for a constant image,
regardless of parameters. tone_map_drago and tone_map_mantiuk additionally reject an hdr that
would produce a true zero-luminance (black) pixel once run through OpenCV's own internal
normalization -- a common case, not just a synthetic one (any hdr whose darkest point across all
three channels is a true black pixel triggers it). tone_map_mantiuk additionally requires both
hdr dimensions to be at least 2. See each function's docstring for the full parameter contract
and exact value ranges.
A finite result is not unconditionally guaranteed even for well-formed, non-degenerate hdr.
All four operators internally raise a normalized value to some exponent (gamma, tone_map_drago's
bias, or an internal, non-parametrized exponent for tone_map_reinhard/tone_map_mantiuk);
OpenCV's own floating-point rounding can occasionally leave that value very slightly negative -- an
ordinary artifact of min/max normalization, not a data problem -- and raising a negative number to a
non-integer power is mathematically undefined, producing NaN. Whether this actually happens is
CPU-architecture/SIMD-dispatch-dependent, not just data-dependent: verified directly that the same
seed and parameters that tone-map finitely on Apple Silicon produced a non-finite result (cleanly
caught by RuntimeError) on x86_64 CI. Treat RuntimeError from any tone-mapping function as a real,
expected possibility for otherwise-ordinary input, not only for the degenerate cases listed above.
Mertens exposure fusion (fuse_exposures, above) is a different operation and does not produce a
radiance map -- do not run its output through any of the tone-mapping functions; its result is already
close to display-ready (see its own section above).
Non-local means denoising:
import improcv as im
denoised = im.nl_means_denoise(gray) # uint8 2D grayscale
denoised_bgr = im.nl_means_denoise_colored(bgr) # uint8 (H, W, 3) BGRBoth are uint8-only with no automatic conversion (grayscale/BGR/BGRA/2-channel input to the wrong
function is rejected, not silently handled). Higher h (and h_color for the colored version)
generally smooths more aggressively but can also remove real detail. nl_means_denoise_colored
works internally in CIELAB (denoising luminance and color separately, like OpenCV's own
implementation) -- unlike the grayscale version, h_luminance=0, h_color=0 does not guarantee a
result identical to the input, since the BGR/CIELAB round-trip alone can shift values slightly.
Larger search_window_size values can substantially increase execution time; 7/21
(template_window_size/search_window_size) are OpenCV's own recommended defaults, not hard limits.
template_window_size and search_window_size are independent parameters -- there is no
requirement that search_window_size be at least template_window_size.
Panorama and scan stitching:
import improcv as im
panorama = im.stitch_images(
images,
mode="panorama",
)
# flat scans / documents captured under a simpler planar transformation:
scan = im.stitch_images(images, mode="scans")stitch_images wraps OpenCV's high-level cv2.Stitcher. images must be a real Sequence (list,
tuple, or another collections.abc.Sequence) of at least 2 uint8 BGR (H, W, 3) images -- a single
stacked array (including 4D), str/bytes/bytearray, and a generator/iterator are all rejected
explicitly, as are grayscale, (H, W, 1), 2-channel, BGRA, uint16, float32, and float64 images,
with no automatic conversion. Images may have different spatial shapes -- there is no requirement that
they match. mode="panorama" (the default) uses a homography/perspective model, suited to photos
taken by rotating a camera; mode="scans" uses an affine model, suited to flat scans or documents --
"scans" is not limited to pure translational sliding, it is a broader affine model, just not
projective/perspective. Both modes share the identical input/output contract.
On success, the result is a new, independent uint8 BGR array -- not cropped, and not guaranteed any
particular shape, aspect ratio, or relationship to the input sizes. Insufficient overlap, too few
usable features, or an algorithmic failure of homography/camera-parameter estimation all raise
RuntimeError (never ValueError -- the input images are structurally fine, the algorithm simply
could not relate them), naming the specific OpenCV status and its numeric code.
Neither the outcome nor the exact result is guaranteed deterministic, even within a single process.
OpenCV's feature matching and geometry estimation use RANSAC internally, drawing from OpenCV's global
RNG -- verified directly that, for a borderline amount of overlap, the same input images can succeed
on one call and fail with RuntimeError on the next, in the same process, and that even a reliably
successful stitch is not guaranteed to produce the same output shape or pixel values across repeated
calls. stitch_images never calls cv2.setRNGSeed itself -- that would silently change OpenCV's
global RNG state for every other OpenCV call in the process, not just this one. You can call
cv2.setRNGSeed yourself before calling stitch_images if reproducibility matters for your own
experiments, but that is a process-global setting, not a per-call one, and it does not promise
identical results across different OpenCV builds or platforms.
Stitching can be expensive in both time and memory, in a way this wrapper cannot safely bound.
OpenCV allocates the output panorama and its internal intermediate buffers inside stitch(), before
control returns to Python -- verified directly that a poorly-conditioned geometry estimate (e.g. two
images whose relative orientation does not match what mode expects) can make OpenCV allocate, and
report as a successful result, a panorama and intermediate buffers many times larger than the
inputs. Because the large allocation already happens before this wrapper ever sees a return value, no
check on the returned array could have prevented it, so stitch_images does not attempt one. For
untrusted or unpredictable input sets, consider running this function in an isolated process with its
own resource limits, or use OpenCV's lower-level stitching pipeline directly for finer control.
Per-image feature masks supported by the lower-level OpenCV Stitcher API are not exposed by this
wrapper. This first version also does not expose any of cv2.Stitcher's registration/seam/
compositing/confidence settings -- it always uses OpenCV's own defaults.
DNN preprocessing:
import improcv as im
blob = im.create_dnn_blob(
image,
size=(224, 224),
scale=1.0 / 255.0,
mean=(0.0, 0.0, 0.0),
swap_rb=True,
)
# a batch of images sharing one set of parameters:
batch = im.create_dnn_batch_blob(
[image_a, image_b],
size=(224, 224),
scale=1.0 / 255.0,
swap_rb=True,
)create_dnn_blob/create_dnn_batch_blob wrap cv2.dnn.blobFromImage/blobFromImages to turn a
uint8/float32 image (or a Sequence of them) into a blob ready for cv2.dnn.Net.setInput --
they do not load a model, create a cv2.dnn.Net, or run inference; that is a separate, later slice.
The output is always float32 and always 4-D NCHW ((1, C, H, W) for a single image, (N, C, H, W)
for a batch) -- a single image still gets an explicit batch dimension, there is no 3-D HWC return
path. size is (width, height), matching OpenCV's own convention, not NumPy's (height, width)
indexing order. crop=False (the default) resizes directly to size, stretching independently in
each dimension without preserving aspect ratio; crop=True uniformly scales the image so it covers
size in both dimensions, then takes a centered crop -- both always use OpenCV's INTER_LINEAR,
which is not configurable, because the underlying OpenCV function does not expose an interpolation
parameter either.
The operation is (input - mean) * scale. A scalar mean (e.g. mean=1.0) is broadcast by improcv
to every channel; passing that same scalar directly to raw cv2.dnn.blobFromImage would not
broadcast it -- it would be interpreted as only the first channel's mean, leaving the others at zero.
A tuple mean must have exactly one element per channel, and its order refers to the output
channel order: for BGR/BGRA input, swap_rb=False means (B, G, R[, A]) and swap_rb=True means
(R, G, B[, A]) -- passing BGR-ordered image data does not, by itself, produce RGB-ordered output;
swap_rb=True is required for that, and it never touches an alpha channel, which always stays last.
create_dnn_batch_blob requires an explicit size -- there is no "keep native size" default for a
batch, because OpenCV silently resizes every image after the first to match the first image's
native size when no size is given, which silently produces wrong results for a batch of
differently-sized images. create_dnn_blob (single image) does default size to None, which
keeps that one image's native size.
Only uint8 and float32 input is accepted (grayscale, (H, W, 1), BGR, or BGRA) -- verified
directly that other dtypes accepted by raw OpenCV on some versions (e.g. int16, uint16,
float64) are silently converted on OpenCV >= 4.13 but raise a raw cv2.error on OpenCV 4.9, so
allowing them here would make behavior depend on the installed OpenCV version. There is no
output_dtype parameter in this first version -- the output is always float32; a uint8-output
mode may be added later, compatibly, as a new keyword-only parameter if a concrete need for it
appears.
Extremely large size values can exhaust process memory or be killed by the operating system --
this is not, and cannot be, turned into a catchable Python exception. size is validated to be
representable (each dimension and their product must fit a signed 32-bit int, matching an internal
limit in OpenCV's own blob-construction code), but that says nothing about whether the resulting
allocation is a reasonable size for the machine running it -- pick size based on your actual model
input requirements, not arbitrarily large values.
No DNN inference or cv2.dnn.Net configuration (backend/target) is included in this slice, and no
new dependency (or improcv[ml] extra) was introduced for it -- both functions run on the same base
OpenCV install as the rest of improcv. ONNX model loading is implemented below as a separate
Phase 5 slice.
ONNX model loading:
import improcv as im
net = im.load_onnx_network("model.onnx")
blob = im.create_dnn_blob(
image,
size=(224, 224),
scale=1.0 / 255.0,
swap_rb=True,
)
net.setInput(blob)
output = net.forward()load_onnx_network/load_onnx_network_from_bytes load an ONNX file (from a path or from an
in-memory bytes buffer, respectively) into a cv2.dnn.Net -- they are ONNX-only, not a
generic "load any DNN model" function; other formats (TensorFlow, Caffe, Darknet, Torch, OpenVINO)
are out of scope. Preprocessing (create_dnn_blob/create_dnn_batch_blob, above) and model loading
are both improcv API; Net.setInput/Net.forward (shown above) remain raw OpenCV API, not wrapped
-- this slice does not add an inference function, model-specific postprocessing, or a backend/target
API.
The path and bytes loaders are two separate functions, not one function accepting either, because a
bare bytes argument is genuinely ambiguous to raw OpenCV: verified directly that
cv2.dnn.readNetFromONNX(some_bytes) (positional) is silently routed to the path overload on
OpenCV 4.13/5.0 (interpreting the bytes as a file path and failing to find it) but to the buffer
overload on OpenCV 4.9 -- an actual behavioral difference between supported versions, not a
hypothetical one. load_onnx_network_from_bytes only accepts bytes (not bytearray, memoryview,
or an ndarray) and internally converts to a uint8 array before calling OpenCV by keyword, which
resolves correctly and identically on every supported version.
load_onnx_network's path accepts a str or os.PathLike[str] (e.g. pathlib.Path); its content,
not its extension, is what's parsed -- a valid ONNX file with no .onnx extension is accepted.
Filesystem problems that Python can detect directly give native exceptions (FileNotFoundError,
IsADirectoryError, an empty file raises ValueError); a problem OpenCV's own parser hits (a
corrupt/invalid model, or a permission/ACL issue that only surfaces once OpenCV tries to open the
file) raises RuntimeError with the original error attached as __cause__. A path containing
non-ASCII characters is not guaranteed to work identically on every platform -- verified directly
(via CI) that the same accented path opens fine on Linux/macOS but makes OpenCV's own file-opening
code fail on Windows (surfacing as RuntimeError, not a bug in this wrapper's validation); prefer
an ASCII-only path where cross-platform behavior matters.
Every call to either loader parses the model again and returns a new, independent cv2.dnn.Net --
nothing is cached, and repeated loading of a large model is exactly as expensive as it looks. The
returned Net is a stateful object: calling setInput() mutates it, backend/target configuration is
your responsibility, and this wrapper makes no thread-safety promise about it.
On OpenCV 5, improcv requests ENGINE_CLASSIC as the common behavior shared with OpenCV 4.x, since
4.x has no other engine. OpenCV process configuration, including OPENCV_FORCE_DNN_ENGINE, may
override that request -- this is a best-effort request, not a guarantee.
ONNX models are parsed by OpenCV's native (C++) code, with no sandboxing from this wrapper. There is no file-size limit, and no attempt is made to validate the ONNX format beyond what OpenCV's own parser does. A malicious or merely corrupted model can exploit a parser bug, and a very large model can consume a large amount of memory or end the process outright -- the same risk as parsing any untrusted binary format. Do not load models from untrusted sources in-process; isolate them in a separate process with its own resource limits instead. This slice does not download models from anywhere -- you provide the path or bytes.
Classification evaluation:
import improcv as im
cm = im.confusion_matrix(
y_true=[0, 0, 1, 1],
y_pred=[0, 1, 1, 1],
labels=[0, 1],
)
metrics = im.classification_metrics(
y_true=[0, 0, 1, 1],
y_pred=[0, 1, 1, 1],
labels=[0, 1],
average=None,
)confusion_matrix/classification_metrics cover single-label multiclass classification with
integer labels only -- one true class and one predicted class per sample, not multilabel, and not
string/hashable labels. cm.matrix[i, j] counts samples whose true class is cm.labels[i] and
predicted class is cm.labels[j]: rows are true labels, columns are predicted labels.
labels=None infers the class universe as the sorted union of every value observed in y_true/
y_pred; an explicit labels fixes the exact row/column order instead. improcv raises
ValueError in two cases where sklearn.metrics.confusion_matrix does not: a duplicate value
within an explicit labels (sklearn silently accepts it, but with confusing index semantics --
the repeated label's row/column gets written to by whichever occurrence's index NumPy resolves
last, not merged or rejected), and an observed y_true/y_pred value outside an explicit labels
(sklearn silently drops that sample from the matrix instead of erroring).
classification_metrics's result type depends on average: average=None (the default) gives
precision/recall/f1 as per-class, read-only float64 arrays; average="micro"/"macro"/
"weighted" gives them as plain Python floats instead -- never both forms from the same call.
support is always a per-class, read-only int64 array regardless of average, and accuracy
is always a plain float. zero_division (0.0, 1.0, or "nan") controls what a class reports
when its own division is genuinely undefined -- precision, recall, and F1 each have
their own zero-division condition, checked independently from true positive/false positive/false
negative counts (TP/FP/FN), never from each other: precision uses zero_division only when
TP + FP == 0 (the class was never predicted); recall uses it only when TP + FN == 0 (the class
never occurs in the true labels); F1 uses it only when 2*TP + FP + FN == 0 (the class is
completely absent from both). A class with TP = 0 but real, nonzero FP/FN has a correctly
defined F1 = 0 -- zero_division does not turn that 0 into 1.0 or NaN. With "nan", an
actually-undefined value's NaN propagates into "macro"/"weighted" aggregates by plain
averaging, not by skipping the affected class, including when that class's own support (and
therefore its weight) is zero.
A confusion matrix already computed (including one you've aggregated yourself from several
batches, e.g. ConfusionMatrixResult(matrix=sum(batch_matrices), labels=original_labels)) can be
passed to classification_metrics_from_confusion_matrix directly, without recomputing it from raw
labels -- since such a matrix is never re-derived from y_true/y_pred, its total count is
verified to fit in int64 before support/precision/recall/F1 are computed, raising ValueError
instead of silently wrapping around to a negative count if it doesn't. An empty confusion matrix
is possible only with explicit labels (confusion_matrix([], [], labels=[0, 1]) returns a
well-defined all-zero matrix); classification_metrics, classification_metrics_from_confusion_matrix,
and confusion_matrix with labels=None all require at least one observation. Building the dense
matrix costs O(len(labels) ** 2) memory, so a very large explicit labels can exhaust process
memory even though improcv checks the allocation is at least representable first.
This is a numeric core only: no plotting, no multilabel classification, no sample weights, and no
scikit-learn dependency.
Binary one-vs-rest ranking curves -- ROC, precision-recall, ROC AUC, and average precision:
import improcv as im
y_true = [0, 0, 1, 1]
y_score = [0.1, 0.4, 0.35, 0.8]
roc = im.roc_curve(y_true, y_score, positive_label=1)
pr = im.precision_recall_curve(y_true, y_score, positive_label=1)
roc_auc = im.roc_auc_score(y_true, y_score, positive_label=1)
ap = im.average_precision_score(y_true, y_score, positive_label=1)
pr_auc = im.auc(pr.recall, pr.precision) # trapezoidal PR-curve area -- distinct from `ap`
# all four accept an optional, keyword-only sample_weight
weighted_roc_auc = im.roc_auc_score(
y_true, y_score, positive_label=1, sample_weight=[2.0, 1.0, 3.0, 1.0]
)y_score is a ranking score, not a predicted label or a probability -- it does not need to lie in
[0, 1], and a larger score means greater confidence in the positive class. roc_curve/
precision_recall_curve/roc_auc_score/average_precision_score are binary, one-vs-rest:
positive_label is always required and explicit -- a sample is positive iff its label equals
positive_label; every other observed label is negative, regardless of how many distinct negative
labels occur, and there is no automatic inference of which label is positive. A threshold
classifies a sample positive iff score >= threshold; every sample sharing the same score is
aggregated into one threshold before FPR/TPR/precision/recall is computed there, so permuting the
order of tied samples (or of the whole input) never changes the result.
y_score accepts a Sequence of Python int/float or NumPy float16/float32/float64
scalars, or a 1-D ndarray with an integer or float16/float32/float64 dtype -- not "any
NumPy floating dtype": a NumPy floating scalar or ndarray wider than float64 (e.g.
np.longdouble where it is a genuine extended-precision type on the current platform) is rejected
with TypeError either way, since narrowing it could silently collapse two distinct scores into
the same tied threshold. An integer score (Python or NumPy, in a Sequence or an ndarray) is
legal only when it converts to float64 exactly -- both an out-of-range magnitude (e.g. 10**400,
which would otherwise raise a raw OverflowError) and an in-range value that would lose precision
(e.g. 2**53 + 1) raise the same documented ValueError instead. Every zero in the normalized
score array is canonicalized to positive zero, so a threshold derived from a tied +0.0/-0.0
group is always +0.0 regardless of which sign happened to appear first in the input.
Both curves start at the same sentinel threshold +inf (predicting nothing positive): ROC starts
at (FPR, TPR) = (0.0, 0.0), precision-recall starts at (precision, recall) = (1.0, 0.0) with no
corresponding real threshold. thresholds[1:] holds every distinct observed score in strictly
decreasing order, so thresholds/the two curve-value arrays always share one length (K + 1 for
K distinct scores) -- recall increases alongside decreasing thresholds, matching roc_curve's
convention (a deliberate departure from sklearn.metrics.precision_recall_curve's descending
recall). roc_curve/roc_auc_score require at least one positive and one negative sample,
raising ValueError instead of scikit-learn's UndefinedMetricWarning for a degenerate input;
precision_recall_curve only requires at least one positive sample -- a y_true with no negative
sample is legal, giving precision == 1.0 at every real threshold.
roc_auc_score integrates the ROC curve with the trapezoidal rule (equivalent to the probability
that a random positive sample outranks a random negative one, with a tied pair counted as
one-half) -- it never calls np.trapz/np.trapezoid, since neither name exists across this
project's full supported NumPy range: np.trapezoid was only introduced in NumPy 2.0, and
np.trapz was removed in NumPy 2.4, so no single name works across this project's full
numpy>=1.24 support range. roc_curve/precision_recall_curve return new, independent,
read-only float64 arrays -- never a view of y_true/y_score; roc_auc_score,
average_precision_score, and auc (below) return a plain Python float, not an array.
average_precision_score is binary classification ranking average precision -- not
object-detection AP or mAP (which additionally require matching predictions to ground truth by
IoU; this function has no notion of bounding boxes, IoU, or per-class averaging). It is a
non-interpolated weighted mean of precision, using each recall increment as its weight:
sum((recall[i] - recall[i - 1]) * precision[i] for i in 1..K) over the same grouped-threshold
points precision_recall_curve returns, with precision[i] always taken from the right end of
each recall increment. This is not linear interpolation and not the trapezoidal area under the PR
curve, which is a distinct quantity average_precision_score does not compute -- depending on the
shape of the curve and its ties, that trapezoidal area can be larger or smaller than average
precision, never consistently one or the other, so no fixed relationship between the two should be
assumed. A perfectly reversed ranking does not give 0.0 (average precision has no complement
relation the way ROC AUC does); a y_true with no negative sample gives exactly 1.0; constant
scores (no discriminative power) give exactly the positive prevalence P / len(y_true) -- these
are the only two inputs with a closed-form result documented here, not a general property of every
weak or random ranking. average_precision_score shares roc_curve's input and error contract,
except a y_true with no negative sample is accepted rather than rejected (same relaxation as
precision_recall_curve).
All four functions accept an optional, keyword-only sample_weight (None by default, which
preserves the unweighted behavior above bit for bit). Given explicitly, it must be the same length
as y_true/y_score, holding the same accepted numeric types as y_score, but non-negative with
at least one positive value. A sample with sample_weight == 0.0 is removed entirely before
thresholds are built -- a score that exists only at zero weight never produces a threshold, and a
class present only among zero-weight samples is treated as absent (raising the same effective-
support errors as an unweighted y_true missing that class). TP(t)/FP(t) become the sum of
weights of the effective positive/negative samples with score >= t; roc_auc_score/
average_precision_score use the matching weighted curve internally rather than recomputing
anything from the public curve types, so roc_auc_score(..., sample_weight=w) is always exactly
auc(*that same weighted roc_curve's rate arrays*). Ties are aggregated the same way regardless of
weights or input order: every sample sharing a score is grouped and its weight summed via
math.fsum over a canonically sorted sequence, not a plain running float64 sum, so permuting
samples within a tie (or the whole input) never changes the result -- a plain per-sample
np.cumsum would not have that guarantee (verified directly: np.cumsum([1e16, 1.0, 1.0])[-1] != np.cumsum([1.0, 1.0, 1e16])[-1]). An extreme sample_weight dynamic range that would make a whole
distinct-score group's contribution numerically vanish from the running total raises ValueError
rather than silently dropping that group. sample_weight=None is not the only unweighted-compatible
case: sample_weight=[1.0, ...] (all ones) and small-integer weights equivalent to physically
replicating samples both give bit-identical results to the corresponding unweighted call.
auc(x, y) is a general-purpose trapezoidal area-under-curve helper with no ranking semantics of
its own -- no positive_label, no tie-aggregation, no notion of positive/negative samples. x
must be non-decreasing or non-increasing throughout (duplicate x values are legal either way and
contribute a zero-width segment; constant x gives exactly 0.0); a non-increasing x gives the
same positive geometric area a non-decreasing order of the same points would, not a signed integral
-- the result never depends on which direction a monotonic x happens to be given in. Unlike
roc_auc_score/average_precision_score, whose inputs and outputs are always in [0, 1] by
construction, auc's y may be negative and so may its result. Ordinary calls -- y entirely
non-negative or entirely non-positive, with no intermediate overflow/underflow -- use a fast,
canonical float64 summation (the path roc_auc_score's always-non-negative TPR and
auc(curve.recall, curve.precision)'s always-non-negative precision both take). auc falls back to
computing the exact trapezoidal sum as a rational number (fractions.Fraction, standard library,
used only on these rare paths) over the already-normalized float64 values, converting only the
final total back to float, in three situations: an intermediate segment width/height-sum/product
would overflow float64; a segment's own contribution would underflow in a way that could lose it
before it has a chance to be summed with its neighbors; or y contains both a positive and a
negative value, since opposite-signed contributions can cancel in the final sum in a way no NumPy
overflow/underflow/invalid signal would ever catch (e.g. y=[1.0, 1e-20, -1.0], whose exact area is
1e-20 but whose plain float64 segment contributions of 0.5 and -0.5 would otherwise silently
cancel to 0.0). This correctly preserves cancellation between huge intermediate contributions, an
accumulated subnormal residual (several segments that each round to 0.0 on their own, but whose
exact total is a representable positive subnormal float64), and a mixed-sign cancellation residual
-- all cases a fast, per-segment float64 computation could otherwise lose. A contribution whose
exact value genuinely is closer to 0.0 than to the smallest positive subnormal float64 still
legitimately rounds to 0.0 -- the exact fallback decides this correctly rather than the fast path
guessing. Only an input whose true, exact area is not representable as a finite float64 raises
ValueError -- this never silently returns inf/-inf/NaN, and never emits a NumPy warning for
an accepted input regardless of the caller's own np.seterr/np.errstate configuration. auc
computes the trapezoidal area under the precision-recall curve when called as
auc(curve.recall, curve.precision) -- this is a distinct quantity from average_precision_score
(a non-interpolated, non-trapezoidal definition, see above), and this is the complete, supported way
to compute it: there is no separate score-level function for it, to avoid one more symbol that could
be confused with average_precision_score.
roc_curve/precision_recall_curve/roc_auc_score/average_precision_score run in O(N log N)
time, O(N) memory, dominated by sorting scores by rank. auc needs no sorting: for non-negative or
non-positive y with no intermediate overflow/underflow, its fast float64 path is O(N) time,
O(N) memory. A curve with mixed-sign y (crossing zero, or otherwise containing both a positive
and a negative value), or one triggering an intermediate overflow/underflow, uses the rare exact
fallback instead -- a deliberate, publicly-supported correctness trade-off, not an edge case to avoid
-- built on standard-library arbitrary-precision rational arithmetic, whose cost also depends on how
large the underlying integer numerator/denominator representations grow, so it is not promised to
run in strict O(N) time independent of the input values themselves; this slice covers binary
ROC/PR/ROC-AUC/average-precision (with optional sample_weight) plus a generic trapezoidal auc
helper: no multiclass score matrix, no averaging, no plotting, and no sample_weight for auc
itself (it operates on curve points, not observations) or for confusion_matrix/
classification_metrics (a separate, unresolved design question -- see those functions' own
docstrings).
Augmentation sampling and replay -- flip:
import numpy as np
rng = np.random.default_rng(42)
flip_params = im.sample_flip(
rng,
horizontal_probability=0.5,
vertical_probability=0.1,
)
flipped = im.apply_flip(image, flip_params)With a segmentation mask, apply the same sampled flip to both:
pair = im.apply_flip(image, flip_params, mask=mask)
flipped_image = pair.image
flipped_mask = pair.maskAugmentation sampling and replay -- crop:
crop_params = im.sample_crop(
rng,
source_size=(image.shape[1], image.shape[0]),
crop_size=(256, 256),
)
pair = im.apply_crop(image, crop_params, mask=mask)Augmentation sampling and replay -- affine (shear, rotation, translation, isotropic scale):
affine_params = im.sample_affine(
rng,
source_size=(image.shape[1], image.shape[0]),
angle_range=(-10.0, 10.0),
translation_x_range=(-8.0, 8.0),
translation_y_range=(-8.0, 8.0),
scale_range=(0.9, 1.1),
shear_x_range=(-0.15, 0.15),
shear_y_range=(-0.10, 0.10),
)
pair = im.apply_affine(
image,
affine_params,
mask=mask,
mask_border_value=255,
)sample_flip/sample_crop/sample_affine take an explicit rng: np.random.Generator and return
a small, independent result (FlipParameters/CropParameters/AffineParameters) -- there is no
global/implicit RNG anywhere in improcv. That result can be stored and replayed any number of
times through apply_flip/apply_crop/apply_affine, always producing the same output for the
same input. CropParameters/AffineParameters carry the source_size they were sampled for;
apply_crop/apply_affine require the image (and mask, if given) to match that size exactly, so
parameters sampled for one image can't be silently misapplied to a differently-sized one. Passing
mask= applies the identical transform to the mask, returning both as an AugmentedImageMask
instead of a bare array; a segmentation mask is restricted to uint8/uint16/int16 in this
slice (not bool/int32/int64/floating-point, and not one-hot/multi-channel encodings).
For sample_affine/apply_affine specifically: angle_range is in degrees (positive =
counter-clockwise, matching im.rotate); translation_x_range/translation_y_range are in pixels
(positive x moves content right, positive y moves it down, matching im.translate);
scale_range is a positive, dimensionless, isotropic multiplier. shear_x_range/shear_y_range
are raw, dimensionless shear coefficients, not degrees: shear_x maps x' = x + shear_x * y,
and shear_y (applied after shear_x) then maps y' = y + shear_y * x', using the already-sheared
x' -- documented as "shear x, then shear y", never as simultaneous, since that's exactly what the
sequential composition means. A positive shear_x moves the bottom of the image right relative to
the top; a positive shear_y moves the right side down relative to the left. There is no forbidden
angle and no abs(shear) limit -- this is a deliberate choice of parameterization:
[[1, shear_x], [shear_y, 1 + shear_x*shear_y]] has determinant 1 mathematically, for any finite
coefficients (area- and orientation-preserving), unlike the naive-looking [[1, shear_x], [shear_y, 1]], which becomes singular whenever shear_x * shear_y == 1 and flips orientation beyond that --
improcv never uses the naive form. That determinant-1 guarantee is about the exact real-number
parameterization, not a promise of infinite float64 precision: improcv rejects a shear_x/
shear_y pair whose product is large enough (roughly 2**52 in magnitude) that float64 can no
longer tell 1.0 + shear_x*shear_y apart from shear_x*shear_y itself, since that would silently
store a matrix that has lost the unit term making it invertible. Short of that, a large shear
coefficient is still accepted even though the resulting matrix can be very poorly conditioned,
strongly deform the image, or push its content outside the canvas entirely -- there is no automatic
protection against that beyond the float64-representability check. Each *_range is a
(low, high) tuple sampled independently via Generator.uniform -- low is always reachable,
equal endpoints sample that exact constant, but hitting high itself is not guaranteed for a
non-degenerate range (an ordinary property of continuous floating-point sampling). The transform is
always shear x, then shear y, then rotation + isotropic scale (all around the image center), then
translated -- this composition order is fixed and documented, not an implementation detail, since
shear does not commute with rotation, and translation does not commute with the rest in general.
AffineParameters.matrix (the (2, 3) matrix actually applied) is the sole source of truth for
replay; angle/translation/scale/shear are sampling metadata kept for debugging/logging/
repr only and are never used to reconstruct or cross-check the matrix. When shear_x_range/
shear_y_range are left at their (0.0, 0.0) default, no extra rng draw happens and the matrix
is bit-for-bit identical to what sample_affine produced before shear existed -- code written
before this feature keeps sampling angle/translation/scale from the exact same rng sequence,
call after call. Output spatial size always equals the source size -- this slice does not expand
the canvas the way im.rotate_bound does. The mask is always warped with nearest-neighbor
interpolation and a constant border (mask_border_value, default 0); the caller cannot change
the mask's interpolation or border mode, only the fill value.
This slice covers flip, crop, and a shear+rotation+translation+isotropic-scale affine transform: no
perspective warp, no anisotropic scale, no canvas expansion, no photometric augmentation
(brightness/contrast/blur/noise), no bounding box/keypoint/polygon support, and no Compose-style
augmentation pipeline.
Dataset image discovery:
files = im.discover_images(
"dataset/images",
recursive=True,
)With custom extensions:
files = im.discover_images(
"dataset/images",
extensions={".png", ".tif"},
)discover_images finds candidate image files under a directory by filename extension only --
files are never opened or decoded, so an empty, corrupted, or non-image file with a matching
extension is still discovered. The result is a materialized, globally-sorted tuple[Path, ...]
(sorted by each path's POSIX-style form relative to root, independent of the underlying
filesystem's traversal order or the platform's path separator) -- this costs O(N) memory, so this
is not a streaming indexer for an arbitrarily large tree. Returned paths keep root's own
relative/absolute form (e.g. discover_images("data") returns paths like Path("data/cat.jpg"),
never resolved or made absolute). root itself may be a symlink to a directory, but any symlink or
Windows reparse point (including a junction) found while traversing root's contents is always
skipped, along with everything under it -- there is no follow_symlinks option. A descendant file
or directory whose name starts with . is skipped by default (this never applies to root itself,
so an explicitly given root like ".dataset" is still searched); pass include_hidden=True to
include them. Filesystem errors are fail-fast: a missing/inaccessible root, or a permission error
encountered anywhere during traversal, raises immediately rather than silently skipping data --
every descendant is classified from a fresh filesystem check at inspection time, not a stale result
from listing the directory, so a file deleted or replaced right before being inspected still raises
rather than passing through unnoticed (a file changed after that check is an ordinary, undetected
race, same as with any filesystem operation). An
empty directory, or a directory with no matching files, returns (), not an error. This slice does
not pair images with masks, infer classes from directory names, produce dataset splits, or load/
decode any image, and adds no new dependency.
improcv is in early development. 0.1.0a1 is designated as the first public release and covers
the accumulated scope of Phases 0-3 (see ROADMAP.md
for what that includes, and why it doesn't match the project's original one-phase-per-minor-version
plan). See CHANGELOG.md for the exact
list of what's been added.
Compatibility policy before 1.0.0:
0.1.0a1is an alpha: functional, tested, but not yet declared stable.- Before
1.0.0, the public API may still change, including in backwards-incompatible ways, in any0.MINORrelease. While in alpha, this also applies between consecutive prereleases of the same version (e.g.0.1.0a1→0.1.0a2). - Any backwards-incompatible change will always be called out explicitly in
CHANGELOG.md, not silently folded into a routine entry. - Deprecation (a warning period before removal) will be used where practical, but the project does
not yet guarantee a fixed deprecation window before
1.0.0. - A full, stable compatibility and deprecation policy will be established before
1.0.0ships.
MIT — see LICENSE.