---
author:
- Yujan Shrestha
- Nishant Dahal
cta_button_text: Discuss your validation plan
cta_case_studies: []
cta_hero_details: Innolitics connects medical imaging software, validation, and FDA
  strategy. Bring your intended use, pilot results, annotation plan, and available
  case count. We can help plan and execute the work through 510(k) submission and
  review.
cta_hero_text: Turn your segmentation model into a submission-ready device.
date: '2026-09-11'
description: Plan HD95 acceptance criteria and sample size for medical image segmentation
  with an interactive calculator, tested Python, and a 250 patient budget example.
related:
- 2fdbd5b7-a754-806f-9feb-e047367f287b
- 3b9bd5b7-a754-80a7-93f0-e57a5abdc606
- 3b9bd5b7-a754-809d-b0d3-cd764ae54dfa
title: 'HD95 for Medical Image Segmentation: How to set FDA acceptance criteria and
  sample size'
topics:
- AI/ML
- Regulatory
---

### Introduction

Your segmentation model has a good Dice score and an average HD95 of 1.8 mm. Your team can afford to annotate 250 patients. Is that enough to support an FDA submission?

Before we can answer that, we need to decide how much boundary error the clinical task can tolerate. We also need to define what the validation study must show to demonstrate that the device meets that limit.

This guide will show you how to turn the 95th percentile Hausdorff distance, or HD95, into a defined acceptance rule and calculate the sample size for a planned chance of passing. We will work through one example, use an interactive calculator, and work backward from a budget of 250 patients. Python code and lookup tables let you reproduce the results.

FDA\'s final [quantitative imaging guidance](https://www.fda.gov/media/123271/download) recommends setting acceptance criteria from the intended use, defining the experimental unit, and planning the statistical analysis before collecting validation data. It does not prescribe a universal HD95 cutoff or sample size. The numbers below are planning examples, not FDA requirements.

<figure>
  <img src="/img/articles/HD95_for_Medical_Image_Segmentation_How_to_set_FDA_acceptance_criteria_and_sample_size-3d7bd5b7a75480f3affddc3b8f5243dc.png">
  <figcaption>
    <em><strong>Figure 1.</strong></em> <em>Set the clinical limit and
    patient pass rule before calculating sample size. With a 95% pass-rate
    floor, an expected pass rate of 98%, and a one-sided 95% exact lower
    bound strictly above the floor, a study of 311 independent patients
    gives 90.22% power and requires at least 302 passes. The 3.0 mm limit
    and other criteria are hypothetical, not FDA requirements.</em>
  </figcaption>
</figure>

### What does HD95 measure in medical image segmentation? 

HD95 measures how much a predicted segmentation disagrees with a reference segmentation along a boundary. Lower values indicate better agreement under the selected implementation.

For the convention used in our example, we find the closest reference boundary point for each predicted boundary point and repeat the process in the opposite direction. We then find the 95th percentile in each direction and report the larger of the two values.

$$HD95=\max\left(Q_{0.95}(d_{P\rightarrow R}), Q_{0.95}(d_{R\rightarrow P})\right).$$

Here, <span markdown="0">\(P\)</span> is the predicted boundary and <span markdown="0">\(R\)</span> is the reference boundary. Distances are in millimeters when the coordinates or voxel spacing are in millimeters.

