Merge branch 'worktree-agent-ad0c2eed1a8890262'

This commit is contained in:
Brandon Thomas 2026-10-02 22:37:04 -04:00
commit cb64ba6fa9
31 changed files with 5031 additions and 16 deletions

1
Cargo.lock generated
View File

@ -1185,6 +1185,7 @@ dependencies = [
"effectcraft-raster",
"effectcraft-track",
"rayon",
"serde",
"serde_json",
]

View File

@ -32,6 +32,6 @@ Measured feature by feature in [docs/parity.md](docs/parity.md) (end of 2 Octobe
- **≈ 100 agent-hours of work remain by the audit's estimate.** With five Claude Opus 5.5 agents in
parallel that is **≈ 8–10 hours of wall-clock time at the pace measured so far, up to ≈ 25 hours**
by the audit's conservative per-feature figures.
- Hardest remaining pieces: Roto Brush, the 3D camera tracker, Advanced 3D, a scripting object model.
- Hardest remaining pieces: the 3D camera tracker, Advanced 3D, a scripting object model (Roto Brush & Refine Edge landed in M6.7).
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).

View File

@ -17,5 +17,6 @@ effectcraft-project = { workspace = true }
effectcraft-raster = { workspace = true }
effectcraft-track = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

View File

@ -37,6 +37,7 @@ pub mod paint;
mod perspective;
mod perspective2;
pub mod puppet;
pub mod roto;
mod sim;
mod sim2;
mod sim3;
@ -381,6 +382,7 @@ pub fn registry() -> &'static [EffectSpec] {
v.extend(paint::specs());
v.extend(puppet::specs());
v.extend(warp_stab::specs());
v.extend(roto::specs());
v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name)));
for s in v.iter_mut() {
if GPU_EFFECTS.contains(&s.id) {
@ -456,7 +458,7 @@ pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_
/// Display names of nested parameter groups by match id.
fn group_name(m: &str) -> &str {
warp_stab::GROUPS.iter().find(|(id, _)| *id == m).map(|(_, n)| *n).unwrap_or(m)
warp_stab::GROUPS.iter().chain(roto::GROUPS).find(|(id, _)| *id == m).map(|(_, n)| *n).unwrap_or(m)
}
/// The nested group at `path` (`borders/autoScale`) under `g`, created on first use.
@ -528,6 +530,8 @@ pub const TIME_DEPENDENT: &[&str] = &[
"ec.obsolete.mochashape",
// Each frame gets its own stabilizing warp.
warp_stab::ID,
// Each frame has its own segmentation.
roto::ID,
];
/// See [`TIME_DEPENDENT`].

698
crates/effects/src/roto.rs Normal file
View File

@ -0,0 +1,698 @@
//! Roto Brush & Refine Edge (applied by the Roto Brush tool; Effect ▸ Matte).
//!
//! The instance stores the user's strokes, base frame and segmentation span as JSON in its hidden
//! **Strokes** parameter ([`RotoData`]); the segmentation itself is *derived* data: each frame's
//! result is a deterministic function of its chain key ([`RotoData::chain_keys`] seeded with
//! [`seed`]: what the frames are, the Roto Brush settings and the strokes from the base frame
//! out to the frame), so it lives in a process-wide content-keyed cache ([`cached`]) that the
//! engine's propagation job fills at full resolution and rendering fills on demand (reading
//! neighbouring frames with [`crate::EffectHost::self_at`]). Undo, redo and reopening a project
//! therefore never see stale mattes. **Freeze** stores the final mattes in the hidden **Frozen**
//! parameter ([`FrozenCache`]); they are used while their key still matches.
//!
//! Rendering: binary segmentation → Roto Brush Matte (Shift Edge, Reduce Chatter, Feather,
//! Contrast) → Refine Edge matting inside the Refine Edge band (guided filter + closed-form
//! matting with Higher Quality) → Refine Edge Matte adjustments → matte motion blur →
//! decontamination of the edge colours. Outside the segmentation span the layer is transparent;
//! without strokes the effect passes its input through.
//!
//! *Version* is stored but all versions use the same (classic, graph-cut) engine; *Quality ▸
//! Best* segments at a higher working resolution with more iterations.
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::{Arc, Mutex, OnceLock};
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::Image;
use effectcraft_track::roto::refine::{self, MatteParams};
use effectcraft_track::roto::{self as rb, FrameSeg, RotoData, SegOpts, rle};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use crate::warp_stab::param;
use crate::{Buf, EffectCtx, EffectSpec, Params, num, p, popup, slider};
pub use effectcraft_track::roto::{Stroke, StrokeKind};
/// The effect's id.
pub const ID: &str = "ec.matte.rotobrush";
/// Hidden parameters.
pub const STROKES: &str = "strokes";
pub const INPUT_KEY: &str = "inputKey";
pub const FROZEN: &str = "frozen";
/// Default segmentation span each side of the base frame (frames).
pub const DEFAULT_SPAN: i64 = 20;
/// Display names of the instance's parameter groups.
pub const GROUPS: &[(&str, &str)] = &[("rotoBrushMatte", "Roto Brush Matte"), ("refineEdgeMatte", "Refine Edge Matte")];
pub const VERSIONS: [&str; 3] = ["1.0", "2.0", "3.0"];
pub const QUALITIES: [&str; 2] = ["Standard", "Best"];
pub fn specs() -> Vec<EffectSpec> {
vec![EffectSpec {
id: ID,
name: "Roto Brush & Refine Edge",
category: "Matte",
params: vec![
p("version", "Version", Value::Enum(2), popup(&VERSIONS)),
p("quality", "Quality", Value::Enum(0), popup(&QUALITIES)),
p("rotoBrushMatte/searchRadius", "Search Radius", num(15.0), slider(0.0, 100.0, 0.0, 50.0, 1)),
p("rotoBrushMatte/motionThreshold", "Motion Threshold", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("rotoBrushMatte/motionDamping", "Motion Damping", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("rotoBrushMatte/viewSearchRegion", "View Search Region", Value::Bool(false), ParamUi::Checkbox),
p("rotoBrushMatte/feather", "Feather", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
p("rotoBrushMatte/contrast", "Contrast", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("rotoBrushMatte/shiftEdge", "Shift Edge", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)),
p("rotoBrushMatte/reduceChatter", "Reduce Chatter", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("refineEdgeMatte/smooth", "Smooth", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 1)),
p("refineEdgeMatte/feather", "Feather", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
p("refineEdgeMatte/contrast", "Contrast", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("refineEdgeMatte/shiftEdge", "Shift Edge", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)),
p("refineEdgeMatte/reduceChatter", "Reduce Chatter", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("refineEdgeMatte/useMotionBlur", "Use Motion Blur", Value::Bool(false), ParamUi::Checkbox),
p("refineEdgeMatte/motionBlurSamples", "Motion Blur Samples", num(11.0), slider(1.0, 64.0, 1.0, 32.0, 0)),
p("refineEdgeMatte/shutterAngle", "Shutter Angle", num(180.0), slider(0.0, 720.0, 0.0, 360.0, 0)),
p("refineEdgeMatte/higherQuality", "Higher Quality", Value::Bool(true), ParamUi::Checkbox),
p("refineEdgeMatte/decontaminateEdgeColors", "Decontaminate Edge Colors", Value::Bool(true), ParamUi::Checkbox),
p("refineEdgeMatte/decontaminationAmount", "Decontamination Amount", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("refineEdgeMatte/extendWhereSmoothed", "Extend Where Smoothed", Value::Bool(true), ParamUi::Checkbox),
p("refineEdgeMatte/increaseDecontaminationRadius", "Increase Decontamination Radius", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
p("refineEdgeMatte/viewDecontaminationMap", "View Decontamination Map", Value::Bool(false), ParamUi::Checkbox),
p(STROKES, "Strokes", Value::Str(String::new()), ParamUi::Hidden),
p(INPUT_KEY, "Input Key", Value::Str(String::new()), ParamUi::Hidden),
p(FROZEN, "Frozen", Value::Str(String::new()), ParamUi::Hidden),
],
render,
gpu: false,
float: true,
}]
}
fn f(params: &Params, id: &str) -> f64 {
param(params, id).map(Value::as_f64).unwrap_or(0.0)
}
fn e(params: &Params, id: &str) -> u32 {
param(params, id).map(Value::as_enum).unwrap_or(0)
}
fn b(params: &Params, id: &str) -> bool {
param(params, id).map(Value::as_bool).unwrap_or(false)
}
fn s<'a>(params: &'a Params, id: &str) -> &'a str {
match param(params, id) {
Some(Value::Str(s)) => s,
_ => "",
}
}
fn fnv(h: &mut u64, bytes: &[u8]) {
for b in bytes {
*h ^= *b as u64;
*h = h.wrapping_mul(0x0100_0000_01b3);
}
}
/// The instance's strokes (parsed once per content).
pub fn data(params: &Params) -> Arc<RotoData> {
static C: OnceLock<Mutex<HashMap<u64, Arc<RotoData>>>> = OnceLock::new();
let j = s(params, STROKES);
let mut h = 0xcbf2_9ce4_8422_2325;
fnv(&mut h, j.as_bytes());
let c = C.get_or_init(Default::default);
if let Some(d) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&h) {
return d.clone();
}
let d = Arc::new(RotoData::from_json(j));
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
if m.len() > 64 {
m.clear();
}
m.insert(h, d.clone());
d
}
/// Segmentation settings at `scale` (pixels per layer pixel).
pub fn seg_opts(params: &Params, scale: f64) -> SegOpts {
SegOpts {
scale,
search_radius: f(params, "rotoBrushMatte/searchRadius").max(0.0),
motion_threshold: f(params, "rotoBrushMatte/motionThreshold"),
motion_damping: f(params, "rotoBrushMatte/motionDamping"),
best: e(params, "quality") == 1,
}
}
/// Matte settings.
pub fn matte_params(params: &Params) -> MatteParams {
MatteParams {
rb_feather: f(params, "rotoBrushMatte/feather").max(0.0),
rb_contrast: f(params, "rotoBrushMatte/contrast"),
rb_shift: f(params, "rotoBrushMatte/shiftEdge"),
rb_chatter: f(params, "rotoBrushMatte/reduceChatter"),
smooth: f(params, "refineEdgeMatte/smooth").max(0.0),
feather: f(params, "refineEdgeMatte/feather").max(0.0),
contrast: f(params, "refineEdgeMatte/contrast"),
shift: f(params, "refineEdgeMatte/shiftEdge"),
chatter: f(params, "refineEdgeMatte/reduceChatter"),
motion_blur: b(params, "refineEdgeMatte/useMotionBlur"),
mb_samples: f(params, "refineEdgeMatte/motionBlurSamples").round().clamp(1.0, 64.0) as usize,
shutter_angle: f(params, "refineEdgeMatte/shutterAngle"),
higher_quality: b(params, "refineEdgeMatte/higherQuality"),
decontaminate: b(params, "refineEdgeMatte/decontaminateEdgeColors"),
decontamination: f(params, "refineEdgeMatte/decontaminationAmount"),
extend_where_smoothed: b(params, "refineEdgeMatte/extendWhereSmoothed"),
increase_radius: f(params, "refineEdgeMatte/increaseDecontaminationRadius").max(0.0),
}
}
/// Seed of the chain keys: what the frames are (the engine-maintained Input Key), the layer
/// size, the frame rate and the segmentation settings.
pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325;
fnv(&mut h, s(params, INPUT_KEY).as_bytes());
for v in [
layer_size[0],
layer_size[1],
fps,
f(params, "rotoBrushMatte/searchRadius"),
f(params, "rotoBrushMatte/motionThreshold"),
f(params, "rotoBrushMatte/motionDamping"),
] {
fnv(&mut h, &v.to_bits().to_le_bytes());
}
fnv(&mut h, &[e(params, "quality") as u8, e(params, "version") as u8]);
h
}
/// The layer frame shown at layer time `t` seconds.
pub fn frame_of(t: f64, fps: f64) -> i64 {
(t * fps + 1e-6).floor() as i64
}
// ------------------------------------------------------------------ derived-data cache
struct SegCache {
map: HashMap<(u64, u64), Arc<FrameSeg>>,
order: VecDeque<(u64, u64)>,
bytes: usize,
}
const CACHE_BYTES: usize = 768 << 20;
fn cache() -> &'static Mutex<SegCache> {
static C: OnceLock<Mutex<SegCache>> = OnceLock::new();
C.get_or_init(|| Mutex::new(SegCache { map: HashMap::new(), order: VecDeque::new(), bytes: 0 }))
}
/// A cached segmentation (chain key, scale).
pub fn cached(key: u64, scale: f64) -> Option<Arc<FrameSeg>> {
cache().lock().unwrap_or_else(|e| e.into_inner()).map.get(&(key, scale.to_bits())).cloned()
}
pub fn store(key: u64, scale: f64, seg: Arc<FrameSeg>) {
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
let k = (key, scale.to_bits());
if c.map.contains_key(&k) {
return;
}
c.bytes += seg.matte.len() + seg.refine.len();
c.map.insert(k, seg);
c.order.push_back(k);
while c.bytes > CACHE_BYTES {
let Some(old) = c.order.pop_front() else { break };
if let Some(s) = c.map.remove(&old) {
c.bytes -= s.matte.len() + s.refine.len();
}
}
}
/// Drop the cached segmentations of these chain keys (every scale).
pub fn forget(keys: impl IntoIterator<Item = u64>) {
let keys: std::collections::HashSet<u64> = keys.into_iter().collect();
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
let gone: Vec<(u64, u64)> = c.map.keys().filter(|k| keys.contains(&k.0)).copied().collect();
for k in gone {
if let Some(s) = c.map.remove(&k) {
c.bytes -= s.matte.len() + s.refine.len();
}
}
c.order.retain(|k| !keys.contains(&k.0));
}
/// Drop every cached segmentation (Edit ▸ Purge).
pub fn purge() {
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
c.map.clear();
c.order.clear();
c.bytes = 0;
}
/// The layer-space grid (`round(size · scale)` pixels) of a buffer.
pub fn layer_grid(buf: &Buf, layer_size: [f64; 2]) -> Image {
let s = if buf.scale > 0.0 { buf.scale } else { 1.0 };
let (w, h) = (((layer_size[0] * s).round() as u32).max(1), ((layer_size[1] * s).round() as u32).max(1));
let (ox, oy) = (buf.offset[0].round() as i64, buf.offset[1].round() as i64);
let mut img = Image::new(w, h);
img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
*px = buf.img.get(x as i64 + ox, y as i64 + oy);
}
});
img
}
/// Everything needed to compute an instance's segmentations.
pub struct Chain {
pub data: Arc<RotoData>,
pub keys: BTreeMap<i64, u64>,
pub opts: SegOpts,
pub refine_radius: f64,
}
impl Chain {
pub fn new(params: &Params, layer_size: [f64; 2], fps: f64, scale: f64) -> Chain {
let data = data(params);
let keys = data.chain_keys(seed(params, layer_size, fps));
Chain { refine_radius: data.refine_radius(), data, keys, opts: seg_opts(params, scale) }
}
/// Frame `f`'s segmentation at this chain's scale: cached, or computed from the nearest
/// cached frame towards the base (full-resolution results serve smaller scales).
/// `frame(g)`: the effect's input on frame g on the layer grid at this scale.
pub fn ensure(&self, f: i64, frame: &mut dyn FnMut(i64) -> Option<Arc<Image>>, cancel: &dyn Fn() -> bool) -> Option<Arc<FrameSeg>> {
let sc = self.opts.scale;
let lookup = |g: i64| -> Option<Arc<FrameSeg>> {
let k = *self.keys.get(&g)?;
if let Some(s) = cached(k, sc) {
return Some(s);
}
if sc != 1.0 {
let full = cached(k, 1.0)?;
let s = Arc::new(downscale(&full, sc));
store(k, sc, s.clone());
return Some(s);
}
None
};
if let Some(s) = lookup(f) {
return Some(s);
}
let base = self.data.base?;
if !self.data.in_span(f) {
return None;
}
// Walk towards the base to the nearest available frame.
let mut path = vec![f];
let mut g = f;
let mut start: Option<Arc<FrameSeg>> = None;
while let Some(src) = self.data.source_of(g) {
if let Some(s) = lookup(src) {
start = Some(s);
break;
}
path.push(src);
g = src;
}
path.reverse();
let mut prev_img: Option<Arc<Image>> = None;
let mut cur = start;
for g in path {
if cancel() {
return None;
}
let img = frame(g)?;
let strokes = self.data.strokes_at(g);
let seg = match (&cur, g == base) {
(_, true) | (None, _) => rb::segment(&img, &strokes, None, &self.opts, self.refine_radius),
(Some(p), false) => {
let src = self.data.source_of(g)?;
let pimg = match prev_img.take() {
Some(i) => i,
None => frame(src)?,
};
if pimg.width != img.width || pimg.height != img.height {
return None;
}
rb::propagate(&pimg, p, &img, &strokes, &self.opts, self.refine_radius)
}
};
let seg = Arc::new(seg);
store(*self.keys.get(&g)?, sc, seg.clone());
cur = Some(seg);
prev_img = Some(img);
}
cur
}
/// Whether frame `f` is computed (at full resolution or at `scale`).
pub fn is_cached(&self, f: i64, scale: f64) -> bool {
self.keys.get(&f).is_some_and(|k| cached(*k, 1.0).is_some() || cached(*k, scale).is_some())
}
}
/// Nearest-neighbour resampling of a segmentation to another scale.
pub fn downscale(s: &FrameSeg, scale: f64) -> FrameSeg {
let (w, h) = (((s.w as f64) * scale).round().max(1.0) as usize, ((s.h as f64) * scale).round().max(1.0) as usize);
resample(s, w, h)
}
fn resample(s: &FrameSeg, w: usize, h: usize) -> FrameSeg {
if s.w == w && s.h == h {
return s.clone();
}
let mut out = FrameSeg { w, h, matte: vec![0; w * h], refine: vec![0; w * h], fg: s.fg.clone(), bg: s.bg.clone() };
if s.w == 0 || s.h == 0 {
return out;
}
for y in 0..h {
let sy = (((y as f64 + 0.5) * s.h as f64 / h as f64) as usize).min(s.h - 1);
for x in 0..w {
let sx = (((x as f64 + 0.5) * s.w as f64 / w as f64) as usize).min(s.w - 1);
out.matte[y * w + x] = s.matte[sy * s.w + sx];
out.refine[y * w + x] = s.refine.get(sy * s.w + sx).copied().unwrap_or(0);
}
}
out
}
/// The final matte of frame `seg` (with its neighbours), on its own grid.
pub fn final_alpha(
img: &Image,
seg: &FrameSeg,
prev: Option<&FrameSeg>,
next: Option<&FrameSeg>,
mp: &MatteParams,
refine_radius: f64,
scale: f64,
) -> Vec<f32> {
let rgb = rb::rgb_of(img);
refine::matte_alpha(&rgb, seg, prev, next, mp, refine_radius * scale, scale)
}
fn needs_neighbours(mp: &MatteParams) -> bool {
mp.rb_chatter > 0.0 || mp.chatter > 0.0 || mp.motion_blur
}
// ------------------------------------------------------------------ Freeze
/// Frozen mattes (Freeze): the final 8-bit alpha and the Refine Edge band of every frame of
/// the span at full resolution, valid while `key` matches [`frozen_key`].
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct FrozenCache {
pub key: String,
pub size: [usize; 2],
pub frames: BTreeMap<i64, FrozenFrame>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct FrozenFrame {
/// Alpha (RLE, base64).
pub a: String,
/// Refine Edge band (RLE, base64).
pub b: String,
}
impl FrozenCache {
pub fn from_json(s: &str) -> Option<FrozenCache> {
if s.trim().is_empty() {
return None;
}
serde_json::from_str(s).ok()
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_default()
}
}
/// What frozen mattes depend on: every chain key and the matte settings.
pub fn frozen_key(params: &Params, layer_size: [f64; 2], fps: f64) -> String {
let ch = Chain::new(params, layer_size, fps, 1.0);
let mut h = 0xcbf2_9ce4_8422_2325;
for (f, k) in &ch.keys {
fnv(&mut h, &f.to_le_bytes());
fnv(&mut h, &k.to_le_bytes());
}
fnv(&mut h, format!("{:?}", matte_params(params)).as_bytes());
format!("{h:016x}")
}
/// Whether the instance is frozen (has frozen mattes).
pub fn is_frozen(params: &Params) -> bool {
!s(params, FROZEN).is_empty()
}
type FrozenParsed = Mutex<HashMap<u64, Arc<FrozenCache>>>;
fn frozen(params: &Params) -> Option<Arc<FrozenCache>> {
static C: OnceLock<FrozenParsed> = OnceLock::new();
let j = s(params, FROZEN);
if j.is_empty() {
return None;
}
let mut h = 0xcbf2_9ce4_8422_2325;
fnv(&mut h, j.as_bytes());
let c = C.get_or_init(Default::default);
if let Some(d) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&h) {
return Some(d.clone());
}
let d = Arc::new(FrozenCache::from_json(j)?);
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
if m.len() > 8 {
m.clear();
}
m.insert(h, d.clone());
Some(d)
}
/// Encode one frame for [`FrozenCache`].
pub fn freeze_frame(alpha: &[f32], band: &[u8]) -> FrozenFrame {
let a: Vec<u8> = alpha.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8).collect();
FrozenFrame { a: rle::encode(&a), b: rle::encode(band) }
}
// ------------------------------------------------------------------ render
/// A frame's matte (alpha and band) on the layer grid of `img` at `scale`.
fn matte_for(ctx: &EffectCtx, f: i64, img: &Arc<Image>, scale: f64) -> Option<(Vec<f32>, Vec<u8>, f64)> {
let (w, h) = (img.width as usize, img.height as usize);
// Frozen mattes.
if let Some(fc) = frozen(ctx.params)
&& fc.key == frozen_key(ctx.params, ctx.layer_size, ctx.fps())
&& let Some(fr) = fc.frames.get(&f)
{
let a = rle::decode(&fr.a)?;
let bnd = rle::decode(&fr.b)?;
let [fw, fh] = fc.size;
if a.len() == fw * fh && bnd.len() == fw * fh {
let af: Vec<f32> = a.iter().map(|v| *v as f32 / 255.0).collect();
let (alpha, band) = if fw == w && fh == h {
(af, bnd)
} else {
let mut al = vec![0.0f32; w * h];
let mut bd = vec![0u8; w * h];
for y in 0..h {
for x in 0..w {
let (sx, sy) = ((x as f64 + 0.5) * fw as f64 / w as f64, (y as f64 + 0.5) * fh as f64 / h as f64);
al[y * w + x] = rb::matting::sample(&af, fw, fh, sx, sy);
bd[y * w + x] = bnd[(sy as usize).min(fh - 1) * fw + (sx as usize).min(fw - 1)];
}
}
(al, bd)
};
let rr = data(ctx.params).refine_radius();
return Some((alpha, band, rr));
}
}
let host = ctx.env.host;
let chain = Chain::new(ctx.params, ctx.layer_size, ctx.fps(), scale);
let fps = ctx.fps();
let effects = ctx.env.effect_index;
let layer_size = ctx.layer_size;
let me = img.clone();
let mut frame = |g: i64| -> Option<Arc<Image>> {
if g == f {
return Some(me.clone());
}
let buf = host?.self_at(g as f64 / fps, effects)?;
let im = layer_grid(&buf, layer_size);
(im.width as usize == w && im.height as usize == h).then(|| Arc::new(im))
};
let seg = chain.ensure(f, &mut frame, &|| false)?;
let seg = if seg.w == w && seg.h == h { (*seg).clone() } else { resample(&seg, w, h) };
let mp = matte_params(ctx.params);
let (mut prev, mut next) = (None, None);
if needs_neighbours(&mp) {
for (slot, g) in [(&mut prev, f - 1), (&mut next, f + 1)] {
if chain.data.in_span(g)
&& let Some(s) = chain.ensure(g, &mut frame, &|| false)
{
*slot = Some(if s.w == w && s.h == h { (*s).clone() } else { resample(&s, w, h) });
}
}
}
let alpha = final_alpha(img, &seg, prev.as_ref(), next.as_ref(), &mp, chain.refine_radius, scale);
Some((alpha, seg.refine, chain.refine_radius))
}
fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
let d = data(ctx.params);
if d.is_empty() || ctx.adjustment {
return buf;
}
let f = frame_of(ctx.time, ctx.fps());
let sc = if buf.scale > 0.0 { buf.scale } else { 1.0 };
let mut out = Buf { img: Image::new(buf.img.width, buf.img.height), offset: buf.offset, scale: buf.scale };
if !d.in_span(f) {
return out;
}
let img = Arc::new(layer_grid(&buf, ctx.layer_size));
let Some((alpha, band, rr)) = matte_for(ctx, f, &img, sc) else { return out };
let (w, h) = (img.width as usize, img.height as usize);
let mp = matte_params(ctx.params);
let mut rgb = rb::rgb_of(&img);
let map = refine::decontaminate(&mut rgb, &alpha, &band, w, h, &mp, rr * sc, sc);
let view_map = b(ctx.params, "refineEdgeMatte/viewDecontaminationMap");
let (ox, oy) = (buf.offset[0].round() as i64, buf.offset[1].round() as i64);
for y in 0..h {
for x in 0..w {
let (bx, by) = (x as i64 + ox, y as i64 + oy);
if bx < 0 || by < 0 || bx >= out.img.width as i64 || by >= out.img.height as i64 {
continue;
}
let i = y * w + x;
let a_in = img.data[i][3];
let px = if view_map {
let v = map[i];
[v, v, v, 1.0]
} else {
let a = alpha[i] * a_in;
[rgb[i][0] * a, rgb[i][1] * a, rgb[i][2] * a, a]
};
out.img.set(bx as u32, by as u32, px);
}
}
out
}
/// The final matte of the effect at layer frame `f` on the full-resolution layer grid of
/// `input` (the effect's input buffer at scale 1), computing what is missing. For Freeze and
/// the Layer panel overlays.
pub fn frame_matte(
params: &Params,
layer_size: [f64; 2],
fps: f64,
f: i64,
frame: &mut dyn FnMut(i64) -> Option<Arc<Image>>,
cancel: &dyn Fn() -> bool,
) -> Option<(Vec<f32>, Vec<u8>)> {
let chain = Chain::new(params, layer_size, fps, 1.0);
let img = frame(f)?;
let seg = chain.ensure(f, frame, cancel)?;
let mp = matte_params(params);
let (mut prev, mut next) = (None, None);
if needs_neighbours(&mp) {
if chain.data.in_span(f - 1) {
prev = chain.ensure(f - 1, frame, cancel);
}
if chain.data.in_span(f + 1) {
next = chain.ensure(f + 1, frame, cancel);
}
}
let alpha = final_alpha(&img, &seg, prev.as_deref(), next.as_deref(), &mp, chain.refine_radius, 1.0);
Some((alpha, seg.refine.clone()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, EffectHost, LayerPixels};
/// A host serving a moving red disk over a blue/green background as the effect's input.
struct Seq;
fn frame(t: f64) -> Image {
let fi = (t * 25.0 + 1e-6).floor();
let c = [40.0 + 2.0 * fi, 40.0];
let mut img = Image::new(100, 80);
for y in 0..80 {
for x in 0..100 {
let d = ((x as f64 + 0.5 - c[0]).hypot(y as f64 + 0.5 - c[1])) - 15.0;
let a = (0.5 - d).clamp(0.0, 1.0) as f32;
let tex = ((x * 7 + y * 13) % 9) as f32 / 60.0;
let fg = [0.85 + tex, 0.2, 0.1];
let bg = [0.1, 0.4 + tex, 0.7];
img.set(x, y, [fg[0] * a + bg[0] * (1.0 - a), fg[1] * a + bg[1] * (1.0 - a), fg[2] * a + bg[2] * (1.0 - a), 1.0]);
}
}
img
}
impl EffectHost for Seq {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
Some(Buf { img: frame(t), offset: [0.0; 2], scale: 1.0 })
}
}
fn params_with(d: &RotoData) -> Params {
let s = &specs()[0];
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
params.values.insert(STROKES.into(), Value::Str(d.to_json()));
params
}
#[test]
fn renders_the_segmented_disk_on_demand() {
let mut d = RotoData::default();
d.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[35.0, 40.0], [45.0, 40.0]] }, 20, [0, 24]);
d.add(Stroke { kind: StrokeKind::Bg, frame: 0, radius: 3.0, points: vec![[5.0, 5.0], [95.0, 5.0], [95.0, 75.0]] }, 20, [0, 24]);
let params = params_with(&d);
let host = Seq;
let run = |t: f64| {
let ctx = EffectCtx {
params: &params,
time: t,
layer_size: [100.0, 80.0],
seed: 1,
adjustment: false,
env: EffectEnv { host: Some(&host), frame_rate: 25.0, ..Default::default() },
};
crate::apply(&specs()[0], &ctx, Buf { img: frame(t), offset: [0.0; 2], scale: 1.0 }).img
};
// Frame 5 (propagated through frames 1–4).
let out = run(5.0 / 25.0 + 0.001);
let c = [50.0, 40.0];
let mut wrong = 0;
for y in 0..80u32 {
for x in 0..100u32 {
let d = (x as f64 + 0.5 - c[0]).hypot(y as f64 + 0.5 - c[1]);
let a = out.data[(y * 100 + x) as usize][3];
if (d < 14.0 && a < 0.5) || (d > 16.0 && a > 0.5) {
wrong += 1;
}
}
}
assert!(wrong < 20, "{wrong} wrong pixels");
// Outside the span: transparent.
let mut d2 = (*data(&params)).clone();
d2.span = [0, 3];
let p2 = params_with(&d2);
let ctx = EffectCtx {
params: &p2,
time: 0.3,
layer_size: [100.0, 80.0],
seed: 1,
adjustment: false,
env: EffectEnv { host: Some(&host), frame_rate: 25.0, ..Default::default() },
};
let o = crate::apply(&specs()[0], &ctx, Buf { img: frame(0.3), offset: [0.0; 2], scale: 1.0 }).img;
assert!(o.data.iter().all(|p| p[3] == 0.0));
}
}

