Skip to main content

Photo filters & colormaps

This page is about looks: the tone, colour and stylisation effects you reach for to finish an image — a warm cast, a sepia wash, a pencil sketch, a false-colour heatmap of a depth map — and the Filter type that names and composes them. Every effect is a high-level Image verb that consumes the image it is called on and frees every intermediate, with the mid-level Mat op underneath it when you want the raw knob.

note
What this page is not

These are finishing effects — colour grades and stylisations. The structural, analysis-oriented operations (blur families, edges, thresholds, morphology, colour-space conversions) live in Image processing and Filters as Mat ops. A few overlap (blur, sharpen, invert show up in both worlds) because a named "look" is free to reuse them.

Everything runs against a synthetic scene, so no image file is read:

First example — one filter, one line

The shortest possible taste: take an image, apply a named look, write it out. filter consumes the Image and hands back a new one; write is a terminal that encodes and releases.

Image.reading("photo.jpg") { img =>
img.filter(Filter.vintage).write("vintage.jpg")
}

Or, without a named filter, call the verbs directly and chain them — each one consumes the receiver and returns the next Image:

scene().temperature(0.4).saturate(1.2).gamma(0.9).close()
Move semantics still apply

A filter is a transform, so it consumes the image. val warm = scene(); warm.saturate(1.2); warm.gamma(0.9) throws on the second call — warm was spent by saturate. Chain the calls, or take a .copy first if you need to branch. In these runnable snippets every image is either chained to a terminal or .close()d so nothing leaks.

Tone & colour

The everyday colour grade. saturate scales colourfulness — 0 gives a (still three-channel) grey, 1.0 leaves it, above 1.0 is more vivid. temperature shifts the white balance: positive warms (toward red), negative cools (toward blue), on a -1..1 scale. gamma bends the mid-tones — below 1.0 darkens, above brightens — without clipping the ends. posterize collapses each channel to a handful of levels for a flat, screen-printed look.

scene().saturate(1.4).close() // more vivid
scene().saturate(0).close() // grey, still 3-channel
scene().temperature(0.5).close() // warm
scene().temperature(-0.5).close() // cool
scene().gamma(0.8).close() // darker mids
scene().posterize(4).close() // 4 levels per channel

The full tone-and-colour toolbox, all consuming transforms on Image:

VerbEffectArgumentNotes
saturate(factor)colourfulnessfactor >= 00 grey, 1 unchanged, >1 vivid
temperature(shift)white balanceshift in [-1, 1]>0 warm (red), <0 cool (blue)
gamma(g)mid-tonesg > 0<1 darkens, >1 lifts; ends unclipped
posterize(levels)flatten toneslevels in [2, 256]per-channel banding
adjust(brightness, contrast)linear levelcontrast scales (1.0), brightness shifts (0)saturating convertScaleAbs
invertphotographic negative255 − v per channel
sepiawarm-brown washfixed 3×3 colour matrix
embossdirectional relief3×3 convolution

sepia and emboss are the two matrix effects: sepia is the classic warm-brown wash (a fixed colour matrix via Core.transform), emboss is a directional relief from a 3×3 convolution. Neither takes an argument.

scene().sepia.close()
scene().emboss.close()

invert and adjust round out the linear grades — adjust is one call for brightness and contrast, where contrast multiplies and brightness adds:

scene().invert.close() // negative
scene().adjust(brightness = 20, contrast = 1.3).close() // punchier
gamma vs adjust

adjust is a linear level change — it multiplies and adds, so it clips at both ends once it hits 0 or 255. gamma is a curve: it moves the mid-tones while leaving pure black and pure white where they are, so it rarely clips. Reach for gamma when you want to open up shadows without blowing the highlights.

Stylisation (the photo module)

These come from OpenCV's photo module — non-photorealistic rendering that treats the image as a whole rather than pixel-by-pixel. stylize gives a smooth, saturated "cartoon" cast; sketch is a pencil drawing; enhance (detail enhancement) sharpens local contrast; edgePreserving smooths flat regions while keeping edges crisp.

scene().stylize().close() // painterly
scene().sketch().close() // pencil drawing
scene().enhance().close() // local detail boost
scene().edgePreserving().close() // smooth, edges kept

Each takes tuning parameters with sensible defaults. All four require an 8-bit, 3-channel input (the ordinary BGR image you get from Image.read); hand them a greyscale Mat and OpenCV throws.