**This definition is part of the endpoint.** Some implementations aggregate both sets of distances. Others use different surface extraction or weighting methods. The [HDilemma study](https://papers.miccai.org/miccai-2024/paper/2469_paper.pdf) noted significant differences among implementations. Note the software version, 2D or 3D calculation, surface definition, weighting, percentile method, and directional combination. Ensure both masks use the same physical coordinate system and spacing is aligned with the array axes. An average of slice-level HD95 values is not the same as 3D HD95.

HD95 is also not the maximum boundary error. A small extra region can have a large error without changing the 95th percentile.

<figure>
  <img src="/img/articles/HD95_for_Medical_Image_Segmentation_How_to_set_FDA_acceptance_criteria_and_sample_size-3d7bd5b7a754808c8c32fea769593522.png">
  <figcaption>
    <strong>Figure 2.</strong> Three synthetic segmentation errors show why
    supporting checks matter. A shifted boundary has HD95 of 6.0 mm. A small
    extra region has HD95 of 0.0 mm despite a maximum Hausdorff distance of
    59.4 mm. A missed region has HD95 of 14.0 mm. Calculations use 2D masks,
    1 mm spacing, equally weighted boundary voxel centers, and the larger
    directional 95th percentile. No patient data are shown.
  </figcaption>
</figure>

Keep these three uses of 95 separate.

| Quantity              | What it describes                                                                                            |
|-----------------------|--------------------------------------------------------------------------------------------------------------|
| HD95                  | The 95th percentile of boundary distances within one segmentation comparison, under the specified convention |
| 95% patient pass rate | The proportion of patients whose results satisfy a defined case rule                                         |
| 95% confidence bound  | Statistical uncertainty about a population quantity estimated from the study                                 |

### Step 1. Set the clinical limit before choosing the sample size

Start with what the segmentation will be used for. A contour used for volume measurement and a contour used near a critical treatment boundary may need different limits and supporting endpoints.

Build the rationale based on the task to be performed, the impact of an error, associated literature, or similar devices, and reference annotation that aligns with your task. While expert disagreement is helpful, it is not sufficient to establish that the same error would be clinically reasonable.

On separate development data, compare candidate HD95 limits with blinded expert judgments of contour acceptability and downstream task errors, where relevant. Review serious errors that occur even below a candidate limit. Document why the selected distance limit and allowed patient failure rate fit the clinical risk. The 95% pass rate floor used below also needs that justification.

| Intended use                                        | Evidence to consider alongside HD95                                 |
|-----------------------------------------------------|---------------------------------------------------------------------|
| Measure an organ or lesion volume                   | Volume error and agreement across the claimed size range            |
| Support treatment contouring                        | Expert acceptability and downstream effects relevant to treatment   |
| Segment lesions that may be missed or falsely added | Lesion sensitivity, false positives, and rules for matching lesions |

Public 510(k) summaries illustrate why the endpoint needs this context. [ART-Plan K234068](https://www.accessdata.fda.gov/cdrh_docs/pdf23/K234068.pdf), PDF pages 8 and 9, describes a **mean HD95 criterion of 5.75 mm or less**, with other quantitative and expert assessments. [Neosoma Brain Mets K252922](https://www.accessdata.fda.gov/cdrh_docs/pdf25/K252922.pdf), PDF pages 12 and 13, evaluates HD95 on **true positive lesions** and reports sensitivity and false positives separately. Neither example establishes a cutoff for another device.

For our example, suppose the team has justified **3.0 mm** for one named organ in a defined CT population. This value is hypothetical. We choose it to demonstrate the method. We do not derive it from the pilot model\'s average error.

If the clinical limit or endpoint remains uncertain, consider a [Pre-Submission](https://www.fda.gov/media/114034/download) with a proposed protocol, rationale, and focused questions before spending the validation budget.

### Step 2. Write a patient rule and a study rule 

In this example, each individual patient provides one predefined CT volume. The reference organ is present, and qualified readers define its contour under a written annotation and adjudication procedure.

**A patient passes this endpoint when the device produces the required segmentation and its HD95 is at most 3.0 mm.** An empty prediction or a failed device run counts as a failure. We do not discard it because the metric returned NaN or infinity.

**The study passes this endpoint when the one-sided 95% exact lower confidence bound for the patient pass rate is greater than 95%.** We use the Clopper-Pearson bound.

Let <span markdown="0">\(p\)</span> be the true population pass rate for this case rule. The study criterion is:

$$L_{95\%}(p)>0.95.$$

This asks whether more than 95% of the intended population meets the case rule, with the stated confidence procedure. A confidence bound for mean HD95 answers a different question. A low mean can coexist with some poor cases.

Our pass rate method is designed to answer the question stated above. It should not be used to replace a pre-specified mean HD95 endpoint. If the claim calls for a mean, percentile, or paired comparison, plan power for that estimator and its confidence interval instead.

Define other outcomes before testing. If both masks are empty due to a lack of anatomy, HD95 has no boundary with which to compare. Address these situations under a separate absence rule if they are in scope. Missing reference data require prespecified accounting and exclusion rules. Keep them distinct from device failures.

For multiple required organs, one possible patient rule requires every organ to pass its own limit. Then the planning input must be the **joint patient pass rate**. Do not consider organs, slices, phases, or boundary points as independent patients. If the analysis retains those repeated measurements, use a design and analysis that accounts for their dependence.

### Step 3. State the planning assumptions

Estimate the expected patient pass rate from case level pilot outcomes. The average HD95 of 1.8 mm in our introduction cannot supply this rate. Use pilot data that were not used to select the final model or acceptance threshold. Count failed outputs using the same rules planned for validation. Run several plausible scenarios because a small pilot can make performance look better than it is.

Here are the assumptions we will use in our example:

| Input                                                           | Example                                                                                            |
|-----------------------------------------------------------------|----------------------------------------------------------------------------------------------------|
| Case threshold                                                  | HD95 at most 3.0 mm, with required output produced                                                 |
| Minimum population pass rate, <span markdown="0">\(p_0\)</span> | 95%                                                                                                |
| Expected true pass rate, <span markdown="0">\(p_1\)</span>      | 98%                                                                                                |
| Confidence rule                                                 | One-sided 95% exact lower bound strictly above <span markdown="0">\(p_0\)</span>                   |
| Target power                                                    | 90%                                                                                                |
| Sampling model                                                  | Independent patients sampled from the intended population, with a common marginal pass probability |

We choose a one-sided bound because the question is whether the pass rate clears a floor. This is an example design choice. The lower end of a **two-sided 95% interval** uses a different tail probability and needs a new calculation. The code uses `alpha=0.05` for the one-sided rule. Use `alpha=0.025` for that two-sided lower endpoint.

For this binary endpoint, an exact binomial calculation avoids having to assume a normal or lognormal distribution for HD95. The distance threshold still matters. Changing 3.0 mm changes which patients pass and therefore changes the expected pass rate.

### Interactive HD95 sample size calculator 

You can use the calculator to plan a sample size or see what your current budget can support. Try our example first, then enter an expected pass rate supported by your pilot data.

<style>
#hd95-planner {
  --hd95-navy: #27387c;
  --hd95-orange: #c64028;
  --hd95-border: #d9dce8;
  --hd95-muted: #59616f;
  box-sizing: border-box;
  color-scheme: light;
  container-type: inline-size;
  container-name: hd95-planner;
  width: 100%;
  margin: 28px 0;
  padding: 24px;
  border: 1px solid var(--hd95-border);
  border-top: 4px solid var(--hd95-navy);
  border-radius: 10px;
  background: #f6f7fa;
  color: #303743;
  font: 16px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  scroll-margin-top: 6rem;
}
#hd95-planner *, #hd95-planner *::before, #hd95-planner *::after { box-sizing: border-box; }
#hd95-planner [hidden] { display: none !important; }
#hd95-planner p { margin: 0; }
#hd95-planner .hd95-eyebrow { color: var(--hd95-orange); font-size: 11px; font-weight: 750; letter-spacing: .09em; text-transform: uppercase; }
#hd95-planner .hd95-title { margin: 5px 0 10px; color: var(--hd95-navy); font-size: 25px; font-weight: 750; line-height: 1.25; }
#hd95-planner .hd95-intro { font-size: 14px; }
#hd95-planner fieldset { min-width: 0; margin: 22px 0 0; padding: 0; border: 0; }
#hd95-planner legend { width: 100%; margin-bottom: 12px; padding: 0; color: var(--hd95-navy); font-size: 16px; font-weight: 700; }
#hd95-planner .hd95-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 20px; }
#hd95-planner label { display: block; margin: 0 0 5px; color: #293451; font-size: 13px; font-weight: 650; }
#hd95-planner input, #hd95-planner select, #hd95-planner button { font: inherit; }
#hd95-planner input[type="number"], #hd95-planner select { display: block; width: 100%; height: 44px; margin: 0; padding: 8px 11px; border: 1px solid #acb4c7; border-radius: 5px; background: #fff; color: #26334d; font-size: 16px; }
#hd95-planner .hd95-percent-wrap { position: relative; }
#hd95-planner .hd95-percent-wrap input { padding-right: 45px; }
#hd95-planner .hd95-percent-wrap > span { position: absolute; top: 9px; right: 27px; color: var(--hd95-muted); pointer-events: none; }
#hd95-planner .hd95-help { margin-top: 5px; color: var(--hd95-muted); font-size: 12px; line-height: 1.45; }
#hd95-planner :is(input, select, button, summary):focus-visible { outline: 3px solid #c64028; outline-offset: 3px; }
#hd95-planner [aria-invalid="true"] { border: 2px solid #b42318; }
#hd95-planner .hd95-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }
#hd95-planner button { min-height: 44px; padding: 10px 16px; border: 1px solid transparent; border-radius: 5px; font-size: 14px; font-weight: 650; cursor: pointer; }
#hd95-planner .hd95-primary { background: var(--hd95-orange); color: #fff; }
#hd95-planner .hd95-secondary { border-color: #aab2c5; background: #fff; color: var(--hd95-navy); }
#hd95-planner button:hover { filter: brightness(.94); }
#hd95-planner button:disabled { opacity: .65; cursor: default; }
#hd95-planner .hd95-error { margin-top: 14px; padding: 12px; border-left: 3px solid #b42318; background: #fff1ef; color: #8c251c; font-size: 14px; }
#hd95-planner .hd95-status { min-height: 20px; margin-top: 9px; color: var(--hd95-muted); font-size: 12px; }
#hd95-planner .hd95-results { margin-top: 12px; padding-top: 20px; border-top: 1px solid var(--hd95-border); }
#hd95-planner .hd95-result-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
#hd95-planner .hd95-card { padding: 17px; border: 1px solid var(--hd95-border); border-radius: 6px; background: #fff; }
#hd95-planner .hd95-card-label { color: var(--hd95-muted); font-size: 12px; font-weight: 650; }
#hd95-planner .hd95-value { margin: 5px 0 8px; color: var(--hd95-navy); font-size: 34px; font-weight: 750; line-height: 1.1; letter-spacing: -.025em; }
#hd95-planner .hd95-value small { font-size: 14px; font-weight: 500; letter-spacing: 0; }
#hd95-planner .hd95-detail { font-size: 13px; line-height: 1.5; }
#hd95-planner .hd95-detail + .hd95-detail { margin-top: 8px; }
#hd95-planner .hd95-budget { margin-top: 14px; padding: 16px 17px; border-left: 3px solid var(--hd95-orange); background: #fff; }
#hd95-planner .hd95-section-title { margin: 0 0 8px; color: var(--hd95-navy); font-size: 16px; font-weight: 700; line-height: 1.35; }
#hd95-planner .hd95-budget p + p { margin-top: 7px; }
#hd95-planner .hd95-chart-wrap { margin-top: 22px; }
#hd95-planner .hd95-chart-key { display: flex; flex-wrap: wrap; gap: 5px 16px; margin: 8px 0 4px; font-size: 11px; color: var(--hd95-muted); }
#hd95-planner .hd95-chart-key span { display: inline-flex; gap: 5px; align-items: center; }
#hd95-planner .hd95-key-line { width: 17px; height: 0; border-top: 2px solid var(--hd95-navy); }
#hd95-planner .hd95-key-target { border-color: #616875; border-top-style: dashed; }
#hd95-planner .hd95-key-budget { border-color: var(--hd95-orange); }
#hd95-planner .hd95-chart { display: block; width: 100%; height: 235px; overflow: visible; background: #fff; border-radius: 5px; touch-action: pan-y; }
#hd95-planner .hd95-chart text { font-family: inherit; fill: #535e72; font-size: 11px; }
#hd95-planner .hd95-chart-inspect { margin: 10px 0 0; }
#hd95-planner .hd95-chart-inspect label { font-size: 12px; }
#hd95-planner input[type="range"] { display: block; width: 100%; margin: 3px 0 7px; accent-color: var(--hd95-navy); cursor: pointer; }
#hd95-planner .hd95-inspection { min-height: 40px; font-size: 12px; color: #34405c; }
#hd95-planner .hd95-method { margin-top: 18px; padding-top: 15px; border-top: 1px solid var(--hd95-border); font-size: 12px; color: var(--hd95-muted); }
#hd95-planner .hd95-method p + p { margin-top: 8px; }
#hd95-planner .hd95-download { margin-top: 14px; }
@container hd95-planner (max-width: 450px) {
  #hd95-planner .hd95-field-grid, #hd95-planner .hd95-result-grid { grid-template-columns: 1fr; }
}
@media (max-width: 520px) {
  #hd95-planner { padding: 17px 14px; }
  #hd95-planner .hd95-title { font-size: 22px; }
  #hd95-planner .hd95-field-grid, #hd95-planner .hd95-result-grid { grid-template-columns: 1fr; }
  #hd95-planner .hd95-actions button { flex: 1 1 auto; }
}
</style>
<section id="hd95-planner" class="not-prose" data-ready="false" aria-labelledby="hd95-planner-title">
  <p class="hd95-eyebrow">Interactive planning tool</p>
  <h3 id="hd95-planner-title" class="hd95-title">HD95 sample-size calculator</h3>
  <p class="hd95-intro">Start with a patient pass rule that you have justified clinically. For example, a patient passes if HD95 is at or below a specified distance. The article uses a hypothetical 3 mm limit. A distance alone cannot set the sample size.</p>
  <noscript><p class="hd95-error">Enable JavaScript to use the calculator. The worked examples and Python example below provide the same method.</p></noscript>
  <form id="hd95-form" novalidate>
    <fieldset>
      <legend>Your planning assumptions</legend>
      <div class="hd95-field-grid">
        <div>
          <label for="hd95-p0">Minimum acceptable patient pass rate</label>
          <div class="hd95-percent-wrap"><input id="hd95-p0" name="p0" type="number" value="95" min="0.001" max="99.999" step="any" inputmode="decimal" required aria-label="Minimum acceptable patient pass rate, percent" aria-describedby="hd95-p0-help"><span aria-hidden="true">%</span></div>
          <p id="hd95-p0-help" class="hd95-help">The exact lower confidence bound must be above this rate.</p>
        </div>
        <div>
          <label for="hd95-p1">Expected true patient pass rate</label>
          <div class="hd95-percent-wrap"><input id="hd95-p1" name="p1" type="number" value="98" min="0.001" max="100" step="any" inputmode="decimal" required aria-label="Expected true patient pass rate, percent" aria-describedby="hd95-p1-help"><span aria-hidden="true">%</span></div>
          <p id="hd95-p1-help" class="hd95-help">A planning assumption from pilot data. Must exceed the minimum.</p>
        </div>
        <div>
          <label for="hd95-target">Target power</label>
          <select id="hd95-target" name="targetPower" aria-describedby="hd95-target-help"><option value="0.8">80%</option><option value="0.9" selected>90%</option><option value="0.95">95%</option></select>
          <p id="hd95-target-help" class="hd95-help">Chance of meeting the study criterion if the expected rate is true.</p>
        </div>
        <div>
          <label for="hd95-confidence">Confidence level</label>
          <select id="hd95-confidence" name="confidence"><option value="0.9">90%</option><option value="0.95" selected>95%</option><option value="0.99">99%</option></select>
          <p class="hd95-help">Used with the confidence rule below.</p>
        </div>
        <div>
          <label for="hd95-rule">Confidence rule</label>
          <select id="hd95-rule" name="rule" aria-describedby="hd95-rule-help"><option value="one-sided" selected>One-sided lower bound</option><option value="two-sided">Lower end of two-sided interval</option></select>
          <p id="hd95-rule-help" class="hd95-help">The two-sided option assigns half the error probability to each tail.</p>
        </div>
        <div>
          <label for="hd95-budget">Available evaluable patients</label>
          <input id="hd95-budget" name="budget" type="number" value="250" min="1" max="10000" step="1" inputmode="numeric" required aria-describedby="hd95-budget-help">
          <p id="hd95-budget-help" class="hd95-help">An independent budget check. Enter 1 to 10,000 patients.</p>
        </div>
      </div>
    </fieldset>
    <div id="hd95-error" class="hd95-error" role="alert" hidden></div>
    <div class="hd95-actions"><button id="hd95-calculate" class="hd95-primary" type="submit">Calculate sample size</button><button class="hd95-secondary" type="reset">Reset defaults</button></div>
  </form>
  <p id="hd95-status" class="hd95-status" role="status" aria-live="polite"></p>
  <div id="hd95-results" class="hd95-results" hidden>
    <div class="hd95-result-grid">
      <div class="hd95-card">
        <p class="hd95-card-label">Minimum qualifying sample size</p>
        <p id="hd95-n" class="hd95-value"></p>
        <p id="hd95-n-detail" class="hd95-detail"></p>
      </div>
      <div class="hd95-card">
        <p class="hd95-card-label">Required patient passes</p>
        <p id="hd95-k" class="hd95-value"></p>
        <p id="hd95-k-detail" class="hd95-detail"></p>
        <p id="hd95-bound-detail" class="hd95-detail"></p>
      </div>
    </div>
    <div class="hd95-budget">
      <h4 id="hd95-budget-title" class="hd95-section-title">Your patient budget</h4>
      <p id="hd95-budget-power" class="hd95-detail"></p>
      <p id="hd95-budget-passes" class="hd95-detail"></p>
      <p id="hd95-budget-required" class="hd95-detail"></p>
    </div>
    <div class="hd95-chart-wrap">
      <h4 class="hd95-section-title">Power at each patient count</h4>
      <p id="hd95-chart-summary" class="hd95-help"></p>
      <div class="hd95-chart-key" aria-hidden="true"><span><i class="hd95-key-line"></i>Exact power</span><span><i class="hd95-key-line hd95-key-target"></i>Target</span><span><i class="hd95-key-line hd95-key-budget"></i>Budget</span></div>
      <svg id="hd95-chart" class="hd95-chart" role="img" aria-labelledby="hd95-chart-title hd95-chart-desc"><title id="hd95-chart-title">Exact binomial power by patient count</title><desc id="hd95-chart-desc"></desc></svg>
      <div class="hd95-chart-inspect"><label for="hd95-inspect">Inspect a patient count</label><input id="hd95-inspect" type="range" min="1" max="500" step="1" value="250" aria-describedby="hd95-inspection"><p id="hd95-inspection" class="hd95-inspection" aria-live="polite"></p></div>
      <p class="hd95-help">The jumps are real. Allowable failures change in whole numbers, so a larger sample can have lower power. Check the exact count you plan to use.</p>
    </div>
    <div class="hd95-method"><p id="hd95-assumptions"></p><p>Uses an exact Clopper-Pearson lower bound for one prespecified patient pass rule, with independent patients and a common pass probability. These are evaluable patient counts. Repeated scans, missing data, subgroup targets and multiple endpoints need a design-specific plan. A calculated sample size does not establish an FDA acceptance criterion.</p></div>
    <button id="hd95-download" class="hd95-secondary hd95-download" type="button">Download results as CSV</button>
  </div>
</section>
<script>
/* Exact binomial planning for one prespecified result per independent patient.
 * The study passes only when its one-sided Clopper-Pearson lower bound > p0.
 * No normal approximation, patient data, network access, or external packages.
 * UMD export supports the browser calculator and independent Node verification.
 */
(function (root, factory) {
  'use strict'
  if (typeof module === 'object' && module.exports) module.exports = factory()
  else root.HD95Calculator = factory()
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
  'use strict'

  const MAX_N = 10000
  const EPS = Number.EPSILON
  const TINY = 1e-300
  const LANCZOS = [
    676.5203681218851, -1259.1392167224028, 771.3234287776531,
    -176.6150291621406, 12.507343278686905, -0.13857109526572012,
    9.984369578019572e-6, 1.5056327351493116e-7
  ]

  function probability(value, name, closed) {
    if (!Number.isFinite(value) || (closed ? value < 0 || value > 1 : value <= 0 || value >= 1)) {
      throw new RangeError(name + (closed ? ' must be between 0 and 1' : ' must be greater than 0 and less than 1'))
    }
  }

  function count(value, name, minimum, maximum) {
    if (!Number.isInteger(value) || value < minimum || value > maximum) {
      throw new RangeError(name + ' must be an integer from ' + minimum + ' to ' + maximum)
    }
  }

  function logGamma(z) {
    let x = 0.99999999999980993
    const zm1 = z - 1
    for (let i = 0; i < LANCZOS.length; i += 1) x += LANCZOS[i] / (zm1 + i + 1)
    const t = zm1 + 7.5
    return 0.9189385332046727 + (zm1 + 0.5) * Math.log(t) - t + Math.log(x)
  }

  // Modified Lentz continued fraction for the regularized incomplete beta.
  // The symmetry branch keeps evaluation on the rapidly converging side.
  function betaFraction(a, b, x) {
    const qab = a + b
    const qap = a + 1
    const qam = a - 1
    let c = 1
    let d = 1 - qab * x / qap
    if (Math.abs(d) < TINY) d = TINY
    d = 1 / d
    let result = d
    for (let m = 1; m <= 10000; m += 1) {
      const m2 = 2 * m
      let aa = m * (b - m) * x / ((qam + m2) * (a + m2))
      d = 1 + aa * d
      if (Math.abs(d) < TINY) d = TINY
      c = 1 + aa / c
      if (Math.abs(c) < TINY) c = TINY
      d = 1 / d
      result *= d * c
      aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))
      d = 1 + aa * d
      if (Math.abs(d) < TINY) d = TINY
      c = 1 + aa / c
      if (Math.abs(c) < TINY) c = TINY
      d = 1 / d
      const delta = d * c
      result *= delta
      if (Math.abs(delta - 1) <= 4 * EPS) return result
    }
    throw new Error('The exact probability calculation did not converge')
  }

  function regularizedBeta(x, a, b) {
    if (x <= 0) return 0
    if (x >= 1) return 1
    const logFront = logGamma(a + b) - logGamma(a) - logGamma(b) + a * Math.log(x) + b * Math.log1p(-x)
    const front = Math.exp(logFront)
    const result = x < (a + 1) / (a + b + 2)
      ? front * betaFraction(a, b, x) / a
      : 1 - front * betaFraction(b, a, 1 - x) / b
    return Math.min(1, Math.max(0, result))
  }

  function tail(k, n, p) {
    if (k === 0) return 1
    if (k > n || p === 0) return 0
    if (p === 1) return 1
    if (k === n) return Math.exp(n * Math.log(p))
    if (k === 1) return -Math.expm1(n * Math.log1p(-p))
    return regularizedBeta(p, k, n - k + 1)
  }

  function binomialTail(k, n, p) {
    count(n, 'n', 0, MAX_N)
    count(k, 'k', 0, n + 1)
    probability(p, 'p', true)
    return tail(k, n, p)
  }

  function inverseTail(k, n, target) {
    if (n === 1) return target
    if (k === n) return Math.exp(Math.log(target) / n)
    if (k === 1) return -Math.expm1(Math.log1p(-target) / n)
    let lo = 0
    let hi = 1
    for (let step = 0; step < 64; step += 1) {
      const mid = lo + (hi - lo) / 2
      if (mid === lo || mid === hi) break
      if (tail(k, n, mid) < target) lo = mid
      else hi = mid
    }
    return lo + (hi - lo) / 2
  }

  function lowerBound(k, n, alpha) {
    count(n, 'n', 1, MAX_N)
    count(k, 'k', 0, n)
    probability(alpha, 'alpha', false)
    return k === 0 ? 0 : inverseTail(k, n, alpha)
  }

  function clearsFloor(k, n, p0, alpha) {
    if (k > n) return true
    const nullTail = tail(k, n, p0)
    // At a floating-point boundary, evaluate the stated strict bound rule
    // directly. In particular, equality with lowerBound(k,n,alpha) fails.
    if (Math.abs(nullTail - alpha) <= 1e-9 * alpha) {
      return inverseTail(k, n, alpha) > p0
    }
    return nullTail < alpha
  }

  function required(n, p0, alpha) {
    let lo = 1
    let hi = n + 1
    while (lo < hi) {
      const mid = Math.floor((lo + hi) / 2)
      if (clearsFloor(mid, n, p0, alpha)) hi = mid
      else lo = mid + 1
    }
    return lo
  }

  function requiredPasses(n, p0, alpha) {
    count(n, 'n', 1, MAX_N)
    probability(p0, 'p0', false)
    probability(alpha, 'alpha', false)
    return required(n, p0, alpha)
  }

  function validateOptions(options, planning) {
    if (!options || typeof options !== 'object') throw new TypeError('Provide calculator options')
    probability(options.p0, 'p0', false)
    probability(options.p1, 'p1', true)
    probability(options.alpha, 'alpha', false)
    if (planning && options.p1 <= options.p0) {
      throw new RangeError('The expected patient pass rate must exceed the minimum acceptable pass rate to plan a study')
    }
  }

  function scan(p0, p1, alpha, nMax, visit) {
    let k = 1
    for (let n = 1; n <= nMax; n += 1) {
      // The critical count can stay unchanged or increase by one as n grows.
      if (!clearsFloor(k, n, p0, alpha)) k += 1
      const power = tail(k, n, p1)
      const result = visit(n, k, power)
      if (result) return result
    }
    return null
  }

  function plan(options) {
    validateOptions(options, true)
    probability(options.targetPower, 'targetPower', false)
    const nMax = options.nMax === undefined ? MAX_N : options.nMax
    count(nMax, 'nMax', 1, MAX_N)
    return scan(options.p0, options.p1, options.alpha, nMax, function (n, k, power) {
      if (power >= options.targetPower) {
        return { n: n, k: k, power: power, lowerBound: inverseTail(k, n, options.alpha) }
      }
      return null
    })
  }

  function budget(options) {
    validateOptions(options, false)
    probability(options.targetPower, 'targetPower', false)
    count(options.n, 'n', 1, MAX_N)
    const n = options.n
    const k = required(n, options.p0, options.alpha)
    return {
      n: n,
      k: k,
      power: tail(k, n, options.p1),
      lowerBound: k > n ? null : inverseTail(k, n, options.alpha),
      requiredP1: k > n ? null : inverseTail(k, n, options.targetPower)
    }
  }

  function powerCurve(options) {
    validateOptions(options, false)
    count(options.nMax, 'nMax', 1, MAX_N)
    const points = []
    scan(options.p0, options.p1, options.alpha, options.nMax, function (n, k, power) {
      points.push({ n: n, power: power, k: k })
      return null
    })
    return points
  }

  return Object.freeze({
    MAX_N: MAX_N,
    binomialTail: binomialTail,
    requiredPasses: requiredPasses,
    lowerBound: lowerBound,
    plan: plan,
    budget: budget,
    powerCurve: powerCurve
  })
})