View File

@ -525,6 +525,9 @@ fn purge_caches(s: &mut Session, p: &Value) -> Result<Value> {
s.snapshot = None;
s.state.viewer.show_snapshot = false;
}
if matches!(what.as_str(), "all" | "image" | "memory" | "memoryAndDisk") {
effectcraft_effects::roto::purge();
}
s.events.push(crate::Event::PurgeCaches);
s.toast(format!("Purged {what} cache"));
Ok(json!({"purged": what}))

View File

@ -31,6 +31,7 @@ mod prop_groups;
pub mod puppet;
mod query;
mod render_queue;
pub mod roto_cmds;
mod settings;
mod stubs;
mod styles;
@ -134,6 +135,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(track::specs());
v.extend(mask_interp::specs());
v.extend(warp_cmds::specs());
v.extend(roto_cmds::specs());
v.extend(paint::specs());
v.extend(puppet::specs());
v.extend(liquify::specs());

View File

@ -0,0 +1,424 @@
//! Roto Brush & Refine Edge commands: strokes (the Roto Brush and Refine Edge tools in the Layer
//! panel), propagation, the segmentation span, Freeze / Unfreeze, tool options and status.
//!
//! `roto.stroke` records a stroke into the layer's Roto Brush & Refine Edge effect (applying the
//! effect on the first stroke, as After Effects does); the first foreground stroke sets the base
//! frame and a span of 20 frames each side. Strokes on a frozen effect are refused (Unfreeze
//! first). Every edit is one undo step.
use effectcraft_effects::roto::{self as fx, FROZEN, Stroke, StrokeKind};
use effectcraft_keyframe::Value as KV;
use effectcraft_project::build::Ids;
use effectcraft_project::{ItemId, Layer, LayerId, PropGroup, Uid};
use effectcraft_track::roto::{RotoData, iou, rle};
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, f_p, has_comp, layer_mut, layer_p, str_p};
use crate::roto::{self as rt, Direction, RotoOptions, RotoTask, VIEWS, is_roto, params_static};
use crate::{EngineError, Result, Session, cmd, query};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"roto.stroke",
"Roto Brush Stroke",
[],
None,
"{layer?, kind?: fg|bg|refine|refineErase, points: [[x, y], …] (layer pixels), frame? (layer frame; default current), radius? (layer px; default the tool's diameter / 2), effect?}",
has_comp,
stroke
),
cmd!(
"roto.propagate",
"Propagate Roto Brush",
[],
None,
"{layer?, effect?, direction?: forward|backward|both, to? (layer frame: extend the span), wait?: block until done}",
has_comp,
propagate
),
cmd!("roto.span", "Set Segmentation Span", [], None, "{layer?, effect?, start?, end? (layer frames)}", has_comp, span),
cmd!("roto.freeze", "Freeze", [], None, "{layer?, effect?, wait?}", has_comp, freeze),
cmd!("roto.unfreeze", "Unfreeze", [], None, "{layer?, effect?}", has_comp, unfreeze),
cmd!("roto.clearStrokes", "Remove Roto Brush Strokes", [], None, "{layer?, effect?, frame? (only this layer frame), kind?}", has_comp, clear_strokes),
cmd!("roto.cancel", "Stop Roto Brush", [], None, "{}", is_running, |s, _| Ok(json!({"stopped": s.stop_roto()}))),
cmd!(
"roto.options",
"Roto Brush Options",
[],
None,
"{diameter?, refineDiameter?, view?: alphaBoundary|alpha|alphaOverlay|none, overlayColor?: [r,g,b], overlayOpacity?, boundaryColor?: [r,g,b], autoPropagate?}",
always,
options
),
query!(
"roto.status",
"Roto Brush Status",
"{layer?, effect?, frame? (layer frame: also report its matte), matte?: include the matte (RLE, base64), compute?: compute the frame if needed, compareTo?: RLE matte to report the IoU against}",
status
),
]
}
fn is_running(s: &Session) -> std::result::Result<(), String> {
if s.is_roto_running() { Ok(()) } else { Err("no Roto Brush job is running".into()) }
}
/// The Roto Brush instance targeted by `p`: `layer` / `effect` (uid, name or 1-based index),
/// else a selected one, else the first on the selected (or any) layer.
pub(crate) fn roto_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
let cid = super::comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let candidates: Vec<LayerId> = match p.get("layer") {
Some(v) => vec![super::resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?],
None => {
let mut v: Vec<LayerId> = s.state.selected_props.iter().map(|(l, _)| *l).collect();
v.extend(s.state.selected_layers.iter().copied());
v.extend(comp.layers.iter().map(|l| l.id));
v
}
};
for lid in candidates {
let Some(fxg) = comp.layer(lid).and_then(Layer::effects) else { continue };
let g = match p.get("effect") {
Some(Value::Number(n)) => {
let n = n.as_u64().unwrap_or(0);
fxg.groups().find(|g| g.uid == n).or_else(|| fxg.groups().nth((n as usize).saturating_sub(1)))
}
Some(Value::String(name)) => fxg.groups().find(|g| &g.name == name),
_ => s
.state
.selected_props
.iter()
.find_map(|(l, u)| (*l == lid).then(|| fxg.groups().find(|g| g.uid == *u && is_roto(g))).flatten())
.or_else(|| fxg.groups().find(|g| is_roto(g))),
};
if let Some(g) = g.filter(|g| is_roto(g)) {
return Ok((cid, lid, g.uid));
}
if p.get("layer").is_some() {
break;
}
}
Err(bad(cmd, "no Roto Brush & Refine Edge effect found (paint a stroke with the Roto Brush tool first)"))
}
fn group(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Result<(&Layer, &PropGroup)> {
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
let g = l.props.find_group(uid).ok_or_else(|| EngineError::Other("the effect is gone".into()))?;
Ok((l, g))
}
fn data_of(g: &PropGroup) -> RotoData {
fx::data(&params_static(g)).as_ref().clone()
}
fn frozen_of(g: &PropGroup) -> bool {
matches!(g.get(FROZEN).map(|p| &p.value), Some(KV::Str(s)) if !s.is_empty())
}
/// Write an instance's data (one undo step).
fn write_data(s: &mut Session, label: &str, cid: ItemId, lid: LayerId, uid: Uid, d: &RotoData) -> Result<()> {
let j = d.to_json();
s.edit(label, None, |proj, _| {
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| EngineError::Other("the effect is gone".into()))?;
if let Some(pr) = g.get_mut(fx::STROKES) {
pr.value = KV::Str(j);
}
Ok(())
})
}
/// Current layer frame of `layer` (or `frame` from the params).
fn frame_p(s: &Session, p: &Value, cid: ItemId, layer: &Layer) -> i64 {
if let Some(f) = f_p(p, "frame") {
return f.round() as i64;
}
let comp = s.project.comp(cid);
let fps = comp.map(|c| c.frame_rate.as_f64()).unwrap_or(30.0);
fx::frame_of(layer.layer_time(s.time_of(cid)).seconds(), fps)
}
fn points_p(p: &Value, cmd: &str) -> Result<Vec<[f64; 2]>> {
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing `points` [[x, y], …] (layer pixels)"))?;
let mut v = vec![];
for q in raw {
let a = q.as_array().ok_or_else(|| bad(cmd, "each point is [x, y]"))?;
let (Some(x), Some(y)) = (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64)) else {
return Err(bad(cmd, "each point is [x, y]"));
};
v.push([x, y]);
}
if v.is_empty() {
return Err(bad(cmd, "`points` is empty"));
}
Ok(v)
}
fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "roto.stroke";
let (cid, lid) = match roto_p(s, p, cmd) {
Ok((c, l, _)) => (c, l),
Err(_) => layer_p(s, p, cmd)?,
};
let kind = match str_p(p, "kind") {
Some(k) => StrokeKind::from_name(k).ok_or_else(|| bad(cmd, format!("unknown kind `{k}` (fg | bg | refine | refineErase)")))?,
None => StrokeKind::Fg,
};
let points = points_p(p, cmd)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or_else(|| bad(cmd, "no layer"))?;
if !layer.has_video() || layer.switches.adjustment || layer.effects().is_none() {
return Err(bad(cmd, "the Roto Brush works on footage, solid and composition layers"));
}
let frame = frame_p(s, p, cid, layer);
let limits = rt::frame_limits(comp, layer);
if frame < limits[0] || frame > limits[1] {
return Err(bad(cmd, format!("frame {frame} is outside the layer ({}–{})", limits[0], limits[1])));
}
let o = &s.state.roto;
let def_r = if matches!(kind, StrokeKind::Refine | StrokeKind::RefineErase) { o.refine_diameter / 2.0 } else { o.diameter / 2.0 };
let radius = f_p(p, "radius").unwrap_or(def_r).clamp(0.5, 2500.0);
let size = rt::layer_size(&s.project, comp, layer);
// The instance: the requested / first one, else a new one.
let existing = roto_p(s, p, cmd).ok().filter(|(c, l, _)| *c == cid && *l == lid).map(|x| x.2);
if let Some(uid) = existing {
let (_, g) = group(s, cid, lid, uid)?;
if frozen_of(g) {
return Err(bad(cmd, "the Roto Brush is frozen: Unfreeze it to edit strokes"));
}
}
if existing.is_none() && !kind.is_segmentation() {
return Err(bad(cmd, "draw a foreground stroke with the Roto Brush tool first"));
}
let st = Stroke { kind, frame, radius, points };
let label = if kind.is_segmentation() { "Roto Brush Stroke" } else { "Refine Edge Stroke" };
let (uid, d) = s.edit(label, None, |proj, _| {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, lid)?;
let fxg = l.props.sub_mut("effects").ok_or_else(|| bad(cmd, "this layer has no effects"))?;
let uid = match existing {
Some(u) => u,
None => {
let spec = effectcraft_effects::find(fx::ID).ok_or_else(|| EngineError::Other("Roto Brush effect missing".into()))?;
let n = fxg.groups().filter(|g| is_roto(g)).count();
let name = if n == 0 { spec.name.to_string() } else { format!("{} {}", spec.name, n + 1) };
let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), &name, size);
let u = g.uid;
fxg.children.push(g.into());
u
}
};
let g = fxg.find_group_mut(uid).ok_or_else(|| bad(cmd, "effect gone"))?;
let mut d = data_of(g);
d.add(st, fx::DEFAULT_SPAN, limits);
if let Some(pr) = g.get_mut(fx::STROKES) {
pr.value = KV::Str(d.to_json());
}
proj.next_id = proj.next_id.max(next);
Ok((uid, d))
})?;
if !s.state.selected_props.iter().any(|(l, u)| *l == lid && *u == uid) {
s.state.selected_layers = vec![lid];
}
Ok(json!({
"layer": lid.0, "effect": uid, "frame": frame, "kind": kind, "radius": radius,
"base": d.base, "span": d.span, "strokes": d.strokes.len(),
}))
}
fn propagate(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "roto.propagate";
let (cid, lid, uid) = roto_p(s, p, cmd)?;
let dir = match str_p(p, "direction") {
Some(d) => Direction::from_name(d).ok_or_else(|| bad(cmd, format!("unknown direction `{d}` (forward | backward | both)")))?,
None => Direction::Both,
};
if let Some(to) = f_p(p, "to") {
let to = to.round() as i64;
let (l, g) = group(s, cid, lid, uid)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let lim = rt::frame_limits(comp, l);
let mut d = data_of(g);
let to = to.clamp(lim[0], lim[1]);
let before = d.span;
d.span[0] = d.span[0].min(to);
d.span[1] = d.span[1].max(to);
if d.span != before {
if frozen_of(g) {
return Err(bad(cmd, "the Roto Brush is frozen: Unfreeze it to change the span"));
}
write_data(s, "Extend Segmentation Span", cid, lid, uid, &d)?;
}
}
let wait = b_p(p, "wait").unwrap_or(false);
let n = s.start_roto(cid, lid, uid, RotoTask::Propagate, dir, wait).map_err(|e| bad(cmd, e))?;
let mut out = json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_roto_running(), "progress": s.roto_progress()});
if wait {
out["status"] = status(s, &json!({"layer": lid.0, "effect": uid, "comp": cid.0}))?;
}
Ok(out)
}
fn span(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "roto.span";
let (cid, lid, uid) = roto_p(s, p, cmd)?;
let (l, g) = group(s, cid, lid, uid)?;
if frozen_of(g) {
return Err(bad(cmd, "the Roto Brush is frozen: Unfreeze it to change the span"));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let lim = rt::frame_limits(comp, l);
let mut d = data_of(g);
let base = d.base.ok_or_else(|| bad(cmd, "no strokes yet"))?;
let first = d.strokes.iter().map(|s| s.frame).min().unwrap_or(base);
let last = d.strokes.iter().map(|s| s.frame).max().unwrap_or(base);
if let Some(a) = f_p(p, "start") {
d.span[0] = (a.round() as i64).clamp(lim[0], first);
}
if let Some(b) = f_p(p, "end") {
d.span[1] = (b.round() as i64).clamp(last, lim[1]);
}
write_data(s, "Segmentation Span", cid, lid, uid, &d)?;
Ok(json!({"layer": lid.0, "effect": uid, "span": d.span, "base": base}))
}
fn freeze(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "roto.freeze";
let (cid, lid, uid) = roto_p(s, p, cmd)?;
let wait = b_p(p, "wait").unwrap_or(false);
let n = s.start_roto(cid, lid, uid, RotoTask::Freeze, Direction::Both, wait).map_err(|e| bad(cmd, e))?;
let (_, g) = group(s, cid, lid, uid)?;
Ok(json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_roto_running(), "frozen": frozen_of(g), "progress": s.roto_progress()}))
}
fn unfreeze(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "roto.unfreeze";
let (cid, lid, uid) = roto_p(s, p, cmd)?;
let (_, g) = group(s, cid, lid, uid)?;
if !frozen_of(g) {
return Ok(json!({"layer": lid.0, "effect": uid, "frozen": false}));
}
s.edit("Unfreeze", None, |proj, _| {
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| EngineError::Other("the effect is gone".into()))?;
if let Some(pr) = g.get_mut(FROZEN) {
pr.value = KV::Str(String::new());
}
Ok(())
})?;
Ok(json!({"layer": lid.0, "effect": uid, "frozen": false}))
}
fn clear_strokes(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "roto.clearStrokes";
let (cid, lid, uid) = roto_p(s, p, cmd)?;
let (_, g) = group(s, cid, lid, uid)?;
if frozen_of(g) {
return Err(bad(cmd, "the Roto Brush is frozen: Unfreeze it to edit strokes"));
}
let mut d = data_of(g);
let frame = f_p(p, "frame").map(|f| f.round() as i64);
let kind = match str_p(p, "kind") {
Some(k) => Some(StrokeKind::from_name(k).ok_or_else(|| bad(cmd, format!("unknown kind `{k}`")))?),
None => None,
};
let n0 = d.strokes.len();
d.strokes.retain(|st| !(frame.is_none_or(|f| st.frame == f) && kind.is_none_or(|k| st.kind == k)));
if d.strokes.iter().all(|s| !s.kind.is_segmentation()) {
d.base = None;
} else if d.base.is_some_and(|b| !d.strokes.iter().any(|s| s.frame == b && s.kind.is_segmentation())) {
// The base moves to the earliest remaining stroked frame.
d.base = d.strokes.iter().filter(|s| s.kind.is_segmentation()).map(|s| s.frame).min();
}
let removed = n0 - d.strokes.len();
if removed > 0 {
write_data(s, "Remove Roto Brush Strokes", cid, lid, uid, &d)?;
}
Ok(json!({"layer": lid.0, "effect": uid, "removed": removed, "strokes": d.strokes.len(), "base": d.base}))
}
fn options(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "roto.options";
let mut o: RotoOptions = s.state.roto.clone();
for (k, f) in [("diameter", &mut o.diameter as &mut f64), ("refineDiameter", &mut o.refine_diameter), ("overlayOpacity", &mut o.overlay_opacity)] {
if let Some(v) = f_p(p, k) {
*f = v;
}
}
o.diameter = o.diameter.clamp(1.0, 5000.0);
o.refine_diameter = o.refine_diameter.clamp(1.0, 5000.0);
o.overlay_opacity = o.overlay_opacity.clamp(0.0, 100.0);
if let Some(v) = str_p(p, "view") {
let v = VIEWS.iter().find(|x| x.eq_ignore_ascii_case(v)).ok_or_else(|| bad(cmd, format!("unknown view `{v}` ({})", VIEWS.join(" | "))))?;
o.view = v.to_string();
}
for (k, c) in [("overlayColor", &mut o.overlay_color), ("boundaryColor", &mut o.boundary_color)] {
if let Some(a) = p.get(k).and_then(Value::as_array) {
if a.len() < 3 {
return Err(bad(cmd, format!("`{k}` is [r, g, b] (0–1)")));
}
for (i, v) in a.iter().take(3).enumerate() {
c[i] = v.as_f64().unwrap_or(0.0).clamp(0.0, 1.0);
}
}
}
if let Some(b) = b_p(p, "autoPropagate") {
o.auto_propagate = b;
}
s.state.roto = o.clone();
Ok(serde_json::to_value(o).unwrap_or(Value::Null))
}
fn status(s: &mut Session, p: &Value) -> Result<Value> {
s.poll_roto(false);
let mut out =
json!({"running": s.is_roto_running(), "progress": s.roto_progress(), "banner": s.roto_progress().map(|p| p.banner()), "options": s.state.roto});
let Ok((cid, lid, uid)) = roto_p(s, p, "roto.status") else { return Ok(out) };
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let (layer, g) = group(s, cid, lid, uid)?;
let (layer, g) = (layer.clone(), g.clone());
let d = data_of(&g);
out["layer"] = json!(lid.0);
out["effect"] = json!(uid);
out["base"] = json!(d.base);
out["span"] = json!(d.span);
out["strokes"] = json!(d.strokes.len());
out["strokeFrames"] = json!(d.stroke_frames());
out["frozen"] = json!(frozen_of(&g));
out["pending"] = json!(s.roto_pending.contains(&(cid, lid, uid)));
out["limits"] = json!(rt::frame_limits(&comp, &layer));
let frame = frame_p(s, p, cid, &layer);
out["frame"] = json!(frame);
let want_matte = b_p(p, "matte").unwrap_or(false);
let compute = b_p(p, "compute").unwrap_or(want_matte);
if (p.get("frame").is_some() || want_matte) && d.in_span(frame) {
let size = rt::layer_size(&s.project, &comp, &layer);
let fps = comp.frame_rate.as_f64();
let ps = params_static(&g);
let chain = fx::Chain::new(&ps, size, fps, 1.0);
let seg = match chain.keys.get(&frame).and_then(|k| fx::cached(*k, 1.0)) {
Some(sg) => Some(sg),
None if compute => {
let index = layer.effects().and_then(|f| f.groups().position(|x| x.uid == uid)).unwrap_or(0);
let mut src = rt::FrameSource::new(&s.project, s.footage.as_ref(), s.expr.as_deref(), &s.layer_cache, cid, &comp, &layer, index, size);
chain.ensure(frame, &mut |f| src.get(f), &|| false)
}
None => None,
};
if let Some(sg) = seg {
out["area"] = json!(sg.area());
out["centroid"] = json!(sg.centroid());
out["size"] = json!([sg.w, sg.h]);
out["refineArea"] = json!(sg.refine.iter().filter(|v| **v != 0).count());
if want_matte {
out["matte"] = json!(rle::encode(&sg.matte));
}
if let Some(gt) = p.get("compareTo").and_then(Value::as_str).and_then(rle::decode) {
out["iou"] = json!(iou(&sg.matte, &gt));
}
}
}
let computed = s.roto_computed(cid, lid, uid);
out["computed"] = json!(computed.iter().filter(|(_, c)| **c).map(|(f, _)| *f).collect::<Vec<_>>());
Ok(out)
}

View File

