Clinical Audiogram — plotnine

A standardized clinical audiogram that displays hearing test results, plotting hearing threshold (dB HL) against test frequency for each ear. The y-axis is inverted so that 0 dB HL (best hearing) sits at the top and increasing hearing loss extends downward, while the x-axis is logarithmic spanning the standard audiometric frequencies (125 Hz to 8 kHz). Thresholds are marked with the conventional symbols — a circle (O) for the right ear in red and a cross (X) for the left ear in blue — and connected per ear, with shaded horizontal bands indicating severity of hearing loss (normal, mild, moderate, severe, profound).

Clinical Audiogram rendered with plotnine

Python source (plotnine)

""" anyplot.ai
audiogram-clinical: Clinical Audiogram
Library: plotnine 0.15.7 | Python 3.13.13
Quality: 89/100 | Created: 2026-06-15
"""

import sys


sys.path.pop(0)  # prevent this file (plotnine.py) from shadowing the plotnine library

import os

import pandas as pd
from plotnine import (
    aes,
    element_blank,
    element_line,
    element_rect,
    element_text,
    geom_line,
    geom_point,
    geom_rect,
    geom_text,
    ggplot,
    guides,
    labs,
    scale_color_manual,
    scale_fill_manual,
    scale_linetype_manual,
    scale_shape_manual,
    scale_x_log10,
    scale_y_reverse,
    theme,
    theme_minimal,
)


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Audiogram uses strict clinical color conventions (semantic exception)
RIGHT_COLOR = "#AE3030"  # Imprint matte red — right ear standard
LEFT_COLOR = "#4467A3"  # Imprint blue — left ear standard
ANYPLOT_AMBER = "#DDCC77"  # speech frequency range indicator

# Data: noise-induced high-frequency sensorineural hearing loss pattern
frequencies = [125, 250, 500, 1000, 2000, 4000, 8000]
right_thresh = [10, 10, 15, 20, 30, 55, 65]
left_thresh = [15, 15, 20, 25, 40, 60, 75]

df = pd.concat(
    [
        pd.DataFrame({"frequency": frequencies, "threshold": right_thresh, "ear": "Right Ear (O)"}),
        pd.DataFrame({"frequency": frequencies, "threshold": left_thresh, "ear": "Left Ear (X)"}),
    ],
    ignore_index=True,
)

# Severity bands — contiguous boundaries for clean shading
bands = pd.DataFrame(
    {
        "xmin": [100] * 6,
        "xmax": [10000] * 6,
        "ymin": [-10, 25, 40, 55, 70, 90],
        "ymax": [25, 40, 55, 70, 90, 120],
        "severity": ["Normal", "Mild", "Moderate", "Mod. Severe", "Severe", "Profound"],
    }
)

# Subtle severity band fills per theme
if THEME == "light":
    band_colors = {
        "Normal": "#DFF2EC",
        "Mild": "#ECF4D9",
        "Moderate": "#F5EDD6",
        "Mod. Severe": "#F2E3CE",
        "Severe": "#F0D7D7",
        "Profound": "#EDD7EC",
    }
else:
    band_colors = {
        "Normal": "#14291E",
        "Mild": "#1C2414",
        "Moderate": "#242012",
        "Mod. Severe": "#241A12",
        "Severe": "#241212",
        "Profound": "#20121E",
    }

# Severity band labels: midpoint y positions, placed near right edge
band_labels = pd.DataFrame(
    {
        "x": [9400] * 6,
        "y": [7.5, 32.5, 47.5, 62.5, 80.0, 105.0],
        "label": ["Normal", "Mild", "Moderate", "Mod. Severe", "Severe", "Profound"],
    }
)

# Speech frequency reference band (500–4000 Hz is clinically critical for speech intelligibility)
speech_ref = pd.DataFrame({"xmin": [500], "xmax": [4000], "ymin": [-10], "ymax": [120]})
speech_label = pd.DataFrame({"x": [1414], "y": [-7.0], "label": ["Speech range"]})

# Title font scaling
title = "audiogram-clinical · python · plotnine · anyplot.ai"
n = len(title)
ratio = 67 / n if n > 67 else 1.0
title_fontsize = max(8, round(12 * ratio))

# Plot
plot = (
    ggplot(df, aes(x="frequency", y="threshold"))
    # Severity shading (drawn first so data sits on top)
    + geom_rect(
        data=bands, mapping=aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax", fill="severity"), inherit_aes=False
    )
    + scale_fill_manual(values=band_colors)
    + guides(fill=False)
    # Speech frequency range: subtle amber vertical band highlighting clinically important region
    + geom_rect(
        data=speech_ref,
        mapping=aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax"),
        inherit_aes=False,
        fill=ANYPLOT_AMBER,
        alpha=0.12,
    )
    # Connecting lines per ear
    + geom_line(aes(color="ear", linetype="ear"), size=1.0)
    # Threshold markers: O for right, X for left
    + geom_point(aes(color="ear", shape="ear"), size=3.5, stroke=0.8)
    # Severity band labels inside plot near right edge
    + geom_text(
        data=band_labels,
        mapping=aes(x="x", y="y", label="label"),
        inherit_aes=False,
        size=3.5,
        color=INK_MUTED,
        ha="right",
    )
    # Speech range label near top of band (y=-7 is near the top of the inverted axis)
    + geom_text(
        data=speech_label,
        mapping=aes(x="x", y="y", label="label"),
        inherit_aes=False,
        size=2.5,
        color=INK_MUTED,
        ha="center",
    )
    # Color: right=red, left=blue (clinical convention); same name merges legend entries
    + scale_color_manual(values={"Right Ear (O)": RIGHT_COLOR, "Left Ear (X)": LEFT_COLOR}, name=" ")
    + scale_shape_manual(values={"Right Ear (O)": "o", "Left Ear (X)": "x"}, name=" ")
    + scale_linetype_manual(values={"Right Ear (O)": "solid", "Left Ear (X)": "dashed"}, name=" ")
    # Logarithmic frequency axis with standard audiometric ticks
    + scale_x_log10(
        breaks=[125, 250, 500, 1000, 2000, 4000, 8000],
        labels=["125", "250", "500", "1k", "2k", "4k", "8k"],
        limits=[100, 10000],
    )
    # Inverted hearing level axis: 0 dB at top, loss increases downward
    + scale_y_reverse(limits=(-10, 120), breaks=list(range(-10, 130, 10)))
    + labs(x="Frequency (Hz)", y="Hearing Level (dB HL)", title=title)
    + theme_minimal()
    + theme(
        figure_size=(6, 6),
        text=element_text(size=7),
        plot_title=element_text(size=title_fontsize, color=INK, weight="bold"),
        axis_title=element_text(size=10, color=INK),
        axis_text=element_text(size=8, color=INK_SOFT),
        legend_text=element_text(size=8, color=INK_SOFT),
        legend_title=element_text(size=9, color=INK),
        legend_background=element_rect(fill=ELEVATED_BG, color=None),
        legend_key=element_blank(),
        panel_background=element_rect(fill=PAGE_BG, color=None),
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_grid_major=element_line(color=INK_SOFT, size=0.3, alpha=0.5),
        panel_grid_minor=element_blank(),
        panel_border=element_rect(color=INK_SOFT, fill=None, size=0.5),
        legend_position="right",
    )
)

# Save
plot.save(f"plot-{THEME}.png", dpi=400, width=6, height=6, units="in")

Part of Clinical Audiogram on anyplot.ai.

Other implementations