Chernoff Faces for Multivariate Data — Makie.jl

Chernoff faces visualize multivariate data by mapping each variable to a facial feature (eye size, mouth curvature, face width, nose length, etc.), transforming each observation into a unique cartoon face. This technique leverages humans' innate ability to recognize and distinguish faces, making it easier to identify patterns, clusters, and outliers across multiple dimensions simultaneously.

Chernoff Faces for Multivariate Data rendered with Makie.jl

Renders

Julia source (Makie.jl)

# anyplot.ai
# chernoff-basic: Chernoff Faces for Multivariate Data
# Library: makie 0.21.9 | Julia 1.11.9
# Quality: 96/100 | Created: 2026-09-02

using CairoMakie
using Colors
using Random

Random.seed!(42)

# --- Theme tokens -------------------------------------------------------------
const THEME       = get(ENV, "ANYPLOT_THEME", "light")
const PAGE_BG      = THEME == "light" ? colorant"#FAF8F1" : colorant"#1A1A17"
const ELEVATED_BG  = THEME == "light" ? colorant"#FFFDF6" : colorant"#242420"
const INK          = THEME == "light" ? colorant"#1A1A17" : colorant"#F0EFE8"
const INK_SOFT     = THEME == "light" ? colorant"#4A4A44" : colorant"#B8B7B0"
const BRAND        = colorant"#009E73"  # Imprint palette position 1 -- ALWAYS first series
const ANYPLOT_AMBER = colorant"#DDCC77" # warning / caution -- flags the outlier patient

# --- Chernoff face recipe -------------------------------------------------------
# A custom Makie recipe: `chernoffface!` is a reusable, self-contained glyph
# (face outline + eyes/pupils + eyebrows + nose + mouth + label) whose shape is
# entirely driven by declarative attributes. This leans on Makie's recipe
# system (`@recipe`, attribute-linked sub-plots) rather than a generic
# poly!/lines!/text! loop.
@recipe(ChernoffFace, cx, cy) do scene
    Attributes(
        face_width    = 0.5,
        face_height   = 0.5,
        eye_size      = 0.5,
        eye_spacing   = 0.5,
        eyebrow_slant = 0.5,
        nose_length   = 0.5,
        mouth_curve   = 0.5,
        mouth_width   = 0.5,
        rx_base       = 0.55,
        ry_base       = 0.68,
        facecolor     = :white,
        outlinecolor  = :black,
        outlinewidth  = 3.0,
        ink           = :black,
        ink_soft      = :gray,
        label         = "",
        labelcolor    = :gray,
        labelsize     = 13.0,
    )
end

function Makie.plot!(cf::ChernoffFace)
    cx = cf[1][]
    cy = cf[2][]

    rx = cf.rx_base[] * (0.75 + 0.55 * cf.face_width[])
    ry = cf.ry_base[] * (0.75 + 0.55 * cf.face_height[])

    θ_face = range(0, 2π; length = 80)
    face_pts = [Point2f(cx + rx * cos(t), cy + ry * sin(t)) for t in θ_face]
    poly!(cf, face_pts; color = cf.facecolor, strokecolor = cf.outlinecolor,
          strokewidth = cf.outlinewidth)

    eye_r  = 0.05 + 0.09 * cf.eye_size[]
    eye_dx = rx * (0.30 + 0.24 * cf.eye_spacing[])
    eye_y  = cy + ry * 0.15
    θ_eye  = range(0, 2π; length = 40)

    for side in (-1, 1)
        ex = cx + side * eye_dx
        eye_pts = [Point2f(ex + eye_r * cos(t), eye_y + eye_r * sin(t)) for t in θ_eye]
        poly!(cf, eye_pts; color = cf.facecolor, strokecolor = cf.ink, strokewidth = 2)
        pupil_pts = [Point2f(ex + 0.4 * eye_r * cos(t), eye_y + 0.4 * eye_r * sin(t)) for t in θ_eye]
        poly!(cf, pupil_pts; color = cf.ink, strokewidth = 0)
    end

    brow_half_len = rx * 0.32
    brow_y = eye_y + eye_r * 1.9
    brow_slope = (0.5 - cf.eyebrow_slant[]) * 0.32 * ry

    for (side, mirror) in ((-1, 1), (1, -1))
        bx = cx + side * eye_dx
        dy = mirror * brow_slope
        lines!(cf, [Point2f(bx - brow_half_len, brow_y - dy), Point2f(bx + brow_half_len, brow_y + dy)];
               color = cf.ink_soft, linewidth = 4)
    end

    nose_len = ry * (0.22 + 0.30 * cf.nose_length[])
    nose_top = cy + ry * 0.02
    lines!(cf, [Point2f(cx, nose_top), Point2f(cx, nose_top - nose_len)];
           color = cf.ink_soft, linewidth = 2.5)

    mouth_width = rx * (0.55 + 0.55 * cf.mouth_width[])
    mouth_base_y = cy - ry * 0.42
    mouth_a = (0.5 - cf.mouth_curve[]) * 0.9 * ry / max((mouth_width / 2)^2, 1e-6)
    mouth_xs = range(-mouth_width / 2, mouth_width / 2; length = 30)
    mouth_pts = [Point2f(cx + xv, mouth_base_y + mouth_a * xv^2) for xv in mouth_xs]
    lines!(cf, mouth_pts; color = cf.ink, linewidth = 3.5)

    text!(cf, cx, cy - ry - 0.16; text = cf.label, color = cf.labelcolor,
          fontsize = cf.labelsize, align = (:center, :top))

    cf
end

