Skip to main content

Getting Started

scalacv is a Scala 3 wrapper for OpenCV 4.13 — the industry-standard computer-vision library. It wraps the official OpenCV Java API so you write ordinary Scala: Seq, Option, Either, extension methods, and a fluent Image you transform by chaining. No raw int constants, no manual memory management, no Java ceremony.

If you have never touched OpenCV, that is fine — this page takes you from an empty build.mill to a working edge-detection pipeline. If you have, the short version is: add one platform jar, call OpenCv.load() once, and reach for Image.

The 30-second mental model

scalacv has two tiers (the high-level Image and mid-level Mat extensions), three modules (core, vision, graphs), one ownership rule (a transform consumes the image it was called on), and one error policy (expected failures are Either, bugs throw). The Architecture page unpacks all four; you do not need them yet to run the examples below.

Add the dependency

scalacv is published under the group id com.worxbend as four separate artifacts: scalacv (the core), scalacv-vision, scalacv-graphs and scalacv-zio. Only the core is required. The other three are opt-in, so a program that reads and filters images never pulls a SLAM loop-closure detector into its jar — but that also means a symbol that lives in one of them will not compile until you add its line. Three of the five tutorials on this site need scalacv-vision — faces, DNN, and the motion-alarm step of the video tutorial — so add that one too if you plan to follow along.

On top of scalacv itself you need OpenCV. scalacv depends on the OpenCV Java API, which has no native code in it. The natives ship in per-platform classifier jars, and no build tool can put a classifier into a published POM — so you add the one for your platform yourself.

All of it in Mill — delete the scalacv lines you do not need, and keep both bytedeco ones:

def mvnDeps = Seq(
mvn"com.worxbend::scalacv:0.1.0", // core: images, video, contours, drawing, filters
mvn"com.worxbend::scalacv-vision:0.1.0", // detectors, DNN, pose, tracking, motion, OCR, calibration, SLAM
mvn"com.worxbend::scalacv-graphs:0.1.0", // the Picture scene graph, charts, animated GIFs
mvn"com.worxbend::scalacv-zio:0.1.0", // only if you use ZIO
mvn"org.bytedeco:opencv:4.13.0-1.5.13;classifier=linux-x86_64",
mvn"org.bytedeco:openblas:0.3.31-1.5.13;classifier=linux-x86_64"
)

The same for sbt (note %% for the Scala artifacts, % for the Java-world bytedeco jars, and classifier):

libraryDependencies ++= Seq(
"com.worxbend" %% "scalacv" % "0.1.0",
"com.worxbend" %% "scalacv-vision" % "0.1.0",
"com.worxbend" %% "scalacv-graphs" % "0.1.0",
"com.worxbend" %% "scalacv-zio" % "0.1.0",
"org.bytedeco" % "opencv" % "4.13.0-1.5.13" classifier "linux-x86_64",
"org.bytedeco" % "openblas" % "0.3.31-1.5.13" classifier "linux-x86_64"
)

Both opencv and openblas lines are needed: libopencv_core links libopenblas, so omitting the second gives you a half-loaded library.

Pick the classifier that matches where the code will run:

Platformclassifier
Linux x86-64linux-x86_64
Linux ARM64linux-arm64
macOS Apple Siliconmacosx-arm64
macOS Intelmacosx-x86_64
Windows x86-64windows-x86_64

There are -gpu variants of linux-x86_64, linux-arm64 and windows-x86_64 if you need CUDA. There is no windows-arm64 build.

Don't want to choose? mvn"org.bytedeco:opencv-platform:4.13.0-1.5.13" bundles every platform and works anywhere — for about 408 MB instead of 36–80 MB. Good for a laptop, wasteful in a single-platform container.

Which modules do you actually need?

You want to…Add this coordinate
read/transform/write images, contours, video capturecom.worxbend::scalacv (core, always)
face/QR/marker detection, DNN, pose, tracking, motion, OCR, SLAMcom.worxbend::scalacv-vision
draw scene graphs, charts, animated GIFscom.worxbend::scalacv-graphs
an effect-typed APIcom.worxbend::scalacv-zio

