Skip to main content

Working with the raw OpenCV API

scalacv is a wrapper, never a wall. Underneath every convenience is the ordinary OpenCV 4.13 Java API — org.opencv.core.Mat, org.opencv.imgproc.Imgproc, the detectors — and scalacv is built so you can reach it at any point, use the exact call you need, and come back up without ceremony. This page is the map of how to move between levels.

New here? Read this first.

If you only ever use Image, you never need this page — the high-level API covers the common path. Come back when OpenCV has a function scalacv doesn't wrap yet, or when you need a knob (a CV_16S output depth, a raw detector) the higher tiers hide. Nothing here is exotic; it's just "call the Java method directly, then let scalacv manage the memory again."

Three levels, one library

There are three altitudes, and picking the right one per step is the whole skill:

LevelWhat you holdFrees itself?Reach for it when
HighImagean owned image, chained by verbsyes, per chainthe common read → transform → detect → write path
MidManaged[Mat] + extension opsone owned Mat at a timeyes, via use/pipeyou need a Mat-level knob or to hold intermediates
Low — raw org.opencv.*whatever the Java binding hands backyou decideOpenCV exposes a call the higher levels don't wrap

The levels are not sealed tiers you commit to at the top of a file. A single pipeline can start on Image, drop to a raw Imgproc call for one operator, and rise back to Image for the write — and the rest of this page is exactly those moves.

Why bother having levels at all?

Native memory is not garbage-collected in any useful timeframe (a multi-megabyte pixel buffer looks like ~40 bytes of Java header to the collector — see Mat lifecycle). Every level is really an answer to the same question: who frees this Mat, and when? Image answers "the chain does"; Managed answers "the use block does"; raw OpenCV answers "you do." Moving between levels is moving that answer around.

The one primitive: Managed

Everything below Image rests on one type, Managed[A] — a native handle that is freed exactly once. Before the escape hatches make sense, it helps to see it on its own.

// Managed.use is the form to reach for by default: it runs the body, then frees, even on exception.
val rows: Int =
Managed.use(Mat(48, 48, CvType.CV_8UC3)) { m => m.rows } // Mat freed when the block returns
rows
// res1: Int = 48

The handle-holding form exists too, for when a value must outlive a single block. You own it, so you close it:

val handle: Managed[Mat] = Managed(Mat(16, 16, CvType.CV_8UC3))
val cols: Int = handle.get.cols // `get` borrows the Mat; the handle still owns it
handle.close() // your responsibility, since you did not use `use`

Two guarantees make this safe, and both exist because getting them wrong is a JVM crash rather than an exception:

MethodWhat it doesAfter it
getborrows the underlying objecthandle still owns it
use(f)runs f(get), then releaseshandle spent
release() / close()frees the native memory (idempotent)handle spent
isReleasedhas it been freed/consumed yet?(a query)

Touching a spent handle throws IllegalStateException on the Scala side, before anything crosses JNI — you get a stack trace, not a segfault:

val spent = Managed(Mat(8, 8, CvType.CV_8UC3))
spent.close()
spent.get // throws IllegalStateException — the alternative would be a SIGSEGV from native code
// java.lang.IllegalStateException: this Mat has already been released or consumed — using it now would crash the JVM from native code. A high-level Image is spent by any transform (gray/blur/…) or terminal (write/bytes/close); call `.copy` before the first use if you need it twice. Run with -Dscalacv.trackOwnership=true to record where it was consumed.
// at scalacv.Managed.spentError(Managed.scala:55)
// at scalacv.Managed.get(Managed.scala:68)
// at repl.MdocSession$MdocApp.$init$$$anonfun$3(low-level.md:52)
Diagnosing use-after-move

When a spent-handle error fires, the failing line is the reuse, which is rarely the interesting one. Start the JVM with -Dscalacv.trackOwnership=true and the exception carries, as its cause, the stack of the transform or terminal that actually consumed the handle. It is off by default because it allocates a Throwable on every consume; on the happy path it costs nothing.

Escaping from an Image

An Image owns a single Managed[Mat]. Three methods let you get at it, and which one you pick depends on whether you are borrowing the Mat or taking ownership of it.

DoorwayDirectionOwnershipYou must…
img.matdown (borrow)stays with the Imagenot release it — the Image will
img.manageddown (handover)moves to the returned Managedrelease the Managed (or use it)
Image.wrap(managed)up (handover)moves into the new Imagenot release the Managed too

mat borrows — do not release it

img.mat hands you the underlying org.opencv.core.Mat for any raw call. The Image still owns it: read from it, pass it to a detector, run an Imgproc function against it — but do not release it, because the Image will.

val img = Image.blank(64, 64)

// A raw org.opencv.core.Core call on the borrowed Mat. The Image still owns `img.mat`.
val average = org.opencv.core.Core.mean(img.mat)

img.close() // this frees the Mat — `average` is already a plain value, safe to keep
A borrow is a loan, not a gift.

Never call .release() on img.mat, and never stash it to use after the Image is closed. Both free the same pointer that the Image still thinks it owns — a double free, or a read of freed memory. If you need the Mat to outlive the Image, use img.managed (handover) or img.copy.managed (independent copy).

