From f7273b5a39c8c1e02ab893d7771dee4931791ccd Mon Sep 17 00:00:00 2001 From: Lol3rrr Date: Sat, 5 Oct 2024 05:18:28 +0200 Subject: [PATCH] Initial work on the actual maps for the heatmap overview --- .gitattributes | 1 + Cargo.lock | 69 +++++++------ analysis/src/heatmap.rs | 125 ++++++++++++++++++++--- analysis/tests/heatmap.rs | 3 +- backend/src/api/demos.rs | 27 ++++- backend/src/lib.rs | 2 +- frontend/index.html | 2 + frontend/src/demo.rs | 12 ++- frontend/src/demo/heatmap.rs | 24 ++++- frontend/static/minimaps/de_ancient.png | 3 + frontend/static/minimaps/de_anubis.png | 3 + frontend/static/minimaps/de_dust2.png | 3 + frontend/static/minimaps/de_inferno.png | 3 + frontend/static/minimaps/de_mirage.png | 3 + frontend/static/minimaps/de_overpass.png | 3 + 15 files changed, 228 insertions(+), 55 deletions(-) create mode 100755 frontend/static/minimaps/de_ancient.png create mode 100755 frontend/static/minimaps/de_anubis.png create mode 100755 frontend/static/minimaps/de_dust2.png create mode 100755 frontend/static/minimaps/de_inferno.png create mode 100755 frontend/static/minimaps/de_mirage.png create mode 100755 frontend/static/minimaps/de_overpass.png diff --git a/.gitattributes b/.gitattributes index c6be91b..d52ccc1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ testfiles/* filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text diff --git a/Cargo.lock b/Cargo.lock index 4b5743d..6b4b250 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "addr2line" -version = "0.24.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5fb1d8e4442bd405fdfd1dacb42792696b0cf9cb15882e5d097b742a676d375" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ "gimli", ] @@ -289,7 +289,7 @@ dependencies = [ "futures-util", "image", "memmap2", - "reqwest 0.12.7", + "reqwest 0.12.8", "serde", "serde_json", "steam-openid", @@ -411,9 +411,9 @@ checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" [[package]] name = "cc" -version = "1.1.22" +version = "1.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9540e661f81799159abee814118cc139a2004b3a3aa3ea37724a1b66530b90e0" +checksum = "812acba72f0a070b003d3697490d2b55b837230ae7c6c6497f05cc2ddbb8d938" dependencies = [ "jobserver", "libc", @@ -465,9 +465,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.18" +version = "4.5.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0956a43b323ac1afaffc053ed5c4b7c1f1800bacd1683c353aabbb752515dd3" +checksum = "7be5744db7978a28d9df86a214130d106a89ce49644cbc4e3f0c22c3fba30615" dependencies = [ "clap_builder", "clap_derive", @@ -475,9 +475,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.18" +version = "4.5.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d72166dd41634086d5803a47eb71ae740e61d84709c36f3c34110173db3961b" +checksum = "a5fbc17d3ef8278f55b282b2a2e75ae6f6c7d4bb70ed3d0382375104bfafdb4b" dependencies = [ "anstream", "anstyle", @@ -666,7 +666,7 @@ dependencies = [ [[package]] name = "csdemo" version = "0.1.0" -source = "git+https://github.com/Lol3rrr/csdemo.git#777248ab3c597b36103a9e0fa05e606134a1503c" +source = "git+https://github.com/Lol3rrr/csdemo.git#4671d0cbde48800dbb99cabba27ccb408df9fc3f" dependencies = [ "bitter", "phf", @@ -719,7 +719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" dependencies = [ "cfg-if", - "hashbrown", + "hashbrown 0.14.5", "lock_api", "once_cell", "parking_lot_core", @@ -1113,9 +1113,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.31.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "gloo-net" @@ -1238,6 +1238,12 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" + [[package]] name = "heck" version = "0.5.0" @@ -1332,9 +1338,9 @@ checksum = "08a397c49fec283e3d6211adbe480be95aae5f304cfb923e9970e08956d5168a" [[package]] name = "httparse" -version = "1.9.4" +version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" +checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" [[package]] name = "httpdate" @@ -1509,12 +1515,12 @@ checksum = "44feda355f4159a7c757171a77de25daf6411e217b4cabd03bd6650690468126" [[package]] name = "indexmap" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.0", ] [[package]] @@ -1542,9 +1548,9 @@ checksum = "f958d3d68f4167080a18141e10381e7634563984a537f2a49a30fd8e53ac5767" [[package]] name = "ipnet" -version = "2.10.0" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" +checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" [[package]] name = "is_terminal_polyfill" @@ -2114,9 +2120,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.4" +version = "0.36.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" dependencies = [ "memchr", ] @@ -2819,9 +2825,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.7" +version = "0.12.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8f4955649ef5c38cc7f9e8aa41761d48fb9677197daea9984dc54f56aad5e63" +checksum = "f713147fbe92361e52392c73b8c9e48c04c6625bce969ef54dc901e58e042a7b" dependencies = [ "base64 0.22.1", "bytes", @@ -2844,7 +2850,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile 2.1.3", + "rustls-pemfile 2.2.0", "serde", "serde_json", "serde_urlencoded", @@ -2947,11 +2953,10 @@ dependencies = [ [[package]] name = "rustls-pemfile" -version = "2.1.3" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "196fe16b00e106300d3e45ecfcb764fa292a535d7326a29a5875c579c7417425" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" dependencies = [ - "base64 0.22.1", "rustls-pki-types", ] @@ -3927,9 +3932,9 @@ dependencies = [ [[package]] name = "unicode-bidi" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" +checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" [[package]] name = "unicode-ident" @@ -3948,9 +3953,9 @@ dependencies = [ [[package]] name = "unicode-properties" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ea75f83c0137a9b98608359a5f1af8144876eb67bcb1ce837368e906a9f524" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" [[package]] name = "unicode-segmentation" diff --git a/analysis/src/heatmap.rs b/analysis/src/heatmap.rs index bac0607..9cdbfee 100644 --- a/analysis/src/heatmap.rs +++ b/analysis/src/heatmap.rs @@ -4,19 +4,27 @@ pub struct Config { #[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct HeatMap { + #[serde(default)] + min_x: usize, + #[serde(default)] + min_y: usize, max_x: usize, max_y: usize, max_value: usize, rows: Vec>, + block_size: f32, } impl HeatMap { - fn new() -> Self { + fn new(block_size: f32) -> Self { Self { + min_x: 0, + min_y: 0, max_x: 0, max_y: 0, max_value: 0, rows: Vec::new(), + block_size, } } @@ -133,6 +141,8 @@ fn get_entityid(props: &[csdemo::parser::entities::EntityProp]) -> Option { }) } +pub const MAX_COORD: f32 = (1 << 14) as f32; + fn process_tick( config: &Config, tick_state: &csdemo::parser::EntityTickStates, @@ -146,7 +156,7 @@ fn process_tick( for entity_state in tick_state .states .iter() - .filter(|s| s.class == "CCSPlayerPawn") + .filter(|s| s.class.as_ref() == "CCSPlayerPawn") { if let Some(pawn_id) = get_entityid(&entity_state.props) { let user_id = pawn_ids.get(&pawn_id).cloned().unwrap(); @@ -192,8 +202,6 @@ fn process_tick( assert!(y_coord >= 0.0); assert!(z_coord >= 0.0); - const MAX_COORD: f32 = (1 << 14) as f32; - let x_cell_coord = ((x_cell as f32 * (1 << 9) as f32)) as f32; let y_cell_coord = ((y_cell as f32 * (1 << 9) as f32)) as f32; let z_cell_coord = ((z_cell as f32 * (1 << 9) as f32)) as f32; @@ -235,7 +243,7 @@ fn process_tick( // 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()); + let heatmap = heatmaps.entry(user_id.clone()).or_insert(HeatMap::new(config.cell_size)); heatmap.increment(x_cell, y_cell); } } @@ -256,13 +264,20 @@ impl core::fmt::Display for HeatMap { } impl HeatMap { + pub fn coords(&self) -> ((f32, f32), (f32, f32)) { + ( + (self.min_x as f32 * self.block_size - MAX_COORD, self.max_x as f32 * self.block_size - MAX_COORD), + (self.min_y as f32 * self.block_size - MAX_COORD, self.max_y as f32 * self.block_size - MAX_COORD) + ) + } + 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); + let mut buffer = image::RgbImage::new((self.max_x - self.min_x) as u32 + 1, (self.max_y - self.min_y) as u32 + 1); 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) { + for (x, cell) in row.iter().copied().chain(core::iter::repeat(0)).enumerate().take((self.max_x - self.min_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(); @@ -273,17 +288,101 @@ impl HeatMap { buffer } - pub fn shrink(&mut self) { - 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); + pub fn fit(&mut self, xs: core::ops::Range, ys: core::ops::Range) { + let min_x = (xs.start / self.block_size - self.min_x as f32) as usize; + let min_y = (ys.start / self.block_size - self.min_y as f32) as usize; let _ = self.rows.drain(0..min_y); for row in self.rows.iter_mut() { - let _ = row.drain(0..min_x); + let _ = row.drain(0..min_x.min(row.len())); } + let x_steps = ((xs.end - xs.start) / self.block_size) as usize; + let y_steps = ((ys.end - ys.start) / self.block_size) as usize; - self.max_y = self.rows.len(); - self.max_x = self.rows.iter().map(|r| r.len()).max().unwrap_or(0); + for row in self.rows.iter_mut() { + row.resize(x_steps, 0); + } + self.rows.resize_with(y_steps, || vec![0; x_steps]); + + self.min_y += (0..min_y).len(); + self.min_x += (0..min_x).len(); + + self.max_y = self.min_y + self.rows.len(); + self.max_x = self.min_x + self.rows.iter().map(|r| r.len()).max().unwrap_or(0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fit_no_cutoff() { + let mut input = HeatMap::new(2.0); + + input.increment(3, 3); + input.increment(2, 2); + + assert_eq!(input.min_x, 0); + assert_eq!(input.min_y, 0); + assert_eq!(input.max_x, 3); + assert_eq!(input.max_y, 3); + + assert_eq!( + &vec![ + vec![], + vec![], + vec![0, 0, 1], + vec![0, 0, 0, 1] + ], + &input.rows + ); + + input.fit(2.0..10.0, 2.0..10.0); + + + assert_eq!( + &vec![ + vec![0, 0, 0, 0], + vec![0, 1, 0, 0], + vec![0, 0, 1, 0], + vec![0, 0, 0, 0], + ], + &input.rows + ); + } + + #[test] + fn fit_cutoff() { + let mut input = HeatMap::new(2.0); + + input.increment(3, 3); + input.increment(2, 2); + + assert_eq!(input.min_x, 0); + assert_eq!(input.min_y, 0); + assert_eq!(input.max_x, 3); + assert_eq!(input.max_y, 3); + + assert_eq!( + &vec![ + vec![], + vec![], + vec![0, 0, 1], + vec![0, 0, 0, 1] + ], + &input.rows + ); + + input.fit(6.0..10.0, 6.0..10.0); + + assert_eq!( + &vec![ + vec![1, 0], + vec![0, 0] + ], + &input.rows + ); } } diff --git a/analysis/tests/heatmap.rs b/analysis/tests/heatmap.rs index 7448e5e..50b65cf 100644 --- a/analysis/tests/heatmap.rs +++ b/analysis/tests/heatmap.rs @@ -11,7 +11,8 @@ fn heatmap_nuke() { let config = heatmap::Config { cell_size: 5.0 }; let result = heatmap::parse(&config, &input_bytes).unwrap(); - assert_eq!(result.player_heatmaps.len(), result.player_info.len()); + assert_eq!(result.player_heatmaps.len(), 11); + assert_eq!(result.entity_to_player.len(), 12); } #[test] diff --git a/backend/src/api/demos.rs b/backend/src/api/demos.rs index 782c7ed..8923b00 100644 --- a/backend/src/api/demos.rs +++ b/backend/src/api/demos.rs @@ -72,7 +72,13 @@ async fn upload( tracing::info!("Upload for Session: {:?}", steam_id); - let file_content = crate::get_demo_from_upload("demo", form).await.unwrap(); + let file_content = match crate::get_demo_from_upload("demo", form).await { + Some(c) => c, + None => { + tracing::error!("Getting File content from request"); + return Err((axum::http::StatusCode::BAD_REQUEST, "Failed to get file-content from upload")); + } + }; let user_folder = std::path::Path::new(&state.upload_folder).join(format!("{}/", steam_id)); if !tokio::fs::try_exists(&user_folder).await.unwrap_or(false) { @@ -260,13 +266,28 @@ async fn heatmap( Ok(d) => d, Err(e) => { tracing::error!("Querying DB: {:?}", e); - return Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR);; + return Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR); } }; + // TODO + // These are currently the values for de_inferno + // The corresponding values for each map can be found using the Source2 Viewer and opening the + // files in 'game/csgo/pak01_dir.vpk' and then 'resource/overviews/{map}.txt' + let pos_x: f32 = 2087.0; + let pos_y: f32 = 3870.0; + let scale: f32 = 4.9; + + let x = |map_coord: f32| { + (map_coord * scale) - pos_x + analysis::heatmap::MAX_COORD + }; + let y = |map_coord: f32| { + -(map_coord * scale) + pos_y + analysis::heatmap::MAX_COORD + }; + let data: Vec = result.into_iter().map(|(player, heatmap)| { let mut heatmap: analysis::heatmap::HeatMap = serde_json::from_str(&heatmap.data).unwrap(); - heatmap.shrink(); + heatmap.fit(x(0.0)..x(1024.0), y(1024.0)..y(0.0)); let h_image = heatmap.as_image(); let mut buffer = std::io::Cursor::new(Vec::new()); diff --git a/backend/src/lib.rs b/backend/src/lib.rs index e5a4ee8..a7147db 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -66,8 +66,8 @@ pub async fn run_api( "/api/", crate::api::router(crate::api::RouterConfig { steam_api_key: steam_api_key.into(), - steam_callback_base_url: "http://localhost:3000".into(), // steam_callback_base_url: "http://localhost:3000".into(), + steam_callback_base_url: "http://192.168.0.156:3000".into(), steam_callback_path: "/api/steam/callback".into(), upload_dir: upload_folder.clone(), }), diff --git a/frontend/index.html b/frontend/index.html index 15e85df..e7d2d59 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,8 @@ + + diff --git a/frontend/src/demo.rs b/frontend/src/demo.rs index 5ccfcd7..c7cb496 100644 --- a/frontend/src/demo.rs +++ b/frontend/src/demo.rs @@ -3,11 +3,17 @@ use leptos_router::{Outlet, A}; pub mod heatmap; +#[derive(Debug, Clone)] +struct CurrentDemoName(ReadSignal); + #[leptos::component] pub fn demo() -> impl leptos::IntoView { let params = leptos_router::use_params_map(); let id = move || params.with(|params| params.get("id").cloned().unwrap_or_default()); + let (rx, map_tx) = create_signal(String::new()); + provide_context(CurrentDemoName(rx.clone())); + let demo_info = create_resource( || (), move |_| async move { @@ -15,7 +21,11 @@ pub fn demo() -> impl leptos::IntoView { .send() .await .unwrap(); - res.json::().await.unwrap() + let value = res.json::().await.unwrap(); + + map_tx.set(value.map.clone()); + + value }, ); diff --git a/frontend/src/demo/heatmap.rs b/frontend/src/demo/heatmap.rs index bd6163f..a71dca2 100644 --- a/frontend/src/demo/heatmap.rs +++ b/frontend/src/demo/heatmap.rs @@ -1,5 +1,7 @@ use leptos::*; +use super::CurrentDemoName; + #[leptos::component] pub fn heatmaps() -> impl leptos::IntoView { let heatmaps_resource = @@ -40,12 +42,22 @@ fn heatmap_view(heatmaps: Vec) -> impl lep let h1 = heatmaps.clone(); + let map = use_context::().unwrap(); + let style = stylers::style! { "Heatmap-View", - img { - width: 75vw; - height: 75vw; + .heatmap_image { + width: 1024px; + height: 1024px; display: block; + position: relative; + } + .heatmap_image > * { + position: absolute; + } + + .heatmap_image > .heatmap { + opacity: 0.5; } }; @@ -72,7 +84,11 @@ fn heatmap_view(heatmaps: Vec) -> impl lep move || { match value.get() { Some(heatmap) => view! { - + class=style, +
+ + +
}.into_any(), None => view! {

ERROR

}.into_any(), } diff --git a/frontend/static/minimaps/de_ancient.png b/frontend/static/minimaps/de_ancient.png new file mode 100755 index 0000000..3107125 --- /dev/null +++ b/frontend/static/minimaps/de_ancient.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f28947c2420b30eabc1ad90db9857f03f9f14065740edc8144d0f265ddd3eff +size 156098 diff --git a/frontend/static/minimaps/de_anubis.png b/frontend/static/minimaps/de_anubis.png new file mode 100755 index 0000000..5980d1a --- /dev/null +++ b/frontend/static/minimaps/de_anubis.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f8ab21b5d3a106d3166177ba87ba9cdea12c09a49af56b2eea6ee1dc2fe93ac7 +size 223176 diff --git a/frontend/static/minimaps/de_dust2.png b/frontend/static/minimaps/de_dust2.png new file mode 100755 index 0000000..ea8b498 --- /dev/null +++ b/frontend/static/minimaps/de_dust2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4549e4e49fdb34bf28cc820b7b7b9f9c47cf3a1743caec2a67f2435ca6e8398c +size 153248 diff --git a/frontend/static/minimaps/de_inferno.png b/frontend/static/minimaps/de_inferno.png new file mode 100755 index 0000000..d736acb --- /dev/null +++ b/frontend/static/minimaps/de_inferno.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85161826d4f51f5ed9812ea24dacad4d54efa4e32fb076563ff2b3f8179105b8 +size 128732 diff --git a/frontend/static/minimaps/de_mirage.png b/frontend/static/minimaps/de_mirage.png new file mode 100755 index 0000000..cffde5c --- /dev/null +++ b/frontend/static/minimaps/de_mirage.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4767f3213b1764f44a2d8fc126333328196184a316b30c8a9030c20afcdf47e +size 135467 diff --git a/frontend/static/minimaps/de_overpass.png b/frontend/static/minimaps/de_overpass.png new file mode 100755 index 0000000..b05445f --- /dev/null +++ b/frontend/static/minimaps/de_overpass.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d196e60eeb9a452cef2676c8974888df3ff9ee5ecaec6d6cc03185548b327623 +size 189394