///|
/// One COCO-style summary value. -1 means there are no eligible ground truths.
pub struct Metrics {
  ap : Double
  ap50 : Double
  ap75 : Double
  ap_small : Double
  ap_medium : Double
  ap_large : Double
  ar1 : Double
  ar10 : Double
  ar100 : Double
  ar_small : Double
  ar_medium : Double
  ar_large : Double
} derive(Debug, ToJson)

///|
pub extend Metrics with Debug::{to_repr}

///|
pub extend Metrics with ToJson::{to_json}

///|
pub fn Metrics::ap(self : Metrics) -> Double {
  self.ap
}

///|
pub fn Metrics::ap50(self : Metrics) -> Double {
  self.ap50
}

///|
pub fn Metrics::ap75(self : Metrics) -> Double {
  self.ap75
}

///|
pub fn Metrics::ap_small(self : Metrics) -> Double {
  self.ap_small
}

///|
pub fn Metrics::ap_medium(self : Metrics) -> Double {
  self.ap_medium
}

///|
pub fn Metrics::ap_large(self : Metrics) -> Double {
  self.ap_large
}

///|
pub fn Metrics::ar1(self : Metrics) -> Double {
  self.ar1
}

///|
pub fn Metrics::ar10(self : Metrics) -> Double {
  self.ar10
}

///|
pub fn Metrics::ar100(self : Metrics) -> Double {
  self.ar100
}

///|
pub fn Metrics::ar_small(self : Metrics) -> Double {
  self.ar_small
}

///|
pub fn Metrics::ar_medium(self : Metrics) -> Double {
  self.ar_medium
}

///|
pub fn Metrics::ar_large(self : Metrics) -> Double {
  self.ar_large
}

///|
pub struct ClassMetrics {
  category_id : Int
  metrics : Metrics
} derive(ToJson)

///|
pub extend ClassMetrics with ToJson::{to_json}

///|
pub fn ClassMetrics::category_id(self : ClassMetrics) -> Int {
  self.category_id
}

///|
pub fn ClassMetrics::metrics(self : ClassMetrics) -> Metrics {
  self.metrics
}

///|
pub struct EvaluationReport {
  schema_version : String
  evaluator_version : String
  config : EvalConfig
  counts : InputCounts
  summary : Metrics
  classes : Array[ClassMetrics]
  diagnostics : Array[MatchDiagnostic]
  image_summaries : Array[ImageSummary]
  recall_thresholds : Array[Double]
  pr_curves : Array[PrecisionRecallCurve]
  quality_warnings : Array[QualityWarning]
} derive(ToJson)

///|
/// Input sizes and detections retained by the all-area maxDets=100 evaluation.
pub struct InputCounts {
  images : Int
  categories : Int
  ground_truths : Int
  detections : Int
  evaluated_detections : Int
} derive(ToJson)

///|
pub extend InputCounts with ToJson::{to_json}

///|
pub fn InputCounts::images(self : InputCounts) -> Int {
  self.images
}

///|
pub fn InputCounts::categories(self : InputCounts) -> Int {
  self.categories
}

///|
pub fn InputCounts::ground_truths(self : InputCounts) -> Int {
  self.ground_truths
}

///|
pub fn InputCounts::detections(self : InputCounts) -> Int {
  self.detections
}

///|
pub fn InputCounts::evaluated_detections(self : InputCounts) -> Int {
  self.evaluated_detections
}

///|
/// A nonfatal spatial input warning; scoring uses the original box unchanged.
pub struct QualityWarning {
  source : String
  record_id : Int64
  image_id : Int
  category_id : Int
  kind : String
}

///|
pub impl ToJson for QualityWarning with fn to_json(self) {
  Json::object({
    "source": @json.to_json(self.source),
    "record_id": Json::number(
      self.record_id.to_double(),
      repr=self.record_id.to_string(),
    ),
    "image_id": @json.to_json(self.image_id),
    "category_id": @json.to_json(self.category_id),
    "kind": @json.to_json(self.kind),
  })
}

///|
pub extend QualityWarning with ToJson::{to_json}

///|
pub fn QualityWarning::source(self : QualityWarning) -> String {
  self.source
}