# --- Data: patient vital-sign profiles -----------------------------------------
n = 12
patient_ids = [string("P", lpad(i, 2, '0')) for i in 1:n]

resting_heart_rate = clamp.(72 .+ 12 .* randn(n), 50, 110)     # bpm            -> eye size
systolic_bp        = clamp.(122 .+ 14 .* randn(n), 95, 165)    # mmHg           -> face width
cholesterol        = clamp.(195 .+ 30 .* randn(n), 130, 280)   # mg/dL          -> eyebrow slant
bmi                = clamp.(26 .+ 4 .* randn(n), 18, 38)       # kg/m^2         -> face height
blood_glucose      = clamp.(100 .+ 18 .* randn(n), 75, 160)    # mg/dL          -> mouth curvature
sleep_hours        = clamp.(6.8 .+ 1.1 .* randn(n), 4.5, 9.0)  # hours          -> mouth width
respiratory_rate   = clamp.(15 .+ 2.5 .* randn(n), 11, 22)     # breaths/minute -> nose length
body_temperature   = clamp.(98.2 .+ 0.6 .* randn(n), 96.8, 100.4) # deg F       -> eye spacing

# Min-max normalize each variable to [0, 1] before mapping to a facial feature
eye_size_n    = (resting_heart_rate .- minimum(resting_heart_rate)) ./ (maximum(resting_heart_rate) - minimum(resting_heart_rate))
face_width_n  = (systolic_bp .- minimum(systolic_bp)) ./ (maximum(systolic_bp) - minimum(systolic_bp))
eyebrow_n     = (cholesterol .- minimum(cholesterol)) ./ (maximum(cholesterol) - minimum(cholesterol))
face_height_n = (bmi .- minimum(bmi)) ./ (maximum(bmi) - minimum(bmi))
mouth_curve_n = (blood_glucose .- minimum(blood_glucose)) ./ (maximum(blood_glucose) - minimum(blood_glucose))
mouth_width_n = (sleep_hours .- minimum(sleep_hours)) ./ (maximum(sleep_hours) - minimum(sleep_hours))
nose_len_n    = (respiratory_rate .- minimum(respiratory_rate)) ./ (maximum(respiratory_rate) - minimum(respiratory_rate))
eye_spacing_n = (body_temperature .- minimum(body_temperature)) ./ (maximum(body_temperature) - minimum(body_temperature))

# Flag the most extreme combined profile -- largest total deviation from the
# cohort midpoint (0.5) across all 8 normalized variables -- as a visual entry
# point into the comparison.
normalized = hcat(eye_size_n, face_width_n, eyebrow_n, face_height_n,
                   mouth_curve_n, mouth_width_n, nose_len_n, eye_spacing_n)
extremity = vec(sum((normalized .- 0.5) .^ 2; dims = 2))
outlier_idx = argmax(extremity)

# --- Grid layout: 4 columns x 3 rows -------------------------------------------
ncols, nrows = 4, 3
spacing_x, spacing_y = 2.0, 2.6

centers = Point2f[]
for i in 1:n
    row = div(i - 1, ncols)
    col = mod(i - 1, ncols)
    push!(centers, Point2f(col * spacing_x, -row * spacing_y))
end

rx_max = 0.55 * 1.30
ry_max = 0.68 * 1.30

# --- Figure -------------------------------------------------------------------
fig = Figure(
    resolution      = (1200, 1200),
    fontsize        = 14,
    backgroundcolor = PAGE_BG,
)

ax = Axis(
    fig[1, 1];
    title             = "chernoff-basic · julia · makie · anyplot.ai",
    titlesize         = 26,
    titlecolor        = INK,
    backgroundcolor   = PAGE_BG,
    aspect            = DataAspect(),
)
hidedecorations!(ax)
hidespines!(ax)

xlims!(ax, -rx_max - 0.3, (ncols - 1) * spacing_x + rx_max + 0.3)
ylims!(ax, -(nrows - 1) * spacing_y - ry_max - 0.55, ry_max + 0.3)

# --- Draw one Chernoff face per patient via the custom recipe -------------------
for i in 1:n
    is_outlier = i == outlier_idx
    chernoffface!(ax, centers[i][1], centers[i][2];
        face_width    = face_width_n[i],
        face_height   = face_height_n[i],
        eye_size      = eye_size_n[i],
        eye_spacing   = eye_spacing_n[i],
        eyebrow_slant = eyebrow_n[i],
        nose_length   = nose_len_n[i],
        mouth_curve   = mouth_curve_n[i],
        mouth_width   = mouth_width_n[i],
        facecolor     = ELEVATED_BG,
        outlinecolor  = is_outlier ? ANYPLOT_AMBER : BRAND,
        outlinewidth  = is_outlier ? 5.0 : 3.0,
        ink           = INK,
        ink_soft      = INK_SOFT,
        label         = patient_ids[i],
        labelcolor    = INK_SOFT,
        labelsize     = 13,
    )
end

# --- Save -----------------------------------------------------------------------
save("plot-$(THEME).png", fig; px_per_unit = 2)

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/chernoff-basic/makie/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "chernoff-basic",
  "language": "julia",
  "library": "makie",
  "page": "https://anyplot.ai/chernoff-basic/julia/makie",
  "hub": "https://anyplot.ai/chernoff-basic",
  "code_json": "https://api.anyplot.ai/specs/chernoff-basic/makie/code",
  "spec_json": "https://api.anyplot.ai/specs/chernoff-basic",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/chernoff-basic/julia/makie/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/chernoff-basic/julia/makie/plot-dark.png",
  "quality_score": 96.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Chernoff Faces for Multivariate Data on anyplot.ai.

Other implementations