Score correction
Signed-off-by: prescientmoon <git@moonythm.dev>
This commit is contained in:
parent
c77f4fdc1d
commit
b2e88e703b
|
@ -1,7 +1,7 @@
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
|
|
||||||
use crate::context::{Context, Error};
|
use crate::context::{Context, Error};
|
||||||
use crate::score::{jacket_rects, CreatePlay, ImageCropper, ImageDimensions, RelativeRect};
|
use crate::score::{jacket_rects, CreatePlay, ImageCropper, ImageDimensions, RelativeRect, Score};
|
||||||
use crate::user::User;
|
use crate::user::User;
|
||||||
use image::imageops::FilterType;
|
use image::imageops::FilterType;
|
||||||
use image::ImageFormat;
|
use image::ImageFormat;
|
||||||
|
@ -293,33 +293,41 @@ Title error: {}
|
||||||
.content(format!("Image {}: reading score", i + 1));
|
.content(format!("Image {}: reading score", i + 1));
|
||||||
handle.edit(ctx, edited).await?;
|
handle.edit(ctx, edited).await?;
|
||||||
|
|
||||||
let score = match cropper.read_score(Some(chart.note_count), &ocr_image) {
|
let score_possibilities =
|
||||||
// {{{ OCR error handling
|
match cropper.read_score(Some(chart.note_count), &ocr_image) {
|
||||||
Err(err) => {
|
// {{{ OCR error handling
|
||||||
error_with_image(
|
Err(err) => {
|
||||||
ctx,
|
error_with_image(
|
||||||
&cropper.bytes,
|
ctx,
|
||||||
&file.filename,
|
&cropper.bytes,
|
||||||
"Could not read score from picture",
|
&file.filename,
|
||||||
&err,
|
"Could not read score from picture",
|
||||||
)
|
&err,
|
||||||
.await?;
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
Ok(score) => score,
|
Ok(scores) => scores,
|
||||||
};
|
};
|
||||||
|
|
||||||
// {{{ Build play
|
// {{{ Build play
|
||||||
|
let (score, maybe_fars, score_warning) =
|
||||||
|
Score::resolve_ambiguities(score_possibilities, None, chart.note_count)?;
|
||||||
let play = CreatePlay::new(score, chart, &user)
|
let play = CreatePlay::new(score, chart, &user)
|
||||||
.with_attachment(file)
|
.with_attachment(file)
|
||||||
|
.with_fars(maybe_fars)
|
||||||
.save(&ctx.data())
|
.save(&ctx.data())
|
||||||
.await?;
|
.await?;
|
||||||
// }}}
|
// }}}
|
||||||
// }}}
|
// }}}
|
||||||
// {{{ Deliver embed
|
// {{{ Deliver embed
|
||||||
let (embed, attachment) = play.to_embed(&song, &chart, i).await?;
|
let (mut embed, attachment) = play.to_embed(&song, &chart, i).await?;
|
||||||
|
if let Some(warning) = score_warning {
|
||||||
|
embed = embed.description(warning);
|
||||||
|
}
|
||||||
|
|
||||||
embeds.push(embed);
|
embeds.push(embed);
|
||||||
if let Some(attachment) = attachment {
|
if let Some(attachment) = attachment {
|
||||||
attachments.push(attachment);
|
attachments.push(attachment);
|
||||||
|
|
11
src/main.rs
11
src/main.rs
|
@ -9,18 +9,15 @@ mod jacket;
|
||||||
mod score;
|
mod score;
|
||||||
mod user;
|
mod user;
|
||||||
|
|
||||||
|
use chart::Difficulty;
|
||||||
use context::{Error, UserContext};
|
use context::{Error, UserContext};
|
||||||
use poise::serenity_prelude::{self as serenity, UserId};
|
use poise::serenity_prelude::{self as serenity};
|
||||||
use sqlx::sqlite::SqlitePoolOptions;
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
use std::{env::var, path::PathBuf, str::FromStr, sync::Arc, time::Duration};
|
use std::{env::var, path::PathBuf, str::FromStr, sync::Arc, time::Duration};
|
||||||
|
|
||||||
// {{{ Error handler
|
// {{{ Error handler
|
||||||
async fn on_error(error: poise::FrameworkError<'_, UserContext, Error>) {
|
async fn on_error(error: poise::FrameworkError<'_, UserContext, Error>) {
|
||||||
match 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 => {
|
error => {
|
||||||
if let Err(e) = poise::builtins::on_error(error).await {
|
if let Err(e) = poise::builtins::on_error(error).await {
|
||||||
println!("Error while handling error: {}", e)
|
println!("Error while handling error: {}", e)
|
||||||
|
@ -76,6 +73,10 @@ async fn main() {
|
||||||
println!("Logged in as {}", _ready.user.name);
|
println!("Logged in as {}", _ready.user.name);
|
||||||
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
|
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
|
||||||
let ctx = UserContext::new(PathBuf::from_str(&data_dir)?, pool).await?;
|
let ctx = UserContext::new(PathBuf::from_str(&data_dir)?, pool).await?;
|
||||||
|
|
||||||
|
// for song in ctx.song_cache.lock().unwrap().songs() {
|
||||||
|
// song.lookup(Difficulty::BYD)
|
||||||
|
// }
|
||||||
Ok(ctx)
|
Ok(ctx)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
200
src/score.rs
200
src/score.rs
|
@ -266,7 +266,7 @@ pub fn jacket_rects() -> &'static [RelativeRect] {
|
||||||
// }}}
|
// }}}
|
||||||
// }}}
|
// }}}
|
||||||
// {{{ Score
|
// {{{ Score
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct Score(pub u32);
|
pub struct Score(pub u32);
|
||||||
|
|
||||||
impl Score {
|
impl Score {
|
||||||
|
@ -288,13 +288,41 @@ impl Score {
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
|
|
||||||
|
#[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.
|
/// 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
|
/// Pure (and higher) notes reward two score units, far notes reward one, and lost notes reward
|
||||||
/// none.
|
/// none.
|
||||||
pub fn analyse(self, note_count: u32) -> (Score, u32, u32) {
|
pub fn analyse(self, note_count: u32) -> (Score, u32, u32) {
|
||||||
// Smallest possible difference between (zeta-)scores
|
// Smallest possible difference between (zeta-)scores
|
||||||
let increment = Rational64::new_raw(5_000_000, note_count as i64).reduced();
|
let increment = Self::increment(note_count);
|
||||||
let zeta_increment = Rational64::new_raw(2_000_000, note_count as i64).reduced();
|
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 = Rational64::from_integer(self.0 as i64);
|
||||||
|
@ -348,6 +376,151 @@ impl Score {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
|
// {{{ 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 - losts - fars, fars);
|
||||||
|
let lp_score = Score::compute_naive(note_count, pures, note_count - losts - pures);
|
||||||
|
|
||||||
|
// {{{ Score is fixed, gotta figure out the exact distribution
|
||||||
|
if no_shiny_scores.len() == 1 {
|
||||||
|
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() == 0 {
|
||||||
|
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!")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// }}}
|
||||||
|
// {{{ Score is not fixed, gotta figure out everything at once
|
||||||
|
} else {
|
||||||
|
// 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
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((agreement.into_iter().max().unwrap(), Some(consensus_fars),
|
||||||
|
Some("Due to a reading error, I could not make sure the shiny-amount I calculated is accurate!")))
|
||||||
|
|
||||||
|
// }}}
|
||||||
|
// {{{ 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 {
|
impl Display for Score {
|
||||||
|
@ -407,6 +580,12 @@ impl CreatePlay {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn with_fars(mut self, far_count: Option<u32>) -> Self {
|
||||||
|
self.far_notes = far_count;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
// {{{ Save
|
// {{{ Save
|
||||||
pub async fn save(self, ctx: &UserContext) -> Result<Play, Error> {
|
pub async fn save(self, ctx: &UserContext) -> Result<Play, Error> {
|
||||||
let attachment_id = self.discord_attachment_id.map(|i| i.get() as i64);
|
let attachment_id = self.discord_attachment_id.map(|i| i.get() as i64);
|
||||||
|
@ -628,7 +807,7 @@ impl ImageCropper {
|
||||||
&mut self,
|
&mut self,
|
||||||
note_count: Option<u32>,
|
note_count: Option<u32>,
|
||||||
image: &DynamicImage,
|
image: &DynamicImage,
|
||||||
) -> Result<Score, Error> {
|
) -> Result<Vec<Score>, Error> {
|
||||||
self.crop_image_to_bytes(
|
self.crop_image_to_bytes(
|
||||||
&image.resize_exact(image.width(), image.height(), FilterType::Nearest),
|
&image.resize_exact(image.width(), image.height(), FilterType::Nearest),
|
||||||
RelativeRect::from_aspect_ratio(ImageDimensions::from_image(image), score_rects())
|
RelativeRect::from_aspect_ratio(ImageDimensions::from_image(image), score_rects())
|
||||||
|
@ -659,8 +838,6 @@ impl ImageCropper {
|
||||||
// The OCR sometimes fails to read "74" with the arcaea font,
|
// The OCR sometimes fails to read "74" with the arcaea font,
|
||||||
// so we try to detect that and fix it
|
// so we try to detect that and fix it
|
||||||
loop {
|
loop {
|
||||||
println!("Attempts: {:?}.", results);
|
|
||||||
|
|
||||||
let old_stack_len = results.len();
|
let old_stack_len = results.len();
|
||||||
results = results
|
results = results
|
||||||
.iter()
|
.iter()
|
||||||
|
@ -690,7 +867,7 @@ impl ImageCropper {
|
||||||
// }}}
|
// }}}
|
||||||
// {{{ Return score if consensus exists
|
// {{{ Return score if consensus exists
|
||||||
// 1. Discard scores that are known to be impossible
|
// 1. Discard scores that are known to be impossible
|
||||||
results = results
|
let mut results: Vec<_> = results
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|result| {
|
.filter(|result| {
|
||||||
8_000_000 <= *result
|
8_000_000 <= *result
|
||||||
|
@ -704,24 +881,22 @@ impl ImageCropper {
|
||||||
})
|
})
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
})
|
})
|
||||||
|
.map(|r| Score(r))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// 2. Look for consensus
|
// 2. Look for consensus
|
||||||
for result in results.iter() {
|
for result in results.iter() {
|
||||||
if results.iter().filter(|e| **e == *result).count() > results.len() / 2 {
|
if results.iter().filter(|e| **e == *result).count() > results.len() / 2 {
|
||||||
return Ok(Score(*result));
|
return Ok(vec![*result]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
|
|
||||||
|
// If there's no consensus, we return everything
|
||||||
results.sort();
|
results.sort();
|
||||||
results.dedup();
|
results.dedup();
|
||||||
|
|
||||||
Err(format!(
|
Ok(results)
|
||||||
"Cannot read score. Possible values: {:?}.",
|
|
||||||
results
|
|
||||||
))?;
|
|
||||||
unreachable!()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_score_with_mode(&mut self, mode: PageSegMode, whitelist: &str) -> Result<Score, Error> {
|
fn read_score_with_mode(&mut self, mode: PageSegMode, whitelist: &str) -> Result<Score, Error> {
|
||||||
|
@ -744,7 +919,6 @@ impl ImageCropper {
|
||||||
|
|
||||||
let text: String = t.get_text()?.trim().to_string();
|
let text: String = t.get_text()?.trim().to_string();
|
||||||
|
|
||||||
println!("Got {}", text);
|
|
||||||
let text: String = text
|
let text: String = text
|
||||||
.chars()
|
.chars()
|
||||||
.map(|char| if char == '/' { '7' } else { char })
|
.map(|char| if char == '/' { '7' } else { char })
|
||||||
|
|
Loading…
Reference in a new issue