</script>
<script>
(function () {
  "use strict";

  var root = document.getElementById("hd95-planner");
  if (!root || root.dataset.initialized === "true") return;
  root.dataset.initialized = "true";
  var core = window.HD95Calculator;
  var MAX_N = 10000;
  var SVG_NS = "http://www.w3.org/2000/svg";
  var form = document.getElementById("hd95-form");
  var button = document.getElementById("hd95-calculate");
  var results = document.getElementById("hd95-results");
  var error = document.getElementById("hd95-error");
  var status = document.getElementById("hd95-status");
  var chart = document.getElementById("hd95-chart");
  var inspect = document.getElementById("hd95-inspect");
  var current = null;
  var chartLayout = null;
  var revision = 0;
  var integer = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });

  function el(id) { return document.getElementById(id); }
  function put(id, text) { el(id).textContent = text; }
  function count(n) { return integer.format(n); }
  function percent(p, digits) { return (100 * p).toFixed(digits === undefined ? 2 : digits) + "%"; }
  function exactNumber(p) { return Number(p.toPrecision(15)).toString(); }
  function boundPercent(bound, minimum) {
    var digits = 3;
    // Do not round a qualifying bound down to the displayed acceptance floor.
    while (digits < 12 && Number((100 * bound).toFixed(digits)) <= Number((100 * minimum).toFixed(digits))) digits += 1;
    return percent(bound, digits);
  }
  function powerPercent(power, target) {
    var digits = 2;
    // A below-target result must remain below the target after display rounding.
    while (digits < 12 && power < target && Number((100 * power).toFixed(digits)) >= 100 * target) digits += 1;
    return percent(power, digits);
  }
  function readNumber(id) { return el(id).value.trim() === "" ? NaN : Number(el(id).value); }
  function hideResults(message) {
    revision += 1;
    current = null;
    chartLayout = null;
    root.dataset.ready = "false";
    results.hidden = true;
    error.hidden = true;
    status.textContent = message || "";
    button.disabled = false;
    button.textContent = "Calculate sample size";
  }
  function showError(message, id) {
    hideResults("");
    error.textContent = message;
    error.hidden = false;
    if (id) {
      el(id).setAttribute("aria-invalid", "true");
      el(id).focus();
    }
  }
  function readInputs() {
    return {
      p0: readNumber("hd95-p0") / 100,
      p1: readNumber("hd95-p1") / 100,
      targetPower: readNumber("hd95-target"),
      confidence: readNumber("hd95-confidence"),
      rule: el("hd95-rule").value,
      n: readNumber("hd95-budget")
    };
  }
  function validate(input) {
    if (!Number.isFinite(input.p0) || input.p0 <= 0 || input.p0 >= 1) return ["Enter a minimum patient pass rate greater than 0% and less than 100%.", "hd95-p0"];
    if (!Number.isFinite(input.p1) || input.p1 <= input.p0 || input.p1 > 1) return ["Enter an expected true patient pass rate above the minimum and no greater than 100%.", "hd95-p1"];
    if ([0.8, 0.9, 0.95].indexOf(input.targetPower) === -1) return ["Choose a target power of 80%, 90% or 95%.", "hd95-target"];
    if ([0.9, 0.95, 0.99].indexOf(input.confidence) === -1) return ["Choose a confidence level of 90%, 95% or 99%.", "hd95-confidence"];
    if (["one-sided", "two-sided"].indexOf(input.rule) === -1) return ["Choose one of the two confidence rules.", "hd95-rule"];
    if (!Number.isInteger(input.n) || input.n < 1 || input.n > MAX_N) return ["Enter a whole patient budget from 1 to 10,000.", "hd95-budget"];
    return null;
  }
  function ruleName(input) {
    return percent(input.confidence, 0) + (input.rule === "one-sided" ? " one-sided lower confidence bound" : " two-sided confidence interval, using its lower end");
  }
  function svgNode(tag, attributes, text) {
    var node = document.createElementNS(SVG_NS, tag);
    Object.keys(attributes || {}).forEach(function (key) { node.setAttribute(key, attributes[key]); });
    if (text !== undefined) node.textContent = text;
    chart.appendChild(node);
    return node;
  }
  function inspectPoint(n) {
    if (!current || !chartLayout) return;
    n = Math.max(1, Math.min(current.curve.length, Math.round(n)));
    var point = current.curve[n - 1];
    inspect.value = n;
    inspect.setAttribute("aria-valuetext", count(n) + " patients, " + powerPercent(point.power, current.input.targetPower) + " power");
    put("hd95-inspection", count(n) + " patients: " + powerPercent(point.power, current.input.targetPower) + " power. " + (point.k > n ? "Even all patients passing cannot meet the confidence criterion." : "At least " + count(point.k) + " must pass, allowing " + count(n - point.k) + " failures."));
    var x = chartLayout.x(n);
    var y = chartLayout.y(point.power);
    chartLayout.cursor.setAttribute("x1", x);
    chartLayout.cursor.setAttribute("x2", x);
    chartLayout.dot.setAttribute("cx", x);
    chartLayout.dot.setAttribute("cy", y);
  }
  function renderChart() {
    if (!current || results.hidden) return;
    var input = current.input;
    var curve = current.curve;
    var width = Math.max(240, Math.round(chart.getBoundingClientRect().width));
    var height = 235;
    var pad = { left: 39, right: 15, top: 16, bottom: 38 };
    var plotWidth = width - pad.left - pad.right;
    var plotHeight = height - pad.top - pad.bottom;
    function x(n) { return pad.left + (n - 1) / Math.max(1, curve.length - 1) * plotWidth; }
    function y(p) { return pad.top + (1 - p) * plotHeight; }
    var savedN = Number(inspect.value);
    chart.replaceChildren();
    chart.setAttribute("viewBox", "0 0 " + width + " " + height);
    svgNode("title", { id: "hd95-chart-title" }, "Exact binomial power by patient count");
    svgNode("desc", { id: "hd95-chart-desc" }, "Power at every integer count from 1 to " + count(curve.length) + ". The dashed horizontal line shows the " + percent(input.targetPower, 0) + " target. The orange vertical line shows the budget of " + count(input.n) + " patients, with " + powerPercent(current.budget.power, input.targetPower) + " power. Use the patient-count slider after this chart for the exact values.");
    [0, 0.25, 0.5, 0.75, 1].forEach(function (tick) {
      svgNode("line", { x1: pad.left, x2: width - pad.right, y1: y(tick), y2: y(tick), stroke: "#e1e5ef", "stroke-width": 1 });
      svgNode("text", { x: pad.left - 7, y: y(tick) + 4, "text-anchor": "end" }, (100 * tick) + "%");
    });
    var tickCount = width < 400 ? 3 : 5;
    for (var t = 0; t < tickCount; t += 1) {
      var n = Math.round(1 + (curve.length - 1) * t / (tickCount - 1));
      svgNode("text", { x: x(n), y: height - 20, "text-anchor": t === tickCount - 1 ? "end" : t === 0 ? "start" : "middle" }, count(n));
    }
    svgNode("text", { x: pad.left + plotWidth / 2, y: height - 3, "text-anchor": "middle" }, "Evaluable patients");
    svgNode("line", { x1: pad.left, x2: width - pad.right, y1: y(input.targetPower), y2: y(input.targetPower), stroke: "#616875", "stroke-width": 1.4, "stroke-dasharray": "5 4" });
    svgNode("line", { x1: x(input.n), x2: x(input.n), y1: pad.top, y2: height - pad.bottom, stroke: "#c64028", "stroke-width": 1.5 });
    var path = curve.map(function (point, i) { return (i === 0 ? "M" : "L") + x(point.n).toFixed(2) + "," + y(point.power).toFixed(2); }).join(" ");
    svgNode("path", { d: path, fill: "none", stroke: "#27387c", "stroke-width": 1.6, "stroke-linejoin": "round" });
    var cursor = svgNode("line", { x1: 0, x2: 0, y1: pad.top, y2: height - pad.bottom, stroke: "#9ba5bb", "stroke-width": 1, "stroke-dasharray": "2 4" });
    var dot = svgNode("circle", { cx: 0, cy: 0, r: 4, fill: "#27387c", stroke: "#fff", "stroke-width": 1.5 });
    chartLayout = { x: x, y: y, cursor: cursor, dot: dot, left: pad.left, plotWidth: plotWidth, width: width };
    inspectPoint(savedN);
  }
  function render(result) {
    var input = result.input;
    var plan = result.plan;
    var budget = result.budget;
    if (plan) {
      put("hd95-n", count(plan.n));
      put("hd95-n-detail", "Evaluable patients. Exact power is " + powerPercent(plan.power, input.targetPower) + " at an expected true pass rate of " + percent(input.p1, 2) + ".");
      put("hd95-k", count(plan.k) + " of " + count(plan.n));
      put("hd95-k-detail", "At most " + count(plan.n - plan.k) + " patient failures.");
      put("hd95-bound-detail", "With exactly " + count(plan.k) + " passes, the lower confidence bound is approximately " + boundPercent(plan.lowerBound, input.p0) + ".");
    } else {
      put("hd95-n", "None found");
      put("hd95-n-detail", "No sample size from 1 to 10,000 meets the target power under these assumptions. The search stops at 10,000.");
      put("hd95-k", "No plan");
      put("hd95-k-detail", "A larger study or different planning assumptions need further evaluation.");
      put("hd95-bound-detail", "The budget result below still uses your selected patient count.");
    }
    put("hd95-budget-title", "With your budget of " + count(input.n) + " patients");
    put("hd95-budget-power", "Exact power is " + powerPercent(budget.power, input.targetPower) + " at the expected true pass rate of " + percent(input.p1, 2) + ". This " + (budget.power >= input.targetPower ? "meets" : "is below") + " the " + percent(input.targetPower, 0) + " target.");
    if (budget.k > input.n) {
      put("hd95-budget-passes", "Even if every patient passes, this count cannot put the lower confidence bound above " + percent(input.p0, 2) + ".");
      put("hd95-budget-required", "No true pass rate, including 100%, can achieve the target power at this budget.");
    } else {
      put("hd95-budget-passes", "At least " + count(budget.k) + " of " + count(input.n) + " must pass, allowing " + count(input.n - budget.k) + " failures. The lower bound at that cutoff is approximately " + boundPercent(budget.lowerBound, input.p0) + ".");
      var roundedP1 = Math.ceil(budget.requiredP1 * 10000) / 10000;
      put("hd95-budget-required", "To reach " + percent(input.targetPower, 0) + " power at this budget, the true patient pass rate would need to be at least about " + percent(roundedP1, 2) + " (rounded up to 0.01 percentage point). This is a sensitivity analysis, not evidence of model performance.");
    }
    put("hd95-chart-summary", "Showing every integer sample size from 1 to " + count(result.curve.length) + ". Hover or use the slider to inspect exact values.");
    put("hd95-assumptions", "Minimum acceptable pass rate: " + exactNumber(100 * input.p0) + "%. Expected true pass rate: " + exactNumber(100 * input.p1) + "%. Target power: " + percent(input.targetPower, 0) + ". Confidence rule: " + ruleName(input) + ". Tail probability: " + exactNumber(input.alpha) + ". The study passes only when the lower bound is strictly above the minimum. The search returns the first qualifying count through 10,000, not a guarantee for all larger counts.");
    results.hidden = false;
    root.dataset.ready = "true";
    inspect.max = result.curve.length;
    inspect.value = input.n;
    renderChart();
    status.textContent = plan ? "Updated. The minimum qualifying sample size is " + count(plan.n) + " evaluable patients." : "Updated. No qualifying sample size found through 10,000. The budget analysis is available.";
  }
  function calculate() {
    form.querySelectorAll("[aria-invalid]").forEach(function (node) { node.removeAttribute("aria-invalid"); });
    hideResults("");
    var input = readInputs();
    var invalid = validate(input);
    if (invalid) { showError(invalid[0], invalid[1]); return; }
    if (!core) { showError("The calculator could not load. Reload this page or use the Python example below."); return; }
    input.alpha = (1 - input.confidence) / (input.rule === "two-sided" ? 2 : 1);
    // Normalize decimal selector values before exact boundary comparisons.
    input.alpha = Number(input.alpha.toPrecision(12));
    var thisRevision = revision;
    button.disabled = true;
    button.textContent = "Calculating...";
    status.textContent = "Calculating exact binomial power...";
    window.setTimeout(function () {
      if (thisRevision !== revision) return;
      try {
        var plan = core.plan({ p0: input.p0, p1: input.p1, targetPower: input.targetPower, alpha: input.alpha, nMax: MAX_N });
        var budget = core.budget({ n: input.n, p0: input.p0, p1: input.p1, targetPower: input.targetPower, alpha: input.alpha });
        var curveMax = Math.min(MAX_N, Math.max(120, Math.ceil(Math.max(input.n, plan ? plan.n : MAX_N) * 1.2)));
        var curve = core.powerCurve({ p0: input.p0, p1: input.p1, alpha: input.alpha, nMax: curveMax });
        current = { input: input, plan: plan, budget: budget, curve: curve };
        render(current);
      } catch (failure) {
        showError("The calculation could not finish for these inputs. Check the values or use the Python example below for further analysis.");
        if (window.console && window.console.error) window.console.error("HD95 calculator:", failure);
      } finally {
        button.disabled = false;
        button.textContent = "Calculate sample size";
      }
    }, 0);
  }
  function download() {
    if (!current) return;
    var input = current.input;
    var plan = current.plan;
    var budget = current.budget;
    var rows = [
      ["HD95 patient pass-rate planning summary", "Value"],
      ["Minimum acceptable patient pass probability", input.p0],
      ["Expected true patient pass probability", input.p1],
      ["Target power", input.targetPower],
      ["Confidence level", input.confidence],
      ["Confidence rule", input.rule],
      ["Lower-tail probability", input.alpha],
      ["Acceptance comparison", "Exact lower confidence bound strictly above minimum acceptable pass probability"],
      ["Search maximum evaluable patients", MAX_N],
      ["Minimum qualifying evaluable patients", plan ? plan.n : "No qualifying count through 10000"],
      ["Required patient passes at minimum N", plan ? plan.k : "Not available"],
      ["Maximum patient failures at minimum N", plan ? plan.n - plan.k : "Not available"],
      ["Exact power at minimum N", plan ? plan.power : "Not available"],
      ["Exact lower bound at minimum-N pass cutoff", plan ? plan.lowerBound : "Not available"],
      ["Budget evaluable patients", input.n],
      ["Required patient passes at budget", budget.k <= input.n ? budget.k : "Impossible even with all patients passing"],
      ["Maximum patient failures at budget", budget.k <= input.n ? input.n - budget.k : "Not available"],
      ["Exact power at budget", budget.power],
      ["Exact lower bound at budget pass cutoff", budget.lowerBound === null ? "Not available" : budget.lowerBound],
      ["Required true pass probability for target power at budget", budget.requiredP1 === null ? "Impossible at this budget" : budget.requiredP1],
      ["Assumptions", "One prespecified binary patient pass rule. Independent evaluable patients with a common pass probability. No adjustment for repeated scans, missing data, subgroups or multiple endpoints."],
      ["HD95 distance limit", "Not estimated by this tool. The article's 3 mm example is hypothetical and needs clinical justification."],
      ["Interpretation", "Conditional planning results. Exact power can decrease at a larger integer N. A calculated sample size does not establish an FDA acceptance criterion."],
      [],
      ["Curve evaluable patients", "Exact power", "Required patient passes"]
    ];
    current.curve.forEach(function (point) { rows.push([point.n, point.power, point.k > point.n ? "Impossible" : point.k]); });
    var csv = rows.map(function (row) { return row.map(function (cell) { return "\"" + String(cell).replace(/\"/g, "\"\"") + "\""; }).join(","); }).join("\r\n");
    var blob = new Blob([csv + "\r\n"], { type: "text/csv;charset=utf-8" });
    var url = URL.createObjectURL(blob);
    var link = document.createElement("a");
    link.href = url;
    link.download = "hd95-sample-size-planning.csv";
    root.appendChild(link);
    link.click();
    link.remove();
    window.setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
  }
  form.addEventListener("submit", function (event) { event.preventDefault(); calculate(); });
  form.addEventListener("input", function (event) {
    event.target.removeAttribute("aria-invalid");
    hideResults("Assumptions changed. Calculate again to update the results.");
  });
  form.addEventListener("change", function (event) {
    event.target.removeAttribute("aria-invalid");
    hideResults("Assumptions changed. Calculate again to update the results.");
  });
  form.addEventListener("reset", function () {
    hideResults("Restoring default assumptions...");
    window.setTimeout(calculate, 0);
  });
  inspect.addEventListener("input", function () { inspectPoint(Number(inspect.value)); });
  chart.addEventListener("pointermove", function (event) {
    if (!chartLayout || !current || event.pointerType === "touch") return;
    var rect = chart.getBoundingClientRect();
    var x = (event.clientX - rect.left) / rect.width * chartLayout.width;
    inspectPoint(1 + (x - chartLayout.left) / chartLayout.plotWidth * (current.curve.length - 1));
  });
  chart.addEventListener("click", function (event) {
    if (!chartLayout || !current) return;
    var rect = chart.getBoundingClientRect();
    var x = (event.clientX - rect.left) / rect.width * chartLayout.width;
    inspectPoint(1 + (x - chartLayout.left) / chartLayout.plotWidth * (current.curve.length - 1));
  });
  el("hd95-download").addEventListener("click", download);
  if (window.ResizeObserver) {
    var lastWidth = 0;
    new ResizeObserver(function () {
      var width = Math.round(chart.getBoundingClientRect().width);
      if (current && width > 0 && width !== lastWidth) { lastWidth = width; renderChart(); }
    }).observe(chart);
  } else {
    window.addEventListener("resize", renderChart);
  }
  calculate();
}());

