From 83b4a24b15b826ff08e103dc1c0e3311ced428a3 Mon Sep 17 00:00:00 2001 From: Lol3rrr Date: Sun, 29 Sep 2024 00:32:20 +0200 Subject: [PATCH] Add Heatmaps to UI Add Heatmap analysis to website as well as a basic UI for viewing the Heatmaps. There are still issues, like some players not getting a heatmap assigned and heatmaps including data from warmup etc. --- Cargo.lock | 10 +++ analysis/Cargo.toml | 4 + analysis/src/heatmap.rs | 76 +++++++++-------- analysis/tests/heatmap.rs | 4 +- backend/Cargo.toml | 3 + backend/src/analysis.rs | 28 ++++++- backend/src/api.rs | 2 + backend/src/api/demos.rs | 41 +++++++++ backend/src/lib.rs | 32 +++++-- backend/src/main.rs | 1 + backend/src/models.rs | 9 ++ backend/src/schema.rs | 10 +++ common/src/lib.rs | 6 ++ frontend/index.html | 2 + frontend/src/demo.rs | 5 +- frontend/src/demo/heatmap.rs | 83 +++++++++++++++++++ frontend/src/main.rs | 1 + migrations/2024-09-28-132839_heatmap/down.sql | 2 + migrations/2024-09-28-132839_heatmap/up.sql | 7 ++ 19 files changed, 280 insertions(+), 46 deletions(-) create mode 100644 frontend/src/demo/heatmap.rs create mode 100644 migrations/2024-09-28-132839_heatmap/down.sql create mode 100644 migrations/2024-09-28-132839_heatmap/up.sql diff --git a/Cargo.lock b/Cargo.lock index e5ac10f..cb6ee8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,9 +42,11 @@ checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1" name = "analysis" version = "0.1.0" dependencies = [ + "colors-transform", "csdemo", "image", "pretty_assertions", + "serde", "tracing", "tracing-test", ] @@ -277,6 +279,7 @@ dependencies = [ "analysis", "async-trait", "axum", + "base64 0.22.1", "clap", "common", "csdemo", @@ -284,6 +287,7 @@ dependencies = [ "diesel-async", "diesel_async_migrations", "futures-util", + "image", "memmap2", "reqwest 0.12.7", "serde", @@ -517,6 +521,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" +[[package]] +name = "colors-transform" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9226dbc05df4fb986f48d730b001532580883c4c06c5d1c213f4b34c1c157178" + [[package]] name = "common" version = "0.1.0" diff --git a/analysis/Cargo.toml b/analysis/Cargo.toml index a515770..2611b1a 100644 --- a/analysis/Cargo.toml +++ b/analysis/Cargo.toml @@ -6,7 +6,11 @@ edition = "2021" [dependencies] csdemo = { package = "csdemo", git = "https://github.com/Lol3rrr/csdemo.git", ref = "main" } tracing = { version = "0.1.4" } + image = { version = "0.25" } +colors-transform = { version = "0.2" } + +serde = { version = "1.0", features = ["derive"] } [dev-dependencies] pretty_assertions = { version = "1.4" } diff --git a/analysis/src/heatmap.rs b/analysis/src/heatmap.rs index 0934366..8a72f25 100644 --- a/analysis/src/heatmap.rs +++ b/analysis/src/heatmap.rs @@ -2,6 +2,7 @@ pub struct Config { pub cell_size: f32, } +#[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct HeatMap { max_x: usize, max_y: usize, @@ -41,7 +42,7 @@ impl HeatMap { } } -pub fn parse(config: &Config, buf: &[u8]) -> Result, ()> { +pub fn parse(config: &Config, buf: &[u8]) -> Result<(std::collections::HashMap, std::collections::HashMap), ()> { let tmp = csdemo::Container::parse(buf).map_err(|e| ())?; let output = csdemo::parser::parse( csdemo::FrameIterator::parse(tmp.inner), @@ -64,6 +65,8 @@ pub fn parse(config: &Config, buf: &[u8]) -> Result::new(); let mut player_lifestate = std::collections::HashMap::::new(); let mut player_position = std::collections::HashMap::::new(); @@ -85,7 +88,22 @@ pub fn parse(config: &Config, buf: &[u8]) -> Result Option { + props.iter().find_map(|prop| { + if prop.prop_info.prop_name.as_ref() != "CCSPlayerPawn.m_nEntityId" { + return None; + } + + let pawn_id: i32 = match &prop.value { + csdemo::parser::Variant::U32(v) => *v as i32, + other => panic!("Unexpected Variant: {:?}", other), + }; + + Some(pawn_id) + }) } fn process_tick( @@ -103,33 +121,20 @@ fn process_tick( .iter() .filter(|s| s.class == "CCSPlayerPawn") { - let user_id_entry = entity_id_to_user.entry(entity_state.id); - let user_id = match user_id_entry { - std::collections::hash_map::Entry::Occupied(v) => v.into_mut(), - std::collections::hash_map::Entry::Vacant(v) => { - let pawn_id_prop: Option = entity_state.props.iter().find_map(|prop| { - if prop.prop_info.prop_name.as_ref() != "CCSPlayerPawn.m_nEntityId" { - return None; - } + let user_id = match get_entityid(&entity_state.props) { + Some(pawn_id) => { + let user_id = pawn_ids.get(&pawn_id).cloned().unwrap(); - let pawn_id: i32 = match &prop.value { - csdemo::parser::Variant::U32(v) => *v as i32, - other => panic!("Unexpected Variant: {:?}", other), - }; - - Some(pawn_id) - }); - - let user_id: Option = pawn_id_prop - .map(|pawn_id| pawn_ids.get(&pawn_id).cloned()) - .flatten(); - - match user_id { - Some(user_id) => v.insert(user_id), + entity_id_to_user.insert(entity_state.id, user_id.clone()); + user_id.clone() + } + None => { + match entity_id_to_user.get(&entity_state.id).cloned() { + Some(user) => user, None => continue, } } - }; + }; let _inner_guard = tracing::trace_span!("Entity", ?user_id, entity_id=?entity_state.id).entered(); @@ -147,7 +152,7 @@ fn process_tick( None => player_cells.get(&user_id).map(|(_, _, z)| *z).unwrap_or(0), }; - player_cells.insert(*user_id, (x_cell, y_cell, z_cell)); + player_cells.insert(user_id, (x_cell, y_cell, z_cell)); let x_coord = match entity_state.get_prop("CCSPlayerPawn.CBodyComponentBaseAnimGraph.m_vecX").map(|prop| prop.value.as_f32()).flatten() { Some(c) => c, @@ -162,7 +167,7 @@ fn process_tick( None => player_position.get(&user_id).map(|(_, _, z)| *z).unwrap_or(0.0), }; - player_position.insert(*user_id, (x_coord, y_coord, z_coord)); + player_position.insert(user_id, (x_coord, y_coord, z_coord)); assert!(x_coord >= 0.0); assert!(y_coord >= 0.0); @@ -198,7 +203,7 @@ fn process_tick( let lifestate = match n_lifestate { Some(state) => { - player_lifestate.insert(*user_id, state); + player_lifestate.insert(user_id, state); state } None => player_lifestate.get(&user_id).copied().unwrap_or(1), @@ -209,7 +214,7 @@ fn process_tick( continue; } - tracing::trace!("Coord (X, Y, Z): {:?} -> {:?}", (x_coord, y_coord, z_coord), (x_cell, y_cell)); + // tracing::trace!("Coord (X, Y, Z): {:?} -> {:?}", (x_coord, y_coord, z_coord), (x_cell, y_cell)); let heatmap = heatmaps.entry(user_id.clone()).or_insert(HeatMap::new()); heatmap.increment(x_cell, y_cell); @@ -233,13 +238,16 @@ impl core::fmt::Display for HeatMap { impl HeatMap { pub fn as_image(&self) -> image::RgbImage { + use colors_transform::Color; + let mut buffer = image::RgbImage::new(self.max_x as u32 + 1, self.max_y as u32 + 1); - tracing::trace!("Creating Image with Dimensions: {}x{}", buffer.width(), buffer.height()); + for (y, row) in self.rows.iter().rev().enumerate() { + for (x, cell) in row.iter().copied().chain(core::iter::repeat(0)).enumerate().take(self.max_x) { + let scaled = (1.0/(1.0 + (cell as f32))) * 240.0; + let raw_rgb = colors_transform::Hsl::from(scaled, 100.0, 50.0).to_rgb(); - for (y, row) in self.rows.iter().enumerate() { - for (x, cell) in row.iter().enumerate() { - buffer.put_pixel(x as u32, y as u32, image::Rgb([*cell as u8, 0, 0])) + buffer.put_pixel(x as u32, y as u32, image::Rgb([raw_rgb.get_red() as u8, raw_rgb.get_green() as u8, raw_rgb.get_blue() as u8])) } } @@ -250,8 +258,6 @@ impl HeatMap { let min_x = self.rows.iter().filter_map(|row| row.iter().enumerate().filter(|(_, v)| **v != 0).map(|(i, _)| i).next()).min().unwrap_or(0); let min_y = self.rows.iter().enumerate().filter(|(y, row)| row.iter().any(|v| *v != 0)).map(|(i, _)| i).min().unwrap_or(0); - tracing::trace!("Truncate to Min-X: {} - Min-Y: {}", min_x, min_y); - let _ = self.rows.drain(0..min_y); for row in self.rows.iter_mut() { let _ = row.drain(0..min_x); diff --git a/analysis/tests/heatmap.rs b/analysis/tests/heatmap.rs index 365d14f..264ec43 100644 --- a/analysis/tests/heatmap.rs +++ b/analysis/tests/heatmap.rs @@ -8,8 +8,8 @@ fn heatmap_nuke() { dbg!(path); let input_bytes = std::fs::read(path).unwrap(); - let config = heatmap::Config { cell_size: 25.0 }; - let result = heatmap::parse(&config, &input_bytes).unwrap(); + let config = heatmap::Config { cell_size: 5.0 }; + let (result, players) = heatmap::parse(&config, &input_bytes).unwrap(); for (user, mut heatmap) in result { heatmap.shrink(); diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f31adfa..c0c0d3c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -26,6 +26,9 @@ reqwest = { version = "0.12", features = ["json"] } common = { path = "../common/" } analysis = { path = "../analysis/" } +image = { version = "0.25" } +base64 = { version = "0.22" } + csdemo = { package = "csdemo", git = "https://github.com/Lol3rrr/csdemo.git", ref = "main" } memmap2 = { version = "0.9" } clap = { version = "4.5", features = ["derive"] } diff --git a/backend/src/analysis.rs b/backend/src/analysis.rs index 9a36fb5..c188b5b 100644 --- a/backend/src/analysis.rs +++ b/backend/src/analysis.rs @@ -63,7 +63,7 @@ pub async fn poll_next_task( } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct AnalysisInput { pub steamid: String, pub demoid: i64, @@ -120,3 +120,29 @@ pub fn analyse_base(input: AnalysisInput) -> BaseInfo { }).collect() } } + +#[tracing::instrument(skip(input))] +pub fn analyse_heatmap(input: AnalysisInput) -> std::collections::HashMap { + tracing::info!("Generating HEATMAPs"); + + let file = std::fs::File::open(&input.path).unwrap(); + let mmap = unsafe { memmap2::MmapOptions::new().map(&file).unwrap() }; + + let config = analysis::heatmap::Config { + cell_size: 5.0, + }; + let (heatmaps, players) = analysis::heatmap::parse(&config, &mmap).unwrap(); + + tracing::info!("Got {} Heatmaps", heatmaps.len()); + heatmaps.into_iter().filter_map(|(userid, heatmap)| { + let player = match players.get(&userid) { + Some(p) => p, + None => { + tracing::warn!("Could not find player: {:?}", userid); + return None; + } + }; + + Some((player.xuid.to_string(), heatmap)) + }).collect() +} diff --git a/backend/src/api.rs b/backend/src/api.rs index 122d477..fa52519 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -40,6 +40,8 @@ pub mod steam { ) -> Result { let url = state.openid.get_redirect_url(); + tracing::info!("Redirecting to {:?}", url); + Ok(axum::response::Redirect::to(url)) } diff --git a/backend/src/api/demos.rs b/backend/src/api/demos.rs index 18df9e8..782c7ed 100644 --- a/backend/src/api/demos.rs +++ b/backend/src/api/demos.rs @@ -22,6 +22,7 @@ where .route("/:id/info", axum::routing::get(info)) .route("/:id/analysis/scoreboard", axum::routing::get(scoreboard)) .route("/:id/reanalyse", axum::routing::get(analyise)) + .route("/:id/analysis/heatmap", axum::routing::get(heatmap)) .with_state(Arc::new(DemoState { upload_folder: upload_folder.into(), })) @@ -239,3 +240,43 @@ async fn scoreboard( team2, })) } + +#[tracing::instrument(skip(session))] +async fn heatmap( + session: UserSession, + Path(demo_id): Path, +) -> Result>, axum::http::StatusCode> { + use base64::prelude::Engine; + + let query = crate::schema::demo_players::dsl::demo_players + .inner_join(crate::schema::demo_heatmaps::dsl::demo_heatmaps.on( + crate::schema::demo_players::dsl::steam_id.eq(crate::schema::demo_heatmaps::dsl::steam_id) + .and(crate::schema::demo_players::dsl::demo_id.eq(crate::schema::demo_heatmaps::dsl::demo_id)) + )).filter(crate::schema::demo_players::dsl::demo_id.eq(demo_id)); + + let mut db_con = crate::db_connection().await; + + let result: Vec<(crate::models::DemoPlayer, crate::models::DemoPlayerHeatmap)> = match query.load(&mut db_con).await { + Ok(d) => d, + Err(e) => { + tracing::error!("Querying DB: {:?}", e); + return Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR);; + } + }; + + let data: Vec = result.into_iter().map(|(player, heatmap)| { + let mut heatmap: analysis::heatmap::HeatMap = serde_json::from_str(&heatmap.data).unwrap(); + heatmap.shrink(); + let h_image = heatmap.as_image(); + + let mut buffer = std::io::Cursor::new(Vec::new()); + h_image.write_to(&mut buffer, image::ImageFormat::Png).unwrap(); + + common::demo_analysis::PlayerHeatmap { + name: player.name, + png_data: base64::prelude::BASE64_STANDARD.encode(buffer.into_inner()), + } + }).collect(); + + Ok(axum::Json(data)) +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 07a3250..e5a4ee8 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -66,7 +66,7 @@ pub async fn run_api( "/api/", crate::api::router(crate::api::RouterConfig { steam_api_key: steam_api_key.into(), - steam_callback_base_url: "http://192.168.0.156:3000".into(), + steam_callback_base_url: "http://localhost:3000".into(), // steam_callback_base_url: "http://localhost:3000".into(), steam_callback_path: "/api/steam/callback".into(), upload_dir: upload_folder.clone(), @@ -101,15 +101,18 @@ pub async fn run_analysis(upload_folder: impl Into) { let demo_id = input.demoid; - let result = tokio::task::spawn_blocking(move || crate::analysis::analyse_base(input)) + let base_input = input.clone(); + let base_result = tokio::task::spawn_blocking(move || crate::analysis::analyse_base(base_input)) .await .unwrap(); - tracing::debug!("Analysis-Result: {:?}", result); + let heatmap_result = tokio::task::spawn_blocking(move || crate::analysis::analyse_heatmap(input)) + .await + .unwrap(); let mut db_con = crate::db_connection().await; - let (player_info, player_stats): (Vec<_>, Vec<_>) = result + let (player_info, player_stats): (Vec<_>, Vec<_>) = base_result .players .into_iter() .map(|(info, stats)| { @@ -133,9 +136,19 @@ pub async fn run_analysis(upload_folder: impl Into) { }) .unzip(); + let player_heatmaps: Vec<_> = heatmap_result.into_iter().map(|(player, heatmap)| { + tracing::trace!("HeatMap for Player: {:?}", player); + + crate::models::DemoPlayerHeatmap { + demo_id, + steam_id: player, + data: serde_json::to_string(&heatmap).unwrap(), + } + }).collect(); + let demo_info = crate::models::DemoInfo { demo_id, - map: result.map, + map: base_result.map, }; let store_demo_info_query = @@ -173,6 +186,11 @@ pub async fn run_analysis(upload_folder: impl Into) { crate::schema::demo_player_stats::dsl::damage, )), )); + let store_demo_player_heatmaps_query = diesel::dsl::insert_into(crate::schema::demo_heatmaps::dsl::demo_heatmaps) + .values(player_heatmaps) + .on_conflict((crate::schema::demo_heatmaps::dsl::demo_id, crate::schema::demo_heatmaps::dsl::steam_id)) + .do_update() + .set((crate::schema::demo_heatmaps::dsl::data.eq(diesel::upsert::excluded(crate::schema::demo_heatmaps::dsl::data)))); let update_process_info = diesel::dsl::update(crate::schema::processing_status::dsl::processing_status) .set(crate::schema::processing_status::dsl::info.eq(1)) @@ -184,6 +202,7 @@ pub async fn run_analysis(upload_folder: impl Into) { store_demo_info_query.execute(conn).await?; store_demo_players_query.execute(conn).await?; store_demo_player_stats_query.execute(conn).await?; + store_demo_player_heatmaps_query.execute(conn).await?; update_process_info.execute(conn).await?; Ok(()) }) @@ -191,7 +210,6 @@ pub async fn run_analysis(upload_folder: impl Into) { .await .unwrap(); - // TODO - // Remove task from queue + tracing::info!("Stored analysis results"); } } diff --git a/backend/src/main.rs b/backend/src/main.rs index f681631..78ab303 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -27,6 +27,7 @@ async fn main() { .with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::filter::filter_fn(|meta| { meta.target().contains("backend") + || meta.target().contains("analysis") })); tracing::subscriber::set_global_default(registry).unwrap(); diff --git a/backend/src/models.rs b/backend/src/models.rs index 48b2006..3be79ae 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -79,3 +79,12 @@ pub struct AnalysisTask { pub demo_id: i64, pub steam_id: String, } + +#[derive(Queryable, Selectable, Insertable, Debug)] +#[diesel(table_name = crate::schema::demo_heatmaps)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct DemoPlayerHeatmap { + pub demo_id: i64, + pub steam_id: String, + pub data: String, +} diff --git a/backend/src/schema.rs b/backend/src/schema.rs index 572b476..fa783a0 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -8,6 +8,14 @@ diesel::table! { } } +diesel::table! { + demo_heatmaps (demo_id, steam_id) { + demo_id -> Int8, + steam_id -> Text, + data -> Text, + } +} + diesel::table! { demo_info (demo_id) { demo_id -> Int8, @@ -66,6 +74,7 @@ diesel::table! { } diesel::joinable!(analysis_queue -> demos (demo_id)); +diesel::joinable!(demo_heatmaps -> demo_info (demo_id)); diesel::joinable!(demo_info -> demos (demo_id)); diesel::joinable!(demo_player_stats -> demo_info (demo_id)); diesel::joinable!(demo_players -> demo_info (demo_id)); @@ -73,6 +82,7 @@ diesel::joinable!(processing_status -> demos (demo_id)); diesel::allow_tables_to_appear_in_same_query!( analysis_queue, + demo_heatmaps, demo_info, demo_player_stats, demo_players, diff --git a/common/src/lib.rs b/common/src/lib.rs index ea4c32b..1fac98d 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -31,4 +31,10 @@ pub mod demo_analysis { pub damage: usize, pub assists: usize, } + + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] + pub struct PlayerHeatmap { + pub name: String, + pub png_data: String, + } } diff --git a/frontend/index.html b/frontend/index.html index 0adfe97..15e85df 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,6 +2,8 @@ + + diff --git a/frontend/src/demo.rs b/frontend/src/demo.rs index 60708a6..5ccfcd7 100644 --- a/frontend/src/demo.rs +++ b/frontend/src/demo.rs @@ -1,6 +1,8 @@ use leptos::*; use leptos_router::{Outlet, A}; +pub mod heatmap; + #[leptos::component] pub fn demo() -> impl leptos::IntoView { let params = leptos_router::use_params_map(); @@ -39,7 +41,7 @@ pub fn demo() -> impl leptos::IntoView { "Demo", .analysis_bar { display: grid; - grid-template-columns: auto auto; + grid-template-columns: auto auto auto; column-gap: 20px; background-color: #2d2d2d; @@ -68,6 +70,7 @@ pub fn demo() -> impl leptos::IntoView {
diff --git a/frontend/src/demo/heatmap.rs b/frontend/src/demo/heatmap.rs new file mode 100644 index 0000000..a1f7334 --- /dev/null +++ b/frontend/src/demo/heatmap.rs @@ -0,0 +1,83 @@ +use leptos::*; + +#[leptos::component] +pub fn heatmaps() -> impl leptos::IntoView { + let heatmaps_resource = + create_resource(leptos_router::use_params_map(), |params| async move { + let id = params.get("id").unwrap(); + + let res = + reqwasm::http::Request::get(&format!("/api/demos/{}/analysis/heatmap", id)) + .send() + .await + .unwrap(); + res.json::>() + .await + .unwrap() + }); + + view! { +