///|
pub fn QualityWarning::record_id(self : QualityWarning) -> Int64 {
  self.record_id
}

///|
pub fn QualityWarning::image_id(self : QualityWarning) -> Int {
  self.image_id
}

///|
pub fn QualityWarning::category_id(self : QualityWarning) -> Int {
  self.category_id
}

///|
pub fn QualityWarning::kind(self : QualityWarning) -> String {
  self.kind
}

///|
pub extend EvaluationReport with ToJson::{to_json}

///|
pub fn EvaluationReport::to_json_string(self : EvaluationReport) -> String {
  @json.to_json(self).stringify()
}

///|
pub fn EvaluationReport::summary(self : EvaluationReport) -> Metrics {
  self.summary
}

///|
pub fn EvaluationReport::config(self : EvaluationReport) -> EvalConfig {
  self.config
}

///|
pub fn EvaluationReport::counts(self : EvaluationReport) -> InputCounts {
  self.counts
}

///|
pub fn EvaluationReport::evaluator_version(self : EvaluationReport) -> String {
  self.evaluator_version
}

///|
pub fn EvaluationReport::quality_warnings(
  self : EvaluationReport,
) -> Array[QualityWarning] {
  self.quality_warnings.copy()
}

///|
pub fn EvaluationReport::classes(
  self : EvaluationReport,
) -> Array[ClassMetrics] {
  self.classes.copy()
}

///|
/// Per-image decisions at IoU=0.50, all areas, maxDets=100 per category.
pub struct ImageSummary {
  image_id : Int
  tp : Int
  fp : Int
  fn_count : Int
  ignored : Int
} derive(ToJson)

///|
pub extend ImageSummary with ToJson::{to_json}

///|
pub fn ImageSummary::image_id(self : ImageSummary) -> Int {
  self.image_id
}

///|
pub fn ImageSummary::tp(self : ImageSummary) -> Int {
  self.tp
}

///|
pub fn ImageSummary::fp(self : ImageSummary) -> Int {
  self.fp
}

///|
pub fn ImageSummary::fn_count(self : ImageSummary) -> Int {
  self.fn_count
}

///|
pub fn ImageSummary::ignored(self : ImageSummary) -> Int {
  self.ignored
}

///|
pub fn EvaluationReport::image_summaries(
  self : EvaluationReport,
) -> Array[ImageSummary] {
  self.image_summaries.copy()
}

///|
/// A 101-point COCO precision envelope at one category and IoU threshold.
/// A value of -1 means this category has no eligible ground truth.
pub struct PrecisionRecallCurve {
  category_id : Int
  iou_threshold : Double
  precision : Array[Double]
  max_recall : Double
  ap : Double
} derive(ToJson)

///|
pub extend PrecisionRecallCurve with ToJson::{to_json}

///|
pub fn PrecisionRecallCurve::category_id(self : PrecisionRecallCurve) -> Int {
  self.category_id
}

///|
pub fn PrecisionRecallCurve::iou_threshold(
  self : PrecisionRecallCurve,
) -> Double {
  self.iou_threshold
}

///|
pub fn PrecisionRecallCurve::precision(
  self : PrecisionRecallCurve,
) -> Array[Double] {
  self.precision.copy()
}

///|
pub fn PrecisionRecallCurve::max_recall(self : PrecisionRecallCurve) -> Double {
  self.max_recall
}

///|
pub fn PrecisionRecallCurve::ap(self : PrecisionRecallCurve) -> Double {
  self.ap
}

///|
pub fn EvaluationReport::recall_thresholds(
  self : EvaluationReport,
) -> Array[Double] {
  self.recall_thresholds.copy()
}

///|
pub fn EvaluationReport::pr_curves(
  self : EvaluationReport,
) -> Array[PrecisionRecallCurve] {
  self.pr_curves.copy()
}

///|
/// One detection decision, or one unmatched eligible ground truth, at an IoU threshold.
/// Diagnostics use the all-area range and maxDets=100.
pub struct MatchDiagnostic {
  iou_threshold : Double
  image_id : Int
  category_id : Int
  detection_id : Int?
  ground_truth_id : Int64?
  iou : Double
  status : String
  reason : String
}

