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.
This commit is contained in:
Lol3rrr
2024-09-29 00:32:20 +02:00
parent 7f23f4882d
commit 83b4a24b15
19 changed files with 280 additions and 46 deletions

10
Cargo.lock generated
View File

@@ -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"

View File

@@ -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" }

View File

@@ -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<std::collections::HashMap<csdemo::UserId, HeatMap>, ()> {
pub fn parse(config: &Config, buf: &[u8]) -> Result<(std::collections::HashMap<csdemo::UserId, HeatMap>, std::collections::HashMap<csdemo::UserId, csdemo::parser::Player>), ()> {
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<std::collections::HashMap<cs
})
.collect();
tracing::debug!("Pawn-IDs: {:?}", pawn_ids);
let mut entity_id_to_user = std::collections::HashMap::<i32, csdemo::UserId>::new();
let mut player_lifestate = std::collections::HashMap::<csdemo::UserId, u32>::new();
let mut player_position = std::collections::HashMap::<csdemo::UserId, (f32, f32, f32)>::new();
@@ -85,7 +88,22 @@ pub fn parse(config: &Config, buf: &[u8]) -> Result<std::collections::HashMap<cs
);
}
Ok(heatmaps)
Ok((heatmaps, output.player_info))
}
fn get_entityid(props: &[csdemo::parser::entities::EntityProp]) -> Option<i32> {
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,29 +121,16 @@ 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<i32> = 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();
entity_id_to_user.insert(entity_state.id, user_id.clone());
user_id.clone()
}
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<csdemo::UserId> = pawn_id_prop
.map(|pawn_id| pawn_ids.get(&pawn_id).cloned())
.flatten();
match user_id {
Some(user_id) => v.insert(user_id),
None => {
match entity_id_to_user.get(&entity_state.id).cloned() {
Some(user) => user,
None => continue,
}
}
@@ -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);

View File

@@ -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();

View File

@@ -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"] }

View File

@@ -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<String, analysis::heatmap::HeatMap> {
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()
}

View File

@@ -40,6 +40,8 @@ pub mod steam {
) -> Result<axum::response::Redirect, axum::http::StatusCode> {
let url = state.openid.get_redirect_url();
tracing::info!("Redirecting to {:?}", url);
Ok(axum::response::Redirect::to(url))
}

View File

@@ -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<i64>,
) -> Result<axum::response::Json<Vec<common::demo_analysis::PlayerHeatmap>>, 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<common::demo_analysis::PlayerHeatmap> = 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))
}

View File

@@ -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<std::path::PathBuf>) {
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<std::path::PathBuf>) {
})
.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<std::path::PathBuf>) {
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<std::path::PathBuf>) {
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<std::path::PathBuf>) {
.await
.unwrap();
// TODO
// Remove task from queue
tracing::info!("Stored analysis results");
}
}

View File

@@ -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();

View File

@@ -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,
}

View File

@@ -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,

View File

@@ -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,
}
}

View File

@@ -2,6 +2,8 @@
<html>
<head>
<link rel="stylesheet" href="/main.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body></body>
</html>

View File

@@ -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 {
<div class="analysis_bar">
<div class="analysis_selector" class:current=move || selected_tab() == "scoreboard"><A href="scoreboard"><span>Scoreboard</span></A></div>
<div class="analysis_selector" class:current=move || selected_tab() == "perround"><A href="perround"><span>Per Round</span></A></div>
<div class="analysis_selector" class:current=move || selected_tab() == "heatmaps"><A href="heatmaps"><span>Heatmaps</span></A></div>
</div>
<div>
<Outlet/>

View File

@@ -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::<Vec<common::demo_analysis::PlayerHeatmap>>()
.await
.unwrap()
});
view! {
<h3>Heatmaps</h3>
<Suspense fallback=move || view! { <p>Loading Heatmaps</p> }>
<div>
{
move || {
heatmaps_resource.get().map(|h| {
view! { <HeatmapView heatmaps=h /> }
})
}
}
</div>
</Suspense>
}
}
#[leptos::component]
fn heatmap_view(heatmaps: Vec<common::demo_analysis::PlayerHeatmap>) -> impl leptos::IntoView {
let (idx, set_idx) = create_signal(0usize);
let (value, set_value) = create_signal(None::<common::demo_analysis::PlayerHeatmap>);
let h1 = heatmaps.clone();
let style = stylers::style! {
"Heatmap-View",
img {
width: 75vw;
height: 75vw;
display: block;
}
};
view! {
class=style,
<div>
<select on:change=move |ev| {
let new_value = event_target_value(&ev);
let idx: usize = new_value.parse().unwrap();
set_value(heatmaps.get(idx).cloned());
set_idx(idx);
} prop:value=move || idx.get().to_string()>
{ (move |heatmaps: Vec<common::demo_analysis::PlayerHeatmap>| {
heatmaps.iter().enumerate().map(|(idx, heatmap)| {
view! {
<option value={idx}>{heatmap.name.clone()}</option>
}
}).collect::<Vec<_>>()
})(h1.clone())}
</select>
<br />
{
move || {
match value.get() {
Some(heatmap) => view! {
<img class="heatmap_img" src=format!("data:image/jpeg;base64,{}", heatmap.png_data) />
}.into_any(),
None => view! { <p>ERROR</p> }.into_any(),
}
}
}
</div>
}
}

View File

@@ -32,6 +32,7 @@ fn main() {
<Route path="/demo/:id" view=Demo>
<Route path="perround" view=frontend::demo::PerRound />
<Route path="scoreboard" view=frontend::demo::Scoreboard />
<Route path="heatmaps" view=frontend::demo::heatmap::Heatmaps />
<Route path="" view=frontend::demo::Scoreboard />
</Route>
</Routes>

View File

@@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE demo_heatmaps;

View File

@@ -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)
);