|
| 1 | +# anyplot.ai |
| 2 | +# chernoff-basic: Chernoff Faces for Multivariate Data |
| 3 | +# Library: Makie.jl | Julia 1.11 |
| 4 | +# Quality: pending | Created: 2026-09-02 |
| 5 | + |
| 6 | +using CairoMakie |
| 7 | +using Colors |
| 8 | +using Random |
| 9 | + |
| 10 | +Random.seed!(42) |
| 11 | + |
| 12 | +# --- Theme tokens ------------------------------------------------------------- |
| 13 | +const THEME = get(ENV, "ANYPLOT_THEME", "light") |
| 14 | +const PAGE_BG = THEME == "light" ? colorant"#FAF8F1" : colorant"#1A1A17" |
| 15 | +const ELEVATED_BG = THEME == "light" ? colorant"#FFFDF6" : colorant"#242420" |
| 16 | +const INK = THEME == "light" ? colorant"#1A1A17" : colorant"#F0EFE8" |
| 17 | +const INK_SOFT = THEME == "light" ? colorant"#4A4A44" : colorant"#B8B7B0" |
| 18 | +const BRAND = colorant"#009E73" # Imprint palette position 1 -- ALWAYS first series |
| 19 | + |
| 20 | +# --- Data: patient vital-sign profiles ----------------------------------------- |
| 21 | +n = 12 |
| 22 | +patient_ids = [string("P", lpad(i, 2, '0')) for i in 1:n] |
| 23 | + |
| 24 | +resting_heart_rate = clamp.(72 .+ 12 .* randn(n), 50, 110) # bpm -> eye size |
| 25 | +systolic_bp = clamp.(122 .+ 14 .* randn(n), 95, 165) # mmHg -> face width |
| 26 | +cholesterol = clamp.(195 .+ 30 .* randn(n), 130, 280) # mg/dL -> eyebrow slant |
| 27 | +bmi = clamp.(26 .+ 4 .* randn(n), 18, 38) # kg/m^2 -> face height |
| 28 | +blood_glucose = clamp.(100 .+ 18 .* randn(n), 75, 160) # mg/dL -> mouth curvature |
| 29 | +sleep_hours = clamp.(6.8 .+ 1.1 .* randn(n), 4.5, 9.0) # hours -> mouth width |
| 30 | +respiratory_rate = clamp.(15 .+ 2.5 .* randn(n), 11, 22) # breaths/minute -> nose length |
| 31 | + |
| 32 | +# Min-max normalize each variable to [0, 1] before mapping to a facial feature |
| 33 | +eye_size_n = (resting_heart_rate .- minimum(resting_heart_rate)) ./ (maximum(resting_heart_rate) - minimum(resting_heart_rate)) |
| 34 | +face_width_n = (systolic_bp .- minimum(systolic_bp)) ./ (maximum(systolic_bp) - minimum(systolic_bp)) |
| 35 | +eyebrow_n = (cholesterol .- minimum(cholesterol)) ./ (maximum(cholesterol) - minimum(cholesterol)) |
| 36 | +face_height_n = (bmi .- minimum(bmi)) ./ (maximum(bmi) - minimum(bmi)) |
| 37 | +mouth_curve_n = (blood_glucose .- minimum(blood_glucose)) ./ (maximum(blood_glucose) - minimum(blood_glucose)) |
| 38 | +mouth_width_n = (sleep_hours .- minimum(sleep_hours)) ./ (maximum(sleep_hours) - minimum(sleep_hours)) |
| 39 | +nose_len_n = (respiratory_rate .- minimum(respiratory_rate)) ./ (maximum(respiratory_rate) - minimum(respiratory_rate)) |
| 40 | + |
| 41 | +# --- Grid layout: 4 columns x 3 rows ------------------------------------------- |
| 42 | +ncols, nrows = 4, 3 |
| 43 | +spacing_x, spacing_y = 2.0, 2.6 |
| 44 | + |
| 45 | +centers = Point2f[] |
| 46 | +for i in 1:n |
| 47 | + row = div(i - 1, ncols) |
| 48 | + col = mod(i - 1, ncols) |
| 49 | + push!(centers, Point2f(col * spacing_x, -row * spacing_y)) |
| 50 | +end |
| 51 | + |
| 52 | +rx_max = 0.55 * 1.30 |
| 53 | +ry_max = 0.68 * 1.30 |
| 54 | + |
| 55 | +# --- Figure ------------------------------------------------------------------- |
| 56 | +fig = Figure( |
| 57 | + resolution = (1200, 1200), |
| 58 | + fontsize = 14, |
| 59 | + backgroundcolor = PAGE_BG, |
| 60 | +) |
| 61 | + |
| 62 | +ax = Axis( |
| 63 | + fig[1, 1]; |
| 64 | + title = "chernoff-basic · julia · makie · anyplot.ai", |
| 65 | + titlesize = 20, |
| 66 | + titlecolor = INK, |
| 67 | + backgroundcolor = PAGE_BG, |
| 68 | + aspect = DataAspect(), |
| 69 | +) |
| 70 | +hidedecorations!(ax) |
| 71 | +hidespines!(ax) |
| 72 | + |
| 73 | +xlims!(ax, -rx_max - 0.3, (ncols - 1) * spacing_x + rx_max + 0.3) |
| 74 | +ylims!(ax, -(nrows - 1) * spacing_y - ry_max - 0.55, ry_max + 0.3) |
| 75 | + |
| 76 | +# --- Draw one Chernoff face per patient ---------------------------------------- |
| 77 | +for i in 1:n |
| 78 | + cx, cy = centers[i] |
| 79 | + |
| 80 | + rx = 0.55 * (0.75 + 0.55 * face_width_n[i]) |
| 81 | + ry = 0.68 * (0.75 + 0.55 * face_height_n[i]) |
| 82 | + |
| 83 | + face_theta = range(0, 2π; length=80) |
| 84 | + face_pts = [Point2f(cx + rx * cos(t), cy + ry * sin(t)) for t in face_theta] |
| 85 | + poly!(ax, face_pts; color=ELEVATED_BG, strokecolor=BRAND, strokewidth=3) |
| 86 | + |
| 87 | + eye_r = 0.05 + 0.09 * eye_size_n[i] |
| 88 | + eye_dx = rx * 0.42 |
| 89 | + eye_y = cy + ry * 0.15 |
| 90 | + eye_theta = range(0, 2π; length=40) |
| 91 | + |
| 92 | + for side in (-1, 1) |
| 93 | + ex = cx + side * eye_dx |
| 94 | + eye_pts = [Point2f(ex + eye_r * cos(t), eye_y + eye_r * sin(t)) for t in eye_theta] |
| 95 | + poly!(ax, eye_pts; color=PAGE_BG, strokecolor=INK, strokewidth=2) |
| 96 | + pupil_pts = [Point2f(ex + 0.4 * eye_r * cos(t), eye_y + 0.4 * eye_r * sin(t)) for t in eye_theta] |
| 97 | + poly!(ax, pupil_pts; color=INK, strokewidth=0) |
| 98 | + end |
| 99 | + |
| 100 | + brow_half_len = rx * 0.32 |
| 101 | + brow_y = eye_y + eye_r * 1.9 |
| 102 | + brow_slope = (0.5 - eyebrow_n[i]) * 0.32 * ry |
| 103 | + |
| 104 | + for (side, mirror) in ((-1, 1), (1, -1)) |
| 105 | + bx = cx + side * eye_dx |
| 106 | + dy = mirror * brow_slope |
| 107 | + lines!(ax, [Point2f(bx - brow_half_len, brow_y - dy), Point2f(bx + brow_half_len, brow_y + dy)]; |
| 108 | + color=INK_SOFT, linewidth=4) |
| 109 | + end |
| 110 | + |
| 111 | + nose_len = ry * (0.22 + 0.30 * nose_len_n[i]) |
| 112 | + nose_top = cy + ry * 0.02 |
| 113 | + lines!(ax, [Point2f(cx, nose_top), Point2f(cx, nose_top - nose_len)]; color=INK_SOFT, linewidth=2.5) |
| 114 | + |
| 115 | + mouth_width = rx * (0.55 + 0.55 * mouth_width_n[i]) |
| 116 | + mouth_base_y = cy - ry * 0.42 |
| 117 | + mouth_a = (0.5 - mouth_curve_n[i]) * 0.9 * ry / max((mouth_width / 2)^2, 1e-6) |
| 118 | + mouth_xs = range(-mouth_width / 2, mouth_width / 2; length=30) |
| 119 | + mouth_pts = [Point2f(cx + xv, mouth_base_y + mouth_a * xv^2) for xv in mouth_xs] |
| 120 | + lines!(ax, mouth_pts; color=INK, linewidth=3.5) |
| 121 | + |
| 122 | + text!(ax, cx, cy - ry - 0.16; text=patient_ids[i], color=INK_SOFT, fontsize=13, |
| 123 | + align=(:center, :top)) |
| 124 | +end |
| 125 | + |
| 126 | +# --- Save ----------------------------------------------------------------------- |
| 127 | +save("plot-$(THEME).png", fig; px_per_unit = 2) |
0 commit comments