</script>

The default result is **311 patients** for at least 90% power. A budget of **250 patients** gives about **76.4% power** under the same assumptions. Try changing the expected pass rate from 98% to 97%. The required sample size increases to **832 patients**.

The calculator uses the same exact binomial rule as the Python example. An HD95 limit alone cannot supply the expected pass rate. If you change the clinical limit, estimate that rate again from individual pilot outcomes. The power curve shows why you should check the exact patient count you plan to use, even when you round a sample size result upward.

### Step 4. Calculate sample size and check it by simulation

For each proposed sample size <span markdown="0">\(N\)</span>, find the smallest number of passing patients <span markdown="0">\(k\)</span> that clears the confidence rule. Then calculate the probability of getting at least <span markdown="0">\(k\)</span> passes if the true pass rate is <span markdown="0">\(p_1\)</span>.

$$X\sim\operatorname{Binomial}(N,p_1), \qquad \text{power}=P(X\ge k).$$

We use SciPy\'s [binomial distribution](https://docs.scipy.org/doc/scipy-1.13.1/reference/generated/scipy.stats.binom.html) and [exact Clopper-Pearson interval](https://docs.scipy.org/doc/scipy-1.13.1/reference/generated/scipy.stats._result_classes.BinomTestResult.proportion_ci.html).