@ -17,6 +17,7 @@ pub mod mask_track;
pub mod menus;
pub mod prefs;
pub mod render_queue;
pub mod roto;
mod session_settings;
pub mod shortcuts;
pub mod tracking;
@ -41,6 +42,7 @@ pub use effectcraft_project as project;
pub use effectcraft_render as render;
pub use effectcraft_text as text;
pub use effectcraft_time as time;
pub use effectcraft_track as track;
pub use render_queue::{ExportJob, ExportResult, Exporter, JobState};
#[derive(Debug, thiserror::Error)]
@ -176,6 +178,9 @@ pub struct EditorState {
/// Paint and Brushes panel options (Brush, Clone Stamp and Eraser tools).
#[serde(default)]
pub paint: commands::paint::PaintOptions,
/// Roto Brush and Refine Edge tool options.
#[serde(default)]
pub roto: roto::RotoOptions,
/// Puppet tool options for new meshes.
#[serde(default)]
pub puppet: commands::puppet::PuppetOptions,
@ -263,6 +268,10 @@ pub struct Session {
pub warp_job: Option<warp::WarpJob>,
/// Warp Stabilizers waiting for (re-)analysis: (comp, layer, effect uid).
pub warp_pending: Vec<(ItemId, LayerId, Uid)>,
/// The running (or finished, not yet polled) Roto Brush propagation / Freeze.
pub roto_job: Option<roto::RotoJob>,
/// Roto Brush instances edited since their last propagation: (comp, layer, effect uid).
pub roto_pending: Vec<(ItemId, LayerId, Uid)>,
pub events: Vec<Event>,
/// Commands executed: (id, params).
pub journal: Vec<(String, Value)>,
@ -325,6 +334,8 @@ impl Default for Session {
mask_job: None,
warp_job: None,
warp_pending: vec![],
roto_job: None,
roto_pending: vec![],
events: vec![],
journal: vec![],
layer_cache: Arc::new(LayerCache::default()),
@ -392,6 +403,12 @@ impl Session {
self.warp_pending.push(w);
}
}
// Roto Brush Input Keys follow the frames; edited instances propagate again.
for r in roto::sync(&before, &mut p) {
if !self.roto_pending.contains(&r) {
self.roto_pending.push(r);
}
}
let same = merge.is_some() && merge.map(str::to_string) == self.history.merge_key;
if !same {
self.history.undo.push((label.to_string(), before));
@ -569,6 +586,9 @@ impl Session {
self.stop_warp();
self.warp_job = None;
self.warp_pending.clear();
self.stop_roto();
self.roto_job = None;
self.roto_pending.clear();
p.fix_next_id();
self.project = Arc::new(p);
self.history = History::default();
@ -635,3 +655,5 @@ pub fn text_families() -> Vec<String> {
}
#[cfg(test)]
mod tests_paint;
#[cfg(test)]
mod tests_roto;

569
crates/engine/src/roto.rs Normal file
View File

@ -0,0 +1,569 @@
//! Roto Brush & Refine Edge runtime: propagation and Freeze jobs, Input Key maintenance and the
//! tool options.
//!
//! Segmentations are derived data in `effectcraft_effects::roto`'s content-keyed cache (see that
//! module). `roto.propagate` fills it at full resolution in the background (or blocking with
//! `wait`), frame by frame outward from the base frame, rendering the effect's input
//! (`Renderer::layer_input`, source → masks → the effects above it). `roto.freeze` runs the same
//! pass and stores the final mattes in the effect (one undo step). [`sync`], run after every
//! edit, keeps each instance's hidden Input Key equal to a signature of what its frames are made
//! of (the layer's source and timing, its masks and the effects above it) and drops frozen
//! mattes made from other frames.
use std::collections::{BTreeMap, HashMap};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_effects::roto::{self as fx, FROZEN, FrozenCache, INPUT_KEY};
use effectcraft_effects::{Params, flatten_params};
use effectcraft_keyframe::Value;
use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, Project, PropGroup, Uid};
use effectcraft_raster::Image;
use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
use crate::tracking::lock;
use crate::{Event, Session};
/// Roto Brush / Refine Edge tool options (serde: `roto.options`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct RotoOptions {
/// Roto Brush tip diameter (layer pixels).
pub diameter: f64,
/// Refine Edge tip diameter (layer pixels).
pub refine_diameter: f64,
/// Layer panel view: `alphaBoundary` (pink outline), `alpha`, `alphaOverlay`, `none`.
pub view: String,
/// Alpha Overlay colour (straight RGB 0–1) and opacity (percent).
pub overlay_color: [f64; 3],
pub overlay_opacity: f64,
/// Alpha Boundary colour.
pub boundary_color: [f64; 3],
/// Start propagating in the background after each stroke.
pub auto_propagate: bool,
}
impl Default for RotoOptions {
fn default() -> Self {
RotoOptions {
diameter: 20.0,
refine_diameter: 10.0,
view: "alphaBoundary".into(),
overlay_color: [1.0, 0.0, 0.0],
overlay_opacity: 50.0,
boundary_color: [1.0, 0.25, 0.75],
auto_propagate: true,
}
}
}
pub const VIEWS: [&str; 4] = ["alphaBoundary", "alpha", "alphaOverlay", "none"];
/// What a job does.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum RotoTask {
Propagate,
Freeze,
}
/// Which way to propagate.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Direction {
Forward,
Backward,
#[default]
Both,
}
impl Direction {
pub fn from_name(s: &str) -> Option<Direction> {
Some(match s.to_ascii_lowercase().as_str() {
"forward" | "fwd" => Direction::Forward,
"backward" | "back" => Direction::Backward,
"both" | "all" => Direction::Both,
_ => return None,
})
}
}
/// Live job progress (serde for agents / the control channel).
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RotoProgress {
pub task: Option<RotoTask>,
pub done: u64,
pub total: u64,
pub elapsed: f64,
pub fps: f64,
pub finished: bool,
pub cancelled: bool,
pub error: Option<String>,
}
impl RotoProgress {
pub fn banner(&self) -> String {
let pc = (100 * self.done).checked_div(self.total).unwrap_or(0);
match self.task {
Some(RotoTask::Freeze) => format!("Freezing Roto Brush & Refine Edge: {pc}%"),
_ => format!("Propagating Roto Brush: {pc}%"),
}
}
}
#[derive(Default)]
pub struct RotoShared {
pub cancel: AtomicBool,
pub state: Mutex<RotoProgress>,
frozen: Mutex<Option<FrozenCache>>,
}
pub struct RotoJob {
pub shared: Arc<RotoShared>,
thread: Option<std::thread::JoinHandle<()>>,
pub comp: ItemId,
pub layer: LayerId,
pub effect: Uid,
pub task: RotoTask,
/// The strokes the job was started with (a Freeze result is dropped when they changed).
strokes: String,
}
impl RotoJob {
pub fn is_finished(&self) -> bool {
lock(&self.shared.state).finished
}
pub fn progress(&self) -> RotoProgress {
lock(&self.shared.state).clone()
}
}
pub(crate) fn is_roto(g: &PropGroup) -> bool {
matches!(&g.kind, GroupKind::Effect { effect } if effect == fx::ID)
}
fn str_of(g: &PropGroup, m: &str) -> String {
match g.get(m).map(|p| &p.value) {
Some(Value::Str(s)) => s.clone(),
_ => String::new(),
}
}
fn set_str(g: &mut PropGroup, m: &str, v: String) {
if let Some(p) = g.get_mut(m) {
p.value = Value::Str(v);
p.keys.clear();
}
}
fn fnv(s: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in s.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
/// What the effect's input frames are made of: the layer's source and timing, its masks and the
/// effects above the Roto Brush.
pub fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String {
let mut s = crate::warp::signature(p, comp, layer);
if let Some(m) = layer.masks() {
s.push_str(&format!("{m:?}"));
}
if let Some(fx) = layer.effects() {
for g in fx.groups() {
if g.uid == uid {
break;
}
s.push_str(&format!("{g:?}"));
}
}
format!("{:016x}", fnv(&s))
}
/// After an edit: refresh Input Keys; frozen mattes made from other frames are dropped.
/// Returns the instances whose segmentation changed (to propagate again).
pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerId, Uid)> {
let mut todo = vec![];
let mut set = vec![];
for (cid, it) in &after.items {
let ItemKind::Comp(c) = &it.kind else { continue };
for l in &c.layers {
let Some(fxg) = l.effects() else { continue };
for g in fxg.groups().filter(|g| is_roto(g)) {
let key = input_key(after, *cid, l, g.uid);
let old = before.comp(*cid).and_then(|bc| bc.layer(l.id)).and_then(|ol| ol.props.find_group(g.uid));
if str_of(g, INPUT_KEY) != key {
set.push((*cid, l.id, g.uid, key));
todo.push((*cid, l.id, g.uid));
} else if old.is_none_or(|o| str_of(o, fx::STROKES) != str_of(g, fx::STROKES)) {
todo.push((*cid, l.id, g.uid));
}
}
}
}
for (cid, lid, uid, key) in set {
if let Some(g) = after.comp_mut(cid).and_then(|c| c.layer_mut(lid)).and_then(|l| l.props.find_group_mut(uid)) {
let had = !str_of(g, INPUT_KEY).is_empty();
set_str(g, INPUT_KEY, key);
if had {
set_str(g, FROZEN, String::new());
}
}
}
todo.retain(|(c, l, u)| {
after.comp(*c).and_then(|c| c.layer(*l)).and_then(|l| l.props.find_group(*u)).is_some_and(|g| !fx::data(&params_static(g)).is_empty())
});
todo
}
/// Parameters of an instance with static values (strokes and keys are never animated).
pub(crate) fn params_static(g: &PropGroup) -> Params {
flatten_params(g, &mut |p| p.value.clone())
}
/// Evaluated parameters of an instance at comp time `t`.
pub(crate) fn params_at(p: &Project, cid: ItemId, layer: &Layer, g: &PropGroup, t: Tick, expr: Option<&dyn ExprHost>) -> Params {
match p.comp(cid) {
Some(c) => {
let ctx = EvalCtx { expr, ..EvalCtx::new(p, cid, c, t) };
flatten_params(g, &mut |pr| ctx.value(layer, pr))
}
None => params_static(g),
}
}
/// The layer's source size (layer pixels).
pub(crate) fn layer_size(p: &Project, comp: &effectcraft_project::Comp, layer: &Layer) -> [f64; 2] {
let (w, h) = effectcraft_render::source_size(p, layer);
if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] }
}
/// First and last layer frame of the layer inside the comp (inclusive).
pub fn frame_limits(comp: &effectcraft_project::Comp, layer: &Layer) -> [i64; 2] {
let fps = comp.frame_rate.as_f64().max(1e-6);
let lo = layer.layer_time(layer.in_point.max(Tick::ZERO)).seconds();
let hi = layer.layer_time(layer.out_point.min(comp.duration)).seconds();
let a = fx::frame_of(lo, fps);
let b = (fx::frame_of(hi, fps) - 1).max(a);
[a, b]
}
/// Comp time of layer frame `f`.
pub(crate) fn comp_time_of(comp: &effectcraft_project::Comp, layer: &Layer, f: i64) -> Tick {
layer.comp_time(comp.frame_rate.tick_of(f))
}
/// What the worker needs, detached from the session.
struct Work {
project: Arc<Project>,
footage: Arc<dyn FootageSource>,
expr: Option<Arc<dyn ExprHost>>,
cache: Arc<LayerCache>,
comp: ItemId,
layer: LayerId,
effect_index: usize,
params: Params,
size: [f64; 2],
fps: f64,
frames: Vec<i64>,
task: RotoTask,
}
/// Renders the effect's input frames for a worker, keeping the last few.
pub(crate) struct FrameSource<'a> {
r: Renderer<'a>,
ctx0: EvalCtx<'a>,
comp: &'a effectcraft_project::Comp,
layer: &'a Layer,
index: usize,
size: [f64; 2],
memo: HashMap<i64, Arc<Image>>,
}
impl<'a> FrameSource<'a> {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
project: &'a Project,
footage: &'a dyn FootageSource,
expr: Option<&'a dyn ExprHost>,
cache: &'a LayerCache,
cid: ItemId,
comp: &'a effectcraft_project::Comp,
layer: &'a Layer,
index: usize,
size: [f64; 2],
) -> FrameSource<'a> {
let mut r = Renderer::new(project, footage, RenderOpts { scale: 1.0, ..Default::default() });
r.expr = expr;
r.cache = Some(cache);
let ctx0 = EvalCtx { expr, ..EvalCtx::new(project, cid, comp, Tick::ZERO) };
FrameSource { r, ctx0, comp, layer, index, size, memo: HashMap::new() }
}
pub(crate) fn get(&mut self, f: i64) -> Option<Arc<Image>> {
if let Some(i) = self.memo.get(&f) {
return Some(i.clone());
}
let t = comp_time_of(self.comp, self.layer, f);
let buf = self.r.layer_input(&self.ctx0.at(t), self.layer, self.index)?;
let buf = if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 {
let w = (buf.img.width as f64 / buf.scale).round().max(1.0) as u32;
let h = (buf.img.height as f64 / buf.scale).round().max(1.0) as u32;
effectcraft_effects::Buf {
img: effectcraft_raster::resample(&buf.img, w, h),
offset: [buf.offset[0] / buf.scale, buf.offset[1] / buf.scale],
scale: 1.0,
}
} else {
(*buf).clone()
};
let img = Arc::new(fx::layer_grid(&buf, self.size));
if self.memo.len() > 4 {
self.memo.clear();
}
self.memo.insert(f, img.clone());
Some(img)
}
}
fn run_work(w: Work, shared: &RotoShared) {
let t0 = web_time::Instant::now();
let fail = |msg: &str| {
let mut s = lock(&shared.state);
s.error = Some(msg.to_string());
s.finished = true;
};
let Some(comp) = w.project.comp(w.comp) else { return fail("composition missing") };
let Some(layer) = comp.layer(w.layer) else { return fail("layer missing") };
let mut src = FrameSource::new(&w.project, w.footage.as_ref(), w.expr.as_deref(), &w.cache, w.comp, comp, layer, w.effect_index, w.size);
{
let mut s = lock(&shared.state);
s.task = Some(w.task);
s.total = w.frames.len() as u64;
}
let cancel = || shared.cancel.load(Ordering::Relaxed);
let chain = fx::Chain::new(&w.params, w.size, w.fps, 1.0);
let mut frozen = FrozenCache { key: fx::frozen_key(&w.params, w.size, w.fps), ..Default::default() };
for (k, f) in w.frames.iter().enumerate() {
if cancel() {
let mut s = lock(&shared.state);
s.cancelled = true;
s.finished = true;
return;
}
let mut get = |g: i64| src.get(g);
let ok = match w.task {
RotoTask::Propagate => chain.ensure(*f, &mut get, &cancel).is_some(),
RotoTask::Freeze => match fx::frame_matte(&w.params, w.size, w.fps, *f, &mut get, &cancel) {
Some((a, b)) => {
if let Some(img) = src.get(*f) {
frozen.size = [img.width as usize, img.height as usize];
}
frozen.frames.insert(*f, fx::freeze_frame(&a, &b));
true
}
None => false,
},
};
if !ok && !cancel() {
return fail(&format!("frame {f} could not be segmented"));
}
let mut s = lock(&shared.state);
s.done = k as u64 + 1;
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
}
if w.task == RotoTask::Freeze {
*lock(&shared.frozen) = Some(frozen);
}
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
s.finished = true;
}
/// Frames to compute, outward from the base frame.
pub(crate) fn job_frames(d: &effectcraft_track::roto::RotoData, dir: Direction) -> Vec<i64> {
let Some(base) = d.base else { return vec![] };
let mut v = vec![base];
if dir != Direction::Backward {
v.extend(base + 1..=d.span[1]);
}
if dir != Direction::Forward {
v.extend((d.span[0]..base).rev());
}
v
}
impl Session {
pub fn is_roto_running(&self) -> bool {
self.roto_job.as_ref().is_some_and(|j| !j.is_finished())
}
pub fn roto_progress(&self) -> Option<RotoProgress> {
self.roto_job.as_ref().map(|j| j.progress())
}
/// (comp, layer, effect uid, task) of the running job.
pub fn roto_target(&self) -> Option<(ItemId, LayerId, Uid, RotoTask)> {
self.roto_job.as_ref().map(|j| (j.comp, j.layer, j.effect, j.task))
}
pub fn stop_roto(&mut self) -> bool {
match &self.roto_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
true
}
_ => false,
}
}
/// Start propagating (or freezing) Roto Brush `effect` on `layer`. `wait`: block until done
/// (wasm32 always does). Returns the number of frames.
pub fn start_roto(&mut self, comp: ItemId, layer: LayerId, effect: Uid, task: RotoTask, dir: Direction, wait: bool) -> Result<usize, String> {
if self.is_roto_running() {
if !wait {
return Err("a Roto Brush job is already running".into());
}
self.stop_roto();
if let Some(mut j) = self.roto_job.take()
&& let Some(t) = j.thread.take()
{
let _ = t.join();
}
}
self.poll_roto_job();
self.roto_pending.retain(|x| *x != (comp, layer, effect));
let c = self.project.comp(comp).ok_or("no composition")?;
let l = c.layer(layer).ok_or("no layer")?;
let fxg = l.effects().ok_or("the layer has no effects")?;
let index = fxg.groups().position(|g| g.uid == effect).ok_or("no such effect")?;
let g = fxg.groups().nth(index).filter(|g| is_roto(g)).ok_or("not a Roto Brush & Refine Edge effect")?;
let size = layer_size(&self.project, c, l);
let fps = c.frame_rate.as_f64();
let pstat = params_static(g);
let d = fx::data(&pstat);
if d.is_empty() {
return Err("draw a foreground stroke with the Roto Brush tool first".into());
}
let t = comp_time_of(c, l, d.base.unwrap_or(0));
let params = params_at(&self.project, comp, l, g, t, self.expr.as_deref());
let frames = job_frames(&d, if task == RotoTask::Freeze { Direction::Both } else { dir });
let n = frames.len();
let work = Work {
project: self.project.clone(),
footage: self.footage.clone(),
expr: self.expr.clone(),
cache: self.layer_cache.clone(),
comp,
layer,
effect_index: index,
params,
size,
fps,
frames,
task,
};
let strokes = str_of(g, fx::STROKES);
let wait = wait || cfg!(target_arch = "wasm32");
let shared = Arc::new(RotoShared::default());
let thread = if wait {
run_work(work, &shared);
None
} else {
let sh = shared.clone();
Some(std::thread::Builder::new().name("roto".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?)
};
self.roto_job = Some(RotoJob { shared, thread, comp, layer, effect, task, strokes });
self.events.push(Event::ProjectChanged { revision: self.revision });
self.poll_roto_job();
Ok(n)
}
/// Finish a done job: Freeze writes the frozen mattes (one undo step).
fn poll_roto_job(&mut self) -> bool {
let Some(job) = &self.roto_job else { return false };
if !job.is_finished() {
return false;
}
let mut job = self.roto_job.take().expect("job");
if let Some(t) = job.thread.take() {
let _ = t.join();
}
let st = job.progress();
if let Some(e) = &st.error {
self.events.push(Event::Toast { message: format!("Roto Brush: {e}"), error: true });
self.bump();
return true;
}
if st.cancelled {
self.events.push(Event::Toast { message: "Roto Brush: stopped".into(), error: false });
self.bump();
return true;
}
match job.task {
RotoTask::Propagate => {
self.events.push(Event::Toast { message: format!("Roto Brush: propagated {} frame(s) in {:.1} s", st.done, st.elapsed), error: false });
// Rendered frames depend on the cache: redraw.
self.bump();
}
RotoTask::Freeze => {
let Some(fc) = lock(&job.shared.frozen).take() else { return true };
let (cid, lid, uid, strokes) = (job.comp, job.layer, job.effect, job.strokes.clone());
let r = self.edit("Freeze", None, |p, _| {
let g = p
.comp_mut(cid)
.and_then(|c| c.layer_mut(lid))
.and_then(|l| l.props.find_group_mut(uid))
.ok_or_else(|| crate::EngineError::Other("the Roto Brush effect was removed".into()))?;
if str_of(g, fx::STROKES) != strokes {
return Err(crate::EngineError::Other("the strokes changed while freezing".into()));
}
set_str(g, FROZEN, fc.to_json());
Ok(())
});
let msg = match r {
Ok(()) => format!("Roto Brush: froze {} frame(s) in {:.1} s", fc.frames.len(), st.elapsed),
Err(e) => format!("Roto Brush: {e}"),
};
self.events.push(Event::Toast { message: msg, error: false });
}
}
true
}
/// Frontends call this every frame: finishes jobs and (with `auto`) propagates edited
/// instances in the background.
pub fn poll_roto(&mut self, auto: bool) -> bool {
let changed = self.poll_roto_job();
if auto && self.roto_job.is_none() && self.state.roto.auto_propagate {
while let Some((c, l, e)) = self.roto_pending.first().copied() {
self.roto_pending.remove(0);
if self.start_roto(c, l, e, RotoTask::Propagate, Direction::Both, false).is_ok() {
return true;
}
}
}
changed
}
/// The computed frames of an instance (full resolution), by layer frame.
pub fn roto_computed(&self, comp: ItemId, layer: LayerId, effect: Uid) -> BTreeMap<i64, bool> {
let mut out = BTreeMap::new();
let Some(c) = self.project.comp(comp) else { return out };
let Some(l) = c.layer(layer) else { return out };
let Some(g) = l.props.find_group(effect) else { return out };
let ps = params_static(g);
let ch = fx::Chain::new(&ps, layer_size(&self.project, c, l), c.frame_rate.as_f64(), 1.0);
for f in ch.keys.keys() {
out.insert(*f, ch.is_cached(*f, 1.0));
}
out
}
}

View File