VerbEffectSignature (defaults)
stylizepainterly cartoonstylize(strength: Float = 60, detail: Float = 0.45f)
sketchpencil drawingsketch(strength: Float = 60, detail: Float = 0.07f, shade: Float = 0.02f)
enhancelocal detail boostenhance(strength: Float = 10, detail: Float = 0.15f)
edgePreservingsmooth flats, keep edgesedgePreserving(strength: Float = 60, detail: Float = 0.4f)

edgePreserving is the quiet workhorse: it is the smoothing step underneath the painterly filters, and on its own it is a gentle, edge-respecting denoise — useful before thresholding or contour finding when you want to kill texture without softening boundaries.

scene().stylize(strength = 80, detail = 0.3f).close() // more abstract
scene().enhance(strength = 30, detail = 0.2f).close() // stronger clarity
These are the slow ones

The photo-module effects (stylize, sketch, enhance, edgePreserving) run an edge-aware solver over the whole image and are far heavier than the tone grades — easily tens of milliseconds on a mid-size frame. They are fine for stills but think twice before putting one on a per-frame video path. Measure with a benchmark, don't guess.

Colormaps — data as colour

colorMap false-colours a single-channel image (a depth map, a motion field, a mask, any scalar field) into a heatmap. The Colormap enum names the choices; the perceptually-uniform ones — Viridis, Magma, Inferno, Plasma, Turbo — are the honest pick for data, while Jet is the classic-but-misleading rainbow.

// A horizontal ramp stands in for a depth map: 0 on the left, 255 on the right.
def ramp(): Image =
val m = Mat(60, 200, CvType.CV_8UC1)
for x <- 0 until 200 do
Imgproc.line(m, CvPoint(x, 0), CvPoint(x, 60), CvScalar(x * 255.0 / 200), 1)
Image.wrap(Managed(m))

ramp().colorMap(Colormap.Viridis).close() // honest, perceptually uniform
ramp().colorMap(Colormap.Jet).close() // the classic rainbow

The full palette — ten maps, matching OpenCV's COLORMAP_* set:

ColormapFamilyUse for
Viridisperceptually uniformthe default honest choice for data
Magmaperceptually uniformdark background, dramatic
Infernoperceptually uniformheat/energy fields (the heatmap filter uses it)
Plasmaperceptually uniformbright, high-contrast data
Turboperceptually uniformrainbow-like but corrected — safe
Jetlegacy rainbowmatching older tools; misleading for data
Hotsequentialthermal imagery
Bonesequential grey-blueX-ray / medical look
Ocean, Autumnsequentialstylistic
Colormap.values.length
// res21: Int = 10
Jet lies

The classic Jet rainbow has bright bands (cyan, yellow) that read as edges your data doesn't have, and it is unreadable in greyscale or to colour-blind viewers. Prefer Viridis/Turbo for anything a person will draw a conclusion from; keep Jet for matching a legacy screenshot.

colorMap expects a single channel. If you have a colour image, take it to grey first — that is exactly what the built-in heatmap filter does (_.gray.colorMap(Colormap.Inferno)):

scene().gray.colorMap(Colormap.Turbo).close()

Repair & compositing

inpaint reconstructs a masked region from its surroundings — scratch removal, logo erasure. You pass a single-channel mask that is white where the image should be repaired.

val holed =
Image.blank(80, 80, Scalar.White).drawRect(Rect(30, 30, 20, 20), Scalar.Black, Thickness.Filled)
val mask =
Image.blank(80, 80, Scalar.Black, channels = 1).drawRect(Rect(30, 30, 20, 20), Scalar.White, Thickness.Filled)
holed.inpaint(mask).close()
mask.close()
The mask is borrowed; the receiver is consumed

inpaint, seamlessCloneInto, applyMask and blend all follow the same ownership rule: the receiver Image is consumed (it becomes the result), but the mask / background you pass is borrowed — scalacv does not close it for you. Close it yourself, as the snippets above and below do. See Mat lifecycle for the full ownership model.

seamlessCloneInto composites this image onto a background at a point, blending gradients so the seam disappears (Poisson cloning). The result is background-sized.

val patch = Image.blank(30, 30, Scalar(40, 60, 220))
val patchMask = Image.blank(30, 30, Scalar.White, channels = 1)
val background = Image.blank(120, 120, Scalar(180, 180, 180))
patch.seamlessCloneInto(background, patchMask, Point(60, 60)).close()
patchMask.close()
background.close()

applyMask keeps this image only where the mask is non-zero (the rest goes black), and blend is a weighted average of two images — a cross-fade in one call:

val base = Image.blank(80, 80, Scalar(200, 120, 60))
val circle = Image.blank(80, 80, Scalar.Black, channels = 1).drawCircle(Point(40, 40), 30, Scalar.White, Thickness.Filled)
base.applyMask(circle).close() // keep a disc, black elsewhere
circle.close()

