use anyhow::{Context, Result, bail};
use base64::Engine;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
pub mod status {
pub const NOT_NEEDED: &str = "NotNeeded";
pub const APPLIED: &str = "Applied";
pub const NO_HOLE: &str = "NoHole";
pub const FAILED: &str = "Failed";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DmdRect {
pub x: u32,
pub y: u32,
pub w: u32,
pub h: u32,
}
impl DmdRect {
fn element(&self) -> String {
format!(
"<Displays><DMD Target=\"ScoreView\" X=\"{}\" Y=\"{}\" W=\"{}\" H=\"{}\" /></Displays>",
self.x, self.y, self.w, self.h
)
}
}
impl DmdRect {
fn video_element(&self) -> String {
format!(
"<Displays><DMD Target=\"ScoreView\" Source=\"ctrl://default/display?dmd_only=0\" X=\"{}\" Y=\"{}\" W=\"{}\" H=\"{}\" /></Displays>",
self.x, self.y, self.w, self.h
)
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Analysis {
HasDisplays,
NoDmdImage,
NoHole,
Missing(DmdRect),
MissingVideo(DmdRect),
}
const DMD_ASPECT: f32 = 4.0;
const ASPECT_TOLERANCE: f32 = 0.25;
const VIDEO_ASPECT: f32 = 1.0;
const VIDEO_ASPECT_MIN: f32 = 0.8;
const VIDEO_ASPECT_MAX: f32 = 1.5;
const DARK_THRESHOLD: u8 = 16;
const MIN_WIDTH_SHARE: f32 = 0.25;
const MIN_HEIGHT_SHARE: f32 = 0.06;
fn find(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
haystack
.get(from..)?
.windows(needle.len())
.position(|w| w == needle)
.map(|p| p + from)
}
fn dmd_image_bytes(doc: &[u8]) -> Result<Option<Vec<u8>>> {
let Some(tag) = find(doc, b"<DMDImage", 0) else {
return Ok(None);
};
let Some(value) = find(doc, b"Value=\"", tag) else {
return Ok(None);
};
let start = value + b"Value=\"".len();
let end = find(doc, b"\"", start).context("unterminated DMDImage value")?;
let mut b64 = Vec::with_capacity(end - start);
let mut i = start;
while i < end {
match doc[i] {
b'&' => {
while i < end && doc[i] != b';' {
i += 1;
}
}
c if c.is_ascii_whitespace() => {}
c => b64.push(c),
}
i += 1;
}
let bytes = base64::engine::general_purpose::STANDARD
.decode(&b64)
.context("DMDImage is not valid base64")?;
Ok(Some(bytes))
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Shape {
Dmd,
Video,
}
impl Shape {
fn kept_width(self, w: usize, h: usize) -> Option<usize> {
let ar = w as f32 / h as f32;
match self {
Shape::Dmd => (ar >= DMD_ASPECT * (1.0 - ASPECT_TOLERANCE))
.then(|| w.min((h as f32 * DMD_ASPECT * (1.0 + ASPECT_TOLERANCE)) as usize)),
Shape::Video => (VIDEO_ASPECT_MIN..=VIDEO_ASPECT_MAX)
.contains(&ar)
.then_some(w),
}
}
fn aspect(self) -> f32 {
match self {
Shape::Dmd => DMD_ASPECT,
Shape::Video => VIDEO_ASPECT,
}
}
}
fn largest_dark_rect(
dark: &[bool],
width: usize,
height: usize,
shape: Shape,
) -> (usize, usize, usize, usize, usize) {
let mut best = (0, 0, 0, 0, 0);
let mut heights = vec![0usize; width];
let mut stack: Vec<(usize, usize)> = Vec::new();
for y in 0..height {
let row = &dark[y * width..(y + 1) * width];
for (run, &is_dark) in heights.iter_mut().zip(row) {
*run = if is_dark { *run + 1 } else { 0 };
}
stack.clear();
for (x, h) in heights
.iter()
.copied()
.chain(std::iter::once(0))
.enumerate()
{
let mut start = x;
while let Some(&(sx, sh)) = stack.last() {
if sh < h {
break;
}
stack.pop();
let w = x - sx;
if sh >= 20
&& w >= 60
&& let Some(fw) = shape.kept_width(w, sh)
{
let area = fw * sh;
if area > best.0 {
best = (area, sx, y + 1 - sh, fw, sh);
}
}
start = sx;
}
stack.push((start, h));
}
}
best
}
fn luminance(image: &image::DynamicImage) -> (Vec<u8>, usize, usize) {
let rgba = image.to_rgba8();
let (width, height) = (rgba.width() as usize, rgba.height() as usize);
let luma = rgba
.pixels()
.map(|p| {
if p[3] < 32 {
0
} else {
((u32::from(p[0]) * 299 + u32::from(p[1]) * 587 + u32::from(p[2]) * 114) / 1000)
as u8
}
})
.collect();
(luma, width, height)
}
fn mean_luma(luma: &[u8], width: usize, x: usize, y: usize, w: usize, h: usize) -> f32 {
let mut sum = 0u64;
for row in y..y + h {
sum += luma[row * width + x..row * width + x + w]
.iter()
.map(|&v| u64::from(v))
.sum::<u64>();
}
sum as f32 / (w * h).max(1) as f32
}
fn edge_contrast(
luma: &[u8],
width: usize,
height: usize,
rect: (usize, usize, usize, usize),
strict: bool,
) -> f32 {
const RING: usize = 6;
let (x, y, w, h) = rect;
let touches = x < RING || y < RING || x + w + RING > width || y + h + RING > height;
if touches && strict {
return 0.0; }
let ox = x.saturating_sub(RING);
let oy = y.saturating_sub(RING);
let ow = (x + w + RING).min(width) - ox;
let oh = (y + h + RING).min(height) - oy;
let outer_area = (ow * oh) as f32;
let inner_area = (w * h) as f32;
let ring_area = outer_area - inner_area;
let full_ring = ((w + 2 * RING) * (h + 2 * RING) - w * h) as f32;
if ring_area < full_ring * 0.5 {
return 0.0;
}
let outer = mean_luma(luma, width, ox, oy, ow, oh);
let inner = mean_luma(luma, width, x, y, w, h);
(outer * outer_area - inner * inner_area) / ring_area - inner
}
fn flush_flat_hole(
rgba: &image::RgbaImage,
luma: &[u8],
width: usize,
height: usize,
rect: (usize, usize, usize, usize),
outline: usize,
) -> bool {
const RING: usize = 6;
const FLAT: i32 = 4;
const NEUTRAL: i32 = 8;
let (x, y, w, h) = rect;
let touches = x < RING || y < RING || x + w + RING > width || y + h + RING > height;
if !touches || width < 1000 || w <= 2 * outline || h <= 2 * outline {
return false;
}
let (ix, iy, iw, ih) = (x + outline, y + outline, w - 2 * outline, h - 2 * outline);
let centre = rgba.get_pixel((ix + iw / 2) as u32, (iy + ih / 2) as u32).0;
let (cmax, cmin) = (
i32::from(centre[0].max(centre[1]).max(centre[2])),
i32::from(centre[0].min(centre[1]).min(centre[2])),
);
if cmax - cmin > NEUTRAL || centre[3] < 255 {
return false;
}
for row in iy..iy + ih {
for col in ix..ix + iw {
let p = rgba.get_pixel(col as u32, row as u32).0;
if (0..3).any(|c| (i32::from(p[c]) - i32::from(centre[c])).abs() > FLAT) {
return false;
}
}
}
let inner = f32::from(
((u32::from(centre[0]) * 299 + u32::from(centre[1]) * 587 + u32::from(centre[2]) * 114)
/ 1000) as u8,
);
let mut ring = 0f32;
let mut count = 0usize;
if y >= RING {
ring += mean_luma(luma, width, x, y - RING, w, RING) * (w * RING) as f32;
count += w * RING;
}
if y + h + RING <= height {
ring += mean_luma(luma, width, x, y + h, w, RING) * (w * RING) as f32;
count += w * RING;
}
if count == 0 {
return false;
}
((ring / count as f32) - inner).abs() >= 25.0
}
fn fit_aspect(x: usize, y: usize, w: usize, h: usize, aspect: f32) -> DmdRect {
let (mut x, mut y, mut w, mut h) = (x, y, w, h);
if w as f32 / h as f32 > aspect {
let nw = (h as f32 * aspect) as usize;
x += (w - nw) / 2;
w = nw;
} else {
let nh = (w as f32 / aspect) as usize;
y += (h - nh) / 2;
h = nh;
}
DmdRect {
x: x as u32,
y: y as u32,
w: w as u32,
h: h as u32,
}
}
#[cfg(test)]
fn find_hole(image: &image::DynamicImage, shape: Shape) -> Option<DmdRect> {
find_hole_area(image, shape).map(|(_, rect)| rect)
}
fn find_hole_area(image: &image::DynamicImage, shape: Shape) -> Option<(usize, DmdRect)> {
find_hole_with(image, shape, false)
}
fn find_dark_video_area(image: &image::DynamicImage) -> Option<(usize, DmdRect)> {
find_hole_with(image, Shape::Video, true)
}
fn find_hole_with(
image: &image::DynamicImage,
shape: Shape,
relaxed: bool,
) -> Option<(usize, DmdRect)> {
const THRESHOLDS: [u8; 5] = [DARK_THRESHOLD, 24, 32, 44, 60];
const OUTLINE: usize = 10;
const MIN_CONTRAST: f32 = 25.0;
let (luma, width, height) = luminance(image);
if width < 64 || height < 24 {
return None;
}
let rgba = image.to_rgba8();
let mut best: Option<(usize, DmdRect)> = None;
for threshold in THRESHOLDS {
let mut dark: Vec<bool> = luma.iter().map(|&v| v < threshold).collect();
for _ in 0..4 {
let (area, x, y, w, h) = largest_dark_rect(&dark, width, height, shape);
if area == 0 {
break;
}
let big_enough = (w as f32) >= width as f32 * MIN_WIDTH_SHARE
&& (h as f32) >= height as f32 * MIN_HEIGHT_SHARE;
let strict = shape == Shape::Dmd;
let accepted = if relaxed {
let centre = (x * 2 + w) as f32 / 2.0;
big_enough
&& (centre - width as f32 / 2.0).abs() <= width as f32 * 0.08
&& (w * h) as f32 >= (width * height) as f32 * 0.2
} else {
big_enough
&& edge_contrast(&luma, width, height, (x, y, w, h), strict) >= MIN_CONTRAST
};
let flush = !accepted
&& !relaxed
&& strict
&& big_enough
&& flush_flat_hole(&rgba, &luma, width, height, (x, y, w, h), OUTLINE);
if accepted || flush {
if best.is_none_or(|(a, _)| area > a) {
let (ix, iy, iw, ih) = if flush {
(x + OUTLINE, y + OUTLINE, w - 2 * OUTLINE, h - 2 * OUTLINE)
} else {
(x, y, w, h)
};
best = Some((area, fit_aspect(ix, iy, iw, ih, shape.aspect())));
}
break;
}
for row in y..y + h {
dark[row * width + x..row * width + x + w].fill(false);
}
}
}
best
}
pub fn analyze(doc: &[u8]) -> Result<Analysis> {
if find(doc, b"<Displays", 0).is_some() {
return Ok(Analysis::HasDisplays);
}
let Some(bytes) = dmd_image_bytes(doc)? else {
return Ok(Analysis::NoDmdImage);
};
let image = image::load_from_memory(&bytes).context("DMDImage cannot be decoded")?;
let dmd = find_hole_area(&image, Shape::Dmd);
let video = find_hole_area(&image, Shape::Video);
Ok(match (dmd, video) {
(Some((a, rect)), Some((b, _))) if a >= b => Analysis::Missing(rect),
(Some((_, rect)), None) => Analysis::Missing(rect),
(_, Some((_, rect))) => Analysis::MissingVideo(rect),
(None, None) => match find_dark_video_area(&image) {
Some((_, rect)) => Analysis::MissingVideo(rect),
None => Analysis::NoHole,
},
})
}
fn insert_element(doc: &[u8], element: &str) -> Result<Vec<u8>> {
const CLOSE: &[u8] = b"</DirectB2SData>";
let at = doc
.windows(CLOSE.len())
.rposition(|w| w == CLOSE)
.context("no </DirectB2SData> in the file")?;
let mut out = Vec::with_capacity(doc.len() + element.len());
out.extend_from_slice(&doc[..at]);
out.extend_from_slice(element.as_bytes());
out.extend_from_slice(&doc[at..]);
Ok(out)
}
pub fn backup_path(backup_root: &Path, file: &Path) -> PathBuf {
let digest = Sha256::digest(file.to_string_lossy().as_bytes());
let folder: String = digest.iter().take(6).map(|b| format!("{b:02x}")).collect();
let name = file
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "backglass.directb2s".into());
backup_root.join(folder).join(format!("{name}.orig"))
}
pub fn fix_file(file: &Path, backup_root: &Path) -> Result<&'static str> {
let doc = std::fs::read(file).with_context(|| format!("cannot read {}", file.display()))?;
let (rect, element) = match analyze(&doc)? {
Analysis::HasDisplays | Analysis::NoDmdImage => return Ok(status::NOT_NEEDED),
Analysis::NoHole => return Ok(status::NO_HOLE),
Analysis::Missing(rect) => (rect, rect.element()),
Analysis::MissingVideo(rect) => (rect, rect.video_element()),
};
let fixed = insert_element(&doc, &element)?;
let backup = backup_path(backup_root, file);
if !backup.exists() {
if let Some(dir) = backup.parent() {
std::fs::create_dir_all(dir)
.with_context(|| format!("cannot create {}", dir.display()))?;
}
std::fs::write(&backup, &doc)
.with_context(|| format!("cannot write the backup {}", backup.display()))?;
}
if std::fs::read(&backup).map(|b| b.len()).unwrap_or(0) == 0 {
bail!("the backup {} is empty", backup.display());
}
let mut tmp_name = file.as_os_str().to_owned();
tmp_name.push(".pinready-tmp");
let tmp = PathBuf::from(tmp_name);
std::fs::write(&tmp, &fixed).with_context(|| format!("cannot write {}", tmp.display()))?;
if let Ok(meta) = std::fs::metadata(file) {
let _ = std::fs::set_permissions(&tmp, meta.permissions());
}
if let Err(e) = std::fs::rename(&tmp, file) {
let _ = std::fs::remove_file(&tmp);
return Err(e).with_context(|| format!("cannot replace {}", file.display()));
}
log::info!(
"b2s_dmd: {} got its DMD area X={} Y={} W={} H={} (original kept in {})",
file.display(),
rect.x,
rect.y,
rect.w,
rect.h,
backup.display()
);
Ok(status::APPLIED)
}
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageBuffer, Rgba};
fn frame(width: u32, height: u32, hole: (u32, u32, u32, u32)) -> image::DynamicImage {
let img = ImageBuffer::from_fn(width, height, |px, py| {
let (x, y, w, h) = hole;
if px >= x && px < x + w && py >= y && py < y + h {
Rgba([0, 0, 0, 255])
} else {
Rgba([120, 120, 130, 255])
}
});
image::DynamicImage::ImageRgba8(img)
}
fn png(img: &image::DynamicImage) -> Vec<u8> {
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
}
fn document(img: Option<&image::DynamicImage>, displays: bool) -> Vec<u8> {
let mut doc = String::from("<DirectB2SData Version=\"1.3\">\r\n <Images>\r\n");
if let Some(img) = img {
let b64 = base64::engine::general_purpose::STANDARD.encode(png(img));
let mut wrapped = String::new();
for (i, c) in b64.chars().enumerate() {
if i > 0 && i % 76 == 0 {
wrapped.push_str("
");
}
wrapped.push(c);
}
doc.push_str(&format!(" <DMDImage Value=\"{wrapped}\" />\r\n"));
}
doc.push_str(" </Images>\r\n");
if displays {
doc.push_str(
"<Displays><DMD Target=\"Backglass\" X=\"1\" Y=\"2\" W=\"3\" H=\"4\" /></Displays>",
);
}
doc.push_str("</DirectB2SData>");
doc.into_bytes()
}
#[test]
fn finds_the_hole_of_a_frame() {
let img = frame(1012, 309, (35, 37, 938, 234));
let rect = find_hole(&img, Shape::Dmd).expect("hole");
assert!((rect.w as i64 - 936).abs() <= 2, "{rect:?}");
assert!((rect.h as i64 - 234).abs() <= 1, "{rect:?}");
assert!(
(rect.x as i64 - 36).abs() <= 2 && (rect.y as i64 - 37).abs() <= 1,
"{rect:?}"
);
}
#[test]
fn a_hole_that_is_not_pure_black_is_still_found() {
let img = ImageBuffer::from_fn(1280, 720, |x, y| {
if (115..1165).contains(&x) && (450..710).contains(&y) {
let v = 18 + ((x - 115) * 22 / 1050) as u8;
Rgba([v, v, v, 255])
} else if y < 400 {
Rgba([12, 14, 24, 255]) } else {
Rgba([150, 110, 40, 255]) }
});
let rect = find_hole(&image::DynamicImage::ImageRgba8(img), Shape::Dmd).expect("hole");
assert!(rect.x >= 115 && rect.x + rect.w <= 1165, "{rect:?}");
assert!(rect.y >= 450 && rect.y + rect.h <= 710, "{rect:?}");
assert!(rect.w >= 1000, "{rect:?}");
}
#[test]
fn a_nearly_square_hole_is_a_video_screen() {
let img = frame(2190, 698, (719, 38, 750, 660));
let image = image::DynamicImage::ImageRgba8(img.to_rgba8());
let doc = document(Some(&image), false);
assert!(matches!(analyze(&doc).unwrap(), Analysis::MissingVideo(_)));
let rect = find_hole(&image, Shape::Video).expect("video hole");
assert!(
(rect.w as i64 - rect.h as i64).abs() <= 1,
"square: {rect:?}"
);
assert!(rect.x >= 719 && rect.x + rect.w <= 719 + 750);
assert!(rect.y >= 38 && rect.y + rect.h <= 698);
}
#[test]
fn a_video_frame_is_written_with_the_screen_as_source() {
let img = frame(2190, 698, (719, 38, 750, 660));
let doc = document(Some(&img), false);
let Analysis::MissingVideo(rect) = analyze(&doc).unwrap() else {
panic!("expected a video hole");
};
let fixed = insert_element(&doc, &rect.video_element()).unwrap();
let text = String::from_utf8(fixed).unwrap();
assert!(text.contains("Source=\"ctrl://default/display?dmd_only=0\""));
assert!(text.contains("Target=\"ScoreView\""));
assert_eq!(text.matches("<Displays>").count(), 1);
}
fn dark_frame(hole: (u32, u32, u32, u32)) -> image::DynamicImage {
let img = ImageBuffer::from_fn(2190, 698, |px, py| {
let (x, y, w, h) = hole;
if px >= x && px < x + w && py >= y && py < y + h {
Rgba([6, 6, 6, 255])
} else {
Rgba([30, 30, 30, 255])
}
});
image::DynamicImage::ImageRgba8(img)
}
#[test]
fn a_centred_video_hole_in_dark_artwork_is_found() {
let doc = document(Some(&dark_frame((734, 63, 731, 635))), false);
let Analysis::MissingVideo(rect) = analyze(&doc).unwrap() else {
panic!("expected a video hole");
};
assert!(
(rect.w as i64 - rect.h as i64).abs() <= 1,
"square: {rect:?}"
);
}
#[test]
fn a_dark_square_off_centre_or_small_is_not_a_video_screen() {
let off_centre = document(Some(&dark_frame((100, 63, 731, 635))), false);
assert_eq!(analyze(&off_centre).unwrap(), Analysis::NoHole);
let small = document(Some(&dark_frame((1000, 200, 200, 200))), false);
assert_eq!(analyze(&small).unwrap(), Analysis::NoHole);
}
#[test]
fn a_dmd_hole_wins_over_a_video_one() {
let img = frame(2000, 600, (400, 250, 1200, 300));
let doc = document(Some(&img), false);
assert!(matches!(analyze(&doc).unwrap(), Analysis::Missing(_)));
}
#[test]
fn a_plain_image_has_no_hole() {
let img = image::DynamicImage::ImageRgba8(ImageBuffer::from_pixel(
800,
300,
Rgba([90, 90, 90, 255]),
));
assert!(find_hole(&img, Shape::Dmd).is_none());
}
#[test]
fn a_small_dark_spot_is_not_a_dmd() {
let img = frame(1000, 600, (10, 10, 120, 30));
assert!(find_hole(&img, Shape::Dmd).is_none());
}
#[test]
fn a_see_through_hole_counts() {
let img = ImageBuffer::from_fn(800, 300, |x, y| {
if (40..760).contains(&x) && (50..230).contains(&y) {
Rgba([200, 200, 200, 0])
} else {
Rgba([100, 100, 100, 255])
}
});
let rect = find_hole(&image::DynamicImage::ImageRgba8(img), Shape::Dmd).expect("hole");
assert!(rect.w >= 700 && rect.h >= 170);
}
#[test]
fn analyze_reads_the_escaped_base64() {
let img = frame(1012, 309, (35, 37, 938, 234));
match analyze(&document(Some(&img), false)).unwrap() {
Analysis::Missing(r) => assert!(r.w > 900),
other => panic!("{other:?}"),
}
}
#[test]
fn an_existing_displays_element_is_left_alone() {
let img = frame(1012, 309, (35, 37, 938, 234));
assert_eq!(
analyze(&document(Some(&img), true)).unwrap(),
Analysis::HasDisplays
);
}
#[test]
fn no_frame_image_no_change() {
assert_eq!(
analyze(&document(None, false)).unwrap(),
Analysis::NoDmdImage
);
}
#[test]
fn the_element_goes_before_the_closing_tag_and_nothing_else_changes() {
let img = frame(1012, 309, (35, 37, 938, 234));
let doc = document(Some(&img), false);
let rect = DmdRect {
x: 35,
y: 37,
w: 938,
h: 234,
};
let fixed = insert_element(&doc, &rect.element()).unwrap();
let element = rect.element().into_bytes();
let at = fixed.len() - b"</DirectB2SData>".len() - element.len();
assert_eq!(&fixed[at..at + element.len()], &element[..]);
let mut back = fixed.clone();
back.drain(at..at + element.len());
assert_eq!(back, doc);
}
#[test]
fn fix_file_keeps_the_original_and_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let backups = tempfile::tempdir().unwrap();
let file = dir.path().join("Table (Maker 1990).directb2s");
let img = frame(1012, 309, (35, 37, 938, 234));
let original = document(Some(&img), false);
std::fs::write(&file, &original).unwrap();
assert_eq!(fix_file(&file, backups.path()).unwrap(), status::APPLIED);
let fixed = std::fs::read(&file).unwrap();
assert!(find(&fixed, b"<Displays><DMD Target=\"ScoreView\"", 0).is_some());
assert_eq!(
std::fs::read(backup_path(backups.path(), &file)).unwrap(),
original
);
assert!(
!dir.path()
.join("Table (Maker 1990).directb2s.pinready-tmp")
.exists()
);
assert_eq!(fix_file(&file, backups.path()).unwrap(), status::NOT_NEEDED);
assert_eq!(std::fs::read(&file).unwrap(), fixed);
assert_eq!(
std::fs::read(backup_path(backups.path(), &file)).unwrap(),
original
);
}
#[test]
fn a_file_with_no_hole_is_not_touched() {
let dir = tempfile::tempdir().unwrap();
let backups = tempfile::tempdir().unwrap();
let file = dir.path().join("x.directb2s");
let img = image::DynamicImage::ImageRgba8(ImageBuffer::from_pixel(
600,
200,
Rgba([90, 90, 90, 255]),
));
let original = document(Some(&img), false);
std::fs::write(&file, &original).unwrap();
assert_eq!(fix_file(&file, backups.path()).unwrap(), status::NO_HOLE);
assert_eq!(std::fs::read(&file).unwrap(), original);
assert!(!backup_path(backups.path(), &file).exists());
}
#[test]
fn backups_of_same_named_files_do_not_collide() {
let root = Path::new("/b");
let a = backup_path(root, Path::new("/t/one/x.directb2s"));
let b = backup_path(root, Path::new("/t/two/x.directb2s"));
assert_ne!(a, b);
assert_eq!(a.file_name(), b.file_name());
}
fn plugin_search(rgba: &image::RgbaImage, ar: f32) -> (i32, i32, i32, i32) {
let (tw, th) = (rgba.width() as i32, rgba.height() as i32);
let lum: Vec<f32> = rgba
.pixels()
.map(|p| 0.299 * f32::from(p[0]) + 0.587 * f32::from(p[1]) + 0.114 * f32::from(p[2]))
.collect();
let heuristic = |f: (i32, i32, i32, i32)| {
f.2 as f32 / tw as f32 - (0.5 - (f.0 + f.2 / 2) as f32 / tw as f32).abs()
};
let padding = (2 * tw) / 1920;
let (mut lum_min, mut lum_max) = (0f32, 256f32);
let mut search = (0, 0, tw, th);
let mut best = (0, 0, 0, 0);
for _ in 0..7 {
let limit = (lum_min + lum_max) * 0.5;
let mut sub = (0, 0, 0, 0);
let mut search_sub = (0, 0, 0, 0);
let mut st: Vec<i32> = Vec::new();
let mut heights = vec![0i32; tw as usize];
for y in search.1..search.1 + search.3 {
for x in search.0..search.0 + search.2 {
if lum[(y * tw + x) as usize] < limit {
heights[x as usize] += 1;
} else {
heights[x as usize] = 0;
}
}
for x in search.0..=search.0 + search.2 {
while let Some(&top) = st.last() {
if !(x == search.0 + search.2
|| heights[top as usize] > heights[x as usize])
{
break;
}
let height = heights[top as usize];
st.pop();
let width = match st.last() {
None => x - search.0,
Some(&t) => x - t - 1,
};
if width > 6 * padding && height > 3 * padding {
let ux = st.last().map_or(search.0, |&t| t + 1);
let unpadded = (ux, y - height + 1, width, height);
if search_sub.2 == 0 {
search_sub = unpadded;
} else {
let nx = search_sub.0.min(unpadded.0);
let ny = search_sub.1.min(unpadded.1);
let nz =
(search_sub.0 + search_sub.2).max(unpadded.0 + unpadded.2) - nx;
let nw =
(search_sub.1 + search_sub.3).max(unpadded.1 + unpadded.3) - ny;
search_sub = (nx, ny, nz, nw);
}
let mut f = (
unpadded.0 + padding,
unpadded.1 + padding,
unpadded.2 - 2 * padding,
unpadded.3 - 2 * padding,
);
if f.3 > 0 && f.2 as f32 / f.3 as f32 > ar {
let w = (f.3 as f32 * ar) as i32;
f.0 += (f.2 - w) / 2;
f.2 = w;
} else if f.3 > 0 {
let h = (f.2 as f32 / ar) as i32;
f.1 += (f.3 - h) / 2;
f.3 = h;
}
if 3 * f.2 >= tw && heuristic(f) > heuristic(sub) {
sub = f;
}
}
}
if x < search.0 + search.2 {
st.push(x);
}
}
}
if sub.3 > 0 {
lum_max = limit;
best = sub;
search = search_sub;
} else {
lum_min = limit;
}
}
best
}
#[test]
#[ignore]
fn plugin_detect() {
for file in std::env::var("B2S_DMD_FILES")
.unwrap_or_default()
.split(':')
.filter(|f| !f.is_empty())
{
let doc = std::fs::read(file).unwrap();
let Some(bytes) = dmd_image_bytes(&doc).unwrap() else {
println!("{file}: NOIMAGE");
continue;
};
let img = image::load_from_memory(&bytes).unwrap().to_rgba8();
println!(
"{file}: PLUGIN {:?} img={}x{}",
plugin_search(&img, 4.0),
img.width(),
img.height()
);
}
}
#[test]
#[ignore]
fn real_files() {
for file in std::env::var("B2S_DMD_FILES")
.unwrap_or_default()
.split(':')
.filter(|f| !f.is_empty())
{
let doc = std::fs::read(file).unwrap();
println!("{file}: {:?}", analyze(&doc));
}
}
}