The example was tested with Python 3.9.6, NumPy 2.0.2, and SciPy 1.13.1.

``` python
from scipy.stats import beta, binom


def lower_bound(k, n, alpha=0.05):
    """One-sided exact lower confidence bound at confidence 1 - alpha."""
    if n < 1 or int(n) != n or int(k) != k or not 0 <= k <= n:
        raise ValueError("Require integer counts with 0 <= k <= n and n >= 1")
    if not 0 < alpha < 1:
        raise ValueError("Require 0 < alpha < 1")
    return 0.0 if k == 0 else float(beta.ppf(alpha, k, n - k + 1))


def required_passes(n, p0, alpha=0.05):
    """Return n + 1 if the study cannot pass, even with zero failures."""
    lower_bound(0, n, alpha)
    if not 0 < p0 < 1:
        raise ValueError("Require 0 < p0 < 1")
    k = int(binom.isf(alpha, n, p0)) + 1
    # Check the strict confidence-bound rule at the discrete boundary.
    while k <= n and lower_bound(k, n, alpha) <= p0:
        k += 1
    while k > 1 and lower_bound(k - 1, n, alpha) > p0:
        k -= 1
    return k


def plan(p0, p1, target_power=0.90, alpha=0.05, n_max=10_000):
    """Find the smallest fixed n whose exact power meets the target."""
    if not 0 < p0 < p1 <= 1 or not 0 < target_power < 1:
        raise ValueError("Require 0 < p0 < p1 <= 1 and 0 < target_power < 1")
    if not isinstance(n_max, int) or n_max < 1:
        raise ValueError("n_max must be a positive integer")
    for n in range(1, n_max + 1):
        k = required_passes(n, p0, alpha)
        power = float(binom.sf(k - 1, n, p1))
        if power >= target_power:
            return n, k, power
    raise ValueError("No qualifying sample size found through n_max")

import numpy as np

n, k, power = plan(p0=0.95, p1=0.98)
rng = np.random.default_rng(20260907)
trials = rng.binomial(n, 0.98, size=100_000)
simulated_power = np.mean(trials >= k)

print(f"Patients: {n}, required passes: {k}")
print(f"Exact power: {power:.4%}")
print(f"Simulated power: {simulated_power:.4%}")
print(f"Lower bound at {k} passes: {lower_bound(k, n):.6%}")
```

