From 066bb2d031edf8ca2ab223833b11c232a965c305 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Fri, 16 Oct 2026 04:05:58 +0200 Subject: [PATCH] S2.44: the span bar's confidence colours, checked in a real render egui_kittest test: a scripted tracker (sure, doubtful, unsure, absent, sure again) tracks a layer and the Layer panel is rendered; the strip's pixels are green, amber, red, slate and green at the right frames. ROTO_SPAN_SNAPSHOT saves the panel image. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 1 + .../ui-egui/tests/ui_roto_span_confidence.rs | 108 ++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 crates/ui-egui/tests/ui_roto_span_confidence.rs diff --git a/Cargo.lock b/Cargo.lock index ce98aeadc..b4acc3a6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1930,6 +1930,7 @@ dependencies = [ "effectcraft-path", "effectcraft-project", "effectcraft-render", + "effectcraft-segment", "effectcraft-text", "effectcraft-time", "eframe", diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index b1e281627..89532edb3 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -45,6 +45,7 @@ sys-locale = { workspace = true } egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } pollster = "1" +effectcraft-segment = { workspace = true } effectcraft-host = { workspace = true } exr = { workspace = true } diff --git a/crates/ui-egui/tests/ui_roto_span_confidence.rs b/crates/ui-egui/tests/ui_roto_span_confidence.rs new file mode 100644 index 000000000..3b3148d33 --- /dev/null +++ b/crates/ui-egui/tests/ui_roto_span_confidence.rs @@ -0,0 +1,108 @@ +//! The Roto Brush span bar colours the frames a tracker followed by its confidence: green when it is sure, +//! amber when it is doubtful, red when it is not, slate where the object was absent (egui_kittest, UI logic +//! and a real render). A stand-in tracker supplies the confidences, so no weights are needed. +//! `ROTO_SPAN_SNAPSHOT=/abs/out.png` also saves the rendered panel. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_segment::{ClipFrame, MaskModel, ModelInfo, ObjectTracker, Prompt, TrackRequest, TrackedFrame}; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui_kittest::Harness; +use serde_json::json; + +/// Follows a fixed disc; its confidence per frame is scripted. +struct Scripted; + +impl MaskModel for Scripted { + fn info(&self) -> &'static ModelInfo { + &effectcraft_segment::SAM2_TINY + } + fn segment(&self, _: &[[f32; 3]], _: usize, _: usize, _: &Prompt) -> effectcraft_segment::Result> { + Err("no weights".into()) + } + fn tracker(&self) -> Option<&dyn ObjectTracker> { + Some(self) + } +} + +/// (confidence, occluded) of layer frame `f`: sure, doubtful, unsure, absent, sure again. +fn script(f: i64) -> (f32, bool) { + match f { + 0..=5 => (0.99, false), + 6..=9 => (0.6, false), + 10..=12 => (0.2, false), + 13..=15 => (0.0, true), + _ => (0.99, false), + } +} + +impl ObjectTracker for Scripted { + fn track( + &self, + req: &TrackRequest, + frame: &mut dyn FnMut(i64) -> Option>, + _: &dyn Fn() -> bool, + out: &mut dyn FnMut(usize, i64, TrackedFrame), + ) -> effectcraft_segment::Result<()> { + for g in req.first..req.first + req.count as i64 { + let img = frame(g).ok_or("no frame")?; + let (confidence, occluded) = script(g); + let prob = (0..img.w * img.h).map(|i| if !occluded && (i % img.w).abs_diff(img.w / 2) < img.w / 4 && (i / img.w).abs_diff(img.h / 2) < img.h / 4 { 1.0 } else { 0.0 }).collect(); + out(0, g, TrackedFrame { prob, w: img.w, h: img.h, confidence, occluded }); + } + Ok(()) + } +} + +fn close(got: [u8; 4], want: [u8; 3]) -> bool { + got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 24) +} + +#[test] +fn the_span_bar_shows_the_trackers_confidence() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "View", "width": 640, "height": 360, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap(); + let id = s.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id.0; + s.execute("roto.stroke", json!({"layer": id, "points": [[20.0, 40.0], [60.0, 40.0]], "radius": 6})).unwrap(); + s.execute("roto.stroke", json!({"layer": id, "kind": "bg", "points": [[5.0, 5.0], [75.0, 5.0]], "radius": 4})).unwrap(); + effectcraft_engine::effects::roto::set_model(Some(Arc::new(Scripted))); + // the model stays loaded while the panel draws (its id is part of the cache keys) + s.execute("roto.propagate", json!({"layer": id, "direction": "both", "wait": true})).unwrap(); + + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.run_steps(3); + h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap(); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.openLayer", json!({"layer": id})).unwrap(); + h.run_steps(4); + assert_eq!(h.state().ui.focused, PanelKind::Layer); + + let track = h.state().auto.find("layerPanel.rotoSpan").expect("the span bar is on screen").rect; + let (comp, layer) = { + let comp = h.state().session.active_comp().unwrap().clone(); + let layer = comp.layer(effectcraft_project::LayerId(id)).unwrap().clone(); + (comp, layer) + }; + let lim = effectcraft_engine::roto::frame_limits(&comp, &layer); + let n = (lim[1] - lim[0] + 1) as f32; + let image = h.render().expect("the panel renders"); + if let Some(path) = std::env::var_os("ROTO_SPAN_SNAPSHOT") { + image.save(path).unwrap(); + } + let scale = image.width() as f32 / 1600.0; + let sample = |f: i64| -> [u8; 4] { + let x = (track[0] + ((f - lim[0]) as f32 + 0.5) / n * track[2]) * scale; + let y = (track[1] + track[3] - 2.0) * scale; + image.get_pixel(x as u32, y as u32).0 + }; + // the colours the bar uses (see `trust_color`) + let (green, amber, red, slate) = ([70, 200, 90], [230, 180, 50], [220, 70, 60], [110, 118, 150]); + for (f, want, what) in [(2, green, "sure"), (7, amber, "doubtful"), (11, red, "unsure"), (14, slate, "absent"), (18, green, "sure again")] { + let got = sample(f); + assert!(close(got, want), "frame {f} ({what}): drew {got:?}, wanted about {want:?}"); + } + effectcraft_engine::effects::roto::set_model(None); +}