1
0
mirror of https://github.com/opencv/opencv.git synced 2026-07-21 19:33:03 +04:00
Files
opencv/modules/core/test/test_arithm_expr.cpp
T
Vadim Pisarevsky f968fb969f Broadcasting element-wise engine for cv::Mat (+ cv::texpr) (#29426)
* experimental new arithmetics; work-in-progress

* continue working on new-gen arithmetic expressions

* * improved performance of the new add on small arrays
* added sub
* extended tests

* fixed potential bug when adding multi-channel array and a single-channel scalar

* improved const handling

* * added copyMask
* added mul/dev (without scale so far)

* * accelerated mul
* addedd scale to mul and div

* * done substantial refactoring; however a few more rounds of refactoring are ahead.
* added min, max, absdiff, addweighted.

* improved performance of the new arithmetic functions, but some of them are still slow, e.g. operations with mask have some bugs (that affect speed, not accuracy).

* * further (significantly) accelerated several functions, especially on small arrays: mul, binary ops with mask

* further polished the new arithmetic engine

* started integration of the new element-wise arithmetic engine into core

* big step forward. We now use the new engine inside cv::add, subtract, multiply, divide, absdiff, min and max.

* big progress:
* added bitwise operations
* fixed and accelerated compare
* ported regression tests to test new broadcasting behaviour of arithmetic functions

* lot's of improvements in compare, divide, addWeighted!

* lot's of small and big performance improvements in the new arithmetics

* * some more optimizations; parsing texpr-expressions is now faster as well

* port new_arithm to Linux/x86: dispatch guards, scalar-Mat compat fallback, dnn shape-contract fixes

Core:
- arithm.simd.hpp: CV_CPU_OPTIMIZATION_DECLARATIONS_ONLY guards (the file is included
  once per dispatched mode on x86), vx_load_expand instead of the 128-bit v_load_expand,
  VTraits::vlanes() instead of ::nlanes
- arithm.cpp/precomp.hpp: compat fallback for scalar-like Mat operands (1x1, 1xcn/cnx1,
  4x1 CV_64F - java/python tuples, operator-(Mat, Matx)): treated as a per-channel scalar
  ONLY when the shapes are not broadcast-compatible, so every valid numpy-style broadcast
  keeps its meaning and calls that would otherwise throw get the 4.x semantics

DNN (fallout of the stricter shape semantics, found by the new engine):
- dict.hpp: DictValue relied on fresh AutoBuffer having size()==fixed_size; allocate explicitly
- batch_norm: weights_/bias_ are 1-D [n] now; 0/1-D forward runs on exact-shape 1-D views
- net_impl2: extend the post-forward sanity check to non-temp outputs - a layer that
  reallocates its preallocated output tensor now fails loudly instead of silently
  detaching the result from the graph
- LSTM/LSTM2 batchwise (layout=1): getMemoryShapes now matches what forward() writes
  (ONNX: Y=(batch,seq,dirs,hid), Yh/Yc=(batch,dirs,hid)); forward assembles seq-major
  results in a local buffer and transposes INTO the preallocated outputs in place

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* u8/s8 multiply: exact integer SIMD path on non-FP16 builds (3-10x vs 5.x)

The unit-scale branch of vecBinaryKernel already supported a separate work-vector
type Wvec1 (used by the ARM f16 build and by u16/s16 everywhere), but on x86 the
u8/s8 same-type multiply still went through the f32 hub. Route it through
v_uint16/v_int16: products of 8-bit values fit exactly (255^2 < 2^16), the
saturating pack on store gives bit-exact results at half the vector traffic.

Also fix a latent kernel bug this exposed: the unit-scale branch stepped by
Wvec's lane count while loading/storing Wvec1 vectors. All previous Wvec1
instantiations had equal lane counts, but u8's v_uint16 has 2x the lanes of
v_float32 - the pairs overlapped (50% redundant work) and the tail backoff
could write VECSZ bytes past the row end. The branch now derives its step,
offsets and tail condition from Wvec1 itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* vecBinaryKernel: constexpr Op::useScalar instead of a runtime-only scale check

Every binary op functor now declares whether it consumes the scale scalar
(params[0]): true only for mul and the two div variants. Ops that ignore it
(add/sub/min/max/absdiff) take the fast 2-arg branch unconditionally - the
'scalar == 1' check used to fail for them (their params[0] is 0), sending them
through the preproc branch, and the condition now folds at compile time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* restore cv::hal::mul8u as a wrapper over the element-wise engine

The symbol is still declared in core/hal/hal.hpp and called directly by external
code (the G-API fluid backend in opencv_contrib), but its implementation went
away with the old arithm kernels. Forward it to getMulFunc(CV_8U, CV_8U) - with
scale==1 it lands on the new exact integer SIMD path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* silence every new-arithm warning reported by CI (ARM64/Mac) and gcc 15

- arithm.cpp: bitwise_op_ocl and the actualScalarDepth/coerceTypes helpers are
  consumed only by the OpenCL paths - guard them with HAVE_OPENCL; haveScalar in
  cv::compare is read only inside CV_OCL_RUN - CV_UNUSED for OpenCL-less builds
- arithm_expr.hpp: declare getBitwiseFunc/getNotFunc/getAddWeightedFunc next to
  the other per-op entry points (-Wmissing-prototypes in arithm.dispatch.cpp)
- arithm.simd.hpp: define CV_SIMD_16F to 0 when FP16 SIMD is absent (-Wundef);
  {}-init the expandScalar staging buffers (-Wmaybe-uninitialized: they are
  fully written before use, but the compiler cannot prove it with runtime
  vector widths); rename the compare kernel's lambda parameter (-Wshadow)
- arithm_expr.cpp: rename the exec tile-lambda's hot-field locals that shadowed
  TExpr members and outer locals (-Wshadow)
- test_new_arithm_extensive.cpp: rename the name-generator lambdas' parameter
  shadowing the INSTANTIATE macro's own (-Wshadow), drop an unused variable

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* restore 4.x scalar semantics for the bindings' 4x1 CV_64F Scalar columns

The python/java bindings materialize numbers and tuples as a 2-D 4x1 CV_64F
Mat - or UMat, when the call carries UMat arguments. Three CI-reported python
failures came from those pseudo-scalars reaching the engine as arrays:

- absdiff(int_arr, 0): the (4,1) column is broadcast-COMPATIBLE with a 1-D
  array, so numpy semantics silently won - an outer-product f64 result instead
  of the int per-channel-scalar one;
- subtract(u8 4x8x4, (40,)): same, by the rows==4 coincidence;
- multiply(UMat, 2., dst=UMat): the scalar arrives as a UMAT, which the
  scalar detection did not recognize at all.

isScalarArg now treats the exact bindings shape - 2-D 4x1 CV_64F single-channel
Mat/UMat against a <=4-channel array - as a scalar UNCONDITIONALLY (a 1-D [4]
array has dims==1 and still broadcasts). One exception, decided in arithm_op:
when the partner is itself a tiny scalar-shaped array, both are honest data and
ride the broadcast (compare(Mat 4x1, Mat 1x1) - issue #8999 - stays elementwise).

Small-array discipline, this all runs per engine call: the probes read Mat/UMat
fields directly (rows == 4 alone rejects almost everything, no _InputArray
getter dispatch), and a UMAT scalar's 32 bytes are copied into a caller-stack
buffer - no heap, no getMat mapping. Measured: no latency change on 4x4/16x16
element-wise calls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* cv::texpr: std::string_view -> const std::string& in the public API

string_view in an exported signature breaks some CUDA toolchain builds, and for
short expression strings the difference is immaterial (SSO, parsed once). The
parser internals keep string_view - the argument converts implicitly. Also drop
the now-unused <string_view> include from cvstd.hpp, so the header does not
reach every nvcc TU.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* compare boundary-rewrite: fixed 4-slot kind/bound arrays instead of AutoBuffers

A CONST operand is capped at 4 channels (addConst), so the per-channel
kind/bound staging needs no dynamic buffers - plain int[4]/double[4], with a
CV_Assert on the contract. This is also what gcc's -Wmaybe-uninitialized was
flagging (it could not see the AutoBuffer's inline storage get filled).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* element-wise engine: unary math kernels (sqrt/exp/log/sin/cos/tanh/erf/relu) + select

New dispatched pair math.simd.hpp / math.dispatch.cpp - the unary/ternary sibling
of arithm.simd.hpp:

- vecUnaryKernel: T -> T over f16/bf16/f32/f64 on top of the intrin_math
  primitives (v_exp/v_log/v_sin/v_cos/v_sqrt/v_erf/v_max). f32/f64 compute
  natively, f16/bf16 ride the f32 hub inside the kernel (vx_load_pair_as /
  v_store_pair_as) - no materialized casts. Continuity collapse + the halide
  right-edge backoff, suppressed in-place (it would re-apply Op to
  already-written values). tanh = (e^2x-1)/(e^2x+1) with the input clamped to
  +/-10 (f32) / +/-20 (f64) - unclamped saturation hits inf/inf = NaN. erf has
  no f64 SIMD primitive: std::erf per lane.
- selectKernel(mask, x, y): 1-byte mask expanded to lane width and tested
  against zero in the INTEGER domain (immune to DAZ/FTZ), branches of any
  depth by element size, broadcast branches supported.
- emitUnary: math over a float input is T -> T now (f16 in -> f16 out, native
  kernel when input and result depths match); integer inputs still compute in
  the float domain and land in f32.
- emitTernary/select: literal branches are typed via typedConstFrom (an
  OP_CAST of a depth-less flex const crashed); a non-1-byte mask is normalized
  by an explicit "mask != 0" compare, never a value cast.

texpr already parsed the function names - they now execute. Tests: per-depth
accuracy of all 8 ops against the double std:: reference, integer input,
in-place, select over 4 depths / const branch / float mask.

Perf vs the classic kernels (1920x1080 f32, 16 threads, AVX2): exp 3.7x,
log 2.6x, sqrt 5.7x faster; polarToCart expressed as (r*cos(a), r*sin(a)) 4.0x.
Accuracy improves too (max rel err vs f64 reference): exp 8.1e-8 vs 2.1e-7,
log 8.0e-8 vs 1.5e-7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* engine: OP_COPY_MASK folded into OP_SELECT; selectKernel moved to arithm.simd.hpp

copyMask(dst, mask, src) is select(mask, src, dst) - one masking primitive
instead of two. The compiler emits the masked-op tail as
addInsn(OP_SELECT, mask, r, out, out): the output slot rides as both arg2 and
the result, so unmasked elements are preserved by reading them back through
the b-branch. OP_COPY_MASK, copyMaskKernel and getCopyMaskFunc are gone.

selectKernel (moved from math.simd.hpp to arithm.simd.hpp) inherits every
copyMaskKernel optimization:
- the interleaved multichannel fast path (2..4 channels under a per-pixel
  mask: expand the mask once per VECSZ rows, v_store_interleave across lanes);
- the per-row scalar path with the row-skip when the selected source row IS
  dst (the "leave the output untouched" half of copyMask);
- plus the select-specific ones: branch broadcasts (stepx == 0) and the
  right-edge tail backoff under dst-aliases-a-branch - safe because re-running
  select over already-blended elements is idempotent; only dst == mask keeps
  the backoff off (the store would rewrite mask bytes before the re-read).

Masked-add perf is on par with the old copyMask (1280x720, 1 thread: 8UC3
204 -> 198 us, 32FC3 1395 -> 1344, 8UC1/32FC1 within noise). Full core suite
24117 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* engine: dedicated vectorized pow kernel (moved from arithm to math.simd.hpp)

pow was the last scalar-only binary op (scalarBinaryKernel + std::pow, f32/f64
only). The new powKernel keeps exact std::pow semantics and is T x T -> T over
all four float depths (f16/bf16 via the f32 hub):

- scalar exponent (the dominant call shape - texpr literals ride as 0-dim
  broadcast consts) is dispatched PER ROW to the special cases:
  y==2 -> x*x, y==3 -> x*x*x, y==0.5 -> v_sqrt, y==1 -> copy, y==0 -> fill 1;
- everything else - including a per-element exponent array - runs the general
  vectorized exp(y * log(x)) path, valid for x > 0; a vector pair containing
  any x <= 0 lane falls back to scalar std::pow for that pair (v_check_any),
  which preserves every std::pow subtlety: signed results for integer y on
  negative bases, NaN for fractional y, the x == 0 family;
- no right-edge tail backoff: pow is not idempotent, in-place calls finish
  rows in the scalar tail.

Perf vs the classic cv::pow (1920x1080 f32, 16 threads, AVX2): p=2 1.4x
(classic special-cases it too), p=3 6.1x, p=0.5 6.3x, fractional p 4.5x with
slightly better accuracy (6.1e-7 vs 7.4e-7 max rel err). Tests: exponent
sweep 2/3/0.5/1/0/2.5/-1.5 vs the double std::pow reference on f32/f64,
negative bases (exact signed cubes, NaN for fractional), array exponent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* powKernel: halide right-edge tail backoff in every SIMD loop

Same shape as vecBinaryKernel: the final partial vector re-processes
[width - VECSZ*2, width) instead of finishing scalar, suppressed when dst
aliases an input (pow is not idempotent - the overlap region must be
recomputed from an untouched source, which the no-alias case guarantees).

Modest measured win (~2% on ROI rows for the special-cased exponents; the
general path tail was already cheap - modern libm powf is fast), no
regressions; mainly aligns the kernel with the house style, where every
SIMD loop ends vector-wide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix MSVC 2019 C2975: function-local constexpr as a template argument inside a lambda

MSVC 2019 loses the constexpr-ness of function-local constants (LOCAL_OPS,
MAX_DIMS, ...) when they are used as template arguments inside a lambda body
(AutoBuffer<Slice, LOCAL_OPS> / std::array<int, MAX_DIMS> in the parallel
bodies of BroadcastOp::run and TExpr::exec). Hoist them to namespace scope -
no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ocl_arithm_op: route 16U multiply to the CPU engine on Apple OpenCL

The Apple OpenCL driver miscompiles the 16U multiply kernel: products near
the top of the u16 range come back wrapped instead of saturated (CPU vs GPU
NORM_INF up to 65535 in OCL_Arithm/Mul.Mat CV_16U cases). The same arithm.cl
kernel is correct on Intel NEO and NVIDIA drivers - verified not to reproduce
on Linux/Intel iGPU - so gate the decline to __APPLE__ only; the CPU engine
computes 16u multiply exactly (integer SIMD path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* engine: neg/abs as compositions, clamp kernel, ** and ?: operators, abs(a-b) peephole

- OP_NEG and OP_ABS need no kernels: neg = sub(0, a), abs = absdiff(a, 0) -
  including the engine absdiff auto-type rule (signed |a| lands in the
  UNSIGNED type of the same width: |SHRT_MIN| fits u16 exactly instead of
  saturating; NB the public cv::absdiff auto depth keeps the source type for
  4.x compatibility - values agree, the depth rule is the engine own).
- peephole: abs(x - y) rewrites to absdiff(x, y) ALWAYS. On integers the
  literal semantics differ (the subtract saturates first: u8 gives
  max(x-y, 0)), but whoever writes abs(a - b) means absdiff - we deliberately
  hand out the useful semantics instead of the saturation artifact. The just-
  emitted OP_SUB is retired via the moveToOutput manoeuvre, so the program
  shrinks to the single absdiff instruction. abs(x), abs(x - 0) and
  absdiff(x, 0) all give one result.
- OP_CLAMP kernel (arithm.simd.hpp): v_min(v_max(x, lo), hi) over
  u8/s8/u16/s16/u32/s32/f32 (+f64 with 64-bit SIMD), scalar f16/bf16/64-bit
  ints; lo/hi may broadcast (the common clamp(img, a, b) shape) or be full
  arrays; the tail backoff stays on under dst-aliases-x (clamp is idempotent).
  emitTernary types literal bounds via typedConstFrom (same flex-const crash
  select had) and keeps the auto result type pinned to x.
- parser: "a ** b" == pow(a, b), precedence above * /, RIGHT-associative
  (a ** 2 ** 3 == a ** 8); "cond ? a : b" == select(cond, a, b), precedence
  below everything, right-associative chains (f1 ? a : f2 ? b : c) work
  without parentheses. parseTernary() is the expression entry point now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* cv::exp/log/sqrt on the engine via math_op; hal functions wrapped as engine kernels

math_op is the master function of the unary math family (the arithm_op
analogue): same-shape same-type output over f16/bf16/f32/f64 (classic
exp/log accepted f32/f64 only - the half floats are new), two tiers:
- small (<= 100000 elements) and continuous: call the kernel DIRECTLY over
  the flattened data - no TExpr, no broadcastOp, no parallel_for setup;
- everything else: the usual single-instruction program via compile()/exec()
  (parallelism for large arrays, real steps for ROIs).

getMathFunc routes OP_EXP/OP_LOG at f32/f64 through the full cv::hal stack -
an external vendor HAL (CALL_HAL), IPP, or the built-in table kernels,
whichever is installed - by wrapping hal::exp32f/exp64f/log32f/log64f as
engine kernels with the function pointer in TKernel::userdata, the same
mechanism castKernel uses for core BinaryFuncs. The engine adds tiling and
parallelism on top, so every tier gets the best available scalar-span
implementation. v_exp/v_log remain for f16/bf16 (the f32 hub) and the ops
hal has no entry points for.

v_log_default_32f: the degree-8 polynomial is evaluated by Estrin pairing
(4 dependent levels) instead of an 8-FMA Horner chain (~5% on the f16 hub
path). An exp64 Taylor-without-division rewrite was tried and benched SLOWER
than the Cephes Pade scheme (the evaluation is FMA-throughput-bound, and
vdivpd pipelines well enough) - reverted; a table-based reduction is the only
way further there.

cv::exp f32 (16 threads, AVX2+IPP build), old -> new: 640 elements
0.16 -> 0.15 us, 16k 2.79 -> 2.49, 640x480 47 -> 20, 1920x1080 452 -> 60 us
(the old CPU loops were single-threaded); f64 exp 1080p 1295 -> 161 us.
No size regresses; small arrays now run at installed-HAL speed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* getMathFunc: IPP tier + raw-HAL probing; the exp/log table kernels are deleted

The raw cv_hal_* entry points return int for a reason: without an installed
HAL they are stubs returning CV_HAL_ERROR_NOT_IMPLEMENTED. getMathFunc now
selects the exp/log implementation in three tiers:
 1. HAVE_IPP && ipp::useIPP(): ippsExp/ippsLn through thin int adapters (IPP
    is not routed through the cv_hal_ hooks, so it needs its own tier);
 2. the raw cv_hal_exp32f/... hook, PROBED once with a 1-element call on the
    safe input 1.0 (cached in magic statics): implemented -> wrapped as an
    engine kernel with the function pointer in TKernel::userdata;
 3. the engine own v_exp/v_log kernels.
Whichever wins, the engine adds tiling and parallelism on top.

The EXPTAB/LOGTAB table kernels and their tables (~790 lines in
mathfuncs_core.simd.hpp + mathfuncs.cpp) are DELETED: they benched within
~15% of v_exp/v_log, not worth a second implementation. The public
cv::hal::exp32f/exp64f/log32f/log64f keep their contract - CALL_HAL, then
IPP, then the built-in implementation - but the built-in is now the engine
vector kernel via ew::mathSpanEngine (one contiguous span, exported from
math.dispatch.cpp).

All unary math kernels (vec/scalar/hal wrappers, pow) also handle the
vertical-broadcast tile (s0y == 0, a row expanded into a matrix): the first
row is computed, the rest are memcpy of it - transcendentals cost far more
than a row copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* texpr: hypot(x, y) binary op (alias: mag)

hypot = sqrt(x^2 + y^2), NAIVE like cv::magnitude (not the overflow-safe
std::hypot), computed in the float work type; kernels for the four float
depths only (T x T -> T; integer inputs ride the usual f32-compute + cast).
A 10-line EwHypot functor on top of vecBinaryKernel in arithm.simd.hpp -
broadcast branches, continuity collapse and the tail backoff come for free.
Registered in the parser as both "hypot" (the C/numpy name) and "mag" (the
cv::magnitude-flavored alias). A building block for the future cartToPolar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* texpr: atan2(y, x) binary op - radians, standard C range

v_atan2 (arithm.simd.hpp, generic over the universal-intrinsic float vector):
the fastAtan2 minimax polynomial from mathfuncs_core v_atan_f32 reworked to
plain radians - the 180/pi factor dropped from the coefficients and the C
quadrant logic instead of the [0, 360) wrap, so the result matches std::atan2
over (-pi, pi]. Measured absolute accuracy ~1.6e-4 rad. (v_atan_f32 itself is
untouched - cv::phase/fastAtan2 keep their degree semantics.)

EwAtan2 rides vecBinaryKernel: f16/bf16/f32 through v_atan2 (the f32 hub),
f64 through exact scalar std::atan2. arg0 = y, arg1 = x, like std::atan2;
float depths only, same emitBinary policy as pow/hypot. Parser name "atan2".
Together with hypot this completes the cartToPolar building blocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix the RISC-V RVV build and two ARM64 warnings

The new f64 kernel registrations (hypot, pow, the unary math family) gate on
CV_SIMD_64F || CV_SIMD_SCALABLE_64F, but the vx_setall_as(const double*,
v_float64&) helper family in arithm.simd.hpp was still CV_SIMD_64F-only -
scalable platforms (RVV) have v_float64 with CV_SIMD_64F == 0, so
vecBinaryKernel<double, ...> failed to instantiate there. Widen the helper
gate to match (verified with a riscv64 rv64gcv cross-build of opencv_core -
the engine f64 paths now vectorize on RVV instead of not compiling).

cv::exp/cv::log: the depth local is consumed by CV_OCL_RUN only - CV_UNUSED
for OpenCL-less builds (ARM64 -Wunused-variable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* silence the remaining ARM64 gcc warnings

- compare boundary-rewrite: {}-init the fixed kind/bound arrays (filled for
  every channel used below, but gcc cannot prove it across the cn <= 4 loop);
- cv::exp/log: [[maybe_unused]] on the depth local (consumed by CV_OCL_RUN
  only), instead of the CV_UNUSED idiom;
- AutoBuffer::reserve: a targeted -Wmaybe-uninitialized suppression around
  the live-element copy loop - only [0, sz) is read, all written before, but
  gcc inlining a grow-from-inline-storage chain cannot see that. An
  annotation for the analyzer, no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* saturating 32-bit add/sub kernels; cv::texpr python binding; two CI warnings

- v_add_sat/v_sub_sat for v_int32/v_uint32, local to arithm.simd.hpp for now
  (the plan is to grow them into proper universal intrinsics later): NEON
  single-instruction vqadd/vqsub, elsewhere the Hacker Delight bit tricks
  over universal intrinsics (u32 add is 2 ops: or with the wrapped-compare
  mask). EwAdd/EwSub overload vec() for the 32-bit lanes and getAddSubFunc
  routes 32S/32U T->T through vecBinaryKernel instead of the former pure
  scalar kernel. Semantics unchanged - the scalar int64 tail already
  saturated; directed boundary tests added (both rails, 0 - INT_MIN, u32
  cases, a full-range random block vs an exact int64 reference).
  640x480 32S add: 0.48x of 5.x -> parity (memory-bound); 1080p: 6-8x.

- cv::texpr becomes CV_EXPORTS_W: python gets cv.texpr(expr, [inputs]) ->
  tuple of ndarrays, so `res, = cv.texpr(...)` and `mag, ang = cv.texpr(...)`
  unpacking both work. modules/python/test/test_expr.py covers arithmetic,
  the fused abs(a-b), casts, broadcasting, ?: and ** operators, math
  functions vs numpy, clamp, named temporaries, tuple outputs, the one-line
  cartToPolar and the int32 saturation cases.

- warnings: {}-init the parser args array (gcc -Wmaybe-uninitialized on
  Ubuntu 20/22); the compare short-row block gates sizeof(T) <= 4 as
  constexpr so the f64 instantiation does not leave set-but-unused locals
  (gcc 9 -Wunused-but-set-variable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* arithm_op: direct-kernel fast path for small continuous arrays

Building and compiling the 1-instruction program plus the BroadcastOp setup
costs ~40-250ns per call - negligible on big images, dominant at 127x61-class
sizes where the classic 5.x functions were 1.3-2x faster. Mirror math_op two
tiers in arithm_op: two same-type same-shape continuous arrays, no mask, no
scalar, result depth == input depth, <= 100k elements -> call the T x T -> T
kernel directly over the flattened elements (checks ordered cheapest-first).
Applies to add/subtract/min/max/absdiff/multiply/addWeighted/and/or/xor;
compare and divide lower to more than a single kernel (boundary rewrites, int
guards) and keep the ordinary path. addWeighted falls through automatically
for the 32/64-bit int types whose lowering is wide-compute + cast
(getElemwiseFunc returns no direct kernel there).

127x61 vs 5.x, was -> now: add/subtract 8UC1 0.76x -> 1.3x, min/max u8
0.6x -> ~1x, addWeighted 1.0x -> 1.1-1.4x (32SC1 stays 4.5x); the one
remaining laggard is add/sub 32SC1 (0.74-0.82x) - the price of the new
SATURATING semantics (7-instruction AVX2 emulation vs the wrapping single
add of 5.x; single-instruction on NEON).

The 127x61 size is ADDED PERMANENTLY to the arithmetic/addWeighted/compare
perf grids: per-call overhead regressions in these base functions must be
caught by CI, not discovered by users.

dst creation goes through createSameSize (whole-shape transfer including
layout and future metadata, not piecemeal dims+sizes) here and in math_op.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* cv::pow rebuilt on the engine; integer-exponent and 1/sqrt(x) kernel branches

Routing: p = 0/1/2 keep their early special cases (fill/copy/multiply); an
INTEGER array with an INTEGER power keeps the classic iPow multiply chain -
bit-exact compatibility, including its wrap-around quirks (iPow squares in
int, so e.g. pow(255,4) on u8 wraps negative and saturates to 0 - somebody
may rely on that). Everything else goes through the engine with the math_op
two-tier scheme: small continuous arrays call powKernel directly (the
exponent rides as a broadcast T scalar), the rest run the tiled parallel
program. Integer arrays with fractional powers compute in the float domain
and saturate back; the 32U/64-bit depths (classic iPow asserted on them) and
f16/bf16 (the classic float path misread them) now just work.

powKernel gets two new per-row exponent branches:
- p == -0.5: 1/v_sqrt(x) (the classic path used IPP ippsInvSqrt_A21, a
  21-bit approximation; ours is exact - slightly slower on small arrays,
  4.5x faster at 1080p via parallelism);
- any other INTEGER p (|p| <= 65536): LSB-first binary exponentiation, the
  same multiply chain and order as iPow, fully vectorized - a few ulp
  accurate vs ~2e-7 of exp(p*log x), and exact on non-positive bases (the
  sign falls out of the multiplies, 0^negative divides to inf) - no scalar
  patching.

cv::pow f32 vs 5.x: p=0.5 365 -> 61 us at 1080p (6x), p=3 7.7x, p=5 7x AND
faster at every size (the old scalar chain: 0.81 -> 0.47 us at 127x61),
p=2.5 5.3x. The s16^5 iPow path is untouched (118.7 == 118.5 us).
pow_exponents accuracy tests extended to 11 exponents x f32/f64 against the
double std::pow reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf: SANITY_CHECK_NOTHING for the tests whose grids got the 127x61 size

The 127x61 entry added to guard per-call overhead has no regression data in
opencv_extra, so the legacy SANITY_CHECK in addWeighted/compare failed on CI
(locally it passes silently without the test-data path). Accuracy of both
functions is covered by the accuracy suite; the perf tests should measure
time. PatchNaNs/finiteMask keep their SANITY_CHECK - their grids are
untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix a temp-buffer double release in the liveness pass ("d*d" crash)

When the same temp is passed as SEVERAL arguments of its last-use
instruction (e.g. the named-intermediate expression "d = {0} - {1}; d*d",
where the MUL consumes slot d twice), the buffer-reuse scan pushed the
temp's physical buffer onto the free list once per argument. That
overflows the ntemps-sized freeBufs array (caught by the AutoBuffer range
check in Debug: python test_expr.py::test_named_temporary) and, in larger
programs, would hand the same physical buffer to two live temps.

Release the buffer once by retiring lastUse[t] after the first hit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* executor: recalibrate opCost to vectorized cycles + clamp the stripe count

The per-element op costs fed into the parallel_for_ stripe hint were
scalar-era estimates (~20x above the vectorized reality: the atan2/exp
polynomials run at ~1.5 cycles/element, not 30). The hint therefore split
transcendental/divide work into hundreds of ~1 us jobs, which the
macOS/GCD backend dispatches poorly under sustained load, on top of the
P/E-core equal-share straggler effect. Measured on M4 Max (12P+4E),
sustained medians @1920x1080 f32: texpr atan2 320 -> 133 us, cv::exp
280 -> 141 us, cv::log 301 -> ~200 us, cv::pow(x,2.5) 388 -> 376 us;
the PR tables' math rows improved ~1.5-2x across the board.

- opCost is now in units of ~1/4 cycle/element of the SIMD kernels:
  cheap ops 1 (unchanged), div/sqrt/hypot/convert_scale 10 -> 3,
  transcendentals 30 -> 6.
- the stripe hint is clamped by min(4*nthreads, max(32, 3*nthreads)):
  ~4 stripes/thread is plenty of granularity for element-wise work, and
  the ceiling is 32 pieces except on machines with many (heterogeneous)
  cores, where anything coarser than ~3 pieces/worker turns the slow
  cores into equal-share bottlenecks (measured: 32 stripes on 16 threads
  is the worst point of the curve - 193 us vs 137 us at 48 for atan2).
  getNumThreads() is clamped from below (WINRT/plugin backends may
  report 0).

Not addressed here (needs cross-machine data, M2/M3 Ultra): streaming
memory-bound ops saturate the M4 Max fabric at ~8 fat stripes and E-core
participation only adds contention - a cost model cannot express that;
candidate follow-up is a bytes-aware clamp or a GCD-backend-level fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* multiply: v_mul_sat integer kernels (full product clamped to the type)

v_mul_sat(V, V) -> V for u8/s8/u16/s16/u32/s32 - the full-precision
product clamped to the lane type, which is exactly cv::multiply's integer
semantics at scale == 1. Local to arithm.simd.hpp for now, next to
v_add_sat/v_sub_sat, to be promoted into proper universal intrinsics
later. NEON: widening vmull + saturating narrow (vqmovn); other backends:
the portable v_mul_expand + saturating v_pack composition for 8/16-bit
lanes. 32-bit lanes have no universal widening multiply (no v_mul_expand
for s32), so the 32-bit integer fast path is NEON-only for now and the
other backends keep the previous f64 work-vector kernels (which measure
well on x86 with IPP-free AVX2).

EwMul::vec() now routes the integer lane types through v_mul_sat, and
getMulFunc_ passes the NATIVE lane vector as the scale==1 fast-path type:
whole-register loads/stores, the widening happens inside the multiply.
Replaces both the half-register widening loads (u8/s8/u16/s16) and the
scalar-equivalent f64 path for 32S/32U on NEON.

M4 Max, 640x480 (the sizes where the old kernels lost to carotene):
8U 22.0 -> 13.6 us, 8S 15.0 -> 11.1, 16S 21.2 -> 17.4, 32S 84.8 -> 30.5
(parity with the classic path everywhere, 32S was 0.38x). 1920x1080:
8S 1.30x -> 1.78x, 16S 2.57x -> 2.98x, 32S 2.20x -> 4.02x vs 5.x.
Correctness: exhaustive 8-bit (all 65536 pairs per sign), directed
saturation corners for 16/32-bit (46341^2, INT_MIN*-1, 65536*65536, all
sign combinations) against an exact int64 reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* NEON: make v_cvt_f64(v_int32) exact (was via f32, losing bits > 2^24)

The NEON implementations of v_cvt_f64/v_cvt_f64_high for v_int32 did
s32 -> f32 -> f64 (vcvt_f32_s32 + vcvt_f64_f32), silently rounding any
|x| > 2^24. Every vectorized f64 work path with int32 inputs on AArch64
was affected: the engine's addWeighted/divide 32S kernels, convertTo
32S -> 64F, etc. Found via addWeighted 32SC1 on values ~1e9: max error
was 32 vs the exact double reference (the classic carotene path is worse
still - it computes in f32 end-to-end with f32-truncated weights, max
error 96 on the same data).

The exact sequence sxtl + scvtf (vmovl_s32 + vcvtq_f64_s64) is the same
2 instructions, so there is no cost. addWeighted 32SC1 on the engine now
matches the exact-double reference bit-for-bit and stays at parity/1.4x
vs the classic path (640x480/1920x1080).

Pre-existing upstream bug (same code in 4.x) - worth a standalone
backport with directed large-value tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* math_op: drop the temporary direct-IPP tier for exp/log

Upstream moved the IPP math wrappers into the hal/ipp HAL module (cv_hal_exp32f/
log32f & co now resolve to ipp_hal_* which honor cv::ipp::useIPP via
CV_HAL_CHECK_USE_IPP). The engine's single probeHalUnary(cv_hal_*) probe already
picks that up uniformly, so the stopgap #ifdef HAVE_IPP ippExp/ippLog tier and its
ipp::useIPP() branch in getMathFunc are now redundant - removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* arithm: restore cv::hal::and8u/or8u/xor8u/not8u as engine wrappers

These public CV_EXPORTS entry points (core/hal/hal.hpp) lost their definitions when
the bitwise ops moved to the element-wise engine, but they are still declared and called
by other modules (opencv_objdetect's aruco) and external code - the link broke with
undefined references to cv::hal::and8u/xor8u. Restore them as thin forwarders over the
engine's byte-wise bitwise kernels (getBitwiseFunc / getNotFunc), mirroring the existing
mul8u wrapper. CV_Assert guards the kernel lookup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* blobdetector: guard empty contour when computing blob radius

The new AutoBuffer leaves its tail uninitialized for trivial types, which surfaced a
-Wmaybe-uninitialized in findBlobs where the median of per-point distances is read. Use a
std::vector, default the radius to 0, and compute the median only for a non-empty contour -
no unproven size invariant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 01:26:36 +03:00

513 lines
19 KiB
C++

// This file is part of OpenCV project.
// It is subject to the license terms in the LICENSE file found in the top-level directory
// of this distribution and at http://opencv.org/license.html.
// Tests for the public cv::texpr() string frontend. Exercises placeholders, operator precedence,
// function calls, type casts, assignments and tuple (multi-) outputs. Limited to ops with kernels
// today (arithmetic / cast / pow / min / max / absdiff).
#include "test_precomp.hpp"
#include "../src/arithm_expr.hpp" // ew::absdiffResultDepth - the engine's absdiff auto-type rule
namespace opencv_test { namespace {
static Mat expr1(const String& e, const std::vector<Mat>& in)
{
std::vector<Mat> out;
cv::texpr(e, in, out);
return out[0];
}
TEST(Core_TExpr, add)
{
Mat a(12, 15, CV_32F), b(12, 15, CV_32F);
theRNG().fill(a, RNG::UNIFORM, 1.f, 10.f);
theRNG().fill(b, RNG::UNIFORM, 1.f, 10.f);
Mat got = expr1("{0} + {1}", { a, b });
Mat exp; cv::add(a, b, exp);
EXPECT_LE(cvtest::norm(got, exp, NORM_INF), 1e-3);
}
// Built-in binary functions min/max/absdiff parsed and dispatched through emitBinary.
TEST(Core_TExpr, minmax_absdiff)
{
Mat a(18, 21, CV_8U), b(18, 21, CV_8U);
theRNG().fill(a, RNG::UNIFORM, 0, 255);
theRNG().fill(b, RNG::UNIFORM, 0, 255);
Mat gmin = expr1("min({0}, {1})", { a, b });
Mat gmax = expr1("max({0}, {1})", { a, b });
Mat gabs = expr1("absdiff({0}, {1})", { a, b });
Mat emin, emax, eabs;
cv::min(a, b, emin); cv::max(a, b, emax); cv::absdiff(a, b, eabs);
EXPECT_EQ(0, cvtest::norm(gmin, emin, NORM_INF));
EXPECT_EQ(0, cvtest::norm(gmax, emax, NORM_INF));
EXPECT_EQ(0, cvtest::norm(gabs, eabs, NORM_INF));
}
// Operator precedence: '*' binds tighter than '+', unary minus on a literal.
TEST(Core_TExpr, addweighted_precedence)
{
Mat a(20, 16, CV_32F), b(20, 16, CV_32F);
theRNG().fill(a, RNG::UNIFORM, 1.f, 10.f);
theRNG().fill(b, RNG::UNIFORM, 1.f, 10.f);
Mat got = expr1("{0} * 2.5 + {1} * -1.5 + 7", { a, b });
Mat exp; cv::addWeighted(a, 2.5, b, -1.5, 7.0, exp);
EXPECT_LE(cvtest::norm(got, exp, NORM_INF), 1e-3);
}
// Named temporary via ';' assignment.
TEST(Core_TExpr, assignment)
{
Mat a(18, 22, CV_32F), b(18, 22, CV_32F);
theRNG().fill(a, RNG::UNIFORM, 1.f, 10.f);
theRNG().fill(b, RNG::UNIFORM, 1.f, 10.f);
Mat got = expr1("t = {0} * 2.5; t + {1}", { a, b });
Mat exp; cv::addWeighted(a, 2.5, b, 1.0, 0.0, exp);
EXPECT_LE(cvtest::norm(got, exp, NORM_INF), 1e-3);
}
// Tuple -> several outputs.
TEST(Core_TExpr, tuple_outputs)
{
Mat a(14, 19, CV_32F), b(14, 19, CV_32F);
theRNG().fill(a, RNG::UNIFORM, 1.f, 10.f);
theRNG().fill(b, RNG::UNIFORM, 1.f, 10.f);
std::vector<Mat> out;
cv::texpr("({0} + {1}, {0} - {1})", std::vector<Mat>{ a, b }, out);
ASSERT_EQ(out.size(), 2u);
Mat eadd, esub; cv::add(a, b, eadd); cv::subtract(a, b, esub);
EXPECT_LE(cvtest::norm(out[0], eadd, NORM_INF), 1e-3) << "sum";
EXPECT_LE(cvtest::norm(out[1], esub, NORM_INF), 1e-3) << "diff";
}
// Grouping parens (NOT a tuple) inside a larger expression.
TEST(Core_TExpr, grouping_parens)
{
Mat a(11, 13, CV_32F), b(11, 13, CV_32F);
theRNG().fill(a, RNG::UNIFORM, 1.f, 10.f);
theRNG().fill(b, RNG::UNIFORM, 1.f, 10.f);
Mat got = expr1("({0} + {1}) * 2", { a, b });
Mat exp; cv::add(a, b, exp); exp *= 2.0;
EXPECT_LE(cvtest::norm(got, exp, NORM_INF), 1e-3);
}
// Type-cast function: float -> uint8 (saturating).
TEST(Core_TExpr, cast_uint8)
{
Mat a(23, 17, CV_32F);
theRNG().fill(a, RNG::UNIFORM, -50.f, 300.f);
Mat got = expr1("uint8({0})", { a });
Mat exp; a.convertTo(exp, CV_8U);
ASSERT_EQ(got.type(), exp.type());
EXPECT_EQ(0, cvtest::norm(got, exp, NORM_INF));
}
// pow() function call with a scalar exponent.
TEST(Core_TExpr, pow_call)
{
Mat a(16, 16, CV_32F);
theRNG().fill(a, RNG::UNIFORM, 1.f, 5.f);
Mat got = expr1("pow({0}, 2)", { a });
Mat exp; cv::pow(a, 2.0, exp);
EXPECT_LE(cvtest::norm(got, exp, NORM_INF), 1e-3);
}
// pow over the interesting exponents: the special-cased 2/3/0.5/1/0, the general exp/log path
// (2.5, -1.5), and negative bases (integer exponent -> exact signed result, fractional -> NaN),
// against the double std::pow reference. Sizes chosen to exercise both the SIMD body and the tail.
TEST(Core_TExpr, pow_exponents)
{
for (int depth : { CV_32F, CV_64F })
{
const double eps = depth == CV_32F ? 1e-6 : 1e-9;
Mat a0(37, 41, CV_64F), a;
theRNG().fill(a0, RNG::UNIFORM, 0.05, 9.);
a0.convertTo(a, depth);
for (double p : { 2., 3., 0.5, 1., 0., 2.5, -1.5, -0.5, 5., -2., 11. })
{
Mat got = expr1(cv::format("pow({0}, %.10g)", p), { a });
ASSERT_EQ(got.depth(), depth) << "p=" << p;
Mat ad, gd;
a.convertTo(ad, CV_64F); got.convertTo(gd, CV_64F);
double maxerr = 0;
for (int y = 0; y < a.rows; y++)
for (int x = 0; x < a.cols; x++)
{
double r = std::pow(ad.at<double>(y, x), p);
maxerr = std::max(maxerr, std::abs(gd.at<double>(y, x) - r) / std::max(1.0, std::abs(r)));
}
EXPECT_LE(maxerr, eps) << "depth=" << depth << " p=" << p;
}
}
}
// negative bases: integer exponents keep exact signed results (scalar patch path), a fractional
// exponent yields NaN - both matching std::pow
TEST(Core_TExpr, pow_negative_base)
{
Mat a(9, 13, CV_32F);
theRNG().fill(a, RNG::UNIFORM, -5.f, -1.f);
Mat got3 = expr1("pow({0}, 3)", { a });
for (int y = 0; y < a.rows; y++)
for (int x = 0; x < a.cols; x++)
ASSERT_NEAR(got3.at<float>(y, x), std::pow((double)a.at<float>(y, x), 3.), 1e-2);
Mat gotf = expr1("pow({0}, 2.5)", { a });
for (int y = 0; y < a.rows; y++)
for (int x = 0; x < a.cols; x++)
ASSERT_TRUE(cvIsNaN(gotf.at<float>(y, x))) << "pow(neg, frac) must be NaN";
}
// unary minus and abs() - compositions over the binary family (no dedicated kernels)
TEST(Core_TExpr, neg_abs)
{
for (int depth : { CV_32F, CV_16S })
{
Mat a(15, 19, depth);
theRNG().fill(a, RNG::UNIFORM, -100, 100);
Mat gneg = expr1("-{0}", { a });
Mat eneg; cv::subtract(Scalar(0), a, eneg);
ASSERT_EQ(gneg.depth(), depth);
EXPECT_EQ(0, cvtest::norm(gneg, eneg, NORM_INF)) << "neg depth=" << depth;
// abs == absdiff(a, 0) INCLUDING the texpr auto result type rule: signed input -> the
// UNSIGNED type of the same width (|SHRT_MIN| fits u16 exactly, no saturation). NB this
// deliberately differs from the public cv::absdiff, whose auto depth keeps the source
// type for 4.x compatibility - the VALUES agree, the depth rule is the engine's own.
Mat gabs = expr1("abs({0})", { a });
ASSERT_EQ(gabs.depth(), cv::ew::absdiffResultDepth(depth)) << "texpr absdiff type rule";
Mat eabs; cv::absdiff(a, Scalar(0), eabs);
eabs.convertTo(eabs, gabs.depth());
EXPECT_EQ(0, cvtest::norm(gabs, eabs, NORM_INF)) << "abs depth=" << depth;
}
}
// the abs(a - b) -> absdiff(a, b) peephole: on unsigned data the literal semantics (saturating
// subtract) would give max(a-b, 0) - the rewrite must give the true |a - b| everywhere
TEST(Core_TExpr, abs_sub_peephole)
{
for (int depth : { CV_8U, CV_16S, CV_32F })
{
Mat a(23, 17, depth), b(23, 17, depth);
theRNG().fill(a, RNG::UNIFORM, 0, 100);
theRNG().fill(b, RNG::UNIFORM, 0, 100);
Mat got = expr1("abs({0} - {1})", { a, b });
ASSERT_EQ(got.depth(), cv::ew::absdiffResultDepth(depth)) << "depth=" << depth;
Mat exp; cv::absdiff(a, b, exp);
exp.convertTo(exp, got.depth()); // cv::absdiff auto KEEPS the source depth (4.x)
EXPECT_EQ(0, cvtest::norm(got, exp, NORM_INF)) << "depth=" << depth;
}
}
// clamp: scalar bounds (the common shape), array bounds, and type preservation
TEST(Core_TExpr, clamp)
{
for (int depth : { CV_8U, CV_16S, CV_32F, CV_64F })
{
Mat a(25, 31, depth);
theRNG().fill(a, RNG::UNIFORM, -100, 355);
Mat got = expr1("clamp({0}, 10, 200)", { a });
Mat emax, exp;
cv::max(a, 10.0, emax); cv::min(emax, 200.0, exp);
ASSERT_EQ(got.depth(), depth) << "clamp must keep the operand type";
EXPECT_EQ(0, cvtest::norm(got, exp, NORM_INF)) << "depth=" << depth;
}
// array bounds
Mat x(14, 22, CV_32F), lo(14, 22, CV_32F), hi(14, 22, CV_32F);
theRNG().fill(x, RNG::UNIFORM, -10.f, 10.f);
theRNG().fill(lo, RNG::UNIFORM, -5.f, 0.f);
theRNG().fill(hi, RNG::UNIFORM, 0.f, 5.f);
Mat got = expr1("clamp({0}, {1}, {2})", { x, lo, hi });
Mat emax, exp;
cv::max(x, lo, emax); cv::min(emax, hi, exp);
EXPECT_EQ(0, cvtest::norm(got, exp, NORM_INF));
}
// '**' operator: pow alias, binds tighter than '*', right-associative
TEST(Core_TExpr, pow_operator)
{
Mat a(13, 18, CV_32F);
theRNG().fill(a, RNG::UNIFORM, 0.5f, 2.f);
Mat got = expr1("{0} ** 2", { a });
Mat exp; cv::pow(a, 2.0, exp);
EXPECT_EQ(0, cvtest::norm(got, exp, NORM_INF));
// precedence: 3 * a ** 2 == 3 * (a ** 2)
Mat got2 = expr1("3 * {0} ** 2", { a });
Mat exp2 = 3.0 * exp;
EXPECT_LE(cvtest::norm(got2, exp2, NORM_INF), 1e-4);
// right associativity: a ** 2 ** 3 == a ** (2 ** 3) == a ** 8
Mat got3 = expr1("{0} ** 2 ** 3", { a });
Mat exp3; cv::pow(a, 8.0, exp3);
EXPECT_LE(cvtest::norm(got3, exp3, NORM_INF), 1e-4);
}
// '?:' conditional: select alias with the lowest precedence; right-associative chains
TEST(Core_TExpr, ternary_operator)
{
Mat a(19, 23, CV_32F), b(19, 23, CV_32F);
theRNG().fill(a, RNG::UNIFORM, 0.f, 100.f);
theRNG().fill(b, RNG::UNIFORM, 0.f, 100.f);
// max via ?: - the condition is a full comparison (lower precedence than '>')
Mat got = expr1("{0} > {1} ? {0} : {1}", { a, b });
Mat exp; cv::max(a, b, exp);
EXPECT_EQ(0, cvtest::norm(got, exp, NORM_INF));
// arithmetic in every position without parentheses
Mat got2 = expr1("{0} - {1} > 10 ? {0} + 1 : {1} * 2", { a, b });
Mat mask = (a - b > 10), e1 = a + 1, e2 = b * 2, exp2 = e2.clone();
e1.copyTo(exp2, mask);
EXPECT_LE(cvtest::norm(got2, exp2, NORM_INF), 1e-4);
// right-associative chain: c1 ? x : c2 ? y : z
Mat got3 = expr1("{0} > 66 ? 1 : {0} > 33 ? 2 : 3", { a });
Mat exp3(a.size(), CV_32F);
for (int y = 0; y < a.rows; y++)
for (int x = 0; x < a.cols; x++)
{
float v = a.at<float>(y, x);
exp3.at<float>(y, x) = v > 66 ? 1.f : v > 33 ? 2.f : 3.f;
}
EXPECT_EQ(0, cvtest::norm(got3, exp3, NORM_INF));
}
// hypot(x, y) / its cv-flavored alias mag(x, y): naive sqrt(x^2 + y^2), matching cv::magnitude;
// kernels exist for the float depths only (f16/bf16/f32/f64)
TEST(Core_TExpr, hypot_mag)
{
for (int depth : { CV_32F, CV_64F })
{
Mat x(23, 31, depth), y(23, 31, depth);
theRNG().fill(x, RNG::UNIFORM, -100, 100);
theRNG().fill(y, RNG::UNIFORM, -100, 100);
Mat got = expr1("hypot({0}, {1})", { x, y });
ASSERT_EQ(got.depth(), depth);
Mat exp; cv::magnitude(x, y, exp);
EXPECT_LE(cvtest::norm(got, exp, NORM_INF), depth == CV_32F ? 1e-4 : 1e-9);
Mat got2 = expr1("mag({0}, {1})", { x, y }); // alias
EXPECT_EQ(0, cvtest::norm(got2, got, NORM_INF));
}
// broadcast branch: hypot(array, scalar)
Mat x(11, 17, CV_32F);
theRNG().fill(x, RNG::UNIFORM, -10.f, 10.f);
Mat got = expr1("hypot({0}, 3)", { x });
for (int r = 0; r < x.rows; r++)
for (int c = 0; c < x.cols; c++)
{
float v = x.at<float>(r, c);
ASSERT_NEAR(got.at<float>(r, c), std::sqrt(v*v + 9.f), 1e-4) << r << "," << c;
}
// f16: T -> T through the native kernel (f32 hub inside)
Mat xh, yh, y16(11, 17, CV_32F);
x.convertTo(xh, CV_16F);
theRNG().fill(y16, RNG::UNIFORM, -10.f, 10.f);
y16.convertTo(yh, CV_16F);
Mat goth = expr1("hypot({0}, {1})", { xh, yh });
ASSERT_EQ(goth.depth(), CV_16F);
}
// atan2(y, x): radians, the standard C range (-pi, pi], all four quadrants; the f32 kernel is the
// fastAtan2 minimax polynomial (~1e-5 rad absolute), f64 is exact std::atan2
TEST(Core_TExpr, atan2)
{
Mat y(23, 31, CV_32F), x(23, 31, CV_32F);
theRNG().fill(y, RNG::UNIFORM, -10.f, 10.f); // both signs -> all quadrants
theRNG().fill(x, RNG::UNIFORM, -10.f, 10.f);
Mat got = expr1("atan2({0}, {1})", { y, x });
ASSERT_EQ(got.depth(), CV_32F);
double maxerr = 0;
for (int r = 0; r < y.rows; r++)
for (int c = 0; c < y.cols; c++)
{
double ref = std::atan2((double)y.at<float>(r, c), (double)x.at<float>(r, c));
maxerr = std::max(maxerr, std::abs((double)got.at<float>(r, c) - ref));
}
EXPECT_LE(maxerr, 2e-4) << "fastAtan2-class polynomial accuracy (measured ~1.6e-4 rad)";
// f64: exact std::atan2 per element
Mat y64, x64;
y.convertTo(y64, CV_64F); x.convertTo(x64, CV_64F);
Mat got64 = expr1("atan2({0}, {1})", { y64, x64 });
ASSERT_EQ(got64.depth(), CV_64F);
for (int r = 0; r < y.rows; r++)
for (int c = 0; c < y.cols; c++)
ASSERT_EQ(got64.at<double>(r, c),
std::atan2(y64.at<double>(r, c), x64.at<double>(r, c)));
// axis cases: atan2(0, 1) = 0, atan2(1, 0) = pi/2, atan2(0, -1) = pi, atan2(-1, 0) = -pi/2
Mat ya = (Mat_<float>(1, 4) << 0.f, 1.f, 0.f, -1.f);
Mat xa = (Mat_<float>(1, 4) << 1.f, 0.f, -1.f, 0.f);
Mat ga = expr1("atan2({0}, {1})", { ya, xa });
const float expctd[] = { 0.f, (float)(CV_PI/2), (float)CV_PI, (float)(-CV_PI/2) };
for (int i = 0; i < 4; i++)
ASSERT_NEAR(ga.at<float>(0, i), expctd[i], 1e-4) << "axis case " << i;
}
// per-element (array) exponent
TEST(Core_TExpr, pow_array_exponent)
{
Mat a(21, 27, CV_32F), b(21, 27, CV_32F);
theRNG().fill(a, RNG::UNIFORM, 0.1f, 5.f);
theRNG().fill(b, RNG::UNIFORM, -2.f, 3.f);
Mat got = expr1("pow({0}, {1})", { a, b });
double maxerr = 0;
for (int y = 0; y < a.rows; y++)
for (int x = 0; x < a.cols; x++)
{
double r = std::pow((double)a.at<float>(y, x), (double)b.at<float>(y, x));
maxerr = std::max(maxerr, std::abs(got.at<float>(y, x) - r) / std::max(1.0, std::abs(r)));
}
EXPECT_LE(maxerr, 1e-6);
}
// ---------------------------------------------------------------------------------- unary math
// Golden result = the double-precision std:: function applied per element (never the op under
// test); the tolerance is relative, scaled by the output depth's precision.
typedef double (*mathRef)(double);
struct MathOpRef { const char* name; mathRef ref; };
static const MathOpRef mathOps[] = {
{ "sqrt", std::sqrt }, { "exp", std::exp }, { "log", std::log },
{ "sin", std::sin }, { "cos", std::cos }, { "tanh", std::tanh }, { "erf", std::erf },
{ "relu", [](double x) { return x > 0 ? x : 0.; } },
};
// max |got - ref| / max(1, |ref|) over the array, both evaluated in f64
static double relErr(const Mat& got, const Mat& in, mathRef ref)
{
Mat gotd, ind;
got.convertTo(gotd, CV_64F);
in.convertTo(ind, CV_64F);
double maxerr = 0;
for (int y = 0; y < ind.rows; y++)
for (int x = 0; x < ind.cols; x++)
{
double r = ref(ind.at<double>(y, x));
double e = std::abs(gotd.at<double>(y, x) - r) / std::max(1.0, std::abs(r));
maxerr = std::max(maxerr, e);
}
return maxerr;
}
typedef testing::TestWithParam<MatDepth> Core_TExpr_Math;
TEST_P(Core_TExpr_Math, unary_accuracy)
{
const int depth = GetParam();
// eps: half-precision types are exact to ~2^-8/2^-11 per element; the f32 kernels are
// polynomial approximations (a few ulp); f64 sqrt/exp/log are also vectorized polynomials
const double eps = depth == CV_16F ? 2e-3 : depth == CV_16BF ? 1.6e-2
: depth == CV_32F ? 1e-6 : 1e-9;
Mat a0(37, 41, CV_32F);
theRNG().fill(a0, RNG::UNIFORM, 0.05f, 9.f); // positive: one range serves log/sqrt too
Mat a;
a0.convertTo(a, depth);
for (const MathOpRef& m : mathOps)
{
Mat got = expr1(cv::format("%s({0})", m.name), { a });
ASSERT_EQ(got.depth(), depth) << m.name << ": math must be T -> T on float inputs";
EXPECT_LE(relErr(got, a, m.ref), eps) << m.name << " depth=" << depth;
}
// negative inputs for the ops defined there (skip log/sqrt)
Mat b0(37, 41, CV_32F), b;
theRNG().fill(b0, RNG::UNIFORM, -8.f, 8.f);
b0.convertTo(b, depth);
for (const char* name : { "exp", "sin", "cos", "tanh", "erf", "relu" })
{
const MathOpRef* m = nullptr;
for (const MathOpRef& c : mathOps) if (!strcmp(c.name, name)) m = &c;
Mat got = expr1(cv::format("%s({0})", name), { b });
EXPECT_LE(relErr(got, b, m->ref), eps) << name << "(neg) depth=" << depth;
}
}
INSTANTIATE_TEST_CASE_P(/**/, Core_TExpr_Math,
testing::Values(CV_16F, CV_16BF, CV_32F, CV_64F));
// integer input computes in the float domain and lands in f32
TEST(Core_TExpr, math_int_input)
{
Mat a(19, 23, CV_8U);
theRNG().fill(a, RNG::UNIFORM, 1, 100);
Mat got = expr1("sqrt({0})", { a });
ASSERT_EQ(got.depth(), CV_32F);
EXPECT_LE(relErr(got, a, std::sqrt), 1e-6);
}
// in-place unary math (dst aliases src): the tail backoff must not re-apply the op
TEST(Core_TExpr, math_inplace)
{
Mat a(21, 31, CV_32F);
theRNG().fill(a, RNG::UNIFORM, 0.1f, 9.f);
Mat ref = expr1("sqrt({0})", { a });
std::vector<Mat> out{ a }; // preallocated == input => in-place
cv::texpr("sqrt({0})", std::vector<Mat>{ a }, out);
EXPECT_EQ(0, cvtest::norm(out[0], ref, NORM_INF));
}
// ------------------------------------------------------------------------------------- select
TEST(Core_TExpr, select_basic)
{
for (int type : { CV_8UC1, CV_16SC1, CV_32FC1, CV_64FC1 })
{
Mat a(25, 33, type), b(25, 33, type);
theRNG().fill(a, RNG::UNIFORM, 0, 100);
theRNG().fill(b, RNG::UNIFORM, 0, 100);
Mat got = expr1("select({0} > {1}, {0}, {1})", { a, b }); // == max(a, b)
Mat exp; cv::max(a, b, exp);
ASSERT_EQ(got.type(), exp.type()) << "type=" << type;
EXPECT_EQ(0, cvtest::norm(got, exp, NORM_INF)) << "type=" << type;
}
}
// one branch is a scalar constant (broadcast stepx == 0 inside the kernel)
TEST(Core_TExpr, select_const_branch)
{
Mat a(17, 29, CV_32F);
theRNG().fill(a, RNG::UNIFORM, -10.f, 10.f);
Mat got = expr1("select({0} > 0, {0}, 0)", { a }); // == relu
Mat exp = expr1("relu({0})", { a });
EXPECT_EQ(0, cvtest::norm(got, exp, NORM_INF));
}
// non-1-byte mask is normalized via `mask != 0`, not a value cast
TEST(Core_TExpr, select_float_mask)
{
Mat m(15, 27, CV_32F), a(15, 27, CV_32F), b(15, 27, CV_32F);
theRNG().fill(m, RNG::UNIFORM, -1.f, 1.f);
theRNG().fill(a, RNG::UNIFORM, 0.f, 10.f);
theRNG().fill(b, RNG::UNIFORM, 0.f, 10.f);
m.at<float>(3, 5) = 0.f; // exact zero -> must take branch b
m.at<float>(7, 7) = 0.25f; // would round/saturate to 0 under a value cast
Mat got = expr1("select({0}, {1}, {2})", { m, a, b });
Mat mask = (m != 0), exp;
exp = b.clone(); a.copyTo(exp, mask);
EXPECT_EQ(0, cvtest::norm(got, exp, NORM_INF));
}
}} // namespace