///|
pub impl ToJson for MatchDiagnostic with fn to_json(self) {
  Json::object({
    "iou_threshold": @json.to_json(self.iou_threshold),
    "image_id": @json.to_json(self.image_id),
    "category_id": @json.to_json(self.category_id),
    "detection_id": match self.detection_id {
      Some(id) => @json.to_json(id)
      None => Json::null()
    },
    "ground_truth_id": match self.ground_truth_id {
      Some(id) => Json::number(id.to_double(), repr=id.to_string())
      None => Json::null()
    },
    "iou": @json.to_json(self.iou),
    "status": @json.to_json(self.status),
    "reason": @json.to_json(self.reason),
  })
}

///|
pub extend MatchDiagnostic with ToJson::{to_json}

///|
pub fn MatchDiagnostic::iou_threshold(self : MatchDiagnostic) -> Double {
  self.iou_threshold
}

///|
pub fn MatchDiagnostic::image_id(self : MatchDiagnostic) -> Int {
  self.image_id
}

///|
pub fn MatchDiagnostic::category_id(self : MatchDiagnostic) -> Int {
  self.category_id
}

///|
pub fn MatchDiagnostic::detection_id(self : MatchDiagnostic) -> Int? {
  self.detection_id
}

///|
pub fn MatchDiagnostic::ground_truth_id(self : MatchDiagnostic) -> Int64? {
  self.ground_truth_id
}

///|
/// IoU of the chosen match, or zero when no ground truth was matched.
pub fn MatchDiagnostic::iou(self : MatchDiagnostic) -> Double {
  self.iou
}

///|
pub fn MatchDiagnostic::status(self : MatchDiagnostic) -> String {
  self.status
}

///|
pub fn MatchDiagnostic::reason(self : MatchDiagnostic) -> String {
  self.reason
}

///|
pub fn EvaluationReport::diagnostics(
  self : EvaluationReport,
) -> Array[MatchDiagnostic] {
  self.diagnostics.copy()
}

///|
/// AP and AR average over these IoU thresholds. AP50 and AP75 remain fixed probes.
pub struct EvalConfig {
  iou_thresholds : Array[Double]
  include_diagnostics : Bool
  include_image_summaries : Bool
  include_pr_curves : Bool
  ignore_policy : String
} derive(ToJson)

///|
pub extend EvalConfig with ToJson::{to_json}

///|
pub fn EvalConfig::new(
  iou_thresholds : Array[Double],
  include_diagnostics? : Bool = false,
  ignore_policy? : String = "coco_bbox",
  include_image_summaries? : Bool = false,
  include_pr_curves? : Bool = false,
) -> EvalConfig raise EvalError {
  if iou_thresholds.is_empty() || iou_thresholds.length() > 100 {
    raise InvalidConfig("IoU thresholds must contain 1 to 100 values")
  }
  let mut previous = 0.0
  for threshold in iou_thresholds {
    if !finite(threshold) || threshold <= previous || threshold > 1.0 {
      raise InvalidConfig(
        "IoU thresholds must be finite, strictly increasing, and in (0,1]",
      )
    }
    previous = threshold
  }
  if ignore_policy != "coco_bbox" && ignore_policy != "respect_explicit" {
    raise InvalidConfig("ignore_policy must be coco_bbox or respect_explicit")
  }
  {
    iou_thresholds: iou_thresholds.copy(),
    include_diagnostics,
    include_image_summaries,
    include_pr_curves,
    ignore_policy,
  }
}

///|
pub fn EvalConfig::coco() -> EvalConfig {
  {
    iou_thresholds: [
      0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.8999999999999999, 0.95,
    ],
    include_diagnostics: false,
    include_image_summaries: false,
    include_pr_curves: false,
    ignore_policy: "coco_bbox",
  }
}

///|
pub fn EvalConfig::iou_thresholds(self : EvalConfig) -> Array[Double] {
  self.iou_thresholds.copy()
}

///|
pub fn EvalConfig::include_diagnostics(self : EvalConfig) -> Bool {
  self.include_diagnostics
}

///|
pub fn EvalConfig::include_image_summaries(self : EvalConfig) -> Bool {
  self.include_image_summaries
}

///|
pub fn EvalConfig::include_pr_curves(self : EvalConfig) -> Bool {
  self.include_pr_curves
}

