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 MAX_PITCH: f32 = 1.4;
const BODY: f32 = 0.4;
const EDGE_BONUS: f32 = 1.0;
const FRAME_COST: f32 = 0.08;
const EDGE_REACH: usize = 24;
const EVEN: usize = 10;
const DRIFT: f32 = 0.05;
const GATE: f32 = 0.10;
const ANCHORS: usize = 3;
const SHARE: f32 = 2.0 / 3.0;
const ON_FILM: f32 = 0.5;
#[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 length: 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 sums = Sums::new(&picture, &gap);
let place = |length: usize| {
let min_pitch = ((length as f32 * MIN_PITCH) as usize).max(1);
let starts = tile(&sums, length, min_pitch);
let wind = Wind::fit(&starts, length);
(starts, wind)
};
let (starts, wind) = place(length);
let (length, starts, wind) = match wind.as_ref().filter(|w| w.carries(starts.len())) {
Some(w) => match recalibrate(&sums, w, &starts, length) {
Some(corrected) if corrected != length => {
let (starts2, wind2) = place(corrected);
match wind2.as_ref().filter(|w2| w2.carries(starts2.len())) {
Some(_) => (corrected, starts2, wind2),
None => (length, starts, wind),
}
}
_ => (length, starts, wind),
},
None => (length, starts, wind),
};
let (frames, pitch) = match wind {
Some(wind) => {
let frames = match wind.carries(starts.len()) {
true => wind.ladder(&film, length),
false => wind.fill(&starts),
};
(frames, wind.pitch.round() as usize)
}
None => (starts, 0),
};
debug!(
?polarity,
length,
pitch,
found = frames.len(),
"measured the strip"
);
Detected {
frames,
pitch,
length,
}
}
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 locate_end(
sums: &Sums,
start: usize,
length: usize,
tolerance: usize,
reach: usize,
) -> Option<usize> {
let nominal = start + length;
let lo = nominal.saturating_sub(tolerance).max(start + 1);
let hi = (nominal + tolerance).min(sums.cols);
(lo..=hi)
.map(|end| {
(
end,
sums.edge((end.saturating_sub(reach), end), (end, end + reach)),
)
})
.filter(|&(_, score)| score > 0.0)
.max_by(|a, b| a.1.total_cmp(&b.1))
.map(|(end, _)| end)
}
fn recalibrate(sums: &Sums, wind: &Wind, starts: &[usize], length: usize) -> Option<usize> {
let tolerance = ((length as f32 * GATE) as usize).max(1);
let reach = (length / EDGE_REACH).max(1);
let mut measured: Vec<usize> = starts
.iter()
.zip(&wind.on)
.filter(|&(_, &on)| on)
.filter_map(|(&start, _)| {
locate_end(sums, start, length, tolerance, reach).map(|end| end - start)
})
.collect();
if measured.len() < ANCHORS {
return None;
}
measured.sort_unstable();
Some(measured[measured.len().saturating_sub(1) / 2])
}
fn tile(sums: &Sums, length: usize, min_pitch: usize) -> Vec<usize> {
let cols = sums.cols;
if length == 0 || cols < length {
return Vec::new();
}
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;
if sums.mean(&sums.covered, (start, end)) < BODY {
best[start] = f32::MIN;
prior[start] = usize::MAX;
highest[start] = match start > 0 {
true => highest[start - 1],
false => (0.0, usize::MAX),
};
continue;
}
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]) {
if *before <= 0.0 {
continue;
}
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
}
struct Wind {
first: f32,
pitch: f32,
anchors: usize,
on: Vec<bool>,
}
impl Wind {
fn fit(starts: &[usize], length: usize) -> Option<Self> {
if starts.len() < 2 || length == 0 {
return None;
}
let seed = seed(starts, length);
let (lowest, highest) = (seed * (1.0 - DRIFT), seed * (1.0 + DRIFT));
let mut wind = Self {
first: starts[0] as f32,
pitch: seed,
anchors: starts.len(),
on: Vec::new(),
};
let mut on = vec![true; starts.len()];
for _ in 0..8 {
let places = wind.places(starts);
wind.pitch = slope(&places, &on)
.unwrap_or(wind.pitch)
.clamp(lowest, highest);
wind.first = offset(&places, &on, wind.pitch).unwrap_or(wind.first);
let slack = (wind.pitch / EVEN as f32).max(2.0);
let settled: Vec<bool> = places
.iter()
.map(|&(k, start)| (start - (wind.first + wind.pitch * k)).abs() <= slack)
.collect();
let done = settled == on;
on = settled;
if done {
break;
}
}
wind.anchors = on.iter().filter(|&&on| on).count();
wind.on = on;
Some(wind)
}
fn places(&self, starts: &[usize]) -> Vec<(f32, f32)> {
let mut out = Vec::with_capacity(starts.len());
let mut k = 0.0;
for pair in starts.windows(2) {
out.push((k, pair[0] as f32));
k += ((pair[1] - pair[0]) as f32 / self.pitch).round().max(1.0);
}
out.push((k, *starts.last().expect("checked non-empty") as f32));
out
}
fn carries(&self, found: usize) -> bool {
self.anchors >= ANCHORS.max((found as f32 * SHARE).ceil() as usize)
}
fn at(&self, k: i32) -> f32 {
self.first + self.pitch * k as f32
}
fn ladder(&self, film: &[bool], length: usize) -> Vec<usize> {
let cols = film.len();
let mut on = vec![0usize; cols + 1];
for x in 0..cols {
on[x + 1] = on[x] + usize::from(film[x]);
}
let reaches = |k: i32| {
let place = self.at(k);
let start = place.max(0.0) as usize;
let end = ((place + length as f32).max(0.0) as usize).min(cols);
(end > start).then_some((start, end))
};
let least = (length as f32 * ON_FILM) as usize;
let first = (-self.first / self.pitch).floor() as i32 - 1;
let last = ((cols as f32 - self.first) / self.pitch).ceil() as i32 + 1;
(first..=last)
.filter_map(reaches)
.filter(|&(start, end)| on[end] - on[start] >= least)
.map(|(start, _)| start)
.collect()
}
fn fill(&self, starts: &[usize]) -> Vec<usize> {
let slack = (self.pitch / EVEN as f32).max(2.0);
let mut out = vec![starts[0]];
for pair in starts.windows(2) {
let apart = (pair[1] - pair[0]) as f32;
let winds = (apart / self.pitch).round();
if winds >= 2.0 && (apart / winds - self.pitch).abs() <= slack {
let step = apart / winds;
out.extend(
(1..winds as usize).map(|n| pair[0] + (step * n as f32).round() as usize),
);
}
out.push(pair[1]);
}
out
}
}
fn seed(starts: &[usize], length: usize) -> f32 {
let mut gaps: Vec<usize> = starts.windows(2).map(|pair| pair[1] - pair[0]).collect();
gaps.sort_unstable();
let middle = gaps[(gaps.len() - 1) / 2] as f32;
let winds = (middle / (length as f32 * MAX_PITCH)).ceil().max(1.0);
middle / winds
}
fn slope(places: &[(f32, f32)], on: &[bool]) -> Option<f32> {
let kept = || {
places
.iter()
.zip(on)
.filter(|(_, on)| **on)
.map(|(p, _)| *p)
};
let count = kept().count();
if count < 2 {
return None;
}
let mean = |pick: fn(&(f32, f32)) -> f32| kept().map(|p| pick(&p)).sum::<f32>() / count as f32;
let (k, start) = (mean(|p| p.0), mean(|p| p.1));
let spread: f32 = kept().map(|p| (p.0 - k) * (p.0 - k)).sum();
match spread > 0.0 {
true => Some(kept().map(|p| (p.0 - k) * (p.1 - start)).sum::<f32>() / spread),
false => None,
}
}
fn offset(places: &[(f32, f32)], on: &[bool], pitch: f32) -> Option<f32> {
let mut firsts: Vec<f32> = places
.iter()
.zip(on)
.filter(|(_, on)| **on)
.map(|((k, start), _)| start - pitch * k)
.collect();
firsts.sort_by(f32::total_cmp);
firsts.get(firsts.len().saturating_sub(1) / 2).copied()
}
#[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,
nominal: Option<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,
nominal: None,
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.nominal.unwrap_or(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:?}");
assert_eq!(found.length, 120, "{polarity:?}");
}
}
#[test]
fn a_wrong_nominal_length_is_corrected_from_the_strip_itself() {
let true_length = 128;
let mut strip = Strip::new(
vec![30, 200, 370, 540, 710],
true_length,
Polarity::Positive,
);
strip.nominal = Some(120);
let found = strip.detect();
close(&found.frames, &[30, 200, 370, 540, 710], 3);
assert!(
found.length.abs_diff(true_length) <= 4,
"wanted a length near {true_length}, got {}",
found.length
);
}
#[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);
assert_eq!(found.length, 120);
}
#[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);
}
}