panlabel 0.6.0

The universal annotation converter
Documentation
//! HTML rendering for stats reports.

use crate::error::PanlabelError;
use crate::stats::StatsReport;

const CHART_JS: &str = include_str!("assets/chart.min.js");

/// Render a self-contained HTML report for dataset statistics.
pub fn render_html(report: &StatsReport) -> Result<String, PanlabelError> {
    let stats_json = serde_json::to_string(report)
        .map_err(|source| PanlabelError::ReportJsonWrite { source })?
        .replace("</", "<\\/");

    let html = format!(
        r#"<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>panlabel stats</title>
  <style>
    :root {{ color-scheme: light dark; }}
    body {{ font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 1rem auto; max-width: 1200px; padding: 0 1rem 2rem; line-height: 1.5; }}
    h1, h2 {{ margin: 0.5rem 0; }}
    .grid {{ display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); }}
    .card {{ border: 1px solid #9993; border-radius: 10px; padding: 0.8rem; background: #fff2; }}
    canvas {{ width: 100%; height: 260px; }}
    pre {{ overflow-x: auto; background: #0001; border-radius: 8px; padding: 0.8rem; }}
    .summary {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.5rem; }}
    .metric {{ padding: 0.5rem; border: 1px solid #9993; border-radius: 8px; }}
    .metric .label {{ font-size: 0.8rem; opacity: 0.8; }}
    .metric .value {{ font-size: 1.1rem; font-weight: 600; }}
  </style>
  <script>{chart_js}</script>
</head>
<body>
  <h1>panlabel stats</h1>
  <p>Self-contained report generated by <code>panlabel stats --output html</code>.</p>

  <div class="card">
    <h2>Summary</h2>
    <div class="summary">
      <div class="metric"><div class="label">Images</div><div class="value" id="m-images"></div></div>
      <div class="metric"><div class="label">Categories</div><div class="value" id="m-categories"></div></div>
      <div class="metric"><div class="label">Annotations</div><div class="value" id="m-annotations"></div></div>
      <div class="metric"><div class="label">Annotated images</div><div class="value" id="m-annotated"></div></div>
    </div>
  </div>

  <div class="grid">
    <div class="card"><h2>Labels</h2><canvas id="labels-chart"></canvas></div>
    <div class="card"><h2>Area buckets</h2><canvas id="areas-chart"></canvas></div>
    <div class="card"><h2>Aspect ratios</h2><canvas id="aspect-chart"></canvas></div>
    <div class="card"><h2>Image resolutions</h2><canvas id="resolutions-chart"></canvas></div>
  </div>

  <div class="card">
    <h2>Raw JSON</h2>
    <pre id="raw-json"></pre>
  </div>

  <script type="application/json" id="stats-data">{data}</script>
  <script>
    const data = JSON.parse(document.getElementById("stats-data").textContent);

    document.getElementById("m-images").textContent = String(data.summary.images);
    document.getElementById("m-categories").textContent = String(data.summary.categories);
    document.getElementById("m-annotations").textContent = String(data.summary.annotations);
    document.getElementById("m-annotated").textContent = String(data.summary.annotated_images);
    document.getElementById("raw-json").textContent = JSON.stringify(data, null, 2);

    const labelLabels = (data.labels.entries || []).map(x => x.label);
    const labelValues = (data.labels.entries || []).map(x => x.count);

    new Chart(document.getElementById("labels-chart"), {{
      type: "bar",
      data: {{ labels: labelLabels, datasets: [{{ label: "count", data: labelValues }}] }},
      options: {{ responsive: true, maintainAspectRatio: false }}
    }});

    const areaLabels = ["small", "medium", "large", "invalid"];
    const areaValues = [
      data.area_distribution.small,
      data.area_distribution.medium,
      data.area_distribution.large,
      data.area_distribution.invalid,
    ];

    new Chart(document.getElementById("areas-chart"), {{
      type: "bar",
      data: {{ labels: areaLabels, datasets: [{{ label: "count", data: areaValues }}] }},
      options: {{ responsive: true, maintainAspectRatio: false }}
    }});

    const aspectLabels = (data.aspect_ratios.buckets || []).map(x => x.name).concat(["invalid"]);
    const aspectValues = (data.aspect_ratios.buckets || []).map(x => x.count).concat([data.aspect_ratios.invalid]);

    new Chart(document.getElementById("aspect-chart"), {{
      type: "bar",
      data: {{ labels: aspectLabels, datasets: [{{ label: "count", data: aspectValues }}] }},
      options: {{ responsive: true, maintainAspectRatio: false }}
    }});

    new Chart(document.getElementById("resolutions-chart"), {{
      type: "scatter",
      data: {{ datasets: [{{
        label: "resolutions",
        data: [{{ x: data.image_resolutions.mean_w, y: data.image_resolutions.mean_h }}],
      }}] }},
      options: {{
        responsive: true,
        maintainAspectRatio: false,
        scales: {{ x: {{ title: {{ display: true, text: "width" }} }}, y: {{ title: {{ display: true, text: "height" }} }} }},
      }},
    }});
  </script>
</body>
</html>
"#,
        chart_js = CHART_JS,
        data = stats_json,
    );

    Ok(html)
}