use super::meter::ceiling;
use crate::protocol::decode::Image;
use tracing::*;
const TRIM: usize = 8;
const FLOOR: f32 = 0.01;
const DARK: f32 = 0.001;
const BRIGHT: f32 = 0.98;
const OVERSHOOT: f32 = 1.5;
const UNDERSHOOT: f32 = 0.25;
const SPREAD: f32 = 0.5;
const SPREAD_FLOOR: f32 = 0.08;
const TAIL: usize = 50;
const FLAT_RUN: usize = 24;
const SPECK: usize = 32;
const THETA: f32 = 0.5;
const MIN_PITCH: f32 = 0.75;
const EDGE_BONUS: f32 = 1.0;
const FRAME_COST: f32 = 0.08;
const EDGE_REACH: usize = 24;
const EVEN: usize = 10;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Polarity {
Positive,
Negative,
}
impl Polarity {
const fn sign(self) -> f32 {
match self {
Self::Positive => 1.0,
Self::Negative => -1.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Detected {
pub frames: Vec<usize>,
pub pitch: usize,
}
pub fn detect(image: &Image, length: usize, polarity: Polarity) -> Detected {
let columns = columns(image);
let split = otsu(&columns.texture);
let unexposed = Unexposed::measure(&columns, split, polarity, length);
let (mut picture, film) = score_columns(&columns, split, unexposed.as_ref());
open(&mut picture, (length / SPECK).max(1));
let gap: Vec<f32> = film
.iter()
.zip(&picture)
.map(|(&film, &picture)| match film {
true => 1.0 - picture,
false => 0.0,
})
.collect();
let min_pitch = ((length as f32 * MIN_PITCH) as usize).max(1);
let starts = tile(&picture, &gap, length, min_pitch);
let pitch = pitch(&starts);
let frames = ladder(&starts, pitch);
debug!(
?polarity,
length,
pitch,
found = frames.len(),
"measured the strip"
);
Detected { frames, pitch }
}
struct Unexposed {
base: f32,
reach: f32,
spread: f32,
sign: f32,
}
impl Unexposed {
fn measure(columns: &Columns, split: f32, polarity: Polarity, length: usize) -> Option<Self> {
let base = base(columns, split, polarity, length)?;
let sign = polarity.sign();
let mut off: Vec<f32> = (0..columns.density.len())
.filter(|&x| columns.lit[x])
.map(|x| sign * (base - columns.density[x]))
.filter(|&off| off > 0.0)
.collect();
off.sort_by(f32::total_cmp);
let reach = match off.is_empty() {
true => SPREAD_FLOOR,
false => off[off.len() * 9 / 10],
};
Some(Self {
base,
reach,
spread: (reach * SPREAD).max(SPREAD_FLOOR),
sign,
})
}
fn off(&self, density: f32) -> f32 {
self.sign * (self.base - density)
}
fn is_film(&self, density: f32) -> bool {
let off = self.off(density);
off <= self.reach * OVERSHOOT && off >= -self.reach * UNDERSHOOT
}
fn flat_picture(&self, density: f32) -> f32 {
(self.off(density) / self.spread).clamp(0.0, 1.0)
}
}
fn open(picture: &mut [f32], width: usize) {
let window = |v: &[f32], x: usize, pick: fn(f32, f32) -> f32| {
let (from, to) = (x.saturating_sub(width), (x + width + 1).min(v.len()));
v[from..to].iter().copied().fold(v[x], pick)
};
let eroded: Vec<f32> = (0..picture.len())
.map(|x| window(picture, x, f32::min))
.collect();
for (x, wide) in picture.iter_mut().enumerate() {
*wide = window(&eroded, x, f32::max);
}
}
struct Columns {
texture: Vec<f32>,
density: Vec<f32>,
lit: Vec<bool>,
}
fn columns(image: &Image) -> Columns {
let full = f32::from(ceiling(image.bits));
let (floor, dark, bright) = (full * FLOOR, full * DARK, full * BRIGHT);
let trim = image.rows / TRIM;
let band = trim..image.rows.saturating_sub(trim);
let (rows, planes) = (band.len(), image.colors.len());
let mut out = Columns {
texture: vec![0.0; image.cols],
density: vec![0.0; image.cols],
lit: vec![false; image.cols],
};
if rows < 2 || planes == 0 {
return out;
}
for x in 0..image.cols {
let (mut texture, mut density) = (0.0f32, 0.0f32);
let (mut all_dark, mut all_bright) = (true, true);
for plane in &image.colors {
let at = |y: usize| f32::from(plane[y * image.cols + x]);
let level = band.clone().map(at).sum::<f32>() / rows as f32;
let step = band
.clone()
.skip(1)
.map(|y| (at(y) - at(y - 1)).abs())
.sum::<f32>()
/ (rows - 1) as f32;
texture += step / (level + floor);
density += (full / level.max(1.0)).log10();
all_dark &= level <= dark;
all_bright &= level > bright;
}
let lit = !all_dark && !all_bright;
out.texture[x] = match lit {
true => texture / planes as f32,
false => 0.0,
};
out.density[x] = density / planes as f32;
out.lit[x] = lit;
}
out
}
struct Contrast {
split: f32,
low: f32,
top: f32,
}
impl Contrast {
fn measure(texture: &[f32], split: f32) -> Option<Self> {
let (below, above): (Vec<f32>, Vec<f32>) = texture.iter().partition(|&&t| t < split);
let mean = |side: Vec<f32>| match side.is_empty() {
true => None,
false => Some(side.iter().sum::<f32>() / side.len() as f32),
};
Some(Self {
split,
low: mean(below).unwrap_or(split),
top: mean(above).filter(|top| *top > split)?,
})
}
fn score(&self, texture: f32) -> f32 {
match texture >= self.split {
true => 0.5 + 0.5 * (texture - self.split) / (self.top - self.split),
false => 0.5 - 0.5 * (self.split - texture) / (self.split - self.low).max(f32::EPSILON),
}
.clamp(0.0, 1.0)
}
}
fn score_columns(
columns: &Columns,
split: f32,
unexposed: Option<&Unexposed>,
) -> (Vec<f32>, Vec<bool>) {
let Some(contrast) = Contrast::measure(&columns.texture, split) else {
return (vec![0.0; columns.texture.len()], columns.lit.clone());
};
(0..columns.texture.len())
.map(|x| {
if !columns.lit[x] {
return (0.0, false);
}
let varies = contrast.score(columns.texture[x]);
let Some(film) = unexposed else {
return (varies, true);
};
match film.is_film(columns.density[x]) {
true => (varies.max(film.flat_picture(columns.density[x])), true),
false => (0.0, false),
}
})
.unzip()
}
fn base(columns: &Columns, split: f32, polarity: Polarity, length: usize) -> Option<f32> {
let shortest = (length / FLAT_RUN).max(4);
let mut flat: Vec<f32> = Vec::new();
for (start, end) in runs(&columns.texture, split) {
if start == 0 || end == columns.texture.len() || end - start < shortest {
continue;
}
flat.extend(
(start..end)
.filter(|&x| columns.lit[x])
.map(|x| columns.density[x]),
);
}
if flat.len() < shortest {
return None;
}
flat.sort_by(f32::total_cmp);
let last = flat.len() - 1;
let tail = last * TAIL / 1000;
Some(match polarity {
Polarity::Positive => flat[last - tail],
Polarity::Negative => flat[tail],
})
}
fn runs(values: &[f32], split: f32) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let mut start = None;
for x in 0..=values.len() {
match (x < values.len() && values[x] < split, start) {
(true, None) => start = Some(x),
(false, Some(from)) => {
out.push((from, x));
start = None;
}
_ => {}
}
}
out
}
fn otsu(values: &[f32]) -> f32 {
const BINS: usize = 256;
let (lo, hi) = values
.iter()
.fold((f32::MAX, f32::MIN), |(l, h), &v| (l.min(v), h.max(v)));
if hi <= lo {
return lo;
}
let mut counts = [0usize; BINS];
for &v in values {
let bin = ((v - lo) / (hi - lo) * BINS as f32) as usize;
counts[bin.min(BINS - 1)] += 1;
}
let total = values.len() as f64;
let all: f64 = counts
.iter()
.enumerate()
.map(|(i, &c)| i as f64 * c as f64)
.sum();
let (mut under, mut under_sum, mut best, mut split) = (0f64, 0f64, -1f64, 0usize);
for (i, &count) in counts.iter().enumerate() {
under += count as f64;
under_sum += i as f64 * count as f64;
let over = total - under;
if under == 0.0 || over == 0.0 {
continue;
}
let apart = under_sum / under - (all - under_sum) / over;
let score = under * over * apart * apart;
if score > best {
best = score;
split = i;
}
}
lo + (split as f32 + 0.5) * (hi - lo) / BINS as f32
}
struct Sums {
cols: usize,
inside: Vec<f32>,
between: Vec<f32>,
covered: Vec<f32>,
}
impl Sums {
fn new(picture: &[f32], gap: &[f32]) -> Self {
let cols = picture.len();
let mut sums = Self {
cols,
inside: vec![0f32; cols + 1],
between: vec![0f32; cols + 1],
covered: vec![0f32; cols + 1],
};
for x in 0..cols {
sums.inside[x + 1] = sums.inside[x] + picture[x] - THETA;
sums.between[x + 1] = sums.between[x] + gap[x];
sums.covered[x + 1] = sums.covered[x] + picture[x];
}
sums
}
fn worth(&self, from: usize, to: usize) -> f32 {
self.inside[to] - self.inside[from]
}
fn mean(&self, run: &[f32], (from, to): (usize, usize)) -> f32 {
let (from, to) = (from.min(self.cols), to.min(self.cols));
match to > from {
true => (run[to] - run[from]) / (to - from) as f32,
false => 0.0,
}
}
fn edges(&self, from: usize, to: usize, reach: usize) -> f32 {
self.edge((from, from + reach), (from.saturating_sub(reach), from))
+ self.edge((to.saturating_sub(reach), to), (to, to + reach))
}
fn edge(&self, inner: (usize, usize), outer: (usize, usize)) -> f32 {
self.mean(&self.covered, inner) * self.mean(&self.between, outer)
}
}
fn tile(picture: &[f32], gap: &[f32], length: usize, min_pitch: usize) -> Vec<usize> {
let cols = picture.len();
if length == 0 || cols < length {
return Vec::new();
}
let sums = Sums::new(picture, gap);
let last = cols - length;
let reach = (length / EDGE_REACH).max(1);
let bonus = EDGE_BONUS * length as f32;
let cost = FRAME_COST * length as f32;
let mut best = vec![0f32; last + 1];
let mut prior = vec![usize::MAX; last + 1];
let mut highest = vec![(0f32, usize::MAX); last + 1];
for start in 0..=last {
let end = start + length;
let edges = bonus * sums.edges(start, end, reach);
let alone = sums.worth(start, end) + edges - cost;
let (mut score, mut from) = (alone, usize::MAX);
if start >= length {
let (before, at) = highest[start - length];
if before > 0.0 {
(score, from) = (before + alone, at);
}
}
if start >= min_pitch {
let first = (start + 1).saturating_sub(length);
for (prev, before) in (first..).zip(&best[first..=start - min_pitch]) {
let shared = before + sums.worth(prev + length, end) + edges - cost;
if shared > score {
(score, from) = (shared, prev);
}
}
}
best[start] = score;
prior[start] = from;
highest[start] = match start > 0 && highest[start - 1].0 >= score {
true => highest[start - 1],
false => (score, start),
};
}
let (top, mut at) = highest[last];
if top <= 0.0 {
return Vec::new();
}
let mut out = Vec::new();
while at != usize::MAX {
out.push(at);
at = prior[at];
}
out.reverse();
out
}
fn pitch(columns: &[usize]) -> usize {
let mut gaps: Vec<usize> = columns.windows(2).map(|pair| pair[1] - pair[0]).collect();
gaps.sort_unstable();
gaps.get(gaps.len().saturating_sub(1) / 2)
.copied()
.unwrap_or(0)
}
fn ladder(columns: &[usize], pitch: usize) -> Vec<usize> {
let Some(&first) = columns.first() else {
return Vec::new();
};
if pitch == 0 || !even(columns, pitch) {
return columns.to_vec();
}
let mut out = vec![first];
for pair in columns.windows(2) {
let apart = (pair[1] - pair[0] + pitch / 2) / pitch;
out.extend((1..apart).map(|n| pair[0] + n * pitch));
out.push(pair[1]);
}
out
}
fn even(columns: &[usize], pitch: usize) -> bool {
let slack = (pitch / EVEN).max(1);
columns.windows(2).all(|pair| {
let apart = pair[1] - pair[0];
let whole = ((apart + pitch / 2) / pitch).max(1);
apart.abs_diff(whole * pitch) <= slack
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::{decode::Samples, image::Layout};
const SENSOR: usize = 64;
fn column(plane: &mut [u16], feed: usize, x: usize, level: u16, contrast: f32) {
for y in 0..SENSOR {
let swing = ((y * 7 + x * 3) % 11) as f32 / 11.0 - 0.5;
let v = f32::from(level) * (1.0 + contrast * swing);
plane[y * feed + x] = v.clamp(0.0, f32::from(u16::MAX)) as u16;
}
}
struct Levels {
between: u16,
picture: u16,
}
fn levels(polarity: Polarity) -> Levels {
match polarity {
Polarity::Positive => Levels {
between: 700,
picture: 9000,
},
Polarity::Negative => Levels {
between: 30000,
picture: 8000,
},
}
}
struct Strip {
feed: usize,
length: usize,
polarity: Polarity,
frames: Vec<usize>,
flat: Option<usize>,
blank: Option<usize>,
gate: Option<(usize, usize)>,
mask: usize,
}
impl Strip {
fn new(frames: Vec<usize>, length: usize, polarity: Polarity) -> Self {
let feed = frames.iter().max().unwrap_or(&0) + length + 60;
Self {
feed,
length,
polarity,
frames,
flat: None,
blank: None,
gate: None,
mask: 0,
}
}
fn render(&self) -> Samples {
let level = levels(self.polarity);
let mut colors = vec![vec![0u16; SENSOR * self.feed]; 3];
for x in 0..self.feed {
let inside = self
.frames
.iter()
.position(|&top| (top..top + self.length).contains(&x));
let (value, contrast) = match inside {
_ if x < self.mask => (140, 0.10),
_ if self.gate.is_some_and(|(a, b)| (a..b).contains(&x)) => (65200, 0.0),
Some(n) if Some(n) == self.blank => (level.between, 0.0),
Some(n) if Some(n) == self.flat => (level.picture, 0.0),
Some(_) => (level.picture, 0.55),
None => (level.between, 0.0),
};
for plane in &mut colors {
column(plane, self.feed, x, value, contrast);
}
}
Samples { colors, ir: None }
}
fn detect(&self) -> Detected {
let samples = self.render();
let layout = Layout::single_line(SENSOR as u32, self.feed as u32, vec![1, 2, 3]);
let image = Image::new(&layout, &samples).expect("the buffer is the layout's size");
super::detect(&image, self.length, self.polarity)
}
fn tops(&self) -> Vec<usize> {
self.detect().frames
}
}
fn close(got: &[usize], want: &[usize], slack: usize) {
assert_eq!(got.len(), want.len(), "got {got:?}, wanted {want:?}");
for (g, w) in got.iter().zip(want) {
assert!(
g.abs_diff(*w) <= slack,
"got {got:?}, wanted {want:?} within {slack}"
);
}
}
#[test]
fn every_frame_of_an_even_strip_is_found() {
for polarity in [Polarity::Positive, Polarity::Negative] {
let strip = Strip::new(vec![30, 162, 294, 426], 120, polarity);
let found = strip.detect();
close(&found.frames, &[30, 162, 294, 426], 2);
assert_eq!(found.pitch, 132, "{polarity:?}");
}
}
#[test]
fn the_bare_gate_past_the_film_is_not_a_frame() {
let mut strip = Strip::new(vec![30, 162, 294], 120, Polarity::Positive);
strip.feed = 560;
strip.gate = Some((430, 520));
close(&strip.tops(), &[30, 162, 294], 2);
}
#[test]
fn a_frame_behind_the_holder_mask_keeps_its_place() {
let mut strip = Strip::new(vec![20, 152, 284], 120, Polarity::Positive);
strip.mask = 40;
let tops = strip.tops();
close(&tops, &[20, 152, 284], 3);
assert!(
tops[0] + 120 >= 140,
"{tops:?} should still hold all the picture the mask leaves showing"
);
}
#[test]
fn a_flat_picture_is_still_a_frame() {
for polarity in [Polarity::Positive, Polarity::Negative] {
let mut strip = Strip::new(vec![30, 162, 294], 120, polarity);
strip.flat = Some(1);
close(&strip.tops(), &[30, 162, 294], 2);
}
}
#[test]
fn a_frame_with_no_picture_in_it_still_gets_a_place() {
let mut strip = Strip::new(vec![30, 162, 294, 426], 120, Polarity::Positive);
strip.blank = Some(2);
let found = strip.detect();
assert_eq!(found.frames.len(), 4, "{:?}", found.frames);
assert_eq!(
found.frames[2],
found.frames[1] + found.pitch,
"pitch {} in {:?}",
found.pitch,
found.frames
);
}
#[test]
fn frames_that_overlap_come_back_overlapping() {
let strip = Strip::new(vec![30, 132, 294], 120, Polarity::Negative);
let tops = strip.tops();
close(&tops, &[30, 132, 294], 3);
assert!(
tops[1] < tops[0] + 120,
"{tops:?} should have the first two frames sharing film"
);
}
#[test]
fn an_uneven_run_is_not_laddered() {
let strip = Strip::new(vec![30, 130, 294], 120, Polarity::Negative);
let found = strip.detect();
assert_eq!(found.frames.len(), 3, "{:?}", found.frames);
}
#[test]
fn an_empty_holder_holds_no_frames() {
let strip = Strip::new(Vec::new(), 120, Polarity::Positive);
let found = strip.detect();
assert!(found.frames.is_empty(), "{:?}", found.frames);
assert_eq!(found.pitch, 0);
}
}