Refactor a huge amount of code!
Signed-off-by: prescientmoon <git@moonythm.dev>
This commit is contained in:
		
					parent
					
						
							
								8298bdf7cb
							
						
					
				
			
			
				commit
				
					
						eec8d4f964
					
				
			
		
					 25 changed files with 1627 additions and 1786 deletions
				
			
		
							
								
								
									
										277
									
								
								Cargo.lock
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										277
									
								
								Cargo.lock
									
										
									
										generated
									
									
									
								
							|  | @ -24,7 +24,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" | checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "cfg-if", |  "cfg-if", | ||||||
|  "getrandom", |  | ||||||
|  "once_cell", |  "once_cell", | ||||||
|  "version_check", |  "version_check", | ||||||
|  "zerocopy", |  "zerocopy", | ||||||
|  | @ -180,28 +179,6 @@ version = "1.6.0" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" | checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" | ||||||
| 
 | 
 | ||||||
| [[package]] |  | ||||||
| name = "bindgen" |  | ||||||
| version = "0.64.0" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "c4243e6031260db77ede97ad86c27e501d646a27ab57b59a574f725d98ab1fb4" |  | ||||||
| dependencies = [ |  | ||||||
|  "bitflags 1.3.2", |  | ||||||
|  "cexpr", |  | ||||||
|  "clang-sys", |  | ||||||
|  "lazy_static", |  | ||||||
|  "lazycell", |  | ||||||
|  "log", |  | ||||||
|  "peeking_take_while", |  | ||||||
|  "proc-macro2", |  | ||||||
|  "quote", |  | ||||||
|  "regex", |  | ||||||
|  "rustc-hash", |  | ||||||
|  "shlex", |  | ||||||
|  "syn 1.0.109", |  | ||||||
|  "which", |  | ||||||
| ] |  | ||||||
| 
 |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "bit_field" | name = "bit_field" | ||||||
| version = "0.10.2" | version = "0.10.2" | ||||||
|  | @ -322,15 +299,6 @@ dependencies = [ | ||||||
|  "once_cell", |  "once_cell", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] |  | ||||||
| name = "cexpr" |  | ||||||
| version = "0.6.0" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" |  | ||||||
| dependencies = [ |  | ||||||
|  "nom", |  | ||||||
| ] |  | ||||||
| 
 |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "cfg-expr" | name = "cfg-expr" | ||||||
| version = "0.15.8" | version = "0.15.8" | ||||||
|  | @ -362,23 +330,21 @@ dependencies = [ | ||||||
|  "windows-targets 0.52.5", |  "windows-targets 0.52.5", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] |  | ||||||
| name = "clang-sys" |  | ||||||
| version = "1.8.1" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" |  | ||||||
| dependencies = [ |  | ||||||
|  "glob", |  | ||||||
|  "libc", |  | ||||||
|  "libloading", |  | ||||||
| ] |  | ||||||
| 
 |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "color_quant" | name = "color_quant" | ||||||
