Clinical Audiogram — Pygal

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 Pygal

Python source (Pygal)

""" anyplot.ai
audiogram-clinical: Clinical Audiogram
Library: pygal 3.1.0 | Python 3.13.13
Quality: 82/100 | Created: 2026-06-15
"""

import os
import sys


# Remove the script's own directory from sys.path to prevent shadowing the pygal package
_script_dir = os.path.dirname(os.path.abspath(__file__))
if _script_dir in sys.path:
    sys.path.remove(_script_dir)

import pygal
from pygal.style import Style


THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Semantic exception: audiology convention overrides Imprint categorical colors
RIGHT_COLOR = "#AE3030"  # Imprint matte red — right ear
LEFT_COLOR = "#4467A3"  # Imprint blue — left ear

# Muted severity band fills — (light_hex, dark_hex), worst to best
_BAND_COLOR_PAIRS = (
    ("#E8C8C8", "#2A1818"),  # Profound (>90 dB)
    ("#EDD6C0", "#2A2018"),  # Severe (71–90 dB)
    ("#EDE8C0", "#2A2A18"),  # Mod-Severe (56–70 dB)
    ("#DEE8C0", "#1E2A18"),  # Moderate (41–55 dB)
    ("#C8E4C8", "#182A18"),  # Mild (26–40 dB)
    ("#C0E0D4", "#182A22"),  # Normal (≤25 dB)
)
band_fills = tuple(pair[0] if THEME == "light" else pair[1] for pair in _BAND_COLOR_PAIRS)

# Full palette: 6 band fills then the two ear colors
PALETTE = band_fills + (RIGHT_COLOR, LEFT_COLOR)

title_str = "audiogram-clinical · python · pygal · anyplot.ai"
n_chars = len(title_str)
ratio = 67 / n_chars if n_chars > 67 else 1.0
title_font_size = max(44, round(66 * ratio))

custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=PALETTE,
    title_font_size=title_font_size,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=36,
    value_font_size=36,
    stroke_width=3.0,
)

# Standard audiometric octave frequencies — each is exactly 2× the prior, so equal
# categorical spacing in pygal.Line matches logarithmic visual spacing precisely
freq_labels = ["125", "250", "500", "1k", "2k", "4k", "8k"]
n_freqs = len(freq_labels)

# Noise-induced sensorineural hearing loss — bilateral 4 kHz notch
threshold_right = [10, 15, 20, 20, 25, 65, 55]  # right ear, red O markers
threshold_left = [15, 20, 25, 25, 35, 55, 50]  # left ear, blue X markers

# Negate: 0 dB HL (best hearing) sits at y=0 (top); increasing loss goes negative
right_neg = [-t for t in threshold_right]
left_neg = [-t for t in threshold_left]

# Y-axis: absolute-value labels displayed at their negated positions every 10 dB
y_label_items = [{"label": str(abs(v)), "value": v} for v in range(-120, 1, 10)]

chart = pygal.Line(
    width=2400,
    height=2400,
    style=custom_style,
    title=title_str,
    x_title="Frequency (Hz)",
    y_title="Hearing Level (dB HL)",
    show_dots=True,
    dots_size=6,
    show_x_guides=True,
    show_y_guides=True,
    range=(-120, 5),
    legend_at_bottom=True,
    legend_at_bottom_columns=4,
)

chart.x_labels = freq_labels
chart.y_labels = y_label_items

# Stacked fill technique for severity bands:
# Each band is a flat horizontal line with fill=True, which fills from that y-value
# upward to y=0.  Adding from deepest (worst, y=−120) to shallowest (best, y=−25)
# means each later fill overwrites the upper portion, so every band retains its own
# color only between its two surrounding boundary lines.
band_specs = [
    ("Profound (>90 dB)", -120),  # initial fill covers entire chart height
    ("Severe (71–90 dB)", -90),  # overwrites from −90 to 0, exposing profound below
    ("Mod-Severe (56–70 dB)", -70),
    ("Moderate (41–55 dB)", -55),
    ("Mild (26–40 dB)", -40),
    ("Normal (≤25 dB)", -25),
]

for label, y_val in band_specs:
    chart.add(label, [y_val] * n_freqs, fill=True, show_dots=False, stroke_style={"width": 0.5})

# Audiogram lines — drawn on top of all band fills
chart.add("Right Ear (O)", right_neg, fill=False, stroke_style={"width": 5.0})
chart.add("Left Ear (X)", left_neg, fill=False, stroke_style={"width": 5.0})

chart.render_to_png(f"plot-{THEME}.png")
with open(f"plot-{THEME}.html", "wb") as f:
    f.write(chart.render())

Part of Clinical Audiogram on anyplot.ai.

Other implementations