use qrcode::{Color, EcLevel, QrCode};
use super::barcode;
use super::canvas::{BLACK, LumaCanvas, LumaImage, LumaView, Rect, WHITE};
use crate::audio::ToneCode;
use crate::{Error, FrameRate, Result};
pub const URL: &str = "https://github.com/AlexHayton/av-drift-detector";
const PREFIX: &str = "AVD:4";
const OLD_PREFIXES: [&str; 2] = ["AVD:3", "AVD:2"];
const QUIET_ZONE: usize = 4;
const EC_LEVEL: EcLevel = EcLevel::M;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct QrStamp {
pub frame_id: u32,
pub fps: FrameRate,
pub tone_base_harmonic: u32,
pub frame_size: (u32, u32),
}
pub fn payload(frame_id: u32, fps: FrameRate, tones: &ToneCode, frame_size: (u32, u32)) -> String {
let rate = match barcode::fps_code(fps) {
0 => fps.to_string(),
code => format!("C{code}"),
};
format!(
"{URL}#{PREFIX}+F:{frame_id:08}+R:{rate}+A:{}+S:{}X{}",
tones.descriptor(),
frame_size.0,
frame_size.1
)
}
pub fn parse_payload(text: &str) -> Option<QrStamp> {
let (_, fragment) = text.split_once('#')?;
let mut fields = fragment.split('+');
if fields.next()? != PREFIX {
return None;
}
let (mut frame_id, mut fps, mut tones, mut size) = (None, None, None, None);
for field in fields {
match field.split_once(':') {
Some(("F", v)) => frame_id = v.parse().ok(),
Some(("R", v)) => {
fps = match v.strip_prefix('C') {
Some(code) => code.parse().ok().and_then(barcode::fps_from_code),
None => v.parse().ok(),
}
}
Some(("A", v)) => tones = ToneCode::parse_descriptor(v).ok(),
Some(("S", v)) => {
size = v
.split_once('X')
.and_then(|(w, h)| Some((w.parse().ok()?, h.parse().ok()?)))
}
_ => {}
}
}
Some(QrStamp {
frame_id: frame_id?,
fps: fps?,
tone_base_harmonic: tones?,
frame_size: size?,
})
}
pub fn is_old_payload(text: &str) -> bool {
let stamp = text.split_once('#').map_or(text, |(_, fragment)| fragment);
OLD_PREFIXES.iter().any(|p| {
stamp
.strip_prefix(p)
.is_some_and(|rest| rest.starts_with(['+', ' ']))
})
}
pub fn stamp_payload(stamp: &QrStamp) -> Result<String> {
Ok(payload(
stamp.frame_id,
stamp.fps,
&ToneCode::with_base_harmonic(stamp.fps, 48000, stamp.tone_base_harmonic)?,
stamp.frame_size,
))
}
pub fn modules(data: &str) -> Result<(usize, Vec<bool>)> {
let code = QrCode::with_error_correction_level(data, EC_LEVEL)
.map_err(|e| Error::Qr(e.to_string()))?;
let dark = code
.to_colors()
.into_iter()
.map(|c| c == Color::Dark)
.collect();
Ok((code.width(), dark))
}
pub fn placement(area: Rect, modules: usize) -> Option<((i32, i32), i32, Rect)> {
let span = modules + 2 * QUIET_ZONE;
let module = (area.width().min(area.height()) as usize / span) as i32;
if module == 0 {
return None;
}
let side = module * span as i32;
let x0 = area.x0 + (area.width() - side) / 2;
let y0 = area.y0 + (area.height() - side) / 2;
let origin = QUIET_ZONE as i32 * module;
Some((
(x0 + origin, y0 + origin),
module,
Rect::new(x0, y0, side, side),
))
}
pub fn draw(canvas: &mut LumaCanvas<'_>, area: Rect, data: &str) -> Result<()> {
let (modules, dark) = self::modules(data)?;
let ((ox, oy), module, square) = placement(area, modules)
.ok_or_else(|| Error::Qr("frame too small for the QR code".into()))?;
canvas.fill_rect(square, WHITE);
for (i, is_dark) in dark.into_iter().enumerate() {
if is_dark {
let (mx, my) = ((i % modules) as i32, (i / modules) as i32);
canvas.fill_rect(
Rect::new(ox + mx * module, oy + my * module, module, module),
BLACK,
);
}
}
Ok(())
}
#[cfg(feature = "qr-decode")]
const MAX_SEARCH_WIDTH: usize = 1280;
#[cfg(feature = "qr-decode")]
pub const REGION_MARGIN: f32 = 0.5;
#[cfg(feature = "qr-decode")]
const MAX_REFINED: usize = 3;
#[cfg(feature = "qr-decode")]
pub fn read(view: &LumaView<'_>) -> Option<QrStamp> {
locate(view, None).map(|found| found.stamp)
}
#[cfg(feature = "qr-decode")]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct QrGrid {
pub corners: [(f32, f32); 4],
pub modules: usize,
}
#[cfg(feature = "qr-decode")]
impl QrGrid {
pub fn centre(&self) -> (f32, f32) {
let (sx, sy) = self
.corners
.iter()
.fold((0.0, 0.0), |(sx, sy), &(x, y)| (sx + x, sy + y));
(sx / 4.0, sy / 4.0)
}
pub fn bounds(&self) -> Rect {
let xs = self.corners.map(|c| c.0);
let ys = self.corners.map(|c| c.1);
let min = |v: [f32; 4]| v.into_iter().fold(f32::INFINITY, f32::min).floor() as i32;
let max = |v: [f32; 4]| v.into_iter().fold(f32::NEG_INFINITY, f32::max).ceil() as i32;
Rect {
x0: min(xs),
y0: min(ys),
x1: max(xs) + 1,
y1: max(ys) + 1,
}
}
pub fn side(&self) -> f32 {
(0..4)
.map(|i| {
let (a, b) = (self.corners[i], self.corners[(i + 1) % 4]);
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
})
.sum::<f32>()
/ 4.0
}
pub fn region(&self, margin: f32) -> Rect {
let b = self.bounds();
let m = (margin * self.side()).ceil() as i32;
Rect {
x0: b.x0 - m,
y0: b.y0 - m,
x1: b.x1 + m,
y1: b.y1 + m,
}
}
}
#[cfg(feature = "qr-decode")]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LocatedQr {
pub stamp: QrStamp,
pub corners: [(f32, f32); 4],
pub modules: usize,
}
#[cfg(feature = "qr-decode")]
impl LocatedQr {
pub fn grid(&self) -> QrGrid {
QrGrid {
corners: self.corners,
modules: self.modules,
}
}
pub fn centre(&self) -> (f32, f32) {
self.grid().centre()
}
pub fn bounds(&self) -> Rect {
self.grid().bounds()
}
}
#[cfg(feature = "qr-decode")]
#[derive(Clone, Debug, Default, PartialEq)]
pub struct QrSearch {
pub found: Option<LocatedQr>,
pub undecoded: Vec<QrGrid>,
pub old_format: bool,
}
#[cfg(feature = "qr-decode")]
pub fn locate(view: &LumaView<'_>, roi: Option<Rect>) -> Option<LocatedQr> {
search(view, roi).found
}
#[cfg(feature = "qr-decode")]
pub fn search(view: &LumaView<'_>, roi: Option<Rect>) -> QrSearch {
search_with(view, roi, Effort::Decode)
}
#[cfg(feature = "qr-decode")]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Effort {
Decode,
Locate,
}
#[cfg(feature = "qr-decode")]
pub fn search_with(view: &LumaView<'_>, roi: Option<Rect>, effort: Effort) -> QrSearch {
let (area, x0, y0) = match roi {
Some(r) => (
view.crop(r),
r.x0.clamp(0, view.width() as i32),
r.y0.clamp(0, view.height() as i32),
),
None => (*view, 0, 0),
};
if area.width() == 0 || area.height() == 0 {
return QrSearch::default();
}
let mut factor = area.width().div_ceil(MAX_SEARCH_WIDTH);
if effort == Effort::Locate {
factor = factor.max(2);
}
let at = |factor: usize| {
if factor > 1 {
search_area(&downscale(&area, factor).view(), x0, y0, factor)
} else {
search_area(&area, x0, y0, 1)
}
};
let mut result = at(factor);
if effort == Effort::Decode
&& factor >= 3
&& result.found.is_none()
&& result.undecoded.is_empty()
{
result = at(factor.div_ceil(2));
factor = factor.div_ceil(2);
}
if result.found.is_some() || effort == Effort::Locate {
return result;
}
let grids = std::mem::take(&mut result.undecoded);
for grid in grids.iter().take(MAX_REFINED) {
let region = grid.region(REGION_MARGIN);
let crop = view.crop(region);
let (rx, ry) = (
region.x0.clamp(0, view.width() as i32),
region.y0.clamp(0, view.height() as i32),
);
let refined = if factor > 1 {
search_area(&crop, rx, ry, 1)
} else {
QrSearch::default()
};
let refined = match refined.found {
Some(_) => refined,
None => search_area(&stretch_contrast(&crop).view(), rx, ry, 1),
};
result.old_format |= refined.old_format;
if let Some(found) = refined.found {
return QrSearch {
found: Some(found),
..Default::default()
};
}
result
.undecoded
.push(refined.undecoded.first().copied().unwrap_or(*grid));
}
result.undecoded.extend(grids.into_iter().skip(MAX_REFINED));
result
}
#[cfg(feature = "qr-decode")]
fn search_area(view: &LumaView<'_>, x0: i32, y0: i32, factor: usize) -> QrSearch {
let scale = |v: i32, origin: i32| origin as f32 + (v as f32 + 0.5) * factor as f32 - 0.5;
let mut img =
rqrr::PreparedImage::prepare_from_greyscale(view.width(), view.height(), |x, y| {
view.get(x, y)
});
let mut result = QrSearch::default();
for grid in img.detect_grids() {
let corners = grid.bounds.map(|p| (scale(p.x, x0), scale(p.y, y0)));
let modules = rqrr::BitGrid::size(&grid.grid);
match grid.decode() {
Ok((_, text)) => {
result.old_format |= is_old_payload(&text);
if let Some(stamp) = parse_payload(&text) {
result.found = Some(LocatedQr {
stamp,
corners,
modules,
});
return result;
}
}
Err(_) => result.undecoded.push(QrGrid { corners, modules }),
}
}
result
}
#[cfg(feature = "qr-decode")]
fn stretch_contrast(view: &LumaView<'_>) -> LumaImage {
let (w, h) = (view.width(), view.height());
let mut histogram = [0usize; 256];
for y in 0..h {
for x in 0..w {
histogram[usize::from(view.get(x, y))] += 1;
}
}
let count = w * h;
let percentile = |p: usize| {
let target = count * p / 100;
let mut seen = 0;
histogram
.iter()
.position(|&n| {
seen += n;
seen > target
})
.unwrap_or(255) as i32
};
let (lo, hi) = (percentile(2), percentile(98));
let span = (hi - lo).max(1);
let mut out = LumaImage::new(w, h, 0);
for y in 0..h {
for x in 0..w {
let v = (i32::from(view.get(x, y)) - lo) * 255 / span;
out.data[y * w + x] = v.clamp(0, 255) as u8;
}
}
out
}
pub fn downscale(view: &LumaView<'_>, factor: usize) -> LumaImage {
let (w, h) = (view.width() / factor, view.height() / factor);
let mut out = LumaImage::new(w, h, 0);
let area = (factor * factor) as u32;
for y in 0..h {
for x in 0..w {
let mut sum = 0u32;
for dy in 0..factor {
for dx in 0..factor {
sum += u32::from(view.get(x * factor + dx, y * factor + dy));
}
}
out.data[y * w + x] = (sum / area) as u8;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn tones(fps: FrameRate) -> ToneCode {
ToneCode::new(fps, 48000).unwrap()
}
#[test]
fn payload_roundtrip() {
let fps: FrameRate = "29.97".parse().unwrap();
let text = payload(1234, fps, &tones(fps), (1920, 1080));
assert_eq!(
text,
"https://github.com/AlexHayton/av-drift-detector#AVD:4+F:00001234+R:C4+A:K21+S:1920X1080"
);
let expected = QrStamp {
frame_id: 1234,
fps,
tone_base_harmonic: 21,
frame_size: (1920, 1080),
};
assert_eq!(parse_payload(&text), Some(expected));
assert_eq!(
parse_payload("https://example.com/x#AVD:4+F:00001234+R:C4+A:K21+S:1920X1080"),
Some(expected)
);
let custom = FrameRate::new(15, 1).unwrap();
let text = payload(7, custom, &tones(custom), (640, 360));
assert!(
text.ends_with("#AVD:4+F:00000007+R:15+A:K40+S:640X360"),
"{text}"
);
assert_eq!(parse_payload(&text).map(|s| s.fps), Some(custom));
assert_eq!(parse_payload("hello"), None);
assert_eq!(parse_payload(URL), None);
assert_eq!(parse_payload("https://a.b/#AVD:4+F:1+R:C5+A:K20"), None);
assert_eq!(
parse_payload("https://a.b/#AVD:4+F:1+R:C99+A:K20+S:1X1"),
None
);
}
#[test]
fn earlier_formats_are_recognised_but_not_read() {
for old in [
"https://github.com/AlexHayton/av-drift-detector#AVD:3+F:00001234+R:30000/1001+A:T4X16G2K21",
"AVD:2 F:00001234 R:30000/1001 A:T4X16G2K21 T:00:00:41.04",
] {
assert_eq!(parse_payload(old), None, "{old}");
assert!(is_old_payload(old), "{old}");
}
let fps: FrameRate = "30".parse().unwrap();
assert!(!is_old_payload(&payload(1, fps, &tones(fps), (1280, 720))));
assert!(!is_old_payload("https://example.com/"));
}
#[test]
fn every_rate_fits_one_version_5_code() {
for code in 1..=12 {
let fps = super::super::barcode::fps_from_code(code).unwrap();
for id in [0, 1234, 16_777_215] {
for size in [(320, 180), (1920, 1080), (7680, 4320)] {
let text = payload(id, fps, &tones(fps), size);
let (url, fragment) = text.split_once('#').unwrap();
assert_eq!(url, URL);
let charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
assert!(fragment.chars().all(|c| charset.contains(c)), "{text}");
let qr = QrCode::with_error_correction_level(&text, EC_LEVEL).unwrap();
assert_eq!(qr.width(), 37, "expected a version 5 code for {text}");
}
}
}
}
#[cfg(feature = "qr-decode")]
#[test]
fn draw_and_read() {
let fps: FrameRate = "60".parse().unwrap();
let mut img = LumaImage::new(640, 360, BLACK);
let id = 9_876_543;
let text = payload(id, fps, &tones(fps), (640, 360));
draw(&mut img.canvas(), Rect::new(380, 100, 240, 240), &text).unwrap();
let expected = QrStamp {
frame_id: id,
fps,
tone_base_harmonic: 10,
frame_size: (640, 360),
};
assert_eq!(read(&img.view()), Some(expected));
}
#[cfg(feature = "qr-decode")]
#[test]
fn locates_the_code_in_a_region() {
let fps: FrameRate = "30".parse().unwrap();
let mut img = LumaImage::new(1280, 720, BLACK);
let text = payload(42, fps, &tones(fps), (1280, 720));
let area = Rect::new(700, 200, 330, 330);
draw(&mut img.canvas(), area, &text).unwrap();
let (code_x0, code_y0) = (707.0 + 28.0, 207.0 + 28.0);
let side = 37.0 * 7.0;
for roi in [None, Some(Rect::new(600, 100, 600, 500))] {
let found = locate(&img.view(), roi).unwrap();
assert_eq!(found.stamp.frame_id, 42);
let (cx, cy) = found.centre();
assert!(
(cx - (code_x0 + side / 2.0)).abs() < 8.0
&& (cy - (code_y0 + side / 2.0)).abs() < 8.0,
"{roi:?}: centre {cx},{cy} for {found:?}"
);
let b = found.bounds();
assert!((b.width() as f32 - side).abs() <= 16.0, "{b:?}");
}
assert_eq!(locate(&img.view(), Some(Rect::new(0, 0, 600, 720))), None);
assert!(locate(&img.view(), Some(Rect::new(500, -50, 2000, 2000))).is_some());
}
#[cfg(feature = "qr-decode")]
#[test]
fn reads_large_frames_via_downscale() {
let fps: FrameRate = "25".parse().unwrap();
let mut img = LumaImage::new(3840, 2160, BLACK);
let text = payload(77, fps, &tones(fps), (3840, 2160));
draw(&mut img.canvas(), Rect::new(2000, 600, 900, 900), &text).unwrap();
assert_eq!(read(&img.view()).map(|s| s.frame_id), Some(77));
let (cx, cy) = locate(&img.view(), None).unwrap().centre();
assert!(
(cx - 2450.0).abs() < 21.0 && (cy - 1050.0).abs() < 21.0,
"{cx},{cy}"
);
}
}