Initial commit, I guess
Signed-off-by: prescientmoon <git@moonythm.dev>
This commit is contained in:
commit
a0e3decd7a
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
target
|
||||||
|
.direnv
|
||||||
|
.envrc
|
||||||
|
data
|
3729
Cargo.lock
generated
Normal file
3729
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
19
Cargo.toml
Normal file
19
Cargo.toml
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
[package]
|
||||||
|
name = "shimmeringmoon"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chrono = "0.4.38"
|
||||||
|
edit-distance = "2.1.0"
|
||||||
|
image = "0.25.1"
|
||||||
|
num = "0.4.3"
|
||||||
|
plotlib = "0.5.1"
|
||||||
|
poise = "0.6.1"
|
||||||
|
prettytable-rs = "0.10.0"
|
||||||
|
sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio", "chrono"] }
|
||||||
|
tesseract = "0.15.1"
|
||||||
|
tokio = {version="1.38.0", features=["rt-multi-thread"]}
|
||||||
|
|
||||||
|
[profile.dev.package.sqlx-macros]
|
||||||
|
opt-level = 3
|
100
flake.lock
Normal file
100
flake.lock
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"fenix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"rust-analyzer-src": "rust-analyzer-src"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1717827974,
|
||||||
|
"narHash": "sha256-ixopuTeTouxqTxfMuzs6IaRttbT8JqRW5C9Q/57WxQw=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "fenix",
|
||||||
|
"rev": "ab655c627777ab5f9964652fe23bbb1dfbd687a8",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "fenix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1710146030,
|
||||||
|
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1718000748,
|
||||||
|
"narHash": "sha256-zliqz7ovpxYdKIK+GlWJZxifXsT9A1CHNQhLxV0G1Hc=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "869cab745a802b693b45d193b460c9184da671f3",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "release-24.05",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"fenix": "fenix",
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-analyzer-src": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1717583671,
|
||||||
|
"narHash": "sha256-+lRAmz92CNUxorqWusgJbL9VE1eKCnQQojglRemzwkw=",
|
||||||
|
"owner": "rust-lang",
|
||||||
|
"repo": "rust-analyzer",
|
||||||
|
"rev": "48bbdd6a74f3176987d5c809894ac33957000d19",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "rust-lang",
|
||||||
|
"ref": "nightly",
|
||||||
|
"repo": "rust-analyzer",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
60
flake.nix
Normal file
60
flake.nix
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
{
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:nixos/nixpkgs/release-24.05";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
fenix.url = "github:nix-community/fenix";
|
||||||
|
fenix.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, ... }@inputs:
|
||||||
|
inputs.flake-utils.lib.eachSystem
|
||||||
|
(with inputs.flake-utils.lib.system; [ x86_64-linux ])
|
||||||
|
(system:
|
||||||
|
let
|
||||||
|
pkgs = inputs.nixpkgs.legacyPackages.${system}.extend
|
||||||
|
inputs.fenix.overlays.default;
|
||||||
|
inherit (pkgs) lib;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShell = pkgs.mkShell rec {
|
||||||
|
packages = with pkgs; [
|
||||||
|
(fenix.complete.withComponents [
|
||||||
|
"cargo"
|
||||||
|
"clippy"
|
||||||
|
"rust-src"
|
||||||
|
"rustc"
|
||||||
|
"rustfmt"
|
||||||
|
])
|
||||||
|
rust-analyzer-nightly
|
||||||
|
ruff
|
||||||
|
|
||||||
|
clang
|
||||||
|
llvmPackages.clang
|
||||||
|
pkg-config
|
||||||
|
|
||||||
|
leptonica
|
||||||
|
tesseract
|
||||||
|
openssl
|
||||||
|
sqlite
|
||||||
|
];
|
||||||
|
|
||||||
|
LD_LIBRARY_PATH = lib.makeLibraryPath packages;
|
||||||
|
|
||||||
|
# compilation of -sys packages requires manually setting LIBCLANG_PATH
|
||||||
|
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
# {{{ Caching and whatnot
|
||||||
|
# TODO: persist trusted substituters file
|
||||||
|
nixConfig = {
|
||||||
|
extra-substituters = [
|
||||||
|
"https://nix-community.cachix.org"
|
||||||
|
];
|
||||||
|
|
||||||
|
extra-trusted-public-keys = [
|
||||||
|
"nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
|
||||||
|
];
|
||||||
|
};
|
||||||
|
# }}}
|
||||||
|
}
|
1
rustfmt.toml
Normal file
1
rustfmt.toml
Normal file
|
@ -0,0 +1 @@
|
||||||
|
hard_tabs=true
|
55
schema.sql
Normal file
55
schema.sql
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
# {{{ users
|
||||||
|
create table IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
discord_id TEXT UNIQUE NOT NULL,
|
||||||
|
nickname TEXT UNIQUE
|
||||||
|
);
|
||||||
|
# }}}
|
||||||
|
# {{{ songs
|
||||||
|
CREATE TABLE IF NOT EXISTS songs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
ocr_alias TEXT,
|
||||||
|
artist TEXT,
|
||||||
|
|
||||||
|
UNIQUE(title, artist)
|
||||||
|
);
|
||||||
|
# }}}
|
||||||
|
# {{{ charts
|
||||||
|
CREATE TABLE IF NOT EXISTS charts (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
song_id INTEGER NOT NULL,
|
||||||
|
|
||||||
|
difficulty TEXT NOT NULL CHECK (difficulty IN ('PST','PRS','FTR','ETR','BYD')),
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
|
||||||
|
note_count INTEGER NOT NULL,
|
||||||
|
chart_constant INTEGER NOT NULL,
|
||||||
|
|
||||||
|
FOREIGN KEY (song_id) REFERENCES songs(id),
|
||||||
|
UNIQUE(song_id, difficulty)
|
||||||
|
);
|
||||||
|
# }}}
|
||||||
|
# {{{ plays
|
||||||
|
CREATE TABLE IF NOT EXISTS plays (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
chart_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
discord_attachment_id TEXT,
|
||||||
|
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
creation_ptt INTEGER,
|
||||||
|
creation_zeta_ptt INTEGER,
|
||||||
|
|
||||||
|
score INTEGER NOT NULL,
|
||||||
|
zeta_score INTEGER,
|
||||||
|
|
||||||
|
max_recall INTEGER,
|
||||||
|
far_notes INTEGER,
|
||||||
|
|
||||||
|
FOREIGN KEY (chart_id) REFERENCES charts(id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
insert into users(discord_id, nickname) values (385759924917108740, 'prescientmoon');
|
73
scripts/main.py
Executable file
73
scripts/main.py
Executable file
|
@ -0,0 +1,73 @@
|
||||||
|
#!/usr/bin/env nix-shell
|
||||||
|
#!nix-shell -p "pkgs.python3.withPackages (p: with p; [tabulate])"
|
||||||
|
#!nix-shell -i python3
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
data_dir = os.environ.get("SHIMMERING_DATA_DIR")
|
||||||
|
db_path = data_dir + "/db.sqlite"
|
||||||
|
# if not os.path.exists(db_path):
|
||||||
|
# run(f"cat ./schema.sql | sqlite3 {db_path}")
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
|
||||||
|
|
||||||
|
# {{{ Import songs
|
||||||
|
def import_charts_from_csv(input_file):
|
||||||
|
with open(input_file, mode="r") as file:
|
||||||
|
chart_count = 0
|
||||||
|
songs = dict()
|
||||||
|
|
||||||
|
for row in csv.reader(file):
|
||||||
|
if len(row) > 0:
|
||||||
|
chart_count += 1
|
||||||
|
[title, difficulty, level, cc, _, note_count, _, _, _] = row
|
||||||
|
if songs.get(title) is None:
|
||||||
|
songs[title] = []
|
||||||
|
songs[title].append((difficulty, level, cc, note_count))
|
||||||
|
|
||||||
|
for title, charts in songs.items():
|
||||||
|
artist = None
|
||||||
|
|
||||||
|
if title.startswith("Quon"):
|
||||||
|
artist = title[6:-1]
|
||||||
|
title = "Quon"
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO songs(title,artist)
|
||||||
|
VALUES (?,?)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(title, artist),
|
||||||
|
).fetchone()
|
||||||
|
song_id = row[0]
|
||||||
|
|
||||||
|
for difficulty, level, cc, note_count in charts:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO charts(song_id, difficulty, level, note_count, chart_constant)
|
||||||
|
VALUES(?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
song_id,
|
||||||
|
difficulty,
|
||||||
|
level,
|
||||||
|
int(note_count.replace(",", "").replace(".", "")),
|
||||||
|
int(float(cc) * 100),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print(f"Imported {chart_count} charts and {len(songs)} songs")
|
||||||
|
|
||||||
|
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
command = sys.argv[1]
|
||||||
|
subcommand = sys.argv[2]
|
||||||
|
|
||||||
|
if command == "import" and subcommand == "charts":
|
||||||
|
import_charts_from_csv(sys.argv[3])
|
88
src/chart.rs
Normal file
88
src/chart.rs
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
use sqlx::prelude::FromRow;
|
||||||
|
|
||||||
|
use crate::context::{Error, UserContext};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, sqlx::Type)]
|
||||||
|
pub enum Difficulty {
|
||||||
|
PST,
|
||||||
|
PRS,
|
||||||
|
FTR,
|
||||||
|
ETR,
|
||||||
|
BYD,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Difficulty {
|
||||||
|
#[inline]
|
||||||
|
pub fn to_index(self) -> usize {
|
||||||
|
self as usize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow)]
|
||||||
|
pub struct Song {
|
||||||
|
pub id: u32,
|
||||||
|
pub title: String,
|
||||||
|
pub ocr_alias: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, FromRow)]
|
||||||
|
pub struct Chart {
|
||||||
|
pub id: u32,
|
||||||
|
pub song_id: u32,
|
||||||
|
|
||||||
|
pub difficulty: Difficulty,
|
||||||
|
pub level: u32,
|
||||||
|
|
||||||
|
pub note_count: u32,
|
||||||
|
pub chart_constant: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CachedSong {
|
||||||
|
song: Song,
|
||||||
|
charts: [Option<Chart>; 5],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CachedSong {
|
||||||
|
pub fn new(song: Song, charts: [Option<Chart>; 5]) -> Self {
|
||||||
|
Self { song, charts }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SongCache {
|
||||||
|
songs: Vec<Option<CachedSong>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SongCache {
|
||||||
|
pub async fn new(ctx: &UserContext) -> Result<Self, Error> {
|
||||||
|
let mut result = Self::default();
|
||||||
|
|
||||||
|
let songs: Vec<Song> = sqlx::query_as("SELECT * FROM songs")
|
||||||
|
.fetch_all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for song in songs {
|
||||||
|
let song_id = song.id as usize;
|
||||||
|
|
||||||
|
if song_id >= result.songs.len() {
|
||||||
|
result.songs.resize(song_id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let charts: Vec<Chart> = sqlx::query_as("SELECT * FROM charts WHERE song_id=?")
|
||||||
|
.bind(song.id)
|
||||||
|
.fetch_all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut chart_cache = [None; 5];
|
||||||
|
for chart in charts {
|
||||||
|
chart_cache[chart.difficulty.to_index()] = Some(chart);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.songs[song_id] = Some(CachedSong::new(song, chart_cache));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
170
src/commands.rs
Normal file
170
src/commands.rs
Normal file
|
@ -0,0 +1,170 @@
|
||||||
|
use crate::context::{Context, Error};
|
||||||
|
use crate::score::ImageCropper;
|
||||||
|
use crate::user::User;
|
||||||
|
use image::imageops::FilterType;
|
||||||
|
use poise::serenity_prelude::{
|
||||||
|
CreateAttachment, CreateEmbed, CreateEmbedAuthor, CreateMessage, Timestamp,
|
||||||
|
};
|
||||||
|
use poise::{serenity_prelude as serenity, CreateReply};
|
||||||
|
use prettytable::format::{FormatBuilder, LinePosition, LineSeparator};
|
||||||
|
use prettytable::{row, Table};
|
||||||
|
|
||||||
|
/// Show this help menu
|
||||||
|
#[poise::command(prefix_command, track_edits, slash_command)]
|
||||||
|
pub async fn help(
|
||||||
|
ctx: Context<'_>,
|
||||||
|
#[description = "Specific command to show help about"]
|
||||||
|
#[autocomplete = "poise::builtins::autocomplete_command"]
|
||||||
|
command: Option<String>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
poise::builtins::help(
|
||||||
|
ctx,
|
||||||
|
command.as_deref(),
|
||||||
|
poise::builtins::HelpConfiguration {
|
||||||
|
extra_text_at_bottom: "For additional support, message @prescientmoon",
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score management
|
||||||
|
#[poise::command(
|
||||||
|
prefix_command,
|
||||||
|
slash_command,
|
||||||
|
subcommands("magic"),
|
||||||
|
subcommand_required
|
||||||
|
)]
|
||||||
|
pub async fn score(_ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identify scores from attached images.
|
||||||
|
#[poise::command(prefix_command, slash_command)]
|
||||||
|
pub async fn magic(
|
||||||
|
ctx: Context<'_>,
|
||||||
|
#[description = "Images containing scores"] files: Vec<serenity::Attachment>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
println!("{:?}", User::from_context(&ctx).await);
|
||||||
|
|
||||||
|
if files.len() == 0 {
|
||||||
|
ctx.reply("No images found attached to message").await?;
|
||||||
|
} else {
|
||||||
|
let handle = ctx
|
||||||
|
.reply(format!("Processing: 0/{} images", files.len()))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for (i, file) in files.iter().enumerate() {
|
||||||
|
if let Some(_) = file.dimensions() {
|
||||||
|
// Download image and guess it's format
|
||||||
|
let bytes = file.download().await?;
|
||||||
|
let format = image::guess_format(&bytes)?;
|
||||||
|
|
||||||
|
// Image pre-processing
|
||||||
|
let mut image = image::load_from_memory_with_format(&bytes, format)?
|
||||||
|
.resize(1024, 1024, FilterType::Nearest)
|
||||||
|
.grayscale()
|
||||||
|
.blur(1.);
|
||||||
|
image.invert();
|
||||||
|
|
||||||
|
// {{{ Table experiment
|
||||||
|
let table_format = FormatBuilder::new()
|
||||||
|
.separators(
|
||||||
|
&[LinePosition::Title],
|
||||||
|
LineSeparator::new('─', '┬', '┌', '┐'),
|
||||||
|
)
|
||||||
|
.padding(1, 1)
|
||||||
|
.build();
|
||||||
|
let mut table = Table::new();
|
||||||
|
table.set_format(table_format);
|
||||||
|
table.set_titles(row!["Chart", "Level", "Score", "Rating"]);
|
||||||
|
table.add_row(row!["Quon", "BYD 10", "10000807", "12.3 (-132)"]);
|
||||||
|
table.add_row(row!["Monochrome princess", "FTR 9+", " 9380807", "10.2"]);
|
||||||
|
table.add_row(row!["Grievous lady", "FTR 11", " 9286787", "11.2"]);
|
||||||
|
table.add_row(row!["Fracture ray", "FTR 11", " 8990891", "11.0"]);
|
||||||
|
table.add_row(row!["Shades of Light", "FTR 9+", "10000976", " 9.3 (-13)"]);
|
||||||
|
ctx.say(format!("```\n{}\n```", table.to_string())).await?;
|
||||||
|
// }}}
|
||||||
|
|
||||||
|
let icon_attachement = CreateAttachment::file(
|
||||||
|
&tokio::fs::File::open("./data/jackets/grievous.png").await?,
|
||||||
|
"grievous.png",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let msg = CreateMessage::default().embed(
|
||||||
|
CreateEmbed::default()
|
||||||
|
.title("Grievous lady [FTR 11]")
|
||||||
|
.thumbnail("attachment://grievous.png")
|
||||||
|
.field("Score", "998302 (+8973)", true)
|
||||||
|
.field("Rating", "12.2 (+.6)", true)
|
||||||
|
.field("Grade", "EX+", true)
|
||||||
|
.field("ζ-Score", "982108 (+347)", true)
|
||||||
|
.field("ζ-Rating", "11.5 (+.45)", true)
|
||||||
|
.field("ζ-Grade", "EX", true)
|
||||||
|
.field("Status", "FR (-243F)", true)
|
||||||
|
.field("Max recall", "308/1073", true)
|
||||||
|
.field("Breakdown", "894/342/243/23", true),
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.channel_id()
|
||||||
|
.send_files(ctx.http(), [icon_attachement], msg)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Create cropper and run OCR
|
||||||
|
let mut cropper = ImageCropper::default();
|
||||||
|
let score_readout = match cropper.read_score(&image) {
|
||||||
|
// {{{ OCR error handling
|
||||||
|
Err(err) => {
|
||||||
|
let error_attachement =
|
||||||
|
CreateAttachment::bytes(cropper.bytes, &file.filename);
|
||||||
|
let msg = CreateMessage::default().embed(
|
||||||
|
CreateEmbed::default()
|
||||||
|
.title("Could not read score from picture")
|
||||||
|
.attachment(&file.filename)
|
||||||
|
.description(format!("{}", err))
|
||||||
|
.author(
|
||||||
|
CreateEmbedAuthor::new(&ctx.author().name)
|
||||||
|
.icon_url(ctx.author().face()),
|
||||||
|
)
|
||||||
|
.timestamp(Timestamp::now()),
|
||||||
|
);
|
||||||
|
ctx.channel_id()
|
||||||
|
.send_files(ctx.http(), [error_attachement], msg)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
Ok(score) => score,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reply with attachement & readout
|
||||||
|
let attachement = CreateAttachment::bytes(cropper.bytes, &file.filename);
|
||||||
|
let reply = CreateReply::default()
|
||||||
|
.attachment(attachement)
|
||||||
|
.content(format!("Score: {:?}", score_readout))
|
||||||
|
.reply(true);
|
||||||
|
ctx.send(reply).await?;
|
||||||
|
|
||||||
|
// Edit progress reply
|
||||||
|
let progress_reply = CreateReply::default()
|
||||||
|
.content(format!("Processing: {}/{} images", i + 1, files.len()))
|
||||||
|
.reply(true);
|
||||||
|
handle.edit(ctx, progress_reply).await?;
|
||||||
|
} else {
|
||||||
|
ctx.reply("One of the attached files is not an image!")
|
||||||
|
.await?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish off progress reply
|
||||||
|
let progress_reply = CreateReply::default()
|
||||||
|
.content(format!("All images have been processed!"))
|
||||||
|
.reply(true);
|
||||||
|
handle.edit(ctx, progress_reply).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
23
src/context.rs
Normal file
23
src/context.rs
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::chart::SongCache;
|
||||||
|
|
||||||
|
// Types used by all command functions
|
||||||
|
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||||
|
pub type Context<'a> = poise::Context<'a, UserContext, Error>;
|
||||||
|
|
||||||
|
// Custom user data passed to all command functions
|
||||||
|
pub struct UserContext {
|
||||||
|
pub db: SqlitePool,
|
||||||
|
pub song_cache: SongCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserContext {
|
||||||
|
#[inline]
|
||||||
|
pub fn new(db: SqlitePool) -> Self {
|
||||||
|
Self {
|
||||||
|
db,
|
||||||
|
song_cache: SongCache::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
86
src/main.rs
Normal file
86
src/main.rs
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
#![warn(clippy::str_to_string)]
|
||||||
|
#![feature(iter_map_windows)]
|
||||||
|
|
||||||
|
mod chart;
|
||||||
|
mod commands;
|
||||||
|
mod context;
|
||||||
|
mod score;
|
||||||
|
mod user;
|
||||||
|
|
||||||
|
use chart::SongCache;
|
||||||
|
use context::{Error, UserContext};
|
||||||
|
use poise::serenity_prelude as serenity;
|
||||||
|
use score::score_to_zeta_score;
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
use std::{env::var, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
// {{{ Error handler
|
||||||
|
async fn on_error(error: poise::FrameworkError<'_, UserContext, Error>) {
|
||||||
|
match error {
|
||||||
|
poise::FrameworkError::Setup { error, .. } => panic!("Failed to start bot: {:?}", error),
|
||||||
|
poise::FrameworkError::Command { error, ctx, .. } => {
|
||||||
|
println!("Error in command `{}`: {:?}", ctx.command().name, error,);
|
||||||
|
}
|
||||||
|
error => {
|
||||||
|
if let Err(e) = poise::builtins::on_error(error).await {
|
||||||
|
println!("Error while handling error: {}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let data_dir = var("SHIMMERING_DATA_DIR")
|
||||||
|
.expect("Missing `SHIMMERING_DATA_DIR` env var, see README for more information.");
|
||||||
|
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.connect(&format!("sqlite://{}/db.sqlite", data_dir))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("{:?}", score_to_zeta_score(9966677, 1303));
|
||||||
|
println!("{:?}", score_to_zeta_score(9970525, 1303));
|
||||||
|
|
||||||
|
// {{{ Poise options
|
||||||
|
let options = poise::FrameworkOptions {
|
||||||
|
commands: vec![commands::help(), commands::score()],
|
||||||
|
prefix_options: poise::PrefixFrameworkOptions {
|
||||||
|
prefix: Some("!".into()),
|
||||||
|
edit_tracker: Some(Arc::new(poise::EditTracker::for_timespan(
|
||||||
|
Duration::from_secs(3600),
|
||||||
|
))),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
// The global error handler for all error cases that may occur
|
||||||
|
on_error: |error| Box::pin(on_error(error)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// }}}
|
||||||
|
// {{{ Start poise
|
||||||
|
let framework = poise::Framework::builder()
|
||||||
|
.setup(move |ctx, _ready, framework| {
|
||||||
|
Box::pin(async move {
|
||||||
|
println!("Logged in as {}", _ready.user.name);
|
||||||
|
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
|
||||||
|
let mut ctx = UserContext::new(pool);
|
||||||
|
ctx.song_cache = SongCache::new(&ctx).await?;
|
||||||
|
Ok(ctx)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.options(options)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let token = var("SHIMMERING_DISCORD_TOKEN")
|
||||||
|
.expect("Missing `SHIMMERING_DISCORD_TOKEN` env var, see README for more information.");
|
||||||
|
let intents =
|
||||||
|
serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT;
|
||||||
|
|
||||||
|
let client = serenity::ClientBuilder::new(token, intents)
|
||||||
|
.framework(framework)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
client.unwrap().start().await.unwrap()
|
||||||
|
// }}}
|
||||||
|
}
|
385
src/score.rs
Normal file
385
src/score.rs
Normal file
|
@ -0,0 +1,385 @@
|
||||||
|
#![allow(dead_code)]
|
||||||
|
use std::{io::Cursor, sync::OnceLock, time::Instant};
|
||||||
|
|
||||||
|
use image::DynamicImage;
|
||||||
|
use num::Rational64;
|
||||||
|
use tesseract::{PageSegMode, Tesseract};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
chart::{Chart, Difficulty},
|
||||||
|
context::{Error, UserContext},
|
||||||
|
user::User,
|
||||||
|
};
|
||||||
|
|
||||||
|
// {{{ ImageDimensions
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ImageDimensions {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageDimensions {
|
||||||
|
#[inline]
|
||||||
|
pub fn new(width: u32, height: u32) -> Self {
|
||||||
|
Self { width, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn aspect_ratio(&self) -> f32 {
|
||||||
|
self.width as f32 / self.height as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn from_image(image: &DynamicImage) -> Self {
|
||||||
|
Self::new(image.width(), image.height())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
// {{{ AbsoluteRect
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct AbsoluteRect {
|
||||||
|
pub x: u32,
|
||||||
|
pub y: u32,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub dimensions: ImageDimensions,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AbsoluteRect {
|
||||||
|
#[inline]
|
||||||
|
pub fn new(x: u32, y: u32, width: u32, height: u32, dimensions: ImageDimensions) -> Self {
|
||||||
|
Self {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
dimensions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn to_relative(&self) -> RelativeRect {
|
||||||
|
RelativeRect::new(
|
||||||
|
self.x as f32 / self.dimensions.width as f32,
|
||||||
|
self.y as f32 / self.dimensions.height as f32,
|
||||||
|
self.width as f32 / self.dimensions.width as f32,
|
||||||
|
self.height as f32 / self.dimensions.height as f32,
|
||||||
|
self.dimensions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
// {{{ RelativeRect
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct RelativeRect {
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
pub width: f32,
|
||||||
|
pub height: f32,
|
||||||
|
pub dimensions: ImageDimensions,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lerp(i: f32, a: f32, b: f32) -> f32 {
|
||||||
|
a + (b - a) * i
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RelativeRect {
|
||||||
|
#[inline]
|
||||||
|
pub fn new(x: f32, y: f32, width: f32, height: f32, dimensions: ImageDimensions) -> Self {
|
||||||
|
Self {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
dimensions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn to_absolute(&self) -> AbsoluteRect {
|
||||||
|
AbsoluteRect::new(
|
||||||
|
(self.x * self.dimensions.width as f32) as u32,
|
||||||
|
(self.y * self.dimensions.height as f32) as u32,
|
||||||
|
(self.width * self.dimensions.width as f32) as u32,
|
||||||
|
(self.height * self.dimensions.height as f32) as u32,
|
||||||
|
self.dimensions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_aspect_ratio(
|
||||||
|
dimensions: ImageDimensions,
|
||||||
|
datapoints: &[RelativeRect],
|
||||||
|
) -> Option<Self> {
|
||||||
|
let aspect_ratio = dimensions.aspect_ratio();
|
||||||
|
|
||||||
|
for i in 0..(datapoints.len() - 1) {
|
||||||
|
let low = datapoints[i];
|
||||||
|
let high = datapoints[i + 1];
|
||||||
|
|
||||||
|
let low_ratio = low.dimensions.aspect_ratio();
|
||||||
|
let high_ratio = high.dimensions.aspect_ratio();
|
||||||
|
|
||||||
|
if (i == 0 || low_ratio <= aspect_ratio)
|
||||||
|
&& (aspect_ratio <= high_ratio || i == datapoints.len() - 2)
|
||||||
|
{
|
||||||
|
let p = (aspect_ratio - low_ratio) / (high_ratio - low_ratio);
|
||||||
|
return Some(Self::new(
|
||||||
|
lerp(p, low.x, high.x),
|
||||||
|
lerp(p, low.y, high.y) - 0.005,
|
||||||
|
lerp(p, low.width, high.width),
|
||||||
|
lerp(p, low.height, high.height) + 2. * 0.005,
|
||||||
|
dimensions,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
// {{{ Data points
|
||||||
|
fn score_rects() -> &'static [RelativeRect] {
|
||||||
|
static CELL: OnceLock<Vec<RelativeRect>> = OnceLock::new();
|
||||||
|
CELL.get_or_init(|| {
|
||||||
|
let mut rects: Vec<RelativeRect> = vec![
|
||||||
|
AbsoluteRect::new(642, 287, 284, 51, ImageDimensions::new(1560, 720)).to_relative(),
|
||||||
|
AbsoluteRect::new(651, 285, 305, 55, ImageDimensions::new(1600, 720)).to_relative(),
|
||||||
|
AbsoluteRect::new(748, 485, 503, 82, ImageDimensions::new(2000, 1200)).to_relative(),
|
||||||
|
AbsoluteRect::new(841, 683, 500, 92, ImageDimensions::new(2160, 1620)).to_relative(),
|
||||||
|
AbsoluteRect::new(851, 707, 532, 91, ImageDimensions::new(2224, 1668)).to_relative(),
|
||||||
|
AbsoluteRect::new(1037, 462, 476, 89, ImageDimensions::new(2532, 1170)).to_relative(),
|
||||||
|
AbsoluteRect::new(973, 653, 620, 105, ImageDimensions::new(2560, 1600)).to_relative(),
|
||||||
|
AbsoluteRect::new(1069, 868, 636, 112, ImageDimensions::new(2732, 2048)).to_relative(),
|
||||||
|
AbsoluteRect::new(1125, 510, 534, 93, ImageDimensions::new(2778, 1284)).to_relative(),
|
||||||
|
];
|
||||||
|
rects.sort_by_key(|r| (r.dimensions.aspect_ratio() * 1000.0).floor() as u32);
|
||||||
|
|
||||||
|
// Filter datapoints that are close together
|
||||||
|
let mut i = 0;
|
||||||
|
while i < rects.len() - 1 {
|
||||||
|
let low = rects[i];
|
||||||
|
let high = rects[i + 1];
|
||||||
|
|
||||||
|
if (low.dimensions.aspect_ratio() - high.dimensions.aspect_ratio()).abs() < 0.001 {
|
||||||
|
// TODO: we could interpolate here but oh well
|
||||||
|
rects.remove(i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
rects
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn difficulty_rects() -> &'static [RelativeRect] {
|
||||||
|
static CELL: OnceLock<Vec<RelativeRect>> = OnceLock::new();
|
||||||
|
CELL.get_or_init(|| {
|
||||||
|
let mut rects: Vec<RelativeRect> = vec![
|
||||||
|
AbsoluteRect::new(642, 287, 284, 51, ImageDimensions::new(1560, 720)).to_relative(),
|
||||||
|
AbsoluteRect::new(651, 285, 305, 55, ImageDimensions::new(1600, 720)).to_relative(),
|
||||||
|
AbsoluteRect::new(748, 485, 503, 82, ImageDimensions::new(2000, 1200)).to_relative(),
|
||||||
|
AbsoluteRect::new(841, 683, 500, 92, ImageDimensions::new(2160, 1620)).to_relative(),
|
||||||
|
AbsoluteRect::new(851, 707, 532, 91, ImageDimensions::new(2224, 1668)).to_relative(),
|
||||||
|
AbsoluteRect::new(1037, 462, 476, 89, ImageDimensions::new(2532, 1170)).to_relative(),
|
||||||
|
AbsoluteRect::new(973, 653, 620, 105, ImageDimensions::new(2560, 1600)).to_relative(),
|
||||||
|
AbsoluteRect::new(1069, 868, 636, 112, ImageDimensions::new(2732, 2048)).to_relative(),
|
||||||
|
AbsoluteRect::new(1125, 510, 534, 93, ImageDimensions::new(2778, 1284)).to_relative(),
|
||||||
|
];
|
||||||
|
rects.sort_by_key(|r| (r.dimensions.aspect_ratio() * 1000.0).floor() as u32);
|
||||||
|
rects
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
// {{{ Plays
|
||||||
|
/// Returns the zeta score and the number of shinies
|
||||||
|
pub fn score_to_zeta_score(score: u32, note_count: u32) -> (u32, u32) {
|
||||||
|
// Smallest possible difference between (zeta-)scores
|
||||||
|
let increment = Rational64::new_raw(5000000, note_count as i64).reduced();
|
||||||
|
let zeta_increment = Rational64::new_raw(2000000, note_count as i64).reduced();
|
||||||
|
|
||||||
|
let score = Rational64::from_integer(score 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 = (zeta_increment * zeta_score_units).floor().to_integer() as u32;
|
||||||
|
|
||||||
|
(zeta_score, shinies.to_integer() as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
// {{{ Create play
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CreatePlay {
|
||||||
|
chart_id: u32,
|
||||||
|
user_id: u32,
|
||||||
|
discord_attachment_id: Option<String>,
|
||||||
|
|
||||||
|
// Actual score data
|
||||||
|
score: u32,
|
||||||
|
zeta_score: Option<u32>,
|
||||||
|
|
||||||
|
// 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: u32, chart: Chart, user: User) -> Self {
|
||||||
|
Self {
|
||||||
|
chart_id: chart.id,
|
||||||
|
user_id: user.id,
|
||||||
|
discord_attachment_id: None,
|
||||||
|
score,
|
||||||
|
zeta_score: Some(score_to_zeta_score(score, chart.note_count).0),
|
||||||
|
max_recall: None,
|
||||||
|
far_notes: None,
|
||||||
|
// TODO: populate these
|
||||||
|
creation_ptt: None,
|
||||||
|
creation_zeta_ptt: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save(self, ctx: &UserContext) -> Result<Play, Error> {
|
||||||
|
let play = sqlx::query_as!(
|
||||||
|
Play,
|
||||||
|
"
|
||||||
|
INSERT INTO plays(
|
||||||
|
user_id,chart_id,discord_attachment_id,
|
||||||
|
score,zeta_score,max_recall,far_notes
|
||||||
|
)
|
||||||
|
VALUES(?,?,?,?,?,?,?)
|
||||||
|
RETURNING *
|
||||||
|
",
|
||||||
|
self.user_id,
|
||||||
|
self.chart_id,
|
||||||
|
self.discord_attachment_id,
|
||||||
|
self.score,
|
||||||
|
self.zeta_score,
|
||||||
|
self.max_recall,
|
||||||
|
self.far_notes
|
||||||
|
)
|
||||||
|
.fetch_one(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(play)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
// {{{ Play
|
||||||
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||||
|
pub struct Play {
|
||||||
|
id: i64,
|
||||||
|
chart_id: i64,
|
||||||
|
user_id: i64,
|
||||||
|
discord_attachment_id: Option<String>,
|
||||||
|
|
||||||
|
// Actual score data
|
||||||
|
score: i64,
|
||||||
|
zeta_score: Option<i64>,
|
||||||
|
|
||||||
|
// Optional score details
|
||||||
|
max_recall: Option<i64>,
|
||||||
|
far_notes: Option<i64>,
|
||||||
|
|
||||||
|
// Creation data
|
||||||
|
created_at: chrono::NaiveDateTime,
|
||||||
|
creation_ptt: Option<i64>,
|
||||||
|
creation_zeta_ptt: Option<i64>,
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
// {{{ 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 = 10000000 + shiny_count;
|
||||||
|
let zeta_score_units = 4 * (note_count - shiny_count) + 5 * shiny_count;
|
||||||
|
let expected_zeta_score = Rational64::from_integer(zeta_score_units as i64)
|
||||||
|
* Rational64::new_raw(2000000, note_count as i64).reduced();
|
||||||
|
let (zeta_score, computed_shiny_count) = score_to_zeta_score(score, note_count);
|
||||||
|
assert_eq!(zeta_score, expected_zeta_score.to_integer() as u32);
|
||||||
|
assert_eq!(computed_shiny_count, shiny_count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
// }}}
|
||||||
|
// {{{ Ocr types
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ScoreReadout {
|
||||||
|
pub score: u32,
|
||||||
|
pub difficulty: Difficulty,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScoreReadout {
|
||||||
|
pub fn new(score: u32, difficulty: Difficulty) -> Self {
|
||||||
|
Self { score, difficulty }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
// {{{ Run OCR
|
||||||
|
/// Caches a byte vector in order to prevent reallocation
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ImageCropper {
|
||||||
|
/// cached byte array
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageCropper {
|
||||||
|
fn crop_image_to_bytes(
|
||||||
|
&mut self,
|
||||||
|
image: &DynamicImage,
|
||||||
|
rect: AbsoluteRect,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
self.bytes.clear();
|
||||||
|
let image = image.crop_imm(rect.x, rect.y, rect.width, rect.height);
|
||||||
|
let mut cursor = Cursor::new(&mut self.bytes);
|
||||||
|
image.write_to(&mut cursor, image::ImageFormat::Png)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_score(&mut self, image: &DynamicImage) -> Result<ScoreReadout, Error> {
|
||||||
|
let rect =
|
||||||
|
RelativeRect::from_aspect_ratio(ImageDimensions::from_image(image), score_rects())
|
||||||
|
.ok_or_else(|| "Could not find score area in picture")?
|
||||||
|
.to_absolute();
|
||||||
|
self.crop_image_to_bytes(&image, rect)?;
|
||||||
|
|
||||||
|
let mut t = Tesseract::new(None, Some("eng"))?
|
||||||
|
// .set_variable("classify_bln_numeric_mode", "1'")?
|
||||||
|
.set_variable("tessedit_char_whitelist", "0123456789'")?
|
||||||
|
.set_image_from_mem(&self.bytes)?;
|
||||||
|
|
||||||
|
t.set_page_seg_mode(PageSegMode::PsmRawLine);
|
||||||
|
t = t.recognize()?;
|
||||||
|
|
||||||
|
if t.mean_text_conf() < 10 {
|
||||||
|
Err("Score text is not readable.")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text: String = t
|
||||||
|
.get_text()?
|
||||||
|
.trim()
|
||||||
|
.chars()
|
||||||
|
.filter(|char| *char != ' ' && *char != '\'')
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let int = u32::from_str_radix(&text, 10)?;
|
||||||
|
Ok(ScoreReadout::new(int, Difficulty::FTR))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// }}}
|
20
src/user.rs
Normal file
20
src/user.rs
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
use crate::context::{Context, Error};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: u32,
|
||||||
|
pub discord_id: String,
|
||||||
|
pub nickname: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl User {
|
||||||
|
pub async fn from_context(ctx: &Context<'_>) -> Result<Self, Error> {
|
||||||
|
let id = ctx.author().id.get().to_string();
|
||||||
|
let user = sqlx::query_as("SELECT * FROM users WHERE discord_id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&ctx.data().db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue