1
Fork 0
shimmeringmoon/src/recognition/recognize.rs

347 lines
8.4 KiB
Rust

// {{{ Imports
use std::fmt::Display;
use anyhow::{anyhow, bail};
use image::imageops::FilterType;
use image::{DynamicImage, GenericImageView};
use num::integer::Roots;
use poise::serenity_prelude::{CreateAttachment, CreateEmbed};
use poise::CreateReply;
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::commands::discord::MessageContext;
use crate::context::{Error, UserContext};
use crate::levenshtein::edit_distance;
use crate::logs::debug_image_log;
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
#[inline]
pub fn crop(&mut self, image: &DynamicImage, rect: Rect) -> DynamicImage {
image.crop_imm(rect.x as u32, rect.y as u32, rect.width, rect.height)
}
#[inline]
pub fn interp_crop(
&mut self,
ctx: &UserContext,
image: &DynamicImage,
ui_rect: UIMeasurementRect,
) -> Result<DynamicImage, Error> {
let rect = ctx.ui_measurements.interpolate(ui_rect, image)?;
self.last_rect = Some((ui_rect, rect));
let result = self.crop(image, rect);
debug_image_log(&result);
Ok(result)
}
#[inline]
pub fn interp_crop_resize(
&mut self,
ctx: &UserContext,
image: &DynamicImage,
ui_rect: UIMeasurementRect,
size: (u32, u32),
) -> Result<DynamicImage, Error> {
let rect = ctx.ui_measurements.interpolate(ui_rect, image)?;
self.last_rect = Some((ui_rect, rect));
let result = self.crop(image, rect);
let result = result.resize(size.0, size.1, FilterType::Nearest);
debug_image_log(&result);
Ok(result)
}
// }}}
// {{{ Error handling
pub async fn send_discord_error(
&mut self,
ctx: &mut impl MessageContext,
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, 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:?}"
));
ctx.send(
CreateReply::default()
.embed(embed)
.attachment(error_attachement),
)
.await?;
} else {
embed = embed.title("An error occurred");
ctx.send(CreateReply::default().embed(embed)).await?;
}
Ok(())
}
// }}}
// {{{ Read score
pub fn read_score(
&mut self,
ctx: &UserContext,
note_count: Option<u32>,
image: &DynamicImage,
kind: ScoreKind,
) -> Result<Score, Error> {
let image = self.interp_crop(
ctx,
image,
match kind {
ScoreKind::SongSelect => SongSelect(SongSelectRect::Score),
ScoreKind::ScoreScreen => ScoreScreen(ScoreScreenRect::Score),
},
)?;
let measurements = match kind {
ScoreKind::SongSelect => &ctx.exo_measurements,
ScoreKind::ScoreScreen => &ctx.geosans_measurements,
};
let result = Score(
measurements
.recognise(&image, "0123456789'", None, None)?
.chars()
.filter(|c| *c != '\'')
.collect::<String>()
.parse()?,
);
// Discard scores if it's impossible
let valid_analysis = note_count.is_none_or(|note_count| {
let (zeta, shinies, score_units) = result.analyse(note_count);
8_000_000 <= zeta.0
&& zeta.0 <= 10_000_000
&& shinies <= note_count
&& score_units <= 2 * note_count
});
if result.0 <= 10_010_000 && valid_analysis {
Ok(result)
} else {
Err(anyhow!("Score {result} is not vaild"))
}
}
// }}}
// {{{ Read difficulty
pub fn read_difficulty(
&mut self,
ctx: &UserContext,
image: &DynamicImage,
grayscale_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();
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 image = self.interp_crop(
ctx,
grayscale_image,
ScoreScreen(ScoreScreenRect::Difficulty),
)?;
let text = ctx.kazesawa_bold_measurements.recognise(
&image,
"PASTPRESENTFUTUREETERNALBEYOND",
Some(200), // We can afford to be generous with binarization here
None,
)?;
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(|| anyhow!("Unrecognised difficulty '{}'", text))?;
Ok(difficulty)
}
// }}}
// {{{ Read score kind
pub fn read_score_kind(
&mut self,
ctx: &UserContext,
image: &DynamicImage,
) -> Result<ScoreKind, Error> {
let image = self.interp_crop(ctx, image, PlayKind)?;
let text = ctx
.kazesawa_measurements
.recognise(&image, "ResultSelectaSong ", None, None)?;
let result = if edit_distance(&text, "Result") < edit_distance(&text, "SelectaSong") {
ScoreKind::ScoreScreen
} else {
ScoreKind::SongSelect
};
Ok(result)
}
// }}}
// {{{ Read jacket
pub 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(|| anyhow!("Could not recognise jacket"))?;
if distance > (IMAGE_VEC_DIM * 3) as f32 {
bail!("No known jacket looks like this");
}
let (song, chart) = ctx.song_cache.lookup_by_difficulty(song_id, difficulty)?;
Ok((song, chart))
}
// }}}
// {{{ Read distribution
pub fn read_distribution(
&mut self,
ctx: &UserContext,
image: &DynamicImage,
) -> Result<(u32, u32, u32), Error> {
let mut out = [0; 3];
use ScoreScreenRect::*;
static KINDS: [ScoreScreenRect; 3] = [Pure, Far, Lost];
for i in 0..3 {
let image = self.interp_crop(ctx, image, ScoreScreen(KINDS[i]))?;
out[i] = ctx
.kazesawa_bold_measurements
// We need to be very strict with binarization here
.recognise(&image, "0123456789", Some(30), Some((0.33, 0.85)))?
.parse()
.unwrap_or(100000); // This will get discarded as making no sense
}
println!("Ditribution {out:?}");
Ok((out[0], out[1], out[2]))
}
// }}}
// {{{ Read max recall
pub fn read_max_recall(
&mut self,
ctx: &UserContext,
image: &DynamicImage,
) -> Result<u32, Error> {
let image = self.interp_crop(ctx, image, ScoreScreen(ScoreScreenRect::MaxRecall))?;
let max_recall = ctx
.exo_measurements
// We can afford to be generous with binarization here
.recognise(&image, "0123456789", Some(200), None)?
.parse()?;
Ok(max_recall)
}
// }}}
}