Skip to main content

long_screen/kernels/
layers.rs

1//! Per-frame layer-learning evidence (originally `LayerLearner.add`/`addNative` in TS, now frozen verbatim as
2//! the parity oracle at `tests/support/reference/layers.ts`): screen-space motion
3//! discontinuities accumulated across the WHOLE recording. `finish()` — the one-shot region construction that
4//! reads these accumulators — stays with the adapter for now; every per-frame pixel loop lives here.
5//!
6//! Accumulation order and arithmetic are those of the historical adapter (f64 sums in the same order, JS
7//! `Math.hypot` for motion norms) so the accumulators match bit-for-bit.
8
9use crate::kernels::geometry::js_hypot;
10use crate::kernels::pool::SyncPtr;
11
12pub struct FieldMotion {
13    pub x: f64,
14    pub y: f64,
15    pub support: u32,
16    pub confidence: f64,
17}
18
19/// One motion field over `cols × rows` analysis cells.
20pub struct Field<'a> {
21    pub motions: &'a [FieldMotion],
22    pub labels: &'a [u8],
23    pub confidence: &'a [u8],
24    pub dynamic: &'a [u8],
25    pub cols: usize,
26    pub rows: usize,
27    pub difference: f64,
28    pub unknown: bool,
29}
30
31#[inline]
32fn norm(m: &FieldMotion) -> f64 {
33    js_hypot(m.x, m.y)
34}
35
36/// A field carries real evidence only once some model moved more than analysis jitter and is reasonably well
37/// matched; per-frame and long-baseline evidence are both held to this bar.
38/// Mean change over a column's sampled rows, in analysis grey levels, above which the column moved in that frame
39/// (`Learner::col_moving`). Still content changes by less on a decoded recording; moving content by far more.
40pub const MOVING_COLUMN: f64 = 1.0;
41
42pub fn informative(field: &Field<'_>) -> bool {
43    !field.unknown
44        && field.difference >= 0.2
45        && field
46            .motions
47            .iter()
48            .any(|m| norm(m) > 1.0 && m.confidence > 0.3)
49}
50
51pub struct Learner {
52    pub width: usize,
53    pub height: usize,
54    pub cell: usize,
55    pub cols: usize,
56    pub rows: usize,
57    pub split: Vec<f64>,
58    pub evidence: Vec<f64>,
59    pub activity: Vec<f64>,
60    pub observations: Vec<f64>,
61    pub row_fixed: Vec<f64>,
62    pub row_moving: Vec<f64>,
63    /// `row_fixed`/`row_moving` per analysis pixel (`y * width + x`, only the sampled pixels ever count): how often
64    /// staying put explained the pixel better than the strongest moving model, and the other way round. A region
65    /// whose pixels are mostly the former is screen-fixed UI (`regions::merge::freeze_stationary_regions`).
66    pub pixel_fixed: Vec<f64>,
67    pub pixel_moving: Vec<f64>,
68    pub informative_frames: u32,
69    pub row_change: Vec<f64>,
70    pub col_change: Vec<f64>,
71    /// Informative frames in which the column moved: its mean change over the sampled rows exceeded
72    /// `MOVING_COLUMN`. Unlike `col_change`, a few frames of large change (the whole window bouncing at the end of a
73    /// page) do not outweigh many frames of none.
74    pub col_moving: Vec<f64>,
75    pub col_mean: Vec<f64>,
76    pub col_gain: Vec<f64>,
77    pub horizontal_gain: Vec<f64>,
78    /// Native-resolution statistics, allocated on the first native pair.
79    pub native_row_change: Vec<f64>,
80    pub native_col_change: Vec<f64>,
81    pub native_col_mean: Vec<f64>,
82    pub native_frames: u32,
83    pub native_width: usize,
84    pub native_height: usize,
85}
86
87impl Learner {
88    pub fn new(width: usize, height: usize) -> Learner {
89        let cell = 24;
90        let cols = width.div_ceil(cell);
91        let rows = height.div_ceil(cell);
92        let n = cols * rows;
93        Learner {
94            width,
95            height,
96            cell,
97            cols,
98            rows,
99            split: vec![0.0; n * 2],
100            evidence: vec![0.0; n * 2],
101            activity: vec![0.0; n],
102            observations: vec![0.0; n],
103            row_fixed: vec![0.0; height],
104            row_moving: vec![0.0; height],
105            pixel_fixed: vec![0.0; width * height],
106            pixel_moving: vec![0.0; width * height],
107            informative_frames: 0,
108            row_change: vec![0.0; height],
109            col_change: vec![0.0; width],
110            col_moving: vec![0.0; width],
111            col_mean: vec![0.0; width],
112            col_gain: vec![0.0; cols],
113            horizontal_gain: vec![0.0; rows],
114            native_row_change: Vec::new(),
115            native_col_change: Vec::new(),
116            native_col_mean: Vec::new(),
117            native_frames: 0,
118            native_width: 0,
119            native_height: 0,
120        }
121    }
122
123    /// Adds one informative field. `prev`/`current` are analysis grays (width × height); the native pair is
124    /// optional and must share one size (a size change is ignored, as before). Returns false when the field was
125    /// not informative and nothing was accumulated.
126    pub fn add(
127        &mut self,
128        field: &Field<'_>,
129        prev: &[u8],
130        current: &[u8],
131        native: Option<(&[u8], &[u8], usize, usize)>,
132    ) -> bool {
133        if !informative(field) {
134            return false;
135        }
136        self.informative_frames += 1;
137        if let Some((a, b, w, h)) = native {
138            self.add_native(a, b, w, h);
139        }
140        let (width, height, cols, rows) = (self.width, self.height, self.cols, self.rows);
141        for y in 0..height {
142            let (mut sum, mut n) = (0.0f64, 0u32);
143            let mut x = 0;
144            while x < width {
145                let i = y * width + x;
146                sum += (prev[i] as i32 - current[i] as i32).abs() as f64;
147                n += 1;
148                x += 3;
149            }
150            self.row_change[y] += sum / (n.max(1) as f64);
151        }
152        // Column sums walk the sampled rows in order with one accumulator per column: each column still adds
153        // its samples top to bottom, so every float sum is the historical one, without a strided column walk.
154        let (mut change, mut mean) = (vec![0.0f64; width], vec![0.0f64; width]);
155        let mut n = 0u32;
156        let mut y = 0;
157        while y < height {
158            let (p, c) = (
159                &prev[y * width..(y + 1) * width],
160                &current[y * width..(y + 1) * width],
161            );
162            for x in 0..width {
163                change[x] += (p[x] as i32 - c[x] as i32).abs() as f64;
164                mean[x] += c[x] as f64;
165            }
166            n += 1;
167            y += 3;
168        }
169        for x in 0..width {
170            self.col_change[x] += change[x] / n as f64;
171            if change[x] / n as f64 > MOVING_COLUMN {
172                self.col_moving[x] += 1.0;
173            }
174            self.col_mean[x] += mean[x] / n as f64;
175        }
176        let models = field.motions.len();
177        let mut columns = vec![0.0f64; cols * models];
178        let mut lines = vec![0.0f64; rows * models];
179        let informative_frames = self.informative_frames as f64;
180        for y in 0..rows {
181            for x in 0..cols {
182                let i = y * cols + x;
183                let py = (height - 1).min(((y as f64 + 0.5) * self.cell as f64).floor() as usize);
184                // A blank gutter assigned to the zero-motion model is not a second pane. Global cuts require
185                // disagreement between independently MOVING populations; fixed chrome is learned separately.
186                let label = field.labels[i] as usize;
187                if field.confidence[i] <= 110
188                    || field.dynamic[i] != 0
189                    || norm(&field.motions[label]) <= 1.0
190                    || self.row_change[py] / informative_frames < 0.9
191                {
192                    continue;
193                }
194                let w = field.confidence[i] as f64 / 255.0;
195                columns[x * models + label] += w;
196                lines[y * models + label] += w;
197            }
198        }
199        gain(&columns, models, &mut self.col_gain);
200        gain(&lines, models, &mut self.horizontal_gain);
201        for y in 0..rows {
202            for x in 0..cols {
203                let i = y * cols + x;
204                let m = &field.motions[field.labels[i] as usize];
205                let c = field.confidence[i] as f64 / 255.0;
206                if c > 0.25 && field.dynamic[i] == 0 {
207                    self.activity[i] += norm(m) * c;
208                    self.observations[i] += c;
209                }
210                let neighbours = [
211                    if x + 1 < cols { Some(i + 1) } else { None },
212                    if y + 1 < rows { Some(i + cols) } else { None },
213                ];
214                for (k, j) in neighbours.iter().enumerate() {
215                    let Some(j) = *j else {
216                        continue;
217                    };
218                    if field.dynamic[i] != 0 || field.dynamic[j] != 0 {
219                        continue;
220                    }
221                    let weight = c.min(field.confidence[j] as f64 / 255.0);
222                    if weight < 0.25 {
223                        continue;
224                    }
225                    let other = &field.motions[field.labels[j] as usize];
226                    let disagreement = js_hypot(m.x - other.x, m.y - other.y);
227                    self.evidence[i * 2 + k] += weight;
228                    if disagreement > 1.8 {
229                        self.split[i * 2 + k] += weight;
230                    }
231                }
232            }
233        }
234        // Sub-cell horizontal chrome boundaries: accumulate pixel evidence rather than cropping whole blocks.
235        // The strongest-support moving model (Array#sort is stable, so ties keep motion order).
236        let mut moving: Option<&FieldMotion> = None;
237        for m in field.motions {
238            if norm(m) > 1.0 && m.support >= 4 && moving.is_none_or(|best| m.support > best.support)
239            {
240                moving = Some(m);
241            }
242        }
243        if let Some(moving) = moving {
244            let dx = crate::kernels::geometry::js_round(moving.x) as i64;
245            let dy = crate::kernels::geometry::js_round(moving.y) as i64;
246            let (w, h) = (width as i64, height as i64);
247            for y in 2..(h - 2).max(2) {
248                let mut x = 2i64;
249                while x < w - 2 {
250                    if x + dx < 1 || x + dx >= w - 1 || y + dy < 1 || y + dy >= h - 1 {
251                        x += 4;
252                        continue;
253                    }
254                    let i = (y * w + x) as usize;
255                    let stationary = (prev[i] as i32 - current[i] as i32).abs();
256                    let motion =
257                        (prev[((y + dy) * w + x + dx) as usize] as i32 - current[i] as i32).abs();
258                    if stationary + 6 < motion {
259                        self.row_fixed[y as usize] += 1.0;
260                        self.pixel_fixed[i] += 1.0;
261                    }
262                    if motion + 6 < stationary {
263                        self.row_moving[y as usize] += 1.0;
264                        self.pixel_moving[i] += 1.0;
265                    }
266                    x += 4;
267                }
268            }
269        }
270        true
271    }
272
273    fn add_native(&mut self, a: &[u8], b: &[u8], w: usize, h: usize) {
274        if self.native_row_change.is_empty() {
275            self.native_row_change = vec![0.0; h];
276            self.native_col_change = vec![0.0; w];
277            self.native_col_mean = vec![0.0; w];
278            self.native_width = w;
279            self.native_height = h;
280        }
281        if w != self.native_width || h != self.native_height {
282            // The adapter only ever fed one native size per run; a mismatch here is a caller error.
283            return;
284        }
285        self.native_frames += 1;
286        let step = (w / 480).max(1);
287        let vstep = (h / 300).max(1);
288        // Rows and columns are independent accumulators, so both passes are split across the pool; within a row
289        // or column the samples are added in the historical order, so every float sum is unchanged.
290        let sample = |i: usize| {
291            ((a[i] as i32 - b[i] as i32).abs()
292                + (a[i + 1] as i32 - b[i + 1] as i32).abs()
293                + (a[i + 2] as i32 - b[i + 2] as i32).abs()) as f64
294                / 3.0
295        };
296        let rows_out = SyncPtr(self.native_row_change.as_mut_ptr());
297        let chunks = crate::kernels::pool::chunks_for(h * w.div_ceil(step), 32 * 1024);
298        crate::kernels::pool::par_for(chunks, |c| {
299            for y in crate::kernels::pool::split(h, chunks, c)
300                ..crate::kernels::pool::split(h, chunks, c + 1)
301            {
302                let (mut sum, mut n) = (0.0f64, 0u32);
303                let mut x = 0;
304                while x < w {
305                    sum += sample((y * w + x) * 4);
306                    n += 1;
307                    x += step;
308                }
309                // SAFETY: row `y` belongs to this chunk alone.
310                unsafe { *rows_out.get().add(y) += sum / (n.max(1) as f64) };
311            }
312        });
313        let samples = h.div_ceil(vstep);
314        let (mut change, mut mean) = (vec![0.0f64; w], vec![0.0f64; w]);
315        let (change_out, mean_out) = (SyncPtr(change.as_mut_ptr()), SyncPtr(mean.as_mut_ptr()));
316        let chunks = crate::kernels::pool::chunks_for(w * samples, 32 * 1024);
317        crate::kernels::pool::par_for(chunks, |c| {
318            let (x0, x1) = (
319                crate::kernels::pool::split(w, chunks, c),
320                crate::kernels::pool::split(w, chunks, c + 1),
321            );
322            // SAFETY: columns `x0..x1` belong to this chunk alone.
323            let (change, mean) = unsafe {
324                (
325                    std::slice::from_raw_parts_mut(change_out.get().add(x0), x1 - x0),
326                    std::slice::from_raw_parts_mut(mean_out.get().add(x0), x1 - x0),
327                )
328            };
329            let mut y = 0;
330            while y < h {
331                for x in x0..x1 {
332                    let i = (y * w + x) * 4;
333                    change[x - x0] += sample(i);
334                    mean[x - x0] += (b[i] as f64 + b[i + 1] as f64 + b[i + 2] as f64) / 3.0;
335                }
336                y += vstep;
337            }
338        });
339        let n = samples.max(1) as f64;
340        for x in 0..w {
341            self.native_col_change[x] += change[x] / n;
342            self.native_col_mean[x] += mean[x] / n;
343        }
344    }
345}
346
347/// Split gain of a run of bins (`bins[k * models + m]`): for every cut position k, how much better the best
348/// single model on each side explains its side than the best model explains everything, normalised by weight.
349fn gain(bins: &[f64], models: usize, output: &mut [f64]) {
350    let count = output.len();
351    let mut total = vec![0.0f64; models];
352    let mut left = vec![0.0f64; models];
353    let mut weight = 0.0f64;
354    for k in 0..count {
355        for m in 0..models {
356            total[m] += bins[k * models + m];
357            weight += bins[k * models + m];
358        }
359    }
360    if weight < 10.0 {
361        return;
362    }
363    // Math.max(...total): NaN-free here, and -Infinity for zero models exactly like the spread call.
364    let all = total.iter().copied().fold(f64::NEG_INFINITY, f64::max);
365    for k in 1..count {
366        let (mut a, mut b) = (0.0f64, 0.0f64);
367        for m in 0..models {
368            left[m] += bins[(k - 1) * models + m];
369            a = a.max(left[m]);
370            b = b.max(total[m] - left[m]);
371        }
372        output[k] += (a + b - all) / weight;
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn uninformative_fields_leave_every_accumulator_untouched() {
382        let mut learner = Learner::new(48, 24);
383        let motions = [FieldMotion {
384            x: 0.0,
385            y: 0.0,
386            support: 10,
387            confidence: 1.0,
388        }];
389        let cells = learner.cols * learner.rows;
390        let field = Field {
391            motions: &motions,
392            labels: &vec![0u8; cells],
393            confidence: &vec![255u8; cells],
394            dynamic: &vec![0u8; cells],
395            cols: learner.cols,
396            rows: learner.rows,
397            difference: 1.0,
398            unknown: false,
399        };
400        let gray = vec![7u8; 48 * 24];
401        assert!(!learner.add(&field, &gray, &gray, None));
402        assert_eq!(learner.informative_frames, 0);
403        assert!(learner.row_change.iter().all(|&v| v == 0.0));
404    }
405}