///|
pub fn EvalConfig::ignore_policy(self : EvalConfig) -> String {
  self.ignore_policy
}

///|
priv struct RankedDetection {
  detection : Detection
  order : Int
}

///|
priv struct RankedTruth {
  truth : GroundTruth
  order : Int
}

///|
priv struct PreparedImage {
  image_id : Int
  truths : Array[RankedTruth]
  all_truths : Array[GroundTruth]
  candidates : Array[RankedDetection]
}

///|
priv struct MatchRecord {
  score : Double
  order : Int
  matched : Bool
  ignored : Bool
}

///|
priv struct CurveValue {
  ap : Double
  ar : Double
  sampled_precision : Array[Double]
}

///|
fn overlap(detection : Box, truth : Box, crowd : Bool) -> Double {
  if !crowd {
    return detection.iou(truth)
  }
  let width = (detection.x2.min(truth.x2) - detection.x1.max(truth.x1)).max(0.0)
  let height = (detection.y2.min(truth.y2) - detection.y1.max(truth.y1)).max(
    0.0,
  )
  width * height / detection.area()
}

///|
fn in_area(area : Double, lower : Double, upper : Double) -> Bool {
  area >= lower && area <= upper
}

///|
fn truth_ignored(
  truth : GroundTruth,
  lower : Double,
  upper : Double,
  respect_explicit_ignore : Bool,
) -> Bool {
  truth.iscrowd ||
  (respect_explicit_ignore && truth.ignore) ||
  !in_area(truth.area, lower, upper)
}

///|
fn ranked_before(left : RankedDetection, right : RankedDetection) -> Int {
  if left.detection.score > right.detection.score {
    -1
  } else if left.detection.score < right.detection.score {
    1
  } else {
    left.order - right.order
  }
}

///|
fn record_before(left : MatchRecord, right : MatchRecord) -> Int {
  if left.score > right.score {
    -1
  } else if left.score < right.score {
    1
  } else {
    left.order - right.order
  }
}

///|
/// Group and rank once per category; threshold and area matching remain per curve.
fn prepare_category(
  dataset : Dataset,
  detections : Array[RankedDetection],
  category_id : Int,
) -> Array[PreparedImage] {
  let truths_by_image : Map[Int, Array[RankedTruth]] = Map([])
  let all_truths_by_image : Map[Int, Array[GroundTruth]] = Map([])
  let detections_by_image : Map[Int, Array[RankedDetection]] = Map([])
  for order, truth in dataset.truths {
    match all_truths_by_image.get(truth.image_id) {
      Some(items) => items.push(truth)
      None => all_truths_by_image[truth.image_id] = [truth]
    }
    if truth.category_id == category_id {
      match truths_by_image.get(truth.image_id) {
        Some(items) => items.push({ truth, order, })
        None => truths_by_image[truth.image_id] = [{ truth, order, }]
      }
    }
  }
  for candidate in detections {
    if candidate.detection.category_id == category_id {
      match detections_by_image.get(candidate.detection.image_id) {
        Some(items) => items.push(candidate)
        None => detections_by_image[candidate.detection.image_id] = [candidate]
      }
    }
  }
  let images = dataset.images.copy()
  images.sort_by(fn(left, right) {
    if left.id < right.id {
      -1
    } else if left.id > right.id {
      1
    } else {
      0
    }
  })
  let prepared : Array[PreparedImage] = []
  for image in images {
    let truths = match truths_by_image.get(image.id) {
      Some(items) => items
      None => []
    }
    let candidates = match detections_by_image.get(image.id) {
      Some(items) => items
      None => []
    }
    candidates.sort_by(ranked_before)
    let all_truths = match all_truths_by_image.get(image.id) {
      Some(items) => items
      None => []
    }
    prepared.push({ image_id: image.id, truths, all_truths, candidates, })
  }
  prepared
}