managed hands the whole thing over

When you want to stop being an Image and manage the Mat's lifetime yourself, img.managed transfers ownership out. The Image is spent afterwards; the returned Managed[Mat] is now yours to release.

val owned: Managed[Mat] = Image.blank(32, 32).managed // the Image is spent; the Managed owns the Mat now
val elements: Long = owned.get.total // borrow for a raw query — `owned` still owns it
elements
// res4: Long = 1024L

Image.wrap goes the other way

Image.wrap(managed) adopts a Managed[Mat] you already hold and gives you back the high-level API. Ownership transfers into the Image, so do not also release the Managed yourself.

val restored: Image = Image.wrap(owned) // adopts the `owned` handle above; do not release it separately
restored.close() // releases the Mat exactly once, here

mat / managed / wrap are the doorways between the top level and everything below it — a borrow, a handover down, and a handover back up.

note
Branching an Image

Because a transform consumes its receiver, you cannot use one Image twice. To keep the original, take a .copy first — an independent deep copy — then transform the copy. See Mat lifecycle for the full move-semantics story.

Calling raw org.opencv.*, then coming back up

The common shape is: you have an owned image, OpenCV has a function scalacv doesn't wrap, you call it directly on the borrowed Mat, and you adopt the result back into managed ownership.

import org.opencv.imgproc.Imgproc

val src = Image.blank(120, 160)

// Drop to raw org.opencv.* for a knob the higher levels don't expose (here, a CV_16S Laplacian):
val rawOut = new Mat()
Imgproc.Laplacian(src.mat, rawOut, CvType.CV_16S) // borrowed Mat in, our own Mat out
src.close() // done borrowing; the source Image frees its Mat

// Come back up. Wrap the raw result so it is released exactly once...
val result: Managed[Mat] = Managed(rawOut)

// ...or lift it straight into the high-level API and keep chaining:
val encoded: Either[CvError, Array[Byte]] =
Image.wrap(result).bytes(".png") // `result`'s Mat is now owned by the Image, released on bytes()

The rule of thumb: the moment a raw call hands you a bare Mat, wrap it in Managed (or adopt it as an Image). From then on it is freed exactly once, on success or on exception, like everything else.

danger
Never leave a raw Mat unwrapped past the next line.

A bare new Mat() that OpenCV fills is a native allocation with no owner. If an exception fires before you wrap it — or you simply forget — it leaks, and the collector will not reclaim it in time to matter. Wrap it in the same block it was created, so a Managed (or Image) is on the hook for its release no matter what happens next.

The mid level: same story, no Image wrapper

The mid level is the same story without the Image wrapper — every extension op on a Mat already returns an owned Managed[Mat], so pipe threads them and frees each intermediate:

val midLevel: Either[CvError, Array[Byte]] =
Managed.use(Mat(80, 80, CvType.CV_8UC3)) { m =>
m.cvtColor(ColorConversion.BgrToGray)
.pipe(_.canny(50, 150))
.use(Images.encode(_, ".png"))
}

pipe feeds the intermediate Mat to the next stage and releases it once that stage has its own output, so the blur (or grey) output cannot be leaked and cannot be used after the chain moves on. When the last stage produces something other than a Mat — a count, a Seq[Rect], an encoded byte array — use Managed.use instead, which has the same shape and the same guarantee.

For a longer pipeline, Mats.chain reads as a flat list of stages rather than nested lambdas — it is a fold of pipe, releasing each intermediate as the next consumes it, and it borrows its source (never releasing it):

val chained: Either[CvError, Array[Byte]] =
Managed.use(Mat(80, 80, CvType.CV_8UC3)) { m =>
Mats
.chain(m)(
_.cvtColor(ColorConversion.BgrToGray),
_.gaussianBlur(Size(5, 5), 1.5),
_.canny(50, 150)
)
.use(Images.encode(_, ".png"))
}
Every op is pure with respect to its receiver.

