Skip to main content

Notebooks & ecosystem interop

scalacv images are OpenCV Mats under the hood, but the rest of the JVM speaks java.awt.image.BufferedImage — so scalacv bridges the two. That bridge is what makes images display automatically in a notebook and interoperate with ImageIO, Swing, and any Graphics2D you already have. Alongside it, a small model registry downloads the detector/recogniser weights the detection and recognition APIs need.

If you are here to see pixels in a Jupyter cell, skip to Displaying in Almond / Jupyter — it is a one-liner. If you are here to fetch a model file once and cache it, skip to The model registry. Everything else is the plumbing that makes those two things work.

The bridge in one picture​

Two methods span the whole AWT boundary, and both copy — so ownership is never shared and never ambiguous:

DirectionMethodInputOutputNotes
scalacv → AWTImage.toBufferedImage8-bit Image, 1/3/4 channelsBufferedImagegrey → TYPE_BYTE_GRAY, colour → TYPE_3BYTE_BGR; 4-channel BGRA is flattened to BGR
AWT → scalacvImage.fromBufferedImageany BufferedImage type3-channel BGR Imageworks for ARGB, indexed, custom rasters — normalised through BGR

Because both copy, the source you pass in stays yours to keep or dispose, and the result is a fresh, independently owned object. That is the whole reason the bridge is safe to sprinkle through notebook cells without thinking about the Mat lifecycle.

warning
toBufferedImage needs an 8-bit image

It supports depth CV_8U only. A 16-bit or float image — a depth map, a disparity map, a raw filter response — throws. Bring it to 8-bit first: normalize rescales it to 0–255 grey, colorMap renders it in false colour. See Showing non-8-bit results below.

BufferedImage in and out​

Image.toBufferedImage copies the image into a BufferedImage; Image.fromBufferedImage does the reverse. Here is the full round-trip — draw something, hand it to AWT, and bring it back:

{
val img = Picture.star(Point(50, 50), 5, 40, 18).fillColor(Color.Orange).noStroke.render(100, 100, Color.DarkGray)
val awt = img.toBufferedImage
img.close()
val back = Image.fromBufferedImage(awt)
try s"${awt.getWidth}x${awt.getHeight} BufferedImage, back to a ${back.channels}-channel Image"
finally back.close()
}
// res1: String = "100x100 BufferedImage, back to a 3-channel Image"

Note the ownership dance: toBufferedImage borrows the image (it stays alive), so img is closed explicitly; fromBufferedImage produces a new owned back, closed in the finally. In a notebook you rarely close anything by hand — the cell ends and the JVM reclaims it — but the same snippet in library code must, and mdoc runs this code, so it closes.

Coming the other way: from AWT into scalacv​

Anything AWT can produce is a valid input. Build a canvas with Graphics2D the way ordinary Swing code would, then process it as a scalacv Image:

import java.awt.image.BufferedImage
import java.awt.Color as AwtColor

// Build a BufferedImage exactly as AWT/Swing code would.
val canvas = BufferedImage(64, 48, BufferedImage.TYPE_INT_ARGB)
val g = canvas.createGraphics()
try
g.setColor(AwtColor.CYAN)
g.fillRect(0, 0, canvas.getWidth, canvas.getHeight)
finally g.dispose()

// From here it is an ordinary scalacv Image: edge-detect it, then hand it back to AWT.
val edges = Image.fromBufferedImage(canvas).gray.canny(50, 150)
val shownEdges = edges.toBufferedImage
edges.close()
shownEdges.getWidth
// res4: Int = 64

The point is that scalacv slots into an existing imaging pipeline rather than replacing it: pull a frame from ImageIO, process it with the Image API, hand the result back to whatever consumes BufferedImages.

Displaying in Almond / Jupyter​

Almond (the Scala Jupyter kernel) renders a BufferedImage inline automatically. So the one-liner to see a scalacv image in a notebook cell is just toBufferedImage on the last expression:

// In an Almond cell — the last expression displays as an image:
import scalacv.*
OpenCv.load()

Image.read("photo.jpg").map(_.gray.canny(80, 160).toBufferedImage)

A tiny helper makes it habitual — return toBufferedImage from any step and the cell shows it:

def show(img: Image): java.awt.image.BufferedImage = img.toBufferedImage

Image.reading("photo.jpg")(img => show(img.gray.canny(80, 160)))
Show a branch without consuming the pipeline