///|
/// A read-only explanation of an unmatched, non-ignored detection.
fn classify_false_positive(
  all_truths : Array[GroundTruth],
  category_id : Int,
  detection : Box,
  truths : Array[RankedTruth],
  used : Array[Bool],
  ignored : Array[Bool],
  threshold : Double,
  respect_explicit_ignore : Bool,
) -> String {
  for index in 0..= threshold {
      return "duplicate"
    }
  }
  for truth in all_truths {
    if truth.category_id != category_id &&
      !truth.iscrowd &&
      (!respect_explicit_ignore || !truth.ignore) &&
      overlap(detection, truth.bbox, false) >= threshold {
      return "wrong_class"
    }
  }
  for index in 0..= 0.1 {
      return "localization"
    }
  }
  "background"
}

///|
fn evaluate_curve(
  prepared : Array[PreparedImage],
  category_id : Int,
  threshold : Double,
  lower : Double,
  upper : Double,
  max_dets : Int,
  trace : Array[MatchDiagnostic]?,
  respect_explicit_ignore : Bool,
  capture_samples? : Bool = false,
) -> CurveValue {
  let records : Array[MatchRecord] = []
  let mut positives = 0
  for group in prepared {
    let truths = group.truths.copy()
    let candidates = group.candidates
    truths.sort_by(fn(left, right) {
      let left_ignored = truth_ignored(
        left.truth,
        lower,
        upper,
        respect_explicit_ignore,
      )
      let right_ignored = truth_ignored(
        right.truth,
        lower,
        upper,
        respect_explicit_ignore,
      )
      if left_ignored == right_ignored {
        left.order - right.order
      } else if left_ignored {
        1
      } else {
        -1
      }
    })
    let used = Array::make(truths.length(), false)
    let ignored = Array::makei(truths.length(), fn(index) {
      truth_ignored(truths[index].truth, lower, upper, respect_explicit_ignore)
    })
    for flag in ignored {
      if !flag {
        positives += 1
      }
    }
    let count = candidates.length().min(max_dets)
    for detection_index in 0..= 0 && !ignored[best] && ignored[truth_index] {
          break
        }
        let current = overlap(
          candidate.detection.bbox,
          truth.bbox,
          truth.iscrowd,
        )
        if current < best_overlap {
          continue
        }
        best_overlap = current
        best = truth_index
      }
      let match_ignored = if best >= 0 {
        ignored[best]
      } else {
        !in_area(candidate.detection.bbox.area(), lower, upper)
      }
      if best >= 0 {
        used[best] = true
      }
      match trace {
        Some(entries) => {
          let ground_truth_id : Int64? = if best >= 0 {
            Some(truths[best].truth.id)
          } else {
            None
          }
          let reason = if best >= 0 {
            let truth = truths[best].truth
            if truth.iscrowd {
              "crowd"
            } else if respect_explicit_ignore && truth.ignore {
              "ground_truth_ignore"
            } else if !in_area(truth.area, lower, upper) {
              "ground_truth_area"
            } else {
              "matched"
            }
          } else if match_ignored {
            "detection_area"
          } else {
            classify_false_positive(
              group.all_truths,
              category_id,
              candidate.detection.bbox,
              truths,
              used,
              ignored,
              threshold,
              respect_explicit_ignore,
            )
          }
          entries.push({
            iou_threshold: threshold,
            image_id: group.image_id,
            category_id,
            detection_id: Some(candidate.detection.id),
            ground_truth_id,
            iou: if best >= 0 {
              best_overlap
            } else {
              0.0
            },
            status: if match_ignored {
              "ignored"
            } else if best >= 0 {
              "tp"
            } else {
              "fp"
            },
            reason,
          })
        }
        None => ()
      }
      records.push({
        score: candidate.detection.score,
        order: records.length(),
        matched: best >= 0,
        ignored: match_ignored,
      })
    }
    match trace {
      Some(entries) =>
        for truth_index in 0.. ()
    }
  }
  if positives == 0 {
    return {
      ap: -1.0,
      ar: -1.0,
      sampled_precision: if capture_samples {
        Array::make(101, -1.0)
      } else {
        []
      },
    }
  }
  records.sort_by(record_before)
  let mut tp = 0
  let mut fp = 0
  let precision : Array[Double] = []
  let recall : Array[Double] = []
  for record in records {
    if record.ignored {
      continue
    }
    if record.matched {
      tp += 1
    } else {
      fp += 1
    }
    precision.push(tp.to_double() / (tp + fp).to_double())
    recall.push(tp.to_double() / positives.to_double())
  }
  let ar = tp.to_double() / positives.to_double()
  for index = precision.length() - 2; index >= 0; index = index - 1 {
    if precision[index] < precision[index + 1] {
      precision[index] = precision[index + 1]
    }
  }
  let mut ap = 0.0
  let mut index = 0
  let sampled_precision : Array[Double] = []
  for sample in 0..<=100 {
    // COCOeval uses np.linspace(0, 1, 101), whose step is 0.01.
    // Multiplication preserves its rounding at samples such as 0.70.
    let target = sample.to_double() * 0.01
    while index < recall.length() && recall[index] < target {
      index += 1
    }
    let value = if index < precision.length() { precision[index] } else { 0.0 }
    ap += value
    if capture_samples {
      sampled_precision.push(value)
    }
  }
  { ap: ap / 101.0, ar, sampled_precision, }
}

