Skip to main content

open_chess_server/
vision.rs

1//! Reading a chess board from a photo.
2//!
3//! Two readers, used in this order:
4//!
5//! - **Our board reader** (`chess-vision`, CHESS_VISION_LOCAL_MODEL): a
6//!   network trained for exactly this, run on this server's CPU. It gives
7//!   each square's piece probabilities, so the app can weigh every legal
8//!   move against them.
9//! - **A vision language model** (CHESS_VISION_ENDPOINT): asked for the
10//!   piece placement as FEN, which side of the board was nearer the
11//!   camera, the side to move if the picture shows it, and which squares
12//!   it wasn't sure of. Used when there is no board reader, or for its
13//!   second opinion on a photo the reader isn't sure of; that answer is
14//!   also kept as the *suggestion* the Review page pre-fills.
15//!
16//! Either answer is a *reading*, not a position: the app matches it
17//! against the legal moves from the game it is following, or shows it in a
18//! board editor to confirm (see `chess_analysis::photo`).
19
20use std::sync::OnceLock;
21use std::time::Duration;
22
23use anyhow::{Context, Result, bail};
24use serde::{Deserialize, Serialize};
25use serde_json::{Value, json};
26
27use crate::config::Config;
28
29const PROMPT: &str = "You are reading a chess position from a photo or screenshot of a board. \
30Reply with only a JSON object, no other text: \
31{\"placement\": the FEN piece placement, ranks 8 down to 1 separated by '/', White's pieces as \
32uppercase letters (KQRBNP) and Black's as lowercase, empty squares as digits, always written from \
33White's point of view so rank 1 is White's back rank whichever way the photo was taken; \
34\"white_at_bottom\": true if White's pieces were at the bottom of the image (nearer the camera), false if \
35Black's were; \"side_to_move\": \"w\" or \"b\" only if the image shows it (a move indicator, a \
36highlighted last move, a clock), otherwise null; \"confidence\": your confidence from 0 to 1; \
37\"unsure_squares\": the squares (like \"e4\") you could not read reliably}. \
38Count each rank's squares carefully so every rank adds up to eight. \
39If the image does not show a chess board, reply {\"error\": \"what it shows instead\"}.";
40
41/// What the model read.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub struct Reading {
44    pub placement: String,
45    #[serde(default = "yes")]
46    pub white_at_bottom: bool,
47    #[serde(default)]
48    pub side_to_move: Option<String>,
49    #[serde(default)]
50    pub confidence: Option<f64>,
51    #[serde(default)]
52    pub unsure_squares: Vec<String>,
53}
54
55fn yes() -> bool {
56    true
57}
58
59pub(crate) fn http() -> &'static reqwest::Client {
60    static C: OnceLock<reqwest::Client> = OnceLock::new();
61    C.get_or_init(|| {
62        reqwest::Client::builder()
63            .timeout(Duration::from_secs(90))
64            .build()
65            .unwrap_or_else(|_| reqwest::Client::new())
66    })
67}
68
69/// Whether the vision language model is set up.
70pub fn enabled(cfg: &Config) -> bool {
71    !cfg.vision_endpoint.trim().is_empty()
72}
73
74/// Load the board reader named by CHESS_VISION_LOCAL_MODEL (`None` when
75/// it's empty).
76pub fn load_reader(cfg: &Config) -> Result<Option<chess_vision::Reader>> {
77    let path = cfg.vision_local_model.trim();
78    if path.is_empty() {
79        return Ok(None);
80    }
81    let bytes = std::fs::read(path).with_context(|| format!("reading CHESS_VISION_LOCAL_MODEL ({path})"))?;
82    let r = chess_vision::Reader::from_safetensors(&bytes)
83        .with_context(|| format!("loading the board reader {path}"))?;
84    Ok(Some(r))
85}
86
87/// The bytes and media type of a `data:image/...;base64,` URL or bare
88/// base64 (taken as JPEG).
89pub fn decode_image(image: &str) -> Result<(Vec<u8>, String)> {
90    use base64::Engine as _;
91    let (mime, b64) = match image.strip_prefix("data:") {
92        Some(rest) => {
93            let (head, data) = rest.split_once(',').context("a data: URL without a comma")?;
94            let mime = head.split(';').next().unwrap_or("image/jpeg");
95            if !head.ends_with(";base64") {
96                bail!("the photo's data: URL isn't base64");
97            }
98            (mime.to_owned(), data)
99        }
100        None => ("image/jpeg".to_owned(), image),
101    };
102    if !matches!(mime.as_str(), "image/jpeg" | "image/png" | "image/webp") {
103        bail!("send the photo as JPEG, PNG or WebP (not {mime})");
104    }
105    let bytes = base64::engine::general_purpose::STANDARD
106        .decode(b64.trim())
107        .context("the photo isn't valid base64")?;
108    Ok((bytes, mime))
109}
110
111/// Pull the JSON object out of a model reply (models sometimes wrap it in
112/// prose or a code fence).
113fn json_in(text: &str) -> Option<Value> {
114    let start = text.find('{')?;
115    let end = text.rfind('}')?;
116    serde_json::from_str(text.get(start..=end)?).ok()
117}
118
119/// Parse and sanity-check a reply.
120pub fn parse_reply(text: &str) -> Result<Reading> {
121    let v = json_in(text).context("the vision model's reply had no JSON in it")?;
122    if let Some(e) = v.get("error").and_then(Value::as_str) {
123        bail!("no chess board found: {e}");
124    }
125    let r: Reading =
126        serde_json::from_value(v).context("the vision model's reply wasn't in the expected shape")?;
127    if chess_analysis::photo::parse_placement(&r.placement).is_none() {
128        bail!("the board read from the photo doesn't have eight ranks of eight squares ({})", r.placement);
129    }
130    Ok(r)
131}
132
133/// Ask the model to read `image` (a `data:` URL or bare base64 JPEG/PNG).
134pub async fn read_board(cfg: &Config, image: &str) -> Result<Reading> {
135    let url = if image.starts_with("data:") {
136        image.to_string()
137    } else {
138        format!("data:image/jpeg;base64,{image}")
139    };
140    let body = json!({
141        "model": cfg.vision_model,
142        "max_tokens": 600,
143        "temperature": 0,
144        "messages": [{
145            "role": "user",
146            "content": [
147                { "type": "image_url", "image_url": { "url": url } },
148                { "type": "text", "text": PROMPT },
149            ],
150        }],
151    });
152    let endpoint = format!("{}/chat/completions", cfg.vision_endpoint.trim_end_matches('/'));
153    let mut req = http().post(&endpoint).json(&body);
154    if let Some(k) = cfg.vision_api_key.as_deref().filter(|k| !k.is_empty()) {
155        req = req.bearer_auth(k);
156    }
157    let resp = req.send().await.context("couldn't reach the vision model")?;
158    let status = resp.status();
159    let v: Value = resp.json().await.context("the vision model's answer wasn't JSON")?;
160    if !status.is_success() {
161        bail!(
162            "the vision model refused the photo ({status}): {}",
163            v["error"]["message"].as_str().unwrap_or("")
164        );
165    }
166    let text = v["choices"][0]["message"]["content"].as_str().unwrap_or_default();
167    parse_reply(text)
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn replies_are_parsed_and_checked() {
176        let r = parse_reply(
177            "Here you go:\n```json\n{\"placement\": \"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR\", \
178             \"white_at_bottom\": false, \"side_to_move\": \"b\", \"confidence\": 0.8, \"unsure_squares\": [\"e4\"]}\n```",
179        )
180        .unwrap();
181        assert!(!r.white_at_bottom);
182        assert_eq!(r.side_to_move.as_deref(), Some("b"));
183        assert_eq!(r.unsure_squares, vec!["e4"]);
184        assert!(parse_reply("{\"error\": \"a cat\"}").unwrap_err().to_string().contains("a cat"));
185        assert!(parse_reply("{\"placement\": \"8/8/8\"}").is_err());
186        assert!(parse_reply("no idea").is_err());
187    }
188
189    #[test]
190    fn photos_are_decoded() {
191        let (b, m) = decode_image("data:image/png;base64,aGVsbG8=").unwrap();
192        assert_eq!((b.as_slice(), m.as_str()), (&b"hello"[..], "image/png"));
193        assert_eq!(decode_image("aGVsbG8=").unwrap().1, "image/jpeg");
194        assert!(decode_image("data:image/gif;base64,aGVsbG8=").is_err());
195        assert!(decode_image("data:image/png,hello").is_err());
196        assert!(decode_image("data:image/png;base64,***").is_err());
197    }
198}