diff --git a/src/commands.rs b/src/commands.rs index d25ff3f..aeb9ade 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,7 +1,7 @@ use std::fmt::Display; 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 image::imageops::FilterType; use image::ImageFormat; @@ -293,33 +293,41 @@ Title error: {} .content(format!("Image {}: reading score", i + 1)); handle.edit(ctx, edited).await?; - let score = match cropper.read_score(Some(chart.note_count), &ocr_image) { - // {{{ OCR error handling - Err(err) => { - error_with_image( - ctx, - &cropper.bytes, - &file.filename, - "Could not read score from picture", - &err, - ) - .await?; + let score_possibilities = + match cropper.read_score(Some(chart.note_count), &ocr_image) { + // {{{ OCR error handling + Err(err) => { + error_with_image( + ctx, + &cropper.bytes, + &file.filename, + "Could not read score from picture", + &err, + ) + .await?; - continue; - } - // }}} - Ok(score) => score, - }; + continue; + } + // }}} + Ok(scores) => scores, + }; // {{{ Build play + let (score, maybe_fars, score_warning) = + Score::resolve_ambiguities(score_possibilities, None, chart.note_count)?; let play = CreatePlay::new(score, chart, &user) .with_attachment(file) + .with_fars(maybe_fars) .save(&ctx.data()) .await?; // }}} // }}} // {{{ 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); if let Some(attachment) = attachment { attachments.push(attachment); diff --git a/src/main.rs b/src/main.rs index b9bce5c..9c46cf1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,18 +9,15 @@ mod jacket; mod score; mod user; +use chart::Difficulty; use context::{Error, UserContext}; -use poise::serenity_prelude::{self as serenity, UserId}; +use poise::serenity_prelude::{self as serenity}; use sqlx::sqlite::SqlitePoolOptions; use std::{env::var, path::PathBuf, str::FromStr, 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) @@ -76,6 +73,10 @@ async fn main() { println!("Logged in as {}", _ready.user.name); poise::builtins::register_globally(ctx, &framework.options().commands).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) }) }) diff --git a/src/score.rs b/src/score.rs index fdfde40..2ccf0d4 100644 --- a/src/score.rs +++ b/src/score.rs @@ -266,7 +266,7 @@ pub fn jacket_rects() -> &'static [RelativeRect] { // }}} // }}} // {{{ Score -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct Score(pub u32); 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. /// /// 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 = 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 score = Rational64::from_integer(self.0 as i64); @@ -348,6 +376,151 @@ impl Score { } } // }}} + // {{{ Scores & Distribution => score + pub fn resolve_ambiguities( + scores: Vec, + read_distribution: Option<(u32, u32, u32)>, + note_count: u32, + ) -> Result<(Score, Option, 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 { @@ -407,6 +580,12 @@ impl CreatePlay { self } + #[inline] + pub fn with_fars(mut self, far_count: Option) -> Self { + self.far_notes = far_count; + self + } + // {{{ Save pub async fn save(self, ctx: &UserContext) -> Result { let attachment_id = self.discord_attachment_id.map(|i| i.get() as i64); @@ -628,7 +807,7 @@ impl ImageCropper { &mut self, note_count: Option, image: &DynamicImage, - ) -> Result { + ) -> Result, Error> { self.crop_image_to_bytes( &image.resize_exact(image.width(), image.height(), FilterType::Nearest), 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, // so we try to detect that and fix it loop { - println!("Attempts: {:?}.", results); - let old_stack_len = results.len(); results = results .iter() @@ -690,7 +867,7 @@ impl ImageCropper { // }}} // {{{ Return score if consensus exists // 1. Discard scores that are known to be impossible - results = results + let mut results: Vec<_> = results .into_iter() .filter(|result| { 8_000_000 <= *result @@ -704,24 +881,22 @@ impl ImageCropper { }) .unwrap_or(true) }) + .map(|r| Score(r)) .collect(); // 2. Look for consensus for result in results.iter() { 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.dedup(); - Err(format!( - "Cannot read score. Possible values: {:?}.", - results - ))?; - unreachable!() + Ok(results) } fn read_score_with_mode(&mut self, mode: PageSegMode, whitelist: &str) -> Result { @@ -744,7 +919,6 @@ impl ImageCropper { let text: String = t.get_text()?.trim().to_string(); - println!("Got {}", text); let text: String = text .chars() .map(|char| if char == '/' { '7' } else { char })