///|
fn average(values : Array[Double]) -> Double {
  let mut sum = 0.0
  let mut count = 0
  for value in values {
    if value >= 0.0 {
      sum += value
      count += 1
    }
  }
  if count == 0 {
    -1.0
  } else {
    sum / count.to_double()
  }
}

///|
fn metrics_for_category(
  prepared : Array[PreparedImage],
  category_id : Int,
  thresholds : Array[Double],
  diagnostics : Array[MatchDiagnostic]?,
  curves : Array[PrecisionRecallCurve]?,
  respect_explicit_ignore : Bool,
) -> Metrics {
  let all_ap : Array[Double] = []
  let all_ar1 : Array[Double] = []
  let all_ar10 : Array[Double] = []
  let all_ar100 : Array[Double] = []
  let small_ap : Array[Double] = []
  let medium_ap : Array[Double] = []
  let large_ap : Array[Double] = []
  let small_ar : Array[Double] = []
  let medium_ar : Array[Double] = []
  let large_ar : Array[Double] = []
  let mut ap50 = -1.0
  let mut ap75 = -1.0
  let mut saw_ap50 = false
  let mut saw_ap75 = false
  let capture_samples = match curves {
    Some(_) => true
    None => false
  }
  for threshold in thresholds {
    let all = evaluate_curve(
      prepared,
      category_id,
      threshold,
      0.0,
      1.0e10,
      100,
      diagnostics,
      respect_explicit_ignore,
      capture_samples~,
    )
    match curves {
      Some(entries) =>
        entries.push({
          category_id,
          iou_threshold: threshold,
          precision: all.sampled_precision,
          max_recall: all.ar,
          ap: all.ap,
        })
      None => ()
    }
    all_ap.push(all.ap)
    all_ar100.push(all.ar)
    all_ar1.push(
      evaluate_curve(
        prepared,
        category_id,
        threshold,
        0.0,
        1.0e10,
        1,
        None,
        respect_explicit_ignore,
      ).ar,
    )
    all_ar10.push(
      evaluate_curve(
        prepared,
        category_id,
        threshold,
        0.0,
        1.0e10,
        10,
        None,
        respect_explicit_ignore,
      ).ar,
    )
    let small = evaluate_curve(
      prepared,
      category_id,
      threshold,
      0.0,
      1024.0,
      100,
      None,
      respect_explicit_ignore,
    )
    let medium = evaluate_curve(
      prepared,
      category_id,
      threshold,
      1024.0,
      9216.0,
      100,
      None,
      respect_explicit_ignore,
    )
    let large = evaluate_curve(
      prepared,
      category_id,
      threshold,
      9216.0,
      1.0e10,
      100,
      None,
      respect_explicit_ignore,
    )
    small_ap.push(small.ap)
    medium_ap.push(medium.ap)
    large_ap.push(large.ap)
    small_ar.push(small.ar)
    medium_ar.push(medium.ar)
    large_ar.push(large.ar)
    if threshold == 0.5 {
      ap50 = all.ap
      saw_ap50 = true
    }
    if threshold == 0.75 {
      ap75 = all.ap
      saw_ap75 = true
    }
  }
  if !saw_ap50 {
    ap50 = evaluate_curve(
        prepared,
        category_id,
        0.5,
        0.0,
        1.0e10,
        100,
        None,
        respect_explicit_ignore,
      ).ap
  }
  if !saw_ap75 {
    ap75 = evaluate_curve(
        prepared,
        category_id,
        0.75,
        0.0,
        1.0e10,
        100,
        None,
        respect_explicit_ignore,
      ).ap
  }
  {
    ap: average(all_ap),
    ap50,
    ap75,
    ap_small: average(small_ap),
    ap_medium: average(medium_ap),
    ap_large: average(large_ap),
    ar1: average(all_ar1),
    ar10: average(all_ar10),
    ar100: average(all_ar100),
    ar_small: average(small_ar),
    ar_medium: average(medium_ar),
    ar_large: average(large_ar),
  }
}