@ -0,0 +1,288 @@
//! Roto Brush & Refine Edge end to end through the commands, on synthetic footage: a textured
//! disk moving over a textured background.
use effectcraft_project::LayerId;
use effectcraft_raster::Image;
use effectcraft_render::RenderOpts;
use effectcraft_track::roto::rle;
use serde_json::{Value, json};
use crate::Session;
use crate::tests_track::{H, W, frame_time, setup};
const R: f64 = 40.0;
fn noise(x: f64, y: f64, seed: u32) -> f64 {
fn h(i: i64, j: i64, s: u32) -> f64 {
let mut v = (i.wrapping_mul(73856093) ^ j.wrapping_mul(19349663) ^ (s as i64).wrapping_mul(83492791)) as u64;
v ^= v >> 13;
v = v.wrapping_mul(0x5bd1e995);
v ^= v >> 15;
(v % 10_000) as f64 / 10_000.0
}
let mut out = 0.0;
for (cell, amp) in [(9.0, 0.6), (3.5, 0.4)] {
let (fx, fy) = (x / cell, y / cell);
let (i, j) = (fx.floor() as i64, fy.floor() as i64);
let (tx, ty) = (fx - i as f64, fy - j as f64);
let (sx, sy) = (tx * tx * (3.0 - 2.0 * tx), ty * ty * (3.0 - 2.0 * ty));
let a = h(i, j, seed) + (h(i + 1, j, seed) - h(i, j, seed)) * sx;
let b = h(i, j + 1, seed) + (h(i + 1, j + 1, seed) - h(i, j + 1, seed)) * sx;
out += amp * (a + (b - a) * sy);
}
out
}
/// Disk centre on frame `f`.
fn center(f: u32) -> [f64; 2] {
let t = f as f64;
[90.0 + 4.0 * t, 110.0 + 1.5 * t + 5.0 * (t * 0.3).sin()]
}
/// Disk centre with a jump at frame 8 (propagation loses it there).
fn jumping(f: u32) -> [f64; 2] {
if f < 8 { center(f) } else { [230.0 + 2.0 * (f - 8) as f64, 150.0] }
}
fn draw(c: [f64; 2]) -> Image {
let mut img = Image::new(W, H);
for y in 0..H {
for x in 0..W {
let p = [x as f64 + 0.5, y as f64 + 0.5];
let d = (p[0] - c[0]).hypot(p[1] - c[1]);
let a = (R + 0.5 - d).clamp(0.0, 1.0) as f32;
let n = noise(p[0] - c[0], p[1] - c[1], 11);
let f = [(0.75 + 0.25 * n) as f32, (0.35 + 0.3 * n) as f32, (0.1 + 0.15 * n) as f32];
let (bn, bm) = (noise(p[0], p[1], 5), noise(p[0] * 1.7, p[1] * 1.7, 9));
let b = [(0.05 + 0.25 * bm) as f32, (0.3 + 0.4 * bn) as f32, (0.45 + 0.4 * bm) as f32];
img.set(x, y, [f[0] * a + b[0] * (1.0 - a), f[1] * a + b[1] * (1.0 - a), f[2] * a + b[2] * (1.0 - a), 1.0]);
}
}
img
}
fn gt(c: [f64; 2]) -> String {
let m: Vec<u8> = (0..W * H).map(|i| (((i % W) as f64 + 0.5 - c[0]).hypot((i / W) as f64 + 0.5 - c[1]) <= R) as u8).collect();
rle::encode(&m)
}
fn line(a: [f64; 2], b: [f64; 2]) -> Value {
json!((0..=8).map(|k| [a[0] + (b[0] - a[0]) * k as f64 / 8.0, a[1] + (b[1] - a[1]) * k as f64 / 8.0]).collect::<Vec<_>>())
}
/// A session with the clip (the Track solid removed) and foreground / background strokes on
/// frame 0. `name`: the footage file name (segmentations are cached process-wide by content key,
/// which includes the footage's identity: different clips need different names).
fn painted(path: fn(u32) -> [f64; 2], name: &str) -> (Session, LayerId, u64) {
let (mut s, clip, solid) = setup(move |f| draw(path(f)));
let cid = s.active_comp_id().unwrap();
s.edit("remove solid", None, |p, _| {
p.comp_mut(cid).unwrap().layers.retain(|l| l.id != solid);
for it in p.items.values_mut() {
if let effectcraft_project::ItemKind::Footage(f) = &mut it.kind {
f.path = name.into();
}
}
Ok(())
})
.unwrap();
s.execute("time.set", json!({"time": 0})).unwrap();
let c = path(0);
let r =
s.execute_checked("roto.stroke", json!({"layer": clip.0, "points": line([c[0] - 22.0, c[1] - 8.0], [c[0] + 22.0, c[1] + 8.0]), "radius": 6})).unwrap();
let uid = r["effect"].as_u64().unwrap();
assert_eq!(r["base"], 0);
assert_eq!(r["span"], json!([0, 20]));
for (a, b) in [([c[0] - 60.0, c[1] - 58.0], [c[0] + 70.0, c[1] - 58.0]), ([c[0] + 70.0, c[1] + 58.0], [c[0] - 60.0, c[1] + 58.0])] {
s.execute_checked("roto.stroke", json!({"layer": clip.0, "kind": "bg", "points": line(a, b), "radius": 6})).unwrap();
}
(s, clip, uid)
}
/// Drop an instance's derived segmentations from the process-wide cache.
fn forget(s: &Session, clip: LayerId, uid: u64) {
let comp = s.active_comp().unwrap();
let l = comp.layer(clip).unwrap();
let g = l.props.find_group(uid).unwrap();
let size = crate::roto::layer_size(&s.project, comp, l);
let ch = effectcraft_effects::roto::Chain::new(&crate::roto::params_static(g), size, comp.frame_rate.as_f64(), 1.0);
effectcraft_effects::roto::forget(ch.keys.values().copied());
}
fn iou_at(s: &mut Session, clip: LayerId, f: u32, c: [f64; 2]) -> f64 {
let st = s.execute("roto.status", json!({"layer": clip.0, "frame": f, "compute": true, "compareTo": gt(c)})).unwrap();
st["iou"].as_f64().unwrap_or_else(|| panic!("no matte on frame {f}: {st}"))
}
fn alpha_at(s: &Session, f: u32) -> Vec<f32> {
let img = s.render(s.active_comp_id().unwrap(), frame_time(f), RenderOpts { scale: 1.0, ..Default::default() });
img.data.iter().map(|p| p[3]).collect()
}
#[test]
fn roto_brush_segments_and_propagates() {
let (mut s, clip, uid) = painted(center, "disk-a.mov");
let l = s.active_comp().unwrap().layer(clip).unwrap().clone();
assert_eq!(l.effects().unwrap().groups().filter(|g| crate::roto::is_roto(g)).count(), 1, "one effect for all strokes");
let j0 = iou_at(&mut s, clip, 0, center(0));
assert!(j0 > 0.95, "base frame IoU {j0}");
// Propagate the span (blocking).
let r = s.execute_checked("roto.propagate", json!({"layer": clip.0, "direction": "both", "wait": true})).unwrap();
assert_eq!(r["frames"], 21, "{r}");
assert_eq!(r["status"]["computed"].as_array().unwrap().len(), 21);
let mut worst: f64 = 1.0;
for f in 0..=20 {
worst = worst.min(iou_at(&mut s, clip, f, center(f)));
}
assert!(worst > 0.9, "worst IoU over the span {worst}");
// The render applies the matte; outside the span the layer is transparent.
let a = alpha_at(&s, 12);
let c = center(12);
let mut wrong = 0;
for (i, v) in a.iter().enumerate() {
let d = (((i as u32 % W) as f64 + 0.5 - c[0]).hypot((i as u32 / W) as f64 + 0.5 - c[1])) - R;
if (d < -3.0 && *v < 0.5) || (d > 3.0 && *v > 0.5) {
wrong += 1;
}
}
assert!(wrong < 150, "{wrong} wrong pixels in the render");
assert!(alpha_at(&s, 23).iter().all(|v| *v == 0.0));
// Extending the span propagates further.
let r = s.execute("roto.propagate", json!({"layer": clip.0, "direction": "forward", "to": 24, "wait": true})).unwrap();
assert_eq!(r["status"]["span"], json!([0, 24]));
assert!(iou_at(&mut s, clip, 24, center(24)) > 0.9);
// Undo / redo of strokes and the span.
let n = |s: &Session| {
let st = s.active_comp().unwrap().layer(clip).unwrap().props.find_group(uid).unwrap().get(effectcraft_effects::roto::STROKES).unwrap().value.clone();
match st {
effectcraft_keyframe::Value::Str(j) => effectcraft_track::roto::RotoData::from_json(&j),
_ => panic!(),
}
};
assert_eq!(n(&s).span, [0, 24]);
assert!(s.undo());
assert_eq!(n(&s).span, [0, 20]);
assert_eq!(n(&s).strokes.len(), 3);
assert!(s.undo());
assert_eq!(n(&s).strokes.len(), 2);
assert!(s.redo());
assert!(s.redo());
assert_eq!(n(&s).strokes.len(), 3);
assert_eq!(n(&s).span, [0, 24]);
// Serde keeps the strokes; the reopened project renders the same matte.
let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap();
let g = back.comp(s.active_comp_id().unwrap()).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap().clone();
assert_eq!(
crate::roto::params_static(&g).get("strokes"),
crate::roto::params_static(s.active_comp().unwrap().layer(clip).unwrap().props.find_group(uid).unwrap()).get("strokes")
);
// Bad parameters are reported.
assert!(s.execute_checked("roto.stroke", json!({"layer": clip.0, "kind": "sideways", "points": [[1, 1]]})).is_err());
assert!(s.execute_checked("roto.propagate", json!({"layer": clip.0, "bogus": 1})).is_err());
assert!(s.execute_checked("roto.stroke", json!({"layer": clip.0, "points": [[1, 1]], "frame": 99})).is_err());
}
#[test]
fn correction_strokes_restart_propagation() {
let (mut s, clip, _) = painted(jumping, "disk-jump.mov");
s.execute("roto.propagate", json!({"layer": clip.0, "wait": true})).unwrap();
assert!(iou_at(&mut s, clip, 6, jumping(6)) > 0.9);
let bad = iou_at(&mut s, clip, 8, jumping(8));
assert!(bad < 0.5, "the jump should break propagation ({bad})");
// Correct frame 8: paint the disk in and its old place out.
let (c, old) = (jumping(8), center(8));
s.execute("roto.stroke", json!({"layer": clip.0, "frame": 8, "points": line([c[0] - 20.0, c[1]], [c[0] + 20.0, c[1]]), "radius": 6})).unwrap();
s.execute(
"roto.stroke",
json!({"layer": clip.0, "frame": 8, "kind": "bg", "points": line([old[0] - 15.0, old[1] - 15.0], [old[0] + 15.0, old[1] + 15.0]), "radius": 8}),
)
.unwrap();
let st = s.execute("roto.status", json!({"layer": clip.0})).unwrap();
let computed: Vec<i64> = serde_json::from_value(st["computed"].clone()).unwrap();
assert!(computed.contains(&7) && !computed.contains(&8) && !computed.contains(&9), "frames from the correction on are recomputed: {computed:?}");
assert_eq!(st["strokeFrames"], json!([0, 8]));
s.execute("roto.propagate", json!({"layer": clip.0, "direction": "forward", "wait": true})).unwrap();
for f in 8..=20 {
let j = iou_at(&mut s, clip, f, jumping(f));
assert!(j > 0.9, "frame {f}: IoU {j}");
}
// Clearing the correction brings the bad frame back.
let r = s.execute("roto.clearStrokes", json!({"layer": clip.0, "frame": 8})).unwrap();
assert_eq!(r["removed"], 2);
assert!(iou_at(&mut s, clip, 8, jumping(8)) < 0.5);
}
#[test]
fn freeze_caches_the_mattes() {
let (mut s, clip, uid) = painted(center, "disk-freeze.mov");
// A refine band along the edge on the base frame.
let c = center(0);
let ring: Vec<[f64; 2]> = (0..=40).map(|k| k as f64 / 40.0 * std::f64::consts::TAU).map(|a| [c[0] + R * a.cos(), c[1] + R * a.sin()]).collect();
s.execute_checked("roto.stroke", json!({"layer": clip.0, "kind": "refine", "points": ring, "radius": 5})).unwrap();
s.execute("roto.span", json!({"layer": clip.0, "end": 3})).unwrap();
let live: Vec<Vec<f32>> = (0..=3).map(|f| alpha_at(&s, f)).collect();
// Freeze in the background and wait for it.
let r = s.execute_checked("roto.freeze", json!({"layer": clip.0})).unwrap();
assert_eq!(r["frames"], 4);
let t0 = std::time::Instant::now();
while s.roto_job.is_some() {
assert!(t0.elapsed().as_secs() < 300);
std::thread::sleep(std::time::Duration::from_millis(5));
s.poll_roto(false);
}
let st = s.execute("roto.status", json!({"layer": clip.0})).unwrap();
assert_eq!(st["frozen"], true, "{st}");
// Frozen renders match the live ones (8-bit alpha), even without the derived segmentations.
forget(&s, clip, uid);
s.layer_cache = Default::default();
for (f, want) in live.iter().enumerate() {
let got = alpha_at(&s, f as u32);
let worst = got.iter().zip(want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(worst <= 1.0 / 255.0 + 1e-4, "frame {f}: {worst}");
}
assert!(st["computed"].as_array().unwrap().len() >= 4);
// Frozen effects refuse strokes; Unfreeze is undoable.
assert!(s.execute("roto.stroke", json!({"layer": clip.0, "points": [[10, 10]]})).is_err());
s.execute("roto.unfreeze", json!({"layer": clip.0})).unwrap();
assert_eq!(s.execute("roto.status", json!({"layer": clip.0})).unwrap()["frozen"], false);
assert!(s.undo());
assert_eq!(s.execute("roto.status", json!({"layer": clip.0})).unwrap()["frozen"], true);
// Changing the source frames (the layer's timing) drops the frozen mattes.
let cid = s.active_comp_id().unwrap();
s.edit("shift", None, |p, _| {
let l = p.comp_mut(cid).unwrap().layer_mut(clip).unwrap();
l.start_time = frame_time(1);
Ok(())
})
.unwrap();
assert_eq!(s.execute("roto.status", json!({"layer": clip.0, "effect": uid})).unwrap()["frozen"], false);
}
#[test]
fn propagation_is_deterministic_and_runs_in_background() {
let (mut s, clip, uid) = painted(center, "disk-bg.mov");
s.execute("roto.span", json!({"layer": clip.0, "end": 5})).unwrap();
s.execute("roto.propagate", json!({"layer": clip.0})).unwrap();
let t0 = std::time::Instant::now();
while s.roto_job.is_some() {
assert!(t0.elapsed().as_secs() < 300);
std::thread::sleep(std::time::Duration::from_millis(5));
s.poll_roto(false);
}
let st = s.execute("roto.status", json!({"layer": clip.0})).unwrap();
assert_eq!(st["computed"], json!([0, 1, 2, 3, 4, 5]), "{st}");
let a = s.execute("roto.status", json!({"layer": clip.0, "frame": 5, "matte": true})).unwrap()["matte"].clone();
// Recomputing from scratch gives the same matte.
forget(&s, clip, uid);
let b = s.execute("roto.status", json!({"layer": clip.0, "frame": 5, "matte": true})).unwrap()["matte"].clone();
assert!(a.is_string());
assert_eq!(a, b);
// Tool options.
let o = s.execute_checked("roto.options", json!({"diameter": 30, "view": "alphaOverlay"})).unwrap();
assert_eq!(o["view"], "alphaOverlay");
assert_eq!(s.state.roto.diameter, 30.0);
assert!(s.execute("roto.options", json!({"view": "sideways"})).is_err());
// The default radius follows the tool diameter.
let r = s.execute("roto.stroke", json!({"layer": clip.0, "points": [[90, 110]]})).unwrap();
assert_eq!(r["radius"], 15.0);
}

View File

@ -1,6 +1,6 @@
[package]
name = "effectcraft-track"
description = "Motion tracking: feature/search region point tracking (pyramid normalized cross-correlation + Lucas-Kanade refinement), confidence, and corner-pin / transform solves"
description = "Motion tracking: feature/search region point tracking (pyramid normalized cross-correlation + Lucas-Kanade refinement), confidence, corner-pin / transform solves, stabilization and Roto Brush segmentation (graph cut, matting)"
version.workspace = true
edition.workspace = true
license.workspace = true

View File

@ -22,6 +22,7 @@ pub mod fit;
pub mod klt;
pub mod mask;
pub mod plane;
pub mod roto;
pub mod solve;
pub mod stabilize;

View File

@ -0,0 +1,228 @@
//! Gaussian mixture colour models (full 3 × 3 covariances), as used by GrabCut (C. Rother,
//! V. Kolmogorov and A. Blake, "GrabCut: Interactive Foreground Extraction using Iterated Graph
//! Cuts", SIGGRAPH 2004).
//!
//! Fitting is deterministic: components are initialised by recursive splitting of the cluster
//! with the largest variance along its principal axis (Orchard & Bouman, "Color Quantization of
//! Images", IEEE TSP 1991), then refined by a few hard-assignment EM iterations.
use serde::{Deserialize, Serialize};
/// One Gaussian: weight, mean, inverse covariance and log normaliser.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Gauss {
pub weight: f64,
pub mean: [f64; 3],
pub inv: [[f64; 3]; 3],
/// −log(weight) + ½·log(det Σ) (the 2π term is dropped: it cancels between models).
pub log_norm: f64,
}
/// A colour model.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Gmm {
pub comps: Vec<Gauss>,
}
/// Regularisation added to every covariance's diagonal (colour noise floor).
const REG: f64 = 2e-4;
fn det3(m: &[[f64; 3]; 3]) -> f64 {
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
}
fn inv3(m: &[[f64; 3]; 3]) -> Option<[[f64; 3]; 3]> {
let d = det3(m);
if d.abs() < 1e-30 {
return None;
}
let mut r = [[0.0; 3]; 3];
r[0][0] = (m[1][1] * m[2][2] - m[1][2] * m[2][1]) / d;
r[0][1] = (m[0][2] * m[2][1] - m[0][1] * m[2][2]) / d;
r[0][2] = (m[0][1] * m[1][2] - m[0][2] * m[1][1]) / d;
r[1][0] = (m[1][2] * m[2][0] - m[1][0] * m[2][2]) / d;
r[1][1] = (m[0][0] * m[2][2] - m[0][2] * m[2][0]) / d;
r[1][2] = (m[0][2] * m[1][0] - m[0][0] * m[1][2]) / d;
r[2][0] = (m[1][0] * m[2][1] - m[1][1] * m[2][0]) / d;
r[2][1] = (m[0][1] * m[2][0] - m[0][0] * m[2][1]) / d;
r[2][2] = (m[0][0] * m[1][1] - m[0][1] * m[1][0]) / d;
Some(r)
}
/// Sufficient statistics of a cluster.
#[derive(Clone, Copy, Default)]
struct Stats {
n: f64,
s: [f64; 3],
ss: [[f64; 3]; 3],
}
impl Stats {
fn add(&mut self, c: [f32; 3]) {
let c = [c[0] as f64, c[1] as f64, c[2] as f64];
self.n += 1.0;
for i in 0..3 {
self.s[i] += c[i];
for j in 0..3 {
self.ss[i][j] += c[i] * c[j];
}
}
}
fn mean(&self) -> [f64; 3] {
let n = self.n.max(1.0);
[self.s[0] / n, self.s[1] / n, self.s[2] / n]
}
fn cov(&self) -> [[f64; 3]; 3] {
let n = self.n.max(1.0);
let m = self.mean();
let mut c = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
c[i][j] = self.ss[i][j] / n - m[i] * m[j];
}
c[i][i] += REG;
}
c
}
}
/// Principal eigenvector and eigenvalue of a symmetric 3 × 3 matrix (power iteration).
fn principal(c: &[[f64; 3]; 3]) -> ([f64; 3], f64) {
let mut v = [0.577, 0.577, 0.577];
let mut lambda = 0.0;
for _ in 0..40 {
let w = [
c[0][0] * v[0] + c[0][1] * v[1] + c[0][2] * v[2],
c[1][0] * v[0] + c[1][1] * v[1] + c[1][2] * v[2],
c[2][0] * v[0] + c[2][1] * v[1] + c[2][2] * v[2],
];
let n = (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt();
if n < 1e-30 {
break;
}
lambda = n;
v = [w[0] / n, w[1] / n, w[2] / n];
}
(v, lambda)
}
impl Gmm {
/// Fit `k` components to `samples` (at most `max_samples` are used, taken at a fixed stride).
pub fn fit(samples: &[[f32; 3]], k: usize, max_samples: usize) -> Gmm {
if samples.is_empty() {
return Gmm::default();
}
let stride = samples.len().div_ceil(max_samples.max(1)).max(1);
let xs: Vec<[f32; 3]> = samples.iter().step_by(stride).copied().collect();
// Orchard–Bouman splitting.
let mut label = vec![0usize; xs.len()];
let mut ncl = 1;
while ncl < k {
let mut st = vec![Stats::default(); ncl];
for (x, l) in xs.iter().zip(&label) {
st[*l].add(*x);
}
let (mut best, mut best_l, mut axis) = (usize::MAX, 0.0, [0.0; 3]);
for (c, s) in st.iter().enumerate() {
if s.n < 2.0 {
continue;
}
let (v, l) = principal(&s.cov());
if l > best_l {
best = c;
best_l = l;
axis = v;
}
}
if best == usize::MAX || best_l <= REG * 1.01 {
break;
}
let m = st[best].mean();
let thr = axis[0] * m[0] + axis[1] * m[1] + axis[2] * m[2];
for (x, l) in xs.iter().zip(label.iter_mut()) {
if *l == best && axis[0] * x[0] as f64 + axis[1] * x[1] as f64 + axis[2] * x[2] as f64 > thr {
*l = ncl;
}
}
ncl += 1;
}
let mut g = Gmm::from_labels(&xs, &label, ncl);
// Hard EM.
for _ in 0..3 {
for (x, l) in xs.iter().zip(label.iter_mut()) {
*l = g.best(*x);
}
let ng = Gmm::from_labels(&xs, &label, g.comps.len());
if ng.comps.is_empty() {
break;
}
g = ng;
}
g
}
fn from_labels(xs: &[[f32; 3]], label: &[usize], k: usize) -> Gmm {
let mut st = vec![Stats::default(); k];
for (x, l) in xs.iter().zip(label) {
st[*l].add(*x);
}
let total: f64 = st.iter().map(|s| s.n).sum::<f64>().max(1.0);
let comps = st
.iter()
.filter(|s| s.n >= 1.0)
.filter_map(|s| {
let c = s.cov();
let inv = inv3(&c)?;
let det = det3(&c).max(1e-300);
let weight = s.n / total;
Some(Gauss { weight, mean: s.mean(), inv, log_norm: -weight.ln() + 0.5 * det.ln() })
})
.collect();
Gmm { comps }
}
#[inline]
fn comp_cost(g: &Gauss, c: [f32; 3]) -> f64 {
let d = [c[0] as f64 - g.mean[0], c[1] as f64 - g.mean[1], c[2] as f64 - g.mean[2]];
let mut q = 0.0;
for i in 0..3 {
q += d[i] * (g.inv[i][0] * d[0] + g.inv[i][1] * d[1] + g.inv[i][2] * d[2]);
}
g.log_norm + 0.5 * q
}
/// The most likely component of colour `c`.
pub fn best(&self, c: [f32; 3]) -> usize {
let mut b = 0;
let mut bv = f64::INFINITY;
for (i, g) in self.comps.iter().enumerate() {
let v = Self::comp_cost(g, c);
if v < bv {
bv = v;
b = i;
}
}
b
}
/// −log p(c) (up to a constant shared by all models), clamped to a finite range.
pub fn cost(&self, c: [f32; 3]) -> f64 {
if self.comps.is_empty() {
return 10.0;
}
// log-sum-exp over components.
let mut m = f64::INFINITY;
let mut costs = [0.0f64; 16];
let n = self.comps.len().min(16);
for (i, g) in self.comps.iter().take(n).enumerate() {
costs[i] = Self::comp_cost(g, c);
m = m.min(costs[i]);
}
let s: f64 = costs[..n].iter().map(|v| (m - v).exp()).sum();
(m - s.ln()).clamp(-20.0, 60.0)
}
pub fn is_empty(&self) -> bool {
self.comps.is_empty()
}
}

View File

@ -0,0 +1,465 @@
//! Matte utilities: exact Euclidean distance transforms (Felzenszwalb & Huttenlocher,
//! "Distance Transforms of Sampled Functions", Theory of Computing 8, 2012), box and Gaussian
//! filters, the colour **guided filter** (K. He, J. Sun and X. Tang, "Guided Image Filtering",
//! ECCV 2010 / IEEE PAMI 2013) used for edge matting, foreground colour estimation
//! (decontamination, from the compositing equation I = αF + (1 − α)B with B and F estimated from
//! nearby pure background / foreground pixels), and the Refine Edge matte adjustments.
use rayon::prelude::*;
const BIG: f32 = 1e12;
/// 1-D squared distance transform of `f` (lower envelope of parabolas).
fn dt1(f: &[f32], d: &mut [f32], v: &mut [usize], z: &mut [f64]) {
let n = f.len();
if n == 0 {
return;
}
let ff = |q: usize| f[q] as f64 + (q * q) as f64;
let mut k = 0usize;
v[0] = 0;
z[0] = f64::NEG_INFINITY;
z[1] = f64::INFINITY;
for q in 1..n {
let mut s = (ff(q) - ff(v[k])) / (2.0 * (q as f64 - v[k] as f64));
while s <= z[k] {
if k == 0 {
break;
}
k -= 1;
s = (ff(q) - ff(v[k])) / (2.0 * (q as f64 - v[k] as f64));
}
k += 1;
v[k] = q;
z[k] = s;
z[k + 1] = f64::INFINITY;
}
k = 0;
for (q, dq) in d.iter_mut().enumerate().take(n) {
while z[k + 1] < q as f64 {
k += 1;
}
let p = v[k];
let dd = q as f64 - p as f64;
*dq = (dd * dd + f[p] as f64) as f32;
}
}
/// Euclidean distance from every pixel to the nearest pixel where `seed` is true (BIG.sqrt()
/// when there is none).
pub fn distance(seed: &[bool], w: usize, h: usize) -> Vec<f32> {
if w == 0 || h == 0 {
return vec![];
}
let mut g: Vec<f32> = seed.iter().map(|s| if *s { 0.0 } else { BIG }).collect();
// Columns.
let cols: Vec<Vec<f32>> = (0..w)
.into_par_iter()
.map(|x| {
let f: Vec<f32> = (0..h).map(|y| g[y * w + x]).collect();
let mut d = vec![0.0; h];
let (mut v, mut z) = (vec![0usize; h], vec![0.0f64; h + 1]);
dt1(&f, &mut d, &mut v, &mut z);
d
})
.collect();
for (x, c) in cols.iter().enumerate() {
for y in 0..h {
g[y * w + x] = c[y];
}
}
// Rows.
g.par_chunks_mut(w).for_each(|row| {
let f = row.to_vec();
let (mut v, mut z) = (vec![0usize; w], vec![0.0f64; w + 1]);
dt1(&f, row, &mut v, &mut z);
});
g.par_iter_mut().for_each(|v| *v = v.sqrt());
g
}
/// Signed distance to a binary matte's edge: positive inside (distance to the nearest
/// background pixel centre, minus ½), negative outside.
pub fn signed_distance(m: &[u8], w: usize, h: usize) -> Vec<f32> {
let fg: Vec<bool> = m.iter().map(|v| *v != 0).collect();
let bg: Vec<bool> = m.iter().map(|v| *v == 0).collect();
let din = distance(&bg, w, h);
let dout = distance(&fg, w, h);
m.iter().enumerate().map(|(i, v)| if *v != 0 { din[i] - 0.5 } else { 0.5 - dout[i] }).collect()
}
/// Mean over the (2r+1)² window (clamped at the edges: averages only the pixels inside).
pub fn box_mean(src: &[f32], w: usize, h: usize, r: usize) -> Vec<f32> {
if w == 0 || h == 0 {
return vec![];
}
// Horizontal window sums per row.
let mut tmp = vec![0.0f64; w * h];
tmp.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
let s = &src[y * w..y * w + w];
let mut acc = 0.0f64;
for x in 0..r.min(w) {
acc += s[x] as f64;
}
for (x, o) in row.iter_mut().enumerate() {
if x + r < w {
acc += s[x + r] as f64;
}
if x > r {
acc -= s[x - r - 1] as f64;
}
*o = acc;
}
});
// Vertical prefix sums (row by row, cache friendly), then window differences.
let mut pre = vec![0.0f64; w * (h + 1)];
for y in 0..h {
let (done, rest) = pre.split_at_mut((y + 1) * w);
let prev = &done[y * w..];
let cur = &mut rest[..w];
let t = &tmp[y * w..y * w + w];
for x in 0..w {
cur[x] = prev[x] + t[x];
}
}
let mut out = vec![0.0f32; w * h];
out.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
let a = y.saturating_sub(r);
let b = (y + r + 1).min(h);
let cy = (b - a) as f64;
for (x, o) in row.iter_mut().enumerate() {
let cx = ((x + r + 1).min(w) - x.saturating_sub(r)) as f64;
*o = ((pre[b * w + x] - pre[a * w + x]) / (cx * cy)) as f32;
}
});
out
}
/// Approximate Gaussian blur (three box passes) with standard deviation `sigma` pixels.
pub fn gauss(src: &[f32], w: usize, h: usize, sigma: f64) -> Vec<f32> {
if sigma < 0.3 {
return src.to_vec();
}
// Box radius whose three passes match the variance: 3·((2r+1)² − 1)/12 = σ².
let r = (((4.0 * sigma * sigma + 1.0).sqrt() - 1.0) / 2.0).round().max(1.0) as usize;
let a = box_mean(src, w, h, r);
let b = box_mean(&a, w, h, r);
box_mean(&b, w, h, r)
}
fn inv3(m: [[f32; 3]; 3]) -> [[f32; 3]; 3] {
let d = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
let d = if d.abs() < 1e-20 { 1e-20 } else { d };
[
[(m[1][1] * m[2][2] - m[1][2] * m[2][1]) / d, (m[0][2] * m[2][1] - m[0][1] * m[2][2]) / d, (m[0][1] * m[1][2] - m[0][2] * m[1][1]) / d],
[(m[1][2] * m[2][0] - m[1][0] * m[2][2]) / d, (m[0][0] * m[2][2] - m[0][2] * m[2][0]) / d, (m[0][2] * m[1][0] - m[0][0] * m[1][2]) / d],
[(m[1][0] * m[2][1] - m[1][1] * m[2][0]) / d, (m[0][1] * m[2][0] - m[0][0] * m[2][1]) / d, (m[0][0] * m[1][1] - m[0][1] * m[1][0]) / d],
]
}
/// The colour guided filter of `p` with guide `img` (RGB, w × h), window radius `r`,
/// regularisation `eps` (He et al. 2010, section 3.5).
pub fn guided_filter(img: &[[f32; 3]], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec<f32> {
let n = w * h;
let ch = |c: usize| -> Vec<f32> { img.iter().map(|v| v[c]).collect() };
let (ir, ig, ib) = (ch(0), ch(1), ch(2));
let mul = |a: &[f32], b: &[f32]| -> Vec<f32> { a.iter().zip(b).map(|(x, y)| x * y).collect() };
let bm = |v: &[f32]| box_mean(v, w, h, r);
let (mr, mg, mb, mp) = (bm(&ir), bm(&ig), bm(&ib), bm(p));
let (mrp, mgp, mbp) = (bm(&mul(&ir, p)), bm(&mul(&ig, p)), bm(&mul(&ib, p)));
let (vrr, vrg, vrb) = (bm(&mul(&ir, &ir)), bm(&mul(&ir, &ig)), bm(&mul(&ir, &ib)));
let (vgg, vgb, vbb) = (bm(&mul(&ig, &ig)), bm(&mul(&ig, &ib)), bm(&mul(&ib, &ib)));
let mut a = vec![[0.0f32; 3]; n];
let mut b = vec![0.0f32; n];
a.par_iter_mut().zip(b.par_iter_mut()).enumerate().for_each(|(i, (ai, bi))| {
let cov = [mrp[i] - mr[i] * mp[i], mgp[i] - mg[i] * mp[i], mbp[i] - mb[i] * mp[i]];
let s = [
[vrr[i] - mr[i] * mr[i] + eps, vrg[i] - mr[i] * mg[i], vrb[i] - mr[i] * mb[i]],
[vrg[i] - mr[i] * mg[i], vgg[i] - mg[i] * mg[i] + eps, vgb[i] - mg[i] * mb[i]],
[vrb[i] - mr[i] * mb[i], vgb[i] - mg[i] * mb[i], vbb[i] - mb[i] * mb[i] + eps],
];
let inv = inv3(s);
let av = [
cov[0] * inv[0][0] + cov[1] * inv[1][0] + cov[2] * inv[2][0],
cov[0] * inv[0][1] + cov[1] * inv[1][1] + cov[2] * inv[2][1],
cov[0] * inv[0][2] + cov[1] * inv[1][2] + cov[2] * inv[2][2],
];
*ai = av;
*bi = mp[i] - av[0] * mr[i] - av[1] * mg[i] - av[2] * mb[i];
});
let (a0, a1, a2): (Vec<f32>, Vec<f32>, Vec<f32>) = (a.iter().map(|v| v[0]).collect(), a.iter().map(|v| v[1]).collect(), a.iter().map(|v| v[2]).collect());
let (ma0, ma1, ma2, mbb) = (bm(&a0), bm(&a1), bm(&a2), bm(&b));
(0..n).into_par_iter().map(|i| ma0[i] * ir[i] + ma1[i] * ig[i] + ma2[i] * ib[i] + mbb[i]).collect()
}
/// Closed-form matting (A. Levin, D. Lischinski and Y. Weiss, "A Closed-Form Solution to Natural
/// Image Matting", IEEE PAMI 30(2), 2008): the alpha that minimises αᵀLα with the matting
/// Laplacian `L` of the colour image, with `known` pixels fixed to `init`. Solved by conjugate
/// gradients over the unknown pixels, starting from `init`; `L·x` is evaluated with box filters
/// (K. He, J. Sun and X. Tang, "Fast Matting Using Large Kernel Matting Laplacian Matrices",
/// CVPR 2010): `(L·x)ᵢ = Σ_{k∋i} (xᵢ − a_kᵀIᵢ − b_k)` with `(a_k, b_k)` the ridge-regression
/// fit of `x` to the colours in window k.
pub fn closed_form(img: &[[f32; 3]], init: &[f32], known: &[bool], w: usize, h: usize, r: usize, eps: f32, iters: usize) -> Vec<f32> {
let n = w * h;
if n == 0 || known.iter().all(|k| *k) {
return init.to_vec();
}
let ch = |c: usize| -> Vec<f32> { img.iter().map(|v| v[c]).collect() };
let ic = [ch(0), ch(1), ch(2)];
let bm = |v: &[f32]| box_mean(v, w, h, r);
let mu = [bm(&ic[0]), bm(&ic[1]), bm(&ic[2])];
let prod = |a: usize, b: usize| -> Vec<f32> { ic[a].iter().zip(&ic[b]).map(|(x, y)| x * y).collect() };
let mut cov = [[vec![], vec![], vec![]], [vec![], vec![], vec![]], [vec![], vec![], vec![]]];
for a in 0..3 {
for b in a..3 {
let m = bm(&prod(a, b));
cov[a][b] = m.clone();
cov[b][a] = m;
}
}
let inv: Vec<[[f32; 3]; 3]> = (0..n)
.into_par_iter()
.map(|i| {
let mut s = [[0.0f32; 3]; 3];
for a in 0..3 {
for b in 0..3 {
s[a][b] = cov[a][b][i] - mu[a][i] * mu[b][i] + if a == b { eps } else { 0.0 };
}
}
inv3(s)
})
.collect();
// Pixels per window (= windows per pixel).
let cnt: Vec<f32> = {
let (rr, mut out) = (r as i64, vec![0.0f32; n]);
for (i, o) in out.iter_mut().enumerate() {
let (x, y) = ((i % w) as i64, (i / w) as i64);
let cx = (x + rr).min(w as i64 - 1) - (x - rr).max(0) + 1;
let cy = (y + rr).min(h as i64 - 1) - (y - rr).max(0) + 1;
*o = (cx * cy) as f32;
}
out
};
let lap = |x: &[f32]| -> Vec<f32> {
let pm = bm(x);
let ipm: Vec<Vec<f32>> = (0..3).map(|c| bm(&ic[c].iter().zip(x).map(|(a, b)| a * b).collect::<Vec<_>>())).collect();
let ab: Vec<[f32; 4]> = (0..n)
.into_par_iter()
.map(|i| {
let cv = [ipm[0][i] - mu[0][i] * pm[i], ipm[1][i] - mu[1][i] * pm[i], ipm[2][i] - mu[2][i] * pm[i]];
let m = &inv[i];
let a = [
m[0][0] * cv[0] + m[0][1] * cv[1] + m[0][2] * cv[2],
m[1][0] * cv[0] + m[1][1] * cv[1] + m[1][2] * cv[2],
m[2][0] * cv[0] + m[2][1] * cv[1] + m[2][2] * cv[2],
];
[a[0], a[1], a[2], pm[i] - a[0] * mu[0][i] - a[1] * mu[1][i] - a[2] * mu[2][i]]
})
.collect();
let sa: Vec<Vec<f32>> = (0..3).map(|c| bm(&ab.iter().map(|v| v[c]).collect::<Vec<_>>())).collect();
let sb = bm(&ab.iter().map(|v| v[3]).collect::<Vec<_>>());
(0..n).into_par_iter().map(|i| cnt[i] * (x[i] - sa[0][i] * ic[0][i] - sa[1][i] * ic[1][i] - sa[2][i] * ic[2][i] - sb[i])).collect()
};
// L_UU x = −(L α_K)_U.
let a0: Vec<f32> = (0..n).map(|i| if known[i] { init[i] } else { 0.0 }).collect();
let la0 = lap(&a0);
let mut x: Vec<f32> = (0..n).map(|i| if known[i] { 0.0 } else { init[i] }).collect();
let lx = lap(&x);
let mut res: Vec<f64> = (0..n).map(|i| if known[i] { 0.0 } else { (-la0[i] - lx[i]) as f64 }).collect();
let mut p = res.clone();
let mut rs: f64 = res.iter().map(|v| v * v).sum();
let rs0 = rs.max(1e-30);
for _ in 0..iters {
if rs <= rs0 * 1e-8 {
break;
}
let pf: Vec<f32> = p.iter().map(|v| *v as f32).collect();
let ap: Vec<f64> = lap(&pf).iter().enumerate().map(|(i, v)| if known[i] { 0.0 } else { *v as f64 }).collect();
let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
if pap <= 1e-30 {
break;
}
let alpha = rs / pap;
for i in 0..n {
x[i] += (alpha * p[i]) as f32;
res[i] -= alpha * ap[i];
}
let rs_new: f64 = res.iter().map(|v| v * v).sum();
let beta = rs_new / rs;
rs = rs_new;
for i in 0..n {
p[i] = res[i] + beta * p[i];
}
}
(0..n).map(|i| if known[i] { init[i] } else { x[i].clamp(0.0, 1.0) }).collect()
}
/// Contrast (0–100 %) around ½ on a soft alpha.
pub fn contrast(a: &mut [f32], pc: f64) {
if pc <= 0.0 {
return;
}
let k = 1.0 / (1.0 - 0.99 * (pc / 100.0).clamp(0.0, 1.0)) as f32;
a.par_iter_mut().for_each(|v| *v = (0.5 + (*v - 0.5) * k).clamp(0.0, 1.0));
}
/// Shift Edge (−100–100 %) on a soft alpha: positive values grow the matte (a gamma curve on α).
pub fn shift_soft(a: &mut [f32], pc: f64) {
if pc.abs() < 1e-9 {
return;
}
let g = 2f32.powf((-pc / 50.0) as f32);
a.par_iter_mut().for_each(|v| *v = v.clamp(0.0, 1.0).powf(g));
}
/// Estimate the pure foreground colour of every pixel listed in `region` (alpha strictly
/// between 0 and 1) and mix it in by `amount` (0–1). `radius`: neighbourhood (pixels) that pure
/// background / foreground estimates are taken from. Returns the decontamination map (how much
/// each pixel's colour changed, 0–1).
pub fn decontaminate(img: &mut [[f32; 3]], alpha: &[f32], region: &[bool], w: usize, h: usize, radius: usize, amount: f32) -> Vec<f32> {
let n = w * h;
let mut map = vec![0.0f32; n];
if !region.iter().any(|r| *r) || amount <= 0.0 {
return map;
}
let lo: Vec<f32> = alpha.iter().map(|a| if *a < 0.02 { 1.0 } else { 0.0 }).collect();
let hi: Vec<f32> = alpha.iter().map(|a| if *a > 0.98 { 1.0 } else { 0.0 }).collect();
// Windowed means of pure background / foreground colours, widening where none is near.
let est = |m: &[f32]| -> Vec<Option<[f32; 3]>> {
let mut out: Vec<Option<[f32; 3]>> = vec![None; n];
let mut r = radius.max(2);
for _ in 0..4 {
let cnt = box_mean(m, w, h, r);
let sums: Vec<Vec<f32>> = (0..3).map(|c| box_mean(&img.iter().zip(m).map(|(p, k)| p[c] * k).collect::<Vec<_>>(), w, h, r)).collect();
let mut missing = false;
for i in 0..n {
if region[i] && out[i].is_none() {
if cnt[i] > 1e-4 {
out[i] = Some([sums[0][i] / cnt[i], sums[1][i] / cnt[i], sums[2][i] / cnt[i]]);
} else {
missing = true;
}
}
}
if !missing {
break;
}
r *= 2;
}
out
};
let bgc = est(&lo);
let fgc = est(&hi);
for i in 0..n {
if !region[i] {
continue;
}
let a = alpha[i].clamp(0.0, 1.0);
if a <= 0.0 || a >= 1.0 {
continue;
}
let c = img[i];
let f = match (bgc[i], fgc[i]) {
(Some(b), fbar) => {
let mut f = [0.0f32; 3];
for k in 0..3 {
f[k] = ((c[k] - (1.0 - a) * b[k]) / a).clamp(0.0, 1.0);
}
// Low alphas amplify noise: lean on the nearby foreground colour there.
if let Some(fb) = fbar {
let t = ((0.25 - a) / 0.25).clamp(0.0, 1.0);
for k in 0..3 {
f[k] += (fb[k] - f[k]) * t;
}
}
f
}
(None, Some(fb)) => fb,
(None, None) => c,
};
let mut d = 0.0f32;
for k in 0..3 {
let v = c[k] + (f[k] - c[k]) * amount;
d = d.max((v - c[k]).abs());
img[i][k] = v;
}
map[i] = d.min(1.0);
}
map
}
/// Sample a float plane bilinearly at continuous coordinates (pixel centres at +0.5), clamped.
pub fn sample(p: &[f32], w: usize, h: usize, x: f64, y: f64) -> f32 {
if w == 0 || h == 0 {
return 0.0;
}
let fx = (x - 0.5).clamp(0.0, (w - 1) as f64);
let fy = (y - 0.5).clamp(0.0, (h - 1) as f64);
let (x0, y0) = (fx.floor() as usize, fy.floor() as usize);
let (x1, y1) = ((x0 + 1).min(w - 1), (y0 + 1).min(h - 1));
let (tx, ty) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32);
let g = |x: usize, y: usize| p[y * w + x];
let top = g(x0, y0) + (g(x1, y0) - g(x0, y0)) * tx;
let bot = g(x0, y1) + (g(x1, y1) - g(x0, y1)) * tx;
top + (bot - top) * ty
}
/// Average of `a` sampled along the segment `[-v/2, v/2]` with `samples` taps (motion blur of a
/// matte moving by `v` pixels during the shutter).
pub fn motion_blur(a: &[f32], w: usize, h: usize, v: [f64; 2], samples: usize) -> Vec<f32> {
let n = samples.max(2);
if v[0].hypot(v[1]) < 0.25 {
return a.to_vec();
}
(0..w * h)
.into_par_iter()
.map(|i| {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
let mut s = 0.0;
for k in 0..n {
let t = k as f64 / (n - 1) as f64 - 0.5;
s += sample(a, w, h, x - v[0] * t, y - v[1] * t);
}
s / n as f32
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn distance_matches_brute_force() {
let (w, h) = (23, 17);
let seed: Vec<bool> = (0..w * h).map(|i| (i * 7919) % 31 == 0).collect();
let d = distance(&seed, w, h);
for y in 0..h {
for x in 0..w {
let mut b = f32::INFINITY;
for j in 0..h {
for i in 0..w {
if seed[j * w + i] {
b = b.min(((x as f32 - i as f32).powi(2) + (y as f32 - j as f32).powi(2)).sqrt());
}
}
}
assert!((d[y * w + x] - b).abs() < 1e-3, "{x},{y}: {} vs {b}", d[y * w + x]);
}
}
}
#[test]
fn guided_filter_recovers_a_linear_blend() {
// I = αF + (1 − α)B with constant F, B: the filtered binary matte is close to α.
let (w, h) = (64, 8);
let alpha: Vec<f32> = (0..w * h).map(|i| (((i % w) as f32 - 26.0) / 12.0).clamp(0.0, 1.0)).collect();
let (f, b) = ([0.9f32, 0.2, 0.1], [0.1f32, 0.3, 0.8]);
let img: Vec<[f32; 3]> = alpha.iter().map(|a| [a * f[0] + (1.0 - a) * b[0], a * f[1] + (1.0 - a) * b[1], a * f[2] + (1.0 - a) * b[2]]).collect();
let p: Vec<f32> = alpha.iter().map(|a| if *a >= 0.5 { 1.0 } else { 0.0 }).collect();
let q = guided_filter(&img, &p, w, h, 8, 1e-5);
let err: f32 = q.iter().zip(&alpha).map(|(a, b)| (a.clamp(0.0, 1.0) - b).abs()).sum::<f32>() / (w * h) as f32;
assert!(err < 0.03, "mean error {err}");
}
}

View File

@ -0,0 +1,438 @@
//! Min-cut / max-flow on sparse graphs with source and sink terminals, by the
//! Boykov–Kolmogorov algorithm (Y. Boykov and V. Kolmogorov, "An Experimental Comparison of
//! Min-Cut/Max-Flow Algorithms for Energy Minimization in Vision", IEEE PAMI 26(9), 2004).
//!
//! Two search trees grow from the terminals (S from the source, T from the sink) through
//! non-saturated edges; when they touch, the path found is augmented; saturated tree edges turn
//! the nodes below them into *orphans*, which the adoption stage re-attaches to their tree (or
//! frees). Trees are reused between augmentations, which is what makes the method fast on the
//! grid graphs of image segmentation. The timestamp/distance heuristic of section 3.2.3 keeps
//! adoption paths short.
//!
//! Capacities are integers (callers scale their energies), so saturation tests are exact and
//! the result is deterministic.
use std::collections::VecDeque;
const NONE: u32 = u32::MAX;
const TERMINAL: u32 = u32::MAX - 1;
const ORPHAN: u32 = u32::MAX - 2;
#[derive(Clone, Copy)]
struct Node {
first: u32,
/// Arc to the parent (from this node), or TERMINAL / ORPHAN / NONE (free).
parent: u32,
/// Residual terminal capacity: > 0 towards the source, < 0 towards the sink.
tr_cap: i64,
is_sink: bool,
active: bool,
ts: u32,
dist: u32,
}
#[derive(Clone, Copy)]
struct Arc {
head: u32,
next: u32,
r_cap: i32,
}
/// A graph for one min-cut computation.
pub struct Graph {
nodes: Vec<Node>,
arcs: Vec<Arc>,
flow: i64,
time: u32,
queue: VecDeque<u32>,
orphans: VecDeque<u32>,
}
/// Which side of the cut a node ended on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Segment {
Source,
Sink,
}
impl Graph {
pub fn new(nodes: usize, edges_hint: usize) -> Graph {
Graph {
nodes: vec![Node { first: NONE, parent: NONE, tr_cap: 0, is_sink: false, active: false, ts: 0, dist: 0 }; nodes],
arcs: Vec::with_capacity(edges_hint * 2),
flow: 0,
time: 0,
queue: VecDeque::new(),
orphans: VecDeque::new(),
}
}
pub fn len(&self) -> usize {
self.nodes.len()
}
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
/// Terminal capacities of node `i`: `source` is paid when `i` ends on the sink side, `sink`
/// when it ends on the source side.
pub fn add_tweights(&mut self, i: usize, source: i64, sink: i64) {
let n = &mut self.nodes[i];
let d = n.tr_cap;
let (s, t) = if d > 0 { (source + d, sink) } else { (source, sink - d) };
self.flow += s.min(t);
n.tr_cap = s - t;
}
/// An edge between `i` and `j` with capacity `cap` from i to j and `rev` from j to i.
pub fn add_edge(&mut self, i: usize, j: usize, cap: i32, rev: i32) {
debug_assert!(i != j);
let a = self.arcs.len() as u32;
self.arcs.push(Arc { head: j as u32, next: self.nodes[i].first, r_cap: cap });
self.nodes[i].first = a;
self.arcs.push(Arc { head: i as u32, next: self.nodes[j].first, r_cap: rev });
self.nodes[j].first = a + 1;
}
#[inline]
fn set_active(&mut self, i: u32) {
let n = &mut self.nodes[i as usize];
if !n.active {
n.active = true;
self.queue.push_back(i);
}
}
/// Compute the maximum flow (= the minimum cut's cost).
pub fn maxflow(&mut self) -> i64 {
for i in 0..self.nodes.len() {
let n = &mut self.nodes[i];
if n.tr_cap != 0 {
n.is_sink = n.tr_cap < 0;
n.parent = TERMINAL;
n.ts = 0;
n.dist = 1;
self.set_active(i as u32);
}
}
let mut current: Option<u32> = None;
loop {
let i = match current.take() {
Some(i) if self.nodes[i as usize].parent != NONE => i,
_ => loop {
let Some(i) = self.queue.pop_front() else { return self.flow };
self.nodes[i as usize].active = false;
if self.nodes[i as usize].parent != NONE {
break i;
}
},
};
// Growth.
let mut found = NONE;
let ni = self.nodes[i as usize];
let mut a = ni.first;
if !ni.is_sink {
while a != NONE {
let arc = self.arcs[a as usize];
if arc.r_cap > 0 {
let j = arc.head as usize;
let nj = self.nodes[j];
if nj.parent == NONE {
let n = &mut self.nodes[j];
n.is_sink = false;
n.parent = a ^ 1;
n.ts = ni.ts;
n.dist = ni.dist + 1;
self.set_active(j as u32);
} else if nj.is_sink {
found = a;
break;
} else if nj.ts <= ni.ts && nj.dist > ni.dist {
let n = &mut self.nodes[j];
n.parent = a ^ 1;
n.ts = ni.ts;
n.dist = ni.dist + 1;
}
}
a = arc.next;
}
} else {
while a != NONE {
let arc = self.arcs[a as usize];
if self.arcs[(a ^ 1) as usize].r_cap > 0 {
let j = arc.head as usize;
let nj = self.nodes[j];
if nj.parent == NONE {
let n = &mut self.nodes[j];
n.is_sink = true;
n.parent = a ^ 1;
n.ts = ni.ts;
n.dist = ni.dist + 1;
self.set_active(j as u32);
} else if !nj.is_sink {
found = a ^ 1;
break;
} else if nj.ts <= ni.ts && nj.dist > ni.dist {
let n = &mut self.nodes[j];
n.parent = a ^ 1;
n.ts = ni.ts;
n.dist = ni.dist + 1;
}
}
a = arc.next;
}
}
self.time = self.time.wrapping_add(1);
if found != NONE {
// Keep `i` current: it may have more paths.
current = Some(i);
self.augment(found);
self.adopt();
}
}
}
fn augment(&mut self, middle: u32) {
let mut b = self.arcs[middle as usize].r_cap as i64;
// Source side.
let mut i = self.arcs[(middle ^ 1) as usize].head;
loop {
let a = self.nodes[i as usize].parent;
if a == TERMINAL {
break;
}
b = b.min(self.arcs[(a ^ 1) as usize].r_cap as i64);
i = self.arcs[a as usize].head;
}
b = b.min(self.nodes[i as usize].tr_cap);
// Sink side.
let mut i = self.arcs[middle as usize].head;
loop {
let a = self.nodes[i as usize].parent;
if a == TERMINAL {
break;
}
b = b.min(self.arcs[a as usize].r_cap as i64);
i = self.arcs[a as usize].head;
}
b = b.min(-self.nodes[i as usize].tr_cap);
let bi = b as i32;
self.arcs[(middle ^ 1) as usize].r_cap += bi;
self.arcs[middle as usize].r_cap -= bi;
// Source side.
let mut i = self.arcs[(middle ^ 1) as usize].head;
loop {
let a = self.nodes[i as usize].parent;
if a == TERMINAL {
break;
}
self.arcs[a as usize].r_cap += bi;
self.arcs[(a ^ 1) as usize].r_cap -= bi;
if self.arcs[(a ^ 1) as usize].r_cap == 0 {
self.make_orphan(i);
}
i = self.arcs[a as usize].head;
}
self.nodes[i as usize].tr_cap -= b;
if self.nodes[i as usize].tr_cap == 0 {
self.make_orphan(i);
}
// Sink side.
let mut i = self.arcs[middle as usize].head;
loop {
let a = self.nodes[i as usize].parent;
if a == TERMINAL {
break;
}
self.arcs[(a ^ 1) as usize].r_cap += bi;
self.arcs[a as usize].r_cap -= bi;
if self.arcs[a as usize].r_cap == 0 {
self.make_orphan(i);
}
i = self.arcs[a as usize].head;
}
self.nodes[i as usize].tr_cap += b;
if self.nodes[i as usize].tr_cap == 0 {
self.make_orphan(i);
}
self.flow += b;
}
fn make_orphan(&mut self, i: u32) {
self.nodes[i as usize].parent = ORPHAN;
self.orphans.push_back(i);
}
/// Whether node `j`'s path to its terminal is intact; returns its distance (marking the
/// path with the current time) or None.
fn origin_dist(&mut self, j: u32) -> Option<u32> {
let mut d = 0u32;
let mut k = j;
loop {
let n = self.nodes[k as usize];
if n.ts == self.time {
d += n.dist;
break;
}
let a = n.parent;
d += 1;
if a == TERMINAL {
let n = &mut self.nodes[k as usize];
n.ts = self.time;
n.dist = 1;
break;
}
if a == ORPHAN || a == NONE {
return None;
}
k = self.arcs[a as usize].head;
}
// Mark the path.
let total = d;
let mut k = j;
let mut dd = d;
while self.nodes[k as usize].ts != self.time {
let n = &mut self.nodes[k as usize];
n.ts = self.time;
n.dist = dd;
dd -= 1;
k = self.arcs[n.parent as usize].head;
}
Some(total)
}
fn adopt(&mut self) {
while let Some(i) = self.orphans.pop_front() {
let sink = self.nodes[i as usize].is_sink;
let mut best = NONE;
let mut best_d = u32::MAX;
let mut a = self.nodes[i as usize].first;
while a != NONE {
let arc = self.arcs[a as usize];
// S orphan: the parent must push flow into i (residual on j→i = sister of i→j).
// T orphan: i must push flow into the parent (residual on i→j).
let cap = if sink { arc.r_cap } else { self.arcs[(a ^ 1) as usize].r_cap };
if cap > 0 {
let j = arc.head;
let nj = self.nodes[j as usize];
if nj.is_sink == sink
&& nj.parent != NONE
&& let Some(d) = self.origin_dist(j)
&& d < best_d
{
best = a;
best_d = d;
}
}
a = arc.next;
}
if best != NONE {
let n = &mut self.nodes[i as usize];
n.parent = best;
n.ts = self.time;
n.dist = best_d + 1;
continue;
}
// No parent: free the node; its children become orphans, neighbours that could
// reach it become active.
self.nodes[i as usize].ts = 0;
let mut a = self.nodes[i as usize].first;
while a != NONE {
let arc = self.arcs[a as usize];
let j = arc.head;
let nj = self.nodes[j as usize];
if nj.is_sink == sink && nj.parent != NONE {
let cap = if sink { arc.r_cap } else { self.arcs[(a ^ 1) as usize].r_cap };
if cap > 0 {
self.set_active(j);
}
let pa = nj.parent;
if pa != TERMINAL && pa != ORPHAN && self.arcs[pa as usize].head == i {
self.make_orphan(j);
}
}
a = arc.next;
}
self.nodes[i as usize].parent = NONE;
}
}
/// The side of node `i` after [`Graph::maxflow`] (free nodes go to the sink).
pub fn segment(&self, i: usize) -> Segment {
let n = &self.nodes[i];
if n.parent != NONE && !n.is_sink { Segment::Source } else { Segment::Sink }
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Brute-force min cut over all labelings of a small graph.
fn brute(n: usize, t: &[(i64, i64)], e: &[(usize, usize, i32, i32)]) -> i64 {
let mut best = i64::MAX;
for m in 0..(1u32 << n) {
let src = |i: usize| m & (1 << i) != 0;
let mut c = 0i64;
for (i, (s, k)) in t.iter().enumerate() {
c += if src(i) { *k } else { *s };
}
for (i, j, a, b) in e {
if src(*i) && !src(*j) {
c += *a as i64;
}
if src(*j) && !src(*i) {
c += *b as i64;
}
}
best = best.min(c);
}
best
}
#[test]
fn matches_brute_force_on_random_graphs() {
let mut s = 12345u64;
let mut rnd = |m: u64| {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
s % m
};
for _ in 0..300 {
let n = 2 + rnd(9) as usize;
let t: Vec<(i64, i64)> = (0..n).map(|_| (rnd(20) as i64, rnd(20) as i64)).collect();
let mut e = vec![];
for _ in 0..rnd(20) {
let (i, j) = (rnd(n as u64) as usize, rnd(n as u64) as usize);
if i != j {
e.push((i, j, rnd(15) as i32, rnd(15) as i32));
}
}
let mut g = Graph::new(n, e.len());
for (i, (a, b)) in t.iter().enumerate() {
g.add_tweights(i, *a, *b);
}
for (i, j, a, b) in &e {
g.add_edge(*i, *j, *a, *b);
}
let f = g.maxflow();
assert_eq!(f, brute(n, &t, &e));
// The labeling realises the cut.
let src = |i: usize| g.segment(i) == Segment::Source;
let mut c = 0i64;
for (i, (a, b)) in t.iter().enumerate() {
c += if src(i) { *b } else { *a };
}
for (i, j, a, b) in &e {
if src(*i) && !src(*j) {
c += *a as i64;
}
if src(*j) && !src(*i) {
c += *b as i64;
}
}
assert_eq!(c, f);
}
}
}

View File

@ -0,0 +1,754 @@
//! Roto Brush segmentation and propagation, clean-room from published methods:
//!
//! - **Interactive graph-cut segmentation** (Y. Boykov and M.-P. Jolly, "Interactive Graph Cuts
//! for Optimal Boundary & Region Segmentation of Objects in N-D Images", ICCV 2001): the user's
//! foreground / background strokes are hard constraints, every other pixel gets a data cost
//! from colour models and neighbouring pixels a contrast-sensitive smoothness cost; the
//! minimum s–t cut ([`maxflow`], Boykov–Kolmogorov) is the segmentation.
//! - **Iterated colour models** (C. Rother, V. Kolmogorov and A. Blake, "GrabCut", SIGGRAPH
//! 2004): Gaussian mixtures ([`gmm`]) are fitted to the strokes, then re-fitted to the
//! segmentation and the cut repeated; the 8-connected n-link weight is
//! `γ/dist · exp(−β‖Iₚ − I_q‖²)` with `β = 1 / (2⟨‖Iₚ − I_q‖²⟩)`.
//! - **Coarse to fine**: large frames are segmented at a reduced size, then re-cut at full
//! resolution in a narrow band around the upsampled boundary (Lombaert et al., "A Multilevel
//! Banded Graph Cuts Method for Fast Image Segmentation", ICCV 2005).
//! - **Propagation** (in the spirit of X. Bai et al., "Video SnapCut", SIGGRAPH 2009): the
//! previous frame's matte is warped to the next frame by optical flow
//! (`effectcraft_raster::flow::block_flow`), and the frame re-segmented by graph cut only in a
//! band of **Search Radius** pixels around the warped boundary, with the previous frame's
//! colour models and the warped matte as a shape prior. Correction strokes on a frame re-cut
//! the whole frame with the warped matte as a soft prior, and propagation restarts from there.
//!
//! Mattes are in layer pixels at a given scale (1 = full resolution). Strokes are stored in
//! layer pixels ([`RotoData`], serde) and keyed per frame so that each frame's result is a
//! deterministic function of its *chain*: the base frame's strokes, then every frame's strokes
//! on the way out to it ([`RotoData::chain_keys`]).
pub mod gmm;
pub mod matting;
pub mod maxflow;
pub mod refine;
pub mod rle;
use std::collections::BTreeMap;
use effectcraft_raster::Image;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
pub use gmm::Gmm;
use maxflow::{Graph, Segment};
/// What a stroke paints.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum StrokeKind {
/// Roto Brush (green): foreground.
Fg,
/// Roto Brush with Alt (red): background.
Bg,
/// Refine Edge tool: mark a band for edge matting.
Refine,
/// Refine Edge tool with Alt: remove from the band.
RefineErase,
}
impl StrokeKind {
pub fn from_name(s: &str) -> Option<StrokeKind> {
Some(match s.to_ascii_lowercase().as_str() {
"fg" | "foreground" => StrokeKind::Fg,
"bg" | "background" => StrokeKind::Bg,
"refine" | "refineedge" => StrokeKind::Refine,
"refineerase" | "refine-erase" | "erase" => StrokeKind::RefineErase,
_ => return None,
})
}
pub fn is_segmentation(self) -> bool {
matches!(self, StrokeKind::Fg | StrokeKind::Bg)
}
}
/// One stroke: a polyline swept by a disc of `radius` layer pixels, on layer frame `frame`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Stroke {
pub kind: StrokeKind,
pub frame: i64,
pub radius: f64,
pub points: Vec<[f64; 2]>,
}
/// A Roto Brush instance's user data: strokes, the base frame and the segmentation span
/// (inclusive layer frames).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct RotoData {
pub base: Option<i64>,
pub span: [i64; 2],
pub strokes: Vec<Stroke>,
}
fn fnv(h: &mut u64, bytes: &[u8]) {
for b in bytes {
*h ^= *b as u64;
*h = h.wrapping_mul(0x0100_0000_01b3);
}
}
impl RotoData {
pub fn from_json(s: &str) -> RotoData {
if s.trim().is_empty() {
return RotoData::default();
}
serde_json::from_str(s).unwrap_or_default()
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_default()
}
pub fn is_empty(&self) -> bool {
self.base.is_none() || !self.strokes.iter().any(|s| s.kind.is_segmentation())
}
pub fn strokes_at(&self, frame: i64) -> Vec<&Stroke> {
self.strokes.iter().filter(|s| s.frame == frame).collect()
}
/// Frames that carry strokes.
pub fn stroke_frames(&self) -> Vec<i64> {
let mut v: Vec<i64> = self.strokes.iter().map(|s| s.frame).collect();
v.sort_unstable();
v.dedup();
v
}
pub fn in_span(&self, f: i64) -> bool {
self.base.is_some() && f >= self.span[0] && f <= self.span[1]
}
/// Add a stroke: the first segmentation stroke sets the base frame and a span of
/// `default_span` frames each side (clamped to `limits`); strokes outside the span extend it.
pub fn add(&mut self, s: Stroke, default_span: i64, limits: [i64; 2]) {
let f = s.frame;
match self.base {
None if s.kind.is_segmentation() => {
self.base = Some(f);
self.span = [(f - default_span).max(limits[0]), (f + default_span).min(limits[1]).max(f)];
}
Some(_) => {
self.span[0] = self.span[0].min(f);
self.span[1] = self.span[1].max(f);
}
None => {}
}
self.strokes.push(s);
}
/// Largest Refine Edge stroke radius (layer pixels).
pub fn refine_radius(&self) -> f64 {
self.strokes.iter().filter(|s| matches!(s.kind, StrokeKind::Refine)).map(|s| s.radius).fold(0.0, f64::max)
}
/// Content key of every frame in the span: the base frame's from `seed` (what the frames
/// and settings are) and its strokes, every other frame's from its neighbour towards the
/// base and its own strokes.
pub fn chain_keys(&self, seed: u64) -> BTreeMap<i64, u64> {
let mut out = BTreeMap::new();
let Some(base) = self.base else { return out };
let key = |prev: u64, f: i64| {
let mut h = prev ^ 0x9e37_79b9_7f4a_7c15;
fnv(&mut h, &f.to_le_bytes());
for s in self.strokes.iter().filter(|s| s.frame == f) {
fnv(&mut h, serde_json::to_string(s).unwrap_or_default().as_bytes());
}
h
};
let k0 = key(seed, base);
out.insert(base, k0);
let mut k = k0;
for f in base + 1..=self.span[1] {
k = key(k, f);
out.insert(f, k);
}
k = k0;
for f in (self.span[0]..base).rev() {
k = key(k, f);
out.insert(f, k);
}
out
}
/// The neighbour a frame is propagated from (None for the base frame and outside the span).
pub fn source_of(&self, f: i64) -> Option<i64> {
let base = self.base?;
if !self.in_span(f) || f == base {
return None;
}
Some(if f > base { f - 1 } else { f + 1 })
}
}
/// Segmentation settings (the effect's Roto Brush parameters, in frame pixels).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SegOpts {
/// Pixels of the matte per layer pixel.
pub scale: f64,
/// Search Radius (layer pixels).
pub search_radius: f64,
/// Motion Threshold / Motion Damping, percent.
pub motion_threshold: f64,
pub motion_damping: f64,
/// Quality ▸ Best.
pub best: bool,
}
impl Default for SegOpts {
fn default() -> Self {
SegOpts { scale: 1.0, search_radius: 15.0, motion_threshold: 10.0, motion_damping: 20.0, best: false }
}
}
/// One frame's segmentation: the binary matte, the Refine Edge band and the colour models.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FrameSeg {
pub w: usize,
pub h: usize,
pub matte: Vec<u8>,
pub refine: Vec<u8>,
pub fg: Gmm,
pub bg: Gmm,
}
impl FrameSeg {
pub fn empty(w: usize, h: usize) -> FrameSeg {
FrameSeg { w, h, matte: vec![0; w * h], refine: vec![0; w * h], ..Default::default() }
}
pub fn area(&self) -> usize {
self.matte.iter().filter(|v| **v != 0).count()
}
/// Centroid of the matte (pixels), if any.
pub fn centroid(&self) -> Option<[f64; 2]> {
let (mut sx, mut sy, mut n) = (0.0, 0.0, 0.0);
for (i, v) in self.matte.iter().enumerate() {
if *v != 0 {
sx += (i % self.w) as f64 + 0.5;
sy += (i / self.w) as f64 + 0.5;
n += 1.0;
}
}
(n > 0.0).then(|| [sx / n, sy / n])
}
}
/// Straight RGB of an image (transparent pixels are black).
pub fn rgb_of(img: &Image) -> Vec<[f32; 3]> {
img.data.par_iter().map(|p| if p[3] > 1e-6 { [p[0] / p[3], p[1] / p[3], p[2] / p[3]] } else { [0.0; 3] }).collect()
}
/// Pixels covered by `strokes` (of the given kinds) at `scale`.
pub fn rasterize(strokes: &[&Stroke], kinds: &[StrokeKind], scale: f64, w: usize, h: usize) -> Vec<bool> {
let mut m = vec![false; w * h];
for s in strokes.iter().filter(|s| kinds.contains(&s.kind)) {
let r = (s.radius * scale).max(0.5);
let pts: Vec<[f64; 2]> = s.points.iter().map(|p| [p[0] * scale, p[1] * scale]).collect();
let segs: Vec<([f64; 2], [f64; 2])> = if pts.len() == 1 { vec![(pts[0], pts[0])] } else { pts.windows(2).map(|w| (w[0], w[1])).collect() };
for (a, b) in segs {
let x0 = (a[0].min(b[0]) - r).floor().max(0.0) as usize;
let x1 = ((a[0].max(b[0]) + r).ceil().max(0.0) as usize).min(w);
let y0 = (a[1].min(b[1]) - r).floor().max(0.0) as usize;
let y1 = ((a[1].max(b[1]) + r).ceil().max(0.0) as usize).min(h);
let d = [b[0] - a[0], b[1] - a[1]];
let l2 = d[0] * d[0] + d[1] * d[1];
for y in y0..y1 {
for x in x0..x1 {
let p = [x as f64 + 0.5, y as f64 + 0.5];
let t = if l2 > 0.0 { (((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / l2).clamp(0.0, 1.0) } else { 0.0 };
let q = [a[0] + d[0] * t, a[1] + d[1] * t];
if (p[0] - q[0]).hypot(p[1] - q[1]) <= r {
m[y * w + x] = true;
}
}
}
}
}
m
}
/// Hard constraints: 1 = foreground, 2 = background, 0 = free.
const HARD_FG: u8 = 1;
const HARD_BG: u8 = 2;
/// Graph-cut energy weights.
const GAMMA: f64 = 50.0;
const PRIOR: f64 = 1.0;
/// Fixed-point scale of the energies.
const K: f64 = 16.0;
const INF: i64 = 1 << 40;
const NB: [(i64, i64, bool); 8] = [(1, 0, true), (0, 1, true), (1, 1, true), (-1, 1, true), (-1, 0, false), (0, -1, false), (-1, -1, false), (1, -1, false)];
fn beta(img: &[[f32; 3]], free: &[bool], w: usize, h: usize) -> f64 {
let (mut s, mut n) = (0.0f64, 0.0f64);
for y in 0..h {
for x in 0..w {
let i = y * w + x;
if !free[i] {
continue;
}
let c = img[i];
for (dx, dy) in [(1usize, 0usize), (0, 1)] {
let (nx, ny) = (x + dx, y + dy);
if nx < w && ny < h {
let q = img[ny * w + nx];
s += ((c[0] - q[0]).powi(2) + (c[1] - q[1]).powi(2) + (c[2] - q[2]).powi(2)) as f64;
n += 1.0;
}
}
}
}
if s <= 1e-12 { 1.0 } else { n / (2.0 * s) }
}
/// One min-cut over the `free` pixels; others keep `labels`. `prior`: P(foreground) per pixel.
fn cut(img: &[[f32; 3]], w: usize, h: usize, labels: &mut [u8], free: &[bool], hard: &[u8], fg: &Gmm, bg: &Gmm, prior: Option<&[f32]>) {
let mut idx = vec![u32::MAX; w * h];
let mut nodes = vec![];
for i in 0..w * h {
if free[i] {
idx[i] = nodes.len() as u32;
nodes.push(i);
}
}
if nodes.is_empty() {
return;
}
let b = beta(img, free, w, h);
let costs: Vec<(f64, f64)> = nodes
.par_iter()
.map(|&i| {
let c = img[i];
let (mut cf, mut cb) = (fg.cost(c), bg.cost(c));
if let Some(p) = prior {
let pf = (p[i] as f64).clamp(0.02, 0.98);
cf += PRIOR * -pf.ln();
cb += PRIOR * -(1.0 - pf).ln();
}
(cf, cb)
})
.collect();
let mut g = Graph::new(nodes.len(), nodes.len() * 4);
for (n, &i) in nodes.iter().enumerate() {
let (x, y) = ((i % w) as i64, (i / w) as i64);
let (cf, cb) = costs[n];
let m = cf.min(cb);
let (mut src, mut snk) = (((cb - m) * K).round() as i64, ((cf - m) * K).round() as i64);
match hard[i] {
HARD_FG => (src, snk) = (INF, 0),
HARD_BG => (src, snk) = (0, INF),
_ => {}
}
g.add_tweights(n, src, snk);
let c = img[i];
for (dx, dy, fwd) in NB {
let (nx, ny) = (x + dx, y + dy);
if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 {
continue;
}
let j = ny as usize * w + nx as usize;
let q = img[j];
let d2 = ((c[0] - q[0]).powi(2) + (c[1] - q[1]).powi(2) + (c[2] - q[2]).powi(2)) as f64;
let dist = if dx != 0 && dy != 0 { std::f64::consts::SQRT_2 } else { 1.0 };
let wt = ((GAMMA / dist * (-b * d2).exp()) * K).round() as i32;
if wt <= 0 {
continue;
}
if idx[j] == u32::MAX {
// Fixed neighbour: disagreeing with it costs the edge.
if labels[j] != 0 {
g.add_tweights(n, wt as i64, 0);
} else {
g.add_tweights(n, 0, wt as i64);
}
} else if fwd {
g.add_edge(n, idx[j] as usize, wt, wt);
}
}
}
g.maxflow();
for (n, &i) in nodes.iter().enumerate() {
labels[i] = (g.segment(n) == Segment::Source) as u8;
}
}
/// Colour samples of the pixels where `sel` holds.
fn samples(img: &[[f32; 3]], sel: impl Fn(usize) -> bool) -> Vec<[f32; 3]> {
img.iter().enumerate().filter(|(i, _)| sel(*i)).map(|(_, c)| *c).collect()
}
const COMPONENTS: usize = 5;
const MAX_SAMPLES: usize = 24_000;
fn fit_models(img: &[[f32; 3]], labels: &[u8]) -> (Gmm, Gmm) {
let fg = samples(img, |i| labels[i] != 0);
let bg = samples(img, |i| labels[i] == 0);
(Gmm::fit(&fg, COMPONENTS, MAX_SAMPLES), Gmm::fit(&bg, COMPONENTS, MAX_SAMPLES))
}
/// Keep the foreground components (8-connected) that touch `seeds`; fill small background
/// holes that touch neither the frame edge nor a background seed.
fn clean(labels: &mut [u8], seeds: &[bool], bg_seeds: &[bool], w: usize, h: usize) {
let n = w * h;
let mut comp = vec![u32::MAX; n];
let mut stack = vec![];
let mut fg_area = 0usize;
for start in 0..n {
if comp[start] != u32::MAX {
continue;
}
let lab = labels[start];
let id = start as u32;
comp[start] = id;
stack.push(start);
let mut members = vec![];
let (mut seeded, mut edge, mut bgs) = (false, false, false);
while let Some(i) = stack.pop() {
members.push(i);
seeded |= seeds[i];
bgs |= bg_seeds[i];
let (x, y) = ((i % w) as i64, (i / w) as i64);
edge |= x == 0 || y == 0 || x == w as i64 - 1 || y == h as i64 - 1;
for (dx, dy, _) in NB {
let (nx, ny) = (x + dx, y + dy);
if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 {
continue;
}
let j = ny as usize * w + nx as usize;
if comp[j] == u32::MAX && labels[j] == lab {
comp[j] = id;
stack.push(j);
}
}
}
if lab != 0 {
if seeded {
fg_area += members.len();
} else {
for i in members {
labels[i] = 0;
}
}
} else if !edge && !bgs {
// Remember holes as negative candidates (resolved below once fg_area is known).
for i in members {
comp[i] = u32::MAX - 1;
}
}
}
// Fill holes smaller than 2 % of the foreground.
let mut seen = vec![false; n];
for start in 0..n {
if comp[start] != u32::MAX - 1 || seen[start] || labels[start] != 0 {
continue;
}
let mut members = vec![start];
seen[start] = true;
let mut k = 0;
while k < members.len() {
let i = members[k];
k += 1;
let (x, y) = ((i % w) as i64, (i / w) as i64);
for (dx, dy, _) in NB {
let (nx, ny) = (x + dx, y + dy);
if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 {
continue;
}
let j = ny as usize * w + nx as usize;
if !seen[j] && comp[j] == u32::MAX - 1 && labels[j] == 0 {
seen[j] = true;
members.push(j);
}
}
}
// A hole is enclosed by foreground that survived the component filter.
let enclosed = members.iter().all(|&i| {
let (x, y) = ((i % w) as i64, (i / w) as i64);
NB.iter().all(|(dx, dy, _)| {
let (nx, ny) = (x + dx, y + dy);
if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 {
return false;
}
let j = ny as usize * w + nx as usize;
labels[j] != 0 || comp[j] == u32::MAX - 1
})
});
if enclosed && (members.len() as f64) < 0.02 * fg_area as f64 {
for i in members {
labels[i] = 1;
}
}
}
}
fn downsample_rgb(img: &[[f32; 3]], w: usize, h: usize, f: usize) -> (Vec<[f32; 3]>, usize, usize) {
let (cw, ch) = (w.div_ceil(f), h.div_ceil(f));
let out = (0..cw * ch)
.into_par_iter()
.map(|i| {
let (cx, cy) = (i % cw, i / cw);
let mut s = [0.0f32; 3];
let mut n = 0.0;
for y in cy * f..((cy + 1) * f).min(h) {
for x in cx * f..((cx + 1) * f).min(w) {
let c = img[y * w + x];
s[0] += c[0];
s[1] += c[1];
s[2] += c[2];
n += 1.0;
}
}
[s[0] / n, s[1] / n, s[2] / n]
})
.collect();
(out, cw, ch)
}
/// What a frame is segmented with besides its strokes: the matte propagated from its neighbour.
pub struct Prior {
/// Warped neighbour matte, soft (0–1).
pub soft: Vec<f32>,
/// The neighbour's colour models.
pub fg: Gmm,
pub bg: Gmm,
/// Warped Refine Edge band.
pub refine: Vec<u8>,
}
/// Segment a frame from its strokes (and a propagated prior): GrabCut iterations on a reduced
/// copy, then a full-resolution cut in a band around the boundary.
pub fn segment(img: &Image, strokes: &[&Stroke], prior: Option<&Prior>, opts: &SegOpts, refine_radius: f64) -> FrameSeg {
let (w, h) = (img.width as usize, img.height as usize);
if w == 0 || h == 0 {
return FrameSeg::empty(w, h);
}
let rgb = rgb_of(img);
let fgs = rasterize(strokes, &[StrokeKind::Fg], opts.scale, w, h);
let bgs = rasterize(strokes, &[StrokeKind::Bg], opts.scale, w, h);
let hard: Vec<u8> = fgs
.iter()
.zip(&bgs)
.map(|(f, b)| {
if *b {
HARD_BG
} else if *f {
HARD_FG
} else {
0
}
})
.collect();
let soft_prior: Option<Vec<f32>> = prior.map(|p| matting::gauss(&p.soft, w, h, (opts.search_radius * opts.scale / 4.0).max(1.0)));
// Initial colour models: the strokes (and the propagated matte).
let (fg0, bg0) = match prior {
Some(p) => {
let fgc = samples(&rgb, |i| (hard[i] == HARD_FG) || (hard[i] == 0 && p.soft[i] >= 0.5));
let bgc = samples(&rgb, |i| (hard[i] == HARD_BG) || (hard[i] == 0 && p.soft[i] < 0.5));
let f = Gmm::fit(&fgc, COMPONENTS, MAX_SAMPLES);
let b = Gmm::fit(&bgc, COMPONENTS, MAX_SAMPLES);
(if f.is_empty() { p.fg.clone() } else { f }, if b.is_empty() { p.bg.clone() } else { b })
}
None => {
let fgc = samples(&rgb, |i| hard[i] == HARD_FG);
let mut bgc = samples(&rgb, |i| hard[i] == HARD_BG);
if bgc.is_empty() {
// No background strokes: what is far from the foreground strokes.
let d = matting::distance(&fgs, w, h);
let r = strokes.iter().filter(|s| s.kind == StrokeKind::Fg).map(|s| s.radius).fold(1.0, f64::max) * opts.scale;
let far = (4.0 * r).max(0.25 * w.max(h) as f64) as f32;
bgc = samples(&rgb, |i| d[i] > far);
if bgc.is_empty() {
bgc = samples(&rgb, |i| hard[i] != HARD_FG);
}
}
(Gmm::fit(&fgc, COMPONENTS, MAX_SAMPLES), Gmm::fit(&bgc, COMPONENTS, MAX_SAMPLES))
}
};
let iters = if opts.best { 4 } else { 3 };
let work = if opts.best { 960 } else { 480 };
let f = w.max(h).div_ceil(work).max(1);
let mut labels: Vec<u8> = match &soft_prior {
Some(p) => p.iter().map(|v| (*v >= 0.5) as u8).collect(),
None => fgs.iter().map(|v| *v as u8).collect(),
};
for (l, hd) in labels.iter_mut().zip(&hard) {
if *hd != 0 {
*l = (*hd == HARD_FG) as u8;
}
}
let (mut fgm, mut bgm) = (fg0, bg0);
if f > 1 {
let (crgb, cw, ch) = downsample_rgb(&rgb, w, h, f);
let mut chard = vec![0u8; cw * ch];
let mut cprior = soft_prior.as_ref().map(|_| vec![0.0f32; cw * ch]);
for cy in 0..ch {
for cx in 0..cw {
let (mut anyf, mut anyb, mut s, mut n) = (false, false, 0.0f32, 0.0f32);
for y in cy * f..((cy + 1) * f).min(h) {
for x in cx * f..((cx + 1) * f).min(w) {
let i = y * w + x;
anyf |= hard[i] == HARD_FG;
anyb |= hard[i] == HARD_BG;
if let Some(p) = &soft_prior {
s += p[i];
}
n += 1.0;
}
}
chard[cy * cw + cx] = match (anyf, anyb) {
(true, false) => HARD_FG,
(false, true) => HARD_BG,
_ => 0,
};
if let Some(cp) = &mut cprior {
cp[cy * cw + cx] = s / n;
}
}
}
let mut cl: Vec<u8> = match &cprior {
Some(p) => p.iter().map(|v| (*v >= 0.5) as u8).collect(),
None => chard.iter().map(|v| (*v == HARD_FG) as u8).collect(),
};
let free = vec![true; cw * ch];
for it in 0..iters {
cut(&crgb, cw, ch, &mut cl, &free, &chard, &fgm, &bgm, cprior.as_deref());
if it + 1 < iters {
let (a, b) = fit_models(&crgb, &cl);
if !a.is_empty() && !b.is_empty() {
(fgm, bgm) = (a, b);
}
}
}
for y in 0..h {
for x in 0..w {
let i = y * w + x;
labels[i] = if hard[i] != 0 { (hard[i] == HARD_FG) as u8 } else { cl[(y / f) * cw + x / f] };
}
}
let (a, b) = fit_models(&rgb, &labels);
if !a.is_empty() && !b.is_empty() {
(fgm, bgm) = (a, b);
}
// Full-resolution band.
let sd = matting::signed_distance(&labels, w, h);
let band = f as f32 + 1.5;
let free: Vec<bool> = sd.iter().map(|d| d.abs() <= band).collect();
cut(&rgb, w, h, &mut labels, &free, &hard, &fgm, &bgm, soft_prior.as_deref());
} else {
let free = vec![true; w * h];
for it in 0..iters {
cut(&rgb, w, h, &mut labels, &free, &hard, &fgm, &bgm, soft_prior.as_deref());
if it + 1 < iters {
let (a, b) = fit_models(&rgb, &labels);
if !a.is_empty() && !b.is_empty() {
(fgm, bgm) = (a, b);
}
}
}
}
// Keep what the strokes (or the prior's core) select.
let mut seeds = fgs.clone();
if let Some(p) = &soft_prior {
let core = core_of(p, w, h, opts.search_radius * opts.scale);
for (s, c) in seeds.iter_mut().zip(core) {
*s |= c;
}
}
clean(&mut labels, &seeds, &bgs, w, h);
finish(img, &rgb, labels, strokes, prior.map(|p| p.refine.as_slice()), opts, refine_radius)
}
/// Pixels deep inside a soft matte (more than `r` from its edge), else the matte itself.
fn core_of(soft: &[f32], w: usize, h: usize, r: f64) -> Vec<bool> {
let bin: Vec<u8> = soft.iter().map(|v| (*v >= 0.5) as u8).collect();
let sd = matting::signed_distance(&bin, w, h);
let core: Vec<bool> = sd.iter().map(|d| *d as f64 > r).collect();
if core.iter().any(|c| *c) { core } else { bin.iter().map(|v| *v != 0).collect() }
}
/// Final models and Refine Edge band of a segmented frame.
fn finish(_img: &Image, rgb: &[[f32; 3]], labels: Vec<u8>, strokes: &[&Stroke], warped_refine: Option<&[u8]>, opts: &SegOpts, refine_radius: f64) -> FrameSeg {
let (w, h) = (_img.width as usize, _img.height as usize);
let (fg, bg) = fit_models(rgb, &labels);
let add = rasterize(strokes, &[StrokeKind::Refine], opts.scale, w, h);
let erase = rasterize(strokes, &[StrokeKind::RefineErase], opts.scale, w, h);
let near: Option<Vec<f32>> = warped_refine.filter(|r| r.iter().any(|v| *v != 0)).map(|_| matting::signed_distance(&labels, w, h));
let rr = (refine_radius * opts.scale + 2.0) as f32;
let refine: Vec<u8> = (0..w * h)
.map(|i| {
let carried = match (warped_refine, &near) {
(Some(r), Some(sd)) => r[i] != 0 && sd[i].abs() <= rr,
_ => false,
};
((carried || add[i]) && !erase[i]) as u8
})
.collect();
FrameSeg { w, h, matte: labels, refine, fg, bg }
}
/// Warp the previous frame's segmentation onto the next frame by optical flow (next → prev),
/// with Motion Threshold / Damping applied to the vectors.
pub fn warp_prior(prev_img: &Image, prev: &FrameSeg, next_img: &Image, opts: &SegOpts) -> Prior {
let (w, h) = (next_img.width as usize, next_img.height as usize);
let flow = effectcraft_raster::flow::block_flow(next_img, prev_img, 8, 4);
let thr = (opts.motion_threshold / 100.0 * opts.search_radius * opts.scale).max(0.0);
let damp = (1.0 - opts.motion_damping / 100.0).clamp(0.0, 1.0);
let src: Vec<f32> = prev.matte.iter().map(|v| *v as f32).collect();
let pw = prev.w.min(w);
let ph = prev.h.min(h);
let res: Vec<(f32, u8)> = (0..w * h)
.into_par_iter()
.map(|i| {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
let v = flow.at(x, y);
let (mut vx, mut vy) = (v[0] as f64, v[1] as f64);
if vx.hypot(vy) < thr {
vx *= damp;
vy *= damp;
}
let (sx, sy) = (x + vx, y + vy);
let soft = if pw == prev.w && ph == prev.h { matting::sample(&src, prev.w, prev.h, sx, sy) } else { 0.0 };
let (ix, iy) = (sx.floor().clamp(0.0, (prev.w.max(1) - 1) as f64) as usize, sy.floor().clamp(0.0, (prev.h.max(1) - 1) as f64) as usize);
let r = prev.refine.get(iy * prev.w + ix).copied().unwrap_or(0);
(soft, r)
})
.collect();
Prior { soft: res.iter().map(|r| r.0).collect(), fg: prev.fg.clone(), bg: prev.bg.clone(), refine: res.iter().map(|r| r.1).collect() }
}
/// Propagate the previous frame's segmentation to the next frame: warp it, then re-cut in a
/// band of Search Radius pixels around the warped boundary. Strokes on the next frame re-cut
/// the whole frame with the warped matte as a soft prior instead.
pub fn propagate(prev_img: &Image, prev: &FrameSeg, next_img: &Image, strokes: &[&Stroke], opts: &SegOpts, refine_radius: f64) -> FrameSeg {
let (w, h) = (next_img.width as usize, next_img.height as usize);
let prior = warp_prior(prev_img, prev, next_img, opts);
if strokes.iter().any(|s| s.kind.is_segmentation()) {
return segment(next_img, strokes, Some(&prior), opts, refine_radius);
}
let rgb = rgb_of(next_img);
let mut labels: Vec<u8> = prior.soft.iter().map(|v| (*v >= 0.5) as u8).collect();
let sd = matting::signed_distance(&labels, w, h);
let r = (opts.search_radius * opts.scale).max(2.0) as f32;
let free: Vec<bool> = sd.iter().map(|d| d.abs() <= r).collect();
let hard = vec![0u8; w * h];
let soft = matting::gauss(&prior.soft, w, h, (r as f64 / 4.0).max(1.0));
let (fg, bg) = if prev.fg.is_empty() || prev.bg.is_empty() { fit_models(&rgb, &labels) } else { (prev.fg.clone(), prev.bg.clone()) };
cut(&rgb, w, h, &mut labels, &free, &hard, &fg, &bg, Some(&soft));
let seeds = core_of(&prior.soft, w, h, r as f64);
clean(&mut labels, &seeds, &vec![false; w * h], w, h);
finish(next_img, &rgb, labels, strokes, Some(&prior.refine), opts, refine_radius)
}
/// Intersection over union of two binary mattes.
pub fn iou(a: &[u8], b: &[u8]) -> f64 {
let (mut i, mut u) = (0usize, 0usize);
for (x, y) in a.iter().zip(b) {
let (x, y) = (*x != 0, *y != 0);
i += (x && y) as usize;
u += (x || y) as usize;
}
if u == 0 { 1.0 } else { i as f64 / u as f64 }
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,198 @@
//! From a frame's binary segmentation to the final soft matte: the Roto Brush Matte and Refine
//! Edge Matte adjustments, edge matting inside the Refine Edge band (colour guided filter),
//! Reduce Chatter (temporal averaging with the neighbouring frames), matte motion blur and edge
//! colour decontamination.
use super::FrameSeg;
use super::matting::{self, contrast, gauss, guided_filter, shift_soft, signed_distance};
/// The effect's matte parameters (After Effects' units: pixels and percent).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MatteParams {
// Roto Brush Matte.
pub rb_feather: f64,
pub rb_contrast: f64,
pub rb_shift: f64,
pub rb_chatter: f64,
// Refine Edge Matte.
pub smooth: f64,
pub feather: f64,
pub contrast: f64,
pub shift: f64,
pub chatter: f64,
pub motion_blur: bool,
pub mb_samples: usize,
pub shutter_angle: f64,
/// Higher Quality: closed-form matting after the guided-filter estimate.
pub higher_quality: bool,
pub decontaminate: bool,
pub decontamination: f64,
pub extend_where_smoothed: bool,
pub increase_radius: f64,
}
impl Default for MatteParams {
fn default() -> Self {
MatteParams {
rb_feather: 0.0,
rb_contrast: 0.0,
rb_shift: 0.0,
rb_chatter: 0.0,
smooth: 1.0,
feather: 0.0,
contrast: 0.0,
shift: 0.0,
chatter: 0.0,
motion_blur: false,
mb_samples: 11,
shutter_angle: 180.0,
higher_quality: true,
decontaminate: true,
decontamination: 100.0,
extend_where_smoothed: true,
increase_radius: 0.0,
}
}
}
fn shifted(seg: &FrameSeg, px: f64) -> Vec<f32> {
if px.abs() < 0.25 {
return seg.matte.iter().map(|v| *v as f32).collect();
}
let sd = signed_distance(&seg.matte, seg.w, seg.h);
sd.iter().map(|d| ((*d as f64) > -px) as u8 as f32).collect()
}
/// The soft matte of `seg` (all planes `seg.w × seg.h`), with `rgb` the frame's straight colour.
/// `prev` / `next`: the neighbouring frames' segmentations (Reduce Chatter, motion blur).
/// `band_radius`: the Refine Edge brush radius in these pixels; `scale`: pixels per layer pixel.
pub fn matte_alpha(
rgb: &[[f32; 3]],
seg: &FrameSeg,
prev: Option<&FrameSeg>,
next: Option<&FrameSeg>,
p: &MatteParams,
band_radius: f64,
scale: f64,
) -> Vec<f32> {
let (w, h) = (seg.w, seg.h);
let n = w * h;
let same = |s: &&FrameSeg| s.w == w && s.h == h;
let (prev, next) = (prev.filter(same), next.filter(same));
// Roto Brush Matte: Shift Edge (100 % = 10 px), Reduce Chatter, Feather, Contrast.
let shift_px = p.rb_shift / 100.0 * 10.0 * scale;
let mut a = shifted(seg, shift_px);
let nb: Vec<Vec<f32>> = [prev, next].into_iter().flatten().map(|s| shifted(s, shift_px)).collect();
if p.rb_chatter > 0.0 && !nb.is_empty() {
let k = (p.rb_chatter / 100.0).clamp(0.0, 1.0) as f32 * (nb.len() as f32 / (nb.len() as f32 + 1.0));
for i in 0..n {
let m = nb.iter().map(|v| v[i]).sum::<f32>() / nb.len() as f32;
a[i] += (m - a[i]) * k;
}
}
if p.rb_feather > 0.0 {
a = gauss(&a, w, h, p.rb_feather * scale / 2.0);
}
contrast(&mut a, p.rb_contrast);
// Refine Edge band: guided-filter matting.
if seg.refine.iter().any(|v| *v != 0) && seg.refine.len() == n {
let r = (band_radius * 1.0).round().max(2.0) as usize;
let eps = (2e-4 * (1.0 + p.smooth.max(0.0))) as f32;
// Work on the band's bounding box (plus the filter's reach).
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
for (i, v) in seg.refine.iter().enumerate() {
if *v != 0 {
let (x, y) = (i % w, i / w);
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x + 1);
y1 = y1.max(y + 1);
}
}
let pad = 2 * r + 2;
let (x0, y0, x1, y1) = (x0.saturating_sub(pad), y0.saturating_sub(pad), (x1 + pad).min(w), (y1 + pad).min(h));
let (cw, ch) = (x1 - x0, y1 - y0);
let crop = |src: &dyn Fn(usize) -> f32| -> Vec<f32> { (0..cw * ch).map(|j| src((y0 + j / cw) * w + x0 + j % cw)).collect() };
let cimg: Vec<[f32; 3]> = (0..cw * ch).map(|j| rgb[(y0 + j / cw) * w + x0 + j % cw]).collect();
let cp = crop(&|i| a[i]);
let band: Vec<bool> = crop(&|i| seg.refine[i] as f32).iter().map(|v| *v > 0.0).collect();
let outside: Vec<bool> = band.iter().map(|b| !b).collect();
// Guided filter estimate, then closed-form matting with everything outside the band known.
let mut q = guided_filter(&cimg, &cp, cw, ch, r, eps);
for j in 0..cw * ch {
q[j] = if band[j] { q[j].clamp(0.0, 1.0) } else { cp[j] };
}
if p.higher_quality {
q = matting::closed_form(&cimg, &q, &outside, cw, ch, 1, 1e-5 * (1.0 + p.smooth.max(0.0)) as f32, 50);
}
// Refine Edge Matte adjustments.
if p.smooth > 1.0 {
q = gauss(&q, cw, ch, (p.smooth - 1.0) * 0.5 * scale);
}
if p.feather > 0.0 {
q = gauss(&q, cw, ch, p.feather * scale / 2.0);
}
contrast(&mut q, p.contrast);
shift_soft(&mut q, p.shift);
// Blend in, fading over the band's last two pixels.
let din = matting::distance(&outside, cw, ch);
for j in 0..cw * ch {
if !band[j] {
continue;
}
let wgt = (din[j] / 2.0).clamp(0.0, 1.0);
let i = (y0 + j / cw) * w + x0 + j % cw;
a[i] += (q[j] - a[i]) * wgt;
}
// Refine-stage Reduce Chatter: towards the neighbours' mattes inside the band.
if p.chatter > 0.0 && !nb.is_empty() {
let k = (p.chatter / 100.0).clamp(0.0, 1.0) as f32 * 0.5;
let soft: Vec<Vec<f32>> = nb.iter().map(|v| gauss(v, w, h, (r as f64 / 2.0).max(1.0))).collect();
for i in 0..n {
if seg.refine[i] != 0 {
let m = soft.iter().map(|v| v[i]).sum::<f32>() / soft.len() as f32;
a[i] += (m - a[i]) * k;
}
}
}
}
// Motion blur: the matte's motion between the neighbouring frames.
if p.motion_blur {
let c = seg.centroid();
let v = match (prev.and_then(FrameSeg::centroid), next.and_then(FrameSeg::centroid), c) {
(Some(a), Some(b), _) => Some([(b[0] - a[0]) / 2.0, (b[1] - a[1]) / 2.0]),
(Some(a), None, Some(c)) => Some([c[0] - a[0], c[1] - a[1]]),
(None, Some(b), Some(c)) => Some([b[0] - c[0], b[1] - c[1]]),
_ => None,
};
if let Some(v) = v {
let s = p.shutter_angle / 360.0;
a = matting::motion_blur(&a, w, h, [v[0] * s, v[1] * s], p.mb_samples);
}
}
a.iter_mut().for_each(|v| *v = v.clamp(0.0, 1.0));
a
}
/// Decontaminate edge colours of `rgb` in place for the matte `alpha` (see
/// [`matting::decontaminate`]); returns the decontamination map. `band`: the Refine Edge band
/// (pixels where decontamination always applies).
pub fn decontaminate(rgb: &mut [[f32; 3]], alpha: &[f32], band: &[u8], w: usize, h: usize, p: &MatteParams, band_radius: f64, scale: f64) -> Vec<f32> {
if !p.decontaminate {
return vec![0.0; w * h];
}
let grow = p.increase_radius * scale;
let in_band: Vec<bool> = if band.len() == w * h && band.iter().any(|v| *v != 0) {
if grow > 0.5 {
let d = matting::distance(&band.iter().map(|v| *v != 0).collect::<Vec<_>>(), w, h);
d.iter().map(|v| (*v as f64) <= grow).collect()
} else {
band.iter().map(|v| *v != 0).collect()
}
} else {
vec![false; w * h]
};
let region: Vec<bool> = (0..w * h).map(|i| alpha[i] > 0.004 && alpha[i] < 0.996 && (in_band[i] || p.extend_where_smoothed)).collect();
let radius = (band_radius + grow).max(4.0 * scale).round() as usize;
matting::decontaminate(rgb, alpha, &region, w, h, radius, (p.decontamination / 100.0).clamp(0.0, 1.0) as f32)
}

View File

@ -0,0 +1,136 @@
//! Compact text encodings of mattes for project storage: run-length coding (LEB128 varints)
//! wrapped in standard base64 (RFC 4648 §4).
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub fn base64_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for c in data.chunks(3) {
let b = [c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0)];
let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
out.push(B64[(n >> 18) as usize & 63] as char);
out.push(B64[(n >> 12) as usize & 63] as char);
out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' });
out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' });
}
out
}
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
let val = |c: u8| -> Option<u32> {
Some(match c {
b'A'..=b'Z' => (c - b'A') as u32,
b'a'..=b'z' => (c - b'a' + 26) as u32,
b'0'..=b'9' => (c - b'0' + 52) as u32,
b'+' => 62,
b'/' => 63,
_ => return None,
})
};
let bytes: Vec<u8> = s.bytes().filter(|c| !c.is_ascii_whitespace()).collect();
if !bytes.len().is_multiple_of(4) {
return None;
}
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
for c in bytes.chunks(4) {
let pad = c.iter().rev().take_while(|b| **b == b'=').count();
let mut n = 0u32;
for (i, b) in c.iter().enumerate() {
n |= if *b == b'=' { 0 } else { val(*b)? } << (18 - 6 * i);
}
out.push((n >> 16) as u8);
if pad < 2 {
out.push((n >> 8) as u8);
}
if pad < 1 {
out.push(n as u8);
}
}
Some(out)
}
fn put(out: &mut Vec<u8>, mut v: u64) {
loop {
let b = (v & 0x7f) as u8;
v >>= 7;
if v == 0 {
out.push(b);
return;
}
out.push(b | 0x80);
}
}
fn get(data: &[u8], pos: &mut usize) -> Option<u64> {
let mut v = 0u64;
let mut shift = 0;
loop {
let b = *data.get(*pos)?;
*pos += 1;
v |= ((b & 0x7f) as u64) << shift;
if b & 0x80 == 0 {
return Some(v);
}
shift += 7;
if shift > 63 {
return None;
}
}
}
/// Encode a byte plane as (value, run length) pairs.
pub fn encode(values: &[u8]) -> String {
let mut out = vec![];
put(&mut out, values.len() as u64);
let mut i = 0;
while i < values.len() {
let v = values[i];
let mut j = i + 1;
while j < values.len() && values[j] == v {
j += 1;
}
out.push(v);
put(&mut out, (j - i) as u64);
i = j;
}
base64_encode(&out)
}
/// Decode [`encode`]'s output (None when malformed).
pub fn decode(s: &str) -> Option<Vec<u8>> {
let data = base64_decode(s)?;
let mut pos = 0;
let n = get(&data, &mut pos)? as usize;
if n > 1 << 30 {
return None;
}
let mut out = Vec::with_capacity(n);
while out.len() < n {
let v = *data.get(pos)?;
pos += 1;
let run = get(&data, &mut pos)? as usize;
if out.len() + run > n {
return None;
}
out.resize(out.len() + run, v);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips() {
for n in [0usize, 1, 2, 3, 4, 5, 100, 1000] {
let v: Vec<u8> = (0..n).map(|i| ((i / 7) % 3) as u8 * 100).collect();
assert_eq!(decode(&encode(&v)).unwrap(), v);
let raw: Vec<u8> = (0..n).map(|i| (i * 31) as u8).collect();
assert_eq!(base64_decode(&base64_encode(&raw)).unwrap(), raw);
}
assert_eq!(base64_encode(b"Man"), "TWFu");
assert_eq!(base64_encode(b"Ma"), "TWE=");
assert!(decode("!!!!").is_none());
}
}

View File

@ -0,0 +1,331 @@
//! Roto Brush on synthetic sequences: a textured disk moving over a textured background.
use super::refine::{MatteParams, decontaminate, matte_alpha};
use super::*;
pub(crate) const W: u32 = 200;
pub(crate) const H: u32 = 150;
pub(crate) const R: f64 = 32.0;
/// Smooth value noise in 0..1.
pub(crate) fn noise(x: f64, y: f64, seed: u32) -> f64 {
fn h(i: i64, j: i64, s: u32) -> f64 {
let mut v = (i.wrapping_mul(73856093) ^ j.wrapping_mul(19349663) ^ (s as i64).wrapping_mul(83492791)) as u64;
v ^= v >> 13;
v = v.wrapping_mul(0x5bd1e995);
v ^= v >> 15;
(v % 10_000) as f64 / 10_000.0
}
let mut out = 0.0;
for (cell, amp) in [(9.0, 0.6), (3.5, 0.4)] {
let (fx, fy) = (x / cell, y / cell);
let (i, j) = (fx.floor() as i64, fy.floor() as i64);
let (tx, ty) = (fx - i as f64, fy - j as f64);
let (sx, sy) = (tx * tx * (3.0 - 2.0 * tx), ty * ty * (3.0 - 2.0 * ty));
let a = h(i, j, seed) + (h(i + 1, j, seed) - h(i, j, seed)) * sx;
let b = h(i, j + 1, seed) + (h(i + 1, j + 1, seed) - h(i, j + 1, seed)) * sx;
out += amp * (a + (b - a) * sy);
}
out
}
pub(crate) fn fg_color(x: f64, y: f64) -> [f32; 3] {
let n = noise(x, y, 11);
[(0.75 + 0.25 * n) as f32, (0.35 + 0.3 * n) as f32, (0.1 + 0.15 * n) as f32]
}
pub(crate) fn bg_color(x: f64, y: f64) -> [f32; 3] {
let n = noise(x, y, 5);
let m = noise(x * 1.7, y * 1.7, 9);
[(0.05 + 0.25 * m) as f32, (0.3 + 0.4 * n) as f32, (0.45 + 0.4 * m) as f32]
}
pub(crate) fn center(f: i64) -> [f64; 2] {
let t = f as f64;
[60.0 + 3.1 * t, 70.0 + 1.3 * t + 4.0 * (t * 0.3).sin()]
}
/// Frame `f`: the disk (texture moving with it) over the static background, with a 1-pixel
/// anti-aliased edge; `c` overrides the centre.
pub(crate) fn frame_at(c: [f64; 2]) -> (Image, Vec<u8>) {
let mut img = Image::new(W, H);
let mut gt = vec![0u8; (W * H) as usize];
for y in 0..H {
for x in 0..W {
let p = [x as f64 + 0.5, y as f64 + 0.5];
let d = (p[0] - c[0]).hypot(p[1] - c[1]);
let a = (R + 0.5 - d).clamp(0.0, 1.0) as f32;
let f = fg_color(p[0] - c[0], p[1] - c[1]);
let b = bg_color(p[0], p[1]);
img.set(x, y, [f[0] * a + b[0] * (1.0 - a), f[1] * a + b[1] * (1.0 - a), f[2] * a + b[2] * (1.0 - a), 1.0]);
gt[(y * W + x) as usize] = (d <= R) as u8;
}
}
(img, gt)
}
fn line(kind: StrokeKind, frame: i64, a: [f64; 2], b: [f64; 2], radius: f64) -> Stroke {
Stroke { kind, frame, radius, points: (0..=10).map(|k| [a[0] + (b[0] - a[0]) * k as f64 / 10.0, a[1] + (b[1] - a[1]) * k as f64 / 10.0]).collect() }
}
/// The strokes an artist would paint on frame `f`: one through the disk, one around it.
pub(crate) fn base_strokes(f: i64) -> Vec<Stroke> {
let c = center(f);
vec![
line(StrokeKind::Fg, f, [c[0] - 18.0, c[1] - 6.0], [c[0] + 18.0, c[1] + 6.0], 5.0),
line(StrokeKind::Bg, f, [c[0] - 50.0, c[1] - 45.0], [c[0] + 50.0, c[1] - 45.0], 5.0),
line(StrokeKind::Bg, f, [c[0] + 50.0, c[1] + 45.0], [c[0] - 45.0, c[1] + 45.0], 5.0),
]
}
#[test]
fn strokes_segment_the_base_frame() {
let (img, gt) = frame_at(center(0));
let st = base_strokes(0);
let refs: Vec<&Stroke> = st.iter().collect();
let seg = segment(&img, &refs, None, &SegOpts::default(), 0.0);
let j = iou(&seg.matte, &gt);
assert!(j > 0.95, "IoU {j}");
// Deterministic.
let again = segment(&img, &refs, None, &SegOpts::default(), 0.0);
assert_eq!(seg, again);
}
#[test]
fn coarse_to_fine_segments_large_frames() {
// A 2× upscaled frame goes through the reduced-size path (Standard works at 480 px).
let (small, _) = frame_at(center(0));
let img = effectcraft_raster::resample(&small, W * 3, H * 3);
let c = center(0);
let st: Vec<Stroke> = base_strokes(0)
.into_iter()
.map(|mut s| {
s.points.iter_mut().for_each(|p| *p = [p[0] * 3.0, p[1] * 3.0]);
s.radius *= 3.0;
s
})
.collect();
let refs: Vec<&Stroke> = st.iter().collect();
let seg = segment(&img, &refs, None, &SegOpts::default(), 0.0);
let gt: Vec<u8> =
(0..W * H * 9).map(|i| ((((i % (W * 3)) as f64 + 0.5) / 3.0 - c[0]).hypot((((i / (W * 3)) as f64 + 0.5) / 3.0) - c[1]) <= R) as u8).collect();
let j = iou(&seg.matte, &gt);
assert!(j > 0.95, "IoU {j}");
}
#[test]
fn propagation_follows_the_disk_for_20_frames() {
let (img0, _) = frame_at(center(0));
let st = base_strokes(0);
let refs: Vec<&Stroke> = st.iter().collect();
let opts = SegOpts::default();
let mut seg = segment(&img0, &refs, None, &opts, 0.0);
let mut prev = img0;
let mut worst: f64 = 1.0;
for f in 1..=20 {
let (img, gt) = frame_at(center(f));
seg = propagate(&prev, &seg, &img, &[], &opts, 0.0);
let j = iou(&seg.matte, &gt);
worst = worst.min(j);
prev = img;
}
assert!(worst > 0.9, "worst IoU {worst}");
}
#[test]
fn a_correction_stroke_fixes_a_bad_frame() {
let opts = SegOpts::default();
let (img0, _) = frame_at(center(0));
let st = base_strokes(0);
let refs: Vec<&Stroke> = st.iter().collect();
let seg0 = segment(&img0, &refs, None, &opts, 0.0);
// The disk jumps far away on frame 1: propagation cannot follow.
let jump = [150.0, 90.0];
let (img1, gt1) = frame_at(jump);
let bad = propagate(&img0, &seg0, &img1, &[], &opts, 0.0);
assert!(iou(&bad.matte, &gt1) < 0.5, "the jump should break propagation");
// Paint the disk in and the old place out.
let c0 = center(0);
let fix = [
line(StrokeKind::Fg, 1, [jump[0] - 15.0, jump[1]], [jump[0] + 15.0, jump[1]], 5.0),
line(StrokeKind::Bg, 1, [c0[0] - 10.0, c0[1] - 10.0], [c0[0] + 10.0, c0[1] + 10.0], 6.0),
];
let fr: Vec<&Stroke> = fix.iter().collect();
let good = propagate(&img0, &seg0, &img1, &fr, &opts, 0.0);
let j = iou(&good.matte, &gt1);
assert!(j > 0.95, "corrected IoU {j}");
// And propagation continues from the corrected frame.
let c2 = [jump[0] + 3.0, jump[1] + 1.0];
let (img2, gt2) = frame_at(c2);
let next = propagate(&img1, &good, &img2, &[], &opts, 0.0);
assert!(iou(&next.matte, &gt2) > 0.93);
}
/// A soft-edged disk (alpha ramp over `width` pixels) over a background.
fn soft_frame(c: [f64; 2], width: f64, fg: [f32; 3], bg: [f32; 3]) -> (Image, Vec<f32>) {
let mut img = Image::new(W, H);
let mut gt = vec![0.0f32; (W * H) as usize];
for y in 0..H {
for x in 0..W {
let p = [x as f64 + 0.5, y as f64 + 0.5];
let d = (p[0] - c[0]).hypot(p[1] - c[1]);
let a = ((R + width / 2.0 - d) / width).clamp(0.0, 1.0) as f32;
// Faint texture on both layers.
let tf = (noise(p[0], p[1], 3) as f32 - 0.5) * 0.04;
let tb = (noise(p[0], p[1], 4) as f32 - 0.5) * 0.04;
let f = [fg[0] + tf, fg[1] + tf, fg[2] + tf];
let b = [bg[0] + tb, bg[1] + tb, bg[2] + tb];
img.set(x, y, [f[0] * a + b[0] * (1.0 - a), f[1] * a + b[1] * (1.0 - a), f[2] * a + b[2] * (1.0 - a), 1.0]);
gt[(y * W + x) as usize] = a;
}
}
(img, gt)
}
fn ring(frame: i64, c: [f64; 2], r: f64, radius: f64) -> Stroke {
Stroke {
kind: StrokeKind::Refine,
frame,
radius,
points: (0..=48).map(|k| k as f64 / 48.0 * std::f64::consts::TAU).map(|a| [c[0] + r * a.cos(), c[1] + r * a.sin()]).collect(),
}
}
#[test]
fn refine_edge_recovers_a_soft_edge_and_decontaminates() {
let c = [100.0, 75.0];
let (fgc, bgc) = ([0.9f32, 0.25, 0.1], [0.1f32, 0.3, 0.9]);
let (img, gt) = soft_frame(c, 10.0, fgc, bgc);
let mut st = vec![
line(StrokeKind::Fg, 0, [c[0] - 15.0, c[1]], [c[0] + 15.0, c[1]], 5.0),
line(StrokeKind::Bg, 0, [c[0] - 60.0, c[1] - 55.0], [c[0] + 60.0, c[1] - 55.0], 5.0),
];
st.push(ring(0, c, R, 9.0));
let refs: Vec<&Stroke> = st.iter().collect();
let seg = segment(&img, &refs, None, &SegOpts::default(), 9.0);
assert!(seg.refine.iter().filter(|v| **v != 0).count() > 1000);
let p = MatteParams::default();
let rgb = rgb_of(&img);
let a = matte_alpha(&rgb, &seg, None, None, &p, 9.0, 1.0);
// Inside the ramp the matte is fractional and close to the truth.
let (mut err, mut n, mut frac) = (0.0f64, 0usize, 0usize);
let (mut err_bin, mut n_bin) = (0.0f64, 0usize);
for i in 0..a.len() {
if gt[i] > 0.0 && gt[i] < 1.0 {
err += (a[i] - gt[i]).abs() as f64;
err_bin += (seg.matte[i] as f32 - gt[i]).abs() as f64;
n += 1;
n_bin += 1;
frac += (a[i] > 0.05 && a[i] < 0.95) as usize;
}
}
let (err, err_bin) = (err / n as f64, err_bin / n_bin as f64);
assert!(err < 0.03, "mean alpha error {err} (binary {err_bin})");
assert!(err < err_bin * 0.4);
assert!(frac as f64 > 0.6 * n as f64, "{frac}/{n} fractional");
// Decontamination: edge pixels' colour is the foreground's, not a blue blend.
let mut col = rgb.clone();
let map = decontaminate(&mut col, &a, &seg.refine, W as usize, H as usize, &p, 9.0, 1.0);
let (mut blue_before, mut blue_after, mut m) = (0.0f64, 0.0f64, 0usize);
for i in 0..a.len() {
if a[i] > 0.2 && a[i] < 0.8 {
blue_before += rgb[i][2] as f64;
blue_after += col[i][2] as f64;
m += 1;
}
}
let (bb, ba) = (blue_before / m as f64, blue_after / m as f64);
assert!(ba < 0.2 && bb > 0.35, "blue before {bb}, after {ba}");
assert!(map.iter().any(|v| *v > 0.2));
// Without the band, the binary matte passes through.
let mut seg2 = seg.clone();
seg2.refine.iter_mut().for_each(|v| *v = 0);
let a2 = matte_alpha(&rgb, &seg2, None, None, &p, 9.0, 1.0);
assert!(a2.iter().zip(&seg.matte).all(|(x, y)| (*x - *y as f32).abs() < 1e-6));
}
#[test]
fn reduce_chatter_and_motion_blur_use_the_neighbours() {
let (w, h) = (40usize, 20usize);
let mk = |x0: usize| FrameSeg {
w,
h,
matte: (0..w * h).map(|i| (i % w >= x0 && i % w < x0 + 10) as u8).collect(),
refine: vec![0; w * h],
..Default::default()
};
let (a, b, c) = (mk(10), mk(14), mk(18));
let rgb = vec![[0.5f32; 3]; w * h];
let p = MatteParams { rb_chatter: 100.0, ..Default::default() };
let al = matte_alpha(&rgb, &b, Some(&a), Some(&c), &p, 0.0, 1.0);
// Pixel 12 is only in the previous frame: partially on.
assert!(al[12] > 0.2 && al[12] < 0.5, "{}", al[12]);
let p = MatteParams { motion_blur: true, ..Default::default() };
let al = matte_alpha(&rgb, &b, Some(&a), Some(&c), &p, 0.0, 1.0);
assert!(al[14] > 0.3 && al[14] < 0.9, "{}", al[14]);
assert!(al[19] > 0.99);
}
#[test]
fn chain_keys_restart_at_correction_frames() {
let mut d = RotoData::default();
for s in base_strokes(5) {
d.add(s, 20, [0, 30]);
}
assert_eq!(d.base, Some(5));
assert_eq!(d.span, [0, 25]);
let k1 = d.chain_keys(1);
d.add(line(StrokeKind::Fg, 12, [0.0, 0.0], [1.0, 1.0], 3.0), 20, [0, 30]);
let k2 = d.chain_keys(1);
for f in 0..=11 {
assert_eq!(k1[&f], k2[&f], "frame {f}");
}
for f in 12..=25 {
assert_ne!(k1[&f], k2[&f], "frame {f}");
}
assert_ne!(d.chain_keys(2)[&5], k2[&5]);
let back = RotoData::from_json(&d.to_json());
assert_eq!(back, d);
assert_eq!(d.source_of(12), Some(11));
assert_eq!(d.source_of(3), Some(4));
assert_eq!(d.source_of(5), None);
}
/// `cargo test -p effectcraft-track --release roto_perf -- --ignored --nocapture`
#[test]
#[ignore]
fn roto_perf_1080p() {
let up = |c: [f64; 2]| {
let (img, _) = frame_at(c);
effectcraft_raster::resample(&img, 1920, 1080)
};
let s = 1920.0 / W as f64;
let st: Vec<Stroke> = base_strokes(0)
.into_iter()
.map(|mut k| {
k.points.iter_mut().for_each(|p| *p = [p[0] * s, p[1] * s]);
k.radius *= s;
k
})
.collect();
let refs: Vec<&Stroke> = st.iter().collect();
let opts = SegOpts { search_radius: 15.0 * s / 4.0, ..Default::default() };
let a = up(center(0));
let t = std::time::Instant::now();
let seg = segment(&a, &refs, None, &opts, 0.0);
eprintln!("segment 1080p: {:?}", t.elapsed());
let b = up(center(1));
let t = std::time::Instant::now();
let next = propagate(&a, &seg, &b, &[], &opts, 0.0);
eprintln!("propagate 1080p: {:?} (area {})", t.elapsed(), next.area());
let mut seg2 = next.clone();
let c = center(1);
let ring: Vec<[f64; 2]> = (0..=64).map(|k| k as f64 / 64.0 * std::f64::consts::TAU).map(|t| [(c[0] + R * t.cos()) * s, (c[1] + R * t.sin()) * s]).collect();
let st = [Stroke { kind: StrokeKind::Refine, frame: 1, radius: 20.0, points: ring }];
let r = rasterize(&st.iter().collect::<Vec<_>>(), &[StrokeKind::Refine], 1.0, 1920, 1080);
seg2.refine = r.iter().map(|v| *v as u8).collect();
let rgb = rgb_of(&b);
let t = std::time::Instant::now();
let _ = matte_alpha(&rgb, &seg2, None, None, &MatteParams::default(), 20.0, 1.0);
eprintln!("refine 1080p: {:?}", t.elapsed());
}

View File

@ -33,6 +33,7 @@ pub enum Icon {
Clone,
Eraser,
RotoBrush,
RefineEdge,
Puppet,
// switches & timeline
Eye,
@ -282,6 +283,15 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
pen.ellipse(6.0, 10.0, 3.6, 3.6);
pen.dot(6.0, 10.0, 1.0);
}
RefineEdge => {
// A brush over a soft (dashed) edge.
pen.line(&[(13.5, 2.5), (9.0, 7.0)]);
pen.ellipse(7.0, 9.0, 2.6, 2.6);
pen.line(&[(2.0, 14.0), (3.5, 12.5)]);
pen.line(&[(5.0, 14.0), (6.5, 12.5)]);
pen.line(&[(8.0, 14.0), (9.5, 12.5)]);
pen.line(&[(11.0, 14.0), (12.5, 12.5)]);
}
Puppet => {
pen.circle(8.0, 3.6, 1.6);
pen.line(&[(8.0, 5.2), (8.0, 10.0)]);

View File

@ -725,6 +725,7 @@ impl EffectcraftApp {
// Warp Stabilizer: finish analyses and start queued (re-)analyses in the background.
if self.session.warp_job.is_some() || !self.session.warp_pending.is_empty() {
self.session.poll_warp(true);
self.session.poll_roto(true);
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
self.apply_prefs(&ctx);

View File

@ -72,7 +72,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("tool.pen", "Pen Tool", [], Some("G")),
uic!("tool.type", "Type Tools", [], Some("Cmd+T")),
uic!("tool.brush", "Brush Tools", [], Some("Cmd+B")),
uic!("tool.rotoBrush", "Roto Brush Tool", [], Some("Alt+W")),
uic!("tool.rotoBrush", "Roto Brush & Refine Edge Tools", [], Some("Alt+W")),
uic!("tool.puppet", "Puppet Tools", [], Some("Cmd+P")),
uic!("app.newComp", "New Composition...", [], None),
uic!("app.compSettings", "Composition Settings...", [], None),
@ -139,6 +139,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
"pen" => Some(9),
"type" => Some(10),
"brush" => Some(11),
"rotoBrush" => Some(14),
"puppet" => Some(15),
_ => None,
};

View File

@ -537,6 +537,35 @@ fn warp_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, l
p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, status, Tokens::ui(11.5), t.text_dim);
}
/// Roto Brush & Refine Edge: Freeze / Unfreeze and Propagate buttons and the segmentation status.
fn roto_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, r: Rect, actions: &mut Actions) {
use effectcraft_engine::effects::roto as fx;
let t = app.tokens;
let params = effectcraft_engine::effects::flatten_params(g, &mut |pr| pr.value.clone());
let frozen = fx::is_frozen(&params);
let d = fx::data(&params);
let running = app.session.roto_target().is_some_and(|(_, l, u, _)| l == layer.id && u == g.uid) && app.session.is_roto_running();
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0));
let label = if frozen { "Unfreeze" } else { "Freeze" };
if widgets::text_button(ui, b1, label, frozen, &t, egui::Id::new(("roto-freeze-ec", g.uid))).clicked() && !running {
let id = if frozen { "roto.unfreeze" } else { "roto.freeze" };
actions.push((id.into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
app.auto.add(&format!("effectControls.roto.{}.freeze", g.uid), b1, label);
let (l2, id2) = if running { ("Stop", "roto.cancel") } else { ("Propagate", "roto.propagate") };
if widgets::text_button(ui, b2, l2, false, &t, egui::Id::new(("roto-prop-ec", g.uid))).clicked() && !frozen {
actions.push((id2.into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
app.auto.add(&format!("effectControls.roto.{}.propagate", g.uid), b2, l2);
let status = match (app.session.roto_progress(), d.base) {
(Some(pr), _) if running => pr.banner(),
(_, None) => "No strokes: paint with the Roto Brush tool (Alt+W) in the Layer panel".to_string(),
(_, Some(b)) => format!("Base frame {b}, span {}–{}{}", d.span[0], d.span[1], if frozen { " (frozen)" } else { "" }),
};
p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, status, Tokens::ui(11.5), t.text_dim);
}
// ---------------------------------------------------------------------------------------------
// Curves / Levels editors
@ -545,6 +574,7 @@ fn editor_height(effect: &str, width: f32) -> f32 {
match effect {
"ec.color.curves" => 34.0 + curves_size(width) + 26.0,
effectcraft_engine::effects::warp_stab::ID => 50.0,
effectcraft_engine::effects::roto::ID => 50.0,
"ec.color.levels" | "ec.color.levelsic" => 34.0 + 80.0 + 58.0,
"ec.color.autolevels" | "ec.color.autocontrast" | "ec.color.autocolor" => 24.0 + 80.0 + 12.0,
_ => 0.0,
@ -920,6 +950,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
match effect.as_str() {
"ec.color.curves" => curves_editor(app, ui, &bp, &layer, g, &ectx, er, &mut actions),
effectcraft_engine::effects::warp_stab::ID => warp_editor(app, ui, &bp, &layer, g, er, &mut actions),
effectcraft_engine::effects::roto::ID => roto_editor(app, ui, &bp, &layer, g, er, &mut actions),
"ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions),
_ => histogram_only(app, ui, &bp, g, er),
}

View File

@ -3,9 +3,13 @@
//!
//! The image is the layer's source and masks followed by its effects up to the View menu's
//! choice (by default the last Paint effect, as After Effects shows "Paint"). Dragging with a
//! paint tool records the stroke's points in layer space and, on release, runs `paint.stroke`
//! paint tools records the stroke's points in layer space and, on release, runs `paint.stroke`
//! at the current time (the drag's duration feeds Write On). Alt/Option-click with the Clone
//! Stamp sets the clone source.
//!
//! With a Roto Brush & Refine Edge effect the panel shows the effect's input with the matte
//! overlay, the segmentation span bar and the view / Freeze buttons; the Roto Brush and Refine
//! Edge tools paint their strokes here (see [`super::roto_tool`]).
use effectcraft_engine::effects::paint;
use effectcraft_engine::project::{Layer, LayerId};
@ -46,9 +50,15 @@ pub fn current_layer(app: &EffectcraftApp) -> Option<Layer> {
comp.layer(id).cloned()
}
/// Number of effects shown by default: through the last Paint effect (none when there isn't one).
fn default_view(layer: &Layer) -> usize {
layer.effects().and_then(|fx| fx.groups().enumerate().filter(|(_, g)| paint::is_paint(g)).map(|(i, _)| i + 1).last()).unwrap_or(0)
/// Number of effects shown by default: the Roto Brush's input while a Roto Brush tool is active
/// (or when there is no Paint effect), else through the last Paint effect (none when there
/// isn't one).
fn default_view(layer: &Layer, tool: Tool) -> usize {
let paint = layer.effects().and_then(|fx| fx.groups().enumerate().filter(|(_, g)| paint::is_paint(g)).map(|(i, _)| i + 1).last());
match super::roto_tool::roto_index(layer) {
Some(r) if tool.roto_kind(false).is_some() || paint.is_none() => r,
_ => paint.unwrap_or(0),
}
}
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
@ -64,7 +74,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let comp = app.session.project.comp(cid).cloned().unwrap_or_else(|| effectcraft_engine::project::Comp::new(1, 1, Default::default(), Default::default()));
let time = app.session.time();
let fx_count = layer.effects().map(|f| f.groups().count()).unwrap_or(0);
let view = app.ui.layer_view.map(|v| v.min(fx_count)).unwrap_or_else(|| default_view(&layer));
let view = app.ui.layer_view.map(|v| v.min(fx_count)).unwrap_or_else(|| default_view(&layer, app.ui.tool));
let roto = super::roto_tool::roto_index(&layer);
// Title strip.
let top = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0));
@ -90,9 +101,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let tc = super::timecode(&app.session, &comp, time);
p.text(pos2(bar.max.x - 10.0, bar.center().y), Align2::RIGHT_CENTER, tc, Tokens::mono(12.0), t.timecode);
// Roto Brush: view / Freeze buttons and the segmentation span bar.
let mut canvas_bottom = bar.min.y;
if roto.is_some() {
super::roto_tool::bar_buttons(app, ui, &p, bar, vr.max.x + 12.0, &layer);
let sb = Rect::from_min_max(pos2(rect.min.x, bar.min.y - 18.0), pos2(rect.max.x, bar.min.y));
super::roto_tool::span_bar(app, ui, &p, sb, &comp, &layer);
canvas_bottom = sb.min.y;
}
// Canvas.
let area = Rect::from_min_max(pos2(rect.min.x, top.max.y), pos2(rect.max.x, bar.min.y));
let area = Rect::from_min_max(pos2(rect.min.x, top.max.y), pos2(rect.max.x, canvas_bottom));
p.rect_filled(area, 0.0, t.pasteboard);
let (w, h) = effectcraft_engine::render::source_size(&app.session.project, &layer);
let (lw, lh) = if w == 0 { (comp.width as f64, comp.height as f64) } else { (w as f64, h as f64) };
@ -139,10 +158,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let r = Rect::from_min_max(to_screen([tx.rect[0], tx.rect[1]]), to_screen([tx.rect[2], tx.rect[3]]));
painter.image(tx.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
if roto.is_some_and(|r| r == view) {
super::roto_tool::draw_overlay(app, &ctx, &painter, &comp, &layer, scale, &to_screen);
}
painter.rect_stroke(frame, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside);
app.auto.add("layerPanel.canvas", frame, &layer.name);
app.auto.add("layerPanel.area", area, "Layer panel");
// Interaction: Roto Brush / Refine Edge.
if app.ui.tool.roto_kind(false).is_some() {
super::roto_tool::interact(app, ui, &painter, area, &layer, zoom, &to_screen, &to_layer);
return;
}
// Interaction: paint tools.
let resp = ui.interact(area, egui::Id::new("layer-panel-interact"), Sense::click_and_drag());
let kind = app.ui.tool.paint_kind();

View File

@ -21,6 +21,7 @@ pub mod project;
pub mod properties;
pub mod puppet_tool;
pub mod render_queue;
pub mod roto_tool;
pub mod script_console;
pub mod settings;
pub mod shortcut_editor;

View File

@ -0,0 +1,331 @@
//! Roto Brush and Refine Edge tools in the Layer panel: green foreground strokes, Alt/Option red
//! background strokes, Refine Edge strokes along soft edges; the matte overlay (Alpha Boundary in
//! pink, Alpha, Alpha Overlay), the segmentation span bar and the Freeze button.
use effectcraft_engine::effects::roto as fx;
use effectcraft_engine::project::{Comp, Layer};
use effectcraft_engine::render::{EvalCtx, RenderOpts, Renderer};
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::json;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
/// Position of the layer's first Roto Brush & Refine Edge effect.
pub fn roto_index(layer: &Layer) -> Option<usize> {
layer.effects()?.groups().position(|g| matches!(&g.kind, effectcraft_engine::project::GroupKind::Effect { effect } if effect == fx::ID))
}
fn roto_group(layer: &Layer) -> Option<&effectcraft_engine::project::PropGroup> {
layer.effects()?.groups().nth(roto_index(layer)?)
}
/// A stroke being drawn.
#[derive(Clone, Debug)]
struct Drag {
layer: u64,
kind: &'static str,
points: Vec<[f64; 2]>,
}
#[derive(Clone)]
struct Overlay {
key: (u64, u64, i64, u32, String),
tex: egui::TextureHandle,
rect: [f64; 4],
}
fn c32(c: [f64; 3], a: u8) -> Color32 {
Color32::from_rgba_unmultiplied((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8, a)
}
/// Draw the matte overlay of the Roto Brush on `layer` (the panel shows the effect's input).
pub fn draw_overlay(
app: &mut EffectcraftApp,
ctx: &egui::Context,
painter: &egui::Painter,
comp: &Comp,
layer: &Layer,
scale: f64,
to_screen: &dyn Fn([f64; 2]) -> Pos2,
) {
let Some(idx) = roto_index(layer) else { return };
let o = app.session.state.roto.clone();
if o.view == "none" {
return;
}
let Some(cid) = app.session.active_comp_id() else { return };
let time = app.session.time();
let key = (
app.session.revision,
layer.id.0,
time.0,
(scale * 1000.0) as u32,
format!("{}{:?}{:?}{}", o.view, o.overlay_color, o.boundary_color, o.overlay_opacity),
);
let id = egui::Id::new("roto-overlay");
let cached: Option<Overlay> = ctx.data(|d| d.get_temp(id));
let ov = match cached {
Some(c) if c.key == key => Some(c),
_ => {
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref() };
let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() });
r.expr = app.session.expr.as_deref();
r.cache = Some(&app.session.layer_cache);
let (Some(inp), Some(out)) = (r.layer_input(&ectx, layer, idx), r.layer_input(&ectx, layer, idx + 1)) else { return };
let (w, h) = (inp.img.width as usize, inp.img.height as usize);
let a_at = |x: i64, y: i64| -> f32 {
// Output pixel at the same layer point.
let lx = (x as f64 + 0.5 - inp.offset[0]) / inp.scale;
let ly = (y as f64 + 0.5 - inp.offset[1]) / inp.scale;
let (ox, oy) = out.to_px([lx, ly]);
out.img.get(ox.floor() as i64, oy.floor() as i64)[3]
};
let alpha: Vec<f32> = (0..w * h).map(|i| a_at((i % w) as i64, (i / w) as i64)).collect();
let inside = |x: i64, y: i64| x >= 0 && y >= 0 && x < w as i64 && y < h as i64 && alpha[y as usize * w + x as usize] >= 0.5;
let mut px = vec![Color32::TRANSPARENT; w * h];
let bc = c32(o.boundary_color, 255);
let oc = c32(o.overlay_color, (o.overlay_opacity / 100.0 * 255.0).clamp(0.0, 255.0) as u8);
// View Search Region: the band around the boundary (yellow).
let search = roto_group(layer).filter(|g| g.prop("rotoBrushMatte/viewSearchRegion").is_some_and(|p| p.value.as_bool())).map(|g| {
let r = g.prop("rotoBrushMatte/searchRadius").map(|p| p.value.as_f64()).unwrap_or(15.0) * scale;
let bin: Vec<u8> = alpha.iter().map(|a| (*a >= 0.5) as u8).collect();
let sd = effectcraft_engine::track::roto::matting::signed_distance(&bin, w, h);
(sd, r as f32)
});
for y in 0..h as i64 {
for x in 0..w as i64 {
let i = y as usize * w + x as usize;
let a = alpha[i];
px[i] = match o.view.as_str() {
"alpha" => {
let v = (a.clamp(0.0, 1.0) * 255.0) as u8;
Color32::from_rgb(v, v, v)
}
"alphaOverlay" if a < 0.5 => oc,
"alphaOverlay" => Color32::TRANSPARENT,
_ => {
let me = a >= 0.5;
let edge = me && [(-1, 0), (1, 0), (0, -1), (0, 1)].iter().any(|(dx, dy)| !inside(x + dx, y + dy));
if edge { bc } else { Color32::TRANSPARENT }
}
};
if let Some((sd, r)) = &search {
let d = sd[i].abs();
if (d - r).abs() < 0.75 {
px[i] = Color32::from_rgb(255, 220, 0);
}
}
}
}
let img = egui::ColorImage { size: [w, h], pixels: px, source_size: egui::Vec2::new(w as f32, h as f32) };
let x0 = -inp.offset[0] / inp.scale;
let y0 = -inp.offset[1] / inp.scale;
let rect = [x0, y0, x0 + w as f64 / inp.scale, y0 + h as f64 / inp.scale];
let tex = ctx.load_texture("roto-overlay", img, egui::TextureOptions::NEAREST);
let c = Overlay { key, tex, rect };
ctx.data_mut(|d| d.insert_temp(id, c.clone()));
Some(c)
}
};
if let Some(ov) = ov {
let r = Rect::from_min_max(to_screen([ov.rect[0], ov.rect[1]]), to_screen([ov.rect[2], ov.rect[3]]));
painter.image(ov.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
}
/// Pointer handling for the Roto Brush / Refine Edge tools. Returns true when it handled input.
pub fn interact(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
painter: &egui::Painter,
area: Rect,
layer: &Layer,
zoom: f64,
to_screen: &dyn Fn([f64; 2]) -> Pos2,
to_layer: &dyn Fn(Pos2) -> [f64; 2],
) -> bool {
let ctx = ui.ctx().clone();
let mods = ui.input(|i| i.modifiers);
let Some(kind) = app.ui.tool.roto_kind(mods.alt) else { return false };
let resp = ui.interact(area, egui::Id::new("layer-panel-roto"), Sense::click_and_drag());
let o = app.session.state.roto.clone();
let refine = kind.starts_with("refine");
let dia = if refine { o.refine_diameter } else { o.diameter };
let col = match kind {
"fg" => Color32::from_rgb(40, 220, 60),
"bg" => Color32::from_rgb(235, 50, 40),
"refine" => Color32::from_rgb(110, 200, 255),
_ => Color32::from_rgb(255, 160, 60),
};
if let Some(hp) = resp.hover_pos() {
let lp = to_layer(hp);
app.pointer_comp = Some([lp[0] as f32, lp[1] as f32]);
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
let r = (dia * 0.5 * zoom) as f32;
painter.circle_stroke(hp, r, Stroke::new(1.5, col));
let sign = if kind == "bg" || kind == "refineErase" { "−" } else { "+" };
painter.text(hp, Align2::CENTER_CENTER, sign, Tokens::semibold(12.0), col);
}
let did = egui::Id::new("layer-panel-roto-drag");
if resp.drag_started()
&& let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
{
ctx.data_mut(|d| d.insert_temp(did, Drag { layer: layer.id.0, kind, points: vec![to_layer(pos)] }));
}
if let Some(mut d) = ctx.data(|d| d.get_temp::<Drag>(did)) {
if let Some(pos) = resp.interact_pointer_pos() {
let lp = to_layer(pos);
let last = d.points.last().copied().unwrap_or(lp);
if (lp[0] - last[0]).hypot(lp[1] - last[1]) * zoom >= 1.5 {
d.points.push(lp);
ctx.data_mut(|dd| dd.insert_temp(did, d.clone()));
}
}
let width = ((dia * zoom) as f32).max(1.0);
let pts: Vec<Pos2> = d.points.iter().map(|q| to_screen(*q)).collect();
let c = col.gamma_multiply(0.6);
if pts.len() == 1 {
painter.circle_filled(pts[0], width / 2.0, c);
} else {
painter.add(egui::Shape::line(pts, Stroke::new(width, c)));
}
if resp.drag_stopped() {
ctx.data_mut(|dd| dd.remove::<Drag>(did));
run_stroke(app, d.layer, d.kind, &d.points, dia / 2.0);
}
} else if resp.clicked()
&& let Some(pos) = resp.interact_pointer_pos()
{
run_stroke(app, layer.id.0, kind, &[to_layer(pos)], dia / 2.0);
}
true
}
fn run_stroke(app: &mut EffectcraftApp, layer: u64, kind: &str, points: &[[f64; 2]], radius: f64) {
let r = app.session.execute("roto.stroke", json!({"layer": layer, "kind": kind, "points": points, "radius": radius}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
}
/// The segmentation span bar (above the panel's bottom bar): the layer's frames, the span, the
/// base frame (yellow), computed frames (green) and stroke frames. Click to go to a frame; drag
/// the span's ends to change it.
pub fn span_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, comp: &Comp, layer: &Layer) {
let t = app.tokens;
let Some(g) = roto_group(layer) else { return };
let Some(cid) = app.session.active_comp_id() else { return };
let uid = g.uid;
let params = effectcraft_engine::effects::flatten_params(g, &mut |p| p.value.clone());
let d = fx::data(&params);
p.rect_filled(r, 0.0, t.panel_bg);
let lim = effectcraft_engine::roto::frame_limits(comp, layer);
let n = (lim[1] - lim[0] + 1).max(1) as f32;
let track = Rect::from_min_max(pos2(r.min.x + 10.0, r.min.y + 4.0), pos2(r.max.x - 10.0, r.max.y - 4.0));
let x_of = |f: f64| track.min.x + ((f - lim[0] as f64) / n as f64) as f32 * track.width();
let f_of = |x: f32| (lim[0] as f64 + ((x - track.min.x) / track.width()).clamp(0.0, 0.9999) as f64 * n as f64).floor() as i64;
p.rect_filled(track, 2.0, t.pasteboard);
if let Some(base) = d.base {
let sp = Rect::from_min_max(pos2(x_of(d.span[0] as f64), track.min.y), pos2(x_of(d.span[1] as f64 + 1.0), track.max.y));
let frozen = fx::is_frozen(&params);
p.rect_filled(sp, 2.0, if frozen { Color32::from_rgb(60, 110, 190) } else { Color32::from_rgb(90, 90, 96) });
for (f, done) in app.session.roto_computed(cid, layer.id, uid) {
if done {
let cr = Rect::from_min_max(pos2(x_of(f as f64), track.max.y - 4.0), pos2(x_of(f as f64 + 1.0), track.max.y));
p.rect_filled(cr, 0.0, Color32::from_rgb(70, 200, 90));
}
}
for f in d.stroke_frames() {
let x = x_of(f as f64 + 0.5);
p.line_segment([pos2(x, track.min.y), pos2(x, track.min.y + 5.0)], Stroke::new(2.0, Color32::WHITE));
}
let bx = x_of(base as f64 + 0.5);
p.rect_filled(Rect::from_center_size(pos2(bx, track.center().y), vec2(6.0, track.height())), 1.0, Color32::from_rgb(240, 200, 40));
}
// Current time.
let fps = comp.frame_rate.as_f64();
let cur = fx::frame_of(layer.layer_time(app.session.time()).seconds(), fps);
let cx = x_of(cur as f64 + 0.5);
p.line_segment([pos2(cx, r.min.y), pos2(cx, r.max.y)], Stroke::new(1.0, t.timecode));
p.rect_stroke(track, 2.0, Stroke::new(1.0, Color32::from_black_alpha(140)), StrokeKind::Outside);
app.auto.add("layerPanel.rotoSpan", track, "Segmentation span");
let resp = ui.interact(track, egui::Id::new("roto-span-bar"), Sense::click_and_drag());
let hid = egui::Id::new("roto-span-handle");
if resp.drag_started()
&& let (Some(pos), Some(_)) = (resp.interact_pointer_pos(), d.base)
{
let (a, b) = (x_of(d.span[0] as f64), x_of(d.span[1] as f64 + 1.0));
let h = if (pos.x - a).abs() < 6.0 {
1
} else if (pos.x - b).abs() < 6.0 {
2
} else {
0
};
ui.ctx().data_mut(|m| m.insert_temp(hid, h));
}
let handle: i32 = ui.ctx().data(|m| m.get_temp(hid)).unwrap_or(0);
if let Some(pos) = resp.interact_pointer_pos() {
let f = f_of(pos.x);
if handle == 0 && (resp.clicked() || resp.dragged()) {
let t = layer.comp_time(comp.frame_rate.tick_of(f));
let _ = app.session.execute("time.set", json!({"time": t.seconds()}));
}
if handle != 0 && resp.dragged() {
p.line_segment([pos2(pos.x, track.min.y), pos2(pos.x, track.max.y)], Stroke::new(2.0, Color32::from_rgb(240, 200, 40)));
}
if handle != 0 && resp.drag_stopped() {
let k = if handle == 1 { "start" } else { "end" };
if let Err(e) = app.session.execute("roto.span", json!({"layer": layer.id.0, "effect": uid, k: f})) {
app.ui.status = e.to_string();
}
}
}
if resp.drag_stopped() {
ui.ctx().data_mut(|m| m.remove::<i32>(hid));
}
}
/// View-mode toggles and Freeze / Propagate buttons in the Layer panel's bottom bar, from `x`.
pub fn bar_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: Rect, x: f32, layer: &Layer) {
let t = app.tokens;
let mut x = x;
let view = app.session.state.roto.view.clone();
for (id, label) in [("alpha", "Alpha"), ("alphaBoundary", "Boundary"), ("alphaOverlay", "Overlay")] {
let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(66.0, 20.0));
let on = view == id;
if widgets::text_button(ui, r, label, on, &t, egui::Id::new(("roto-view", id))).clicked() {
let v = if on { "none" } else { id };
let _ = app.session.execute("roto.options", json!({"view": v}));
}
app.auto.add(&format!("layerPanel.roto.view.{id}"), r, label);
x += 70.0;
}
let Some(g) = roto_group(layer) else { return };
let uid = g.uid;
let params = effectcraft_engine::effects::flatten_params(g, &mut |p| p.value.clone());
let frozen = fx::is_frozen(&params);
let running = app.session.is_roto_running();
x += 8.0;
let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(74.0, 20.0));
let label = if frozen { "Unfreeze" } else { "Freeze" };
if widgets::text_button(ui, r, label, frozen, &t, egui::Id::new(("roto-freeze", uid))).clicked() {
let id = if frozen { "roto.unfreeze" } else { "roto.freeze" };
if let Err(e) = app.session.execute(id, json!({"layer": layer.id.0, "effect": uid})) {
app.ui.status = e.to_string();
}
}
app.auto.add("layerPanel.roto.freeze", r, label);
x += 80.0;
if running {
let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(60.0, 20.0));
if widgets::text_button(ui, r, "Stop", false, &t, egui::Id::new("roto-stop")).clicked() {
let _ = app.session.execute("roto.cancel", json!({}));
}
app.auto.add("layerPanel.roto.stop", r, "Stop");
if let Some(pr) = app.session.roto_progress() {
p.text(pos2(r.max.x + 8.0, bar.center().y), Align2::LEFT_CENTER, pr.banner(), Tokens::ui(11.5), t.text_dim);
}
}
}

View File

@ -34,6 +34,7 @@ pub enum Tool {
Clone,
Eraser,
RotoBrush,
RefineEdge,
Puppet,
PuppetStarch,
PuppetBend,
@ -58,10 +59,10 @@ impl Tool {
&[Tool::Brush],
&[Tool::Clone],
&[Tool::Eraser],
&[Tool::RotoBrush],
&[Tool::RotoBrush, Tool::RefineEdge],
&[Tool::Puppet, Tool::PuppetStarch, Tool::PuppetBend, Tool::PuppetAdvanced, Tool::PuppetOverlap],
];
pub const ALL: [Tool; 29] = [
pub const ALL: [Tool; 30] = [
Tool::Selection,
Tool::Hand,
Tool::Zoom,
@ -86,6 +87,7 @@ impl Tool {
Tool::Clone,
Tool::Eraser,
Tool::RotoBrush,
Tool::RefineEdge,
Tool::Puppet,
Tool::PuppetStarch,
Tool::PuppetBend,
@ -118,6 +120,7 @@ impl Tool {
Tool::Clone => "Clone Stamp Tool",
Tool::Eraser => "Eraser Tool",
Tool::RotoBrush => "Roto Brush Tool",
Tool::RefineEdge => "Refine Edge Tool",
Tool::Puppet => "Puppet Position Pin Tool",
Tool::PuppetStarch => "Puppet Starch Pin Tool",
Tool::PuppetBend => "Puppet Bend Pin Tool",
@ -139,7 +142,7 @@ impl Tool {
Tool::Pen | Tool::PenAdd | Tool::PenDelete | Tool::PenConvert | Tool::MaskFeather => Some("G"),
Tool::Type | Tool::TypeVertical => Some("Cmd+T"),
Tool::Brush | Tool::Clone | Tool::Eraser => Some("Cmd+B"),
Tool::RotoBrush => Some("Alt+W"),
Tool::RotoBrush | Tool::RefineEdge => Some("Alt+W"),
Tool::Puppet | Tool::PuppetStarch | Tool::PuppetBend | Tool::PuppetAdvanced | Tool::PuppetOverlap => Some("Cmd+P"),
}
}
@ -169,6 +172,7 @@ impl Tool {
Tool::Clone => Icon::Clone,
Tool::Eraser => Icon::Eraser,
Tool::RotoBrush => Icon::RotoBrush,
Tool::RefineEdge => Icon::RefineEdge,
Tool::Puppet | Tool::PuppetStarch | Tool::PuppetBend | Tool::PuppetAdvanced | Tool::PuppetOverlap => Icon::Puppet,
}
}
@ -187,6 +191,17 @@ impl Tool {
_ => return None,
})
}
/// Roto Brush / Refine Edge: the stroke kind they paint (`roto.stroke` kind), without and
/// with Alt/Option.
pub fn roto_kind(self, alt: bool) -> Option<&'static str> {
Some(match (self, alt) {
(Tool::RotoBrush, false) => "fg",
(Tool::RotoBrush, true) => "bg",
(Tool::RefineEdge, false) => "refine",
(Tool::RefineEdge, true) => "refineErase",
_ => return None,
})
}
/// Paint tools, with their stroke kind (`paint.stroke` kind).
pub fn paint_kind(self) -> Option<&'static str> {
Some(match self {

View File

@ -147,6 +147,28 @@ effectcraft-cli exec warp.analyze '{"layer":"#1","wait":true}' shaky.ecproj --sa
effectcraft-cli exec warp.status '{"layer":"#1"}' shaky.ecproj --json
```
### Roto Brush & Refine Edge
The Roto Brush tool (Alt+W cycles Roto Brush / Refine Edge) paints in the Layer panel; agents use
`roto.stroke {layer, kind: fg|bg|refine|refineErase, points: [[x, y], …], frame?, radius?}`
(layer pixels, layer frames). The first foreground stroke applies the Roto Brush & Refine Edge
effect and sets the base frame with a span of 20 frames each side. `roto.propagate {layer,
direction?: forward|backward|both, to?, wait?}` segments the span (in the background unless
`wait`); strokes on any other frame correct it and propagation restarts from there.
`roto.span {start?, end?}`, `roto.freeze {wait?}` / `roto.unfreeze`, `roto.clearStrokes {frame?,
kind?}`, `roto.cancel` and `roto.options {diameter?, refineDiameter?, view?: alphaBoundary|alpha|
alphaOverlay|none}` complete the set; every edit is one undo step. `roto.status {layer, frame?,
matte?, compute?, compareTo?}` reports the base frame, span, computed and stroked frames, frozen
state, job progress and, for a frame, the matte's area, centroid, RLE matte and IoU against a
reference. Matte settings are ordinary properties (`effects/#1/rotoBrushMatte/searchRadius`,
`effects/#1/refineEdgeMatte/decontaminateEdgeColors`, …).
```sh
effectcraft-cli run clip.ecproj roto.stroke '{"layer":"#1","points":[[300,200],[360,230]],"radius":10}' \
roto.stroke '{"layer":"#1","kind":"bg","points":[[40,40],[600,40]],"radius":12}' \
roto.propagate '{"layer":"#1","wait":true}' roto.freeze '{"layer":"#1","wait":true}' --save
```
### Text: styles and editing
Source Text holds character style runs and per-paragraph settings. `layer.setText` changes the

View File

@ -207,6 +207,19 @@ auto-scale; cached per analysis and settings) and warps each frame; Synthesize E
borders from neighbouring frames read with `EffectHost::self_at`. The effect lives in the
effects crate, hence the `effects → track` edge.
**Roto Brush & Refine Edge** (`effectcraft_track::roto`, `effects::roto`, `engine::roto`):
strokes (foreground, background, Refine Edge) are stored as JSON in the effect's hidden Strokes
parameter with the base frame and segmentation span. Each frame is segmented by graph cut
(Boykov–Jolly hard constraints, GrabCut colour mixtures, our own Boykov–Kolmogorov max-flow,
coarse to fine) and propagated to the next frame by warping the matte with block optical flow and
re-cutting in a Search Radius band; frames with correction strokes are re-cut with the warped
matte as a soft prior. Refine Edge bands get guided-filter + closed-form matting and
foreground-colour decontamination. Segmentations are derived data: each frame's chain key (Input
Key, settings, strokes from the base frame out) indexes a process-wide cache that renders fill on
demand (`self_at`) and `roto.propagate` fills in the background; `Session::edit` keeps the Input
Key in step with the layer's source, masks and upstream effects. Freeze stores final 8-bit mattes
in the effect.
## 5. Expressions
Expressions are JavaScript, run by boa, with After Effects' object model (`thisComp`, `thisLayer`,

View File

@ -73,11 +73,11 @@ library groundwork for mask tracking and Warp Stabilizer.
| Remaining (audit-scale agent-hours) | ≈ 167 | ≈ 100 |
| Wall clock, five parallel agents | 42–50 h | **≈ 8–10 h at today's measured pace; ≈ 25 h by the audit's conservative figures** |
What is left is concentrated in large systems: Roto Brush and Refine Edge, the 3D camera tracker,
What is left is concentrated in large systems: the 3D camera tracker,
the Warp Stabilizer and mask-tracking UI on top of
the new tracking library, SVG/PSD import, WebM, a disk cache,
native macOS menus, and wasm threads. (The missing effect categories, 3D Channel, Immersive Video
and OCIO, landed in M9.11; the scripting object model in M14.4.)
and OCIO, landed in M9.11; the scripting object model in M14.4; Roto Brush and Refine Edge in M6.7.)
## By area
@ -97,7 +97,7 @@ and OCIO, landed in M9.11; the scripting object model in M14.4.)
| Effects | ≈ 75% | 5.0 | GPU versions of more effects and the 3D Camera Tracker (every other After Effects effect exists since M9.11 and M12.5) |
| Interface | 55% | 10.4 | drag-to-dock and floating panels, preferences, native macOS menus (viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Project | 53% | 8.1 | 8/16/32-bit pipeline and colour management, auto-save, proxies, folder moves |
| Masks & roto | 52% | 10.0 | Roto Brush, variable-width mask feather points (mask tracking and Mask Interpolation landed in M6.6) |
| Masks & roto | 68% | 6.0 | variable-width mask feather points, Roto Brush's learned (3.0) segmentation model (mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
| Preview | 55% | 6.1 | GPU 3D and adjustment layers, disk cache (region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
| Tracking | ≈ 70% | 4.0 | 3D camera tracker, face tracking and Subspace Warp's mesh warp (Rolling Shutter Repair landed in M9.11; point tracker, mask tracking and Warp Stabilizer landed in M6.x / M12.5) |
| Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |