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).

""" anyplot.ai
audiogram-clinical: Clinical Audiogram
Library: altair 6.2.1 | Python 3.13.13
Quality: 91/100 | Updated: 2026-06-15
"""
import os
import altair as alt
import pandas as pd
from PIL import Image
# 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"
# Clinical convention (semantic exception): red=right ear, blue=left ear —
# universally recognised audiology symbols; not the default Imprint order.
RIGHT_COLOR = "#AE3030" # Imprint position 5 (matte red)
LEFT_COLOR = "#4467A3" # Imprint position 3 (blue)
# Imprint palette — first 6 positions used for severity band fills at low opacity
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD"]
# Data — bilateral high-frequency sensorineural notch (classic noise-induced pattern)
# Notch peaks at 4 kHz, partial recovery at 8 kHz; low frequencies largely intact
frequencies = [125, 250, 500, 1000, 2000, 4000, 8000]
right_thresholds = [10, 10, 15, 20, 35, 65, 55]
left_thresholds = [15, 15, 20, 25, 40, 70, 60]
right_df = pd.DataFrame({"frequency": frequencies, "threshold": right_thresholds, "ear": "Right Ear (O)"})
left_df = pd.DataFrame({"frequency": frequencies, "threshold": left_thresholds, "ear": "Left Ear (X)"})
df = pd.concat([right_df, left_df], ignore_index=True)
# Severity band boundaries (dB HL)
band_rows = [
{"y": -10, "y2": 25, "label": "Normal"},
{"y": 25, "y2": 40, "label": "Mild"},
{"y": 40, "y2": 55, "label": "Moderate"},
{"y": 55, "y2": 70, "label": "Mod. Severe"},
{"y": 70, "y2": 90, "label": "Severe"},
{"y": 90, "y2": 120, "label": "Profound"},
]
band_data = pd.DataFrame(band_rows)
band_data["mid_y"] = (band_data["y"] + band_data["y2"]) / 2
# Labels at the low-frequency left margin where ear data is always in the Normal band;
# all Mild-to-Profound labels sit well below (larger dB HL) the ear lines at 125-250 Hz
band_data["x_label"] = 160
bands_domain = band_data["label"].tolist()
# Shared scales
x_scale = alt.Scale(type="log", domain=[125, 8000])
y_scale = alt.Scale(domain=[-10, 120], reverse=True)
x_axis = alt.Axis(
title="Frequency (Hz)",
values=[125, 250, 500, 1000, 2000, 4000, 8000],
labelExpr="datum.value >= 1000 ? (datum.value / 1000) + 'k' : datum.value",
)
y_axis = alt.Axis(title="Hearing Level (dB HL)", values=list(range(-10, 121, 10)))
# Severity bands — rect marks spanning the full view width
bands = (
alt.Chart(band_data)
.mark_rect(opacity=0.15)
.encode(
y=alt.Y("y:Q", scale=y_scale),
y2="y2:Q",
color=alt.Color("label:N", scale=alt.Scale(domain=bands_domain, range=IMPRINT_PALETTE), legend=None),
)
)
# Severity band labels — left-aligned from the low-frequency left margin
band_labels = (
alt.Chart(band_data)
.mark_text(align="left", fontSize=10, fontStyle="italic")
.encode(
x=alt.X("x_label:Q", scale=x_scale),
y=alt.Y("mid_y:Q", scale=y_scale),
text="label:N",
color=alt.value(INK_MUTED),
)
)
# Interactive selection — click a legend symbol to highlight that ear, dim the other
selection = alt.selection_point(fields=["ear"], bind="legend")
# Shared color / shape encoding for ears
ear_domain = ["Right Ear (O)", "Left Ear (X)"]
color_scale_ears = alt.Scale(domain=ear_domain, range=[RIGHT_COLOR, LEFT_COLOR])
shape_scale_ears = alt.Scale(domain=ear_domain, range=["circle", "cross"])
legend_cfg = alt.Legend(
title="",
orient="top-right",
fillColor=ELEVATED_BG,
strokeColor=INK_SOFT,
labelColor=INK_SOFT,
labelFontSize=11,
symbolSize=160,
symbolStrokeWidth=2.5,
)
# Connecting lines per ear — opacity driven by selection state
lines_layer = (
alt.Chart(df)
.mark_line(strokeWidth=2.5)
.encode(
x=alt.X("frequency:Q", scale=x_scale, axis=x_axis),
y=alt.Y("threshold:Q", scale=y_scale, axis=y_axis),
color=alt.Color("ear:N", scale=color_scale_ears, legend=None),
detail="ear:N",
opacity=alt.condition(selection, alt.value(1.0), alt.value(0.2)),
)
)
# Markers: circle (O) for right ear, cross (X) for left ear
points_layer = (
alt.Chart(df)
.mark_point(size=200, strokeWidth=2.5, filled=False)
.encode(
x=alt.X("frequency:Q", scale=x_scale),
y=alt.Y("threshold:Q", scale=y_scale),
color=alt.Color("ear:N", scale=color_scale_ears, legend=legend_cfg),
shape=alt.Shape("ear:N", scale=shape_scale_ears, legend=None),
opacity=alt.condition(selection, alt.value(1.0), alt.value(0.2)),
tooltip=[
alt.Tooltip("frequency:Q", title="Frequency (Hz)"),
alt.Tooltip("threshold:Q", title="Threshold (dB HL)"),
alt.Tooltip("ear:N", title="Ear"),
],
)
.add_params(selection)
)
title_text = "audiogram-clinical · python · altair · anyplot.ai"
# Compose layered chart
chart = (
alt.layer(bands, band_labels, lines_layer, points_layer)
.resolve_scale(color="independent")
.properties(
width=500,
height=460,
background=PAGE_BG,
title=alt.Title(title_text, fontSize=16, color=INK, fontWeight="bold"),
padding={"left": 0, "right": 0, "top": 0, "bottom": 0},
)
.configure_view(fill=PAGE_BG, stroke=None, continuousWidth=500, continuousHeight=460)
.configure_axis(
domainColor=INK_SOFT,
tickColor=INK_SOFT,
gridColor=INK,
gridOpacity=0.12,
labelColor=INK_SOFT,
titleColor=INK,
labelFontSize=10,
titleFontSize=12,
)
.configure_title(color=INK, fontSize=16, fontWeight="bold")
.configure_legend(
fillColor=ELEVATED_BG,
strokeColor=INK_SOFT,
labelColor=INK_SOFT,
titleColor=INK,
labelFontSize=11,
titleFontSize=10,
)
.interactive()
)
# Save PNG and HTML
TW, TH = 2400, 2400
chart.save(f"plot-{THEME}.png", scale_factor=4.0)
# PAD-only to exact canvas target (never crop — would trigger AR-09 edge-clip reject)
_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
raise SystemExit(
f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. "
f"Shrink chart .properties(width=, height=) values and re-render."
)
if _w < TW or _h < TH:
_bg_hex = PAGE_BG.lstrip("#")
_bg_rgb = tuple(int(_bg_hex[i : i + 2], 16) for i in (0, 2, 4))
_canvas = Image.new("RGB", (TW, TH), _bg_rgb)
_canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
_canvas.save(f"plot-{THEME}.png")
chart.save(f"plot-{THEME}.html")
Part of Clinical Audiogram on anyplot.ai.