Heatmaps

+ + Loading Heatmaps

}> +
+ { + move || { + heatmaps_resource.get().map(|h| { + view! { } + }) + } + } +
+
+ } +} + +#[leptos::component] +fn heatmap_view(heatmaps: Vec) -> impl leptos::IntoView { + let (idx, set_idx) = create_signal(0usize); + let (value, set_value) = create_signal(None::); + + let h1 = heatmaps.clone(); + + let style = stylers::style! { + "Heatmap-View", + img { + width: 75vw; + height: 75vw; + display: block; + } + }; + + view! { + class=style, +
+ +
+ + { + move || { + match value.get() { + Some(heatmap) => view! { + + }.into_any(), + None => view! {

ERROR

}.into_any(), + } + } + } +
+ } +} diff --git a/frontend/src/main.rs b/frontend/src/main.rs index a1b2ef9..1b77a88 100644 --- a/frontend/src/main.rs +++ b/frontend/src/main.rs @@ -32,6 +32,7 @@ fn main() { + diff --git a/migrations/2024-09-28-132839_heatmap/down.sql b/migrations/2024-09-28-132839_heatmap/down.sql new file mode 100644 index 0000000..0298a9e --- /dev/null +++ b/migrations/2024-09-28-132839_heatmap/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE demo_heatmaps; diff --git a/migrations/2024-09-28-132839_heatmap/up.sql b/migrations/2024-09-28-132839_heatmap/up.sql new file mode 100644 index 0000000..5283664 --- /dev/null +++ b/migrations/2024-09-28-132839_heatmap/up.sql @@ -0,0 +1,7 @@ +-- Your SQL goes here +CREATE TABLE IF NOT EXISTS demo_heatmaps ( + demo_id bigint REFERENCES demo_info(demo_id), + steam_id TEXT NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY (demo_id, steam_id) +);