Transforms move the image, so show(img.blur(5)) consumes img and you cannot keep processing it. To display an intermediate step and continue, show a .copy:

def show(img: Image): java.awt.image.BufferedImage = img.toBufferedImage

Image.reading("photo.jpg") { img =>
val preview = show(img.copy.gray) // a throwaway copy for the cell
val result = img.blur(9).canny(50, 150) // the real pipeline, still owns img
(preview, result.toBufferedImage)
}

Showing non-8-bit results​

Many interesting outputs are not 8-bit — a distance transform, a disparity/depth map, a gradient magnitude. toBufferedImage rejects them on purpose (it would silently truncate). Bring them to 8-bit first, and you get a choice of how:

WantDoResult
Plain intensitynormalize()float/16-bit rescaled to 0–255 grey
Legible false colour.gray.colorMap(map)a 3-channel Colormap rendering

The false-colour path is what turns an unreadable single-channel response into something a human can actually parse in a cell:

import java.awt.image.BufferedImage as Bi

val heat = Picture.star(Point(50, 50), 5, 40, 18)
.fillColor(Color.White).noStroke
.render(100, 100, Color.Black)
.gray // 1-channel, 8-bit
.colorMap(Colormap.Viridis) // 3-channel false colour
val heatAwt: Bi = heat.toBufferedImage
heat.close()
heatAwt.getWidth
// res8: Int = 100

The available maps are Autumn, Bone, Jet, Ocean, Hot, Magma, Inferno, Plasma, Viridis, and Turbo — the same set OpenCV ships. Viridis and Turbo are the perceptually uniform ones; prefer them over Jet for anything you will read quantitatively.

The model registry​

The neural models — YuNet for detection, SFace for recognition — are files you fetch once. Models generalises FaceDetect.downloadModel: it downloads to a temp file beside the target and moves it into place only after it verifies, so an interrupted run never strands a truncated model; and it is idempotent, so calling it at start-up is free once the file is present.

import java.nio.file.Paths

// Fetch the bundled specs into a cache directory:
val cache = Paths.get(sys.props("user.home"), ".cache", "scalacv-models")
for
yunet <- Models.fetch(FaceDetect.modelSpec, cache)
sface <- Models.fetch(FaceRecognizer.modelSpec, cache)
yield (yunet, sface)

What fetch guarantees​

PropertyWhat it means for you
AtomicDownloads to a sibling .part temp file and moves into place only after verifying — a crashed download never leaves a half-written model the next load trips on.
IdempotentA target that already exists (and, if a hash is pinned, still matches) is returned without touching the network. Safe to call every start-up.
Verifying by defaultA pinned SHA-256 is checked on every download and every cache hit — bit rot and tampering both fail loudly.
Multi-mirrorurls are tried in order; the first that downloads and verifies wins, and a Left names every URL that failed.
file:// awareA model already on disk is just another source — no HTTP client involved.

The result is Either[CvError, Path], so failures — an uncreatable directory, every mirror down, a checksum mismatch — travel in the error model rather than as exceptions.

Built-in specs​

Both detector specs live next to their detectors and pin a checksum (FaceDetect's is the same one FaceDetect verifies internally):

SpecForVerified?
FaceDetect.modelSpecYuNet face detectionyes (pinned SHA-256)
FaceRecognizer.modelSpecSFace embeddingsyes (pinned SHA-256)

Rolling your own spec​

For any other model, build a ModelSpec with a file name, mirror URLs (http(s):// or file://), and its SHA-256 — verification is the default:

val custom = ModelSpec(
fileName = "my_model.onnx",
urls = Seq("https://example.com/my_model.onnx"),
sha256 = "…"
)

// Only when a model has no published checksum, opt out explicitly:
val trusted = ModelSpec.unverified("other.onnx", Seq("https://example.com/other.onnx"))
note
unverified is a named opt-out, not a shortcut

It loses the tamper/corruption guard, so a corrupt or swapped download loads without complaint. Reach for it only when a model genuinely has no published checksum — and prefer publishing one yourself (sha256Of the file once) over trusting bytes forever.

Once the path is in hand, feed it to whichever loader wants it — Dnn.fromOnnx(path) for a custom net, or FaceDetect/FaceRecognizer for the bundled ones. See DNN inference for the ONNX side.

Next​