Output:

    Patients: 311, required passes: 302
    Exact power: 90.2247%
    Simulated power: 90.2270%
    Lower bound at 302 passes: 95.004496%

For the worked scenario, the smallest sample size reaching 90% power is **311 independent patients**. It requires at least **302 passes**, allowing at most **9 failures**. Exact power is **90.22%**. At 302 passes, the one-sided lower bound is approximately **95.0045%**, just above the 95% floor.

<figure>
  <img src="/img/articles/HD95_for_Medical_Image_Segmentation_How_to_set_FDA_acceptance_criteria_and_sample_size-3d7bd5b7a75480638821e9dad32943f7.png">
  <figcaption>
    <strong>Figure 3.</strong> For this hypothetical study of 311 patients,
    the one-sided 95% exact lower confidence bound first exceeds the 95%
    floor at 302 passes. With 301 passes, the bound is 94.6070%. With 302
    passes, it is 95.0045%. The magnified view shows that the margin above
    the floor is only 0.0045 percentage points. These are illustrative
    criteria, not FDA cutoffs.
  </figcaption>
</figure>

The simulation checks the probability of passing the same rule. It does not validate the clinical threshold or prove that the assumed 98% rate is realistic.

### Results and takeaways

The table below keeps the minimum population pass rate at 95% and the confidence rule unchanged. We check sample sizes in order and stop at the first that reaches the target, with a search limit of 10,000.

