Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ namespace PokemonFRLG{

BattlePokemonDetector::BattlePokemonDetector(Color color)
// be warned: the name/level/hp box moves up and down a little bit
: m_left_box(0.577404, 0.504327, 0.001442, 0.121875) // off-white (255, 255, 236), can be interrupted by a status condition
, m_right_box(0.948558, 0.585096, 0.000481, 0.063462) // dark teal (74, 111, 102)
: m_left_box(0.577404, 0.504327, 0.001442, 0.121875) // off-white rgb(255, 255, 235), can be interrupted by a status condition
, m_right_box(0.948558, 0.585096, 0.000481, 0.063462) // dark teal rgb(72, 106, 98)
, m_top_box(0.594712, 0.481971, 0.325962, 0.002163) // off-white, movement makes this unreliable
, m_bottom_box(0.554808, 0.674519, 0.034615, 0.002163) // dark teal
, m_background_box(0.552564, 0.583653, 0.414103, 0.009615) // depends on in-game location. white or rgb(215, 169, 96)
{}
void BattlePokemonDetector::make_overlays(VideoOverlaySet& items) const{
const BoxOption& GAME_BOX = GameSettings::instance().GAME_BOX;
Expand All @@ -44,21 +45,25 @@ bool BattlePokemonDetector::detect(const ImageViewRGB32& screen){
ImageViewRGB32 right_image = extract_box_reference(game_screen, m_right_box);
ImageViewRGB32 top_image = extract_box_reference(game_screen, m_top_box);
ImageViewRGB32 bottom_image = extract_box_reference(game_screen, m_bottom_box);
if (is_solid(left_image, { 0.3418, 0.3418, 0.3164 })
&& is_solid(right_image, { 0.2578, 0.3868, 0.3554 }, 0.075, 5)
&& is_solid(top_image, { 0.3418, 0.3418, 0.3164 })
&& is_solid(bottom_image, { 0.2578, 0.3868, 0.3554 }, 0.075, 5)
ImageViewRGB32 background_image = extract_box_reference(game_screen, m_background_box);

if (is_grey(left_image, 680, 770, 20)
&& is_grey(right_image, 220, 320, 20)
&& is_grey(top_image, 680, 770, 20)
&& is_grey(bottom_image, 220, 320, 20)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you mention struggling here, can you just add more detection locations?

@theastrogoth theastrogoth Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't think of any good ones, unless we want to detect the sprites. Battle backgrounds change by location, so we're stuck with the Name/Level/HP bar area, I think

@Mysticial Mysticial Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you tried negative locations? Since you know what the common false positives are, add boxes that will detect those false positives and require that they don't pass for this detection.

The Home screen detector as well as the BDSP dialog detector uses some negative detections to filter out some backgrounds.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added! I was a little hesitant to do this in case there were many background colors to handle depending on in-game location, but as far as I can tell, there are only two (brown in caves, white elsewhere)

&& !(is_white(background_image) || is_solid(background_image, {0.4468, 0.3528, 0.2004}, 0.25, 20))
){
return true;
}
return false;
}

BattleOpponentDetector::BattleOpponentDetector(Color color)
: m_left_box(0.067308, 0.140865, 0.001442, 0.090144) // off-white (255, 255, 236)
: m_left_box(0.067308, 0.140865, 0.001442, 0.090144) // off-white rgb(255, 255, 235)
, m_right_box(0.422596, 0.132211, 0.000481, 0.054808) // off-white
, m_top_box(0.085577, 0.119952, 0.329327, 0.000721) // off-white
, m_bottom_box(0.109615, 0.264182, 0.328365, 0.001442) // dark teal (74, 111, 102)
, m_bottom_box(0.109615, 0.264182, 0.328365, 0.001442) // dark teal rgb(72, 106, 98)
, m_background_box(0.050641, 0.126923, 0.388462, 0.063462) // depends on in-game location. white or rgb(187, 149, 65)
{}
void BattleOpponentDetector::make_overlays(VideoOverlaySet& items) const{
const BoxOption& GAME_BOX = GameSettings::instance().GAME_BOX;
Expand All @@ -74,11 +79,13 @@ bool BattleOpponentDetector::detect(const ImageViewRGB32& screen){
ImageViewRGB32 right_image = extract_box_reference(game_screen, m_right_box);
ImageViewRGB32 top_image = extract_box_reference(game_screen, m_top_box);
ImageViewRGB32 bottom_image = extract_box_reference(game_screen, m_bottom_box);
ImageViewRGB32 background_image = extract_box_reference(game_screen, m_background_box);

if (is_solid(left_image, { 0.3418, 0.3418, 0.3164 })
&& is_solid(right_image, { 0.3418, 0.3418, 0.3164 })
&& is_solid(top_image, { 0.3418, 0.3418, 0.3164 })
&& is_solid(bottom_image, { 0.2578, 0.3868, 0.3554 }, 0.075, 5)
if (is_grey(left_image, 680, 770, 20)
&& is_grey(right_image, 680, 770, 20)
&& is_grey(top_image, 680, 770, 20)
&& is_grey(bottom_image, 220, 320, 20)
&& !(is_white(background_image) || is_solid(background_image, {0.4663, 0.3716, 0.1621}, 0.25, 20))
){
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class BattlePokemonDetector : public StaticScreenDetector{
ImageFloatBox m_right_box;
ImageFloatBox m_top_box;
ImageFloatBox m_bottom_box;
ImageFloatBox m_background_box;
};

// Watches for the player's Pokemon to disappear
Expand All @@ -56,6 +57,7 @@ class BattleOpponentDetector : public StaticScreenDetector{
ImageFloatBox m_right_box;
ImageFloatBox m_top_box;
ImageFloatBox m_bottom_box;
ImageFloatBox m_background_box;
};

// Watches for the opponent to disappear
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,38 +88,58 @@ void StatsReader::read_page1(

ImageViewRGB32 name_box = extract_box_reference(game_screen, jpn ? m_box_name_jpn : m_box_name);

// remove shadow
ImageRGB32 name_filtered = filter_rgb32_range(
name_box, 0xff000000, 0xffc7c7c7, Color(0xffc3c3c3), true
);
// make text black
name_filtered = filter_rgb32_range(
name_filtered, 0xffc8c8c8, 0xffffffff, Color(0xff000000), true
);

ImageRGB32 name_ready = preprocess_for_ocr(
name_filtered, "name", 7, 2, true,
combine_rgb(0, 0, 0), jpn ? combine_rgb(160, 160, 160) : combine_rgb(120, 120, 120)
);

const std::vector<OCR::TextColorRange> name_text_color_ranges{
{combine_rgb(0, 0, 0), combine_rgb(120, 120, 120)}
};

if (subset.size() > 0){
auto name_result = Pokemon::PokemonNameReader(subset).read_substring(
logger, language, name_ready, name_text_color_ranges
);
if (!name_result.results.empty()){
stats.name = name_result.results.begin()->second.token;
static const std::vector<int> WHITE_THRESHOLDS = { 180, 200, 220, 230, 240 };
OCR::StringMatchResult best_result;
bool initialized = false;
for (int thresh : WHITE_THRESHOLDS){
ImageRGB32 name_filtered(name_box.width(), name_box.height());
for (size_t r = 0; r < name_box.height(); r++){
for (size_t c = 0; c < name_box.width(); c++){
Color pixel(name_box.pixel(c, r));
if (pixel.red() > thresh && pixel.green() > thresh && pixel.blue() > thresh){
name_filtered.pixel(c, r) = (uint32_t)0xff000000; // Black
}else{
name_filtered.pixel(c, r) = (uint32_t)0xffffffff; // White
}
}
}
}else{
auto name_result = Pokemon::PokemonNameReader::instance().read_substring(
logger, language, name_ready, name_text_color_ranges
ImageRGB32 name_ready = preprocess_for_ocr(
name_filtered, "name", 7, 2, true,
combine_rgb(0, 0, 0), jpn ? combine_rgb(160, 160, 160) : combine_rgb(140, 140, 140)
);
if (!name_result.results.empty()){
stats.name = name_result.results.begin()->second.token;

const std::vector<OCR::TextColorRange> name_text_color_ranges{
{combine_rgb(0, 0, 0), combine_rgb(120, 120, 120)}
};

OCR::StringMatchResult result;
if (subset.size() > 0){
auto name_result = Pokemon::PokemonNameReader(subset).read_substring(
logger, language, name_ready, name_text_color_ranges
);
if (!name_result.results.empty()){
result = name_result;
}
}else{
auto name_result = Pokemon::PokemonNameReader::instance().read_substring(
logger, language, name_ready, name_text_color_ranges
);
if (!name_result.results.empty()){
result = name_result;
}
}
if (!result.results.empty()){
if (!initialized){
best_result = result;
initialized = true;
}else if (result.results.begin()->first < best_result.results.begin()->first){
best_result = result;
}
}
}
if (initialized && !best_result.results.empty()){
stats.name = best_result.results.begin()->second.token;
}

// Detect gender by comparing red vs blue pixels
Expand Down Expand Up @@ -148,52 +168,29 @@ void StatsReader::read_page1(
stats.gender = SummaryGender::Genderless;
}



ImageViewRGB32 level_box = extract_box_reference(game_screen, jpn ? m_box_level_jpn : m_box_level);

ImageRGB32 level_upscaled =
level_box.scale_to(level_box.width() * 4, level_box.height() * 4);
if (save_debug_images){
level_upscaled.save("DebugDumps/ocr_level_upscaled.png");
}

// The level has a colored (lilac) background. The text is white, with a
// gray/black shadow. To bridge the gaps and make a solid black character on a
// white background: We want to turn BOTH the bright white text AND the dark
// shadow into BLACK pixels, and turn the mid-tone lilac background into
// WHITE. We can do this by keeping pixels that are very bright (text) or very
// dark (shadow).

ImageRGB32 level_ready(level_upscaled.width(), level_upscaled.height());
for (size_t r = 0; r < level_upscaled.height(); r++){
for (size_t c = 0; c < level_upscaled.width(); c++){
Color pixel(level_upscaled.pixel(c, r));
// If it's very bright (white text) OR very dark (shadow), it becomes
// black text. Otherwise (lilac background), it becomes white background.
if ((pixel.red() > 200 && pixel.green() > 200 && pixel.blue() > 200) ||
(pixel.red() < 100 && pixel.green() < 100 && pixel.blue() < 100)){
level_ready.pixel(c, r) = (uint32_t)0xff000000; // Black
}else{
level_ready.pixel(c, r) = (uint32_t)0xffffffff; // White
}
}
}

if (save_debug_images){
level_ready.save("DebugDumps/ocr_level_ready.png");
}

if (!GlobalSettings::instance().USE_PADDLE_OCR){
// The level uses white text with dark shadow on a lilac background.
// The digit reader's binarizer captures dark pixels (<=190 on all channels)
// but NOT the white text (all channels 255 -> excluded). This leaves the
// shadow outline fragmented into many small disconnected blobs.
// Preprocess: convert bright-white text pixels to black so the binarizer
// merges text + shadow into one solid connected blob per digit.
ImageRGB32 preprocessed = filter_rgb32_range(
level_box, 0xffc8c8c8, 0xffffffff, Color(0xff000000), true
);
ImageRGB32 preprocessed = level_box.scale_to(level_box.width(), level_box.height());
for (size_t r = 0; r < level_box.height(); r++){
for (size_t c = 0; c < level_box.width(); c++){
Color pixel(level_box.pixel(c, r));
// Try to detect lilac background first based on low green channel,
// replacing it with a darker lilac color (for matching the template)
// For other pixels, if it's bright it becomes black text.
if ((pixel.blue() > pixel.green() + 25) && (pixel.red() > pixel.green() + 15)){
preprocessed.pixel(c, r) = (uint32_t)0xffd1b0f0; // from template
}else if (pixel.red() > 200 && pixel.green() > 200 && pixel.blue() > 200){
preprocessed.pixel(c, r) = (uint32_t)0xff000000; // Black
}
}
}
if (save_debug_images){
preprocessed.save("DebugDumps/ocr_level_preprocessed.png");
}
Expand All @@ -215,6 +212,37 @@ void StatsReader::read_page1(
logger, level_digit_view, 230.0, DigitTemplateType::LevelBox,
"levelDigit", 0x7F);
}else{
// The level has a colored (lilac) background. The text is white, with a
// gray/black shadow. To bridge the gaps and make a solid black character on a
// white background: We want to turn BOTH the bright white text AND the dark
// shadow into BLACK pixels, and turn the mid-tone lilac background into
// WHITE. We can do this by keeping pixels that are very bright (text) or very
// dark (shadow).
ImageRGB32 level_upscaled =
level_box.scale_to(level_box.width() * 4, level_box.height() * 4);
if (save_debug_images){
level_upscaled.save("DebugDumps/ocr_level_upscaled.png");
}
ImageRGB32 level_ready(level_upscaled.width(), level_upscaled.height());
for (size_t r = 0; r < level_upscaled.height(); r++){
for (size_t c = 0; c < level_upscaled.width(); c++){
Color pixel(level_upscaled.pixel(c, r));
// Try to detect lilac background first based on low green channel.
// For other pixels, if it's very bright (white text) OR very dark (shadow),
// it becomes black text. Otherwise, it becomes white background.
if ((pixel.blue() > pixel.green() + 25) && (pixel.red() > pixel.green() + 15)){
level_ready.pixel(c, r) = (uint32_t)0xffffffff; // White
}else if ((pixel.red() > 200 && pixel.green() > 200 && pixel.blue() > 200) ||
(pixel.red() < 100 && pixel.green() < 100 && pixel.blue() < 100)){
level_ready.pixel(c, r) = (uint32_t)0xff000000; // Black
}else{
level_ready.pixel(c, r) = (uint32_t)0xffffffff; // White
}
}
}
if (save_debug_images){
level_ready.save("DebugDumps/ocr_level_ready.png");
}
// Pass the binarized image to PaddleOCR
stats.level = OCR::read_number(logger, level_ready, language);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ void GiftRng::program(SingleSwitchProgramEnvironment& env, ProControllerContext&
RNG_CALIBRATION.set_hits(search_hits);
bool finished = update_history(
env.console, uncertain_history, calibration_history, MAX_HISTORY_LENGTH,
calibrations, search_hits, 1
calibrations, search_hits, 1, 2, MAX_RARE_CANDIES == 0
);

for (uint64_t i=0; i<MAX_RARE_CANDIES; i++){
Expand All @@ -473,6 +473,10 @@ void GiftRng::program(SingleSwitchProgramEnvironment& env, ProControllerContext&

bool failed = use_rare_candy(env.console, context, LANGUAGE, pokemon, filters, BASE_STATS, AdvRngMethod::Method1, false, i == 0);
if (failed){
update_history(
env.console, uncertain_history, calibration_history,
MAX_HISTORY_LENGTH, calibrations, search_hits, 1, 2, true
);
stats.errors++;
send_program_recoverable_error_notification(
env, NOTIFICATION_ERROR_RECOVERABLE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ void RoamingLegendaryRng::program(SingleSwitchProgramEnvironment& env, ProContro
RNG_CALIBRATION.set_hits(search_hits);
bool finished = update_history(
env.console, uncertain_history, calibration_history, MAX_HISTORY_LENGTH,
calibrations, search_hits, 1
calibrations, search_hits, 1, 2, MAX_RARE_CANDIES == 0
);
finished = finished || all_indistinguishable(search_hits, searcher, GENDER_THRESHOLD);

Expand All @@ -445,6 +445,10 @@ void RoamingLegendaryRng::program(SingleSwitchProgramEnvironment& env, ProContro

bool failed = use_rare_candy(env.console, context, LANGUAGE, pokemon, filters, BASE_STATS, AdvRngMethod::Method1, false, i == 0);
if (failed){
update_history(
env.console, uncertain_history, calibration_history,
MAX_HISTORY_LENGTH, calibrations, search_hits, 1, 2, true
);
stats.errors++;
send_program_recoverable_error_notification(
env, NOTIFICATION_ERROR_RECOVERABLE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -756,12 +756,20 @@ void StarterRng::program(SingleSwitchProgramEnvironment& env, ProControllerConte
// Stage 2: first search update -- post-rival-battle
bool failed = walk_to_rival_battle(env, context);
if (failed){
update_history(
env.console, uncertain_history, calibration_history,
MAX_HISTORY_LENGTH, calibrations, search_hits, 1, 2, true
);
stats.errors++;
continue; // reset game
}

failed = auto_battle_rival(env, context, pokemon, filters, BASE_STATS);
if (failed){
update_history(
env.console, uncertain_history, calibration_history,
MAX_HISTORY_LENGTH, calibrations, search_hits, 1, 2, true
);
stats.errors++;
continue; // reset game
}
Expand All @@ -787,6 +795,10 @@ void StarterRng::program(SingleSwitchProgramEnvironment& env, ProControllerConte
// Stage 3: subsequent search updates -- leveling up from wild encounters
failed = walk_to_route1_from_lab(env, context);
if (failed){
update_history(
env.console, uncertain_history, calibration_history,
MAX_HISTORY_LENGTH, calibrations, search_hits, 1, 2, true
);
stats.errors++;
continue; // reset game
}
Expand All @@ -808,6 +820,10 @@ void StarterRng::program(SingleSwitchProgramEnvironment& env, ProControllerConte

int ret2 = autolevel_on_route1(env, context, pokemon, filters, BASE_STATS);
if (ret2 < 0){
update_history(
env.console, uncertain_history, calibration_history,
MAX_HISTORY_LENGTH, calibrations, search_hits, 1, 2, true
);
stats.errors++;
break;
}else if(ret2 == 1){
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ void StaticRng::program(SingleSwitchProgramEnvironment& env, ProControllerContex
RNG_CALIBRATION.set_hits(search_hits);
bool finished = update_history(
env.console, uncertain_history, calibration_history, MAX_HISTORY_LENGTH,
calibrations, search_hits, 1
calibrations, search_hits, 1, 2, MAX_RARE_CANDIES == 0
);

for (uint64_t i=0; i<MAX_RARE_CANDIES; i++){
Expand All @@ -466,6 +466,10 @@ void StaticRng::program(SingleSwitchProgramEnvironment& env, ProControllerContex
}
bool failed = use_rare_candy(env.console, context, LANGUAGE, pokemon, filters, BASE_STATS, AdvRngMethod::Method1, false, i == 0);
if (failed) {
update_history(
env.console, uncertain_history, calibration_history,
MAX_HISTORY_LENGTH, calibrations, search_hits, 1, 2, true
);
stats.errors++;
send_program_recoverable_error_notification(
env, NOTIFICATION_ERROR_RECOVERABLE,
Expand Down
Loading
Loading