vision and graphs depend only on core, so you pull exactly what you use — see Architecture.

A missing module looks like a missing method

Every module puts its verbs in the same scalacv package, so one import scalacv.* covers all four — but only for the jars that are actually on your classpath. If the compiler says value faces is not a member of Image, or Not found: Cascades, the symbol is not misspelled: it lives in scalacv-vision (or scalacv-graphs) and that dependency line is missing. See Troubleshooting.

If you forget the native lines

scalacv alone compiles without them, but it will not run: the OpenCV symbols are absent until you add them. OpenCv.load() does not fail with a cryptic link error — it prints a copy-pasteable fix naming the platform you are actually on. See Troubleshooting if you hit it.

What lands on disk. For linux-x86_64 the opencv jar is ~31 MB and openblas ~20 MB; the first OpenCv.load() extracts them once into ~/.javacpp (~196 MB on Linux) and every later run reuses that cache — see The native cache to relocate or pre-warm it.

Load the natives

Call this once, at the top of your program, before touching any other scalacv API. It is idempotent and thread-safe, so calling it again from anywhere is free:

import scalacv.*

OpenCv.load()

That single import scalacv.* brings in everything — Image, Scalar, Rect, the enums, and all the extension methods. You import once per file, not per feature.

OpenCv.isLoaded
// res1: Boolean = true
Load before you build

Image.blank, Image.read, Camera.open and friends all cross into native code. Calling them before OpenCv.load() is the single most common first-run mistake. Put the call in your main (or a test fixture) so it always runs first.

Your first pipeline

The high-level Image API reads, transforms and writes in a single chain — every intermediate is freed for you. Lead with Image.reading: it scopes the image to the block and releases it on success, on failure, and on exception, so this is the one entry point that cannot leak the source:

Image.reading("photo.jpg") { img => img.gray.blur(2).canny(80, 160).write("edges.png") }

Read that left to right: open photo.jpg, convert to greyscale, blur slightly to kill noise, run Canny edge detection, write edges.png. Each step returns a new Image and spends the previous one, so only one native buffer is alive at a time — more on that below.

The read/flatMap form is equivalent when you would rather thread the Either by hand — every terminal (write, bytes, close) still releases — but reach for it only when reading does not fit:

Image.read("photo.jpg").flatMap(_.gray.blur(2).canny(80, 160).write("edges.png"))

Here it is end to end on a scene we draw ourselves, so it runs with no image file:

val edges: Either[CvError, Array[Byte]] =
Image
.blank(160, 120, Scalar.White)
.drawRect(Rect(30, 30, 90, 60), Scalar.Black)
.gray
.canny(50, 150)
.bytes(".png")

bytes is a terminal, like write — it encodes to an in-memory PNG (great for an HTTP response or a BLOB) and releases the image. Everything is typed; there are no raw int constants anywhere:

ColorConversion.BgrToGray.cvValue
// res4: Int = 6

The one rule you must know: move semantics

Image has move semantics. Every transform (gray, blur, canny, resize, crop, any draw*, …) consumes the image it was called on and hands back a fresh one. This is what makes a long chain leak-free: each step frees the previous native Mat, so the pipeline never piles up intermediates. The catch is that you may not reuse a spent image — doing so throws a clear IllegalStateException, not a segfault:

val img = Image.blank(8, 8)
val gray = img.gray // consumes `img`
img.width // throws: `img` was spent by `.gray`
// 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 scalacv.Image.width(Image.scala:63)
// at repl.MdocSession$MdocApp.$init$$$anonfun$1(getting-started.md:60)

To branch — use one image two ways — take a copy first:

val base = Image.blank(64, 64, Scalar.White)
val thumb = base.copy.resize(16, 16) // copy is independent
val full = base.gray.canny(50, 150) // base is still live for this branch
thumb.close()
full.close()

Two kinds of method behave differently from transforms:

KindExamplesEffect on the receiver
Transformgray, blur, canny, crop, drawRect, filterconsumes it, returns a new Image
Querywidth, height, channels, contours, facesborrows it — stays alive, call as often as you like
Terminalwrite, bytes, closeconsumes it and releases the native memory

The full story — and why the JVM's garbage collector cannot manage this for you — is in Mat lifecycle.

Building a scene from scratch

You do not need an image file to experiment. Image.blank(width, height, color) gives you a canvas, and the draw* methods paint onto it. Coordinates are pixels with the origin at the top-left, x right and y down. Colours are Scalar values in BGR order (OpenCV's default), so Scalar.Red is Scalar(0, 0, 255):

val poster =
Image.blank(240, 160, Scalar(30, 30, 30)) // dark grey background
.drawRect(Rect(20, 20, 80, 60), Scalar.Green, Thickness.Filled)
.drawCircle(Point(170, 70), 40, Scalar.Red, Thickness.Filled)
.drawText("scalacv", Point(20, 140), Scalar.White, scale = 1.2)
.bytes(".png")

Pass Thickness.Filled for a solid shape or Thickness.Stroke(n) for an n-pixel outline; the default is a one-pixel stroke. See Drawing & annotation for the full palette and text metrics.

Querying an image

Queries only read, so you can call as many as you like before consuming the image. Here we count the white shapes on a black canvas by finding their contours:

val shapes =
Image.blank(200, 120, Scalar.Black)
.drawRect(Rect(20, 20, 60, 40), Scalar.White, Thickness.Filled)
.drawCircle(Point(150, 60), 25, Scalar.White, Thickness.Filled)
.gray

val shapeCount = shapes.contours().size // borrows `shapes`, leaves it alive
val stillWide = shapes.width // still alive — another query
shapes.close() // done: release it ourselves
shapeCount
// res8: Int = 2
Who closes what

Image.reading and Managed.use close for you. When you build an Image by hand and never reach a terminal (write/bytes/close), you must close it — as we did above — or it leaks. In a long-running program a leak is silent until you run out of native memory, so prefer the scoped forms. See Mat lifecycle.

Applying a named filter

The graphs/filters catalogue bundles ready-made "looks" as composable Image => Image transforms. Applying one consumes the image, exactly like any other transform:

val vintage =
Image.blank(96, 96, Scalar(120, 90, 60))
.filter(Filter.vintage)
.bytes(".jpg")

Every built-in filter is available, and you can andThen them or define your own with Filter("mine")(_.gamma(1.2).saturate(1.3)):

Filter.all.size
// res9: Int = 17

Reading images efficiently

Image.read (and the scoped Image.reading) take an optional ImreadFlags. Decode straight to greyscale to skip a colour conversion, or decode at a fraction of full resolution — the codec skips the discarded detail rather than producing it and then shrinking, which is meaningfully faster for large files:

// Straight to single-channel grey — no BGR round-trip.
Image.read("scan.jpg", ImreadFlags.Grayscale)

// Decode a huge photo at half resolution in one step.
val half = ImreadFlags(ImreadColor.Color, ImreadScale.Half)
Image.read("huge.jpg", half)
FlagWhat it does
ImreadFlags.Color3-channel BGR (the default)
ImreadFlags.Grayscalesingle-channel, no colour
ImreadFlags.Unchangedkeep alpha / original depth
ImreadFlags(ImreadColor.Color, ImreadScale.Half)decode at ½ (or Quarter, Eighth) size

Reduced-size decode exists only for Grayscale and Color — the type rejects nonsensical combinations up front. See Image I/O for the complete matrix.

When things fail

scalacv splits failure into two deliberate kinds. Expected, data-dependent problems — a missing file, undecodable bytes, a model that won't load — come back as Either[CvError, A] so you handle them as values:

val missing: Either[CvError, Image] = Image.read("does-not-exist.png")
missing.isLeft
// res11: Boolean = true

Programmer errors — a negative blur radius, reusing a consumed image — throw (IllegalArgumentException, IllegalStateException), because they are bugs to fix, not conditions to branch on. That division, and how to fold a mid-chain native error into an Either with Cv.attempt, is the whole of The error model.

Where to go next