visual-cortex 0.9.0

Watch screen regions and receive typed events over async streams when patterns match: OCR, template matching, and pixel conditions on live captures.
//! The preprocessor stage transforms what every later stage sees.

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Duration;

use visual_cortex::{
    Brightness, Detector, DetectorError, DetectorOutput, EventKind, FakeSource, Frame, FrameView,
    Preprocessor, Rate, Region, Session,
};

/// Replaces every frame with a constant-brightness frame taken from a script;
/// proves detectors see preprocessor OUTPUT, not the raw crop.
struct Scripted {
    outputs: Vec<u8>,
    calls: Arc<AtomicU32>,
}

impl Preprocessor for Scripted {
    fn process(&mut self, view: &FrameView<'_>) -> Result<Arc<Frame>, DetectorError> {
        let i = self.calls.fetch_add(1, Ordering::SeqCst) as usize;
        let lum = *self.outputs.get(i).or(self.outputs.last()).unwrap_or(&0);
        Ok(Arc::new(Frame::solid(
            view.width(),
            view.height(),
            [lum, lum, lum, 255],
        )))
    }
}

async fn next_event(stream: &mut visual_cortex::EventStream) -> visual_cortex::Event {
    tokio::time::timeout(Duration::from_secs(10), stream.next())
        .await
        .expect("event")
        .expect("stream open")
}

#[tokio::test(start_paused = true)]
async fn detector_sees_preprocessed_pixels() {
    // Raw source is pitch black; the preprocessor emits bright frames.
    let session = Session::builder()
        .source(FakeSource::new([Frame::solid(8, 8, [0, 0, 0, 255])]))
        .build()
        .await
        .unwrap();

    let mut stream = session
        .watch("bright")
        .region(Region::Full)
        .rate(Rate::hz(10.0))
        .preprocess(Scripted {
            outputs: vec![250],
            calls: Arc::default(),
        })
        .detector(Brightness)
        .subscribe()
        .unwrap();

    let ev = next_event(&mut stream).await;
    let EventKind::Changed { new, .. } = &ev.kind else {
        panic!("expected Changed, got {:?}", ev.kind);
    };
    assert!(
        new.as_number().unwrap() > 200.0,
        "detector must see the preprocessor's bright output, got {new:?}"
    );
}

#[tokio::test(start_paused = true)]
async fn static_preprocessor_output_holds_the_diff_gate() {
    // The RAW frames churn every tick, but the preprocessor's output is
    // constant — the frame-diff gate must therefore fire the detector only
    // once (the diff gate reads preprocessed output, not the raw crop).
    let churning: Vec<Frame> = (0..40u8)
        .map(|i| Frame::solid(8, 8, [i * 6, 0, 0, 255]))
        .collect();
    let session = Session::builder()
        .source(FakeSource::new(churning))
        .build()
        .await
        .unwrap();

    let detector_calls = Arc::new(AtomicU32::new(0));
    struct Counting(Arc<AtomicU32>);
    impl Detector for Counting {
        fn evaluate(&mut self, _v: &FrameView<'_>) -> Result<DetectorOutput, DetectorError> {
            self.0.fetch_add(1, Ordering::SeqCst);
            Ok(DetectorOutput::Number(self.0.load(Ordering::SeqCst) as f64))
        }
    }

    let mut stream = session
        .watch("gated")
        .region(Region::Full)
        .rate(Rate::hz(20.0))
        .preprocess(Scripted {
            outputs: vec![128], // constant output regardless of churning input
            calls: Arc::default(),
        })
        .detector(Counting(detector_calls.clone()))
        .subscribe()
        .unwrap();

    // First (and only) evaluation produces the initial Changed event.
    let _ = next_event(&mut stream).await;
    // Let many ticks elapse; the static masked output must gate the detector.
    tokio::time::sleep(Duration::from_secs(2)).await;
    assert_eq!(
        detector_calls.load(Ordering::SeqCst),
        1,
        "diff gate must hold while preprocessed output is static"
    );
}