Compare commits

..

2 Commits

Author SHA1 Message Date
mark 848ed145bf update web images.
continuous-integration/drone Build is failing
2026-07-10 10:31:28 +02:00
mark c09219c462 blog updates. more demo assets, SURF-attempts and more 2026-07-09 18:03:37 +02:00
21 changed files with 5168 additions and 424 deletions
+14
View File
@@ -15,3 +15,17 @@ thumbcache/
legacy/ legacy/
*.pdf *.pdf
run-*.sh run-*.sh
# demo page source assets: re-include web/img, which the broad img/ and
# *.png patterns above would otherwise swallow
!web/img/
!web/img/*
# demo page build artifacts, regenerated by `cargo run --release --bin
# demoassets`, `cargo run --release --bin storekeys` and
# `python3 pr_to_json.py` (needed when deploying the page)
web/flickr/
web/pr-data.json
# playwright-core for verify-retrieval.mjs (npm install --no-save playwright-core)
node_modules/
+15
View File
@@ -52,3 +52,18 @@ required-features = ["cli"]
name = "bench" name = "bench"
path = "src/bin/bench.rs" path = "src/bin/bench.rs"
required-features = ["cli"] required-features = ["cli"]
[[bin]]
name = "demoassets"
path = "src/bin/demoassets.rs"
required-features = ["cli"]
[[bin]]
name = "storekeys"
path = "src/bin/storekeys.rs"
required-features = ["cli"]
[[bin]]
name = "salientlab"
path = "src/bin/salientlab.rs"
required-features = ["cli"]
+69
View File
@@ -0,0 +1,69 @@
//! Generates the static assets for the web demo ranking pool:
//! a thumbnail per image plus hashes.json with the three hashes,
//! computed from the full-size images.
use clap::Parser;
use image_similarity::descriptors::{Descriptor, Median, DCT, PHash};
use image_similarity::open_image;
use rayon::prelude::*;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
#[derive(Parser)]
struct Args {
/// Directory with source images
#[arg(default_value = "smolflickr")]
source: PathBuf,
/// Output directory, thumbnails go in <out>/thumbs
#[arg(default_value = "web/flickr")]
out: PathBuf,
/// Thumbnail bounding box in pixels
#[arg(long, default_value_t = 140)]
thumb: u32,
}
fn main() {
let args = Args::parse();
let thumbs = args.out.join("thumbs");
fs::create_dir_all(&thumbs).expect("creating output dir");
let mut names: Vec<String> = fs::read_dir(&args.source)
.expect("reading source dir")
.filter_map(|entry| {
let entry = entry.ok()?;
entry.path().is_file().then(|| entry.file_name().to_string_lossy().into_owned())
})
.collect();
names.sort();
let entries: Vec<String> = names
.par_iter()
.filter_map(|name| {
let img = match open_image(args.source.join(name)) {
Ok(img) => img,
Err(e) => {
eprintln!("skipping {name}: {e}");
return None;
}
};
let dct = DCT::new().describe(&img);
let median = Median.describe(&img);
let phash = PHash.describe(&img);
let thumb = img.thumbnail(args.thumb, args.thumb);
let mut buffer = std::io::Cursor::new(Vec::new());
thumb
.to_rgb8()
.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buffer, 80))
.expect("encoding thumbnail");
fs::write(thumbs.join(name), buffer.get_ref()).expect("writing thumbnail");
Some(format!("[\"{name}\",\"{dct:016x}\",\"{median:016x}\",\"{phash:016x}\"]"))
})
.collect();
let json = format!("[\n{}\n]\n", entries.join(",\n"));
let mut file = fs::File::create(args.out.join("hashes.json")).expect("creating hashes.json");
file.write_all(json.as_bytes()).expect("writing hashes.json");
println!("{} images -> {}", entries.len(), args.out.display());
}
+853
View File
@@ -0,0 +1,853 @@
//! Throwaway lab for the salient-point hash variants.
//!
//! Two modes:
//! - default: small-sample table of per-mutation Hamming distances plus
//! detector repeatability, quick iteration on a few hundred images
//! - --pr: the full run. Calibrates on the first --calib images, hashes
//! every image and mutant under all variants (checkpointed to
//! --hashes with resume), then computes PR curves with the same
//! tally semantics as DescriptorStore::get_stats and plots one PNG
//! per variant. The dct hash rides along as a reference method.
//!
//! Not part of the real benchmark pipeline.
use clap::Parser;
use image_similarity::descriptors::{Descriptor, DCT};
use image_similarity::mutators::{get_all_mutators, CropCenter, Mutator, Rotate};
use image_similarity::open_image;
use image_similarity::salient::{
binarize64, hash_strongest, pooled_vector, Feature, Salient,
};
use plotters::prelude::*;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
#[derive(Parser)]
#[command(about = "Salient-point hash experiments")]
struct Args {
/// Directory with base images
#[arg(long, default_value = "smolflickr")]
dir: PathBuf,
/// Number of images to sample (default: 200 for the table, all for --pr)
#[arg(long)]
count: Option<usize>,
/// Keypoints kept per image
#[arg(long, default_value_t = 100)]
features: usize,
/// Keypoints considered for the repeatability measurement
#[arg(long, default_value_t = 50)]
repeat_n: usize,
/// Full PR-curve run over the whole directory
#[arg(long, default_value_t = false)]
pr: bool,
/// Images used to calibrate codebook and thresholds in --pr mode
#[arg(long, default_value_t = 2000)]
calib: usize,
/// Checkpoint file for the hashes in --pr mode
#[arg(long, default_value = "salient-hashes.mp")]
hashes: PathBuf,
/// Skip hashing, compute PR curves from an existing checkpoint file
#[arg(long, default_value_t = false)]
stats_only: bool,
}
const VARIANTS: [&str; 8] = [
"pooled", "strongest", "consensus", "pooled-cal", "pooled-sim", "cons-cal", "words-any",
"words-med",
];
/// All hashed methods: the salient variants plus the dct reference
const METHODS: usize = VARIANTS.len() + 1;
fn method_name(v: usize) -> &'static str {
if v < VARIANTS.len() {
VARIANTS[v]
} else {
"dct-ref"
}
}
/// Per image-version summary statistics that the hash variants read from
struct Stats {
/// Response-weighted mean descriptor, unit normalized
pooled: [f32; 64],
/// Fraction of keypoints voting 1 on each binarize64 bit position
votes: [f32; 64],
/// Keypoints assigned to each of the 64 visual words
counts: [u32; 64],
}
struct Calibration {
/// The visual-word codebook
centroids: Vec<[f32; 64]>,
/// Per-dimension corpus median of the pooled vector
pooled_med: [f32; 64],
/// Corpus median vote fraction per bit
vote_med: [f32; 64],
/// Corpus median count per visual word
word_med: [u32; 64],
/// Random +-1 hyperplanes for the simhash variant
planes: Vec<[f32; 64]>,
}
/// Checkpoint file contents for --pr mode. Per image: one hash per method
/// for the base image followed by every mutant, in mutator order.
#[derive(Serialize, Deserialize)]
struct HashFile {
tags: Vec<String>,
results: Vec<(String, Vec<[u64; METHODS]>)>,
}
fn main() {
let args = Args::parse();
if args.pr {
run_pr(&args);
} else {
run_table(&args);
}
}
/// The standard suite (which now carries a hard rotate45) plus a mid
/// rotation and a heavy crop, to trace the geometric falloff more finely
fn lab_mutators() -> Vec<Box<dyn Mutator>> {
let mut mutators = get_all_mutators();
mutators.push(Box::new(Rotate { degrees: 15.0 }));
mutators.push(Box::new(CropCenter { keep_percent: 50 }));
mutators
}
fn image_paths(dir: &Path, count: usize) -> Vec<(String, PathBuf)> {
let mut paths: Vec<(String, PathBuf)> = std::fs::read_dir(dir)
.expect("cannot read image dir")
.filter_map(|e| e.ok())
.filter(|e| {
matches!(
e.path().extension().and_then(|x| x.to_str()),
Some("jpg") | Some("jpeg") | Some("png")
)
})
.filter_map(|e| e.file_name().into_string().ok().map(|n| (n, e.path())))
.collect();
paths.sort();
paths.truncate(count);
assert!(!paths.is_empty(), "no images found in {:?}", dir);
paths
}
// ---------------------------------------------------------------------------
// full PR run
fn run_pr(args: &Args) {
let paths = image_paths(&args.dir, args.count.unwrap_or(usize::MAX));
let salient = Salient {
params: image_similarity::salient::Params {
max_features: args.features,
..Default::default()
},
};
let mutators = lab_mutators();
let tags: Vec<String> = mutators.iter().map(|m| m.tag()).collect();
let mut file = load_hashes(&args.hashes).unwrap_or(HashFile {
tags: tags.clone(),
results: Vec::new(),
});
assert_eq!(
file.tags, tags,
"{} was made with a different mutator suite, move it away first",
args.hashes.display()
);
if !args.stats_only {
// calibration is deterministic, so recomputing it on resume is safe
let calib = calibrate(&paths[..args.calib.min(paths.len())], &salient);
let done: HashSet<&String> = file.results.iter().map(|(n, _)| n).collect();
let todo: Vec<&(String, PathBuf)> =
paths.iter().filter(|(n, _)| !done.contains(n)).collect();
drop(done);
println!(
"{} images total, {} still to hash, {} mutations, {} methods",
paths.len(),
todo.len(),
mutators.len(),
METHODS
);
let t0 = Instant::now();
let mut last_save = Instant::now();
let mut hashed = 0usize;
for batch in todo.chunks(512) {
let out: Vec<(String, Vec<[u64; METHODS]>)> = batch
.par_iter()
.filter_map(|(name, path)| {
let img = open_image(path).ok()?;
let mut versions = Vec::with_capacity(mutators.len() + 1);
versions.push(hash_methods(&img, &salient, &calib));
for m in &mutators {
versions.push(hash_methods(&m.mutate(&img), &salient, &calib));
}
Some((name.clone(), versions))
})
.collect();
file.results.extend(out);
hashed += batch.len();
let rate = hashed as f32 / t0.elapsed().as_secs_f32();
eprintln!(
" {} / {} images, {:.1} img/s, ~{:.0} min left",
hashed,
todo.len(),
rate,
(todo.len() - hashed) as f32 / rate / 60.0
);
if last_save.elapsed().as_secs() >= 120 {
save_hashes(&args.hashes, &file);
last_save = Instant::now();
}
}
save_hashes(&args.hashes, &file);
println!(
"hashing done in {:.1} min, {} images in {}",
t0.elapsed().as_secs_f32() / 60.0,
file.results.len(),
args.hashes.display()
);
}
pr_stats(&file, &mutators);
}
fn calibrate(paths: &[(String, PathBuf)], salient: &Salient) -> Calibration {
println!("calibrating on {} images...", paths.len());
let t0 = Instant::now();
let feats: Vec<Vec<Feature>> = paths
.par_iter()
.filter_map(|(_, path)| Some(salient.features(&open_image(path).ok()?)))
.collect();
let samples: Vec<[f32; 64]> = feats
.iter()
.flat_map(|f| f.iter().take(50).map(|f| f.desc))
.collect();
let centroids = kmeans(&samples, 64, 25);
let stats: Vec<Stats> = feats
.par_iter()
.map(|f| stats_for(f, &centroids))
.collect();
let calib = Calibration {
pooled_med: column_median(&stats, |s| s.pooled),
vote_med: column_median(&stats, |s| s.votes),
word_med: column_median_u32(&stats),
planes: random_planes(64),
centroids,
};
println!(
"calibration took {:.1}s ({} descriptors)",
t0.elapsed().as_secs_f32(),
samples.len()
);
calib
}
fn hash_methods(img: &image::DynamicImage, salient: &Salient, calib: &Calibration) -> [u64; METHODS] {
let feats = salient.features(img);
let stats = stats_for(&feats, &calib.centroids);
let v = variant_hashes(&feats, &stats, calib);
let mut out = [0u64; METHODS];
out[..VARIANTS.len()].copy_from_slice(&v);
out[VARIANTS.len()] = DCT::new().describe(img);
out
}
fn load_hashes(path: &Path) -> Option<HashFile> {
let bytes = std::fs::read(path).ok()?;
match rmp_serde::from_slice(&bytes) {
Ok(file) => Some(file),
Err(e) => panic!("cannot parse {}: {e}", path.display()),
}
}
fn save_hashes(path: &Path, file: &HashFile) {
let bytes = rmp_serde::to_vec(file).expect("serialize hashes");
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes).expect("write hashes");
std::fs::rename(&tmp, path).expect("rename hashes");
}
/// Same tally as DescriptorStore::get_stats, done by brute force instead of
/// the BK-tree: every base compares against every other hash. A hit is a
/// true positive when it is the base's own mutant, a false positive for
/// mutator m when it is an m-mutant of another image, and a false positive
/// for every mutator when it is a different base image.
fn pr_stats(file: &HashFile, mutators: &[Box<dyn Mutator>]) {
let max_threshold = 24usize;
let n = file.results.len();
let nm = mutators.len();
println!("\ncomputing PR curves over {n} images...");
for v in 0..METHODS {
let t0 = Instant::now();
let bases: Vec<u64> = file.results.iter().map(|(_, h)| h[0][v]).collect();
let muts: Vec<Vec<u64>> = (0..nm)
.map(|m| file.results.iter().map(|(_, h)| h[m + 1][v]).collect())
.collect();
#[derive(Clone)]
struct Hists {
// 65 slots: two hashes can differ in all 64 bits
base_fp: [u64; 65],
tp: Vec<[u64; 65]>,
fp: Vec<[u64; 65]>,
}
let empty = || Hists {
base_fp: [0; 65],
tp: vec![[0; 65]; nm],
fp: vec![[0; 65]; nm],
};
let hists = (0..n)
.into_par_iter()
.fold(empty, |mut h, i| {
let hb = bases[i];
for (j, other) in bases.iter().enumerate() {
if j != i {
h.base_fp[(hb ^ other).count_ones() as usize] += 1;
}
}
for m in 0..nm {
let mm = &muts[m];
h.tp[m][(hb ^ mm[i]).count_ones() as usize] += 1;
for (j, other) in mm.iter().enumerate() {
if j != i {
h.fp[m][(hb ^ other).count_ones() as usize] += 1;
}
}
}
h
})
.reduce(empty, |mut a, b| {
for (x, y) in a.base_fp.iter_mut().zip(&b.base_fp) {
*x += y;
}
for m in 0..nm {
for (x, y) in a.tp[m].iter_mut().zip(&b.tp[m]) {
*x += y;
}
for (x, y) in a.fp[m].iter_mut().zip(&b.fp[m]) {
*x += y;
}
}
a
});
let cumulative = |at: &[u64; 65]| {
let mut cum = [0u64; 65];
let mut sum = 0;
for (t, count) in at.iter().enumerate() {
sum += count;
cum[t] = sum;
}
cum
};
let fp_base = cumulative(&hists.base_fp);
println!(
"\nStats for salient {} ({:.1}s)",
method_name(v),
t0.elapsed().as_secs_f32()
);
let curves: Vec<(String, Vec<(f64, f64)>)> = (0..nm)
.map(|m| {
let tp = cumulative(&hists.tp[m]);
let fp_mut = cumulative(&hists.fp[m]);
let curve: Vec<(f64, f64)> = (0..max_threshold)
.map(|t| {
let tpos = tp[t] as f64;
let fpos = (fp_mut[t] + fp_base[t]) as f64;
let recall = tpos / n as f64;
let precision = if tpos + fpos == 0.0 {
1.0
} else {
tpos / (tpos + fpos)
};
(recall, precision)
})
.collect();
(mutators[m].info(), curve)
})
.collect();
for (name, curve) in &curves {
println!("{name}:");
for (r, p) in curve {
println!("{r}, {p}");
}
}
plot_pr(&format!("sal-{}-pr.png", method_name(v)), method_name(v), &curves);
}
println!("\nwrote sal-<method>-pr.png for {METHODS} methods");
}
/// Same look as the PR plots from main.rs
fn plot_pr(filename: &str, method: &str, curves: &[(String, Vec<(f64, f64)>)]) {
let root = BitMapBackend::new(filename, (1024, 768)).into_drawing_area();
root.fill(&WHITE).unwrap();
let mut chart = ChartBuilder::on(&root)
.caption(
format!("PR curves (salient {method})"),
("sans-serif", 40).into_font(),
)
.margin(25)
.set_all_label_area_size(50)
.build_cartesian_2d(0f64..1f64, 0f64..1f64)
.unwrap();
chart
.configure_mesh()
.x_labels(10)
.y_labels(10)
.disable_mesh()
.x_desc("Recall")
.y_desc("Precision")
.x_label_formatter(&|x| format!("{x:.3}"))
.y_label_formatter(&|x| format!("{x:.3}"))
.draw()
.unwrap();
for (i, (name, curve)) in curves.iter().enumerate() {
let color = Palette99::pick(i).to_rgba();
chart
.draw_series(LineSeries::new(curve.clone(), color.filled()).point_size(2))
.unwrap()
.label(name)
.legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], color));
}
chart
.configure_series_labels()
.position(SeriesLabelPosition::MiddleLeft)
.border_style(BLACK)
.draw()
.unwrap();
}
// ---------------------------------------------------------------------------
// small-sample distance table
struct MutData {
tag: String,
w: f32,
h: f32,
feats: Vec<Feature>,
dct: u64,
}
struct ImageData {
w: f32,
h: f32,
base: Vec<Feature>,
dct: u64,
muts: Vec<MutData>,
}
fn run_table(args: &Args) {
let paths = image_paths(&args.dir, args.count.unwrap_or(200));
let salient = Salient {
params: image_similarity::salient::Params {
max_features: args.features,
..Default::default()
},
};
let mutators = lab_mutators();
println!(
"{} images, {} mutations, {} keypoints per image",
paths.len(),
mutators.len(),
args.features
);
let t0 = Instant::now();
let done = AtomicUsize::new(0);
let data: Vec<ImageData> = paths
.par_iter()
.filter_map(|(_, path)| {
let img = open_image(path).ok()?;
let dct = DCT::new();
let base = salient.features(&img);
let muts = mutators
.iter()
.map(|m| {
let mutated = m.mutate(&img);
MutData {
tag: m.tag(),
w: mutated.width() as f32,
h: mutated.height() as f32,
feats: salient.features(&mutated),
dct: dct.describe(&mutated),
}
})
.collect();
let n = done.fetch_add(1, Ordering::Relaxed) + 1;
if n % 50 == 0 {
eprintln!(" {} / {} images", n, paths.len());
}
Some(ImageData {
w: img.width() as f32,
h: img.height() as f32,
base,
dct: dct.describe(&img),
muts,
})
})
.collect();
let avg_feats: f32 =
data.iter().map(|d| d.base.len() as f32).sum::<f32>() / data.len() as f32;
println!(
"extraction: {:.1}s, avg {:.0} keypoints per base image",
t0.elapsed().as_secs_f32(),
avg_feats
);
repeatability(&data, &mutators, args.repeat_n);
// codebook, then per-version stats, then corpus calibration
let samples: Vec<[f32; 64]> = data
.iter()
.flat_map(|d| d.base.iter().take(50).map(|f| f.desc))
.collect();
println!("\ntraining 64-word codebook on {} descriptors...", samples.len());
let centroids = kmeans(&samples, 64, 25);
let base_stats: Vec<Stats> = data
.par_iter()
.map(|d| stats_for(&d.base, &centroids))
.collect();
let mut_stats: Vec<Vec<Stats>> = data
.par_iter()
.map(|d| d.muts.iter().map(|m| stats_for(&m.feats, &centroids)).collect())
.collect();
let calib = Calibration {
pooled_med: column_median(&base_stats, |s| s.pooled),
vote_med: column_median(&base_stats, |s| s.votes),
word_med: column_median_u32(&base_stats),
planes: random_planes(64),
centroids,
};
let base_hashes: Vec<[u64; 8]> = data
.iter()
.zip(&base_stats)
.map(|(d, s)| variant_hashes(&d.base, s, &calib))
.collect();
let mut_hashes: Vec<Vec<[u64; 8]>> = data
.iter()
.zip(&mut_stats)
.map(|(d, stats)| {
d.muts
.iter()
.zip(stats)
.map(|(m, s)| variant_hashes(&m.feats, s, &calib))
.collect()
})
.collect();
println!("\nhamming distance base vs mutant, mean/p90 (dct hash as reference):");
print!(" {:12} {:>10}", "mutation", "dct");
for v in VARIANTS {
print!(" {:>10}", v);
}
println!();
for (mi, m) in mutators.iter().enumerate() {
print!(" {:12}", m.tag());
let mut dists: Vec<u32> = data
.iter()
.map(|d| (d.dct ^ d.muts[mi].dct).count_ones())
.collect();
print_cell(&mut dists, false);
for v in 0..VARIANTS.len() {
let mut dists: Vec<u32> = (0..data.len())
.map(|i| (base_hashes[i][v] ^ mut_hashes[i][mi][v]).count_ones())
.collect();
print_cell(&mut dists, false);
}
println!();
}
// impostor baseline: unrelated image pairs; p5 shows how close the
// nearest false positives come
print!(" {:12}", "impostor*");
let mut dists: Vec<u32> = (0..data.len())
.map(|i| (data[i].dct ^ data[(i + 1) % data.len()].dct).count_ones())
.collect();
print_cell(&mut dists, true);
for v in 0..VARIANTS.len() {
let mut dists: Vec<u32> = (0..data.len())
.map(|i| {
let j = (i + 1) % data.len();
(base_hashes[i][v] ^ base_hashes[j][v]).count_ones()
})
.collect();
print_cell(&mut dists, true);
}
println!("\n *impostor row is mean/p5 over unrelated pairs: match rows want");
println!(" to sit far below it, especially below its p5");
}
/// One table cell: mean/p90 for match rows, mean/p5 for the impostor row
fn print_cell(dists: &mut Vec<u32>, impostor: bool) {
dists.sort();
let mean = dists.iter().sum::<u32>() as f32 / dists.len() as f32;
let q = if impostor {
dists[dists.len() / 20]
} else {
dists[(dists.len() * 9 / 10).min(dists.len() - 1)]
};
print!(" {:>6.1}/{:<3}", mean, q);
}
fn repeatability(data: &[ImageData], mutators: &[Box<dyn Mutator>], top: usize) {
println!("\ndetector repeatability (top {} keypoints, 4px tolerance):", top);
for (mi, m) in mutators.iter().enumerate() {
let (mut matched, mut total) = (0usize, 0usize);
for img in data {
let md = &img.muts[mi];
let mapped: Vec<(f32, f32, f32)> = md
.feats
.iter()
.take(top)
.map(|f| map_to_base(&md.tag, img.w, img.h, md.w, md.h, f.kp.x, f.kp.y, f.kp.sigma))
.collect();
for f in img.base.iter().take(top) {
if !in_region(&md.tag, img.w, img.h, md.w, md.h, f.kp.x, f.kp.y) {
continue;
}
total += 1;
let hit = mapped.iter().any(|(x, y, s)| {
let (dx, dy) = (x - f.kp.x, y - f.kp.y);
let ratio = (s / f.kp.sigma).max(f.kp.sigma / s);
dx * dx + dy * dy <= 16.0 && ratio <= 1.6
});
if hit {
matched += 1;
}
}
}
println!(
" {:12} {:.2}",
m.tag(),
matched as f32 / total.max(1) as f32
);
}
}
// ---------------------------------------------------------------------------
// shared variant machinery
fn stats_for(feats: &[Feature], centroids: &[[f32; 64]]) -> Stats {
let pooled = pooled_vector(feats);
let mut votes = [0f32; 64];
for f in feats {
let h = binarize64(&f.desc);
for (bit, vote) in votes.iter_mut().enumerate() {
if (h >> (63 - bit)) & 1 == 1 {
*vote += 1.0;
}
}
}
if !feats.is_empty() {
for v in votes.iter_mut() {
*v /= feats.len() as f32;
}
}
let mut counts = [0u32; 64];
for f in feats {
counts[nearest(centroids, &f.desc)] += 1;
}
Stats { pooled, votes, counts }
}
fn variant_hashes(feats: &[Feature], stats: &Stats, calib: &Calibration) -> [u64; 8] {
let half = [0.5f32; 64];
let mut centered = [0f32; 64];
for i in 0..64 {
centered[i] = stats.pooled[i] - calib.pooled_med[i];
}
let mut simhash = 0u64;
for plane in &calib.planes {
simhash <<= 1;
let dot: f32 = plane.iter().zip(&centered).map(|(p, v)| p * v).sum();
if dot > 0.0 {
simhash |= 1;
}
}
let (mut any, mut med) = (0u64, 0u64);
for w in 0..64 {
any <<= 1;
med <<= 1;
if stats.counts[w] > 0 {
any |= 1;
}
if stats.counts[w] > calib.word_med[w] {
med |= 1;
}
}
[
binarize64(&stats.pooled),
hash_strongest(feats),
threshold_bits(&stats.votes, &half),
threshold_bits(&stats.pooled, &calib.pooled_med),
simhash,
threshold_bits(&stats.votes, &calib.vote_med),
any,
med,
]
}
fn threshold_bits(v: &[f32; 64], t: &[f32; 64]) -> u64 {
let mut mask = 0u64;
for i in 0..64 {
mask <<= 1;
if v[i] > t[i] {
mask |= 1;
}
}
mask
}
fn column_median<F: Fn(&Stats) -> [f32; 64]>(stats: &[Stats], get: F) -> [f32; 64] {
let mut out = [0f32; 64];
for (i, o) in out.iter_mut().enumerate() {
let mut col: Vec<f32> = stats.iter().map(|s| get(s)[i]).collect();
col.sort_by(f32::total_cmp);
*o = col[col.len() / 2];
}
out
}
fn column_median_u32(stats: &[Stats]) -> [u32; 64] {
let mut out = [0u32; 64];
for (i, o) in out.iter_mut().enumerate() {
let mut col: Vec<u32> = stats.iter().map(|s| s.counts[i]).collect();
col.sort();
*o = col[col.len() / 2];
}
out
}
fn xorshift(state: &mut u64) -> u64 {
*state ^= *state << 13;
*state ^= *state >> 7;
*state ^= *state << 17;
*state
}
/// Fixed random +-1 hyperplanes, the classic sign-random-projection simhash
fn random_planes(n: usize) -> Vec<[f32; 64]> {
let mut rng = 0x9E3779B97F4A7C15u64;
(0..n)
.map(|_| {
let mut plane = [0f32; 64];
for p in plane.iter_mut() {
*p = if xorshift(&mut rng) & 1 == 1 { 1.0 } else { -1.0 };
}
plane
})
.collect()
}
fn nearest(centroids: &[[f32; 64]], d: &[f32; 64]) -> usize {
let mut best = 0;
let mut best_dist = f32::MAX;
for (c, centroid) in centroids.iter().enumerate() {
let mut dist = 0.0;
for i in 0..64 {
let diff = centroid[i] - d[i];
dist += diff * diff;
}
if dist < best_dist {
best_dist = dist;
best = c;
}
}
best
}
fn kmeans(samples: &[[f32; 64]], k: usize, iters: usize) -> Vec<[f32; 64]> {
let mut rng = 0x243F6A8885A308D3u64;
let mut centroids: Vec<[f32; 64]> = (0..k)
.map(|_| samples[(xorshift(&mut rng) % samples.len() as u64) as usize])
.collect();
for _ in 0..iters {
let assign: Vec<usize> = samples
.par_iter()
.map(|s| nearest(&centroids, s))
.collect();
let mut sums = vec![[0f32; 64]; k];
let mut counts = vec![0usize; k];
for (s, &a) in samples.iter().zip(&assign) {
counts[a] += 1;
for i in 0..64 {
sums[a][i] += s[i];
}
}
for c in 0..k {
if counts[c] == 0 {
centroids[c] = samples[(xorshift(&mut rng) % samples.len() as u64) as usize];
} else {
for i in 0..64 {
centroids[c][i] = sums[c][i] / counts[c] as f32;
}
}
}
}
centroids
}
/// Map a keypoint position in the mutant back to base image coordinates.
/// Only the geometric mutations move points; everything else is identity.
fn map_to_base(
tag: &str,
bw: f32,
bh: f32,
mw: f32,
mh: f32,
x: f32,
y: f32,
sigma: f32,
) -> (f32, f32, f32) {
if tag == ".flip." {
return (bw - 1.0 - x, y, sigma);
}
if tag.starts_with(".crop") {
// crop_imm offsets, integer division like the mutator
let ox = ((bw - mw) / 2.0).floor();
let oy = ((bh - mh) / 2.0).floor();
return (x + ox, y + oy, sigma);
}
if tag == ".scale50." {
return (x * bw / mw, y * bh / mh, sigma * bw / mw);
}
if let Some(deg) = rot_degrees(tag) {
// imageproc warps output through the projection inverse, so a
// mutant point o sits at base position c + R(-theta) (o - c)
let theta = deg.to_radians();
let (s, c) = theta.sin_cos();
let (cx, cy) = (mw / 2.0, mh / 2.0);
let (dx, dy) = (x - cx, y - cy);
return (cx + c * dx + s * dy, cy - s * dx + c * dy, sigma);
}
(x, y, sigma)
}
fn rot_degrees(tag: &str) -> Option<f32> {
tag.strip_prefix(".rot")?.strip_suffix('.')?.parse().ok()
}
/// Whether a base keypoint can possibly survive the mutation, for a fair
/// repeatability denominator (crops delete everything outside the window)
fn in_region(tag: &str, bw: f32, bh: f32, mw: f32, mh: f32, x: f32, y: f32) -> bool {
if tag.starts_with(".crop") {
let ox = ((bw - mw) / 2.0).floor();
let oy = ((bh - mh) / 2.0).floor();
return x >= ox && x < ox + mw && y >= oy && y < oy + mh;
}
true
}
+38
View File
@@ -0,0 +1,38 @@
//! Dumps the distinct hashes of a store as little-endian u64s.
//! The web demo's at-scale BK-tree builds from this file.
use clap::Parser;
use image_similarity::descriptors::get_all_descriptors;
use image_similarity::store::DescriptorStore;
use std::fs;
use std::path::PathBuf;
#[derive(Parser)]
struct Args {
/// Store to read, the file stem picks the descriptor
#[arg(default_value = "dct.store")]
store: PathBuf,
/// Output file of raw little-endian u64 keys
#[arg(default_value = "web/flickr/keys.bin")]
out: PathBuf,
}
fn main() {
let args = Args::parse();
let stem = args.store.file_stem().expect("store path has no file stem").to_string_lossy();
let descriptor = get_all_descriptors()
.into_iter()
.find(|d| d.info() == stem)
.unwrap_or_else(|| panic!("no descriptor named {stem}"));
let store = DescriptorStore::new(descriptor)
.with_file(&args.store)
.expect("loading store");
// sorted for a deterministic file; u64 order carries no hamming
// structure, so the resulting tree shape is as good as random
let mut keys: Vec<u64> = store.keys().collect();
keys.sort_unstable();
let bytes: Vec<u8> = keys.iter().flat_map(|k| k.to_le_bytes()).collect();
fs::write(&args.out, bytes).expect("writing keys");
println!("{} keys -> {}", keys.len(), args.out.display());
}
+77
View File
@@ -44,6 +44,28 @@ impl Node {
fn count(&self) -> usize { fn count(&self) -> usize {
1 + self.children.iter().map(|(_, c)| c.count()).sum::<usize>() 1 + self.children.iter().map(|(_, c)| c.count()).sum::<usize>()
} }
/// find, but records the key of every node compared, in visit order
fn find_trace(&self, key: u64, radius: u64, found: &mut Vec<(u64, u64)>, visited: &mut Vec<u64>) {
visited.push(self.key);
let distance = hamming(self.key, key);
if distance <= radius {
found.push((self.key, distance));
}
for (child_distance, child) in &self.children {
let child_distance = *child_distance as u64;
if child_distance + radius >= distance && child_distance <= distance + radius {
child.find_trace(key, radius, found, visited);
}
}
}
fn walk(&self, parent: u64, distance: u8, out: &mut Vec<(u64, u64, u64)>) {
out.push((self.key, parent, distance as u64));
for (d, child) in &self.children {
child.walk(self.key, *d, out);
}
}
} }
#[derive(Default)] #[derive(Default)]
@@ -80,6 +102,27 @@ impl BkTree {
} }
found found
} }
/// Like find, but also returns every key that was compared against,
/// in visit order. Keys not in the list were pruned away.
pub fn find_trace(&self, key: u64, radius: u64) -> (Vec<(u64, u64)>, Vec<u64>) {
let mut found = Vec::new();
let mut visited = Vec::new();
if let Some(root) = &self.root {
root.find_trace(key, radius, &mut found, &mut visited);
}
(found, visited)
}
/// Preorder walk of the tree as (key, parent key, distance to parent)
/// triples, for visualization. The root lists itself as its parent.
pub fn walk(&self) -> Vec<(u64, u64, u64)> {
let mut out = Vec::new();
if let Some(root) = &self.root {
root.walk(root.key, 0, &mut out);
}
out
}
} }
#[cfg(test)] #[cfg(test)]
@@ -132,4 +175,38 @@ mod tests {
fn empty_tree_finds_nothing() { fn empty_tree_finds_nothing() {
assert!(BkTree::new().find(1, 64).is_empty()); assert!(BkTree::new().find(1, 64).is_empty());
} }
#[test]
fn trace_matches_find_and_visits_at_most_everything() {
let keys = lcg_keys(200);
let mut tree = BkTree::new();
for &key in &keys {
tree.insert(key);
}
for radius in [0, 4, 16] {
let (found, visited) = tree.find_trace(keys[3], radius);
assert_eq!(found, tree.find(keys[3], radius));
assert_eq!(visited[0], keys[0], "search starts at the root");
assert!(visited.len() <= keys.len());
// every match was necessarily compared against
for (key, _) in &found {
assert!(visited.contains(key));
}
}
}
#[test]
fn walk_lists_every_node_with_consistent_distances() {
let keys = lcg_keys(50);
let mut tree = BkTree::new();
for &key in &keys {
tree.insert(key);
}
let nodes = tree.walk();
assert_eq!(nodes.len(), tree.len());
assert_eq!(nodes[0], (keys[0], keys[0], 0), "root is its own parent");
for (key, parent, distance) in &nodes[1..] {
assert_eq!(hamming(*key, *parent), *distance);
}
}
} }
+1
View File
@@ -7,6 +7,7 @@ pub mod cache;
pub mod descriptors; pub mod descriptors;
pub mod store; pub mod store;
pub mod mutators; pub mod mutators;
pub mod salient;
pub mod wasm; pub mod wasm;
/// Opens an image by sniffing its content instead of trusting the file /// Opens an image by sniffing its content instead of trusting the file
+3
View File
@@ -281,6 +281,9 @@ pub fn get_all_mutators() -> Vec<Box<dyn Mutator>> {
Box::new(Letterbox { bar_percent: 10 }), Box::new(Letterbox { bar_percent: 10 }),
Box::new(Logo { size_percent: 10 }), Box::new(Logo { size_percent: 10 }),
Box::new(Rotate { degrees: 2.0 }), Box::new(Rotate { degrees: 2.0 }),
// a hard rotation the frequency hashes cannot follow, where the
// salient-point descriptor's rotation invariance shows itself
Box::new(Rotate { degrees: 45.0 }),
Box::new(Noise { stddev: 10.0 }), Box::new(Noise { stddev: 10.0 }),
] ]
} }
+644
View File
@@ -0,0 +1,644 @@
//! Salient-point detector and descriptor, built for hashing experiments.
//!
//! The detector is SIFT-like: extrema in a Difference-of-Gaussians scale
//! space, filtered for contrast and edge responses. The descriptor is
//! SURF-like: a 4x4 grid of subregions around the keypoint, each summarized
//! by four gradient sums, 64 floats total. Every descriptor is measured in
//! the keypoint's own scale and orientation frame, which is where the scale
//! and rotation invariance comes from.
//!
//! Descriptors are canonicalized under horizontal mirroring: a flipped patch
//! produces the same 64 floats, so hashes built from them inherit flip
//! invariance the way the DCT hash does. See `mirror_descriptor` for the
//! exact symmetry.
//!
//! Not wired into the Descriptor trait or the stores. The salientlab binary
//! runs the experiments.
use image::DynamicImage;
use std::f32::consts::PI;
const ORI_BINS: usize = 36;
/// Descriptor sampling grid: DESC_SAMPLES x DESC_SAMPLES points spanning
/// +-DESC_HALF_EXTENT keypoint sigmas, grouped into DESC_GRID x DESC_GRID
/// subregions. These define the exact window the descriptor reads, so the
/// browser demo draws the same box by calling Keypoint::window_corners.
const DESC_SAMPLES: usize = 20;
const DESC_HALF_EXTENT: f32 = 9.5;
pub const DESC_GRID: usize = 4;
/// Grayscale f32 image, values in [0, 1]
pub struct GrayF32 {
w: usize,
h: usize,
data: Vec<f32>,
}
impl GrayF32 {
pub fn from_image(img: &DynamicImage) -> GrayF32 {
let luma = img.to_luma32f();
GrayF32 {
w: luma.width() as usize,
h: luma.height() as usize,
data: luma.into_raw(),
}
}
fn get(&self, x: isize, y: isize) -> f32 {
let x = x.clamp(0, self.w as isize - 1) as usize;
let y = y.clamp(0, self.h as isize - 1) as usize;
self.data[y * self.w + x]
}
fn bilinear(&self, x: f32, y: f32) -> f32 {
let x0 = x.floor();
let y0 = y.floor();
let fx = x - x0;
let fy = y - y0;
let (xi, yi) = (x0 as isize, y0 as isize);
let v00 = self.get(xi, yi);
let v10 = self.get(xi + 1, yi);
let v01 = self.get(xi, yi + 1);
let v11 = self.get(xi + 1, yi + 1);
v00 * (1.0 - fx) * (1.0 - fy)
+ v10 * fx * (1.0 - fy)
+ v01 * (1.0 - fx) * fy
+ v11 * fx * fy
}
/// Image gradient at a fractional position, central differences
fn gradient(&self, x: f32, y: f32) -> (f32, f32) {
(
(self.bilinear(x + 1.0, y) - self.bilinear(x - 1.0, y)) * 0.5,
(self.bilinear(x, y + 1.0) - self.bilinear(x, y - 1.0)) * 0.5,
)
}
/// Separable gaussian blur, borders clamped
fn gauss_blur(&self, sigma: f32) -> GrayF32 {
let radius = (sigma * 3.0).ceil().max(1.0) as isize;
let mut kernel = Vec::with_capacity(2 * radius as usize + 1);
for i in -radius..=radius {
kernel.push((-(i * i) as f32 / (2.0 * sigma * sigma)).exp());
}
let sum: f32 = kernel.iter().sum();
for k in kernel.iter_mut() {
*k /= sum;
}
let mut tmp = vec![0.0f32; self.w * self.h];
for y in 0..self.h {
for x in 0..self.w {
let mut acc = 0.0;
for (ki, k) in kernel.iter().enumerate() {
acc += k * self.get(x as isize + ki as isize - radius, y as isize);
}
tmp[y * self.w + x] = acc;
}
}
let tmp = GrayF32 { w: self.w, h: self.h, data: tmp };
let mut out = vec![0.0f32; self.w * self.h];
for y in 0..self.h {
for x in 0..self.w {
let mut acc = 0.0;
for (ki, k) in kernel.iter().enumerate() {
acc += k * tmp.get(x as isize, y as isize + ki as isize - radius);
}
out[y * self.w + x] = acc;
}
}
GrayF32 { w: self.w, h: self.h, data: out }
}
/// Every other pixel, for the next octave
fn half(&self) -> GrayF32 {
let w = (self.w / 2).max(1);
let h = (self.h / 2).max(1);
let mut data = Vec::with_capacity(w * h);
for y in 0..h {
for x in 0..w {
data.push(self.data[(y * 2) * self.w + x * 2]);
}
}
GrayF32 { w, h, data }
}
fn sub(a: &GrayF32, b: &GrayF32) -> GrayF32 {
let data = a.data.iter().zip(&b.data).map(|(a, b)| a - b).collect();
GrayF32 { w: a.w, h: a.h, data }
}
}
#[derive(Clone, Copy, Debug)]
pub struct Keypoint {
/// Position in original image coordinates
pub x: f32,
pub y: f32,
/// Scale in original image coordinates
pub sigma: f32,
/// Dominant gradient orientation, radians
pub angle: f32,
/// Absolute DoG value at the extremum
pub response: f32,
}
impl Keypoint {
/// Map a point from the keypoint's oriented frame (u along the
/// orientation, v perpendicular) into image coordinates. Same rotation
/// the descriptor uses to place its samples.
fn to_image(&self, u: f32, v: f32) -> (f32, f32) {
let (sin, cos) = self.angle.sin_cos();
(self.x + u * cos - v * sin, self.y + u * sin + v * cos)
}
/// The four corners of the oriented descriptor window in image
/// coordinates, from the (-u,-v) corner counter-clockwise. For drawing
/// the region the descriptor summarizes.
pub fn window_corners(&self) -> [(f32, f32); 4] {
let e = DESC_HALF_EXTENT * self.sigma;
[(-e, -e), (e, -e), (e, e), (-e, e)].map(|(u, v)| self.to_image(u, v))
}
/// Centres of the DESC_GRID x DESC_GRID subregions in image coordinates,
/// in descriptor order (row = perpendicular to orientation, column =
/// along it), so a caller can line them up with the 64-float descriptor.
pub fn subregion_centers(&self) -> [(f32, f32); DESC_GRID * DESC_GRID] {
let cell = DESC_SAMPLES / DESC_GRID;
let mut out = [(0.0, 0.0); DESC_GRID * DESC_GRID];
for j in 0..DESC_GRID {
for i in 0..DESC_GRID {
let su = (i * cell + cell / 2) as f32 - DESC_HALF_EXTENT;
let sv = (j * cell + cell / 2) as f32 - DESC_HALF_EXTENT;
out[j * DESC_GRID + i] = self.to_image(su * self.sigma, sv * self.sigma);
}
}
out
}
}
#[derive(Clone)]
pub struct Feature {
pub kp: Keypoint,
pub desc: [f32; 64],
}
pub struct Params {
/// DoG scale samples per octave
pub intervals: usize,
/// Blur of the first level of each octave
pub base_sigma: f32,
/// Minimum absolute DoG value for a keypoint (image values are 0..1)
pub contrast_threshold: f32,
/// Maximum principal curvature ratio, rejects points on straight edges
pub edge_ratio: f32,
/// Keep only the strongest keypoints
pub max_features: usize,
/// Canonicalize descriptors under horizontal mirroring
pub flip_canonical: bool,
}
impl Default for Params {
fn default() -> Self {
Params {
intervals: 3,
base_sigma: 1.6,
contrast_threshold: 0.01,
edge_ratio: 10.0,
max_features: 100,
flip_canonical: true,
}
}
}
struct RawKp {
octave: usize,
level: usize,
x: usize,
y: usize,
bx: f32,
by: f32,
bsigma: f32,
response: f32,
}
pub struct Salient {
pub params: Params,
}
impl Default for Salient {
fn default() -> Self {
Self::new()
}
}
impl Salient {
pub fn new() -> Salient {
Salient { params: Params::default() }
}
fn k(&self) -> f32 {
2f32.powf(1.0 / self.params.intervals as f32)
}
/// Gaussian pyramid: per octave `intervals + 3` blur levels, so the DoG
/// stack has `intervals` usable middle layers
fn pyramid(&self, img: &DynamicImage) -> Vec<Vec<GrayF32>> {
let s = self.params.intervals;
let k = self.k();
let sigma0 = self.params.base_sigma;
// assume the input carries sigma 0.5, lift it to base_sigma
let gray = GrayF32::from_image(img);
let mut base = gray.gauss_blur((sigma0 * sigma0 - 0.25).max(0.01).sqrt());
let mut octaves = Vec::new();
while base.w.min(base.h) >= 24 && octaves.len() < 6 {
let mut levels = vec![base];
for i in 1..s + 3 {
let sig_prev = sigma0 * k.powi(i as i32 - 1);
let sig_total = sigma0 * k.powi(i as i32);
let sig_diff = (sig_total * sig_total - sig_prev * sig_prev).sqrt();
levels.push(levels[i - 1].gauss_blur(sig_diff));
}
// levels[s] carries 2 * base_sigma: halved it seeds the next octave
base = levels[s].half();
octaves.push(levels);
}
octaves
}
fn detect(&self, pyramid: &[Vec<GrayF32>]) -> Vec<RawKp> {
let k = self.k();
let mut raw = Vec::new();
for (o, levels) in pyramid.iter().enumerate() {
let dogs: Vec<GrayF32> = levels
.windows(2)
.map(|w| GrayF32::sub(&w[1], &w[0]))
.collect();
let scale_up = (1usize << o) as f32;
for l in 1..dogs.len() - 1 {
let d = &dogs[l];
for y in 1..d.h - 1 {
for x in 1..d.w - 1 {
let v = d.data[y * d.w + x];
if v.abs() < self.params.contrast_threshold {
continue;
}
if !is_extremum(&dogs, l, x, y, v) {
continue;
}
if is_edge(d, x, y, self.params.edge_ratio) {
continue;
}
let osigma = self.params.base_sigma * k.powi(l as i32);
raw.push(RawKp {
octave: o,
level: l,
x,
y,
bx: x as f32 * scale_up,
by: y as f32 * scale_up,
bsigma: osigma * scale_up,
response: v.abs(),
});
}
}
}
}
raw
}
/// Detect, orient and describe the strongest keypoints of an image
pub fn features(&self, img: &DynamicImage) -> Vec<Feature> {
let pyramid = self.pyramid(img);
let mut raw = self.detect(&pyramid);
raw.sort_by(|a, b| b.response.total_cmp(&a.response));
// greedy dedupe: extrema often fire in adjacent scale layers
let mut picked: Vec<RawKp> = Vec::new();
for kp in raw {
if picked.len() >= self.params.max_features {
break;
}
let dup = picked.iter().any(|p| {
let (dx, dy) = (p.bx - kp.bx, p.by - kp.by);
let ratio = (p.bsigma / kp.bsigma).max(kp.bsigma / p.bsigma);
dx * dx + dy * dy < 4.0 && ratio < 1.6
});
if !dup {
picked.push(kp);
}
}
picked
.iter()
.map(|kp| {
let img = &pyramid[kp.octave][kp.level];
let osigma = self.params.base_sigma * self.k().powi(kp.level as i32);
let angle = orientation(img, kp.x, kp.y, osigma);
let mut desc = describe(img, kp.x as f32, kp.y as f32, osigma, angle);
if self.params.flip_canonical {
desc = canonicalize(desc);
}
Feature {
kp: Keypoint {
x: kp.bx,
y: kp.by,
sigma: kp.bsigma,
angle,
response: kp.response,
},
desc,
}
})
.collect()
}
}
/// Strict local extremum over the 26 neighbors in space and scale
fn is_extremum(dogs: &[GrayF32], l: usize, x: usize, y: usize, v: f32) -> bool {
for layer in &dogs[l - 1..=l + 1] {
for dy in -1isize..=1 {
for dx in -1isize..=1 {
let n = layer.get(x as isize + dx, y as isize + dy);
if std::ptr::eq(layer, &dogs[l]) && dx == 0 && dy == 0 {
continue;
}
if (v > 0.0 && n >= v) || (v < 0.0 && n <= v) {
return false;
}
}
}
}
true
}
/// Principal curvature ratio test on the 2x2 spatial Hessian of the DoG,
/// rejects points that sit on an edge and slide along it
fn is_edge(d: &GrayF32, x: usize, y: usize, r: f32) -> bool {
let (x, y) = (x as isize, y as isize);
let v = d.get(x, y);
let dxx = d.get(x + 1, y) - 2.0 * v + d.get(x - 1, y);
let dyy = d.get(x, y + 1) - 2.0 * v + d.get(x, y - 1);
let dxy = (d.get(x + 1, y + 1) - d.get(x + 1, y - 1) - d.get(x - 1, y + 1)
+ d.get(x - 1, y - 1))
/ 4.0;
let tr = dxx + dyy;
let det = dxx * dyy - dxy * dxy;
det <= 0.0 || tr * tr * r >= (r + 1.0) * (r + 1.0) * det
}
/// Dominant gradient orientation: 36-bin histogram of gradient angles in a
/// gaussian-weighted neighborhood, smoothed, peak refined by a parabola
fn orientation(img: &GrayF32, x: usize, y: usize, sigma: f32) -> f32 {
let sig_w = 1.5 * sigma;
let radius = (3.0 * sig_w).round() as isize;
let mut hist = [0f32; ORI_BINS];
for dy in -radius..=radius {
for dx in -radius..=radius {
let (px, py) = (x as isize + dx, y as isize + dy);
let gx = (img.get(px + 1, py) - img.get(px - 1, py)) * 0.5;
let gy = (img.get(px, py + 1) - img.get(px, py - 1)) * 0.5;
let mag = (gx * gx + gy * gy).sqrt();
if mag == 0.0 {
continue;
}
let mut phi = gy.atan2(gx);
if phi < 0.0 {
phi += 2.0 * PI;
}
let w = (-((dx * dx + dy * dy) as f32) / (2.0 * sig_w * sig_w)).exp();
let bin = ((phi / (2.0 * PI) * ORI_BINS as f32) as usize).min(ORI_BINS - 1);
hist[bin] += w * mag;
}
}
// two passes of circular [1 2 1] smoothing
for _ in 0..2 {
let orig = hist;
for b in 0..ORI_BINS {
let l = orig[(b + ORI_BINS - 1) % ORI_BINS];
let r = orig[(b + 1) % ORI_BINS];
hist[b] = (l + 2.0 * orig[b] + r) / 4.0;
}
}
let peak = (0..ORI_BINS)
.max_by(|a, b| hist[*a].total_cmp(&hist[*b]))
.unwrap();
let l = hist[(peak + ORI_BINS - 1) % ORI_BINS];
let c = hist[peak];
let r = hist[(peak + 1) % ORI_BINS];
let denom = l - 2.0 * c + r;
let delta = if denom.abs() > 1e-12 { 0.5 * (l - r) / denom } else { 0.0 };
(peak as f32 + 0.5 + delta) * 2.0 * PI / ORI_BINS as f32
}
/// SURF-style descriptor: 20x20 samples spaced `sigma` apart, rotated to the
/// keypoint orientation, grouped into 4x4 subregions. Each subregion gets
/// [sum du, sum dv, sum |du|, sum |dv|] where (du, dv) is the gradient
/// rotated into the keypoint frame. Unit normalized.
///
/// Layout: index = (j * 4 + i) * 4 + c, with i along the orientation axis,
/// j perpendicular to it, c the component.
fn describe(img: &GrayF32, x: f32, y: f32, sigma: f32, angle: f32) -> [f32; 64] {
let (sin, cos) = angle.sin_cos();
let sig_w = 3.3 * sigma;
let cell = DESC_SAMPLES / DESC_GRID;
let mut desc = [0f32; 64];
for sj in 0..DESC_SAMPLES {
for si in 0..DESC_SAMPLES {
let u = (si as f32 - DESC_HALF_EXTENT) * sigma;
let v = (sj as f32 - DESC_HALF_EXTENT) * sigma;
let px = x + u * cos - v * sin;
let py = y + u * sin + v * cos;
let (gx, gy) = img.gradient(px, py);
let du = cos * gx + sin * gy;
let dv = -sin * gx + cos * gy;
let w = (-(u * u + v * v) / (2.0 * sig_w * sig_w)).exp();
let base = ((sj / cell) * DESC_GRID + si / cell) * 4;
desc[base] += w * du;
desc[base + 1] += w * dv;
desc[base + 2] += w * du.abs();
desc[base + 3] += w * dv.abs();
}
}
let norm: f32 = desc.iter().map(|v| v * v).sum::<f32>().sqrt();
if norm > 0.0 {
for v in desc.iter_mut() {
*v /= norm;
}
}
desc
}
/// The descriptor a horizontally mirrored copy of the patch would produce.
///
/// Under a mirror the dominant orientation maps to pi - theta. Working out
/// the sample grid in that frame: the u axis is preserved, the v axis
/// negates. So subregion (i, j) swaps with (i, 3 - j), the dv sum changes
/// sign, and the other three components are untouched.
pub fn mirror_descriptor(d: &[f32; 64]) -> [f32; 64] {
let mut out = [0f32; 64];
for j in 0..4 {
for i in 0..4 {
let src = (j * 4 + i) * 4;
let dst = ((3 - j) * 4 + i) * 4;
out[dst] = d[src];
out[dst + 1] = -d[src + 1];
out[dst + 2] = d[src + 2];
out[dst + 3] = d[src + 3];
}
}
out
}
/// Pick the mirror representative deterministically, so a patch and its
/// mirror image canonicalize to the same descriptor
fn canonicalize(d: [f32; 64]) -> [f32; 64] {
let dv_total: f32 = (0..16).map(|s| d[s * 4 + 1]).sum();
let mirror = if dv_total.abs() > 1e-6 {
dv_total < 0.0
} else {
// fallback: skew of the |dv| mass along the v axis
let skew: f32 = (0..16).map(|s| ((s / 4) as f32 - 1.5) * d[s * 4 + 3]).sum();
skew < 0.0
};
if mirror {
mirror_descriptor(&d)
} else {
d
}
}
/// 64 bits from one descriptor: the 32 signed sums contribute their sign,
/// the 32 magnitude sums contribute a comparison against their own median.
/// Same recipe as the DCT hash: signs plus ordinal facts, nothing absolute.
pub fn binarize64(desc: &[f32; 64]) -> u64 {
let mut mags: Vec<f32> = (0..16)
.flat_map(|s| [desc[s * 4 + 2], desc[s * 4 + 3]])
.collect();
mags.sort_by(f32::total_cmp);
let median = mags[16];
let mut mask = 0u64;
for (i, &v) in desc.iter().enumerate() {
mask <<= 1;
let bit = if i % 4 < 2 { v > 0.0 } else { v > median };
if bit {
mask |= 1;
}
}
mask
}
/// Response-weighted mean of all descriptors, unit normalized.
/// Symmetric pooling, so it inherits every per-keypoint invariance.
pub fn pooled_vector(feats: &[Feature]) -> [f32; 64] {
let mut acc = [0f32; 64];
for f in feats {
for (a, d) in acc.iter_mut().zip(&f.desc) {
*a += f.kp.response * d;
}
}
let norm: f32 = acc.iter().map(|v| v * v).sum::<f32>().sqrt();
if norm > 0.0 {
for v in acc.iter_mut() {
*v /= norm;
}
}
acc
}
/// Variant 1: binarized pooled descriptor
pub fn hash_pooled(feats: &[Feature]) -> u64 {
binarize64(&pooled_vector(feats))
}
/// Variant 2: binarized descriptor of the single strongest keypoint
pub fn hash_strongest(feats: &[Feature]) -> u64 {
match feats.first() {
Some(f) => binarize64(&f.desc),
None => 0,
}
}
/// Variant 3: per-bit majority vote over all binarized descriptors
pub fn hash_consensus(feats: &[Feature]) -> u64 {
if feats.is_empty() {
return 0;
}
let hashes: Vec<u64> = feats.iter().map(|f| binarize64(&f.desc)).collect();
let mut mask = 0u64;
for bit in (0..64).rev() {
let votes = hashes.iter().filter(|h| (*h >> bit) & 1 == 1).count();
mask <<= 1;
if votes * 2 > hashes.len() {
mask |= 1;
}
}
mask
}
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptors::testimg;
fn dist(a: u64, b: u64) -> u32 {
(a ^ b).count_ones()
}
#[test]
fn mirror_is_an_involution() {
let mut d = [0f32; 64];
for (i, v) in d.iter_mut().enumerate() {
*v = (i as f32 * 0.37).sin();
}
assert_eq!(mirror_descriptor(&mirror_descriptor(&d)), d);
}
#[test]
fn canonical_ignores_mirroring() {
let mut d = [0f32; 64];
for (i, v) in d.iter_mut().enumerate() {
*v = (i as f32 * 0.37).sin();
}
assert_eq!(canonicalize(d), canonicalize(mirror_descriptor(&d)));
}
#[test]
fn binarize_packs_all_bits() {
// strongest positive signal on the first and last positions
let mut d = [0f32; 64];
d[0] = 1.0; // first sign bit, lands on bit 63
d[63] = 1.0; // a magnitude above the (zero) median, lands on bit 0
let h = binarize64(&d);
assert_eq!(h, (1 << 63) | 1);
}
#[test]
fn features_deterministic() {
// synthetic gradients are too smooth to produce DoG extrema
let img = image::open("img/meowl.jpg").unwrap();
let s = Salient::new();
let a = s.features(&img);
let b = s.features(&img);
assert_eq!(a.len(), b.len());
assert!(!a.is_empty());
for (fa, fb) in a.iter().zip(&b) {
assert_eq!(fa.desc, fb.desc);
}
}
#[test]
fn flip_invariant_on_real_photo() {
let img = image::open("img/meowl.jpg").unwrap();
let s = Salient::new();
let a = s.features(&img);
let b = s.features(&img.fliph());
assert!(!a.is_empty());
// detection mirrors exactly, orientation binning wobbles a little
let d = dist(hash_pooled(&a), hash_pooled(&b));
assert!(d <= 6, "pooled flip distance {d}");
let d = dist(hash_consensus(&a), hash_consensus(&b));
assert!(d <= 6, "consensus flip distance {d}");
}
}
+5
View File
@@ -262,6 +262,11 @@ impl DescriptorStore {
self.names.len() self.names.len()
} }
/// All distinct hashes in the store
pub fn keys(&self) -> impl Iterator<Item = u64> + '_ {
self.map.keys().copied()
}
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.names.is_empty() self.names.is_empty()
} }
+223
View File
@@ -1,7 +1,9 @@
//! Wasm bindings for the browser demos. //! Wasm bindings for the browser demos.
//! Hashes come back as BigInt, images go in and out as encoded bytes. //! Hashes come back as BigInt, images go in and out as encoded bytes.
use crate::bk::BkTree;
use crate::descriptors::{Descriptor, Median, DCT, PHash}; use crate::descriptors::{Descriptor, Median, DCT, PHash};
use crate::mutators::{self, Mutator}; use crate::mutators::{self, Mutator};
use crate::salient::{self, Params, Salient};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use std::io::Cursor; use std::io::Cursor;
@@ -71,6 +73,227 @@ pub fn phash_lowfreq(bytes: &[u8]) -> Result<Vec<f64>, JsError> {
Ok(PHash.lowfreq(&img).to_vec()) Ok(PHash.lowfreq(&img).to_vec())
} }
/// DCT hash of every mutant in the standard 16-mutator suite.
/// The image is shrunk first so the heavy mutators stay quick, which can
/// wobble the hashes a bit or two compared to a full-size run.
#[wasm_bindgen]
pub fn suite_hashes(bytes: &[u8]) -> Result<Vec<u64>, JsError> {
let img = load(bytes)?;
let img = if img.width().max(img.height()) > 512 {
img.thumbnail(512, 512)
} else {
img
};
let dct = DCT::new();
Ok(mutators::get_all_mutators()
.iter()
.map(|m| dct.describe(&m.mutate(&img)))
.collect())
}
/// Labels of the suite mutators, newline separated, same order as suite_hashes
#[wasm_bindgen]
pub fn suite_labels() -> String {
mutators::get_all_mutators()
.iter()
.map(|m| m.info())
.collect::<Vec<_>>()
.join("\n")
}
/// A queryable set of hashes: the BK-tree plus a flat copy of the keys
/// as a linear scan baseline.
#[wasm_bindgen]
pub struct BkIndex {
tree: BkTree,
keys: Vec<u64>,
}
#[wasm_bindgen]
impl BkIndex {
#[wasm_bindgen(constructor)]
pub fn new() -> BkIndex {
BkIndex { tree: BkTree::new(), keys: Vec::new() }
}
/// Inserts one key, ignoring duplicates. Fine for demo-sized trees,
/// use insert_bytes for bulk loads.
pub fn insert(&mut self, key: u64) {
if !self.keys.contains(&key) {
self.keys.push(key);
self.tree.insert(key);
}
}
/// Bulk load of little-endian u64 keys (the keys.bin format),
/// assumed to be distinct.
pub fn insert_bytes(&mut self, bytes: &[u8]) {
for chunk in bytes.chunks_exact(8) {
let key = u64::from_le_bytes(chunk.try_into().unwrap());
self.keys.push(key);
self.tree.insert(key);
}
}
pub fn len(&self) -> u32 {
self.keys.len() as u32
}
/// All keys within the radius, as flattened (key, distance) pairs
pub fn find(&self, key: u64, radius: u32) -> Vec<u64> {
self.tree
.find(key, radius as u64)
.into_iter()
.flat_map(|(k, d)| [k, d])
.collect()
}
/// Number of hashes the tree compared against during a find
pub fn find_compared(&self, key: u64, radius: u32) -> u32 {
self.tree.find_trace(key, radius as u64).1.len() as u32
}
/// The keys compared against during a find, in visit order
pub fn trace(&self, key: u64, radius: u32) -> Vec<u64> {
self.tree.find_trace(key, radius as u64).1
}
/// Linear scan baseline, same result as find
pub fn scan(&self, key: u64, radius: u32) -> Vec<u64> {
let radius = radius as u64;
self.keys
.iter()
.map(|&k| (k, ((k ^ key).count_ones()) as u64))
.filter(|&(_, d)| d <= radius)
.flat_map(|(k, d)| [k, d])
.collect()
}
/// The tree as flattened (key, parent key, distance to parent) triples
/// in preorder; the root lists itself as parent.
pub fn structure(&self) -> Vec<u64> {
self.tree
.walk()
.into_iter()
.flat_map(|(k, p, d)| [k, p, d])
.collect()
}
}
impl Default for BkIndex {
fn default() -> Self {
Self::new()
}
}
/// Detected salient points of an image plus their descriptors, for the
/// part-two demos. Built once, then queried for the overlay, the ranking
/// and the per-point neighbourhood description. Coordinates are in the
/// decoded image's own pixels (see width/height), scale them to the canvas.
#[wasm_bindgen]
pub struct SalientImage {
feats: Vec<salient::Feature>,
width: u32,
height: u32,
}
#[wasm_bindgen]
impl SalientImage {
/// Detect and describe up to `max_features` points, strongest first.
#[wasm_bindgen(constructor)]
pub fn new(bytes: &[u8], max_features: u32) -> Result<SalientImage, JsError> {
let img = load(bytes)?;
let detector = Salient {
params: Params {
max_features: max_features.max(1) as usize,
..Default::default()
},
};
let feats = detector.features(&img);
Ok(SalientImage { feats, width: img.width(), height: img.height() })
}
/// Number of keypoints kept.
pub fn len(&self) -> u32 {
self.feats.len() as u32
}
pub fn is_empty(&self) -> bool {
self.feats.is_empty()
}
/// Width of the coordinate space the keypoints live in.
pub fn width(&self) -> u32 {
self.width
}
/// Height of the coordinate space the keypoints live in.
pub fn height(&self) -> u32 {
self.height
}
/// Every keypoint as [x, y, sigma, angle, response], strongest first.
/// The response is the ranking score (how blob-like and high-contrast
/// the point is); it is what "importance" means here.
pub fn keypoints(&self) -> Vec<f32> {
self.feats
.iter()
.flat_map(|f| [f.kp.x, f.kp.y, f.kp.sigma, f.kp.angle, f.kp.response])
.collect()
}
/// The 64-float descriptor of keypoint `i`: 16 subregions, each
/// [sum du, sum dv, sum |du|, sum |dv|] in the point's oriented frame.
pub fn descriptor(&self, i: u32) -> Vec<f32> {
self.feats
.get(i as usize)
.map(|f| f.desc.to_vec())
.unwrap_or_default()
}
/// The four corners of keypoint `i`'s oriented sampling window in image
/// coordinates, as [x, y] x 4. Draw this to show the described region.
pub fn window(&self, i: u32) -> Vec<f32> {
self.feats
.get(i as usize)
.map(|f| {
f.kp.window_corners()
.iter()
.flat_map(|&(x, y)| [x, y])
.collect()
})
.unwrap_or_default()
}
/// The 16 subregion centres of keypoint `i` in image coordinates, as
/// [x, y] x 16, in the same order as the descriptor's 16 blocks.
pub fn subregions(&self, i: u32) -> Vec<f32> {
self.feats
.get(i as usize)
.map(|f| {
f.kp.subregion_centers()
.iter()
.flat_map(|&(x, y)| [x, y])
.collect()
})
.unwrap_or_default()
}
/// The 64-bit strongest-keypoint hash (the variant that survived at 25k).
pub fn hash(&self) -> u64 {
salient::hash_strongest(&self.feats)
}
/// The 64-bit hash of an arbitrary keypoint `i`, so a demo can show a
/// specific point's fingerprint rather than only the strongest one.
pub fn descriptor_hash(&self, i: u32) -> u64 {
self.feats
.get(i as usize)
.map(|f| salient::binarize64(&f.desc))
.unwrap_or(0)
}
}
/// Apply a single mutator to an image, returns PNG bytes. /// Apply a single mutator to an image, returns PNG bytes.
/// The meaning of `amount` depends on the kind. /// The meaning of `amount` depends on the kind.
#[wasm_bindgen] #[wasm_bindgen]
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+1188 -80
View File
File diff suppressed because it is too large Load Diff
+1176 -74
View File
File diff suppressed because it is too large Load Diff
+679 -153
View File
@@ -2,19 +2,33 @@ body {
margin: 0; margin: 0;
padding: 0; padding: 0;
color: var(--foreground); color: var(--foreground);
background: var(--background) url('bg.jpeg'); background: var(--background);
background-size: cover;
font-family: sans-serif; font-family: sans-serif;
height: 100%;
overflow: hidden;
font-size: 14px; font-size: 14px;
} }
#wrapper { /* Fixed background layer, same construction as the landing page */
overflow-x: hidden;
overflow-y: auto; #bg {
position: fixed;
width: 100vw;
height: 100vh;
z-index: -1;
background: var(--background);
}
#bg img {
display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover;
object-position: 50% 50%;
opacity: 0;
transition: opacity 3s linear;
}
#bg img.loaded {
opacity: 1;
} }
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
@@ -25,7 +39,7 @@ h1, h2, h3, h4, h5, h6 {
h1 { h1 {
margin-top: 0; margin-top: 0;
font-size: 24px; font-size: 28px;
} }
h2 { h2 {
@@ -34,45 +48,98 @@ h2 {
p { p {
margin-top: 2px; margin-top: 2px;
line-height: 1.5;
} }
a { a {
color: var(--color2); color: var(--color2);
text-decoration: none;
font-weight: bold;
} }
strong { a:hover {
color: var(--color4); color: var(--color3);
} }
em { em {
color: var(--color3); color: var(--color3);
} }
sup.fn {
font-size: 10px;
}
sup.fn a {
font-weight: normal;
}
pre { pre {
margin: 0; margin: 8px 0;
max-width: 640px;
} }
pre > code { pre > code {
border: 1px solid; border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
display: block;
padding: 8px 12px;
overflow-x: auto;
font-size: 13px;
} }
article { article {
max-width: 1024px; max-width: 1024px;
min-height: 128px;
margin: 25px auto; margin: 25px auto;
padding: 20px; padding: 20px;
background: color-mix(in srgb, var(--background) 90%, transparent); background: color-mix(in srgb, var(--background) 90%, transparent);
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.9); box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.9);
} }
code.block { @media (max-width: 1064px) {
display: block; article {
margin: 15px 12px;
}
} }
img { img {
max-width: 100%; max-width: 100%;
} }
math[display="block"] {
margin: 12px 0 12px 24px;
font-size: 17px;
color: var(--color4);
}
aside.explainer {
border-left: 3px solid var(--color4);
background: color-mix(in srgb, var(--foreground) 5%, transparent);
padding: 8px 12px;
margin: 12px 0;
font-size: 13px;
}
.rewrite-target {
text-decoration: underline dashed var(--color3);
text-underline-offset: 3px;
}
.rewrite-target::after {
content: ' ✎';
color: var(--color3);
font-size: 11px;
}
.attribution {
font-size: 12px;
opacity: 0.7;
}
.big-number {
font-size: 26px;
color: var(--color1);
margin: 8px 0 4px 0;
}
/* Demo */ /* Demo */
.needs-image { .needs-image {
@@ -88,135 +155,107 @@ img {
min-width: 256px; min-width: 256px;
} }
/* Resize demo */ .sample {
height: 48px;
margin: 0 4px 4px 0;
cursor: pointer;
vertical-align: middle;
}
#resize { .sample:hover {
outline: 2px solid var(--color1);
}
/* Comparison slider */
.compare {
position: relative; position: relative;
height: 512px; max-width: 512px;
overflow: hidden;
touch-action: none;
cursor: ew-resize;
user-select: none;
}
.compare img {
display: block;
width: 100%; width: 100%;
transition: height 2s linear; pointer-events: none;
} }
#resize.resize { /* The top image stretches to the base image's box, so differently
height: 256px; sized results (8x8 thumbs, crops) stay comparable under the divider */
} .compare .compare-b {
#resize .image-original {
position: absolute; position: absolute;
width: 100%; top: 0;
height: 512px; left: 0;
height: 100%;
object-fit: fill;
clip-path: inset(0 0 0 50%);
} }
#resize .image-original img { .compare .compare-divider {
max-height: 50vh;
min-height: 257px;
max-width: 100%;
min-width: 257px;
/* height: 512px; */
}
#resize-original {
position: relative;
height: 512px;
}
#resize-original img {
position: absolute; position: absolute;
max-width: 90vw; top: 0;
max-height: 512px; bottom: 0;
min-width: 257px; left: 50%;
min-height: 257px; width: 2px;
opacity: 1; margin-left: -1px;
transition: background: var(--color1);
width 2s linear,
height 2s linear,
filter 3s linear 2s,
opacity 3s ease-in 5s;
} }
#resize.resize #resize-original img { .compare .compare-handle {
width: 256px !important; position: absolute;
height: 256px !important; top: 50%;
filter: grayscale(1) blur(10px); left: 50%;
opacity: 0; transform: translate(-50%, -50%);
} width: 28px;
height: 28px;
#resize-original img.grayscale { line-height: 28px;
filter: grayscale(1) blur(10px);
}
#resize-original img.fade {
opacity: 0;
}
#image-resize img {
width: 256px;
height: 256px;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
}
#image-dct {
margin-left: 32px;
display:grid;
grid-template-columns: repeat(8, 32px);
}
#image-dct > div {
height: 32px;
line-height:32px;
font-size: 11px;
text-align: center; text-align: center;
border: 1px solid var(--color1);
box-sizing: border-box;
}
.similarity-example {
display: flex;
flex-flow: row nowrap;
align-items: flex-end;
}
figure {
margin: 10px;
width: 50%;
}
/* Loader */
#loader.show {
border-radius: 50%; border-radius: 50%;
width: 256px; background: var(--color1);
height: 256px; color: var(--background);
font-size: 10px; font-size: 15px;
position: fixed;
z-index: 99;
top: calc(50vh - 128px);
left: calc(50vw - 128px);
border: 16px solid transparent;
border-top: 16px solid darkred;
transform: translateZ(0);
animation: loader 2s infinite ease-in-out;
} }
@keyframes loader { .compare .compare-label {
0% { position: absolute;
transform: rotate(0deg); top: 6px;
padding: 2px 6px;
font-size: 11px;
background: color-mix(in srgb, var(--background) 75%, transparent);
color: var(--foreground);
} }
50% {
transform: rotate(360deg);
}
100% {
transform: rotate(360deg);
}
}
/* Skeleton */
.todo { .compare .compare-label.a {
opacity: 0.55; left: 6px;
font-style: italic; }
border-left: 3px solid var(--color3);
padding-left: 8px; .compare .compare-label.b {
right: 6px;
}
/* Busy indicator while the worker renders a mutation */
.compare.busy::after {
content: 'mutating…';
position: absolute;
right: 8px;
bottom: 8px;
padding: 3px 9px;
background: color-mix(in srgb, var(--background) 80%, transparent);
color: var(--color1);
font-size: 12px;
animation: busy-pulse 1s ease-in-out infinite;
}
.distances.busy td {
opacity: 0.35;
}
@keyframes busy-pulse {
50% { opacity: 0.35; }
} }
/* Hash visualisation */ /* Hash visualisation */
@@ -233,6 +272,14 @@ figure {
letter-spacing: 2px; letter-spacing: 2px;
} }
.hash-value.small {
display: block;
margin-top: 4px;
font-size: 12px;
letter-spacing: 1px;
opacity: 0.8;
}
.bit-grid { .bit-grid {
display: grid; display: grid;
grid-template-columns: repeat(8, 20px); grid-template-columns: repeat(8, 20px);
@@ -248,6 +295,14 @@ figure {
border: 1px solid var(--background); border: 1px solid var(--background);
} }
.bit-grid.small {
grid-template-columns: repeat(8, 13px);
}
.bit-grid.small .bit {
height: 13px;
}
.bit-grid .bit.on { .bit-grid .bit.on {
background: var(--foreground); background: var(--foreground);
} }
@@ -256,6 +311,20 @@ figure {
background: color-mix(in srgb, var(--foreground) 12%, transparent); background: color-mix(in srgb, var(--foreground) 12%, transparent);
} }
/* Changed bits keep their on/off brightness and get a red tint + border,
so light-changed and dark-changed cells stay distinguishable */
.bit-grid .bit.diff {
border-color: hsl(4 70% 55%);
}
.bit-grid .bit.on.diff {
background: color-mix(in srgb, var(--foreground) 55%, hsl(4 70% 50%));
}
.bit-grid .bit.off.diff {
background: hsl(4 60% 50% / 0.25);
}
.heat-grid { .heat-grid {
display: grid; display: grid;
grid-template-columns: repeat(8, 32px); grid-template-columns: repeat(8, 32px);
@@ -266,7 +335,15 @@ figure {
box-sizing: border-box; box-sizing: border-box;
} }
.pixelated, #image-resize img, #phash-resize img { .heat-grid.mini {
grid-template-columns: repeat(8, 22px);
}
.heat-grid.mini > div {
height: 22px;
}
.pixelated, #phash-resize img, .input-thumb img {
image-rendering: pixelated; image-rendering: pixelated;
image-rendering: -moz-crisp-edges; image-rendering: -moz-crisp-edges;
} }
@@ -276,71 +353,520 @@ figure {
height: 128px; height: 128px;
} }
.sample { .input-thumb img {
height: 48px; width: 176px;
margin: 0 4px; height: 176px;
cursor: pointer;
vertical-align: middle;
} }
/* Mutation compare */ /* DCT extras */
.compare { .grid-overlay {
position: relative; position: relative;
max-width: 640px; width: 256px;
} }
.compare img { .grid-overlay.mini {
display: block; width: 176px;
width: 100%;
} }
.compare #compare-b { #dct-basis-wrap {
position: relative;
display: inline-block;
}
.basis-hover {
display: none;
position: absolute;
outline: 2px solid var(--color1);
outline-offset: -1px;
pointer-events: none;
}
#dct-basis {
cursor: crosshair;
}
#dct-formula .formula-result {
display: none;
}
#dct-formula.live .formula-result {
display: inline;
}
#dct-formula.live .var-u,
#dct-formula.live .var-v,
#dct-formula.live .formula-value {
color: var(--color1);
}
#image-dct .hover-cell {
outline: 2px solid var(--color1);
outline-offset: -2px;
}
#dct-zigzag {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
clip-path: inset(0 0 0 50%); pointer-events: none;
} }
#compare-slider, #mutator-amount { #dct-zigzag polyline {
width: 320px; stroke: var(--color1);
stroke-width: 3;
stroke-linejoin: round;
}
#dct-zigzag polyline.play {
transition: stroke-dashoffset 2.5s ease-in-out;
}
#dct-basis {
background: var(--background);
}
/* Reconstruction demos */
.recon-row {
display: flex;
flex-flow: row wrap;
gap: 24px;
align-items: flex-start;
margin: 10px 0;
}
.recon-row figure {
margin: 0;
text-align: center;
}
.recon-row figcaption {
font-size: 12px;
opacity: 0.8;
margin-top: 4px;
}
.recon-row canvas {
width: 176px;
height: 176px;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
}
#recon-k {
width: 256px;
vertical-align: middle;
}
.recon-note {
font-size: 12px;
opacity: 0.7;
margin-left: 8px;
}
#recon-zigzag {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
#recon-zigzag polyline {
stroke-width: 2.5;
stroke-linejoin: round;
transition: stroke-dashoffset 0.12s linear;
}
#recon-zigzag .zig-head {
stroke: var(--color1);
}
#recon-zigzag .zig-tail {
stroke: color-mix(in srgb, var(--foreground) 45%, transparent);
}
/* Tables */
table.facts {
border-collapse: collapse;
margin: 10px 0;
}
table.facts th, table.facts td {
text-align: left;
padding: 4px 14px 4px 0;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
font-size: 13px;
}
table.facts th {
color: var(--color1);
font-weight: normal;
} }
.distances td { .distances td {
text-align: center; text-align: center;
font-size: 18px; font-size: 18px;
min-width: 64px; min-width: 64px;
}
.distances td.match {
color: hsl(135 50% 45%);
}
.distances td.nomatch {
color: hsl(4 70% 55%);
}
/* LSH demo */
.lsh-thumb img {
height: 110px;
margin-bottom: 6px;
display: block;
}
.lsh-distance {
font-size: 16px;
}
.lsh-distance .match {
font-size: 22px;
color: hsl(135 50% 45%);
}
.lsh-distance .nomatch {
font-size: 22px;
color: hsl(4 70% 55%);
}
/* Flip demo */
#flip-stage img {
max-width: 256px;
max-height: 256px;
transition: transform 0.7s ease-in-out;
}
#flip-stage img.flipped {
transform: scaleX(-1);
}
#ghost-canvas {
transition: transform 0.4s ease-in-out;
}
#ghost-canvas.flipped {
transform: scaleX(-1);
}
.flip-table th {
color: var(--color1); color: var(--color1);
font-weight: normal;
text-align: left;
padding-right: 12px;
}
.flip-table td {
vertical-align: top;
padding: 6px 12px 6px 0;
}
.flip-distance {
font-size: 24px;
}
.flip-distance.match {
color: hsl(135 50% 45%);
}
.flip-distance.nomatch {
color: hsl(4 70% 55%);
} }
/* Ranking */ /* Ranking */
.thumb-grid { .pool {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 4px;
max-height: 330px;
overflow-y: auto;
margin: 10px 0;
}
.pool img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
cursor: pointer;
display: block;
}
.pool img:hover {
outline: 2px solid var(--color1);
}
.pool img.query {
outline: 3px solid var(--color1);
}
.ranking-results h3 {
margin-top: 14px;
}
.thumb-row {
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
gap: 8px; gap: 8px;
} }
.thumb-grid figure { .thumb-row figure {
width: 96px; width: 84px;
margin: 0; margin: 0;
cursor: pointer;
text-align: center; text-align: center;
} }
.thumb-grid figure.query { .thumb-row figure.hit {
outline: 2px solid var(--color1); outline: 2px solid hsl(135 50% 45%);
} }
.thumb-grid img { .thumb-row img {
width: 96px; width: 84px;
height: 96px; height: 84px;
object-fit: cover; object-fit: cover;
display: block;
} }
.thumb-grid figcaption { .thumb-row figcaption {
font-size: 11px; font-size: 11px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
/* Precision-recall charts */
.pr-controls {
margin: 10px 0;
}
#pr-legend {
display: flex;
flex-flow: row wrap;
gap: 4px 14px;
margin-bottom: 8px;
}
.pr-group {
display: flex;
flex-flow: row wrap;
gap: 4px;
}
.pr-chip {
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
background: none;
color: var(--foreground);
font-size: 11px;
padding: 2px 7px;
cursor: pointer;
transition: opacity 0.15s;
}
.pr-chip.dim {
opacity: 0.3;
}
.pr-chip.pinned {
border-color: var(--color1);
}
.pr-slider {
display: inline-block;
margin: 4px 0;
color: var(--color4);
}
.pr-slider input {
width: 220px;
vertical-align: middle;
margin-left: 8px;
}
.pr-charts {
display: flex;
flex-flow: row wrap;
gap: 12px;
}
.pr-charts figure {
margin: 0;
flex: 1 1 300px;
max-width: 340px;
}
.pr-charts figcaption {
color: var(--color1);
font-size: 14px;
margin-bottom: 2px;
}
.pr-svg {
width: 100%;
height: auto;
background: color-mix(in srgb, var(--foreground) 3%, transparent);
}
.pr-grid {
stroke: color-mix(in srgb, var(--foreground) 12%, transparent);
stroke-width: 1;
}
.pr-tick {
fill: color-mix(in srgb, var(--foreground) 55%, transparent);
font-size: 9px;
}
.pr-axis {
fill: color-mix(in srgb, var(--foreground) 70%, transparent);
font-size: 11px;
}
.pr-line {
fill: none;
stroke-width: 1.8;
transition: opacity 0.15s;
}
.pr-dot {
stroke: var(--background);
stroke-width: 2;
transition: opacity 0.15s;
}
.pr-line.faded, .pr-dot.faded {
opacity: 0.12;
}
#pr-tooltip {
position: fixed;
z-index: 10;
background: color-mix(in srgb, var(--background) 92%, transparent);
border: 1px solid var(--color1);
color: var(--foreground);
font-size: 12px;
padding: 4px 8px;
pointer-events: none;
white-space: nowrap;
}
/* Retrieval demos */
.dist-figure, .toy-figure {
margin: 10px 0;
}
.dist-figure figcaption, .toy-figure figcaption {
font-size: 12px;
opacity: 0.8;
margin-top: 4px;
}
.dist-svg {
max-width: 720px;
}
.dist-bar {
fill: color-mix(in srgb, var(--foreground) 45%, transparent);
}
.dist-copy {
fill: hsl(135 50% 45%);
stroke: var(--background);
stroke-width: 1;
}
.dist-region {
fill: color-mix(in srgb, var(--color1) 10%, transparent);
}
.dist-thresh {
stroke: var(--color1);
stroke-width: 2;
}
.dist-readout, .toy-stats {
margin-left: 12px;
color: var(--color4);
}
.toy-node {
fill: color-mix(in srgb, var(--foreground) 30%, transparent);
transition: opacity 0.3s, fill 0.3s;
}
.toy-node.seen {
fill: color-mix(in srgb, var(--foreground) 10%, var(--background));
stroke: var(--foreground);
stroke-width: 2;
}
.toy-node.match {
fill: hsl(135 50% 45%);
}
.toy-node.pruned {
opacity: 0.15;
}
.toy-edge {
stroke: color-mix(in srgb, var(--foreground) 45%, transparent);
stroke-width: 1.5;
transition: opacity 0.3s;
}
.toy-edge-label {
fill: color-mix(in srgb, var(--foreground) 70%, transparent);
font-size: 9px;
transition: opacity 0.3s;
}
.toy-edge.pruned, .toy-edge-label.pruned {
opacity: 0.15;
}
.big-stats .stat-note {
font-size: 12px;
opacity: 0.7;
margin: 0;
max-width: 220px;
}
.big-curve-svg {
width: 250px;
}
.big-curve-line {
stroke: var(--color2);
}
.big-curve-dot {
fill: var(--color1);
}
/* Lists */
ul.loose-ends li, ol.references li {
margin-bottom: 6px;
line-height: 1.5;
}
ol.references li:target {
outline: 1px dashed var(--color1);
outline-offset: 4px;
}
+71 -5
View File
@@ -1,15 +1,32 @@
import init, { hash_all, resize_preview, dct_coefficients, dct_masks, phash_lowfreq, mutate } from './pkg/image_similarity.js'; import init, { hash_all, resize_preview, dct_coefficients, dct_masks, phash_lowfreq, mutate, suite_hashes, suite_labels, BkIndex } from './pkg/image_similarity.js';
const ready = init(); const ready = init();
// The retrieval demos keep their trees in wasm memory between calls:
// a small one that gets visualized and the full experiment index.
let toyIndex = null;
let bigIndex = null;
// Average runtime of fn, sampled until enough wall time passed to
// outrun the clamped performance.now resolution
function time(fn) {
const start = performance.now();
let reps = 0;
do {
fn();
reps++;
} while (performance.now() - start < 20);
return (performance.now() - start) / reps;
}
onmessage = async (e) => { onmessage = async (e) => {
const { id, op, args } = e.data; const { id, op, args } = e.data;
await ready; await ready;
try { try {
const bytes = new Uint8Array(args.buffer);
let result; let result;
switch (op) { switch (op) {
case 'pipeline': case 'pipeline': {
const bytes = new Uint8Array(args.buffer);
result = { result = {
resize8: resize_preview(bytes, 8), resize8: resize_preview(bytes, 8),
resize32: resize_preview(bytes, 32), resize32: resize_preview(bytes, 32),
@@ -19,14 +36,63 @@ onmessage = async (e) => {
hashes: hash_all(bytes), hashes: hash_all(bytes),
}; };
break; break;
}
case 'hashes': case 'hashes':
result = { hashes: hash_all(bytes) }; result = { hashes: hash_all(new Uint8Array(args.buffer)) };
break; break;
case 'mutate': { case 'mutate': {
const png = mutate(bytes, args.kind, args.amount); const png = mutate(new Uint8Array(args.buffer), args.kind, args.amount);
result = { png, hashes: hash_all(png) }; result = { png, hashes: hash_all(png) };
break; break;
} }
case 'suite':
result = {
hashes: suite_hashes(new Uint8Array(args.buffer)),
labels: suite_labels().split('\n'),
};
break;
case 'toy_build': {
toyIndex?.free();
toyIndex = new BkIndex();
for (const key of args.keys) toyIndex.insert(key);
result = { structure: toyIndex.structure(), size: toyIndex.len() };
break;
}
case 'toy_query':
result = {
found: toyIndex.find(args.key, args.radius),
visited: toyIndex.trace(args.key, args.radius),
};
break;
case 'big_build': {
bigIndex?.free();
bigIndex = new BkIndex();
bigIndex.insert_bytes(new Uint8Array(args.buffer));
result = { size: bigIndex.len() };
break;
}
case 'big_query': {
const found = bigIndex.find(args.key, args.radius);
result = {
found,
compared: bigIndex.find_compared(args.key, args.radius),
treeMs: time(() => bigIndex.find(args.key, args.radius)),
scanMs: time(() => bigIndex.scan(args.key, args.radius)),
};
break;
}
// compared counts and found counts for every radius up to max,
// so the radius slider works without a round trip per step
case 'big_profile': {
const compared = [];
const found = [];
for (let t = 0; t <= args.max; t++) {
compared.push(bigIndex.find_compared(args.key, t));
found.push(bigIndex.find(args.key, t).length / 2);
}
result = { compared, found };
break;
}
default: default:
throw new Error(`unknown op ${op}`); throw new Error(`unknown op ${op}`);
} }