diff --git a/Cargo.lock b/Cargo.lock index 837b1c1..7f20237 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -512,7 +512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877bfcce06463cdbcfd7f4efd57608b1384d6d9ae03b33e503fbba1d1a899a52" dependencies = [ "egui", - "image", + "image 0.24.2", ] [[package]] @@ -1018,6 +1018,20 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "image" +version = "0.23.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ffcb7e7244a9bf19d35bf2883b9c080c4ced3c07a9895572178cdb8f13f6a1" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "num-iter", + "num-rational 0.3.2", + "num-traits", +] + [[package]] name = "image" version = "0.24.2" @@ -1031,13 +1045,26 @@ dependencies = [ "gif", "jpeg-decoder", "num-iter", - "num-rational", + "num-rational 0.4.0", "num-traits", "png", "scoped_threadpool", "tiff", ] +[[package]] +name = "img_hash" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ea4eac6fc4f64ed363d5c210732b747bfa5ddd8a25ac347d887f298c3a70b49" +dependencies = [ + "base64", + "image 0.23.14", + "rustdct", + "serde", + "transpose 0.2.1", +] + [[package]] name = "indexmap" version = "1.8.1" @@ -1390,6 +1417,16 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-complex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b19411a9719e753aff12e5187b74d60d3dc449ec3f4dc21e3989c3f554bc95" +dependencies = [ + "autocfg", + "num-traits", +] + [[package]] name = "num-integer" version = "0.1.45" @@ -1411,6 +1448,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12ac428b1cb17fce6f731001d307d351ec70a6d202fc2e60f7d4c5e42d8f4f07" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.0" @@ -1803,6 +1851,28 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "rustdct" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4d167674b4cf68c2114bdbcd34c95aa9071652b73b0f43b19298f1d2780b7d" +dependencies = [ + "rustfft", +] + +[[package]] +name = "rustfft" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77008ed59a8923c8b4ac2e5eaa6d28fbe893d3b9515098a4a5fc7767d6430fe5" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "strength_reduce", + "transpose 0.1.0", +] + [[package]] name = "rustls" version = "0.20.6" @@ -2042,6 +2112,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d44a3643b4ff9caf57abcee9c2c621d6c03d9135e0d8b589bd9afb5992cb176a" +[[package]] +name = "strength_reduce" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3ff2f71c82567c565ba4b3009a9350a96a7269eaa4001ebedae926230bc2254" + [[package]] name = "strsim" version = "0.10.0" @@ -2057,11 +2133,13 @@ dependencies = [ "egui_extras", "ehttp", "futures", - "image", + "image 0.24.2", + "img_hash", "poll-promise", "reqwest", "scrap", "serde", + "serde_json", "tokio", ] @@ -2247,6 +2325,22 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "transpose" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643e21580bb0627c7bb09e5cedbb42c8705b19d012de593ed6b0309270b3cd1e" + +[[package]] +name = "transpose" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95f9c900aa98b6ea43aee227fd680550cdec726526aab8ac801549eadb25e39f" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "try-lock" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index 4cb6b50..e2f0881 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,9 @@ scrap = "0.5" anyhow = "1.0" futures = "0.3" serde = { version = "1", features = ["derive"] } +serde_json = "1" tokio = { version = "1", features = ["full"] } -reqwest = { version = "0.11", features = ["json"] } \ No newline at end of file +reqwest = { version = "0.11", features = ["json"] } + +img_hash = "3" \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..5cec55a --- /dev/null +++ b/config.json @@ -0,0 +1,47 @@ +{ + "ocr_regions": [ + { + "name": "lap", + "x": 2290, + "y": 46, + "width": 145, + "height": 90 + }, + { + "name": "health", + "x": 90, + "y": 1364, + "width": 52, + "height": 24 + }, + { + "name": "gas", + "x": 208, + "y": 1364, + "width": 52, + "height": 24 + }, + { + "name": "tyres", + "x": 325, + "y": 1364, + "width": 52, + "height": 24 + }, + { + "name": "lap_time", + "x": 2325, + "y": 169, + "width": 183, + "height": 43 + }, + { + "name": "best_time", + "x": 2325, + "y": 222, + "width": 183, + "height": 43 + } + ], + "ocr_server_endpoint": "http://localhost:3000/" +} \ No newline at end of file diff --git a/learned.json b/learned.json new file mode 100644 index 0000000..9190260 --- /dev/null +++ b/learned.json @@ -0,0 +1,4 @@ +{ + "learned_images": {}, + "learned_tracks": {} +} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e34941d --- /dev/null +++ b/src/config.rs @@ -0,0 +1,56 @@ +use std::{collections::HashMap, path::PathBuf}; + +use anyhow::Result; +use serde::{Serialize, Deserialize, de::DeserializeOwned}; + +use crate::image_processing::Region; + +#[derive(Default, Serialize, Deserialize, Clone)] +pub struct Config { + pub ocr_regions: Vec, + pub track_region: Option, + pub ocr_server_endpoint: String, +} + +impl Config { + pub fn load() -> Result { + load_or_make_default("config.json", include_str!("configs/config.default.json")) + } + pub fn save(&self) -> Result<()> { + save_json_config("config.json", self) + } +} + +#[derive(Default, Serialize, Deserialize, Clone)] +pub struct LearnedConfig { + pub learned_images: HashMap, + pub learned_tracks: HashMap, +} + +impl LearnedConfig { + pub fn load() -> Result { + load_or_make_default("learned.json", include_str!("configs/learned.default.json")) + } + pub fn save(&self) -> Result<()> { + save_json_config("learned.json", self) + } +} + +fn load_or_make_default(path: &str, default: &str) -> Result { + let file_path = PathBuf::from(path); + if !file_path.exists() { + std::fs::write(&path, default)?; + } + load_json_config(&path) +} + +fn load_json_config(path: &str) -> Result { + let data = std::fs::read(path)?; + let value = serde_json::from_slice(&data)?; + Ok(value) +} + +fn save_json_config(path: &str, val: &T) -> Result<()> { + let serialized = serde_json::to_vec_pretty(val)?; + Ok(std::fs::write(path, &serialized)?) +} \ No newline at end of file diff --git a/src/configs/config.default.json b/src/configs/config.default.json new file mode 100644 index 0000000..5cec55a --- /dev/null +++ b/src/configs/config.default.json @@ -0,0 +1,47 @@ +{ + "ocr_regions": [ + { + "name": "lap", + "x": 2290, + "y": 46, + "width": 145, + "height": 90 + }, + { + "name": "health", + "x": 90, + "y": 1364, + "width": 52, + "height": 24 + }, + { + "name": "gas", + "x": 208, + "y": 1364, + "width": 52, + "height": 24 + }, + { + "name": "tyres", + "x": 325, + "y": 1364, + "width": 52, + "height": 24 + }, + { + "name": "lap_time", + "x": 2325, + "y": 169, + "width": 183, + "height": 43 + }, + { + "name": "best_time", + "x": 2325, + "y": 222, + "width": 183, + "height": 43 + } + ], + "ocr_server_endpoint": "http://localhost:3000/" +} \ No newline at end of file diff --git a/src/configs/learned.default.json b/src/configs/learned.default.json new file mode 100644 index 0000000..9190260 --- /dev/null +++ b/src/configs/learned.default.json @@ -0,0 +1,4 @@ +{ + "learned_images": {}, + "learned_tracks": {} +} \ No newline at end of file diff --git a/src/control_loop.rs b/src/control_loop.rs index 2b7bf8e..187e708 100644 --- a/src/control_loop.rs +++ b/src/control_loop.rs @@ -1,39 +1,145 @@ -use std::{collections::HashMap, time::Duration}; +use std::{ + collections::HashMap, + time::{Duration, Instant}, +}; use anyhow::Result; use egui_extras::RetainedImage; +use image::RgbImage; use scrap::{Capturer, Display}; use crate::{ capture, image_processing::{self, Region}, ocr, - state::{ParsedFrame, SharedAppState}, + state::{AppState, DebugOcrFrame, ParsedFrame, RaceState, SharedAppState}, }; -async fn run_loop_once(state: &SharedAppState, regions: &[Region]) -> Result<()> { +fn is_finished_lap(state: &AppState, frame: &ParsedFrame) -> bool { + if let Some(race) = &state.current_race { + if let Some(last_finish) = &race.last_lap_record_time { + let diff = Instant::now().duration_since(*last_finish); + if diff < Duration::from_secs(5) { + return false; + } + } + } + if let Some(prev_frame) = state.buffered_frames.back() { + prev_frame.lap_time.is_some() + && prev_frame.lap_time == frame.lap_time + && prev_frame.lap_time.unwrap() > Duration::from_secs(5) + } else { + false + } +} + +fn merge_with_max(a: &Option, b: &Option) -> Option { + match (a, b) { + (Some(a), None) => Some(a.clone()), + (None, Some(b)) => Some(b.clone()), + (None, None) => None, + (Some(a), Some(b)) => { + if a > b { + Some(a.clone()) + } else { + Some(b.clone()) + } + } + } +} + +fn merge_frames(prev: &ParsedFrame, next: &ParsedFrame) -> ParsedFrame { + ParsedFrame { + lap: merge_with_max(&prev.lap, &next.lap), + health: merge_with_max(&prev.health, &next.health), + gas: merge_with_max(&prev.gas, &next.gas), + tyres: merge_with_max(&prev.tyres, &next.tyres), + lap_time: merge_with_max(&prev.lap_time, &next.lap_time), + best_time: merge_with_max(&prev.best_time, &next.best_time), + } +} + +fn handle_new_frame(state: &mut AppState, frame: ParsedFrame, image: &RgbImage) { + if frame.lap_time.is_some() { + state.last_frame = Some(frame.clone()); + state.frames_without_lap = 0; + + if state.current_race.is_none() { + let mut race = RaceState::default(); + race.screencap = Some( + RetainedImage::from_image_bytes( + "screencap", + &image_processing::to_png_bytes(image), + ) + .expect("failed to save screenshot"), + ); + state.current_race = Some(race); + } + } else { + state.frames_without_lap += 1; + } + + if state.frames_without_lap >= 10 { + if let Some(race) = state.current_race.take() { + state.past_races.push_front(race); + } + } + + if is_finished_lap(state, &frame) { + let mut merged = merge_frames(state.buffered_frames.back().unwrap(), &frame); + if let Some(lap) = &merged.lap { + merged.lap = Some(lap - 1); + } + + if let Some(race) = state.current_race.as_mut() { + race.laps.push(merged); + race.last_lap_record_time = Some(Instant::now()); + } + } + + state.buffered_frames.push_back(frame); + if state.buffered_frames.len() >= 20 { + state.buffered_frames.pop_front(); + } +} + +async fn run_loop_once(state: &SharedAppState) -> Result<()> { + let config = state.lock().unwrap().config.clone(); let frame = capture::get_frame()?; - let ocr_results = ocr::ocr_all_regions(&frame, ®ions).await; + let ocr_results = ocr::ocr_all_regions(&frame, &config.ocr_regions).await; let mut saved_frames = HashMap::new(); if state.lock().unwrap().debug_frames { - for region in regions { + let hasher = img_hash::HasherConfig::new().to_hasher(); + for region in &config.ocr_regions { let mut extracted = image_processing::extract_region(&frame, region); - image_processing::filter_to_white(&mut extracted, 0.95, 0.05); + image_processing::filter_to_white(&mut extracted); let retained = RetainedImage::from_image_bytes( ®ion.name, &image_processing::to_png_bytes(&extracted), ) .unwrap(); - saved_frames.insert(region.name.clone(), retained); + let have_to_use_other_image_library_version = img_hash::image::RgbImage::from_raw( + extracted.width(), + extracted.height(), + extracted.as_raw().to_vec(), + ) + .unwrap(); + let hash = hasher.hash_image(&have_to_use_other_image_library_version); + saved_frames.insert( + region.name.clone(), + DebugOcrFrame { + image: retained, + rgb_image: extracted, + img_hash: hash, + }, + ); } } { let mut state = state.lock().unwrap(); let parsed = ParsedFrame::parse(&ocr_results); - if parsed.lap_time.is_some() { - state.last_frame = Some(parsed); - } + handle_new_frame(&mut state, parsed, &frame); state.raw_data = ocr_results; state.saved_frames = saved_frames; } @@ -41,17 +147,8 @@ async fn run_loop_once(state: &SharedAppState, regions: &[Region]) -> Result<()> } pub async fn run_control_loop(state: SharedAppState) -> Result<()> { - let regions = vec![ - Region::parse("health 91 1364 52 24").unwrap(), - Region::parse("gas 208 1364 52 24").unwrap(), - Region::parse("tyres 325 1364 52 24").unwrap(), - Region::parse("lap 2295 46 140 87").unwrap(), - Region::parse("best 2325 169 183 43").unwrap(), - Region::parse("lap_time 2325 222 183 43").unwrap(), - ]; - loop { - if let Err(e) = run_loop_once(&state, ®ions).await { + if let Err(e) = run_loop_once(&state).await { eprintln!("Error in control loop: {:?}", e) } tokio::time::sleep(Duration::from_millis(500)).await; diff --git a/src/image_processing.rs b/src/image_processing.rs index 9731d38..f39d60e 100644 --- a/src/image_processing.rs +++ b/src/image_processing.rs @@ -1,57 +1,46 @@ use anyhow::Result; -use image::{RgbImage, Rgb, codecs::png::PngEncoder, ColorType, ImageEncoder}; +use image::{codecs::png::PngEncoder, ColorType, ImageEncoder, Rgb, RgbImage}; +use serde::{Deserialize, Serialize}; -#[derive(Clone)] +#[derive(Clone, Deserialize, Serialize)] pub struct Region { pub name: String, x: usize, y: usize, width: usize, - height: usize + height: usize, } -impl Region { - pub fn parse(encoded: &str) -> Result { - let split: Vec<_> = encoded.split(' ').collect(); - Ok(Region { - name: split.get(0).ok_or(anyhow::anyhow!("expected name in region"))?.to_string(), - x: split.get(1).ok_or(anyhow::anyhow!("failed to parse x from region"))?.parse()?, - y: split.get(2).ok_or(anyhow::anyhow!("failed to parse y from region"))?.parse()?, - width: split.get(3).ok_or(anyhow::anyhow!("failed to parse width from region"))?.parse()?, - height: split.get(4).ok_or(anyhow::anyhow!("failed to parse height from region"))?.parse()?, - }) - } -} - -pub fn extract_region( - image: &RgbImage, - region: &Region, -) -> RgbImage { +pub fn extract_region(image: &RgbImage, region: &Region) -> RgbImage { let mut buffer = RgbImage::new(region.width as u32, region.height as u32); for y in 0..region.height { for x in 0..region.width { buffer.put_pixel( x as u32, y as u32, - *image.get_pixel((region.x + x) as u32, (region.y + y) as u32) + *image.get_pixel((region.x + x) as u32, (region.y + y) as u32), ); } } buffer } -pub fn filter_to_white( - image: &mut RgbImage, - threshold: f64, - variance_threshold: f64 -) { +pub fn filter_to_white(image: &mut RgbImage) { + let threshold = 0.98; + let variance_threshold = 0.02; let past_threshold_color = |v: u8| v as f64 >= (u8::MAX as f64 * threshold); let color_diff = |a: u8, b: u8| (a.abs_diff(b) as f64) / (u8::MAX as f64); for y in 0..image.height() { for x in 0..image.width() { let pixel = image.get_pixel(x, y); let [r, g, b] = pixel.0; - if past_threshold_color(r) && past_threshold_color(g) && past_threshold_color(b) && color_diff(r, g) < variance_threshold && color_diff(r, b) < variance_threshold && color_diff(b, g) < variance_threshold { + if past_threshold_color(r) + && past_threshold_color(g) + && past_threshold_color(b) + && color_diff(r, g) < variance_threshold + && color_diff(r, b) < variance_threshold + && color_diff(b, g) < variance_threshold + { // This is a white pixel, make it black image.put_pixel(x, y, Rgb([0, 0, 0])); } else { @@ -65,11 +54,13 @@ pub fn filter_to_white( pub fn to_png_bytes(image: &RgbImage) -> Vec { let mut buffer: Vec = Vec::new(); let encoder = PngEncoder::new(&mut buffer); - encoder.write_image( - image.as_raw(), - image.width(), - image.height(), - ColorType::Rgb8 - ).expect("failed encoding image to PNG"); + encoder + .write_image( + image.as_raw(), + image.width(), + image.height(), + ColorType::Rgb8, + ) + .expect("failed encoding image to PNG"); buffer -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index 065b803..a3e5bed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,19 +5,26 @@ mod control_loop; mod image_processing; mod ocr; mod state; +mod config; use std::{ sync::{Arc, Mutex}, time::Duration, }; -use eframe::egui; -use egui_extras::RetainedImage; -use state::{AppState, SharedAppState}; +use config::{Config, LearnedConfig}; +use eframe::{ + egui::{self, Ui}, + epaint::Color32, emath::Vec2, +}; +use state::{AppState, RaceState, SharedAppState}; #[tokio::main(flavor = "multi_thread", worker_threads = 8)] async fn main() -> anyhow::Result<()> { - let state = Arc::new(Mutex::new(AppState::default())); + let mut app_state = AppState::default(); + app_state.config = Arc::new(Config::load().unwrap()); + app_state.learned = Arc::new(LearnedConfig::load().unwrap()); + let state = Arc::new(Mutex::new(app_state)); { let state = state.clone(); let _ = tokio::spawn(async move { @@ -35,6 +42,53 @@ async fn main() -> anyhow::Result<()> { ); } +fn show_optional_usize_with_diff( + ui: &mut Ui, + color: Color32, + old: &Option, + v: &Option, +) { + let diff_string = match (old, v) { + (Some(old), Some(v)) => { + let diff = *v as isize - *old as isize; + if diff.is_positive() { + format!(" (+{})", diff) + } else { + format!(" ({})", diff) + } + } + _ => "".to_owned(), + }; + let value = match v { + Some(v) => v.to_string(), + None => "???".to_owned(), + }; + let text = format!("{}{}", value, diff_string); + ui.colored_label(color, text); +} + +fn format_time(time: Duration) -> String { + format!("{:.3}", time.as_secs_f64()) +} + +fn label_time_delta(ui: &mut Ui, time: Duration, old: Option) { + if let Some(old) = old { + if time > old { + ui.colored_label( + Color32::LIGHT_RED, + "+".to_owned() + &format_time(time - old), + ); + } else { + ui.colored_label( + Color32::LIGHT_GREEN, + "-".to_owned() + &format_time(old - time), + ); + } + } else { + ui.label("--"); + } +} + struct MyApp { state: SharedAppState, } @@ -45,6 +99,50 @@ impl MyApp { } } +fn show_race_state(ui: &mut Ui, race: &RaceState) { + egui::Grid::new("current-race").show(ui, |ui| { + ui.label("Lap"); + ui.label("Time"); + ui.label("Δ Previous"); + ui.label("Δ Best"); + ui.label("Health"); + ui.label("Gas"); + ui.label("Tyres"); + ui.end_row(); + for (i, lap) in race.laps.iter().enumerate() { + if let Some(lap_time) = *&lap.lap_time { + let prev_lap = race.laps.get(i - 1); + ui.label(format!("#{}", lap.lap.unwrap_or(i + 1))); + + ui.label(format_time(lap_time)); + label_time_delta(ui, lap_time, prev_lap.and_then(|p| p.lap_time)); + label_time_delta(ui, lap_time, lap.best_time); + + show_optional_usize_with_diff( + ui, + Color32::RED, + &prev_lap.and_then(|p| p.health), + &lap.health, + ); + show_optional_usize_with_diff( + ui, + Color32::GRAY, + &prev_lap.and_then(|p| p.gas), + &lap.gas, + ); + show_optional_usize_with_diff( + ui, + Color32::GREEN, + &prev_lap.and_then(|p| p.tyres), + &lap.tyres, + ); + + ui.end_row(); + } + } + }); +} + impl eframe::App for MyApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let mut state = self.state.lock().unwrap(); @@ -84,7 +182,28 @@ impl eframe::App for MyApp { ui.separator(); ui.checkbox(&mut state.debug_frames, "Debug OCR regions"); }); - egui::CentralPanel::default().show(ctx, |ui| {}); + egui::CentralPanel::default().show(ctx, |ui| { + if let Some(race) = &state.current_race { + ui.heading("Current Race"); + show_race_state(ui, race); + } + let len = state.past_races.len(); + for (i, race) in state.past_races.iter_mut().enumerate() { + ui.separator(); + ui.heading(format!("Race #{}", len - i)); + show_race_state(ui, race); + if let Some(img) = &race.screencap { + img.show_max_size(ui, Vec2::new(600.0, 500.0)); + } + ui.label("Car:"); + ui.text_edit_singleline(&mut race.car); + ui.label("Track:"); + ui.text_edit_singleline(&mut race.track); + if ui.button("Export").clicked() { + println!("EXPORT: TODO"); + } + } + }); if state.debug_frames { egui::SidePanel::right("screenshots").show(ctx, |ui| { @@ -92,7 +211,8 @@ impl eframe::App for MyApp { screenshots_sorted.sort_by_key(|(name, _)| name.clone()); for (name, image) in screenshots_sorted { ui.label(name); - image.show_max_size(ui, ui.available_size()); + ui.label(image.img_hash.to_base64()); + image.image.show_max_size(ui, ui.available_size()); } }); } diff --git a/src/ocr.rs b/src/ocr.rs index cfe6f91..3bc1a31 100644 --- a/src/ocr.rs +++ b/src/ocr.rs @@ -43,7 +43,7 @@ pub async fn ocr_all_regions(image: &RgbImage, regions: &[Region]) -> HashMap { diff --git a/src/state.rs b/src/state.rs index 8d4b51c..848f84d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,8 +1,12 @@ -use std::{sync::{Arc, Mutex}, time::Duration, collections::HashMap}; +use std::{sync::{Arc, Mutex}, time::{Duration, Instant}, collections::{HashMap, VecDeque}}; use egui_extras::RetainedImage; +use image::RgbImage; + +use crate::config::{Config, LearnedConfig}; +#[derive(Debug, Clone)] pub struct ParsedFrame { pub lap: Option, @@ -18,7 +22,6 @@ fn parse_duration(time: &str) -> Option { let sep = time.find(':')?; let (minutes, secs) = time.split_at(sep); - println!("{} {}", minutes, secs); let minutes = minutes.parse::().ok()?; let secs = secs[1..secs.len()].parse::().ok()?; @@ -54,13 +57,41 @@ impl ParsedFrame { } } +#[derive(Default)] +pub struct RaceState { + pub laps: Vec, + pub last_lap_record_time: Option, + + pub screencap: Option, + + pub exported: bool, + + pub car: String, + pub track: String, +} + +pub struct DebugOcrFrame { + pub image: RetainedImage, + pub rgb_image: RgbImage, + pub img_hash: img_hash::ImageHash, +} + #[derive(Default)] pub struct AppState { pub raw_data: HashMap>, pub last_frame: Option, + pub buffered_frames: VecDeque, + + pub frames_without_lap: usize, + pub current_race: Option, + pub past_races: VecDeque, + pub debug_frames: bool, - pub saved_frames: HashMap, + pub saved_frames: HashMap, + + pub config: Arc, + pub learned: Arc, } pub type SharedAppState = Arc>; \ No newline at end of file