val white = Image.blank(160, 120, Scalar.White)
scene().blend(white, weight = 0.7).close() // 70% scene, 30% white — `white` is borrowed
white.close()

Named, composable filters

A Filter is a named Image => Image — a ready-made look you apply with image.filter(...) or compose with andThen. The catalog is built from exactly the operations above, so nothing here is magic; it is a curated set of starting points.

scene().filter(Filter.vintage).close()
scene().filter(Filter.noir).close()
scene().filter(Filter.cartoon).close()
scene().filter(Filter.heatmap).close()

The full catalog, each spelled out as the recipe it actually runs:

FilterRecipeLook
grayscalesaturate(0)grey, still 3-channel so it chains
sepiasepiawarm brown
invertinvertnegative
warmtemperature(0.5)warmer white balance
cooltemperature(-0.5)cooler white balance
vividsaturate(1.5).enhance()punchy, high-clarity
mutedsaturate(0.6)desaturated, soft
noirsaturate(0).adjust(contrast = 1.3).gamma(0.9)high-contrast black & white
vintagesepia.saturate(0.85).gamma(0.9)faded film
cartoonstylize()painterly
sketchsketch()pencil drawing
posterizeposterize(6)screen-print banding
embossembossgrey relief
softBlurblur(3)gentle Gaussian softening
sharpensharpen()unsharp mask
heatmapgray.colorMap(Colormap.Inferno)false-colour heat
dramaticenhance(strength = 20).saturate(1.3)strong local contrast

Because a filter is just a named transform, your own are first-class, and any two compose into a third:

val myLook = Filter("myLook")(_.temperature(0.3).saturate(1.2).gamma(0.9))
scene().filter(myLook).close()

val warmSketch = Filter.warm.andThen(Filter.sketch)
scene().filter(warmSketch).close()
tip
andThen names the composite

Filter.warm.andThen(Filter.sketch) builds a new Filter whose name is "warm+sketch" — handy when you print a filter picker or log which look was applied. Filter("x")(f) names anything: a filter is nothing but a String and an Image => Image.

Filter.all is every built-in look — handy for a contact sheet or a picker:

Filter.all.map(_.name).mkString(", ")
// res38: String = "grayscale, sepia, invert, warm, cool, vivid, muted, noir, vintage, cartoon, sketch, posterize, emboss, softBlur, sharpen, heatmap, dramatic"

A contact sheet is then just "apply each filter to a copy and encode it":

val sheet: Seq[(String, Either[CvError, Array[Byte]])] =
Filter.all.map(f => f.name -> scene().filter(f).bytes(".png"))

The mid-level ops

Each Image verb is a thin cover over a Mat extension op that returns an owned Managed[Mat], for when you want to stay on the low-level surface. The verb and the op share a name (with two exceptions noted below), and the op borrows its receiver and hands back a fresh owned Mat:

val src = sceneMat()
val warmed: Either[CvError, Array[Byte]] =
src.temperature(0.5).use(Images.encode(_, ".png"))
val heat: Either[CvError, Array[Byte]] =
src.colorMap(Colormap.Inferno).use(Images.encode(_, ".png"))
src.release()
High-level Image verbMid-level Mat opNote
saturate / temperature / gamma / posterizesame namespure Managed[Mat] result
sepia / emboss / invert (as bitwiseNot)sepia / emboss / bitwiseNotmatrix / convolution
stylize / enhance / edgePreservingstylize / detailEnhance / edgePreservingenhancedetailEnhance
sketchpencilSketchdifferent name mid-level
colorMap / inpaint / seamlessCloneIntosame namesas above

Chain mid-level ops with pipe so each intermediate is released the moment the next stage consumes it — the same ownership discipline the high-level verbs give you for free:

val graded = sceneMat()
val out: Either[CvError, Array[Byte]] =
graded.temperature(0.4).pipe(_.saturate(1.2)).pipe(_.gamma(0.9)).use(Images.encode(_, ".png"))
graded.release()
Optimise only with a benchmark

The house rule in CLAUDE.md is no optimisation without a benchmark delta and a bit-identical output hash. If you drop from a high-level verb to a hand-rolled mid-level chain for speed, prove it with the benchmark harness — micro-seconds are machine-specific, deltas reproduce.

Next

  • Image processing — the structural operations (blur families, edges, thresholds, morphology) these looks are built on.
  • Filters as Mat ops — the mid-level surface, pipe, and the ownership contract in full.
  • Drawing & annotation — burn labels and boxes onto the graded result before you save it.