use crate::problems::Problems;
use crate::sift::{Features, Keypoint, DESC_LEN};
use crate::verify::Thumb;
use anyhow::{anyhow, bail, Result};
use rayon::prelude::*;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
const MAGIC: &[u8; 8] = b"IMGFPC03";
const MAGIC_PREFIX: &[u8; 6] = b"IMGFPC";
const BATCH: usize = 64;
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct Settings {
pub work_size: u32,
pub features: u32,
pub thumb: u32,
}
pub struct Record {
pub feats: Features,
pub thumb: Thumb,
}
#[derive(Clone, Copy)]
pub struct Key {
pub len: u64,
pub mtime: i64,
}
const FILE_NAME: &str = "analysis.bin";
fn default_dir() -> PathBuf {
std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("img-fp")
}
pub fn resolve_path(explicit: Option<&Path>, problems: &mut Problems) -> Option<PathBuf> {
let path = match explicit {
Some(given) => {
let names_a_dir = given.is_dir() || given.to_string_lossy().ends_with('/');
if names_a_dir { given.join(FILE_NAME) } else { given.to_path_buf() }
}
None => default_dir().join(FILE_NAME),
};
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
if let Err(e) = std::fs::create_dir_all(dir) {
problems.cache(format!("could not create {}: {e}", dir.display()));
return None;
}
}
Some(path)
}
pub fn key_of(path: &Path) -> Option<Key> {
let md = std::fs::metadata(path).ok()?;
let mtime = md.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_nanos() as i64;
Some(Key { len: md.len(), mtime })
}
fn field_of(kp: &Keypoint, i: usize) -> f32 {
match i {
0 => kp.x,
1 => kp.y,
2 => kp.sigma,
3 => kp.angle,
_ => kp.response,
}
}
const FIELDS: usize = 5;
fn paeth(a: u8, b: u8, c: u8) -> u8 {
let p = a as i16 + b as i16 - c as i16;
let (pa, pb, pc) = ((p - a as i16).abs(), (p - b as i16).abs(), (p - c as i16).abs());
if pa <= pb && pa <= pc {
a
} else if pb <= pc {
b
} else {
c
}
}
fn pack(f: &Features, t: &Thumb) -> Result<Vec<u8>> {
let n = f.kps.len();
let mut planes = Vec::with_capacity(n * FIELDS * 4);
let mut bits: Vec<[u8; 4]> = Vec::with_capacity(n);
for field in 0..FIELDS {
bits.clear();
bits.extend(f.kps.iter().map(|kp| field_of(kp, field).to_le_bytes()));
for byte in 0..4 {
planes.extend(bits.iter().map(|v| v[byte]));
}
}
let (w, h) = (t.w as usize, t.h as usize);
let mut resid = Vec::with_capacity(w * h);
let mut prev = vec![0u8; w];
for y in 0..h {
let row = &t.px[y * w..(y + 1) * w];
let (mut left, mut upleft) = (0u8, 0u8);
for x in 0..w {
let up = prev[x];
resid.push(row[x].wrapping_sub(paeth(left, up, upleft)));
left = row[x];
upleft = up;
}
prev.copy_from_slice(row);
}
let mut out = Vec::with_capacity(n * DESC_LEN);
for stream in [&planes, &f.desc, &resid] {
let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::new(6));
z.write_all(stream)?;
let z = z.finish()?;
out.extend_from_slice(&(z.len() as u64).to_le_bytes());
out.extend_from_slice(&z);
}
Ok(out)
}
fn unpack(blob: &[u8], n: usize, tw: u16, th: u16) -> Result<(Vec<Keypoint>, Vec<u8>, Vec<u8>)> {
let (w, h) = (tw as usize, th as usize);
let mut at = 0;
let mut stream = |want: usize| -> Result<Vec<u8>> {
if at + 8 > blob.len() {
bail!("record truncated");
}
let len = u64::from_le_bytes(blob[at..at + 8].try_into().unwrap()) as usize;
at += 8;
if at + len > blob.len() {
bail!("record truncated");
}
let mut raw = Vec::with_capacity(want);
flate2::read::DeflateDecoder::new(&blob[at..at + len])
.take(want as u64 + 1)
.read_to_end(&mut raw)?;
at += len;
if raw.len() != want {
bail!("a record's stream is {} bytes unpacked, not {want}", raw.len());
}
Ok(raw)
};
let planes = stream(n * FIELDS * 4)?;
let desc = stream(n * DESC_LEN)?;
let resid = stream(w * h)?;
let mut kps = vec![Keypoint { x: 0.0, y: 0.0, sigma: 0.0, angle: 0.0, response: 0.0 }; n];
let mut bits = vec![[0u8; 4]; n];
for field in 0..FIELDS {
for byte in 0..4 {
let plane = &planes[(field * 4 + byte) * n..(field * 4 + byte + 1) * n];
for (v, &b) in bits.iter_mut().zip(plane) {
v[byte] = b;
}
}
for (kp, v) in kps.iter_mut().zip(bits.iter()) {
let f = f32::from_le_bytes(*v);
match field {
0 => kp.x = f,
1 => kp.y = f,
2 => kp.sigma = f,
3 => kp.angle = f,
_ => kp.response = f,
}
}
}
let mut px = vec![0u8; w * h];
let mut prev = vec![0u8; w];
for y in 0..h {
let (mut left, mut upleft) = (0u8, 0u8);
let (src, dst) = (&resid[y * w..(y + 1) * w], &mut px[y * w..(y + 1) * w]);
for x in 0..w {
let up = prev[x];
let v = src[x].wrapping_add(paeth(left, up, upleft));
dst[x] = v;
left = v;
upleft = up;
}
prev.copy_from_slice(dst);
}
Ok((kps, desc, px))
}
struct Buf<W: Write>(W);
impl<W: Write> Buf<W> {
fn u32(&mut self, v: u32) -> Result<()> {
self.0.write_all(&v.to_le_bytes())?;
Ok(())
}
fn u64(&mut self, v: u64) -> Result<()> {
self.0.write_all(&v.to_le_bytes())?;
Ok(())
}
fn i64(&mut self, v: i64) -> Result<()> {
self.0.write_all(&v.to_le_bytes())?;
Ok(())
}
fn f32(&mut self, v: f32) -> Result<()> {
self.0.write_all(&v.to_le_bytes())?;
Ok(())
}
fn bytes(&mut self, v: &[u8]) -> Result<()> {
self.u64(v.len() as u64)?;
self.0.write_all(v)?;
Ok(())
}
}
enum Reject {
Stale,
Damaged(anyhow::Error),
}
impl From<anyhow::Error> for Reject {
fn from(e: anyhow::Error) -> Self {
Reject::Damaged(e)
}
}
pub fn load(path: &Path, want: Settings, problems: &mut Problems) -> HashMap<String, (Key, Record)> {
let mut out = HashMap::new();
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return out,
Err(e) => {
problems.cache(format!("could not read {}: {e}", path.display()));
return out;
}
};
match read_stream(std::io::BufReader::with_capacity(1 << 20, file), want, &mut out) {
Ok(()) => out,
Err(Reject::Stale) => HashMap::new(),
Err(Reject::Damaged(e)) => {
problems.cache(format!("ignoring {}: {e}", path.display()));
HashMap::new()
}
}
}
struct Head {
path: String,
key: Key,
w: u32,
h: u32,
n: usize,
tw: u16,
th: u16,
scale: f32,
}
struct Rd<R: Read>(R);
impl<R: Read> Rd<R> {
fn take(&mut self, n: usize) -> Result<Vec<u8>> {
let mut v = vec![0u8; n];
self.0.read_exact(&mut v).map_err(|_| anyhow!("cache truncated"))?;
Ok(v)
}
fn arr<const N: usize>(&mut self) -> Result<[u8; N]> {
let mut v = [0u8; N];
self.0.read_exact(&mut v).map_err(|_| anyhow!("cache truncated"))?;
Ok(v)
}
fn u32(&mut self) -> Result<u32> {
Ok(u32::from_le_bytes(self.arr()?))
}
fn u64(&mut self) -> Result<u64> {
Ok(u64::from_le_bytes(self.arr()?))
}
fn i64(&mut self) -> Result<i64> {
Ok(i64::from_le_bytes(self.arr()?))
}
fn f32(&mut self) -> Result<f32> {
Ok(f32::from_le_bytes(self.arr()?))
}
fn len_or_eof(&mut self) -> Result<Option<usize>> {
let mut v = [0u8; 8];
let mut got = 0;
while got < 8 {
match self.0.read(&mut v[got..]) {
Ok(0) if got == 0 => return Ok(None),
Ok(0) => bail!("cache truncated"),
Ok(k) => got += k,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => return Err(e.into()),
}
}
Ok(Some(u64::from_le_bytes(v) as usize))
}
}
const MAX_PATH: usize = 1 << 16;
fn read_stream<R: Read>(r: R, want: Settings, out: &mut HashMap<String, (Key, Record)>) -> Result<(), Reject> {
let mut r = Rd(r);
let magic: [u8; 8] = r.arr().map_err(|_| anyhow!("not a cache file"))?;
if &magic[..MAGIC_PREFIX.len()] != MAGIC_PREFIX {
return Err(anyhow!("not a cache file").into());
}
if &magic != MAGIC {
return Err(Reject::Stale);
}
let got = Settings { work_size: r.u32()?, features: r.u32()?, thumb: r.u32()? };
if got != want {
return Err(Reject::Stale);
}
let mut batch: Vec<(Head, Vec<u8>)> = Vec::with_capacity(BATCH);
while let Some(len) = r.len_or_eof()? {
if len > MAX_PATH {
return Err(anyhow!("record claims a {len}-byte path").into());
}
let head = Head {
path: String::from_utf8_lossy(&r.take(len)?).into_owned(),
key: Key { len: r.u64()?, mtime: r.i64()? },
w: r.u32()?,
h: r.u32()?,
n: r.u32()? as usize,
tw: r.u32()? as u16,
th: r.u32()? as u16,
scale: r.f32()?,
};
let packed = r.u64()? as usize;
let blob = r.take(packed)?;
batch.push((head, blob));
if batch.len() == BATCH {
unpack_batch(&mut batch, out)?;
}
}
unpack_batch(&mut batch, out)?;
Ok(())
}
fn unpack_batch(batch: &mut Vec<(Head, Vec<u8>)>, out: &mut HashMap<String, (Key, Record)>) -> Result<(), Reject> {
let done: Vec<Result<(String, (Key, Record))>> = batch
.par_iter()
.map(|(h, blob)| {
let (kps, desc, px) = unpack(blob, h.n, h.tw, h.th)?;
Ok((
h.path.clone(),
(
h.key,
Record {
feats: Features { w: h.w, h: h.h, kps, desc },
thumb: Thumb::new(h.tw, h.th, h.scale, px),
},
),
))
})
.collect();
batch.clear();
for r in done {
let (path, rec) = r?;
out.insert(path, rec);
}
Ok(())
}
pub fn carry_over(cached: &HashMap<String, (Key, Record)>) -> Vec<(&str, Key, &Features, &Thumb)> {
let mut out: Vec<_> = cached
.iter()
.filter(|(path, _)| Path::new(path.as_str()).exists())
.map(|(path, (k, rec))| (path.as_str(), *k, &rec.feats, &rec.thumb))
.collect();
out.sort_unstable_by_key(|e| e.0);
out
}
pub fn save(path: &Path, settings: Settings, entries: &[(&str, Key, &Features, &Thumb)]) -> Result<()> {
let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
if let Some(d) = tmp.parent() {
std::fs::create_dir_all(d).ok();
}
let file = std::fs::File::create(&tmp)?;
let mut b = Buf(std::io::BufWriter::with_capacity(1 << 20, file));
let wrote = (|| -> Result<()> {
b.0.write_all(MAGIC)?;
b.u32(settings.work_size)?;
b.u32(settings.features)?;
b.u32(settings.thumb)?;
for chunk in entries.chunks(BATCH) {
let packed: Vec<Result<Vec<u8>>> =
chunk.par_iter().map(|(_, _, f, t)| pack(f, t)).collect();
for ((p, k, f, t), blob) in chunk.iter().zip(packed) {
let blob = blob?;
b.bytes(p.as_bytes())?;
b.u64(k.len)?;
b.i64(k.mtime)?;
b.u32(f.w)?;
b.u32(f.h)?;
b.u32(f.kps.len() as u32)?;
b.u32(t.w as u32)?;
b.u32(t.h as u32)?;
b.f32(t.scale)?;
b.bytes(&blob)?;
}
}
b.0.flush()?;
Ok(())
})();
drop(b);
if let Err(e) = wrote.and_then(|()| Ok(std::fs::rename(&tmp, path)?)) {
std::fs::remove_file(&tmp).ok();
return Err(e);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("img-fp-cache-test-{}-{name}", std::process::id()));
std::fs::remove_dir_all(&d).ok();
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_named_path_is_a_file_unless_it_is_a_directory() {
let log = crate::problems::Log::default();
let mut problems = Problems::new(&log);
let dir = scratch("named");
let named_file = dir.join("mine.bin");
assert_eq!(resolve_path(Some(&named_file), &mut problems).unwrap(), named_file);
assert_eq!(
resolve_path(Some(&dir), &mut problems).unwrap(),
dir.join(FILE_NAME)
);
let trailing = PathBuf::from(format!("{}/", dir.join("sub").display()));
assert_eq!(
resolve_path(Some(&trailing), &mut problems).unwrap(),
dir.join("sub").join(FILE_NAME)
);
let deep = dir.join("one/two/three.bin");
assert_eq!(resolve_path(Some(&deep), &mut problems).unwrap(), deep);
assert!(dir.join("one/two").is_dir());
assert!(!problems.any());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_packed_record_comes_back_bit_for_bit() {
for (n, w, h) in [(0usize, 1usize, 1usize), (1, 1, 7), (3, 7, 1), (137, 40, 31)] {
let kps: Vec<Keypoint> = (0..n)
.map(|i| {
let f = i as f32;
Keypoint {
x: f * 1.37 + 0.5,
y: 383.0 - f * 0.919,
sigma: 1.6 * (1.0 + f / 64.0),
angle: (f * 17.3) % 360.0,
response: 0.001 + f / 100_000.0,
}
})
.collect();
let desc: Vec<u8> = (0..n * DESC_LEN).map(|i| ((i * 37 + i / 128) % 256) as u8).collect();
let px: Vec<u8> = (0..w * h).map(|i| ((i * 91) % 256) as u8).collect();
let feats = Features { w: 640, h: 480, kps, desc };
let thumb = Thumb::new(w as u16, h as u16, 0.25, px);
let blob = pack(&feats, &thumb).unwrap();
let (kps, desc, px) = unpack(&blob, n, w as u16, h as u16).unwrap();
assert_eq!(desc, feats.desc);
assert_eq!(px, thumb.px);
assert_eq!(kps.len(), feats.kps.len());
for (got, want) in kps.iter().zip(feats.kps.iter()) {
assert_eq!(got.x.to_bits(), want.x.to_bits());
assert_eq!(got.y.to_bits(), want.y.to_bits());
assert_eq!(got.sigma.to_bits(), want.sigma.to_bits());
assert_eq!(got.angle.to_bits(), want.angle.to_bits());
assert_eq!(got.response.to_bits(), want.response.to_bits());
}
}
}
#[test]
fn a_short_record_is_rejected_rather_than_trusted() {
let feats = Features { w: 8, h: 8, kps: Vec::new(), desc: Vec::new() };
let thumb = Thumb::new(4, 4, 1.0, vec![7; 16]);
let blob = pack(&feats, &thumb).unwrap();
assert!(unpack(&blob[..blob.len() - 1], 0, 4, 4).is_err());
assert!(unpack(&blob, 0, 8, 8).is_err(), "a record that claims more than it holds");
}
#[test]
fn carry_over_keeps_what_is_still_on_disk_and_not_this_run() {
let dir = scratch("carry");
let here = dir.join("here.jpg");
std::fs::write(&here, b"x").unwrap();
let gone = dir.join("gone.jpg");
let record = || (
Key { len: 1, mtime: 2 },
Record {
feats: Features { w: 1, h: 1, kps: Vec::new(), desc: Vec::new() },
thumb: Thumb::new(1, 1, 1.0, vec![0]),
},
);
let mut cached = HashMap::new();
cached.insert(here.display().to_string(), record());
cached.insert(gone.display().to_string(), record());
cached.insert("scanned-this-run.jpg".to_string(), record());
cached.remove("scanned-this-run.jpg");
let kept = carry_over(&cached);
assert_eq!(kept.len(), 1, "only the untouched file that still exists");
assert_eq!(kept[0].0, here.display().to_string());
std::fs::remove_dir_all(&dir).ok();
}
}