Skip to main content

pixelcoords_core/
locate.rs

1//! Template relocation: find where a saved crop sits in a fresh capture —
2//! the logic behind `pixelcoords find`.
3//!
4//! A session's coordinates describe one frozen instant; the moment the UI
5//! drifts they are silently stale. Every selection already ships with a
6//! pixel-exact crop, so the crop doubles as a search template: normalized
7//! cross-correlation over a fresh capture finds where the region sits
8//! *now*, reports how far it moved, and — just as important — says so
9//! honestly when it cannot: a region whose pixels changed scores below the
10//! floor, and a region that appears more than once comes back flagged
11//! ambiguous instead of silently picking one.
12//!
13//! This is drift correction, not computer vision: it survives movement,
14//! not redesigns or scale changes, and the caller is expected to refuse
15//! mismatched DPI up front.
16
17use serde::Serialize;
18use thiserror::Error;
19
20use crate::geometry::{Point, Shape, Size};
21use crate::report::{Command, Report};
22use crate::session::SelectionRecord;
23
24/// Matches scoring below this are reported not found. Normalized
25/// cross-correlation is 1.0 for a pixel-identical region; anti-aliasing
26/// differences after a move cost a little, a changed region costs a lot.
27pub const SCORE_FLOOR: f64 = 0.9;
28
29/// A credible second location within this gap of the best makes the match
30/// ambiguous — five identical checkboxes must not silently resolve to one.
31pub const AMBIGUITY_GAP: f64 = 0.03;
32
33/// Grayscale pixels in `[0, 1]`, row-major.
34#[derive(Debug, Clone)]
35pub struct GrayImage {
36    pub w: usize,
37    pub h: usize,
38    pub px: Vec<f32>,
39}
40
41impl GrayImage {
42    /// From RGBA8 bytes (4 per pixel), Rec. 601 luma.
43    pub fn from_rgba(w: usize, h: usize, rgba: &[u8]) -> Self {
44        assert_eq!(rgba.len(), w * h * 4, "rgba buffer matches dimensions");
45        let px = rgba
46            .chunks_exact(4)
47            .map(|p| luma(p[0], p[1], p[2]))
48            .collect();
49        Self { w, h, px }
50    }
51}
52
53fn luma(r: u8, g: u8, b: u8) -> f32 {
54    (0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b)) / 255.0
55}
56
57/// The alpha at or above which a crop pixel counts as inside its
58/// shape. Public because `diff` must apply the identical rule — two
59/// definitions of "inside" could silently drift apart.
60pub const MASK_ALPHA_FLOOR: u8 = 128;
61
62/// A crop as a search template: luma plus a mask excluding the transparent
63/// pixels a circle, triangle, or rotated-rect crop carries outside its
64/// shape.
65#[derive(Debug, Clone)]
66pub struct Template {
67    pub gray: GrayImage,
68    pub mask: Vec<bool>,
69}
70
71impl Template {
72    /// From RGBA8 bytes; pixels with alpha below half are masked out.
73    pub fn from_rgba(w: usize, h: usize, rgba: &[u8]) -> Self {
74        let gray = GrayImage::from_rgba(w, h, rgba);
75        let mask = rgba
76            .chunks_exact(4)
77            .map(|p| p[3] >= MASK_ALPHA_FLOOR)
78            .collect();
79        Self { gray, mask }
80    }
81}
82
83/// Where a template was found, and how sure the match is.
84#[derive(Debug, Clone, Copy, PartialEq)]
85pub struct Located {
86    /// Top-left of the best match, in the searched image's pixels.
87    pub x: i32,
88    pub y: i32,
89    /// Normalized cross-correlation of the best match, `-1..=1`.
90    pub score: f64,
91    /// Best score at a location separated from the match by at least half
92    /// the template, or `-1` when the image holds no separated location.
93    pub runner_up: f64,
94    /// The runner-up is itself above `SCORE_FLOOR` and within
95    /// `AMBIGUITY_GAP` of the best — the match is not trustworthy.
96    pub ambiguous: bool,
97}
98
99#[derive(Debug, Error, PartialEq, Eq)]
100pub enum LocateError {
101    #[error("the crop is larger than the capture it is searched in")]
102    TemplateLargerThanScreen,
103    #[error("the crop has no visible pixels to match")]
104    EmptyTemplate,
105    #[error("the crop is a flat color, which matches anywhere rather than somewhere")]
106    FlatTemplate,
107    #[error("the crop does not fit in the {frame:?} frame at {at:?}")]
108    OutOfFrame { at: Point, frame: Size },
109}
110
111/// A template prepared for repeated scoring: its masked pixels, their
112/// values, and the moments correlation needs.
113///
114/// Preparing it is where a crop with nothing to match — no visible
115/// pixels, or one flat colour — is refused. A caller that polls therefore
116/// cannot discover that on poll sixty: it finds out before the loop, with
117/// the reason.
118#[derive(Debug, Clone)]
119pub struct TemplateStats {
120    coords: Vec<(usize, usize)>,
121    values: Vec<f64>,
122    mean: f64,
123    var: f64,
124    /// Extent of the masked pixels, `max coord + 1` per axis — the bound
125    /// a placement has to satisfy. Not the template's own size: a mask
126    /// that is empty at the right edge legitimately lets a scan reach
127    /// further, and `locate` depends on that.
128    span: Size,
129}
130
131impl TemplateStats {
132    /// Prepare `tpl` for scoring, refusing a crop that cannot match.
133    pub fn prepare(tpl: &Template) -> Result<Self, LocateError> {
134        template_stats(tpl)
135    }
136
137    /// The masked extent — how much room a placement needs.
138    #[must_use]
139    pub const fn span(&self) -> Size {
140        self.span
141    }
142
143    /// Normalized cross-correlation with the template placed at `at`.
144    ///
145    /// Fixed-location scoring: no search and no ambiguity, which is
146    /// `locate`'s job. This is what `wait` polls with — a region that has
147    /// not moved is answered by reading its own pixels, rather than
148    /// re-scanning a whole frame to rediscover where it already is.
149    pub fn score_at(&self, screen: &GrayImage, at: Point) -> Result<f64, LocateError> {
150        let fits = at.x >= 0
151            && at.y >= 0
152            && (at.x as usize).saturating_add(self.span.w as usize) <= screen.w
153            && (at.y as usize).saturating_add(self.span.h as usize) <= screen.h;
154        if !fits {
155            return Err(LocateError::OutOfFrame {
156                at,
157                frame: Size::new(screen.w as i32, screen.h as i32),
158            });
159        }
160        Ok(ncc_at(screen, self, at.x as usize, at.y as usize))
161    }
162}
163
164fn template_stats(tpl: &Template) -> Result<TemplateStats, LocateError> {
165    let mut coords = Vec::new();
166    let mut values = Vec::new();
167    for ty in 0..tpl.gray.h {
168        for tx in 0..tpl.gray.w {
169            if !tpl.mask[ty * tpl.gray.w + tx] {
170                continue;
171            }
172            coords.push((tx, ty));
173            values.push(f64::from(tpl.gray.px[ty * tpl.gray.w + tx]));
174        }
175    }
176    if coords.is_empty() {
177        return Err(LocateError::EmptyTemplate);
178    }
179    let n = values.len() as f64;
180    let mean = values.iter().sum::<f64>() / n;
181    let var = values.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>();
182    if var <= f64::EPSILON {
183        return Err(LocateError::FlatTemplate);
184    }
185    let span = Size::new(
186        coords.iter().map(|c| c.0).max().unwrap_or(0) as i32 + 1,
187        coords.iter().map(|c| c.1).max().unwrap_or(0) as i32 + 1,
188    );
189    Ok(TemplateStats {
190        coords,
191        values,
192        mean,
193        var,
194        span,
195    })
196}
197
198/// Normalized cross-correlation of the template placed at (`ox`, `oy`).
199fn ncc_at(screen: &GrayImage, stats: &TemplateStats, ox: usize, oy: usize) -> f64 {
200    let n = stats.values.len() as f64;
201    let mut sum_screen = 0.0;
202    let mut sum_screen_sq = 0.0;
203    let mut sum_cross = 0.0;
204    for (i, &(tx, ty)) in stats.coords.iter().enumerate() {
205        let s = f64::from(screen.px[(oy + ty) * screen.w + ox + tx]);
206        sum_screen += s;
207        sum_screen_sq += s * s;
208        sum_cross += stats.values[i] * s;
209    }
210    let mean_screen = sum_screen / n;
211    let var_screen = sum_screen_sq - n * mean_screen * mean_screen;
212    if var_screen <= f64::EPSILON {
213        return 0.0;
214    }
215    let cov = sum_cross - n * stats.mean * mean_screen;
216    cov / (stats.var * var_screen).sqrt()
217}
218
219/// The best-scoring offset within the window, ends inclusive.
220fn best_in_window(
221    screen: &GrayImage,
222    stats: &TemplateStats,
223    x0: usize,
224    y0: usize,
225    x1: usize,
226    y1: usize,
227) -> (usize, usize, f64) {
228    let mut best = (x0, y0, f64::from(f32::MIN));
229    for oy in y0..=y1 {
230        for ox in x0..=x1 {
231            let score = ncc_at(screen, stats, ox, oy);
232            if score > best.2 {
233                best = (ox, oy, score);
234            }
235        }
236    }
237    best
238}
239
240/// Find the template in `screen`. `hint` is where it was last seen —
241/// always re-checked so a region that has not moved matches immediately.
242pub fn locate(
243    screen: &GrayImage,
244    tpl: &Template,
245    hint: Option<Point>,
246) -> Result<Located, LocateError> {
247    let (tw, th) = (tpl.gray.w, tpl.gray.h);
248    if tw > screen.w || th > screen.h || tw == 0 || th == 0 {
249        return Err(LocateError::TemplateLargerThanScreen);
250    }
251    let stats = template_stats(tpl)?;
252    let max_x = screen.w - tw;
253    let max_y = screen.h - th;
254
255    // Coarse pass: downsampled full-image scan finds candidate locations
256    // cheaply; small templates scan at full resolution instead. Scanning
257    // the whole image even when the hint matches is what makes the
258    // ambiguity flag honest — a duplicate elsewhere must be seen.
259    let factor = match tw.min(th) {
260        0..16 => 1,
261        16..32 => 2,
262        _ => 4,
263    };
264    let mut candidates = coarse_candidates(screen, tpl, factor)?;
265    if let Some(p) = hint {
266        candidates.push((
267            p.x.clamp(0, max_x as i32) as usize,
268            p.y.clamp(0, max_y as i32) as usize,
269        ));
270    }
271
272    // Refine every candidate in a small full-resolution window.
273    let margin = factor * 2;
274    let mut refined: Vec<(usize, usize, f64)> = candidates
275        .iter()
276        .map(|&(cx, cy)| {
277            let x0 = cx.saturating_sub(margin);
278            let y0 = cy.saturating_sub(margin);
279            best_in_window(
280                screen,
281                &stats,
282                x0,
283                y0,
284                (cx + margin).min(max_x),
285                (cy + margin).min(max_y),
286            )
287        })
288        .collect();
289    refined.sort_by(|a, b| b.2.total_cmp(&a.2));
290    let best = refined[0];
291
292    // The runner-up must be a genuinely different location: separated from
293    // the best by at least half the template in some axis.
294    let separated = |x: usize, y: usize| x.abs_diff(best.0) > tw / 2 || y.abs_diff(best.1) > th / 2;
295    let runner_up = refined[1..]
296        .iter()
297        .filter(|&&(x, y, _)| separated(x, y))
298        .map(|&(_, _, s)| s)
299        .fold(-1.0f64, f64::max);
300
301    let ambiguous = runner_up >= SCORE_FLOOR && best.2 - runner_up <= AMBIGUITY_GAP;
302    Ok(Located {
303        x: best.0 as i32,
304        y: best.1 as i32,
305        score: best.2,
306        runner_up,
307        ambiguous,
308    })
309}
310
311/// Candidate locations from a full scan at `factor` downsampling: the best
312/// cell and the best cell separated from it, mapped back to full
313/// resolution. At factor 1 the scan is exact and the candidates are final
314/// positions.
315fn coarse_candidates(
316    screen: &GrayImage,
317    tpl: &Template,
318    factor: usize,
319) -> Result<Vec<(usize, usize)>, LocateError> {
320    let (small_screen, small_stats) = if factor == 1 {
321        (screen.clone(), template_stats(tpl)?)
322    } else {
323        let small_tpl = downsample_template(tpl, factor);
324        (downsample(screen, factor), template_stats(&small_tpl)?)
325    };
326    let tw = small_stats.coords.iter().map(|c| c.0).max().unwrap_or(0) + 1;
327    let th = small_stats.coords.iter().map(|c| c.1).max().unwrap_or(0) + 1;
328    if tw > small_screen.w || th > small_screen.h {
329        return Err(LocateError::TemplateLargerThanScreen);
330    }
331    let max_x = small_screen.w - tw;
332    let max_y = small_screen.h - th;
333
334    let mut scores = vec![0.0f64; (max_x + 1) * (max_y + 1)];
335    for oy in 0..=max_y {
336        for ox in 0..=max_x {
337            scores[oy * (max_x + 1) + ox] = ncc_at(&small_screen, &small_stats, ox, oy);
338        }
339    }
340    // The top cells, each outside the template footprint of those already
341    // chosen. Three, not one: downsampling blurs away up to a cell of
342    // phase, so the true peak can rank behind a lucky neighbor at coarse
343    // resolution — refinement at full resolution settles it.
344    let mut out: Vec<(usize, usize)> = Vec::new();
345    for _ in 0..3 {
346        let mut best = (0usize, 0usize, f64::from(f32::MIN));
347        for oy in 0..=max_y {
348            for ox in 0..=max_x {
349                let suppressed = out.iter().any(|&(px, py)| {
350                    ox.abs_diff(px / factor) <= tw / 2 && oy.abs_diff(py / factor) <= th / 2
351                });
352                if suppressed {
353                    continue;
354                }
355                let s = scores[oy * (max_x + 1) + ox];
356                if s > best.2 {
357                    best = (ox, oy, s);
358                }
359            }
360        }
361        if best.2 <= f64::from(f32::MIN) {
362            break;
363        }
364        out.push((best.0 * factor, best.1 * factor));
365    }
366    Ok(out)
367}
368
369/// Box-average downsample by `factor`.
370fn downsample(img: &GrayImage, factor: usize) -> GrayImage {
371    let w = img.w / factor;
372    let h = img.h / factor;
373    let mut px = Vec::with_capacity(w * h);
374    for y in 0..h {
375        for x in 0..w {
376            let mut sum = 0.0f32;
377            for sy in 0..factor {
378                for sx in 0..factor {
379                    sum += img.px[(y * factor + sy) * img.w + x * factor + sx];
380                }
381            }
382            px.push(sum / (factor * factor) as f32);
383        }
384    }
385    GrayImage { w, h, px }
386}
387
388/// Downsample a masked template: a cell is masked in when at least half
389/// its source pixels are, and averages only those.
390fn downsample_template(tpl: &Template, factor: usize) -> Template {
391    let w = tpl.gray.w / factor;
392    let h = tpl.gray.h / factor;
393    let mut px = Vec::with_capacity(w * h);
394    let mut mask = Vec::with_capacity(w * h);
395    for y in 0..h {
396        for x in 0..w {
397            let mut sum = 0.0f32;
398            let mut n = 0usize;
399            for sy in 0..factor {
400                for sx in 0..factor {
401                    let i = (y * factor + sy) * tpl.gray.w + x * factor + sx;
402                    if !tpl.mask[i] {
403                        continue;
404                    }
405                    sum += tpl.gray.px[i];
406                    n += 1;
407                }
408            }
409            let keep = n * 2 >= factor * factor;
410            mask.push(keep);
411            px.push(if keep { sum / n as f32 } else { 0.0 });
412        }
413    }
414    Template {
415        gray: GrayImage { w, h, px },
416        mask,
417    }
418}
419
420/// Where a selection's crop was cut from its monitor frame: the rotated
421/// bounding box, clipped to the frame — the mirror of the save path.
422pub fn crop_origin(shape: &Shape, rot_deg: i32, frame: Size) -> Point {
423    let bbox = shape.rotated_bbox(rot_deg);
424    Point::new(bbox.x.max(0).min(frame.w), bbox.y.max(0).min(frame.h))
425}
426
427/// One selection's relocation attempt, ready for the report.
428pub struct Relocation {
429    /// Where the crop's top-left sat when the session was saved.
430    pub crop_origin: Point,
431    pub outcome: Result<Located, LocateError>,
432}
433
434/// How far a selection moved.
435#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
436pub struct Delta {
437    pub dx: i32,
438    pub dy: i32,
439}
440
441/// The `find` subcommand's JSON output for one selection.
442#[derive(Debug, Clone, PartialEq, Serialize)]
443pub struct FindResult {
444    pub index: usize,
445    pub label: String,
446    pub monitor: usize,
447    pub found: bool,
448    pub ambiguous: bool,
449    pub score: f64,
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub reason: Option<String>,
452    pub old_px: Shape,
453    #[serde(skip_serializing_if = "Option::is_none")]
454    pub new_px: Option<Shape>,
455    #[serde(skip_serializing_if = "Option::is_none")]
456    pub new_global_px: Option<Shape>,
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub delta: Option<Delta>,
459}
460
461/// Every selection was found, unambiguously — `find`'s aggregate, which
462/// becomes the report's `ok` and the process's exit code.
463///
464/// An empty result set is *not* success: `find` was asked about regions
465/// and answered about none.
466#[must_use]
467pub fn all_relocated(results: &[FindResult]) -> bool {
468    !results.is_empty() && results.iter().all(|r| r.found && !r.ambiguous)
469}
470
471/// Assemble the report from the attempted selections — the whole session
472/// or a labeled subset. Each attempt is `(index, record, relocation)`:
473/// the record's index in the session file, which every row carries as its
474/// identity, and the record itself.
475///
476/// The record is passed rather than looked up by index inside a
477/// `SessionFile`, so a caller cannot hand over an index that does not
478/// resolve. `session::select_by_label` returns exactly these pairs.
479/// `captured_utc` is supplied by the caller — this crate has no clock.
480pub fn report(
481    attempts: &[(usize, &SelectionRecord, Relocation)],
482    captured_utc: String,
483) -> Report<FindResult> {
484    let results: Vec<FindResult> = attempts
485        .iter()
486        .map(|(index, record, reloc)| {
487            let index = *index;
488            let base = FindResult {
489                index,
490                label: record.label.clone(),
491                monitor: record.monitor,
492                found: false,
493                ambiguous: false,
494                score: 0.0,
495                reason: None,
496                old_px: record.px.clone(),
497                new_px: None,
498                new_global_px: None,
499                delta: None,
500            };
501            match &reloc.outcome {
502                Err(e) => FindResult {
503                    reason: Some(e.to_string()),
504                    ..base
505                },
506                Ok(loc) => {
507                    let found = loc.score >= SCORE_FLOOR;
508                    let moved = found && !loc.ambiguous;
509                    let dx = loc.x - reloc.crop_origin.x;
510                    let dy = loc.y - reloc.crop_origin.y;
511                    FindResult {
512                        found,
513                        ambiguous: found && loc.ambiguous,
514                        score: loc.score,
515                        new_px: moved.then(|| record.px.translated(dx, dy)),
516                        new_global_px: moved.then(|| record.global_px.translated(dx, dy)),
517                        delta: moved.then_some(Delta { dx, dy }),
518                        ..base
519                    }
520                }
521            }
522        })
523        .collect();
524    let ok = all_relocated(&results);
525    Report::captured(Command::Find, captured_utc, ok, results)
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use crate::geometry::Rect;
532
533    /// Deterministic pseudo-texture, smoothed so neighboring pixels
534    /// correlate the way real screen content does — iid noise defeats any
535    /// pyramid by construction, and screens are not noise. Every window is
536    /// still unique, so the match stays well-posed.
537    fn textured(w: usize, h: usize, seed: u32) -> GrayImage {
538        let mut state = seed | 1;
539        let mut px: Vec<f32> = (0..w * h)
540            .map(|_| {
541                // xorshift32 — deterministic, no clock, no rand crate.
542                state ^= state << 13;
543                state ^= state >> 17;
544                state ^= state << 5;
545                (state % 1000) as f32 / 1000.0
546            })
547            .collect();
548        for _ in 0..2 {
549            px = blur3(&px, w, h);
550        }
551        GrayImage { w, h, px }
552    }
553
554    fn blur3(px: &[f32], w: usize, h: usize) -> Vec<f32> {
555        let mut out = Vec::with_capacity(w * h);
556        for y in 0..h {
557            for x in 0..w {
558                out.push(blurred_at(px, w, h, x, y));
559            }
560        }
561        out
562    }
563
564    fn blurred_at(px: &[f32], width: usize, height: usize, col: usize, row: usize) -> f32 {
565        let mut sum = 0.0f32;
566        let mut count = 0.0f32;
567        for dy in -1i32..=1 {
568            for dx in -1i32..=1 {
569                let nx = col as i32 + dx;
570                let ny = row as i32 + dy;
571                if nx < 0 || ny < 0 || nx >= width as i32 || ny >= height as i32 {
572                    continue;
573                }
574                sum += px[ny as usize * width + nx as usize];
575                count += 1.0;
576            }
577        }
578        sum / count
579    }
580
581    fn cut(img: &GrayImage, x: usize, y: usize, w: usize, h: usize) -> Template {
582        let mut px = Vec::with_capacity(w * h);
583        for ty in 0..h {
584            for tx in 0..w {
585                px.push(img.px[(y + ty) * img.w + x + tx]);
586            }
587        }
588        Template {
589            gray: GrayImage { w, h, px },
590            mask: vec![true; w * h],
591        }
592    }
593
594    #[test]
595    fn a_cut_template_is_found_where_it_was_cut() {
596        let screen = textured(200, 150, 7);
597        let tpl = cut(&screen, 63, 41, 24, 18);
598        // A stale hint must not stop the full-image scan finding the truth.
599        let loc = locate(&screen, &tpl, Some(Point::new(5, 5))).unwrap();
600        assert_eq!((loc.x, loc.y), (63, 41));
601        assert!(loc.score > 0.999, "exact cut scores ~1, got {}", loc.score);
602        assert!(!loc.ambiguous);
603    }
604
605    #[test]
606    fn a_large_template_takes_the_pyramid_path_and_still_lands_exactly() {
607        let screen = textured(400, 300, 11);
608        let tpl = cut(&screen, 137, 92, 80, 64);
609        let loc = locate(&screen, &tpl, None).unwrap();
610        assert_eq!((loc.x, loc.y), (137, 92));
611        assert!(loc.score > 0.999);
612    }
613
614    #[test]
615    fn a_masked_template_ignores_its_transparent_corners() {
616        let screen = textured(160, 120, 23);
617        let mut tpl = cut(&screen, 50, 40, 32, 32);
618        // Mask out the corners (a circle crop's transparency), then
619        // vandalize those pixels in the template: the match must not care.
620        for (i, m) in tpl.mask.iter_mut().enumerate() {
621            let (x, y) = (i % 32, i / 32);
622            let (cx, cy) = (16i32, 16i32);
623            let (dx, dy) = (x as i32 - cx, y as i32 - cy);
624            if dx * dx + dy * dy > 16 * 16 {
625                *m = false;
626            }
627        }
628        for (i, p) in tpl.gray.px.iter_mut().enumerate() {
629            if !tpl.mask[i] {
630                *p = 1.0 - *p;
631            }
632        }
633        let loc = locate(&screen, &tpl, None).unwrap();
634        assert_eq!((loc.x, loc.y), (50, 40));
635        assert!(loc.score > 0.999);
636    }
637
638    #[test]
639    fn scoring_at_a_known_spot_agrees_with_searching_for_it() {
640        // `wait` polls with `score_at` instead of `locate` because it
641        // already knows where the region is. The two must agree there, or
642        // a region would "match" for one command and not the other.
643        let screen = textured(160, 120, 31);
644        let tpl = cut(&screen, 50, 40, 24, 24);
645        let stats = TemplateStats::prepare(&tpl).unwrap();
646
647        let fixed = stats.score_at(&screen, Point::new(50, 40)).unwrap();
648        let searched = locate(&screen, &tpl, None).unwrap();
649        assert!(
650            (fixed - searched.score).abs() < 1e-9,
651            "{fixed} vs {searched:?}"
652        );
653        assert!(fixed > 0.999);
654    }
655
656    #[test]
657    fn scoring_the_wrong_spot_scores_low_without_searching_for_a_better_one() {
658        let screen = textured(160, 120, 31);
659        let stats = TemplateStats::prepare(&cut(&screen, 50, 40, 24, 24)).unwrap();
660        let elsewhere = stats.score_at(&screen, Point::new(10, 10)).unwrap();
661        assert!(
662            elsewhere < SCORE_FLOOR,
663            "fixed-location scoring reports what is there, got {elsewhere}"
664        );
665    }
666
667    #[test]
668    fn a_placement_off_the_frame_is_refused_rather_than_indexing_past_it() {
669        // The private hot path indexes unchecked; the public entry point
670        // is where a caller's coordinate gets validated.
671        let screen = textured(64, 64, 7);
672        let stats = TemplateStats::prepare(&cut(&screen, 0, 0, 24, 24)).unwrap();
673        assert_eq!(stats.span(), Size::new(24, 24));
674
675        for bad in [Point::new(-1, 0), Point::new(0, -1), Point::new(41, 0)] {
676            assert_eq!(
677                stats.score_at(&screen, bad).unwrap_err(),
678                LocateError::OutOfFrame {
679                    at: bad,
680                    frame: Size::new(64, 64),
681                },
682                "at {bad:?}"
683            );
684        }
685        // Exactly flush with the far edge still fits.
686        assert!(stats.score_at(&screen, Point::new(40, 40)).is_ok());
687    }
688
689    #[test]
690    fn preparing_is_where_an_unusable_crop_is_refused() {
691        // The reason `wait` prepares before its loop rather than inside
692        // it: a featureless crop is named up front, not on poll sixty.
693        let flat = Template {
694            gray: GrayImage {
695                w: 4,
696                h: 4,
697                px: vec![0.5; 16],
698            },
699            mask: vec![true; 16],
700        };
701        assert_eq!(
702            TemplateStats::prepare(&flat).unwrap_err(),
703            LocateError::FlatTemplate
704        );
705
706        let empty = Template {
707            gray: GrayImage {
708                w: 4,
709                h: 4,
710                px: vec![0.5; 16],
711            },
712            mask: vec![false; 16],
713        };
714        assert_eq!(
715            TemplateStats::prepare(&empty).unwrap_err(),
716            LocateError::EmptyTemplate
717        );
718    }
719
720    #[test]
721    fn changed_pixels_score_below_the_floor() {
722        let screen = textured(160, 120, 31);
723        let tpl = cut(&screen, 50, 40, 24, 24);
724        let elsewhere = textured(160, 120, 97);
725        let loc = locate(&elsewhere, &tpl, None).unwrap();
726        assert!(
727            loc.score < SCORE_FLOOR,
728            "unrelated content must not match, got {}",
729            loc.score
730        );
731    }
732
733    #[test]
734    fn a_duplicated_region_is_ambiguous_not_silently_resolved() {
735        let mut screen = textured(300, 100, 43);
736        // Stamp the patch at 20,30 onto 200,30 pixel-for-pixel.
737        for ty in 0..24 {
738            for tx in 0..24 {
739                let v = screen.px[(30 + ty) * 300 + 20 + tx];
740                screen.px[(30 + ty) * 300 + 200 + tx] = v;
741            }
742        }
743        let tpl = cut(&screen, 20, 30, 24, 24);
744        let loc = locate(&screen, &tpl, None).unwrap();
745        assert!(loc.ambiguous, "two identical regions: {loc:?}");
746        assert!(loc.runner_up > 0.999);
747    }
748
749    #[test]
750    fn degenerate_templates_are_refused_with_reasons() {
751        let screen = textured(50, 50, 3);
752        let flat = Template {
753            gray: GrayImage {
754                w: 8,
755                h: 8,
756                px: vec![0.5; 64],
757            },
758            mask: vec![true; 64],
759        };
760        assert_eq!(
761            locate(&screen, &flat, None).unwrap_err(),
762            LocateError::FlatTemplate
763        );
764        let empty = Template {
765            gray: GrayImage {
766                w: 8,
767                h: 8,
768                px: vec![0.5; 64],
769            },
770            mask: vec![false; 64],
771        };
772        assert_eq!(
773            locate(&screen, &empty, None).unwrap_err(),
774            LocateError::EmptyTemplate
775        );
776        let huge = cut(&textured(80, 80, 5), 0, 0, 80, 80);
777        assert_eq!(
778            locate(&screen, &huge, None).unwrap_err(),
779            LocateError::TemplateLargerThanScreen
780        );
781    }
782
783    #[test]
784    fn rgba_conversion_masks_transparency_and_weights_luma() {
785        // Two pixels: opaque pure green, transparent white.
786        let rgba = [0u8, 255, 0, 255, 255, 255, 255, 0];
787        let tpl = Template::from_rgba(2, 1, &rgba);
788        assert!(tpl.mask[0] && !tpl.mask[1]);
789        assert!((tpl.gray.px[0] - 0.587).abs() < 1e-4);
790        assert!((tpl.gray.px[1] - 1.0).abs() < 1e-4);
791    }
792
793    #[test]
794    fn crop_origin_is_the_clipped_rotated_bbox() {
795        let frame = Size::new(1920, 1080);
796        assert_eq!(
797            crop_origin(&Shape::Rect(Rect::new(100, 50, 40, 30)), 0, frame),
798            Point::new(100, 50)
799        );
800        // Rotated 90 about (120, 65): the tall silhouette starts left of
801        // the unrotated box.
802        assert_eq!(
803            crop_origin(&Shape::Rect(Rect::new(100, 50, 40, 30)), 90, frame),
804            Point::new(105, 45)
805        );
806        // A shape hanging off the top-left is clipped to the frame.
807        assert_eq!(
808            crop_origin(&Shape::Rect(Rect::new(-20, -10, 40, 30)), 0, frame),
809            Point::new(0, 0)
810        );
811    }
812
813    fn session_of_one() -> crate::session::SessionFile {
814        use crate::selection::Selection;
815        use crate::session::{MonitorRecord, SessionFile};
816        let mut sel = Selection::new(Shape::Rect(Rect::new(10, 20, 30, 40)), 0);
817        sel.label = "submit".into();
818        SessionFile::build(
819            "test",
820            "2026-07-27T00:00:00Z".into(),
821            vec![MonitorRecord {
822                index: 0,
823                name: "Main".into(),
824                primary: true,
825                origin_px: Point::new(100, 0),
826                size_px: Size::new(1920, 1080),
827                scale: 1.0,
828            }],
829            &[sel],
830            &["c0.png".into()],
831            None,
832        )
833    }
834
835    #[test]
836    fn report_translates_found_selections_and_flags_the_rest() {
837        let session = session_of_one();
838        let found = report(
839            &[(
840                0,
841                &session.selections[0],
842                Relocation {
843                    crop_origin: Point::new(10, 20),
844                    outcome: Ok(Located {
845                        x: 14,
846                        y: 8,
847                        score: 0.97,
848                        runner_up: 0.1,
849                        ambiguous: false,
850                    }),
851                },
852            )],
853            "2026-07-27T01:00:00Z".into(),
854        );
855        assert!(found.ok);
856        let r = &found.results[0];
857        assert_eq!(r.delta, Some(Delta { dx: 4, dy: -12 }));
858        assert_eq!(r.new_px, Some(Shape::Rect(Rect::new(14, 8, 30, 40))));
859        assert_eq!(
860            r.new_global_px,
861            Some(Shape::Rect(Rect::new(114, 8, 30, 40))),
862            "global keeps the monitor origin offset"
863        );
864
865        let miss = report(
866            &[(
867                0,
868                &session.selections[0],
869                Relocation {
870                    crop_origin: Point::new(10, 20),
871                    outcome: Ok(Located {
872                        x: 0,
873                        y: 0,
874                        score: 0.4,
875                        runner_up: 0.1,
876                        ambiguous: false,
877                    }),
878                },
879            )],
880            "t".into(),
881        );
882        assert!(!miss.ok);
883        assert!(!miss.results[0].found);
884        assert!(miss.results[0].new_px.is_none());
885
886        let ambiguous = report(
887            &[(
888                0,
889                &session.selections[0],
890                Relocation {
891                    crop_origin: Point::new(10, 20),
892                    outcome: Ok(Located {
893                        x: 14,
894                        y: 8,
895                        score: 0.99,
896                        runner_up: 0.98,
897                        ambiguous: true,
898                    }),
899                },
900            )],
901            "t".into(),
902        );
903        assert!(!ambiguous.ok);
904        assert!(ambiguous.results[0].found && ambiguous.results[0].ambiguous);
905        assert!(
906            ambiguous.results[0].new_px.is_none(),
907            "an ambiguous match must not hand out coordinates"
908        );
909
910        let errored = report(
911            &[(
912                0,
913                &session.selections[0],
914                Relocation {
915                    crop_origin: Point::new(10, 20),
916                    outcome: Err(LocateError::FlatTemplate),
917                },
918            )],
919            "t".into(),
920        );
921        assert!(!errored.ok);
922        assert!(
923            errored.results[0]
924                .reason
925                .as_deref()
926                .unwrap()
927                .contains("flat color")
928        );
929    }
930
931    #[test]
932    fn find_report_json_shape_is_stable() {
933        let session = session_of_one();
934        let rep = report(
935            &[(
936                0,
937                &session.selections[0],
938                Relocation {
939                    crop_origin: Point::new(10, 20),
940                    outcome: Ok(Located {
941                        x: 10,
942                        y: 20,
943                        score: 1.0,
944                        runner_up: 0.0,
945                        ambiguous: false,
946                    }),
947                },
948            )],
949            "2026-07-27T01:00:00Z".into(),
950        );
951        let json = serde_json::to_value(&rep).unwrap();
952        assert_eq!(json["schema"], 2);
953        assert_eq!(json["command"], "find");
954        assert_eq!(json["captured_utc"], "2026-07-27T01:00:00Z");
955        assert_eq!(json["ok"], true, "all_relocated is spelled ok now");
956        assert_eq!(json["results"][0]["label"], "submit");
957        assert_eq!(json["results"][0]["delta"]["dx"], 0);
958        assert_eq!(json["results"][0]["new_px"]["x"], 10);
959        assert!(json["results"][0].get("reason").is_none());
960        assert!(
961            json["results"][0]["found"].as_bool().unwrap(),
962            "the row keeps its own answer; ok is the aggregate over rows"
963        );
964    }
965}