A mid-level op (cvtColor, canny, gaussianBlur, …) never writes to, releases, or aliases its receiver — it allocates a fresh destination and hands that back as a Managed[Mat] you own. That is exactly why you can call one on a borrowed Mat (a video frame, a detector's input) with no transfer ceremony: the frame is untouched. See Image processing for the full operator set.

Managing any native type yourself

A Mat is not the only native object with an off-heap footprint. Detectors, networks and classifiers all own native memory too, and Managed frees any of them — but how it frees them splits into two regimes, and the split is dictated by the generated Java binding, not by taste. (The full argument for why this matters lives in Mat lifecycle.)

RegimeTypesWhat the binding exposesHow to free it
1Mat, VideoCapture, VideoWritera public release()given Releasable already in scope — just works
2the other 185 (CascadeClassifier, QRCodeDetector, ArucoDetector, Net, FaceDetectorYN, …)only a private delete(long) + a finalize()opt into the bridge with Releasable.handle

Regime 1: the three types with a public release()

Mat, VideoCapture and VideoWriter are the only org.opencv.* types that expose a public release(). For them the Releasable instance is already in scope and Managed just works:

val frameSize =
Managed.use(Mat(48, 48, CvType.CV_8UC3)) { m => // Releasable[Mat] uses the public release()
(m.rows, m.cols)
}

Regime 2: the other 185 — opt into the bridge

Every other native type — CascadeClassifier, QRCodeDetector, ArucoDetector, Net, FaceDetectorYN and 180 more — has no public release(). All it exposes is a private static native void delete(long) and an unconditional finalize(). To free one you opt into the bridge:

import org.opencv.objdetect.QRCodeDetector

// Opt in: route release through the binding's private delete(long).
given Releasable[QRCodeDetector] = Releasable.handle(_.getNativeObjAddr)

val detectorClass =
Managed(new QRCodeDetector()).use { detector =>
// ...run `detector` against a Mat here; when the block returns, the bridge frees it...
detector.getClass.getSimpleName
}

Releasable.handle(_.getNativeObjAddr) does two things, in this order:

  • it disarms the binding's finalizer first (zeroing nativeObj) so that after scalacv calls delete, the finalizer's later delete(this.nativeObj) becomes delete nullptr — a C++ no-op — instead of freeing the same pointer a second time and corrupting the heap;
  • then it calls delete(long) through a cached MethodHandle.

It is deliberately opt-in and loud. delete(long) is private API with no compatibility promise, and the reflection it needs stops working the moment OpenCV is loaded from a named module. So if the bridge cannot be opened, Releasable.handle throws a CvError.NativesMissing rather than falling back to the garbage collector — a silent fallback would look like success while leaking native memory without bound.

The thrown message names the exact flag to add, and it is not a guess: scalacv computes it from the offending class's own module and package, by asking the class itself (cls.getModule.getName and cls.getPackageName). A module is the JDK 9+ unit that decides what reflection may reach; a package is the familiar dotted namespace. Here the two have different names, which is exactly why the flag is computed rather than hard-coded: the bytedeco jar declares the module org.bytedeco.opencv, while the detector classes sit in packages called org.opencv.…. So freeing a QRCodeDetector asks for --add-opens org.bytedeco.opencv/org.opencv.objdetect=ALL-UNNAMED, and freeing a DNN Net asks for --add-opens org.bytedeco.opencv/org.opencv.dnn=ALL-UNNAMED. It is never java.base/java.lang — the member being opened is OpenCV's own private nativeObj field (and its private delete(long)), not anything belonging to the JDK.

When OpenCV is on the classpath rather than the module path — the normal case, and the one this page's examples assume — its classes are in the unnamed module, so there is no module to open and no flag that would help. In that situation the message says exactly that and asks for a bug report, instead of printing a flag with a null module name in it. See the error model for how that surfaces, and Troubleshooting for the fix.

Do not drop a Regime-2 handle unwrapped.

Constructing a QRCodeDetector (or any Regime-2 type) and letting it go out of scope leaks native memory until the collector eventually runs its finalize() — which may be never, under load. Always put it in a Managed with its given Releasable[...] in scope, and prefer .use so release is scoped. The one place you don't declare the given yourself is when scalacv already wraps the detector for you — see below.

Anything that needs a real model file or trained network follows the identical pattern — only the construction changes:

import org.opencv.dnn.{Dnn, Net}

given Releasable[Net] = Releasable.handle(_.getNativeObjAddr)

Managed.use(Dnn.readNetFromONNX("model.onnx")) { net =>
// set inputs, forward, read outputs — raw org.opencv.dnn throughout
net.empty
}

For the batteries-included detectors, scalacv already declares these instances for you and wraps the construction — see Object detection, DNN, and Face recognition. The bridge is what you reach for when you want a detector the library doesn't wrap yet.

Borrowing frames from video and camera

The same borrow-vs-own distinction governs frame iteration, and it is the single most common place a raw-Mat mistake bites. Some entry points hand you a borrowed, reused Mat; others hand you an owned copy:

Entry pointWhat you getSafe to keep?
Video.frames(capture) { it => … }one reused, borrowed Mat per iterationno — copy it if you must retain it
Video.framesCopied(capture) { it => … }an owned copy per iterationyes — but close each one
Camera.foreach() { img => … }an owned Image per frameyes — but close it

With Video.frames, the iterator yields the same Mat object every step, overwritten in place — so collecting them into a Seq gives you N references to one buffer holding the last frame. When in doubt, reach for framesCopied or Camera.foreach, which give you an owned value you can safely hand onward. Full detail lives in Video.

The rule of thumb

Stay high-level for the common path; drop exactly one level at exactly the step that needs a knob the level above doesn't expose, then come straight back up. mat borrows, Managed adopts, Image.wrap lifts — no ceremony to go down, no lock-in keeping you there. The low-level OpenCV API is always one method call away, and reaching for it is a normal thing to do, not an escape from the library.

Next

  • Mat lifecycle — the full ownership model, move semantics, and why native memory needs managing.
  • Image API — the high-level tier you drop out of and back into.
  • Error model — how CvError, thrown CvExceptions, and NativesMissing fit together.