| Expected true patient pass rate | Patients for at least 80% power | Patients for at least 90% power |
|---------------------------------|---------------------------------|---------------------------------|
| 96%                             | 2,698                           | 3,701                           |
| 97%                             | 601                             | 832                             |
| 98%                             | 234                             | 311                             |
| 99%                             | 124                             | 153                             |

The gap between expected performance and the acceptance floor drives the cost. If the true pass rate is only 96%, a study designed to clear 95% needs thousands of patients. At a true rate of 95% or below, this test\'s chance of passing is at most 5%. Increasing the sample size cannot turn that into 80% or 90% power.

<figure>
  <img src="/img/articles/HD95_for_Medical_Image_Segmentation_How_to_set_FDA_acceptance_criteria_and_sample_size-3d7bd5b7a75480e78f7bd2766973a04e.png">
  <figcaption>
    <strong>Figure 4.</strong> Exact power under the stated one-sided 95%
    confidence rule. A one percentage point change in expected performance,
    from 98% to 97%, moves the first sample size reaching 90% power from 311
    to 832. The dips reflect changes in the whole-number pass count.
  </figcaption>
</figure>

**Recalculate power at the exact sample size you plan to use.** The required pass count changes in whole patients, so exact power has small rises and drops. In this example, a study of 234 patients reaches 80% power, but rounding up to 250 gives only 76.4%. The table does not promise that every larger sample size meets the same power target.