///|
/// Evaluate continuous-coordinate bounding boxes with default COCO thresholds.
pub fn evaluate(
  dataset : Dataset,
  predictions : Array[Detection],
) -> EvaluationReport raise EvalError {
  evaluate_internal(dataset, predictions, EvalConfig::coco())
}

///|
/// Evaluate and retain per-detection decisions for the default IoU thresholds.
pub fn evaluate_with_diagnostics(
  dataset : Dataset,
  predictions : Array[Detection],
) -> EvaluationReport raise EvalError {
  let default = EvalConfig::coco()
  evaluate_internal(dataset, predictions, {
    iou_thresholds: default.iou_thresholds,
    include_diagnostics: true,
    include_image_summaries: false,
    include_pr_curves: false,
    ignore_policy: default.ignore_policy,
  })
}

///|
/// Evaluate with custom AP/AR IoU thresholds. Area bins and maxDets remain COCO defaults.
pub fn evaluate_with_config(
  dataset : Dataset,
  predictions : Array[Detection],
  config : EvalConfig,
) -> EvaluationReport raise EvalError {
  evaluate_internal(dataset, predictions, config)
}

///|
fn evaluate_internal(
  dataset : Dataset,
  predictions : Array[Detection],
  config : EvalConfig,
) -> EvaluationReport raise EvalError {
  let image_ids : Map[Int, Bool] = Map([])
  let images_by_id : Map[Int, Image] = Map([])
  let category_ids : Map[Int, Bool] = Map([])
  let detection_ids : Map[Int, Bool] = Map([])
  for image in dataset.images {
    image_ids[image.id] = true
    images_by_id[image.id] = image
  }
  for category in dataset.categories {
    category_ids[category.id] = true
  }
  let detections : Array[RankedDetection] = []
  let quality_warnings : Array[QualityWarning] = []
  let per_image_category : Map[String, Int] = Map([])
  let mut evaluated_detections = 0
  for truth in dataset.truths {
    match images_by_id.get(truth.image_id) {
      Some(image) =>
        if box_outside_image(truth.bbox, image) {
          quality_warnings.push({
            source: "ground_truth",
            record_id: truth.id,
            image_id: truth.image_id,
            category_id: truth.category_id,
            kind: "outside_image",
          })
        }
      None => ()
    }
  }
  for index, prediction in predictions {
    if detection_ids.contains(prediction.id) {
      raise InvalidInput("duplicate detection id \{prediction.id}")
    }
    if !image_ids.contains(prediction.image_id) ||
      !category_ids.contains(prediction.category_id) {
      raise InvalidInput(
        "detection \{prediction.id} references unknown image or category",
      )
    }
    detection_ids[prediction.id] = true
    detections.push({ detection: prediction, order: index, })
    let group = "\{prediction.image_id}:\{prediction.category_id}"
    let count = match per_image_category.get(group) {
      Some(value) => value
      None => 0
    }
    per_image_category[group] = count + 1
    if count < 100 {
      evaluated_detections += 1
    }
    match images_by_id.get(prediction.image_id) {
      Some(image) =>
        if box_outside_image(prediction.bbox, image) {
          quality_warnings.push({
            source: "detection",
            record_id: prediction.id.to_int64(),
            image_id: prediction.image_id,
            category_id: prediction.category_id,
            kind: "outside_image",
          })
        }
      None => ()
    }
  }
  let classes : Array[ClassMetrics] = []
  let diagnostics : Array[MatchDiagnostic] = []
  let image_trace : Array[MatchDiagnostic] = []
  let pr_curves : Array[PrecisionRecallCurve] = []
  let ap : Array[Double] = []
  let ap50 : Array[Double] = []
  let ap75 : Array[Double] = []
  let ap_small : Array[Double] = []
  let ap_medium : Array[Double] = []
  let ap_large : Array[Double] = []
  let ar1 : Array[Double] = []
  let ar10 : Array[Double] = []
  let ar100 : Array[Double] = []
  let ar_small : Array[Double] = []
  let ar_medium : Array[Double] = []
  let ar_large : Array[Double] = []
  for category in dataset.categories {
    let prepared = prepare_category(dataset, detections, category.id)
    let metrics = metrics_for_category(
      prepared,
      category.id,
      config.iou_thresholds,
      if config.include_diagnostics {
        Some(diagnostics)
      } else {
        None
      },
      if config.include_pr_curves {
        Some(pr_curves)
      } else {
        None
      },
      config.ignore_policy == "respect_explicit",
    )
    classes.push({ category_id: category.id, metrics, })
    ap.push(metrics.ap)
    ap50.push(metrics.ap50)
    ap75.push(metrics.ap75)
    ap_small.push(metrics.ap_small)
    ap_medium.push(metrics.ap_medium)
    ap_large.push(metrics.ap_large)
    ar1.push(metrics.ar1)
    ar10.push(metrics.ar10)
    ar100.push(metrics.ar100)
    ar_small.push(metrics.ar_small)
    ar_medium.push(metrics.ar_medium)
    ar_large.push(metrics.ar_large)
    if config.include_image_summaries {
      evaluate_curve(
        prepared,
        category.id,
        0.5,
        0.0,
        1.0e10,
        100,
        Some(image_trace),
        config.ignore_policy == "respect_explicit",
      )
      |> ignore
    }
  }
  let image_summaries : Array[ImageSummary] = []
  if config.include_image_summaries {
    let counts_by_image : Map[Int, Array[Int]] = Map([])
    for image in dataset.images {
      counts_by_image[image.id] = [0, 0, 0, 0]
    }
    for entry in image_trace {
      match counts_by_image.get(entry.image_id) {
        Some(counts) =>
          match entry.status {
            "tp" => counts[0] += 1
            "fp" => counts[1] += 1
            "fn" => counts[2] += 1
            "ignored" => counts[3] += 1
            _ => ()
          }
        None => ()
      }
    }
    for image in dataset.images {
      match counts_by_image.get(image.id) {
        Some(counts) =>
          image_summaries.push({
            image_id: image.id,
            tp: counts[0],
            fp: counts[1],
            fn_count: counts[2],
            ignored: counts[3],
          })
        None => ()
      }
    }
    image_summaries.sort_by(fn(left, right) {
      if left.image_id < right.image_id {
        -1
      } else if left.image_id > right.image_id {
        1
      } else {
        0
      }
    })
  }
  let recall_thresholds : Array[Double] = []
  if config.include_pr_curves {
    for sample in 0..<=100 {
      recall_thresholds.push(sample.to_double() * 0.01)
    }
  }
  {
    schema_version: "0.1.0",
    evaluator_version: "0.1.0",
    config,
    counts: {
      images: dataset.images.length(),
      categories: dataset.categories.length(),
      ground_truths: dataset.truths.length(),
      detections: predictions.length(),
      evaluated_detections,
    },
    summary: {
      ap: average(ap),
      ap50: average(ap50),
      ap75: average(ap75),
      ap_small: average(ap_small),
      ap_medium: average(ap_medium),
      ap_large: average(ap_large),
      ar1: average(ar1),
      ar10: average(ar10),
      ar100: average(ar100),
      ar_small: average(ar_small),
      ar_medium: average(ar_medium),
      ar_large: average(ar_large),
    },
    classes,
    diagnostics,
    image_summaries,
    recall_thresholds,
    pr_curves,
    quality_warnings,
  }
}

///|
fn box_outside_image(box : Box, image : Image) -> Bool {
  box.x1 < 0.0 ||
  box.y1 < 0.0 ||
  box.x2 > image.width.to_double() ||
  box.y2 > image.height.to_double()
}