|
| 1 | +/* FRLG Digit Reader |
| 2 | + * |
| 3 | + * From: https://github.com/PokemonAutomation/ |
| 4 | + * |
| 5 | + */ |
| 6 | + |
| 7 | +#include "PokemonFRLG_DigitReader.h" |
| 8 | +#include "Common/Cpp/Color.h" // needed for COLOR_RED, COLOR_ORANGE |
| 9 | +#include "Common/Cpp/Exceptions.h" |
| 10 | +#include "Common/Cpp/Logging/AbstractLogger.h" |
| 11 | +#include "CommonFramework/Globals.h" |
| 12 | +#include "CommonFramework/ImageTools/ImageBoxes.h" |
| 13 | +#include "CommonFramework/ImageTypes/ImageRGB32.h" |
| 14 | +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" |
| 15 | +#include "CommonTools/ImageMatch/ExactImageMatcher.h" |
| 16 | +#include "CommonTools/Images/BinaryImage_FilterRgb32.h" |
| 17 | +#include "Kernels/Waterfill/Kernels_Waterfill_Session.h" |
| 18 | +#include <array> |
| 19 | +#include <map> |
| 20 | +#include <memory> |
| 21 | +#include <string> |
| 22 | +#include <vector> |
| 23 | + |
| 24 | +#include <opencv2/imgproc.hpp> |
| 25 | + |
| 26 | +#include <iostream> |
| 27 | +using std::cout; |
| 28 | +using std::endl; |
| 29 | + |
| 30 | +namespace PokemonAutomation { |
| 31 | +namespace NintendoSwitch { |
| 32 | +namespace PokemonFRLG { |
| 33 | + |
| 34 | +// --------------------------------------------------------------------------- |
| 35 | +// Template store: loads 10 digit matchers from a resource sub-directory. |
| 36 | +// Results are cached in a static map keyed by template type. |
| 37 | +// Supports both: |
| 38 | +// - StatBox (yellow stat boxes): PokemonFRLG/Digits/ |
| 39 | +// - LevelBox (lilac level box): PokemonFRLG/LevelDigits/ |
| 40 | +// --------------------------------------------------------------------------- |
| 41 | + |
| 42 | +static std::string get_template_path(DigitTemplateType type) { |
| 43 | + switch (type) { |
| 44 | + case DigitTemplateType::StatBox: |
| 45 | + return "PokemonFRLG/Digits/"; |
| 46 | + case DigitTemplateType::LevelBox: |
| 47 | + return "PokemonFRLG/LevelDigits/"; |
| 48 | + default: |
| 49 | + return "PokemonFRLG/Digits/"; |
| 50 | + } |
| 51 | +} |
| 52 | + |
| 53 | +struct DigitTemplates { |
| 54 | + // matchers[d] is the matcher for digit d (0-9), or nullptr if missing. |
| 55 | + std::array<std::unique_ptr<ImageMatch::ExactImageMatcher>, 10> matchers; |
| 56 | + bool any_loaded = false; |
| 57 | + |
| 58 | + explicit DigitTemplates(DigitTemplateType template_type) { |
| 59 | + std::string resource_subdir = get_template_path(template_type); |
| 60 | + for (int d = 0; d < 10; ++d) { |
| 61 | + std::string path = |
| 62 | + RESOURCE_PATH() + resource_subdir + std::to_string(d) + ".png"; |
| 63 | + try { |
| 64 | + ImageRGB32 img(path); |
| 65 | + if (img.width() > 0) { |
| 66 | + matchers[d] = |
| 67 | + std::make_unique<ImageMatch::ExactImageMatcher>(std::move(img)); |
| 68 | + any_loaded = true; |
| 69 | + } |
| 70 | + } catch (...) { |
| 71 | + // Template image missing - slot stays nullptr. |
| 72 | + } |
| 73 | + } |
| 74 | + if (!any_loaded) { |
| 75 | + throw FileException(nullptr, PA_CURRENT_FUNCTION, |
| 76 | + "Failed to load any digit templates", resource_subdir); |
| 77 | + } |
| 78 | + } |
| 79 | + |
| 80 | + static const DigitTemplates& get(DigitTemplateType template_type) { |
| 81 | + static std::map<DigitTemplateType, DigitTemplates> cache; |
| 82 | + auto it = cache.find(template_type); |
| 83 | + if (it == cache.end()) { |
| 84 | + it = cache.emplace(template_type, DigitTemplates(template_type)).first; |
| 85 | + } |
| 86 | + return it->second; |
| 87 | + } |
| 88 | +}; |
| 89 | + |
| 90 | +// --------------------------------------------------------------------------- |
| 91 | +// Main function |
| 92 | +// --------------------------------------------------------------------------- |
| 93 | +int read_digits_waterfill_template( |
| 94 | + Logger& logger, |
| 95 | + const ImageViewRGB32& stat_region, |
| 96 | + double rmsd_threshold, |
| 97 | + DigitTemplateType template_type, |
| 98 | + const std::string& dump_prefix, |
| 99 | + uint8_t binarize_high |
| 100 | +) { |
| 101 | + using namespace Kernels::Waterfill; |
| 102 | + |
| 103 | + if (!stat_region) { |
| 104 | + logger.log("DigitReader: empty stat region.", COLOR_RED); |
| 105 | + return -1; |
| 106 | + } |
| 107 | + |
| 108 | + // ------------------------------------------------------------------ |
| 109 | + // Step 1: Gaussian blur on the NATIVE resolution image. |
| 110 | + // The GBA pixel font has 1-pixel gaps between segments. |
| 111 | + // A 5x5 kernel applied twice bridges those gaps so that waterfill |
| 112 | + // sees each digit as a single connected component. |
| 113 | + // ------------------------------------------------------------------ |
| 114 | + cv::Mat src = stat_region.to_opencv_Mat(); |
| 115 | + cv::Mat blurred; |
| 116 | + src.copyTo(blurred); |
| 117 | + cv::GaussianBlur(blurred, blurred, cv::Size(5, 5), 1.5); |
| 118 | + cv::GaussianBlur(blurred, blurred, cv::Size(5, 5), 1.5); |
| 119 | + |
| 120 | + ImageRGB32 blurred_img(blurred.cols, blurred.rows); |
| 121 | + blurred.copyTo(blurred_img.to_opencv_Mat()); |
| 122 | + |
| 123 | + // ------------------------------------------------------------------ |
| 124 | + // Step 2: Binarise the blurred image. |
| 125 | + // Pixels where ALL channels <= binarize_high become 1 (foreground). |
| 126 | + // Default 0xBE (190) works for yellow stat boxes. |
| 127 | + // Use 0x7F (127) for the lilac level box to prevent the blurred |
| 128 | + // lilac background (B~208, drops to ~156 near shadows) from being |
| 129 | + // captured and merging digit blobs. |
| 130 | + // ------------------------------------------------------------------ |
| 131 | + uint32_t bh = binarize_high; |
| 132 | + uint32_t binarize_color = 0xff000000u | (bh << 16) | (bh << 8) | bh; |
| 133 | + PackedBinaryMatrix matrix = |
| 134 | + compress_rgb32_to_binary_range(blurred_img, 0xff000000u, binarize_color); |
| 135 | + |
| 136 | + // ------------------------------------------------------------------ |
| 137 | + // Step 3: Waterfill - find connected dark blobs (individual digits). |
| 138 | + // Minimum area of 4 pixels to discard lone noise specks. |
| 139 | + // Sort blobs left-to-right by their left edge (min_x). |
| 140 | + // ------------------------------------------------------------------ |
| 141 | + const size_t min_area = 4; |
| 142 | + std::map<size_t, WaterfillObject> blobs; // key = min_x, auto-sorted L->R |
| 143 | + { |
| 144 | + std::unique_ptr<WaterfillSession> session = make_WaterfillSession(matrix); |
| 145 | + auto iter = session->make_iterator(min_area); |
| 146 | + WaterfillObject obj; |
| 147 | + while (blobs.size() < 8 && iter->find_next(obj, false)) { |
| 148 | + // Require at least 3px wide AND 3px tall to discard noise fragments. |
| 149 | + if (obj.max_x - obj.min_x < 3 || obj.max_y - obj.min_y < 3) |
| 150 | + continue; |
| 151 | + // Use min_x as key so the map is automatically sorted left-to-right. |
| 152 | + // If two blobs share an identical min_x, bump the key slightly. |
| 153 | + size_t key = obj.min_x; |
| 154 | + while (blobs.count(key)) |
| 155 | + ++key; |
| 156 | + blobs.emplace(key, std::move(obj)); |
| 157 | + } |
| 158 | + } |
| 159 | + |
| 160 | + if (blobs.empty()) { |
| 161 | + logger.log("DigitReader: waterfill found no digit blobs.", COLOR_RED); |
| 162 | + return -1; |
| 163 | + } |
| 164 | + |
| 165 | + // ------------------------------------------------------------------ |
| 166 | + // Step 4: For each blob, crop the UNBLURRED original stat_region to |
| 167 | + // the blob's bounding box, then template-match against all 10 digit |
| 168 | + // templates using ExactImageMatcher::rmsd(). Pick the lowest RMSD. |
| 169 | + // ------------------------------------------------------------------ |
| 170 | + const DigitTemplates& templates = DigitTemplates::get(template_type); |
| 171 | + std::string result_str; |
| 172 | + |
| 173 | + for (const auto &kv : blobs) { |
| 174 | + const WaterfillObject &obj = kv.second; |
| 175 | + |
| 176 | + size_t width = obj.max_x - obj.min_x; |
| 177 | + size_t height = obj.max_y - obj.min_y; |
| 178 | + |
| 179 | + int expected_digits = 1; |
| 180 | + // GBA font digits are typically narrower than they are tall (aspect ~0.6). |
| 181 | + // If the blob's width is wider than expected for a single digit, it's a |
| 182 | + // merged blob. |
| 183 | + if (width > height * 1.5) { |
| 184 | + expected_digits = 3; // e.g. "100" |
| 185 | + } else if (width > height * 0.8) { |
| 186 | + expected_digits = 2; // e.g. "23" |
| 187 | + } |
| 188 | + |
| 189 | + size_t split_w = width / expected_digits; |
| 190 | + |
| 191 | + for (int i = 0; i < expected_digits; ++i) { |
| 192 | + size_t min_x = obj.min_x + i * split_w; |
| 193 | + size_t max_x = (i == expected_digits - 1) ? obj.max_x : obj.min_x + (i + 1) * split_w; |
| 194 | + |
| 195 | + // Crop original (unblurred) region to the split bounding box. |
| 196 | + ImagePixelBox bbox(min_x, obj.min_y, max_x, obj.max_y); |
| 197 | + ImageViewRGB32 crop = extract_box_reference(stat_region, bbox); |
| 198 | + |
| 199 | + if (dump_prefix == "levelDigit") { |
| 200 | + crop.save("DebugDumps/" + dump_prefix + "_x" + std::to_string(min_x) + "_split_raw.png"); |
| 201 | + } |
| 202 | + |
| 203 | + // Compute RMSD against each digit template; pick the minimum. |
| 204 | + // If no templates are loaded (extraction mode), skip matching entirely. |
| 205 | + double best_rmsd = 9999.0; |
| 206 | + int best_digit = -1; |
| 207 | + if (templates.any_loaded) { |
| 208 | + for (int d = 0; d < 10; ++d) { |
| 209 | + if (!templates.matchers[d]) |
| 210 | + continue; |
| 211 | + double r = templates.matchers[d]->rmsd(crop); |
| 212 | + if (r < best_rmsd) { |
| 213 | + best_rmsd = r; |
| 214 | + best_digit = d; |
| 215 | + } |
| 216 | + } |
| 217 | + } |
| 218 | + |
| 219 | + if (best_rmsd > rmsd_threshold) { |
| 220 | + // Always save the raw crop for user inspection / template extraction. |
| 221 | + crop.save("DebugDumps/" + dump_prefix + "_x" + std::to_string(min_x) + |
| 222 | + "_raw.png"); |
| 223 | + logger.log( |
| 224 | + "DigitReader: blob at x=" + std::to_string(min_x) + |
| 225 | + " skipped (best RMSD=" + std::to_string(best_rmsd) + |
| 226 | + ", threshold=" + std::to_string(rmsd_threshold) + ").", |
| 227 | + COLOR_ORANGE |
| 228 | + ); |
| 229 | + continue; |
| 230 | + } |
| 231 | + |
| 232 | + logger.log( |
| 233 | + "DigitReader: blob at x=" + std::to_string(min_x) + |
| 234 | + " -> digit " + std::to_string(best_digit) + |
| 235 | + " (RMSD=" + std::to_string(best_rmsd) + ")" |
| 236 | + ); |
| 237 | + // Save crop with prefix so level and stat crops are distinguishable. |
| 238 | + crop.save("DebugDumps/" + dump_prefix + "_x" + std::to_string(min_x) + |
| 239 | + "_match" + std::to_string(best_digit) + ".png"); |
| 240 | + result_str += static_cast<char>('0' + best_digit); |
| 241 | + } |
| 242 | + } |
| 243 | + |
| 244 | + if (result_str.empty()) { |
| 245 | + return -1; |
| 246 | + } |
| 247 | + |
| 248 | + int number = std::atoi(result_str.c_str()); |
| 249 | + logger.log( |
| 250 | + "DigitReader: \"" + result_str + "\" -> " + |
| 251 | + std::to_string(number) |
| 252 | + ); |
| 253 | + return number; |
| 254 | +} |
| 255 | + |
| 256 | +} // namespace PokemonFRLG |
| 257 | +} // namespace NintendoSwitch |
| 258 | +} // namespace PokemonAutomation |
| 259 | + |
0 commit comments