### What if the budget is 250 patients? 

At <span markdown="0">\(N=250\)</span>, the study needs at least **244 passes**, so it can tolerate **6 failures**. Under the expected 98% true pass rate, the chance of achieving this is **76.4%**.

We can also work backward. What true patient pass rate would give us the target power with this budget?

| Target power with 250 patients | Required true patient pass rate, approximately |
|--------------------------------|------------------------------------------------|
| 80%                            | 98.102%                                        |
| 90%                            | 98.435%                                        |

With 250 patients, 90% power requires a true patient pass rate of about 98.435% under this study rule. Use independent pilot data to assess whether that assumption is plausible, and check lower rates because pilot estimates are uncertain. This is not a required observed pilot score or a rule for finalizing the model. At an assumed 98% pass rate, plan 311 evaluable patients for 90% power. If your budget remains 250, assess whether better model performance is realistic or revisit the intended claim with a clinical rationale. Keep the validation population representative. Do not relax the HD95 limit just to fit the budget.

For 311 analyzable patients and 10% expected loss from unusable reference data, <span markdown="0">\(\lceil311/0.90\rceil=346\)</span> is an expected yield collection estimate. It does not guarantee 311 usable references. Plan how to fill the prespecified analysis set without looking at pass results. Device failures remain in that set. Do not keep adding patients until the confidence bound passes.

### What belongs in the validation protocol? 

Before testing, connect the intended use to the clinical limit, reference standard, exact metric implementation, case rule, confidence method, and sample size assumptions. Freeze the device version, preprocessing, and any postprocessing. State whether the endpoint measures the raw device output or a contour corrected by a clinician. Evaluating corrected contours alone does not establish the accuracy of the raw device output.

Set the dataset composition to reflect the intended population, scanners, acquisition settings, anatomy, and relevant difficult cases. FDA\'s [Good Machine Learning Practice page](https://www.fda.gov/medical-devices/software-medical-device-samd/good-machine-learning-practice-medical-device-development-guiding-principles) points to principles covering representative data, independent training and testing, suitable reference standards, and clinically meaningful goals.

The overall sample size does not guarantee enough patients in each subgroup. Fixed site quotas, repeated measurements, or separate claims for multiple structures may need another analysis. If several primary endpoints must all pass, plan their joint success probability. The 90% power above covers only the stated patient pass rate endpoint.

HD95 and clinically significant failures should be reported along with the pass rate. Report individual HD95 values alongside the pass rate to show how far failed cases exceed the limit and whether failures cluster by anatomy or acquisition setting.

### Frequently asked questions

#### Is HD95 below 5 mm acceptable to FDA? 

There is no universal 5 mm rule in the FDA guidance discussed here. The answer may depend on the anatomy, the design purpose, the clinical effects, the reference standard, and the endpoint. A cutoff in another device\'s summary is supporting context, not automatic justification.

#### Is Dice sufficient if it is already high? 

Dice measures overlap. HD95 adds boundary information. Figure 2 shows that even high Dice and low HD95 can coexist with an extra region. Choose additional checks for the failures that affect the clinical task.

#### Can 59 patient with no failures set a 95% pass rate floor? 

For this one-sided 95% exact rule, 59 passes out of 59 clears the 95% floor. But if the true pass rate is 98%, the probability of that outcome is only <span markdown="0">\(0.98^{59}\)</span>, about **30.4%**. A result that can pass is not the same as a study planned with 90% power.

### From an HD95 result to a clearance plan

Your engineers, clinical reviewers, and regulatory team need a validation plan they can work with. It should explain what counts as a patient pass, why that result is clinically acceptable, and how the study supports the device\'s claims.

Through our [end-to-end FDA clearance service](https://innolitics.com/services/end-to-end-fda-clearance/), we help with medical imaging software, reference data, validation, submission preparation, and FDA review. Bring your intended use, pilot results, annotation plan, and available patient count so we can work through a clearance plan with you.

For other endpoints, see our guides to [Bland-Altman sample size](https://innolitics.com/articles/bland-altman-sample-size-guide/), [AUC study planning](https://innolitics.com/articles/how-many-cases-to-power-an-auc-analysis/), and [sensitivity and specificity sample size](https://innolitics.com/articles/what-fda-reviewers-will-ask-about-your-sensitivity-specificity-sample-size/).

### Sources

1.  [FDA. Technical Performance Assessment of Quantitative Imaging in Radiological Device Premarket Submissions](https://www.fda.gov/media/123271/download). Final guidance, June 2022, pages 11 to 13. Supports the planning framework, not a numerical HD95 rule.
2.  [Podobnik and Vrtovec. HDilemma: Are Open-Source Hausdorff Distance Implementations Equivalent?](https://papers.miccai.org/miccai-2024/paper/2469_paper.pdf) MICCAI 2024. Explains implementation differences.
3.  [ART-Plan K234068 510(k) summary](https://www.accessdata.fda.gov/cdrh_docs/pdf23/K234068.pdf). PDF pages 8 and 9. Product-specific mean HD95 criterion and supporting assessments.
4.  [Neosoma Brain Mets K252922 510(k) summary](https://www.accessdata.fda.gov/cdrh_docs/pdf25/K252922.pdf). PDF pages 12 and 13. HD95 for true-positive lesions and separate detection endpoints.
5.  [FDA. Requests for Feedback and Meetings for Medical Device Submissions: The Q-Submission Program](https://www.fda.gov/media/114034/download). Final guidance, May 2025. Describes voluntary feedback on a concrete proposed study.
6.  [SciPy 1.13.1 binomial distribution](https://docs.scipy.org/doc/scipy-1.13.1/reference/generated/scipy.stats.binom.html) and [exact proportion confidence intervals](https://docs.scipy.org/doc/scipy-1.13.1/reference/generated/scipy.stats._result_classes.BinomTestResult.proportion_ci.html). Methods used in the numerical example.