| version = "1.1.0" | version = "1.1.0" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" | checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "concurrent-queue" | ||||||
|  | version = "2.5.0" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" | ||||||
|  | dependencies = [ | ||||||
|  |  "crossbeam-utils", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "const-oid" | name = "const-oid" | ||||||
| version = "0.9.6" | version = "0.9.6" | ||||||
|  | @ -742,9 +708,14 @@ dependencies = [ | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "event-listener" | name = "event-listener" | ||||||
| version = "2.5.3" | version = "5.3.1" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" | checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" | ||||||
|  | dependencies = [ | ||||||
|  |  "concurrent-queue", | ||||||
|  |  "parking", | ||||||
|  |  "pin-project-lite", | ||||||
|  | ] | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "exr" | name = "exr" | ||||||
|  | @ -1095,22 +1066,13 @@ dependencies = [ | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "hashlink" | name = "hashlink" | ||||||
| version = "0.8.4" | version = "0.9.1" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" | checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "hashbrown", |  "hashbrown", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] |  | ||||||
| name = "heck" |  | ||||||
| version = "0.4.1" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" |  | ||||||
| dependencies = [ |  | ||||||
|  "unicode-segmentation", |  | ||||||
| ] |  | ||||||
| 
 |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "heck" | name = "heck" | ||||||
| version = "0.5.0" | version = "0.5.0" | ||||||
|  | @ -1239,6 +1201,16 @@ dependencies = [ | ||||||
|  "tokio-rustls 0.24.1", |  "tokio-rustls 0.24.1", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "hypertesseract" | ||||||
|  | version = "0.1.0" | ||||||
|  | source = "git+https://github.com/BlueGhostGH/hypertesseract.git?rev=78dd8ab#78dd8ab1bbab9d7985959a5a8ac2746bce17ff5c" | ||||||
|  | dependencies = [ | ||||||
|  |  "image 0.25.2", | ||||||
|  |  "sys", | ||||||
|  |  "thin", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "iana-time-zone" | name = "iana-time-zone" | ||||||
| version = "0.1.60" | version = "0.1.60" | ||||||
|  | @ -1294,12 +1266,12 @@ dependencies = [ | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "image" | name = "image" | ||||||
| version = "0.25.1" | version = "0.25.2" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "fd54d660e773627692c524beaad361aca785a4f9f5730ce91f42aabe5bce3d11" | checksum = "99314c8a2152b8ddb211f924cdae532d8c5e4c8bb54728e12fff1b0cd5963a10" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "bytemuck", |  "bytemuck", | ||||||
|  "byteorder", |  "byteorder-lite", | ||||||
|  "color_quant", |  "color_quant", | ||||||
|  "exr", |  "exr", | ||||||
|  "gif 0.13.1", |  "gif 0.13.1", | ||||||
|  | @ -1406,40 +1378,12 @@ dependencies = [ | ||||||
|  "spin 0.5.2", |  "spin 0.5.2", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] |  | ||||||
| name = "lazycell" |  | ||||||
| version = "1.3.0" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" |  | ||||||
| 
 |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "lebe" | name = "lebe" | ||||||
| version = "0.5.2" | version = "0.5.2" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" | checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" | ||||||
| 
 | 
 | ||||||
| [[package]] |  | ||||||
| name = "leptonica-plumbing" |  | ||||||
| version = "1.4.0" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "cc7a74c43d6f090d39158d233f326f47cd8bba545217595c93662b4e31156f42" |  | ||||||
| dependencies = [ |  | ||||||
|  "leptonica-sys", |  | ||||||
|  "libc", |  | ||||||
|  "thiserror", |  | ||||||
| ] |  | ||||||
| 
 |  | ||||||
| [[package]] |  | ||||||
| name = "leptonica-sys" |  | ||||||
| version = "0.4.8" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "1c924779fadc73838b9390ddda5fc1939f844fb43bd44ef6794c32bd6e52238a" |  | ||||||
| dependencies = [ |  | ||||||
|  "bindgen", |  | ||||||
|  "pkg-config", |  | ||||||
|  "vcpkg", |  | ||||||
| ] |  | ||||||
| 
 |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "libc" | name = "libc" | ||||||
| version = "0.2.155" | version = "0.2.155" | ||||||
|  | @ -1485,9 +1429,9 @@ dependencies = [ | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "libsqlite3-sys" | name = "libsqlite3-sys" | ||||||
| version = "0.27.0" | version = "0.28.0" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" | checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "cc", |  "cc", | ||||||
|  "pkg-config", |  "pkg-config", | ||||||
|  | @ -1764,6 +1708,24 @@ version = "1.19.0" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" | checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "openssl-sys" | ||||||
|  | version = "0.9.103" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" | ||||||
|  | dependencies = [ | ||||||
|  |  "cc", | ||||||
|  |  "libc", | ||||||
|  |  "pkg-config", | ||||||
|  |  "vcpkg", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "parking" | ||||||
|  | version = "2.2.0" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "parking_lot" | name = "parking_lot" | ||||||
| version = "0.12.3" | version = "0.12.3" | ||||||
|  | @ -1812,12 +1774,6 @@ dependencies = [ | ||||||
|  "rustc_version", |  "rustc_version", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] |  | ||||||
| name = "peeking_take_while" |  | ||||||
| version = "0.1.2" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" |  | ||||||
| 
 |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "pem-rfc7468" | name = "pem-rfc7468" | ||||||
| version = "0.7.0" | version = "0.7.0" | ||||||
|  | @ -2283,12 +2239,6 @@ version = "0.1.24" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" | checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" | ||||||
| 
 | 
 | ||||||
| [[package]] |  | ||||||
| name = "rustc-hash" |  | ||||||
| version = "1.1.0" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" |  | ||||||
| 
 |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "rustc_version" | name = "rustc_version" | ||||||
| version = "0.4.0" | version = "0.4.0" | ||||||
|  | @ -2545,21 +2495,15 @@ version = "0.1.0" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "chrono", |  "chrono", | ||||||
|  "freetype-rs", |  "freetype-rs", | ||||||
|  "image 0.25.1", |  "hypertesseract", | ||||||
|  |  "image 0.25.2", | ||||||
|  "num", |  "num", | ||||||
|  "plotters", |  "plotters", | ||||||
|  "poise", |  "poise", | ||||||
|  "sqlx", |  "sqlx", | ||||||
|  "tesseract", |  | ||||||
|  "tokio", |  "tokio", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] |  | ||||||
| name = "shlex" |  | ||||||
| version = "1.3.0" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" |  | ||||||
| 
 |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "signature" | name = "signature" | ||||||
| version = "2.2.0" | version = "2.2.0" | ||||||
|  | @ -2614,6 +2558,9 @@ name = "smallvec" | ||||||
| version = "1.13.2" | version = "1.13.2" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" | checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" | ||||||
|  | dependencies = [ | ||||||
|  |  "serde", | ||||||
|  | ] | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "socket2" | name = "socket2" | ||||||
|  | @ -2662,9 +2609,9 @@ dependencies = [ | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "sqlx" | name = "sqlx" | ||||||
| version = "0.7.4" | version = "0.8.0" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa" | checksum = "27144619c6e5802f1380337a209d2ac1c431002dd74c6e60aebff3c506dc4f0c" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "sqlx-core", |  "sqlx-core", | ||||||
|  "sqlx-macros", |  "sqlx-macros", | ||||||
|  | @ -2675,11 +2622,10 @@ dependencies = [ | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "sqlx-core" | name = "sqlx-core" | ||||||
| version = "0.7.4" | version = "0.8.0" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6" | checksum = "a999083c1af5b5d6c071d34a708a19ba3e02106ad82ef7bbd69f5e48266b613b" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "ahash", |  | ||||||
|  "atoi", |  "atoi", | ||||||
|  "byteorder", |  "byteorder", | ||||||
|  "bytes", |  "bytes", | ||||||
|  | @ -2693,6 +2639,7 @@ dependencies = [ | ||||||
|  "futures-intrusive", |  "futures-intrusive", | ||||||
|  "futures-io", |  "futures-io", | ||||||
|  "futures-util", |  "futures-util", | ||||||
|  |  "hashbrown", | ||||||
|  "hashlink", |  "hashlink", | ||||||
|  "hex", |  "hex", | ||||||
|  "indexmap", |  "indexmap", | ||||||
|  | @ -2715,26 +2662,26 @@ dependencies = [ | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "sqlx-macros" | name = "sqlx-macros" | ||||||
| version = "0.7.4" | version = "0.8.0" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127" | checksum = "a23217eb7d86c584b8cbe0337b9eacf12ab76fe7673c513141ec42565698bb88" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "proc-macro2", |  "proc-macro2", | ||||||
|  "quote", |  "quote", | ||||||
|  "sqlx-core", |  "sqlx-core", | ||||||
|  "sqlx-macros-core", |  "sqlx-macros-core", | ||||||
|  "syn 1.0.109", |  "syn 2.0.66", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "sqlx-macros-core" | name = "sqlx-macros-core" | ||||||
| version = "0.7.4" | version = "0.8.0" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8" | checksum = "1a099220ae541c5db479c6424bdf1b200987934033c2584f79a0e1693601e776" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "dotenvy", |  "dotenvy", | ||||||
|  "either", |  "either", | ||||||
|  "heck 0.4.1", |  "heck", | ||||||
|  "hex", |  "hex", | ||||||
|  "once_cell", |  "once_cell", | ||||||
|  "proc-macro2", |  "proc-macro2", | ||||||
|  | @ -2746,7 +2693,7 @@ dependencies = [ | ||||||
|  "sqlx-mysql", |  "sqlx-mysql", | ||||||
|  "sqlx-postgres", |  "sqlx-postgres", | ||||||
|  "sqlx-sqlite", |  "sqlx-sqlite", | ||||||
|  "syn 1.0.109", |  "syn 2.0.66", | ||||||
|  "tempfile", |  "tempfile", | ||||||
|  "tokio", |  "tokio", | ||||||
|  "url", |  "url", | ||||||
|  | @ -2754,12 +2701,12 @@ dependencies = [ | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "sqlx-mysql" | name = "sqlx-mysql" | ||||||
| version = "0.7.4" | version = "0.8.0" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" | checksum = "5afe4c38a9b417b6a9a5eeffe7235d0a106716495536e7727d1c7f4b1ff3eba6" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "atoi", |  "atoi", | ||||||
|  "base64 0.21.7", |  "base64 0.22.1", | ||||||
|  "bitflags 2.5.0", |  "bitflags 2.5.0", | ||||||
|  "byteorder", |  "byteorder", | ||||||
|  "bytes", |  "bytes", | ||||||
|  | @ -2797,12 +2744,12 @@ dependencies = [ | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "sqlx-postgres" | name = "sqlx-postgres" | ||||||
| version = "0.7.4" | version = "0.8.0" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" | checksum = "b1dbb157e65f10dbe01f729339c06d239120221c9ad9fa0ba8408c4cc18ecf21" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "atoi", |  "atoi", | ||||||
|  "base64 0.21.7", |  "base64 0.22.1", | ||||||
|  "bitflags 2.5.0", |  "bitflags 2.5.0", | ||||||
|  "byteorder", |  "byteorder", | ||||||
|  "chrono", |  "chrono", | ||||||
|  | @ -2836,9 +2783,9 @@ dependencies = [ | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "sqlx-sqlite" | name = "sqlx-sqlite" | ||||||
| version = "0.7.4" | version = "0.8.0" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa" | checksum = "9b2cdd83c008a622d94499c0006d8ee5f821f36c89b7d625c900e5dc30b5c5ee" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "atoi", |  "atoi", | ||||||
|  "chrono", |  "chrono", | ||||||
|  | @ -2852,10 +2799,10 @@ dependencies = [ | ||||||
|  "log", |  "log", | ||||||
|  "percent-encoding", |  "percent-encoding", | ||||||
|  "serde", |  "serde", | ||||||
|  |  "serde_urlencoded", | ||||||
|  "sqlx-core", |  "sqlx-core", | ||||||
|  "tracing", |  "tracing", | ||||||
|  "url", |  "url", | ||||||
|  "urlencoding", |  | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
|  | @ -2909,6 +2856,16 @@ version = "0.1.2" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" | checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "sys" | ||||||
|  | version = "0.1.0" | ||||||
|  | source = "git+https://github.com/BlueGhostGH/hypertesseract.git?rev=78dd8ab#78dd8ab1bbab9d7985959a5a8ac2746bce17ff5c" | ||||||
|  | dependencies = [ | ||||||
|  |  "openssl-sys", | ||||||
|  |  "pkg-config", | ||||||
|  |  "vcpkg", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "system-configuration" | name = "system-configuration" | ||||||
| version = "0.5.1" | version = "0.5.1" | ||||||
|  | @ -2937,7 +2894,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" | checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "cfg-expr", |  "cfg-expr", | ||||||
|  "heck 0.5.0", |  "heck", | ||||||
|  "pkg-config", |  "pkg-config", | ||||||
|  "toml", |  "toml", | ||||||
|  "version-compare", |  "version-compare", | ||||||
|  | @ -2968,37 +2925,11 @@ dependencies = [ | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "tesseract" | name = "thin" | ||||||
| version = "0.15.1" | version = "0.1.0" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "git+https://github.com/BlueGhostGH/hypertesseract.git?rev=78dd8ab#78dd8ab1bbab9d7985959a5a8ac2746bce17ff5c" | ||||||
| checksum = "220d5c325aa2fa6656edd8924ad9a91d7ac7b5e998fe0f083a84f7f06ec9fda7" |  | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "tesseract-plumbing", |  "sys", | ||||||
|  "tesseract-sys", |  | ||||||
|  "thiserror", |  | ||||||
| ] |  | ||||||
| 
 |  | ||||||
| [[package]] |  | ||||||
| name = "tesseract-plumbing" |  | ||||||
| version = "0.11.0" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "f7fb02c52201d03517af73dd0a146ac62cbd6f0155ad3dc6455d0140d6112191" |  | ||||||
| dependencies = [ |  | ||||||
|  "leptonica-plumbing", |  | ||||||
|  "tesseract-sys", |  | ||||||
|  "thiserror", |  | ||||||
| ] |  | ||||||
| 
 |  | ||||||
| [[package]] |  | ||||||
| name = "tesseract-sys" |  | ||||||
| version = "0.5.15" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "bd33f6f216124cfaf0fa86c2c0cdf04da39b6257bd78c5e44fa4fa98c3a5857b" |  | ||||||
| dependencies = [ |  | ||||||
|  "bindgen", |  | ||||||
|  "leptonica-sys", |  | ||||||
|  "pkg-config", |  | ||||||
|  "vcpkg", |  | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
|  | @ -3355,12 +3286,6 @@ version = "0.1.1" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "e4259d9d4425d9f0661581b804cb85fe66a4c631cadd8f490d1c13a35d5d9291" | checksum = "e4259d9d4425d9f0661581b804cb85fe66a4c631cadd8f490d1c13a35d5d9291" | ||||||
| 
 | 
 | ||||||
| [[package]] |  | ||||||
| name = "unicode-segmentation" |  | ||||||
| version = "1.11.0" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" |  | ||||||
| 
 |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "unicode_categories" | name = "unicode_categories" | ||||||
| version = "0.1.1" | version = "0.1.1" | ||||||
|  | @ -3385,12 +3310,6 @@ dependencies = [ | ||||||
|  "serde", |  "serde", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] |  | ||||||
| name = "urlencoding" |  | ||||||
| version = "2.1.3" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" |  | ||||||
| 
 |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "utf-8" | name = "utf-8" | ||||||
| version = "0.7.6" | version = "0.7.6" | ||||||
|  | @ -3567,18 +3486,6 @@ version = "0.1.8" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" | checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" | ||||||
| 
 | 
 | ||||||
| [[package]] |  | ||||||
| name = "which" |  | ||||||
| version = "4.4.2" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" |  | ||||||
| dependencies = [ |  | ||||||
|  "either", |  | ||||||
|  "home", |  | ||||||
|  "once_cell", |  | ||||||
|  "rustix", |  | ||||||
| ] |  | ||||||
| 
 |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "whoami" | name = "whoami" | ||||||
| version = "1.5.1" | version = "1.5.1" | ||||||
|  |  | ||||||
|  | @ -6,12 +6,12 @@ edition = "2021" | ||||||
| [dependencies] | [dependencies] | ||||||
| chrono = "0.4.38" | chrono = "0.4.38" | ||||||
| freetype-rs = "0.36.0" | freetype-rs = "0.36.0" | ||||||
| image = "0.25.1" | image = "0.25.2" | ||||||
| num = "0.4.3" | num = "0.4.3" | ||||||
| plotters = { git="https://github.com/starlitcanopy/plotters.git", rev="986cd959362a2dbec8d1b25670fd083b904d7b8c", features=["bitmap_backend"] } | plotters = { git="https://github.com/starlitcanopy/plotters.git", rev="986cd959362a2dbec8d1b25670fd083b904d7b8c", features=["bitmap_backend"] } | ||||||
| poise = "0.6.1" | poise = "0.6.1" | ||||||
| sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio", "chrono"] } | sqlx = { version = "0.8.0", features = ["sqlite", "runtime-tokio", "chrono"] } | ||||||
| tesseract = "0.15.1" | hypertesseract = { features=["image"], git="https://github.com/BlueGhostGH/hypertesseract.git", rev="78dd8ab" } | ||||||
| tokio = {version="1.38.0", features=["rt-multi-thread"]} | tokio = {version="1.38.0", features=["rt-multi-thread"]} | ||||||
| 
 | 
 | ||||||
| [profile.dev.package."*"] | [profile.dev.package."*"] | ||||||
|  |  | ||||||
							
								
								
									
										18
									
								
								data/ui.txt
									
										
									
									
									
								
							
							
						
						
									
										18
									
								
								data/ui.txt
									
										
									
									
									
								
							|  | @ -3,12 +3,12 @@ | ||||||
| 1037  462  476   91 Score screen — score | 1037  462  476   91 Score screen — score | ||||||
|  274  434  614  611 Score screen — jacket |  274  434  614  611 Score screen — jacket | ||||||
|  378  332  161   34 Score screen — difficulty |  378  332  161   34 Score screen — difficulty | ||||||
| 1288  849   82   39 Score screen — pures | 1288  846   82   45 Score screen — pures | ||||||
| 1288  909   82   39 Score screen — fars | 1288  906   82   45 Score screen — fars | ||||||
| 1288  969   82   39 Score screen — losts | 1288  966   82   45 Score screen — losts | ||||||
|  584  377   74   31 Score screen — max recall |  584  377   74   31 Score screen — max recall | ||||||
|  634  116 1252  102 Score screen — title |  634  116 1252  102 Score screen — title | ||||||
|   95  256  278   49 Song  select — score |   95  246  278   69 Song  select — score | ||||||
|  465  319  730   45 Song  select — jacket |  465  319  730   45 Song  select — jacket | ||||||
|   89  153    0    0 Song  select — PST |   89  153    0    0 Song  select — PST | ||||||
|  269  153    0    0 Song  select — PRS |  269  153    0    0 Song  select — PRS | ||||||
|  | @ -20,14 +20,14 @@ | ||||||
|  841  682  500   94 Score screen — score |  841  682  500   94 Score screen — score | ||||||
|   51  655  633  632 Score screen — jacket |   51  655  633  632 Score screen — jacket | ||||||
|  155  546  167   38 Score screen — difficulty |  155  546  167   38 Score screen — difficulty | ||||||
| 1104 1087   87   34 Score screen — pures | 1104 1084   87   40 Score screen — pures | ||||||
| 1104 1150   87   34 Score screen — fars | 1104 1147   87   40 Score screen — fars | ||||||
| 1104 1212   87   34 Score screen — losts | 1104 1209   87   40 Score screen — losts | ||||||
|  364  593   87   34 Score screen — max recall |  364  593   87   34 Score screen — max recall | ||||||
|  438  324 1244  104 Score screen — title |  438  324 1244  104 Score screen — title | ||||||
|   15  264  291   52 Song  select — score |   15  254  291   72 Song  select — score | ||||||
|  158  411  909   74 Song  select — jacket |  158  411  909   74 Song  select — jacket | ||||||
|   12  159    0    0 Song  select — PST |   12  159    0    0 Song  select — PST | ||||||
|  199  159    0    0 Song  select — PRS |  199  159    0    0 Song  select — PRS | ||||||
|  389  159    0    0 Song  select — FTR |  389  159    0    0 Song  select — FTR | ||||||
|  579  159    0    0 Song  select — ETR/BYD |  581  159    0    0 Song  select — ETR/BYD | ||||||
|  |  | ||||||
							
								
								
									
										78
									
								
								flake.nix
									
										
									
									
									
								
							
							
						
						
									
										78
									
								
								flake.nix
									
										
									
									
									
								
							|  | @ -6,54 +6,52 @@ | ||||||
|     fenix.inputs.nixpkgs.follows = "nixpkgs"; |     fenix.inputs.nixpkgs.follows = "nixpkgs"; | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   outputs = { self, ... }@inputs: |   outputs = | ||||||
|     inputs.flake-utils.lib.eachSystem |     { ... }@inputs: | ||||||
|       (with inputs.flake-utils.lib.system; [ x86_64-linux ]) |     inputs.flake-utils.lib.eachSystem (with inputs.flake-utils.lib.system; [ x86_64-linux ]) ( | ||||||
|       (system: |       system: | ||||||
|         let |       let | ||||||
|           pkgs = inputs.nixpkgs.legacyPackages.${system}.extend |         pkgs = inputs.nixpkgs.legacyPackages.${system}.extend inputs.fenix.overlays.default; | ||||||
|             inputs.fenix.overlays.default; |         inherit (pkgs) lib; | ||||||
|           inherit (pkgs) lib; |       in | ||||||
|         in |       { | ||||||
|         { |         devShell = pkgs.mkShell rec { | ||||||
|           devShell = pkgs.mkShell rec { |           packages = with pkgs; [ | ||||||
|             packages = with pkgs; [ |             (fenix.complete.withComponents [ | ||||||
|               (fenix.complete.withComponents [ |               "cargo" | ||||||
|                 "cargo" |               "clippy" | ||||||
|                 "clippy" |               "rust-src" | ||||||
|                 "rust-src" |               "rustc" | ||||||
|                 "rustc" |               "rustfmt" | ||||||
|                 "rustfmt" |             ]) | ||||||
|               ]) |             rust-analyzer-nightly | ||||||
|               rust-analyzer-nightly |             ruff | ||||||
|               ruff |             imagemagick | ||||||
|               imagemagick |             fontconfig | ||||||
|               fontconfig |             freetype | ||||||
|               freetype |  | ||||||
| 
 | 
 | ||||||
|               clang |             clang | ||||||
|               llvmPackages.clang |             llvmPackages.clang | ||||||
|               pkg-config |             pkg-config | ||||||
| 
 | 
 | ||||||
|               leptonica |             leptonica | ||||||
|               tesseract |             tesseract | ||||||
|               openssl |             openssl | ||||||
|               sqlite |             sqlite | ||||||
|             ]; |           ]; | ||||||
| 
 | 
 | ||||||
|             LD_LIBRARY_PATH = lib.makeLibraryPath packages; |           LD_LIBRARY_PATH = lib.makeLibraryPath packages; | ||||||
| 
 | 
 | ||||||
|             # compilation of -sys packages requires manually setting LIBCLANG_PATH |           # compilation of -sys packages requires manually setting LIBCLANG_PATH | ||||||
|             LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; |           LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; | ||||||
|           }; |         }; | ||||||
|         }); |       } | ||||||
|  |     ); | ||||||
| 
 | 
 | ||||||
|   # {{{ Caching and whatnot |   # {{{ Caching and whatnot | ||||||
|   # TODO: persist trusted substituters file |   # TODO: persist trusted substituters file | ||||||
|   nixConfig = { |   nixConfig = { | ||||||
|     extra-substituters = [ |     extra-substituters = [ "https://nix-community.cachix.org" ]; | ||||||
|       "https://nix-community.cachix.org" |  | ||||||
|     ]; |  | ||||||
| 
 | 
 | ||||||
|     extra-trusted-public-keys = [ |     extra-trusted-public-keys = [ | ||||||
|       "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=" |       "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=" | ||||||
|  |  | ||||||
|  | @ -4,10 +4,10 @@ use image::{imageops::FilterType, GenericImageView, Rgba}; | ||||||
| use num::Integer; | use num::Integer; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|  | 	arcaea::chart::{Difficulty, Jacket, SongCache}, | ||||||
| 	assets::{get_assets_dir, should_skip_jacket_art}, | 	assets::{get_assets_dir, should_skip_jacket_art}, | ||||||
| 	chart::{Difficulty, Jacket, SongCache}, |  | ||||||
| 	context::Error, | 	context::Error, | ||||||
| 	score::guess_chart_name, | 	recognition::fuzzy_song_name::guess_chart_name, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| /// How many sub-segments to split each side into
 | /// How many sub-segments to split each side into
 | ||||||
|  | @ -78,7 +78,7 @@ pub struct JacketCache { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl JacketCache { | impl JacketCache { | ||||||
| 	// {{{ Generate tree
 | 	// {{{ Generate
 | ||||||
| 	// This is a bit inefficient (using a hash set), but only runs once
 | 	// This is a bit inefficient (using a hash set), but only runs once
 | ||||||
| 	pub fn new(data_dir: &PathBuf, song_cache: &mut SongCache) -> Result<Self, Error> { | 	pub fn new(data_dir: &PathBuf, song_cache: &mut SongCache) -> Result<Self, Error> { | ||||||
| 		let jacket_dir = data_dir.join("jackets"); | 		let jacket_dir = data_dir.join("jackets"); | ||||||
							
								
								
									
										4
									
								
								src/arcaea/mod.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/arcaea/mod.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | ||||||
|  | pub mod chart; | ||||||
|  | pub mod jacket; | ||||||
|  | pub mod play; | ||||||
|  | pub mod score; | ||||||
							
								
								
									
										371
									
								
								src/arcaea/play.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										371
									
								
								src/arcaea/play.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,371 @@ | ||||||
|  | use std::str::FromStr; | ||||||
|  | 
 | ||||||
|  | use num::traits::Euclid; | ||||||
|  | use poise::serenity_prelude::{ | ||||||
|  | 	Attachment, AttachmentId, CreateAttachment, CreateEmbed, CreateEmbedAuthor, Timestamp, | ||||||
|  | }; | ||||||
|  | use sqlx::{query_as, SqlitePool}; | ||||||
|  | 
 | ||||||
|  | use crate::arcaea::chart::{Chart, Song}; | ||||||
|  | use crate::context::{Error, UserContext}; | ||||||
|  | use crate::user::User; | ||||||
|  | 
 | ||||||
|  | use super::score::Score; | ||||||
|  | 
 | ||||||
|  | // {{{ Create play
 | ||||||
|  | #[derive(Debug, Clone)] | ||||||
|  | pub struct CreatePlay { | ||||||
|  | 	chart_id: u32, | ||||||
|  | 	user_id: u32, | ||||||
|  | 	discord_attachment_id: Option<AttachmentId>, | ||||||
|  | 
 | ||||||
|  | 	// Actual score data
 | ||||||
|  | 	score: Score, | ||||||
|  | 	zeta_score: Score, | ||||||
|  | 
 | ||||||
|  | 	// Optional score details
 | ||||||
|  | 	max_recall: Option<u32>, | ||||||
|  | 	far_notes: Option<u32>, | ||||||
|  | 
 | ||||||
|  | 	// Creation data
 | ||||||
|  | 	creation_ptt: Option<u32>, | ||||||
|  | 	creation_zeta_ptt: Option<u32>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl CreatePlay { | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn new(score: Score, chart: &Chart, user: &User) -> Self { | ||||||
|  | 		Self { | ||||||
|  | 			chart_id: chart.id, | ||||||
|  | 			user_id: user.id, | ||||||
|  | 			discord_attachment_id: None, | ||||||
|  | 			score, | ||||||
|  | 			zeta_score: score.to_zeta(chart.note_count as u32), | ||||||
|  | 			max_recall: None, | ||||||
|  | 			far_notes: None, | ||||||
|  | 			// TODO: populate these
 | ||||||
|  | 			creation_ptt: None, | ||||||
|  | 			creation_zeta_ptt: None, | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn with_attachment(mut self, attachment: &Attachment) -> Self { | ||||||
|  | 		self.discord_attachment_id = Some(attachment.id); | ||||||
|  | 		self | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn with_fars(mut self, far_count: Option<u32>) -> Self { | ||||||
|  | 		self.far_notes = far_count; | ||||||
|  | 		self | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn with_max_recall(mut self, max_recall: Option<u32>) -> Self { | ||||||
|  | 		self.max_recall = max_recall; | ||||||
|  | 		self | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// {{{ Save
 | ||||||
|  | 	pub async fn save(self, ctx: &UserContext) -> Result<Play, Error> { | ||||||
|  | 		let attachment_id = self.discord_attachment_id.map(|i| i.get() as i64); | ||||||
|  | 		let play = sqlx::query!( | ||||||
|  | 			" | ||||||
|  |         INSERT INTO plays( | ||||||
|  |         user_id,chart_id,discord_attachment_id, | ||||||
|  |         score,zeta_score,max_recall,far_notes | ||||||
|  |         ) | ||||||
|  |         VALUES(?,?,?,?,?,?,?) | ||||||
|  |         RETURNING id, created_at | ||||||
|  |       ",
 | ||||||
|  | 			self.user_id, | ||||||
|  | 			self.chart_id, | ||||||
|  | 			attachment_id, | ||||||
|  | 			self.score.0, | ||||||
|  | 			self.zeta_score.0, | ||||||
|  | 			self.max_recall, | ||||||
|  | 			self.far_notes | ||||||
|  | 		) | ||||||
|  | 		.fetch_one(&ctx.db) | ||||||
|  | 		.await?; | ||||||
|  | 
 | ||||||
|  | 		Ok(Play { | ||||||
|  | 			id: play.id as u32, | ||||||
|  | 			created_at: play.created_at, | ||||||
|  | 			chart_id: self.chart_id, | ||||||
|  | 			user_id: self.user_id, | ||||||
|  | 			discord_attachment_id: self.discord_attachment_id, | ||||||
|  | 			score: self.score, | ||||||
|  | 			zeta_score: self.zeta_score, | ||||||
|  | 			max_recall: self.max_recall, | ||||||
|  | 			far_notes: self.far_notes, | ||||||
|  | 			creation_ptt: self.creation_ptt, | ||||||
|  | 			creation_zeta_ptt: self.creation_zeta_ptt, | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | 	// }}}
 | ||||||
|  | } | ||||||
|  | // }}}
 | ||||||
|  | // {{{ DbPlay
 | ||||||
|  | /// Version of `Play` matching the format sqlx expects
 | ||||||
|  | #[derive(Debug, Clone, sqlx::FromRow)] | ||||||
|  | pub struct DbPlay { | ||||||
|  | 	pub id: i64, | ||||||
|  | 	pub chart_id: i64, | ||||||
|  | 	pub user_id: i64, | ||||||
|  | 	pub discord_attachment_id: Option<String>, | ||||||
|  | 	pub score: i64, | ||||||
|  | 	pub zeta_score: i64, | ||||||
|  | 	pub max_recall: Option<i64>, | ||||||
|  | 	pub far_notes: Option<i64>, | ||||||
|  | 	pub created_at: chrono::NaiveDateTime, | ||||||
|  | 	pub creation_ptt: Option<i64>, | ||||||
|  | 	pub creation_zeta_ptt: Option<i64>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl DbPlay { | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn to_play(self) -> Play { | ||||||
|  | 		Play { | ||||||
|  | 			id: self.id as u32, | ||||||
|  | 			chart_id: self.chart_id as u32, | ||||||
|  | 			user_id: self.user_id as u32, | ||||||
|  | 			score: Score(self.score as u32), | ||||||
|  | 			zeta_score: Score(self.zeta_score as u32), | ||||||
|  | 			max_recall: self.max_recall.map(|r| r as u32), | ||||||
|  | 			far_notes: self.far_notes.map(|r| r as u32), | ||||||
|  | 			created_at: self.created_at, | ||||||
|  | 			discord_attachment_id: self | ||||||
|  | 				.discord_attachment_id | ||||||
|  | 				.and_then(|s| AttachmentId::from_str(&s).ok()), | ||||||
|  | 			creation_ptt: self.creation_ptt.map(|r| r as u32), | ||||||
|  | 			creation_zeta_ptt: self.creation_zeta_ptt.map(|r| r as u32), | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | // }}}
 | ||||||
|  | // {{{ Play
 | ||||||
|  | #[derive(Debug, Clone)] | ||||||
|  | pub struct Play { | ||||||
|  | 	pub id: u32, | ||||||
|  | 	pub chart_id: u32, | ||||||
|  | 	pub user_id: u32, | ||||||
|  | 
 | ||||||
|  | 	#[allow(unused)] | ||||||
|  | 	pub discord_attachment_id: Option<AttachmentId>, | ||||||
|  | 
 | ||||||
|  | 	// Actual score data
 | ||||||
|  | 	pub score: Score, | ||||||
|  | 	pub zeta_score: Score, | ||||||
|  | 
 | ||||||
|  | 	// Optional score details
 | ||||||
|  | 	pub max_recall: Option<u32>, | ||||||
|  | 	pub far_notes: Option<u32>, | ||||||
|  | 
 | ||||||
|  | 	// Creation data
 | ||||||
|  | 	pub created_at: chrono::NaiveDateTime, | ||||||
|  | 
 | ||||||
|  | 	#[allow(unused)] | ||||||
|  | 	pub creation_ptt: Option<u32>, | ||||||
|  | 
 | ||||||
|  | 	#[allow(unused)] | ||||||
|  | 	pub creation_zeta_ptt: Option<u32>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl Play { | ||||||
|  | 	// {{{ Play => distribution
 | ||||||
|  | 	pub fn distribution(&self, note_count: u32) -> Option<(u32, u32, u32, u32)> { | ||||||
|  | 		if let Some(fars) = self.far_notes { | ||||||
|  | 			let (_, shinies, units) = self.score.analyse(note_count); | ||||||
|  | 			let (pures, rem) = units.checked_sub(fars)?.div_rem_euclid(&2); | ||||||
|  | 			if rem == 1 { | ||||||
|  | 				println!("The impossible happened: got an invalid amount of far notes!"); | ||||||
|  | 				return None; | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			let lost = note_count.checked_sub(fars + pures)?; | ||||||
|  | 			let non_max_pures = pures.checked_sub(shinies)?; | ||||||
|  | 			Some((shinies, non_max_pures, fars, lost)) | ||||||
|  | 		} else { | ||||||
|  | 			None | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	// }}}
 | ||||||
|  | 	// {{{ Play => status
 | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn status(&self, chart: &Chart) -> Option<String> { | ||||||
|  | 		let score = self.score.0; | ||||||
|  | 		if score >= 10_000_000 { | ||||||
|  | 			if score > chart.note_count + 10_000_000 { | ||||||
|  | 				return None; | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			let non_max_pures = (chart.note_count + 10_000_000).checked_sub(score)?; | ||||||
|  | 			if non_max_pures == 0 { | ||||||
|  | 				Some("MPM".to_string()) | ||||||
|  | 			} else { | ||||||
|  | 				Some(format!("PM (-{})", non_max_pures)) | ||||||
|  | 			} | ||||||
|  | 		} else if let Some(distribution) = self.distribution(chart.note_count) { | ||||||
|  | 			// if no lost notes...
 | ||||||
|  | 			if distribution.3 == 0 { | ||||||
|  | 				Some(format!("FR (-{}/-{})", distribution.1, distribution.2)) | ||||||
|  | 			} else { | ||||||
|  | 				Some(format!( | ||||||
|  | 					"C (-{}/-{}/-{})", | ||||||
|  | 					distribution.1, distribution.2, distribution.3 | ||||||
|  | 				)) | ||||||
|  | 			} | ||||||
|  | 		} else { | ||||||
|  | 			None | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn short_status(&self, chart: &Chart) -> Option<char> { | ||||||
|  | 		let score = self.score.0; | ||||||
|  | 		if score >= 10_000_000 { | ||||||
|  | 			let non_max_pures = (chart.note_count + 10_000_000).checked_sub(score)?; | ||||||
|  | 			if non_max_pures == 0 { | ||||||
|  | 				Some('M') | ||||||
|  | 			} else { | ||||||
|  | 				Some('P') | ||||||
|  | 			} | ||||||
|  | 		} else if let Some(distribution) = self.distribution(chart.note_count) | ||||||
|  | 			&& distribution.3 == 0 | ||||||
|  | 		{ | ||||||
|  | 			Some('F') | ||||||
|  | 		} else { | ||||||
|  | 			Some('C') | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	// }}}
 | ||||||
|  | 	// {{{ Play to embed
 | ||||||
|  | 	/// Creates a discord embed for this play.
 | ||||||
|  | 	///
 | ||||||
|  | 	/// The `index` variable is only used to create distinct filenames.
 | ||||||
|  | 	pub async fn to_embed( | ||||||
|  | 		&self, | ||||||
|  | 		db: &SqlitePool, | ||||||
|  | 		user: &User, | ||||||
|  | 		song: &Song, | ||||||
|  | 		chart: &Chart, | ||||||
|  | 		index: usize, | ||||||
|  | 		author: Option<&poise::serenity_prelude::User>, | ||||||
|  | 	) -> Result<(CreateEmbed, Option<CreateAttachment>), Error> { | ||||||
|  | 		// {{{ Get previously best score
 | ||||||
|  | 		let previously_best = query_as!( | ||||||
|  | 			DbPlay, | ||||||
|  | 			" | ||||||
|  |         SELECT * FROM plays | ||||||
|  |         WHERE user_id=? | ||||||
|  |         AND chart_id=? | ||||||
|  |         AND created_at<? | ||||||
|  |         ORDER BY score DESC | ||||||
|  |     ",
 | ||||||
|  | 			user.id, | ||||||
|  | 			chart.id, | ||||||
|  | 			self.created_at | ||||||
|  | 		) | ||||||
|  | 		.fetch_optional(db) | ||||||
|  | 		.await | ||||||
|  | 		.map_err(|_| { | ||||||
|  | 			format!( | ||||||
|  | 				"Could not find any scores for {} [{:?}]", | ||||||
|  | 				song.title, chart.difficulty | ||||||
|  | 			) | ||||||
|  | 		})? | ||||||
|  | 		.map(|p| p.to_play()); | ||||||
|  | 		// }}}
 | ||||||
|  | 
 | ||||||
|  | 		let attachement_name = format!("{:?}-{:?}-{:?}.png", song.id, self.score.0, index); | ||||||
|  | 		let icon_attachement = match chart.cached_jacket.as_ref() { | ||||||
|  | 			Some(jacket) => Some(CreateAttachment::bytes(jacket.raw, &attachement_name)), | ||||||
|  | 			None => None, | ||||||
|  | 		}; | ||||||
|  | 
 | ||||||
|  | 		let mut embed = CreateEmbed::default() | ||||||
|  | 			.title(format!( | ||||||
|  | 				"{} [{:?} {}]", | ||||||
|  | 				&song.title, chart.difficulty, chart.level | ||||||
|  | 			)) | ||||||
|  | 			.field("Score", format!("{} (+?)", self.score), true) | ||||||
|  | 			.field( | ||||||
|  | 				"Rating", | ||||||
|  | 				format!( | ||||||
|  | 					"{:.2} (+?)", | ||||||
|  | 					self.score.play_rating_f32(chart.chart_constant) | ||||||
|  | 				), | ||||||
|  | 				true, | ||||||
|  | 			) | ||||||
|  | 			.field("Grade", format!("{}", self.score.grade()), true) | ||||||
|  | 			.field("ξ-Score", format!("{} (+?)", self.zeta_score), true) | ||||||
|  | 			// {{{ ξ-Rating
 | ||||||
|  | 			.field( | ||||||
|  | 				"ξ-Rating", | ||||||
|  | 				{ | ||||||
|  | 					let play_rating = self.zeta_score.play_rating_f32(chart.chart_constant); | ||||||
|  | 					if let Some(previous) = previously_best { | ||||||
|  | 						let previous_play_rating = | ||||||
|  | 							previous.zeta_score.play_rating_f32(chart.chart_constant); | ||||||
|  | 
 | ||||||
|  | 						if play_rating >= previous_play_rating { | ||||||
|  | 							format!( | ||||||
|  | 								"{:.2} (+{})", | ||||||
|  | 								play_rating, | ||||||
|  | 								play_rating - previous_play_rating | ||||||
|  | 							) | ||||||
|  | 						} else { | ||||||
|  | 							format!( | ||||||
|  | 								"{:.2} (-{})", | ||||||
|  | 								play_rating, | ||||||
|  | 								play_rating - previous_play_rating | ||||||
|  | 							) | ||||||
|  | 						} | ||||||
|  | 					} else { | ||||||
|  | 						format!("{:.2}", play_rating) | ||||||
|  | 					} | ||||||
|  | 				}, | ||||||
|  | 				true, | ||||||
|  | 			) | ||||||
|  | 			// }}}
 | ||||||
|  | 			.field("ξ-Grade", format!("{}", self.zeta_score.grade()), true) | ||||||
|  | 			.field( | ||||||
|  | 				"Status", | ||||||
|  | 				self.status(chart).unwrap_or("-".to_string()), | ||||||
|  | 				true, | ||||||
|  | 			) | ||||||
|  | 			.field( | ||||||
|  | 				"Max recall", | ||||||
|  | 				if let Some(max_recall) = self.max_recall { | ||||||
|  | 					format!("{}", max_recall) | ||||||
|  | 				} else { | ||||||
|  | 					format!("-") | ||||||
|  | 				}, | ||||||
|  | 				true, | ||||||
|  | 			) | ||||||
|  | 			.field("ID", format!("{}", self.id), true); | ||||||
|  | 
 | ||||||
|  | 		if icon_attachement.is_some() { | ||||||
|  | 			embed = embed.thumbnail(format!("attachment://{}", &attachement_name)); | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if let Some(user) = author { | ||||||
|  | 			let mut embed_author = CreateEmbedAuthor::new(&user.name); | ||||||
|  | 			if let Some(url) = user.avatar_url() { | ||||||
|  | 				embed_author = embed_author.icon_url(url); | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			embed = embed | ||||||
|  | 				.timestamp(Timestamp::from_millis( | ||||||
|  | 					self.created_at.and_utc().timestamp_millis(), | ||||||
|  | 				)?) | ||||||
|  | 				.author(embed_author); | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		Ok((embed, icon_attachement)) | ||||||
|  | 	} | ||||||
|  | 	// }}}
 | ||||||
|  | } | ||||||
|  | // }}}
 | ||||||
							
								
								
									
										348
									
								
								src/arcaea/score.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										348
									
								
								src/arcaea/score.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,348 @@ | ||||||
|  | use std::fmt::Display; | ||||||
|  | 
 | ||||||
|  | use num::Rational64; | ||||||
|  | 
 | ||||||
|  | use crate::context::Error; | ||||||
|  | 
 | ||||||
|  | // {{{ Grade
 | ||||||
|  | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] | ||||||
|  | pub enum Grade { | ||||||
|  | 	EXP, | ||||||
|  | 	EX, | ||||||
|  | 	AA, | ||||||
|  | 	A, | ||||||
|  | 	B, | ||||||
|  | 	C, | ||||||
|  | 	D, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl Grade { | ||||||
|  | 	pub const GRADE_STRINGS: [&'static str; 7] = ["EX+", "EX", "AA", "A", "B", "C", "D"]; | ||||||
|  | 	pub const GRADE_SHORTHANDS: [&'static str; 7] = ["exp", "ex", "aa", "a", "b", "c", "d"]; | ||||||
|  | 
 | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn to_index(self) -> usize { | ||||||
|  | 		self as usize | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl Display for Grade { | ||||||
|  | 	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||||
|  | 		write!(f, "{}", Self::GRADE_STRINGS[self.to_index()]) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | // }}}
 | ||||||
|  | // {{{ Score
 | ||||||
|  | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] | ||||||
|  | pub struct Score(pub u32); | ||||||
|  | 
 | ||||||
|  | impl Score { | ||||||
|  | 	// {{{ Score analysis
 | ||||||
|  | 	// {{{ Mini getters
 | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn to_zeta(self, note_count: u32) -> Score { | ||||||
|  | 		self.analyse(note_count).0 | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn shinies(self, note_count: u32) -> u32 { | ||||||
|  | 		self.analyse(note_count).1 | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn units(self, note_count: u32) -> u32 { | ||||||
|  | 		self.analyse(note_count).2 | ||||||
|  | 	} | ||||||
|  | 	// }}}
 | ||||||
|  | 
 | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn increment(note_count: u32) -> Rational64 { | ||||||
|  | 		Rational64::new_raw(5_000_000, note_count as i64).reduced() | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// Remove the contribution made by shinies to a score.
 | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn forget_shinies(self, note_count: u32) -> Self { | ||||||
|  | 		Self( | ||||||
|  | 			(Self::increment(note_count) * Rational64::from_integer(self.units(note_count) as i64)) | ||||||
|  | 				.floor() | ||||||
|  | 				.to_integer() as u32, | ||||||
|  | 		) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// Compute a score without making a distinction between shinies and pures. That is, the given
 | ||||||
|  | 	/// value for `pures` must refer to the sum of `pure` and `shiny` notes.
 | ||||||
|  | 	///
 | ||||||
|  | 	/// This is the simplest way to compute a score, and is useful for error analysis.
 | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn compute_naive(note_count: u32, pures: u32, fars: u32) -> Self { | ||||||
|  | 		Self( | ||||||
|  | 			(Self::increment(note_count) * Rational64::from_integer((2 * pures + fars) as i64)) | ||||||
|  | 				.floor() | ||||||
|  | 				.to_integer() as u32, | ||||||
|  | 		) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// Returns the zeta score, the number of shinies, and the number of score units.
 | ||||||
|  | 	///
 | ||||||
|  | 	/// Pure (and higher) notes reward two score units, far notes reward one, and lost notes reward
 | ||||||
|  | 	/// none.
 | ||||||
|  | 	pub fn analyse(self, note_count: u32) -> (Score, u32, u32) { | ||||||
|  | 		// Smallest possible difference between (zeta-)scores
 | ||||||
|  | 		let increment = Self::increment(note_count); | ||||||
|  | 		let zeta_increment = Rational64::new_raw(2_000_000, note_count as i64).reduced(); | ||||||
|  | 
 | ||||||
|  | 		let score = Rational64::from_integer(self.0 as i64); | ||||||
|  | 		let score_units = (score / increment).floor(); | ||||||
|  | 
 | ||||||
|  | 		let non_shiny_score = (score_units * increment).floor(); | ||||||
|  | 		let shinies = score - non_shiny_score; | ||||||
|  | 
 | ||||||
|  | 		let zeta_score_units = Rational64::from_integer(2) * score_units + shinies; | ||||||
|  | 		let zeta_score = Score((zeta_increment * zeta_score_units).floor().to_integer() as u32); | ||||||
|  | 
 | ||||||
|  | 		( | ||||||
|  | 			zeta_score, | ||||||
|  | 			shinies.to_integer() as u32, | ||||||
|  | 			score_units.to_integer() as u32, | ||||||
|  | 		) | ||||||
|  | 	} | ||||||
|  | 	// }}}
 | ||||||
|  | 	// {{{ Score => Play rating
 | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn play_rating(self, chart_constant: u32) -> i32 { | ||||||
|  | 		chart_constant as i32 | ||||||
|  | 			+ if self.0 >= 10_000_000 { | ||||||
|  | 				200 | ||||||
|  | 			} else if self.0 >= 9_800_000 { | ||||||
|  | 				100 + (self.0 as i32 - 9_800_000) / 2_000 | ||||||
|  | 			} else { | ||||||
|  | 				(self.0 as i32 - 9_500_000) / 3_000 | ||||||
|  | 			} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn play_rating_f32(self, chart_constant: u32) -> f32 { | ||||||
|  | 		(self.play_rating(chart_constant)) as f32 / 100.0 | ||||||
|  | 	} | ||||||
|  | 	// }}}
 | ||||||
|  | 	// {{{ Score => grade
 | ||||||
|  | 	#[inline] | ||||||
|  | 	// TODO: Perhaps make an enum for this
 | ||||||
|  | 	pub fn grade(self) -> Grade { | ||||||
|  | 		let score = self.0; | ||||||
|  | 		if score > 9900000 { | ||||||
|  | 			Grade::EXP | ||||||
|  | 		} else if score > 9800000 { | ||||||
|  | 			Grade::EX | ||||||
|  | 		} else if score > 9500000 { | ||||||
|  | 			Grade::AA | ||||||
|  | 		} else if score > 9200000 { | ||||||
|  | 			Grade::A | ||||||
|  | 		} else if score > 8900000 { | ||||||
|  | 			Grade::B | ||||||
|  | 		} else if score > 8600000 { | ||||||
|  | 			Grade::C | ||||||
|  | 		} else { | ||||||
|  | 			Grade::D | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	// }}}
 | ||||||
|  | 	// {{{ Scores & Distribution => score
 | ||||||
|  | 	pub fn resolve_ambiguities( | ||||||
|  | 		scores: Vec<Score>, | ||||||
|  | 		read_distribution: Option<(u32, u32, u32)>, | ||||||
|  | 		note_count: u32, | ||||||
|  | 	) -> Result<(Score, Option<u32>, Option<&'static str>), Error> { | ||||||
|  | 		if scores.len() == 0 { | ||||||
|  | 			return Err("No scores in list to disambiguate from.")?; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		let mut no_shiny_scores: Vec<_> = scores | ||||||
|  | 			.iter() | ||||||
|  | 			.map(|score| score.forget_shinies(note_count)) | ||||||
|  | 			.collect(); | ||||||
|  | 		no_shiny_scores.sort(); | ||||||
|  | 		no_shiny_scores.dedup(); | ||||||
|  | 
 | ||||||
|  | 		if let Some(read_distribution) = read_distribution { | ||||||
|  | 			let pures = read_distribution.0; | ||||||
|  | 			let fars = read_distribution.1; | ||||||
|  | 			let losts = read_distribution.2; | ||||||
|  | 
 | ||||||
|  | 			// Compute score from note breakdown subpairs
 | ||||||
|  | 			let pf_score = Score::compute_naive(note_count, pures, fars); | ||||||
|  | 			let fl_score = Score::compute_naive( | ||||||
|  | 				note_count, | ||||||
|  | 				note_count.checked_sub(losts + fars).unwrap_or(0), | ||||||
|  | 				fars, | ||||||
|  | 			); | ||||||
|  | 			let lp_score = Score::compute_naive( | ||||||
|  | 				note_count, | ||||||
|  | 				pures, | ||||||
|  | 				note_count.checked_sub(losts + pures).unwrap_or(0), | ||||||
|  | 			); | ||||||
|  | 
 | ||||||
|  | 			if no_shiny_scores.len() == 1 { | ||||||
|  | 				// {{{ Score is fixed, gotta figure out the exact distribution
 | ||||||
|  | 				let score = *scores.iter().max().unwrap(); | ||||||
|  | 
 | ||||||
|  | 				// {{{ Look for consensus among recomputed scores
 | ||||||
|  | 				// Lemma: if two computed scores agree, then so will the third
 | ||||||
|  | 				let consensus_fars = if pf_score == fl_score { | ||||||
|  | 					Some(fars) | ||||||
|  | 				} else { | ||||||
|  | 					// Due to the above lemma, we know all three scores must be distinct by
 | ||||||
|  | 					// this point.
 | ||||||
|  | 					//
 | ||||||
|  | 					// Our strategy is to check which of the three scores agrees with the real
 | ||||||
|  | 					// score, and to then trust the `far` value that contributed to that pair.
 | ||||||
|  | 					let no_shiny_score = score.forget_shinies(note_count); | ||||||
|  | 					let pf_appears = no_shiny_score == pf_score; | ||||||
|  | 					let fl_appears = no_shiny_score == fl_score; | ||||||
|  | 					let lp_appears = no_shiny_score == lp_score; | ||||||
|  | 
 | ||||||
|  | 					match (pf_appears, fl_appears, lp_appears) { | ||||||
|  | 						(true, false, false) => Some(fars), | ||||||
|  | 						(false, true, false) => Some(fars), | ||||||
|  | 						(false, false, true) => Some(note_count - pures - losts), | ||||||
|  | 						_ => None, | ||||||
|  | 					} | ||||||
|  | 				}; | ||||||
|  | 				// }}}
 | ||||||
|  | 
 | ||||||
|  | 				if scores.len() == 1 { | ||||||
|  | 					Ok((score, consensus_fars, None)) | ||||||
|  | 				} else { | ||||||
|  | 					Ok((score, consensus_fars, Some("Due to a reading error, I could not make sure the shiny-amount I calculated is accurate!"))) | ||||||
|  | 				} | ||||||
|  | 
 | ||||||
|  | 			// }}}
 | ||||||
|  | 			} else { | ||||||
|  | 				// {{{ Score is not fixed, gotta figure out everything at once
 | ||||||
|  | 				// Some of the values in the note distribution are likely wrong (due to reading
 | ||||||
|  | 				// errors). To get around this, we take each pair from the triplet, compute the score
 | ||||||
|  | 				// it induces, and figure out if there's any consensus as to which value in the
 | ||||||
|  | 				// provided score list is the real one.
 | ||||||
|  | 				//
 | ||||||
|  | 				// Note that sometimes the note distribution cannot resolve any of the issues. This is
 | ||||||
|  | 				// usually the case when the disagreement comes from the number of shinies.
 | ||||||
|  | 
 | ||||||
|  | 				// {{{ Look for consensus among recomputed scores
 | ||||||
|  | 				// Lemma: if two computed scores agree, then so will the third
 | ||||||
|  | 				let (trusted_pure_count, consensus_computed_score, consensus_fars) = if pf_score | ||||||
|  | 					== fl_score | ||||||
|  | 				{ | ||||||
|  | 					(true, pf_score, fars) | ||||||
|  | 				} else { | ||||||
|  | 					// Due to the above lemma, we know all three scores must be distinct by
 | ||||||
|  | 					// this point.
 | ||||||
|  | 					//
 | ||||||
|  | 					// Our strategy is to check which of the three scores appear in the
 | ||||||
|  | 					// provided score list.
 | ||||||
|  | 					let pf_appears = no_shiny_scores.contains(&pf_score); | ||||||
|  | 					let fl_appears = no_shiny_scores.contains(&fl_score); | ||||||
|  | 					let lp_appears = no_shiny_scores.contains(&lp_score); | ||||||
|  | 
 | ||||||
|  | 					match (pf_appears, fl_appears, lp_appears) { | ||||||
|  |                         (true, false, false) => (true, pf_score, fars), | ||||||
|  |                         (false, true, false) => (false, fl_score, fars), | ||||||
|  |                         (false, false, true) => (true, lp_score, note_count - pures - losts), | ||||||
|  |                         _ => Err(format!("Cannot disambiguate scores {:?}. Multiple disjoint note breakdown subpair scores appear on the possibility list", scores))? | ||||||
|  |                     } | ||||||
|  | 				}; | ||||||
|  | 				// }}}
 | ||||||
|  | 				// {{{ Collect all scores that agree with the consensus score.
 | ||||||
|  | 				let agreement: Vec<_> = scores | ||||||
|  | 					.iter() | ||||||
|  | 					.filter(|score| score.forget_shinies(note_count) == consensus_computed_score) | ||||||
|  | 					.filter(|score| { | ||||||
|  | 						let shinies = score.shinies(note_count); | ||||||
|  | 						shinies <= note_count && (!trusted_pure_count || shinies <= pures) | ||||||
|  | 					}) | ||||||
|  | 					.map(|v| *v) | ||||||
|  | 					.collect(); | ||||||
|  | 				// }}}
 | ||||||
|  | 				// {{{ Case 1: Disagreement in the amount of shinies!
 | ||||||
|  | 				if agreement.len() > 1 { | ||||||
|  | 					let agreement_shiny_amounts: Vec<_> = | ||||||
|  | 						agreement.iter().map(|v| v.shinies(note_count)).collect(); | ||||||
|  | 
 | ||||||
|  | 					println!( | ||||||
|  | 						"Shiny count disagreement. Possible scores: {:?}. Possible shiny amounts: {:?}, Read distribution: {:?}", | ||||||
|  | 						scores, agreement_shiny_amounts, read_distribution | ||||||
|  | 					); | ||||||
|  | 
 | ||||||
|  | 					let msg = Some( | ||||||
|  |                             "Due to a reading error, I could not make sure the shiny-amount I calculated is accurate!" | ||||||
|  |                             ); | ||||||
|  | 
 | ||||||
|  | 					Ok(( | ||||||
|  | 						agreement.into_iter().max().unwrap(), | ||||||
|  | 						Some(consensus_fars), | ||||||
|  | 						msg, | ||||||
|  | 					)) | ||||||
|  | 				// }}}
 | ||||||
|  | 				// {{{ Case 2: Total agreement!
 | ||||||
|  | 				} else if agreement.len() == 1 { | ||||||
|  | 					Ok((agreement[0], Some(consensus_fars), None)) | ||||||
|  | 				// }}}
 | ||||||
|  | 				// {{{ Case 3: No agreement!
 | ||||||
|  | 				} else { | ||||||
|  | 					Err(format!("Could not disambiguate between possible scores {:?}. Note distribution does not agree with any possibility, leading to a score of {:?}.", scores, consensus_computed_score))? | ||||||
|  | 				} | ||||||
|  | 				// }}}
 | ||||||
|  | 				// }}}
 | ||||||
|  | 			} | ||||||
|  | 		} else { | ||||||
|  | 			if no_shiny_scores.len() == 1 { | ||||||
|  | 				if scores.len() == 1 { | ||||||
|  | 					Ok((scores[0], None, None)) | ||||||
|  | 				} else { | ||||||
|  | 					Ok((scores.into_iter().max().unwrap(), None, Some("Due to a reading error, I could not make sure the shiny-amount I calculated is accurate!"))) | ||||||
|  | 				} | ||||||
|  | 			} else { | ||||||
|  | 				Err("Cannot disambiguate between more than one score without a note distribution.")? | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	// }}}
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl Display for Score { | ||||||
|  | 	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||||
|  | 		let score = self.0; | ||||||
|  | 		write!( | ||||||
|  | 			f, | ||||||
|  | 			"{}'{:0>3}'{:0>3}", | ||||||
|  | 			score / 1000000, | ||||||
|  | 			(score / 1000) % 1000, | ||||||
|  | 			score % 1000 | ||||||
|  | 		) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | // }}}
 | ||||||
|  | // {{{ Tests
 | ||||||
|  | #[cfg(test)] | ||||||
|  | mod score_tests { | ||||||
|  | 	use super::*; | ||||||
|  | 
 | ||||||
|  | 	#[test] | ||||||
|  | 	fn zeta_score_consistent_with_pms() { | ||||||
|  | 		// note counts
 | ||||||
|  | 		for note_count in 200..=2000 { | ||||||
|  | 			for shiny_count in 0..=note_count { | ||||||
|  | 				let score = Score(10000000 + shiny_count); | ||||||
|  | 				let zeta_score_units = 4 * (note_count - shiny_count) + 5 * shiny_count; | ||||||
|  | 				let (zeta_score, computed_shiny_count, units) = score.analyse(note_count); | ||||||
|  | 				let expected_zeta_score = Rational64::from_integer(zeta_score_units as i64) | ||||||
|  | 					* Rational64::new_raw(2000000, note_count as i64).reduced(); | ||||||
|  | 
 | ||||||
|  | 				assert_eq!(zeta_score, Score(expected_zeta_score.to_integer() as u32)); | ||||||
|  | 				assert_eq!(computed_shiny_count, shiny_count); | ||||||
|  | 				assert_eq!(units, 2 * note_count); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | // }}}
 | ||||||
|  | @ -4,7 +4,7 @@ use std::{cell::RefCell, env::var, path::PathBuf, str::FromStr, sync::OnceLock}; | ||||||
| use freetype::{Face, Library}; | use freetype::{Face, Library}; | ||||||
| use image::{imageops::FilterType, ImageBuffer, Rgb, Rgba}; | use image::{imageops::FilterType, ImageBuffer, Rgb, Rgba}; | ||||||
| 
 | 
 | ||||||
| use crate::chart::Difficulty; | use crate::arcaea::chart::Difficulty; | ||||||
| 
 | 
 | ||||||
| #[inline] | #[inline] | ||||||
| pub fn get_data_dir() -> PathBuf { | pub fn get_data_dir() -> PathBuf { | ||||||
|  |  | ||||||
|  | @ -34,7 +34,7 @@ impl Color { | ||||||
| 
 | 
 | ||||||
| 	#[inline] | 	#[inline] | ||||||
| 	pub const fn from_bytes(bytes: [u8; 4]) -> Self { | 	pub const fn from_bytes(bytes: [u8; 4]) -> Self { | ||||||
| 		Self(bytes[0], bytes[1], bytes[1], bytes[3]) | 		Self(bytes[0], bytes[1], bytes[2], bytes[3]) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	#[inline] | 	#[inline] | ||||||
|  |  | ||||||
|  | @ -2,9 +2,9 @@ use poise::serenity_prelude::{CreateAttachment, CreateEmbed, CreateMessage}; | ||||||
| use sqlx::query; | use sqlx::query; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
| 	chart::Side, | 	arcaea::chart::Side, | ||||||
| 	context::{Context, Error}, | 	context::{Context, Error}, | ||||||
| 	score::guess_song_and_chart, | 	recognition::fuzzy_song_name::guess_song_and_chart, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| // {{{ Chart
 | // {{{ Chart
 | ||||||
|  |  | ||||||
|  | @ -3,6 +3,7 @@ use crate::context::{Context, Error}; | ||||||
| pub mod chart; | pub mod chart; | ||||||
| pub mod score; | pub mod score; | ||||||
| pub mod stats; | pub mod stats; | ||||||
|  | mod utils; | ||||||
| 
 | 
 | ||||||
| // {{{ Help
 | // {{{ Help
 | ||||||
| /// Show this help menu
 | /// Show this help menu
 | ||||||
|  |  | ||||||
|  | @ -1,10 +1,10 @@ | ||||||
| use std::fmt::Display; | use crate::arcaea::play::{CreatePlay, Play}; | ||||||
| 
 | use crate::arcaea::score::Score; | ||||||
| use crate::context::{Context, Error}; | use crate::context::{Context, Error}; | ||||||
| use crate::score::{CreatePlay, ImageCropper, Play, Score, ScoreKind}; | use crate::recognition::recognize::{ImageAnalyzer, ScoreKind}; | ||||||
| use crate::user::{discord_it_to_discord_user, User}; | use crate::user::{discord_it_to_discord_user, User}; | ||||||
| use image::imageops::FilterType; | use crate::{edit_reply, get_user}; | ||||||
| use poise::serenity_prelude::{CreateAttachment, CreateEmbed, CreateMessage}; | use poise::serenity_prelude::CreateMessage; | ||||||
| use poise::{serenity_prelude as serenity, CreateReply}; | use poise::{serenity_prelude as serenity, CreateReply}; | ||||||
| use sqlx::query; | use sqlx::query; | ||||||
| 
 | 
 | ||||||
|  | @ -21,46 +21,13 @@ pub async fn score(_ctx: Context<'_>) -> Result<(), Error> { | ||||||
| } | } | ||||||
| // }}}
 | // }}}
 | ||||||
| // {{{ Score magic
 | // {{{ Score magic
 | ||||||
| // {{{ Send error embed with image
 |  | ||||||
| async fn error_with_image( |  | ||||||
| 	ctx: Context<'_>, |  | ||||||
| 	bytes: &[u8], |  | ||||||
| 	filename: &str, |  | ||||||
| 	message: &str, |  | ||||||
| 	err: impl Display, |  | ||||||
| ) -> Result<(), Error> { |  | ||||||
| 	let error_attachement = CreateAttachment::bytes(bytes, filename); |  | ||||||
| 	let msg = CreateMessage::default().embed( |  | ||||||
| 		CreateEmbed::default() |  | ||||||
| 			.title(message) |  | ||||||
| 			.attachment(filename) |  | ||||||
| 			.description(format!("{}", err)), |  | ||||||
| 	); |  | ||||||
| 
 |  | ||||||
| 	ctx.channel_id() |  | ||||||
| 		.send_files(ctx.http(), [error_attachement], msg) |  | ||||||
| 		.await?; |  | ||||||
| 
 |  | ||||||
| 	Ok(()) |  | ||||||
| } |  | ||||||
| // }}}
 |  | ||||||
| 
 |  | ||||||
| /// Identify scores from attached images.
 | /// Identify scores from attached images.
 | ||||||
| #[poise::command(prefix_command, slash_command)] | #[poise::command(prefix_command, slash_command)] | ||||||
| pub async fn magic( | pub async fn magic( | ||||||
| 	ctx: Context<'_>, | 	ctx: Context<'_>, | ||||||
| 	#[description = "Images containing scores"] files: Vec<serenity::Attachment>, | 	#[description = "Images containing scores"] files: Vec<serenity::Attachment>, | ||||||
| ) -> Result<(), Error> { | ) -> Result<(), Error> { | ||||||
| 	let user = match User::from_context(&ctx).await { | 	let user = get_user!(&ctx); | ||||||
| 		Ok(user) => user, |  | ||||||
| 		Err(_) => { |  | ||||||
| 			ctx.say("You are not an user in my database, sorry!") |  | ||||||
| 				.await?; |  | ||||||
| 			return Ok(()); |  | ||||||
| 		} |  | ||||||
| 	}; |  | ||||||
| 
 |  | ||||||
| 	println!("Handling command from user {:?}", user.discord_id); |  | ||||||
| 
 | 
 | ||||||
| 	if files.len() == 0 { | 	if files.len() == 0 { | ||||||
| 		ctx.reply("No images found attached to message").await?; | 		ctx.reply("No images found attached to message").await?; | ||||||
|  | @ -71,246 +38,103 @@ pub async fn magic( | ||||||
| 			.reply(format!("Processed 0/{} scores", files.len())) | 			.reply(format!("Processed 0/{} scores", files.len())) | ||||||
| 			.await?; | 			.await?; | ||||||
| 
 | 
 | ||||||
|  | 		let mut analyzer = ImageAnalyzer::default(); | ||||||
|  | 
 | ||||||
| 		for (i, file) in files.iter().enumerate() { | 		for (i, file) in files.iter().enumerate() { | ||||||
| 			if let Some(_) = file.dimensions() { | 			if let Some(_) = file.dimensions() { | ||||||
| 				// {{{ Image pre-processing
 |  | ||||||
| 				let bytes = file.download().await?; | 				let bytes = file.download().await?; | ||||||
| 				let mut image = image::load_from_memory(&bytes)?; | 				let mut image = image::load_from_memory(&bytes)?; | ||||||
| 				// image = image.resize(1024, 1024, FilterType::Nearest);
 | 				// image = image.resize(1024, 1024, FilterType::Nearest);
 | ||||||
| 				// }}}
 |  | ||||||
| 				// {{{ Detection
 |  | ||||||
| 				// Create cropper and run OCR
 |  | ||||||
| 				let mut cropper = ImageCropper::default(); |  | ||||||
| 
 | 
 | ||||||
| 				let edited = CreateReply::default() | 				let result: Result<(), Error> = try { | ||||||
| 					.reply(true) | 					// {{{ Detection
 | ||||||
| 					.content(format!("Image {}: reading jacket", i + 1)); | 					// This makes OCR more likely to work
 | ||||||
| 				handle.edit(ctx, edited).await?; | 					let mut ocr_image = image.grayscale().blur(1.); | ||||||
| 
 | 
 | ||||||
| 				// This makes OCR more likely to work
 | 					edit_reply!(ctx, handle, "Image {}: reading kind", i + 1).await?; | ||||||
| 				let mut ocr_image = image.grayscale().blur(1.); | 					let kind = analyzer.read_score_kind(ctx.data(), &ocr_image)?; | ||||||
| 
 | 
 | ||||||
| 				// {{{ Kind
 | 					edit_reply!(ctx, handle, "Image {}: reading difficulty", i + 1).await?; | ||||||
| 				let edited = CreateReply::default() | 					// Do not use `ocr_image` because this reads the colors
 | ||||||
| 					.reply(true) | 					let difficulty = analyzer.read_difficulty(ctx.data(), &image, kind)?; | ||||||
| 					.content(format!("Image {}: reading kind", i + 1)); |  | ||||||
| 				handle.edit(ctx, edited).await?; |  | ||||||
| 
 | 
 | ||||||
| 				let kind = match cropper.read_score_kind(ctx.data(), &ocr_image) { | 					edit_reply!(ctx, handle, "Image {}: reading jacket", i + 1).await?; | ||||||
| 					// {{{ OCR error handling
 | 					let (song, chart) = analyzer | ||||||
| 					Err(err) => { | 						.read_jacket(ctx.data(), &mut image, kind, difficulty) | ||||||
| 						error_with_image( |  | ||||||
| 							ctx, |  | ||||||
| 							&cropper.bytes, |  | ||||||
| 							&file.filename, |  | ||||||
| 							"Could not read kind from picture", |  | ||||||
| 							&err, |  | ||||||
| 						) |  | ||||||
| 						.await?; | 						.await?; | ||||||
| 
 | 
 | ||||||
| 						continue; | 					ocr_image.invert(); | ||||||
| 					} |  | ||||||
| 					// }}}
 |  | ||||||
| 					Ok(k) => k, |  | ||||||
| 				}; |  | ||||||
| 				// }}}
 |  | ||||||
| 				// {{{ Difficulty
 |  | ||||||
| 				let edited = CreateReply::default() |  | ||||||
| 					.reply(true) |  | ||||||
| 					.content(format!("Image {}: reading difficulty", i + 1)); |  | ||||||
| 				handle.edit(ctx, edited).await?; |  | ||||||
| 
 | 
 | ||||||
| 				// Do not use `ocr_image` because this reads the colors
 | 					let (note_distribution, max_recall) = match kind { | ||||||
| 				let difficulty = match cropper.read_difficulty(ctx.data(), &image, kind) { | 						ScoreKind::ScoreScreen => { | ||||||
| 					// {{{ OCR error handling
 | 							edit_reply!(ctx, handle, "Image {}: reading distribution", i + 1) | ||||||
| 					Err(err) => { | 								.await?; | ||||||
| 						error_with_image( | 							let note_distribution = | ||||||
| 							ctx, | 								Some(analyzer.read_distribution(ctx.data(), &image)?); | ||||||
| 							&cropper.bytes, |  | ||||||
| 							&file.filename, |  | ||||||
| 							"Could not read difficulty from picture", |  | ||||||
| 							&err, |  | ||||||
| 						) |  | ||||||
| 						.await?; |  | ||||||
| 
 | 
 | ||||||
| 						continue; | 							edit_reply!(ctx, handle, "Image {}: reading max recall", i + 1).await?; | ||||||
| 					} | 							let max_recall = Some(analyzer.read_max_recall(ctx.data(), &image)?); | ||||||
| 					// }}}
 |  | ||||||
| 					Ok(d) => d, |  | ||||||
| 				}; |  | ||||||
| 
 | 
 | ||||||
| 				println!("{difficulty:?}"); | 							(note_distribution, max_recall) | ||||||
| 				// }}}
 |  | ||||||
| 				// {{{ Jacket & distribution
 |  | ||||||
| 				let mut jacket_rect = None; |  | ||||||
| 				let song_by_jacket = cropper |  | ||||||
| 					.read_jacket(ctx.data(), &mut image, kind, difficulty, &mut jacket_rect) |  | ||||||
| 					.await; |  | ||||||
| 				// image.invert();
 |  | ||||||
| 				ocr_image.invert(); |  | ||||||
| 				let note_distribution = match kind { |  | ||||||
| 					ScoreKind::ScoreScreen => Some(cropper.read_distribution(ctx.data(), &image)?), |  | ||||||
| 					ScoreKind::SongSelect => None, |  | ||||||
| 				}; |  | ||||||
| 				// }}}
 |  | ||||||
| 				// {{{ Title
 |  | ||||||
| 				let edited = CreateReply::default() |  | ||||||
| 					.reply(true) |  | ||||||
| 					.content(format!("Image {}: reading title", i + 1)); |  | ||||||
| 				handle.edit(ctx, edited).await?; |  | ||||||
| 
 |  | ||||||
| 				let song_by_name = match kind { |  | ||||||
| 					ScoreKind::SongSelect => None, |  | ||||||
| 					ScoreKind::ScoreScreen => { |  | ||||||
| 						Some(cropper.read_song(ctx.data(), &ocr_image, difficulty)) |  | ||||||
| 					} |  | ||||||
| 				}; |  | ||||||
| 
 |  | ||||||
| 				let (song, chart) = match (song_by_jacket, song_by_name) { |  | ||||||
| 					// {{{ Only name succeeded
 |  | ||||||
| 					(Err(err_jacket), Some(Ok(by_name))) => { |  | ||||||
| 						println!("Could not recognise jacket with error: {}", err_jacket); |  | ||||||
| 						by_name |  | ||||||
| 					} |  | ||||||
| 					// }}}
 |  | ||||||
| 					// {{{ Both succeeded
 |  | ||||||
| 					(Ok(by_jacket), Some(Ok(by_name))) => { |  | ||||||
| 						if by_name.0.id != by_jacket.0.id { |  | ||||||
| 							println!( |  | ||||||
| 								"Got diverging choices between '{}' and '{}'", |  | ||||||
| 								by_jacket.0.title, by_name.0.title |  | ||||||
| 							); |  | ||||||
| 						}; |  | ||||||
| 
 |  | ||||||
| 						by_jacket |  | ||||||
| 					} // }}}
 |  | ||||||
| 					// {{{ Only jacket succeeded
 |  | ||||||
| 					(Ok(by_jacket), err_name) => { |  | ||||||
| 						if let Some(err) = err_name { |  | ||||||
| 							println!("Could not read name with error: {:?}", err.unwrap_err()); |  | ||||||
| 						} | 						} | ||||||
|  | 						ScoreKind::SongSelect => (None, None), | ||||||
|  | 					}; | ||||||
| 
 | 
 | ||||||
| 						by_jacket | 					edit_reply!(ctx, handle, "Image {}: reading score", i + 1).await?; | ||||||
| 					} | 					let score_possibilities = analyzer.read_score( | ||||||
| 					// }}}
 | 						ctx.data(), | ||||||
| 					// {{{ Both errors
 | 						Some(chart.note_count), | ||||||
| 					(Err(err_jacket), err_name) => { | 						&ocr_image, | ||||||
| 						if let Some(rect) = jacket_rect { | 						kind, | ||||||
| 							cropper.crop_image_to_bytes(&image, rect)?; | 					)?; | ||||||
| 							error_with_image( |  | ||||||
| 							ctx, |  | ||||||
| 							&cropper.bytes, |  | ||||||
| 							&file.filename, |  | ||||||
| 							"Hey! I could not read the score in the provided picture.", |  | ||||||
| 							&format!( |  | ||||||
|                                 "This can mean one of three things:
 |  | ||||||
| 1. The image you provided is *not that of an Arcaea score |  | ||||||
| 2. The image you provided contains a newly added chart that is not in my database yet |  | ||||||
| 3. The image you provided contains character art that covers the chart name. When this happens, I try to make use of the jacket art in order to determine the chart. Contact `@prescientmoon` on discord to try and resolve the issue! |  | ||||||
| 
 | 
 | ||||||
| Nerdy info: | 					// {{{ Build play
 | ||||||
| ``` | 					let (score, maybe_fars, score_warning) = Score::resolve_ambiguities( | ||||||
| Jacket error: {} | 						score_possibilities, | ||||||
| Title error: {:?} | 						note_distribution, | ||||||
| ```" ,
 | 						chart.note_count, | ||||||
| 								err_jacket, err_name |  | ||||||
| 							), |  | ||||||
| 						) |  | ||||||
| 						.await?; |  | ||||||
| 						} else { |  | ||||||
| 							ctx.reply(format!( |  | ||||||
| 								"This is a weird error that should never happen...
 |  | ||||||
| Nerdy info: |  | ||||||
| ``` |  | ||||||
| Jacket error: {} |  | ||||||
| Title error: {:?} |  | ||||||
| ```",
 |  | ||||||
| 								err_jacket, err_name |  | ||||||
| 							)) |  | ||||||
| 							.await?; |  | ||||||
| 						} |  | ||||||
| 						continue; |  | ||||||
| 					} // }}}
 |  | ||||||
| 				}; |  | ||||||
| 
 |  | ||||||
| 				println!("{}", song.title); |  | ||||||
| 				// }}}
 |  | ||||||
| 				// {{{ Score
 |  | ||||||
| 				let edited = CreateReply::default() |  | ||||||
| 					.reply(true) |  | ||||||
| 					.content(format!("Image {}: reading score", i + 1)); |  | ||||||
| 				handle.edit(ctx, edited).await?; |  | ||||||
| 
 |  | ||||||
| 				let score_possibilities = match cropper.read_score( |  | ||||||
| 					ctx.data(), |  | ||||||
| 					Some(chart.note_count), |  | ||||||
| 					&ocr_image, |  | ||||||
| 					kind, |  | ||||||
| 				) { |  | ||||||
| 					// {{{ OCR error handling
 |  | ||||||
| 					Err(err) => { |  | ||||||
| 						error_with_image( |  | ||||||
| 							ctx, |  | ||||||
| 							&cropper.bytes, |  | ||||||
| 							&file.filename, |  | ||||||
| 							"Could not read score from picture", |  | ||||||
| 							&err, |  | ||||||
| 						) |  | ||||||
| 						.await?; |  | ||||||
| 
 |  | ||||||
| 						continue; |  | ||||||
| 					} |  | ||||||
| 					// }}}
 |  | ||||||
| 					Ok(scores) => scores, |  | ||||||
| 				}; |  | ||||||
| 				// }}}
 |  | ||||||
| 				// {{{ Build play
 |  | ||||||
| 				let (score, maybe_fars, score_warning) = Score::resolve_ambiguities( |  | ||||||
| 					score_possibilities, |  | ||||||
| 					note_distribution, |  | ||||||
| 					chart.note_count, |  | ||||||
| 				) |  | ||||||
| 				.map_err(|err| { |  | ||||||
| 					format!( |  | ||||||
| 						"Error occurred when disambiguating scores for '{}' [{:?}] by {}: {}", |  | ||||||
| 						song.title, difficulty, song.artist, err |  | ||||||
| 					) | 					) | ||||||
| 				})?; | 					.map_err(|err| { | ||||||
| 				println!( | 						format!( | ||||||
| 					"Maybe fars {:?}, distribution {:?}", | 							"Error occurred when disambiguating scores for '{}' [{:?}] by {}: {}", | ||||||
| 					maybe_fars, note_distribution | 							song.title, difficulty, song.artist, err | ||||||
| 				); | 						) | ||||||
| 				let play = CreatePlay::new(score, &chart, &user) | 					})?; | ||||||
| 					.with_attachment(file) |  | ||||||
| 					.with_fars(maybe_fars) |  | ||||||
| 					.save(&ctx.data()) |  | ||||||
| 					.await?; |  | ||||||
| 				// }}}
 |  | ||||||
| 				// }}}
 |  | ||||||
| 				// {{{ Deliver embed
 |  | ||||||
| 				let (mut embed, attachment) = play |  | ||||||
| 					.to_embed(&ctx.data().db, &user, &song, &chart, i, None) |  | ||||||
| 					.await?; |  | ||||||
| 				if let Some(warning) = score_warning { |  | ||||||
| 					embed = embed.description(warning); |  | ||||||
| 				} |  | ||||||
| 
 | 
 | ||||||
| 				embeds.push(embed); | 					let play = CreatePlay::new(score, &chart, &user) | ||||||
| 				attachments.extend(attachment); | 						.with_attachment(file) | ||||||
| 			// }}}
 | 						.with_fars(maybe_fars) | ||||||
|  | 						.with_max_recall(max_recall) | ||||||
|  | 						.save(&ctx.data()) | ||||||
|  | 						.await?; | ||||||
|  | 					// }}}
 | ||||||
|  | 					// }}}
 | ||||||
|  | 					// {{{ Deliver embed
 | ||||||
|  | 					let (mut embed, attachment) = play | ||||||
|  | 						.to_embed(&ctx.data().db, &user, &song, &chart, i, None) | ||||||
|  | 						.await?; | ||||||
|  | 
 | ||||||
|  | 					if let Some(warning) = score_warning { | ||||||
|  | 						embed = embed.description(warning); | ||||||
|  | 					} | ||||||
|  | 
 | ||||||
|  | 					embeds.push(embed); | ||||||
|  | 					attachments.extend(attachment); | ||||||
|  | 					// }}}
 | ||||||
|  | 				}; | ||||||
|  | 
 | ||||||
|  | 				if let Err(err) = result { | ||||||
|  | 					analyzer | ||||||
|  | 						.send_discord_error(ctx, &image, &file.filename, err) | ||||||
|  | 						.await?; | ||||||
|  | 				} | ||||||
| 			} else { | 			} else { | ||||||
| 				ctx.reply("One of the attached files is not an image!") | 				ctx.reply("One of the attached files is not an image!") | ||||||
| 					.await?; | 					.await?; | ||||||
| 				continue; | 				continue; | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			let edited = CreateReply::default().reply(true).content(format!( | 			edit_reply!(ctx, handle, "Processed {}/{} scores", i + 1, files.len()).await?; | ||||||
| 				"Processed {}/{} scores", |  | ||||||
| 				i + 1, |  | ||||||
| 				files.len() |  | ||||||
| 			)); |  | ||||||
| 
 |  | ||||||
| 			handle.edit(ctx, edited).await?; |  | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		handle.delete(ctx).await?; | 		handle.delete(ctx).await?; | ||||||
|  | @ -330,14 +154,7 @@ pub async fn delete( | ||||||
| 	ctx: Context<'_>, | 	ctx: Context<'_>, | ||||||
| 	#[description = "Id of score to delete"] ids: Vec<u32>, | 	#[description = "Id of score to delete"] ids: Vec<u32>, | ||||||
| ) -> Result<(), Error> { | ) -> Result<(), Error> { | ||||||
| 	let user = match User::from_context(&ctx).await { | 	let user = get_user!(&ctx); | ||||||
| 		Ok(user) => user, |  | ||||||
| 		Err(_) => { |  | ||||||
| 			ctx.say("You are not an user in my database, sorry!") |  | ||||||
| 				.await?; |  | ||||||
| 			return Ok(()); |  | ||||||
| 		} |  | ||||||
| 	}; |  | ||||||
| 
 | 
 | ||||||
| 	if ids.len() == 0 { | 	if ids.len() == 0 { | ||||||
| 		ctx.reply("Empty ID list provided").await?; | 		ctx.reply("Empty ID list provided").await?; | ||||||
|  | @ -383,14 +200,14 @@ pub async fn show( | ||||||
| 	for (i, id) in ids.iter().enumerate() { | 	for (i, id) in ids.iter().enumerate() { | ||||||
| 		let res = query!( | 		let res = query!( | ||||||
| 			" | 			" | ||||||
|                 SELECT 
 |         SELECT 
 | ||||||
|                     p.id,p.chart_id,p.user_id,p.score,p.zeta_score, |           p.id,p.chart_id,p.user_id,p.score,p.zeta_score, | ||||||
|                     p.max_recall,p.created_at,p.far_notes, |           p.max_recall,p.created_at,p.far_notes, | ||||||
|                     u.discord_id |           u.discord_id | ||||||
|                 FROM plays p 
 |         FROM plays p 
 | ||||||
|                 JOIN users u ON p.user_id = u.id |         JOIN users u ON p.user_id = u.id | ||||||
|                 WHERE p.id=? |         WHERE p.id=? | ||||||
|             ",
 |       ",
 | ||||||
| 			id | 			id | ||||||
| 		) | 		) | ||||||
| 		.fetch_one(&ctx.data().db) | 		.fetch_one(&ctx.data().db) | ||||||
|  |  | ||||||
|  | @ -17,17 +17,20 @@ use poise::{ | ||||||
| use sqlx::query_as; | use sqlx::query_as; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|  | 	arcaea::chart::{Chart, Song}, | ||||||
|  | 	arcaea::jacket::BITMAP_IMAGE_SIZE, | ||||||
|  | 	arcaea::play::{DbPlay, Play}, | ||||||
|  | 	arcaea::score::Score, | ||||||
| 	assets::{ | 	assets::{ | ||||||
| 		get_b30_background, get_count_background, get_difficulty_background, get_grade_background, | 		get_b30_background, get_count_background, get_difficulty_background, get_grade_background, | ||||||
| 		get_name_backgound, get_ptt_emblem, get_score_background, get_status_background, | 		get_name_backgound, get_ptt_emblem, get_score_background, get_status_background, | ||||||
| 		get_top_backgound, EXO_FONT, | 		get_top_backgound, EXO_FONT, | ||||||
| 	}, | 	}, | ||||||
| 	bitmap::{Align, BitmapCanvas, Color, LayoutDrawer, LayoutManager, Rect}, | 	bitmap::{Align, BitmapCanvas, Color, LayoutDrawer, LayoutManager, Rect}, | ||||||
| 	chart::{Chart, Song}, |  | ||||||
| 	context::{Context, Error}, | 	context::{Context, Error}, | ||||||
| 	jacket::BITMAP_IMAGE_SIZE, | 	get_user, | ||||||
| 	score::{guess_song_and_chart, DbPlay, Play, Score}, | 	recognition::fuzzy_song_name::guess_song_and_chart, | ||||||
| 	user::{discord_it_to_discord_user, User}, | 	user::discord_it_to_discord_user, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| // {{{ Stats
 | // {{{ Stats
 | ||||||
|  | @ -63,14 +66,7 @@ pub async fn best( | ||||||
| 	#[description = "Name of chart to show (difficulty at the end)"] | 	#[description = "Name of chart to show (difficulty at the end)"] | ||||||
| 	name: String, | 	name: String, | ||||||
| ) -> Result<(), Error> { | ) -> Result<(), Error> { | ||||||
| 	let user = match User::from_context(&ctx).await { | 	let user = get_user!(&ctx); | ||||||
| 		Ok(user) => user, |  | ||||||
| 		Err(_) => { |  | ||||||
| 			ctx.say("You are not an user in my database, sorry!") |  | ||||||
| 				.await?; |  | ||||||
| 			return Ok(()); |  | ||||||
| 		} |  | ||||||
| 	}; |  | ||||||
| 
 | 
 | ||||||
| 	let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?; | 	let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?; | ||||||
| 	let play = query_as!( | 	let play = query_as!( | ||||||
|  | @ -121,14 +117,7 @@ pub async fn plot( | ||||||
| 	#[description = "Name of chart to show (difficulty at the end)"] | 	#[description = "Name of chart to show (difficulty at the end)"] | ||||||
| 	name: String, | 	name: String, | ||||||
| ) -> Result<(), Error> { | ) -> Result<(), Error> { | ||||||
| 	let user = match User::from_context(&ctx).await { | 	let user = get_user!(&ctx); | ||||||
| 		Ok(user) => user, |  | ||||||
| 		Err(_) => { |  | ||||||
| 			ctx.say("You are not an user in my database, sorry!") |  | ||||||
| 				.await?; |  | ||||||
| 			return Ok(()); |  | ||||||
| 		} |  | ||||||
| 	}; |  | ||||||
| 
 | 
 | ||||||
| 	let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?; | 	let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?; | ||||||
| 
 | 
 | ||||||
|  | @ -240,14 +229,7 @@ pub async fn plot( | ||||||
| /// Show the 30 best scores
 | /// Show the 30 best scores
 | ||||||
| #[poise::command(prefix_command, slash_command)] | #[poise::command(prefix_command, slash_command)] | ||||||
| pub async fn b30(ctx: Context<'_>) -> Result<(), Error> { | pub async fn b30(ctx: Context<'_>) -> Result<(), Error> { | ||||||
| 	let user = match User::from_context(&ctx).await { | 	let user = get_user!(&ctx); | ||||||
| 		Ok(user) => user, |  | ||||||
| 		Err(_) => { |  | ||||||
| 			ctx.say("You are not an user in my database, sorry!") |  | ||||||
| 				.await?; |  | ||||||
| 			return Ok(()); |  | ||||||
| 		} |  | ||||||
| 	}; |  | ||||||
| 
 | 
 | ||||||
| 	let plays: Vec<DbPlay> = query_as( | 	let plays: Vec<DbPlay> = query_as( | ||||||
| 		" | 		" | ||||||
|  |  | ||||||
							
								
								
									
										24
									
								
								src/commands/utils.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/commands/utils.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,24 @@ | ||||||
|  | #[macro_export] | ||||||
|  | macro_rules! edit_reply { | ||||||
|  |     ($ctx:expr, $handle:expr, $($arg:tt)*) => {{ | ||||||
|  |         let content = format!($($arg)*); | ||||||
|  |         let edited = CreateReply::default() | ||||||
|  |             .reply(true) | ||||||
|  |             .content(content); | ||||||
|  |         $handle.edit($ctx, edited) | ||||||
|  |     }}; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[macro_export] | ||||||
|  | macro_rules! get_user { | ||||||
|  | 	($ctx:expr) => { | ||||||
|  | 		match crate::user::User::from_context($ctx).await { | ||||||
|  | 			Ok(user) => user, | ||||||
|  | 			Err(_) => { | ||||||
|  | 				$ctx.say("You are not an user in my database, sorry!") | ||||||
|  | 					.await?; | ||||||
|  | 				return Ok(()); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	}; | ||||||
|  | } | ||||||
|  | @ -2,7 +2,9 @@ use std::{fs, path::PathBuf}; | ||||||
| 
 | 
 | ||||||
| use sqlx::SqlitePool; | use sqlx::SqlitePool; | ||||||
| 
 | 
 | ||||||
| use crate::{chart::SongCache, jacket::JacketCache, ocr::ui::UIMeasurements}; | use crate::{ | ||||||
|  | 	arcaea::chart::SongCache, arcaea::jacket::JacketCache, recognition::ui::UIMeasurements, | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
| // Types used by all command functions
 | // Types used by all command functions
 | ||||||
| pub type Error = Box<dyn std::error::Error + Send + Sync>; | pub type Error = Box<dyn std::error::Error + Send + Sync>; | ||||||
|  | @ -12,6 +14,7 @@ pub type Context<'a> = poise::Context<'a, UserContext, Error>; | ||||||
| pub struct UserContext { | pub struct UserContext { | ||||||
| 	#[allow(dead_code)] | 	#[allow(dead_code)] | ||||||
| 	pub data_dir: PathBuf, | 	pub data_dir: PathBuf, | ||||||
|  | 
 | ||||||
| 	pub db: SqlitePool, | 	pub db: SqlitePool, | ||||||
| 	pub song_cache: SongCache, | 	pub song_cache: SongCache, | ||||||
| 	pub jacket_cache: JacketCache, | 	pub jacket_cache: JacketCache, | ||||||
|  |  | ||||||
|  | @ -3,17 +3,16 @@ | ||||||
| #![feature(let_chains)] | #![feature(let_chains)] | ||||||
| #![feature(array_try_map)] | #![feature(array_try_map)] | ||||||
| #![feature(async_closure)] | #![feature(async_closure)] | ||||||
|  | #![feature(try_blocks)] | ||||||
| 
 | 
 | ||||||
|  | mod arcaea; | ||||||
| mod assets; | mod assets; | ||||||
| mod bitmap; | mod bitmap; | ||||||
| mod chart; |  | ||||||
| mod commands; | mod commands; | ||||||
| mod context; | mod context; | ||||||
| mod image; |  | ||||||
| mod jacket; |  | ||||||
| mod levenshtein; | mod levenshtein; | ||||||
| mod ocr; | mod recognition; | ||||||
| mod score; | mod transform; | ||||||
| mod user; | mod user; | ||||||
| 
 | 
 | ||||||
| use assets::get_data_dir; | use assets::get_data_dir; | ||||||
|  |  | ||||||
|  | @ -1 +0,0 @@ | ||||||
| pub mod ui; |  | ||||||
							
								
								
									
										127
									
								
								src/recognition/fuzzy_song_name.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								src/recognition/fuzzy_song_name.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,127 @@ | ||||||
|  | use crate::arcaea::chart::{Chart, Difficulty, Song, SongCache}; | ||||||
|  | use crate::context::{Error, UserContext}; | ||||||
|  | use crate::levenshtein::edit_distance_with; | ||||||
|  | 
 | ||||||
|  | /// Similar to `.strip_suffix`, but case insensitive
 | ||||||
|  | #[inline] | ||||||
|  | fn strip_case_insensitive_suffix<'a>(string: &'a str, suffix: &str) -> Option<&'a str> { | ||||||
|  | 	let suffix = suffix.to_lowercase(); | ||||||
|  | 	if string.to_lowercase().ends_with(&suffix) { | ||||||
|  | 		Some(&string[0..string.len() - suffix.len()]) | ||||||
|  | 	} else { | ||||||
|  | 		None | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // {{{ Guess song and chart by name
 | ||||||
|  | pub fn guess_song_and_chart<'a>( | ||||||
|  | 	ctx: &'a UserContext, | ||||||
|  | 	name: &'a str, | ||||||
|  | ) -> Result<(&'a Song, &'a Chart), Error> { | ||||||
|  | 	let name = name.trim(); | ||||||
|  | 	let (name, difficulty) = name | ||||||
|  | 		.strip_suffix("PST") | ||||||
|  | 		.zip(Some(Difficulty::PST)) | ||||||
|  | 		.or_else(|| strip_case_insensitive_suffix(name, "[PST]").zip(Some(Difficulty::PST))) | ||||||
|  | 		.or_else(|| strip_case_insensitive_suffix(name, "PRS").zip(Some(Difficulty::PRS))) | ||||||
|  | 		.or_else(|| strip_case_insensitive_suffix(name, "[PRS]").zip(Some(Difficulty::PRS))) | ||||||
|  | 		.or_else(|| strip_case_insensitive_suffix(name, "FTR").zip(Some(Difficulty::FTR))) | ||||||
|  | 		.or_else(|| strip_case_insensitive_suffix(name, "[FTR]").zip(Some(Difficulty::FTR))) | ||||||
|  | 		.or_else(|| strip_case_insensitive_suffix(name, "ETR").zip(Some(Difficulty::ETR))) | ||||||
|  | 		.or_else(|| strip_case_insensitive_suffix(name, "[ETR]").zip(Some(Difficulty::ETR))) | ||||||
|  | 		.or_else(|| strip_case_insensitive_suffix(name, "BYD").zip(Some(Difficulty::BYD))) | ||||||
|  | 		.or_else(|| strip_case_insensitive_suffix(name, "[BYD]").zip(Some(Difficulty::BYD))) | ||||||
|  | 		.unwrap_or((&name, Difficulty::FTR)); | ||||||
|  | 
 | ||||||
|  | 	guess_chart_name(name, &ctx.song_cache, Some(difficulty), true) | ||||||
|  | } | ||||||
|  | // }}}
 | ||||||
|  | // {{{ Guess chart by name
 | ||||||
|  | /// Runs a specialized fuzzy-search through all charts in the game.
 | ||||||
|  | ///
 | ||||||
|  | /// The `unsafe_heuristics` toggle increases the amount of resolvable queries, but might let in
 | ||||||
|  | /// some false positives. We turn it on for simple user-search commands, but disallow it for things
 | ||||||
|  | /// like OCR-generated text.
 | ||||||
|  | pub fn guess_chart_name<'a>( | ||||||
|  | 	raw_text: &str, | ||||||
|  | 	cache: &'a SongCache, | ||||||
|  | 	difficulty: Option<Difficulty>, | ||||||
|  | 	unsafe_heuristics: bool, | ||||||
|  | ) -> Result<(&'a Song, &'a Chart), Error> { | ||||||
|  | 	let raw_text = raw_text.trim(); // not quite raw 🤔
 | ||||||
|  | 	let mut text: &str = &raw_text.to_lowercase(); | ||||||
|  | 
 | ||||||
|  | 	// Cached vec used by the levenshtein distance function
 | ||||||
|  | 	let mut levenshtein_vec = Vec::with_capacity(20); | ||||||
|  | 	// Cached vec used to store distance calculations
 | ||||||
|  | 	let mut distance_vec = Vec::with_capacity(3); | ||||||
|  | 
 | ||||||
|  | 	let (song, chart) = loop { | ||||||
|  | 		let mut close_enough: Vec<_> = cache | ||||||
|  | 			.songs() | ||||||
|  | 			.filter_map(|item| { | ||||||
|  | 				let song = &item.song; | ||||||
|  | 				let chart = if let Some(difficulty) = difficulty { | ||||||
|  | 					item.lookup(difficulty).ok()? | ||||||
|  | 				} else { | ||||||
|  | 					item.charts().next()? | ||||||
|  | 				}; | ||||||
|  | 
 | ||||||
|  | 				let song_title = &song.lowercase_title; | ||||||
|  | 				distance_vec.clear(); | ||||||
|  | 
 | ||||||
|  | 				let base_distance = edit_distance_with(&text, &song_title, &mut levenshtein_vec); | ||||||
|  | 				if base_distance < 1.max(song.title.len() / 3) { | ||||||
|  | 					distance_vec.push(base_distance * 10 + 2); | ||||||
|  | 				} | ||||||
|  | 
 | ||||||
|  | 				let shortest_len = Ord::min(song_title.len(), text.len()); | ||||||
|  | 				if let Some(sliced) = &song_title.get(..shortest_len) | ||||||
|  | 					&& (text.len() >= 6 || unsafe_heuristics) | ||||||
|  | 				{ | ||||||
|  | 					let slice_distance = edit_distance_with(&text, sliced, &mut levenshtein_vec); | ||||||
|  | 					if slice_distance < 1 { | ||||||
|  | 						distance_vec.push(slice_distance * 10 + 3); | ||||||
|  | 					} | ||||||
|  | 				} | ||||||
|  | 
 | ||||||
|  | 				if let Some(shorthand) = &chart.shorthand | ||||||
|  | 					&& unsafe_heuristics | ||||||
|  | 				{ | ||||||
|  | 					let short_distance = edit_distance_with(&text, shorthand, &mut levenshtein_vec); | ||||||
|  | 					if short_distance < 1.max(shorthand.len() / 3) { | ||||||
|  | 						distance_vec.push(short_distance * 10 + 1); | ||||||
|  | 					} | ||||||
|  | 				} | ||||||
|  | 
 | ||||||
|  | 				distance_vec | ||||||
|  | 					.iter() | ||||||
|  | 					.min() | ||||||
|  | 					.map(|distance| (song, chart, *distance)) | ||||||
|  | 			}) | ||||||
|  | 			.collect(); | ||||||
|  | 
 | ||||||
|  | 		if close_enough.len() == 0 { | ||||||
|  | 			if text.len() <= 1 { | ||||||
|  | 				Err(format!( | ||||||
|  | 					"Could not find match for chart name '{}' [{:?}]", | ||||||
|  | 					raw_text, difficulty | ||||||
|  | 				))?; | ||||||
|  | 			} else { | ||||||
|  | 				text = &text[..text.len() - 1]; | ||||||
|  | 			} | ||||||
|  | 		} else if close_enough.len() == 1 { | ||||||
|  | 			break (close_enough[0].0, close_enough[0].1); | ||||||
|  | 		} else { | ||||||
|  | 			if unsafe_heuristics { | ||||||
|  | 				close_enough.sort_by_key(|(_, _, distance)| *distance); | ||||||
|  | 				break (close_enough[0].0, close_enough[0].1); | ||||||
|  | 			} else { | ||||||
|  | 				return Err(format!("Name '{}' is too vague to choose a match", raw_text).into()); | ||||||
|  | 			}; | ||||||
|  | 		}; | ||||||
|  | 	}; | ||||||
|  | 
 | ||||||
|  | 	Ok((song, chart)) | ||||||
|  | } | ||||||
|  | // }}}
 | ||||||
							
								
								
									
										3
									
								
								src/recognition/mod.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/recognition/mod.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,3 @@ | ||||||
|  | pub mod fuzzy_song_name; | ||||||
|  | pub mod recognize; | ||||||
|  | pub mod ui; | ||||||
							
								
								
									
										495
									
								
								src/recognition/recognize.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										495
									
								
								src/recognition/recognize.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,495 @@ | ||||||
|  | use std::fmt::Display; | ||||||
|  | use std::io::Cursor; | ||||||
|  | use std::str::FromStr; | ||||||
|  | use std::{env, fs}; | ||||||
|  | 
 | ||||||
|  | use hypertesseract::{PageSegMode, Tesseract}; | ||||||
|  | use image::{DynamicImage, GenericImageView}; | ||||||
|  | use image::{ImageBuffer, Rgba}; | ||||||
|  | use num::integer::Roots; | ||||||
|  | use poise::serenity_prelude::{CreateAttachment, CreateEmbed, CreateMessage, Timestamp}; | ||||||
|  | 
 | ||||||
|  | use crate::arcaea::chart::{Chart, Difficulty, Song, DIFFICULTY_MENU_PIXEL_COLORS}; | ||||||
|  | use crate::arcaea::jacket::IMAGE_VEC_DIM; | ||||||
|  | use crate::arcaea::score::Score; | ||||||
|  | use crate::bitmap::{Color, Rect}; | ||||||
|  | use crate::context::{Context, Error, UserContext}; | ||||||
|  | use crate::levenshtein::edit_distance; | ||||||
|  | use crate::recognition::fuzzy_song_name::guess_chart_name; | ||||||
|  | use crate::recognition::ui::{ | ||||||
|  | 	ScoreScreenRect, SongSelectRect, UIMeasurementRect, UIMeasurementRect::*, | ||||||
|  | }; | ||||||
|  | use crate::transform::rotate; | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||||||
|  | pub enum ScoreKind { | ||||||
|  | 	SongSelect, | ||||||
|  | 	ScoreScreen, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// Caches a byte vector in order to prevent reallocation
 | ||||||
|  | #[derive(Debug, Clone, Default)] | ||||||
|  | pub struct ImageAnalyzer { | ||||||
|  | 	/// cached byte array
 | ||||||
|  | 	pub bytes: Vec<u8>, | ||||||
|  | 
 | ||||||
|  | 	/// Last rect used to crop something
 | ||||||
|  | 	last_rect: Option<(UIMeasurementRect, Rect)>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl ImageAnalyzer { | ||||||
|  | 	/// Similar to reinitializing this, but without deallocating memory
 | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn clear(&mut self) { | ||||||
|  | 		self.bytes.clear(); | ||||||
|  | 		self.last_rect = None; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// {{{ Crop
 | ||||||
|  | 	pub fn crop_image_to_bytes(&mut self, image: &DynamicImage, rect: Rect) -> Result<(), Error> { | ||||||
|  | 		self.clear(); | ||||||
|  | 		let image = image.crop_imm(rect.x as u32, rect.y as u32, rect.width, rect.height); | ||||||
|  | 		let mut cursor = Cursor::new(&mut self.bytes); | ||||||
|  | 		image.write_to(&mut cursor, image::ImageFormat::Png)?; | ||||||
|  | 
 | ||||||
|  | 		fs::write(format!("./logs/{}.png", Timestamp::now()), &self.bytes)?; | ||||||
|  | 
 | ||||||
|  | 		Ok(()) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn crop(&mut self, image: &DynamicImage, rect: Rect) -> ImageBuffer<Rgba<u8>, Vec<u8>> { | ||||||
|  | 		if env::var("SHIMMERING_DEBUG_IMGS") | ||||||
|  | 			.map(|s| s == "1") | ||||||
|  | 			.unwrap_or(false) | ||||||
|  | 		{ | ||||||
|  | 			self.crop_image_to_bytes(image, rect).unwrap(); | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		image | ||||||
|  | 			.crop_imm(rect.x as u32, rect.y as u32, rect.width, rect.height) | ||||||
|  | 			.to_rgba8() | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	#[inline] | ||||||
|  | 	pub fn interp_crop( | ||||||
|  | 		&mut self, | ||||||
|  | 		ctx: &UserContext, | ||||||
|  | 		image: &DynamicImage, | ||||||
|  | 		ui_rect: UIMeasurementRect, | ||||||
|  | 	) -> Result<ImageBuffer<Rgba<u8>, Vec<u8>>, Error> { | ||||||
|  | 		let rect = ctx.ui_measurements.interpolate(ui_rect, image)?; | ||||||
|  | 		self.last_rect = Some((ui_rect, rect)); | ||||||
|  | 		Ok(self.crop(image, rect)) | ||||||
|  | 	} | ||||||
|  | 	// }}}
 | ||||||
|  | 	// {{{ Error handling
 | ||||||
|  | 	pub async fn send_discord_error( | ||||||
|  | 		&mut self, | ||||||
|  | 		ctx: Context<'_>, | ||||||
|  | 		image: &DynamicImage, | ||||||
|  | 		filename: &str, | ||||||
|  | 		err: impl Display, | ||||||
|  | 	) -> Result<(), Error> { | ||||||
|  | 		let mut embed = CreateEmbed::default().description(format!( | ||||||
|  | 			"Nerdy info
 | ||||||
|  | ``` | ||||||
|  | {} | ||||||
|  | ```",
 | ||||||
|  | 			err | ||||||
|  | 		)); | ||||||
|  | 
 | ||||||
|  | 		if let Some((ui_rect, rect)) = self.last_rect { | ||||||
|  | 			self.crop_image_to_bytes(image, rect)?; | ||||||
|  | 
 | ||||||
|  | 			let bytes = std::mem::take(&mut self.bytes); | ||||||
|  | 			let error_attachement = CreateAttachment::bytes(bytes, filename); | ||||||
|  | 
 | ||||||
|  | 			embed = embed.attachment(filename).title(format!( | ||||||
|  | 				"An error occurred, around the time I was extracting data for {ui_rect:?}" | ||||||
|  | 			)); | ||||||
|  | 
 | ||||||
|  | 			let msg = CreateMessage::default().embed(embed); | ||||||
|  | 			ctx.channel_id() | ||||||
|  | 				.send_files(ctx.http(), [error_attachement], msg) | ||||||
|  | 				.await?; | ||||||
|  | 		} else { | ||||||
|  | 			embed = embed.title("An error occurred"); | ||||||
|  | 
 | ||||||
|  | 			let msg = CreateMessage::default().embed(embed); | ||||||
|  | 			ctx.channel_id().send_files(ctx.http(), [], msg).await?; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		Ok(()) | ||||||
|  | 	} | ||||||
|  | 	// }}}
 | ||||||
|  | 	// {{{ Read score
 | ||||||
|  | 	pub fn read_score( | ||||||
|  | 		&mut self, | ||||||
|  | 		ctx: &UserContext, | ||||||
|  | 		note_count: Option<u32>, | ||||||
|  | 		image: &DynamicImage, | ||||||
|  | 		kind: ScoreKind, | ||||||
|  | 	) -> Result<Vec<Score>, Error> { | ||||||
|  | 		let image = self.interp_crop( | ||||||
|  | 			ctx, | ||||||
|  | 			image, | ||||||
|  | 			if kind == ScoreKind::ScoreScreen { | ||||||
|  | 				ScoreScreen(ScoreScreenRect::Score) | ||||||
|  | 			} else { | ||||||
|  | 				SongSelect(SongSelectRect::Score) | ||||||
|  | 			}, | ||||||
|  | 		)?; | ||||||
|  | 
 | ||||||
|  | 		let mut results = vec![]; | ||||||
|  | 		for mode in [ | ||||||
|  | 			PageSegMode::SingleWord, | ||||||
|  | 			PageSegMode::RawLine, | ||||||
|  | 			PageSegMode::SingleLine, | ||||||
|  | 			PageSegMode::SparseText, | ||||||
|  | 			PageSegMode::SingleBlock, | ||||||
|  | 		] { | ||||||
|  | 			let result: Result<_, Error> = try { | ||||||
|  | 				// {{{ Read score using tesseract
 | ||||||
|  | 				let text = Tesseract::builder() | ||||||
|  | 					.language(hypertesseract::Language::English) | ||||||
|  | 					.whitelist_str("0123456789'/")? | ||||||
|  | 					.page_seg_mode(mode) | ||||||
|  | 					.assume_numeric_input() | ||||||
|  | 					.build()? | ||||||
|  | 					.load_image(&image)? | ||||||
|  | 					.recognize()? | ||||||
|  | 					.get_text()?; | ||||||
|  | 
 | ||||||
|  | 				let text: String = text | ||||||
|  | 					.trim() | ||||||
|  | 					.chars() | ||||||
|  | 					.map(|char| if char == '/' { '7' } else { char }) | ||||||
|  | 					.filter(|char| *char != ' ' && *char != '\'') | ||||||
|  | 					.collect(); | ||||||
|  | 
 | ||||||
|  | 				let score = u32::from_str_radix(&text, 10)?; | ||||||
|  | 				Score(score) | ||||||
|  | 				// }}}
 | ||||||
|  | 			}; | ||||||
|  | 
 | ||||||
|  | 			match result { | ||||||
|  | 				Ok(result) => { | ||||||
|  | 					results.push(result.0); | ||||||
|  | 				} | ||||||
|  | 				Err(err) => { | ||||||
|  | 					println!("OCR score result error: {}", err); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// {{{ Score correction
 | ||||||
|  | 		// The OCR sometimes fails to read "74" with the arcaea font,
 | ||||||
|  | 		// so we try to detect that and fix it
 | ||||||
|  | 		loop { | ||||||
|  | 			let old_stack_len = results.len(); | ||||||
|  | 			println!("Results {:?}", results); | ||||||
|  | 			results = results | ||||||
|  | 				.iter() | ||||||
|  | 				.flat_map(|result| { | ||||||
|  | 					// If the length is correct, we are good to go!
 | ||||||
|  | 					if *result >= 8_000_000 { | ||||||
|  | 						vec![*result] | ||||||
|  | 					} else { | ||||||
|  | 						let mut results = vec![]; | ||||||
|  | 						for i in [0, 1, 3, 4] { | ||||||
|  | 							let d = 10u32.pow(i); | ||||||
|  | 							if (*result / d) % 10 == 4 && (*result / d) % 100 != 74 { | ||||||
|  | 								let n = d * 10; | ||||||
|  | 								results.push((*result / n) * n * 10 + 7 * n + (*result % n)); | ||||||
|  | 							} | ||||||
|  | 						} | ||||||
|  | 
 | ||||||
|  | 						results | ||||||
|  | 					} | ||||||
|  | 				}) | ||||||
|  | 				.collect(); | ||||||
|  | 
 | ||||||
|  | 			if old_stack_len == results.len() { | ||||||
|  | 				break; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		// }}}
 | ||||||
|  | 		// {{{ Return score if consensus exists
 | ||||||
|  | 		// 1. Discard scores that are known to be impossible
 | ||||||
|  | 		let mut results: Vec<_> = results | ||||||
|  | 			.into_iter() | ||||||
|  | 			.filter(|result| { | ||||||
|  | 				8_000_000 <= *result | ||||||
|  | 					&& *result <= 10_010_000 | ||||||
|  | 					&& note_count | ||||||
|  | 						.map(|note_count| { | ||||||
|  | 							let (zeta, shinies, score_units) = Score(*result).analyse(note_count); | ||||||
|  | 							8_000_000 <= zeta.0 | ||||||
|  | 								&& zeta.0 <= 10_000_000 && shinies <= note_count | ||||||
|  | 								&& score_units <= 2 * note_count | ||||||
|  | 						}) | ||||||
|  | 						.unwrap_or(true) | ||||||
|  | 			}) | ||||||
|  | 			.map(|r| Score(r)) | ||||||
|  | 			.collect(); | ||||||
|  | 		println!("Results {:?}", results); | ||||||
|  | 
 | ||||||
|  | 		// 2. Look for consensus
 | ||||||
|  | 		for result in results.iter() { | ||||||
|  | 			if results.iter().filter(|e| **e == *result).count() > results.len() / 2 { | ||||||
|  | 				return Ok(vec![*result]); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		// }}}
 | ||||||
|  | 
 | ||||||
|  | 		// If there's no consensus, we return everything
 | ||||||
|  | 		results.sort(); | ||||||
|  | 		results.dedup(); | ||||||
|  | 		println!("Results {:?}", results); | ||||||
|  | 
 | ||||||
|  | 		Ok(results) | ||||||
|  | 	} | ||||||
|  | 	// }}}
 | ||||||
|  | 	// {{{ Read difficulty
 | ||||||
|  | 	pub fn read_difficulty( | ||||||
|  | 		&mut self, | ||||||
|  | 		ctx: &UserContext, | ||||||
|  | 		image: &DynamicImage, | ||||||
|  | 		kind: ScoreKind, | ||||||
|  | 	) -> Result<Difficulty, Error> { | ||||||
|  | 		if kind == ScoreKind::SongSelect { | ||||||
|  | 			let min = DIFFICULTY_MENU_PIXEL_COLORS | ||||||
|  | 				.iter() | ||||||
|  | 				.zip(Difficulty::DIFFICULTIES) | ||||||
|  | 				.min_by_key(|(c, d)| { | ||||||
|  | 					let rect = ctx | ||||||
|  | 						.ui_measurements | ||||||
|  | 						.interpolate( | ||||||
|  | 							SongSelect(match d { | ||||||
|  | 								Difficulty::PST => SongSelectRect::Past, | ||||||
|  | 								Difficulty::PRS => SongSelectRect::Present, | ||||||
|  | 								Difficulty::FTR => SongSelectRect::Future, | ||||||
|  | 								_ => SongSelectRect::Beyond, | ||||||
|  | 							}), | ||||||
|  | 							image, | ||||||
|  | 						) | ||||||
|  | 						.unwrap(); | ||||||
|  | 
 | ||||||
|  | 					// rect.width = 100;
 | ||||||
|  | 					// rect.height = 100;
 | ||||||
|  | 					// self.crop_image_to_bytes(image, rect).unwrap();
 | ||||||
|  | 
 | ||||||
|  | 					let image_color = image.get_pixel(rect.x as u32, rect.y as u32); | ||||||
|  | 					let image_color = Color::from_bytes(image_color.0); | ||||||
|  | 
 | ||||||
|  | 					let distance = c.distance(image_color); | ||||||
|  | 					(distance * 10000.0) as u32 | ||||||
|  | 				}) | ||||||
|  | 				.unwrap(); | ||||||
|  | 
 | ||||||
|  | 			return Ok(min.1); | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		let mut ocr = Tesseract::builder() | ||||||
|  | 			.language(hypertesseract::Language::English) | ||||||
|  | 			.page_seg_mode(PageSegMode::RawLine) | ||||||
|  | 			.build()?; | ||||||
|  | 
 | ||||||
|  | 		ocr.load_image(&self.interp_crop(ctx, image, ScoreScreen(ScoreScreenRect::Difficulty))?)? | ||||||
|  | 			.recognize()?; | ||||||
|  | 
 | ||||||
|  | 		let text: &str = &ocr.get_text()?; | ||||||
|  | 		let text = text.trim().to_lowercase(); | ||||||
|  | 
 | ||||||
|  | 		// let conf = t.mean_text_conf();
 | ||||||
|  | 		// if conf < 10 && conf != 0 {
 | ||||||
|  | 		// 	Err(format!(
 | ||||||
|  | 		// 		"Difficulty text is not readable (confidence = {}, text = {}).",
 | ||||||
|  | 		// 		conf, text
 | ||||||
|  | 		// 	))?;
 | ||||||
|  | 		// }
 | ||||||
|  | 
 | ||||||
|  | 		let difficulty = Difficulty::DIFFICULTIES | ||||||
|  | 			.iter() | ||||||
|  | 			.zip(Difficulty::DIFFICULTY_STRINGS) | ||||||
|  | 			.min_by_key(|(_, difficulty_string)| edit_distance(difficulty_string, &text)) | ||||||
|  | 			.map(|(difficulty, _)| *difficulty) | ||||||
|  | 			.ok_or_else(|| format!("Unrecognised difficulty '{}'", text))?; | ||||||
|  | 
 | ||||||
|  | 		Ok(difficulty) | ||||||
|  | 	} | ||||||
|  | 	// }}}
 | ||||||
|  | 	// {{{ Read score kind
 | ||||||
|  | 	pub fn read_score_kind( | ||||||
|  | 		&mut self, | ||||||
|  | 		ctx: &UserContext, | ||||||
|  | 		image: &DynamicImage, | ||||||
|  | 	) -> Result<ScoreKind, Error> { | ||||||
|  | 		let text = Tesseract::builder() | ||||||
|  | 			.language(hypertesseract::Language::English) | ||||||
|  | 			.page_seg_mode(PageSegMode::RawLine) | ||||||
|  | 			.build()? | ||||||
|  | 			.load_image(&self.interp_crop(ctx, image, PlayKind)?)? | ||||||
|  | 			.recognize()? | ||||||
|  | 			.get_text()? | ||||||
|  | 			.trim() | ||||||
|  | 			.to_string(); | ||||||
|  | 
 | ||||||
|  | 		// let conf = t.mean_text_conf();
 | ||||||
|  | 		// if conf < 10 && conf != 0 {
 | ||||||
|  | 		// 	Err(format!(
 | ||||||
|  | 		// 		"Score kind text is not readable (confidence = {}, text = {}).",
 | ||||||
|  | 		// 		conf, text
 | ||||||
|  | 		// 	))?;
 | ||||||
|  | 		// }
 | ||||||
|  | 
 | ||||||
|  | 		let result = if edit_distance(&text, "Result") < edit_distance(&text, "Select a song") { | ||||||
|  | 			ScoreKind::ScoreScreen | ||||||
|  | 		} else { | ||||||
|  | 			ScoreKind::SongSelect | ||||||
|  | 		}; | ||||||
|  | 
 | ||||||
|  | 		Ok(result) | ||||||
|  | 	} | ||||||
|  | 	// }}}
 | ||||||
|  | 	// {{{ Read song
 | ||||||
|  | 	pub fn read_song<'a>( | ||||||
|  | 		&mut self, | ||||||
|  | 		ctx: &'a UserContext, | ||||||
|  | 		image: &DynamicImage, | ||||||
|  | 		difficulty: Difficulty, | ||||||
|  | 	) -> Result<(&'a Song, &'a Chart), Error> { | ||||||
|  | 		let text = Tesseract::builder() | ||||||
|  | 			.language(hypertesseract::Language::English) | ||||||
|  | 			.page_seg_mode(PageSegMode::SingleLine) | ||||||
|  | 			.whitelist_str("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789,.()- ")? | ||||||
|  | 			.build()? | ||||||
|  | 			.load_image(&self.interp_crop(ctx, image, ScoreScreen(ScoreScreenRect::Title))?)? | ||||||
|  | 			.recognize()? | ||||||
|  | 			.get_text()?; | ||||||
|  | 
 | ||||||
|  | 		// let conf = t.mean_text_conf();
 | ||||||
|  | 		// if conf < 20 && conf != 0 {
 | ||||||
|  | 		// 	Err(format!(
 | ||||||
|  | 		// 		"Title text is not readable (confidence = {}, text = {}).",
 | ||||||
|  | 		// 		conf,
 | ||||||
|  | 		// 		raw_text.trim()
 | ||||||
|  | 		// 	))?;
 | ||||||
|  | 		// }
 | ||||||
|  | 
 | ||||||
|  | 		guess_chart_name(&text, &ctx.song_cache, Some(difficulty), false) | ||||||
|  | 	} | ||||||
|  | 	// }}}
 | ||||||
|  | 	// {{{ Read jacket
 | ||||||
|  | 	pub async fn read_jacket<'a>( | ||||||
|  | 		&mut self, | ||||||
|  | 		ctx: &'a UserContext, | ||||||
|  | 		image: &mut DynamicImage, | ||||||
|  | 		kind: ScoreKind, | ||||||
|  | 		difficulty: Difficulty, | ||||||
|  | 	) -> Result<(&'a Song, &'a Chart), Error> { | ||||||
|  | 		let rect = ctx.ui_measurements.interpolate( | ||||||
|  | 			if kind == ScoreKind::ScoreScreen { | ||||||
|  | 				ScoreScreen(ScoreScreenRect::Jacket) | ||||||
|  | 			} else { | ||||||
|  | 				SongSelect(SongSelectRect::Jacket) | ||||||
|  | 			}, | ||||||
|  | 			image, | ||||||
|  | 		)?; | ||||||
|  | 
 | ||||||
|  | 		let cropped = if kind == ScoreKind::ScoreScreen { | ||||||
|  | 			image.view(rect.x as u32, rect.y as u32, rect.width, rect.height) | ||||||
|  | 		} else { | ||||||
|  | 			let angle = f32::atan2(rect.height as f32, rect.width as f32); | ||||||
|  | 			let side = rect.height + rect.width; | ||||||
|  | 			rotate( | ||||||
|  | 				image, | ||||||
|  | 				Rect::new(rect.x, rect.y, side, side), | ||||||
|  | 				(rect.x, rect.y + rect.height as i32), | ||||||
|  | 				angle, | ||||||
|  | 			); | ||||||
|  | 
 | ||||||
|  | 			let len = (rect.width.pow(2) + rect.height.pow(2)).sqrt(); | ||||||
|  | 
 | ||||||
|  | 			image.view(rect.x as u32, rect.y as u32 + rect.height, len, len) | ||||||
|  | 		}; | ||||||
|  | 		let (distance, song_id) = ctx | ||||||
|  | 			.jacket_cache | ||||||
|  | 			.recognise(&*cropped) | ||||||
|  | 			.ok_or_else(|| "Could not recognise jacket")?; | ||||||
|  | 
 | ||||||
|  | 		if distance > (IMAGE_VEC_DIM * 3) as f32 { | ||||||
|  | 			Err("No known jacket looks like this")?; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		let item = ctx.song_cache.lookup(*song_id)?; | ||||||
|  | 		let chart = item.lookup(difficulty)?; | ||||||
|  | 
 | ||||||
|  | 		// NOTE: this will reallocate a few strings, but it is what it is
 | ||||||
|  | 		Ok((&item.song, chart)) | ||||||
|  | 	} | ||||||
|  | 	// }}}
 | ||||||
|  | 	// {{{ Read distribution
 | ||||||
|  | 	pub fn read_distribution( | ||||||
|  | 		&mut self, | ||||||
|  | 		ctx: &UserContext, | ||||||
|  | 		image: &DynamicImage, | ||||||
|  | 	) -> Result<(u32, u32, u32), Error> { | ||||||
|  | 		let mut ocr = Tesseract::builder() | ||||||
|  | 			.language(hypertesseract::Language::English) | ||||||
|  | 			.page_seg_mode(PageSegMode::SparseText) | ||||||
|  | 			.whitelist_str("0123456789")? | ||||||
|  | 			.assume_numeric_input() | ||||||
|  | 			.build()?; | ||||||
|  | 
 | ||||||
|  | 		let mut out = [0; 3]; | ||||||
|  | 
 | ||||||
|  | 		use ScoreScreenRect::*; | ||||||
|  | 		static KINDS: [ScoreScreenRect; 3] = [Pure, Far, Lost]; | ||||||
|  | 
 | ||||||
|  | 		for i in 0..3 { | ||||||
|  | 			let text = ocr | ||||||
|  | 				.load_image(&self.interp_crop(ctx, image, ScoreScreen(KINDS[i]))?)? | ||||||
|  | 				.recognize()? | ||||||
|  | 				.get_text()?; | ||||||
|  | 
 | ||||||
|  | 			println!("Raw '{}'", text.trim()); | ||||||
|  | 			out[i] = u32::from_str(&text.trim()).unwrap_or(0); | ||||||
|  | 		} | ||||||
|  | 		println!("Ditribution {out:?}"); | ||||||
|  | 
 | ||||||
|  | 		Ok((out[0], out[1], out[2])) | ||||||
|  | 	} | ||||||
|  | 	// }}}
 | ||||||
|  | 	// {{{ Read max recall
 | ||||||
|  | 	pub fn read_max_recall<'a>( | ||||||
|  | 		&mut self, | ||||||
|  | 		ctx: &'a UserContext, | ||||||
|  | 		image: &DynamicImage, | ||||||
|  | 	) -> Result<u32, Error> { | ||||||
|  | 		let text = Tesseract::builder() | ||||||
|  | 			.language(hypertesseract::Language::English) | ||||||
|  | 			.page_seg_mode(PageSegMode::SingleLine) | ||||||
|  | 			.whitelist_str("0123456789")? | ||||||
|  | 			.assume_numeric_input() | ||||||
|  | 			.build()? | ||||||
|  | 			.load_image(&self.interp_crop(ctx, image, ScoreScreen(ScoreScreenRect::MaxRecall))?)? | ||||||
|  | 			.recognize()? | ||||||
|  | 			.get_text()?; | ||||||
|  | 
 | ||||||
|  | 		let max_recall = u32::from_str_radix(text.trim(), 10)?; | ||||||
|  | 
 | ||||||
|  | 		// let conf = t.mean_text_conf();
 | ||||||
|  | 		// if conf < 20 && conf != 0 {
 | ||||||
|  | 		// 	Err(format!(
 | ||||||
|  | 		// 		"Title text is not readable (confidence = {}, text = {}).",
 | ||||||
|  | 		// 		conf,
 | ||||||
|  | 		// 		raw_text.trim()
 | ||||||
|  | 		// 	))?;
 | ||||||
|  | 		// }
 | ||||||
|  | 
 | ||||||
|  | 		Ok(max_recall) | ||||||
|  | 	} | ||||||
|  | 	// }}}
 | ||||||
|  | } | ||||||
|  | @ -1,5 +1,3 @@ | ||||||
| #![allow(dead_code)] |  | ||||||
| 
 |  | ||||||
| use std::{fs, path::PathBuf}; | use std::{fs, path::PathBuf}; | ||||||
| 
 | 
 | ||||||
| use image::GenericImage; | use image::GenericImage; | ||||||
							
								
								
									
										1235
									
								
								src/score.rs
									
										
									
									
									
								
							
							
						
						
									
										1235
									
								
								src/score.rs
									
										
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue