AnyBot/src/marks.rs

510 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// What a bot sees of a screen: the picture with every control it can use boxed and numbered,
// and the same numbers in a list ("[12] 連結「Profile」"). It acts by number; the click lands on
// that control's centre. Guessing coordinates from a picture is where clicks went wrong (a
// model took an empty strip for a menu item and clicked it ten times); a number can't miss.
//
// The controls come from the system's accessibility tree: macOS AX and Linux AT-SPI through
// agent-desktop on a person's computer (desktop.rs), AT-SPI in the bots' own computer
// (box/a11y). Web pages are in those trees too (Chromium with renderer accessibility on).
use std::collections::HashMap;
/// One control on the screen, in screen units (the mouse's: points on a Mac, pixels elsewhere).
#[derive(Clone, Debug, PartialEq)]
pub struct Element {
/// 按鈕, 連結, 輸入框… (already in words).
pub kind: String,
pub name: String,
pub value: String,
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
/// Something to click or type into (gets a number), not just text.
pub active: bool,
pub flags: Vec<&'static str>,
pub window: String,
}
impl Element {
pub fn centre(&self) -> (f64, f64) {
(self.x + self.w / 2.0, self.y + self.h / 2.0)
}
}
/// Most controls numbered on one picture (more is clutter the model can't use).
pub const MAX_MARKS: usize = 150;
/// The numbered controls of the latest look at a screen: number → (centre, words).
#[derive(Clone, Debug, Default)]
pub struct Spots {
pub by_number: HashMap<usize, ((f64, f64), String)>,
}
impl Spots {
/// "12", "e12", "[12]" → the control's centre and what it is.
pub fn get(&self, r: &str) -> Result<((f64, f64), String), String> {
let n = number_of(r).ok_or_else(|| format!("「{}」不是編號:請用截圖上的編號(例如 12)", r.trim()))?;
self.by_number.get(&n).cloned().ok_or_else(|| format!("最新的畫面裡沒有編號 {n}:先用 computer(action=screenshot)看一次畫面再點。"))
}
}
impl Spots {
/// The numbered control called `name` in the latest look (its name as listed: 「4」, 「Close」,
/// 「自動更新」), so a step can say which button the way a person would. Ok(number) or why not.
pub fn named(&self, name: &str) -> Result<usize, String> {
let want = name.trim().trim_matches(['「', '」']);
let name_of = |what: &str| what.split_once('「').and_then(|(_, r)| r.split_once('」')).map(|(n, _)| n.to_string());
let mut hits: Vec<usize> = self.by_number.iter().filter(|(_, (_, what))| name_of(what).as_deref() == Some(want)).map(|(n, _)| *n).collect();
hits.sort();
match hits.as_slice() {
[n] => Ok(*n),
[] => {
// Names that come close (one inside the other, ignoring case), so the step can be
// put right at once instead of guessed again.
let low = want.to_lowercase();
let mut near: Vec<(usize, String)> = self
.by_number
.iter()
.filter_map(|(n, (_, what))| name_of(what).filter(|nm| !nm.is_empty()).map(|nm| (*n, nm)))
.filter(|(_, nm)| {
let l = nm.to_lowercase();
!low.is_empty() && (l.contains(&low) || low.contains(&l))
})
.collect();
near.sort();
near.truncate(6);
if near.is_empty() {
Err(format!("最新的畫面裡沒有叫「{want}」的東西:照最新清單上的名字或編號點,不要猜名字。"))
} else {
Err(format!("最新的畫面裡沒有叫「{want}」的東西。名字相近的有:{}。", near.iter().map(|(n, nm)| format!("[{n}]「{nm}」")).collect::<Vec<_>>().join("、")))
}
}
many => Err(format!("最新的畫面裡有 {} 個叫「{want}」的東西(編號 {}):請用編號指定是哪一個。", many.len(), many.iter().map(|n| n.to_string()).collect::<Vec<_>>().join("、"))),
}
}
}
/// A step that names its control (`name`, no number, no x/y) gets that control's number from
/// the latest look; so does every action of a batch.
pub fn names_to_numbers(v: &mut serde_json::Value, spots: Option<&Spots>) -> Result<(), String> {
let fix = |p: &mut serde_json::Value| -> Result<(), String> {
// ("name" in the screen tools; "text" too, the word the browser tools use.)
let named = ["name", "text"].iter().find_map(|k| p[*k].as_str().map(str::trim).filter(|t| !t.is_empty()).map(String::from));
if let Some(t) = named {
// A name wins over coordinates sent along with it (x: 0, y: 0 from a model filling
// every field): the click went to the screen's corner.
if p["ref"].is_null() || p["ref"].as_str().is_some_and(|r| r.trim().is_empty()) {
let s = spots.ok_or("先用 computer_use_screenshot 看一下畫面,再照清單上的名字或編號操作。")?;
p["ref"] = serde_json::Value::String(s.named(&t)?.to_string());
}
}
Ok(())
};
if let Some(actions) = v["actions"].as_array_mut() {
for a in actions.iter_mut() {
fix(a)?;
}
return Ok(());
}
fix(v)
}
pub fn number_of(r: &str) -> Option<usize> {
let t = r.trim().trim_matches(['[', ']']).trim_start_matches(['e', 'E', '#']);
t.parse().ok()
}
/// What the last look's numbers meant, so the next look keeps them: a control that is still
/// there keeps its number (the 「7」 key stays #141 after 「1」 was pressed). Renumbering every
/// look broke steps planned from one picture: a bot sending five clicks at once had the second
/// to fifth land on whatever took those numbers after the first click.
#[derive(Default, Debug, Clone)]
pub struct Numbering {
/// (window, kind, name, position bucket) → number.
exact: HashMap<(String, String, String, i64, i64), usize>,
/// (window, kind, name) → numbers (moved a little, still the same control when only one).
loose: HashMap<(String, String, String), Vec<usize>>,
windows: Vec<String>,
top: usize,
}
/// Position buckets for "the same control" (screen units; a list scrolled a little stays put).
const SAME_SPOT: f64 = 24.0;
/// Numbers grow while windows stay; past this they start again from 1.
const MAX_NUMBER: usize = 600;
/// Order (reading order, by window) and number the controls; the list's lines and the spots.
/// `all_text`: also list plain text (when there is no picture to read it from).
#[cfg(test)]
pub fn number(elements: &[Element], all_text: bool) -> (Vec<(usize, Element)>, Vec<String>, Spots) {
number_keeping(elements, all_text, &mut Numbering::default())
}
/// Same, keeping the numbers of controls that were there in the last look (`memory`, updated).
/// Starts again from 1 when none of the last look's windows is still there.
pub fn number_keeping(elements: &[Element], all_text: bool, memory: &mut Numbering) -> (Vec<(usize, Element)>, Vec<String>, Spots) {
let mut seen = std::collections::HashSet::new();
let mut els: Vec<&Element> = elements.iter().filter(|e| e.w >= 1.0 && e.h >= 1.0 && seen.insert((e.kind.clone(), e.name.clone(), e.x as i64, e.y as i64, e.w as i64, e.h as i64))).collect();
let mut windows: Vec<String> = vec![];
for e in &els {
if !windows.contains(&e.window) {
windows.push(e.window.clone());
}
}
els.sort_by(|a, b| {
let wa = windows.iter().position(|w| *w == a.window).unwrap_or(0);
let wb = windows.iter().position(|w| *w == b.window).unwrap_or(0);
(wa, (a.y / 12.0) as i64, a.x as i64).cmp(&(wb, (b.y / 12.0) as i64, b.x as i64))
});
// Which controls get numbers (reading order, at most MAX_MARKS), then which numbers: the
// last look's where the same control is still there, new ones above the highest.
let marked: Vec<&Element> = els.iter().copied().filter(|e| e.active).take(MAX_MARKS).collect();
let fresh = memory.top > MAX_NUMBER || !windows.iter().any(|w| memory.windows.contains(w));
if fresh {
*memory = Numbering::default();
}
let exact_of = |e: &Element| (e.window.clone(), e.kind.clone(), e.name.clone(), (e.centre().0 / SAME_SPOT).round() as i64, (e.centre().1 / SAME_SPOT).round() as i64);
let loose_of = |e: &Element| (e.window.clone(), e.kind.clone(), e.name.clone());
let mut taken: std::collections::HashSet<usize> = std::collections::HashSet::new();
let mut assigned: Vec<Option<usize>> = marked.iter().map(|e| memory.exact.get(&exact_of(e)).copied()).collect();
for n in assigned.iter().flatten() {
taken.insert(*n);
}
// Moved a little: the one control of that kind and name in that window, both times.
let mut loose_now: HashMap<(String, String, String), usize> = HashMap::new();
for e in &marked {
*loose_now.entry(loose_of(e)).or_default() += 1;
}
for (i, e) in marked.iter().enumerate() {
if assigned[i].is_none() && loose_now.get(&loose_of(e)) == Some(&1) {
if let Some([n]) = memory.loose.get(&loose_of(e)).map(Vec::as_slice) {
if !taken.contains(n) {
assigned[i] = Some(*n);
taken.insert(*n);
}
}
}
}
let mut top = memory.top.max(taken.iter().copied().max().unwrap_or(0));
let mut next = Numbering { windows: windows.clone(), ..Default::default() };
let mut number_of_el: HashMap<*const Element, usize> = HashMap::new();
for (i, e) in marked.iter().enumerate() {
let n = assigned[i].unwrap_or_else(|| {
top += 1;
top
});
number_of_el.insert(*e as *const Element, n);
next.exact.insert(exact_of(e), n);
next.loose.entry(loose_of(e)).or_default().push(n);
}
next.top = top;
*memory = next;
let mut numbered = vec![];
let mut lines = vec![];
let mut spots = Spots::default();
let mut window = None;
for e in els {
if !e.active && !all_text {
continue;
}
if windows.len() > 1 && window.as_ref() != Some(&e.window) && !e.window.is_empty() {
window = Some(e.window.clone());
lines.push(format!("## {}", e.window));
}
let what = describe(e);
if let Some(&n) = number_of_el.get(&(e as *const Element)) {
spots.by_number.insert(n, (e.centre(), what.clone()));
numbered.push((n, e.clone()));
lines.push(format!("[{n}] {what}"));
} else if !e.active {
lines.push(format!(" {what}"));
}
}
(numbered, lines, spots)
}
fn describe(e: &Element) -> String {
let short = |s: &str, n: usize| -> String {
let c: Vec<char> = s.chars().collect();
if c.len() > n { format!("{}…", c[..n].iter().collect::<String>()) } else { s.to_string() }
};
let mut t = e.kind.clone();
if !e.name.is_empty() {
t.push_str(&format!("「{}」", short(&e.name, 60)));
}
if !e.value.is_empty() && e.value != e.name {
t.push_str(&format!(":{}", short(&e.value, 80)));
}
if !e.flags.is_empty() {
t.push_str(&format!("({})", e.flags.join("、")));
}
t
}
/// A fingerprint of what the controls are and where (to say "nothing changed").
/// What came and what went between two looks at the screen (multiset of (kind, name)).
/// The quick model reads this to know what its last step did, without re-reading the whole
/// numbered list (the list is long; the change is usually two lines).
pub fn diff(prev: &[Element], now: &[Element]) -> Vec<String> {
fn key(e: &Element) -> (&str, &str) {
(e.kind.as_str(), e.name.as_str())
}
let mut counts: HashMap<(&str, &str), i32> = HashMap::new();
for e in prev {
*counts.entry(key(e)).or_default() += 1;
}
for e in now {
*counts.entry(key(e)).or_default() -= 1;
}
let mut lines = Vec::new();
// Order: as they appear in the current picture (adds), then as they were listed (removes).
let mut shown: std::collections::HashSet<(&str, &str)> = std::collections::HashSet::new();
for e in now {
let k = key(e);
if counts.get(&k) == Some(&-1) && shown.insert(k) {
lines.push(format!("+ {}「{}」", e.kind, e.name));
}
}
for e in prev {
let k = key(e);
if counts.get(&k) == Some(&1) && shown.insert(k) {
lines.push(format!("- {}「{}」", e.kind, e.name));
}
}
// A dialog that appeared entirely is the case that matters: show it in full.
lines.truncate(12);
lines
}
pub fn fingerprint(elements: &[Element]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
for e in elements {
(e.kind.as_str(), e.name.as_str(), e.value.as_str(), e.x as i64, e.y as i64, e.w as i64, e.h as i64, e.flags.len()).hash(&mut h);
}
h.finish()
}
/// A coarse fingerprint of a picture (16×10 grey cells), for "nothing changed" when there is no
/// tree to compare.
pub fn picture_fingerprint(img: &image::RgbImage) -> u64 {
use std::hash::{Hash, Hasher};
let small = image::imageops::resize(img, 16, 10, image::imageops::FilterType::Triangle);
let mut h = std::collections::hash_map::DefaultHasher::new();
for p in small.pixels() {
// Quantised, so a blinking caret or a clock's second doesn't count as a change.
((p[0] as u16 + p[1] as u16 + p[2] as u16) / 24).hash(&mut h);
}
h.finish()
}
const COLOURS: [[u8; 3]; 6] = [[220, 38, 38], [37, 99, 235], [22, 163, 74], [192, 38, 211], [234, 88, 12], [8, 145, 178]];
/// Box and number each control on the picture. `to_pic` maps screen units to picture pixels.
pub fn draw(img: &mut image::RgbImage, numbered: &[(usize, Element)], to_pic: (f64, f64)) {
let (iw, ih) = (img.width() as i64, img.height() as i64);
for (n, e) in numbered {
let c = COLOURS[n % COLOURS.len()];
let (x0, y0) = ((e.x / to_pic.0).round() as i64, (e.y / to_pic.1).round() as i64);
let (x1, y1) = (((e.x + e.w) / to_pic.0).round() as i64, ((e.y + e.h) / to_pic.1).round() as i64);
rect_outline(img, x0, y0, x1, y1, c);
// The number: white digits on the control's colour, at its top-left corner (inside the
// picture).
let label = n.to_string();
let (lw, lh) = (label.len() as i64 * (DIGIT_W + 1) * SCALE + 3, DIGIT_H * SCALE + 4);
let lx = x0.clamp(0, (iw - lw).max(0));
let ly = (y0 - lh).clamp(0, (ih - lh).max(0));
let ly = if ly + lh > y0 && y0 >= lh { y0 - lh } else { ly };
fill(img, lx, ly, lx + lw, ly + lh, c);
let mut cx = lx + 2;
for ch in label.bytes() {
digit(img, cx, ly + 2, ch - b'0', [255, 255, 255]);
cx += (DIGIT_W + 1) * SCALE;
}
}
}
fn put(img: &mut image::RgbImage, x: i64, y: i64, c: [u8; 3]) {
if x >= 0 && y >= 0 && (x as u32) < img.width() && (y as u32) < img.height() {
img.put_pixel(x as u32, y as u32, image::Rgb(c));
}
}
fn fill(img: &mut image::RgbImage, x0: i64, y0: i64, x1: i64, y1: i64, c: [u8; 3]) {
for y in y0..y1 {
for x in x0..x1 {
put(img, x, y, c);
}
}
}
fn rect_outline(img: &mut image::RgbImage, x0: i64, y0: i64, x1: i64, y1: i64, c: [u8; 3]) {
for x in x0..=x1 {
put(img, x, y0, c);
put(img, x, y1, c);
}
for y in y0..=y1 {
put(img, x0, y, c);
put(img, x1, y, c);
}
}
/// A 3×5 digit font, each pixel drawn SCALE×SCALE.
const DIGIT_W: i64 = 3;
const DIGIT_H: i64 = 5;
const SCALE: i64 = 2;
const DIGITS: [[u8; 5]; 10] = [
[0b111, 0b101, 0b101, 0b101, 0b111],
[0b010, 0b110, 0b010, 0b010, 0b111],
[0b111, 0b001, 0b111, 0b100, 0b111],
[0b111, 0b001, 0b111, 0b001, 0b111],
[0b101, 0b101, 0b111, 0b001, 0b001],
[0b111, 0b100, 0b111, 0b001, 0b111],
[0b111, 0b100, 0b111, 0b101, 0b111],
[0b111, 0b001, 0b010, 0b010, 0b010],
[0b111, 0b101, 0b111, 0b101, 0b111],
[0b111, 0b101, 0b111, 0b001, 0b111],
];
fn digit(img: &mut image::RgbImage, x: i64, y: i64, d: u8, c: [u8; 3]) {
let rows = DIGITS[(d as usize).min(9)];
for (r, bits) in rows.iter().enumerate() {
for col in 0..DIGIT_W {
if bits >> (DIGIT_W - 1 - col) & 1 == 1 {
fill(img, x + col * SCALE, y + r as i64 * SCALE, x + (col + 1) * SCALE, y + (r as i64 + 1) * SCALE, c);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mel(kind: &str, name: &str) -> Element {
Element { kind: kind.into(), name: name.into(), value: String::new(), x: 0.0, y: 0.0, w: 10.0, h: 10.0, active: true, flags: vec![], window: String::new() }
}
#[test]
fn the_change_between_two_looks_is_two_lines() {
let before = vec![mel("按鈕", "Save"), mel("輸入框", "標題")];
let after = vec![mel("按鈕", "Save"), mel("按鈕", "OK"), mel("按鈕", "Cancel")];
assert_eq!(diff(&before, &after), ["+ 按鈕「OK」", "+ 按鈕「Cancel」", "- 輸入框「標題」"]);
// Same controls twice = a multiset: one of two identical buttons leaving is a change.
let two = vec![mel("按鈕", "x"), mel("按鈕", "x")];
assert_eq!(diff(&two, &[mel("按鈕", "x")]), ["- 按鈕「x」"]);
assert!(diff(&before, &before).is_empty());
}
fn el(kind: &str, name: &str, x: f64, y: f64, active: bool) -> Element {
Element { kind: kind.into(), name: name.into(), value: String::new(), x, y, w: 40.0, h: 20.0, active, flags: vec![], window: "W".into() }
}
/// A control still there keeps its number from look to look, even when other controls come
/// and go before it in reading order; a new one gets a number above the highest; a look at a
/// different window starts from 1.
#[test]
fn a_control_can_be_named_instead_of_numbered() {
let els = vec![el("按鈕", "4", 10.0, 10.0, true), el("按鈕", "AC", 60.0, 10.0, true), el("按鈕", "C", 110.0, 10.0, true), el("勾選框", "自動更新", 10.0, 60.0, true)];
let (_, _, spots) = number(&els, false);
assert_eq!(spots.named("4").unwrap(), 1);
assert_eq!(spots.named("「C」").unwrap(), 3, "C is not AC");
assert!(spots.named("Save").unwrap_err().contains("沒有叫「Save」"));
// A near miss names what is there.
let near = spots.named("自動").unwrap_err();
assert!(near.contains("名字相近") && near.contains("[4]「自動更新」"), "{near}");
let mut v = serde_json::json!({"actions": [{"action": "click", "name": "4"}, {"action": "click", "ref": "2"}, {"action": "typing", "value": "x"}]});
names_to_numbers(&mut v, Some(&spots)).unwrap();
assert_eq!(v["actions"][0]["ref"], "1");
assert_eq!(v["actions"][1]["ref"], "2");
assert!(v["actions"][2]["ref"].is_null());
let mut one = serde_json::json!({"name": "自動更新"});
names_to_numbers(&mut one, Some(&spots)).unwrap();
assert_eq!(one["ref"], "4");
// A name with x: 0, y: 0 beside it: the name.
let mut both = serde_json::json!({"name": "AC", "x": 0.0, "y": 0.0});
names_to_numbers(&mut both, Some(&spots)).unwrap();
assert_eq!(both["ref"], "2");
}
#[test]
fn numbers_stay_with_their_controls() {
let mut m = Numbering::default();
let keys = |extra: bool| {
let mut v = vec![];
if extra {
v.push(el("選單", "說明", 5.0, 5.0, true));
}
for (i, k) in ["7", "8", "9", "*"].iter().enumerate() {
v.push(el("按鈕", k, 100.0 + 50.0 * i as f64, 200.0, true));
}
v
};
let (first, _, _) = number_keeping(&keys(false), false, &mut m);
let n_of = |list: &[(usize, Element)], name: &str| list.iter().find(|(_, e)| e.name == name).map(|(n, _)| *n).unwrap();
assert_eq!(n_of(&first, "9"), 3);
// A menu appears first in reading order: the keys keep their numbers, the menu is new.
let (second, lines, spots) = number_keeping(&keys(true), false, &mut m);
assert_eq!(n_of(&second, "9"), 3);
assert_eq!(n_of(&second, "說明"), 5);
assert_eq!(spots.get("3").unwrap().1, "按鈕「9」");
assert_eq!(lines[0], "[5] 選單「說明」");
// Moved a little (a list scrolled): still the same control.
let mut moved = keys(true);
for e in moved.iter_mut() {
e.y += 30.0;
}
let (third, _, _) = number_keeping(&moved, false, &mut m);
assert_eq!(n_of(&third, "9"), 3);
// Another window altogether: from 1 again.
let other: Vec<Element> = keys(false).into_iter().map(|e| Element { window: "Other".into(), ..e }).collect();
let (fourth, _, _) = number_keeping(&other, false, &mut m);
assert_eq!(n_of(&fourth, "7"), 1);
}
#[test]
fn controls_are_numbered_in_reading_order() {
let els = vec![el("按鈕", "下", 10.0, 300.0, true), el("連結", "Profile", 20.0, 100.0, true), el("文字", "標題", 0.0, 50.0, false), el("連結", "Profile", 20.0, 100.0, true)];
let (numbered, lines, spots) = number(&els, false);
// Reading order, duplicates once, plain text left out when there is a picture.
assert_eq!(lines, vec!["[1] 連結「Profile」", "[2] 按鈕「下」"]);
assert_eq!(numbered.len(), 2);
assert_eq!(spots.get("1").unwrap(), ((40.0, 110.0), "連結「Profile」".to_string()));
assert_eq!(spots.get("e2").unwrap().0, (30.0, 310.0));
assert!(spots.get("3").is_err() && spots.get("ok").is_err());
// Without a picture the text is listed too (not numbered).
let (_, lines, _) = number(&els, true);
assert_eq!(lines, vec![" 文字「標題」", "[1] 連結「Profile」", "[2] 按鈕「下」"]);
}
#[test]
fn numbers_are_drawn_on_the_picture() {
let mut img = image::RgbImage::from_pixel(200, 100, image::Rgb([255, 255, 255]));
let els = vec![el("按鈕", "OK", 100.0, 60.0, true)];
let (numbered, _, _) = number(&els, false);
// Screen twice the picture's size: the box lands at (50, 30)–(70, 40).
draw(&mut img, &numbered, (2.0, 2.0));
assert_eq!(img.get_pixel(50, 35).0, COLOURS[1]);
assert_eq!(img.get_pixel(60, 30).0, COLOURS[1]);
// The label sits above the box with white digits in it.
let label_has_white = (16..30).any(|y| (50..62).any(|x| img.get_pixel(x, y).0 == [255, 255, 255]));
let label_has_colour = (16..30).any(|y| (50..62).any(|x| img.get_pixel(x, y).0 == COLOURS[1]));
assert!(label_has_white && label_has_colour);
}
#[test]
fn a_still_screen_has_the_same_fingerprint() {
let a = vec![el("按鈕", "OK", 1.0, 2.0, true)];
let mut b = a.clone();
assert_eq!(fingerprint(&a), fingerprint(&b));
b[0].name = "Cancel".into();
assert_ne!(fingerprint(&a), fingerprint(&b));
let img = image::RgbImage::from_pixel(64, 40, image::Rgb([10, 20, 30]));
let mut img2 = img.clone();
img2.put_pixel(0, 0, image::Rgb([12, 20, 30]));
assert_eq!(picture_fingerprint(&img), picture_fingerprint(&img2));
}
}