GameSentenceMiner 2.19.16__py3-none-any.whl → 2.20.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of GameSentenceMiner might be problematic. Click here for more details.

Files changed (70) hide show
  1. GameSentenceMiner/__init__.py +39 -0
  2. GameSentenceMiner/anki.py +6 -3
  3. GameSentenceMiner/gametext.py +13 -2
  4. GameSentenceMiner/gsm.py +40 -3
  5. GameSentenceMiner/locales/en_us.json +4 -0
  6. GameSentenceMiner/locales/ja_jp.json +4 -0
  7. GameSentenceMiner/locales/zh_cn.json +4 -0
  8. GameSentenceMiner/obs.py +4 -1
  9. GameSentenceMiner/owocr/owocr/ocr.py +304 -134
  10. GameSentenceMiner/owocr/owocr/run.py +1 -1
  11. GameSentenceMiner/ui/anki_confirmation.py +4 -2
  12. GameSentenceMiner/ui/config_gui.py +12 -0
  13. GameSentenceMiner/util/configuration.py +6 -2
  14. GameSentenceMiner/util/cron/__init__.py +12 -0
  15. GameSentenceMiner/util/cron/daily_rollup.py +613 -0
  16. GameSentenceMiner/util/cron/jiten_update.py +397 -0
  17. GameSentenceMiner/util/cron/populate_games.py +154 -0
  18. GameSentenceMiner/util/cron/run_crons.py +148 -0
  19. GameSentenceMiner/util/cron/setup_populate_games_cron.py +118 -0
  20. GameSentenceMiner/util/cron_table.py +334 -0
  21. GameSentenceMiner/util/db.py +236 -49
  22. GameSentenceMiner/util/ffmpeg.py +23 -4
  23. GameSentenceMiner/util/games_table.py +340 -93
  24. GameSentenceMiner/util/jiten_api_client.py +188 -0
  25. GameSentenceMiner/util/stats_rollup_table.py +216 -0
  26. GameSentenceMiner/web/anki_api_endpoints.py +438 -220
  27. GameSentenceMiner/web/database_api.py +955 -1259
  28. GameSentenceMiner/web/jiten_database_api.py +1015 -0
  29. GameSentenceMiner/web/rollup_stats.py +672 -0
  30. GameSentenceMiner/web/static/css/dashboard-shared.css +75 -13
  31. GameSentenceMiner/web/static/css/overview.css +604 -47
  32. GameSentenceMiner/web/static/css/search.css +226 -0
  33. GameSentenceMiner/web/static/css/shared.css +762 -0
  34. GameSentenceMiner/web/static/css/stats.css +221 -0
  35. GameSentenceMiner/web/static/js/components/bar-chart.js +339 -0
  36. GameSentenceMiner/web/static/js/database-bulk-operations.js +320 -0
  37. GameSentenceMiner/web/static/js/database-game-data.js +390 -0
  38. GameSentenceMiner/web/static/js/database-game-operations.js +213 -0
  39. GameSentenceMiner/web/static/js/database-helpers.js +44 -0
  40. GameSentenceMiner/web/static/js/database-jiten-integration.js +750 -0
  41. GameSentenceMiner/web/static/js/database-popups.js +89 -0
  42. GameSentenceMiner/web/static/js/database-tabs.js +64 -0
  43. GameSentenceMiner/web/static/js/database-text-management.js +371 -0
  44. GameSentenceMiner/web/static/js/database.js +86 -718
  45. GameSentenceMiner/web/static/js/goals.js +79 -18
  46. GameSentenceMiner/web/static/js/heatmap.js +29 -23
  47. GameSentenceMiner/web/static/js/overview.js +1205 -339
  48. GameSentenceMiner/web/static/js/regex-patterns.js +100 -0
  49. GameSentenceMiner/web/static/js/search.js +215 -18
  50. GameSentenceMiner/web/static/js/shared.js +193 -39
  51. GameSentenceMiner/web/static/js/stats.js +1536 -179
  52. GameSentenceMiner/web/stats.py +1142 -269
  53. GameSentenceMiner/web/stats_api.py +2104 -0
  54. GameSentenceMiner/web/templates/anki_stats.html +4 -18
  55. GameSentenceMiner/web/templates/components/date-range.html +118 -3
  56. GameSentenceMiner/web/templates/components/html-head.html +40 -6
  57. GameSentenceMiner/web/templates/components/js-config.html +8 -8
  58. GameSentenceMiner/web/templates/components/regex-input.html +160 -0
  59. GameSentenceMiner/web/templates/database.html +564 -117
  60. GameSentenceMiner/web/templates/goals.html +41 -5
  61. GameSentenceMiner/web/templates/overview.html +159 -129
  62. GameSentenceMiner/web/templates/search.html +78 -9
  63. GameSentenceMiner/web/templates/stats.html +159 -5
  64. GameSentenceMiner/web/texthooking_page.py +280 -111
  65. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/METADATA +43 -2
  66. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/RECORD +70 -47
  67. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/WHEEL +0 -0
  68. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/entry_points.txt +0 -0
  69. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/licenses/LICENSE +0 -0
  70. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/top_level.txt +0 -0
@@ -10,6 +10,7 @@ from math import sqrt, floor
10
10
  import json
11
11
  import base64
12
12
  from urllib.parse import urlparse, parse_qs
13
+ import warnings
13
14
 
14
15
  import numpy as np
15
16
  import rapidfuzz.fuzz
@@ -99,6 +100,13 @@ try:
99
100
  except:
100
101
  optimized_png_encode = False
101
102
 
103
+ try:
104
+ from meikiocr import MeikiOCR as MKOCR
105
+ except ImportError:
106
+ pass
107
+
108
+ meiki_model = None
109
+
102
110
 
103
111
  def empty_post_process(text):
104
112
  return text
@@ -1079,6 +1087,250 @@ class OneOCR:
1079
1087
 
1080
1088
  def _preprocess(self, img):
1081
1089
  return pil_image_to_bytes(img, png_compression=1)
1090
+
1091
+
1092
+ class MeikiOCR:
1093
+ name = 'meikiocr'
1094
+ readable_name = 'MeikiOCR'
1095
+ key = 'k'
1096
+ available = False
1097
+
1098
+ def __init__(self, config={}, lang='ja', get_furigana_sens_from_file=True):
1099
+ global meiki_model
1100
+ import regex
1101
+ self.initial_lang = lang
1102
+ self.regex = get_regex(lang)
1103
+ self.punctuation_regex = regex.compile(r'[\p{P}\p{S}]')
1104
+ self.get_furigana_sens_from_file = get_furigana_sens_from_file
1105
+ if 'meikiocr' not in sys.modules:
1106
+ logger.warning('meikiocr not available, MeikiOCR will not work!')
1107
+ elif meiki_model:
1108
+ self.model = meiki_model
1109
+ self.available = True
1110
+ logger.info('MeikiOCR ready')
1111
+ else:
1112
+ try:
1113
+ logger.info('Loading MeikiOCR model')
1114
+ meiki_model = MKOCR()
1115
+ self.model = meiki_model
1116
+ self.available = True
1117
+ logger.info('MeikiOCR ready')
1118
+ except RuntimeError as e:
1119
+ logger.warning(str(e) + ', MeikiOCR will not work!')
1120
+ except Exception as e:
1121
+ logger.warning(f'Error loading MeikiOCR: {e}, MeikiOCR will not work!')
1122
+
1123
+ def get_regex(self, lang):
1124
+ if lang == "ja":
1125
+ self.regex = re.compile(r'[\u3041-\u3096\u30A1-\u30FA\u4E00-\u9FFF]')
1126
+ elif lang == "zh":
1127
+ self.regex = re.compile(r'[\u4E00-\u9FFF]')
1128
+ elif lang == "ko":
1129
+ self.regex = re.compile(r'[\uAC00-\uD7AF]')
1130
+ elif lang == "ar":
1131
+ self.regex = re.compile(r'[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]')
1132
+ elif lang == "ru":
1133
+ self.regex = re.compile(r'[\u0400-\u04FF\u0500-\u052F\u2DE0-\u2DFF\uA640-\uA69F\u1C80-\u1C8F]')
1134
+ elif lang == "el":
1135
+ self.regex = re.compile(r'[\u0370-\u03FF\u1F00-\u1FFF]')
1136
+ elif lang == "he":
1137
+ self.regex = re.compile(r'[\u0590-\u05FF\uFB1D-\uFB4F]')
1138
+ elif lang == "th":
1139
+ self.regex = re.compile(r'[\u0E00-\u0E7F]')
1140
+ else:
1141
+ self.regex = re.compile(
1142
+ r'[a-zA-Z\u00C0-\u00FF\u0100-\u017F\u0180-\u024F\u0250-\u02AF\u1D00-\u1D7F\u1D80-\u1DBF\u1E00-\u1EFF\u2C60-\u2C7F\uA720-\uA7FF\uAB30-\uAB6F]')
1143
+
1144
+ def __call__(self, img, furigana_filter_sensitivity=0, return_coords=False, multiple_crop_coords=False, return_one_box=True, return_dict=False):
1145
+ lang = get_ocr_language()
1146
+ if self.get_furigana_sens_from_file:
1147
+ furigana_filter_sensitivity = get_furigana_filter_sensitivity()
1148
+ else:
1149
+ furigana_filter_sensitivity = furigana_filter_sensitivity
1150
+ if lang != self.initial_lang:
1151
+ self.initial_lang = lang
1152
+ self.regex = get_regex(lang)
1153
+ img, is_path = input_to_pil_image(img)
1154
+ if img.width < 51 or img.height < 51:
1155
+ new_width = max(img.width, 51)
1156
+ new_height = max(img.height, 51)
1157
+ new_img = Image.new("RGBA", (new_width, new_height), (0, 0, 0, 0))
1158
+ new_img.paste(img, ((new_width - img.width) // 2, (new_height - img.height) // 2))
1159
+ img = new_img
1160
+ if not img:
1161
+ return (False, 'Invalid image provided')
1162
+ crop_coords = None
1163
+ crop_coords_list = []
1164
+ ocr_resp = ''
1165
+
1166
+ try:
1167
+ # Convert PIL image to numpy array for meikiocr
1168
+ image_np = np.array(img.convert('RGB'))[:, :, ::-1]
1169
+
1170
+ # Run meikiocr
1171
+ read_results = self.model.run_ocr(image_np)
1172
+
1173
+ # Convert meikiocr response to OneOCR format
1174
+ ocr_resp = self._convert_meikiocr_to_oneocr_format(read_results, img.width, img.height)
1175
+
1176
+ if os.path.exists(os.path.expanduser("~/GSM/temp")):
1177
+ with open(os.path.join(os.path.expanduser("~/GSM/temp"), 'meikiocr_response.json'), 'w',
1178
+ encoding='utf-8') as f:
1179
+ json.dump(ocr_resp, f, indent=4, ensure_ascii=False)
1180
+
1181
+ filtered_lines = [line for line in ocr_resp['lines'] if self.regex.search(line['text'])]
1182
+ x_coords = [line['bounding_rect'][f'x{i}'] for line in filtered_lines for i in range(1, 5)]
1183
+ y_coords = [line['bounding_rect'][f'y{i}'] for line in filtered_lines for i in range(1, 5)]
1184
+ if x_coords and y_coords:
1185
+ crop_coords = (min(x_coords) - 5, min(y_coords) - 5, max(x_coords) + 5, max(y_coords) + 5)
1186
+
1187
+ res = ''
1188
+ skipped = []
1189
+ boxes = []
1190
+ if furigana_filter_sensitivity > 0:
1191
+ passing_lines = []
1192
+ for line in filtered_lines:
1193
+ line_x1, line_x2, line_x3, line_x4 = line['bounding_rect']['x1'], line['bounding_rect']['x2'], \
1194
+ line['bounding_rect']['x3'], line['bounding_rect']['x4']
1195
+ line_y1, line_y2, line_y3, line_y4 = line['bounding_rect']['y1'], line['bounding_rect']['y2'], \
1196
+ line['bounding_rect']['y3'], line['bounding_rect']['y4']
1197
+ line_width = max(line_x2 - line_x1, line_x3 - line_x4)
1198
+ line_height = max(line_y3 - line_y1, line_y4 - line_y2)
1199
+
1200
+ # Check if the line passes the size filter
1201
+ if line_width > furigana_filter_sensitivity and line_height > furigana_filter_sensitivity:
1202
+ # Line passes - include all its text and add to passing_lines
1203
+ for char in line['words']:
1204
+ res += char['text']
1205
+ passing_lines.append(line)
1206
+ else:
1207
+ # Line fails - only include punctuation, skip the rest
1208
+ for char in line['words']:
1209
+ skipped.extend(char for char in line['text'])
1210
+ res += '\n'
1211
+ filtered_lines = passing_lines
1212
+ return_resp = {'text': res, 'text_angle': ocr_resp['text_angle'], 'lines': passing_lines}
1213
+ else:
1214
+ res = ocr_resp['text']
1215
+ return_resp = ocr_resp
1216
+
1217
+ if multiple_crop_coords:
1218
+ for line in filtered_lines:
1219
+ crop_coords_list.append(
1220
+ (line['bounding_rect']['x1'] - 5, line['bounding_rect']['y1'] - 5,
1221
+ line['bounding_rect']['x3'] + 5, line['bounding_rect']['y3'] + 5))
1222
+
1223
+ except RuntimeError as e:
1224
+ return (False, str(e))
1225
+ except Exception as e:
1226
+ return (False, f'MeikiOCR error: {str(e)}')
1227
+
1228
+ x = [True, res]
1229
+ if return_coords:
1230
+ x.append(filtered_lines)
1231
+ if multiple_crop_coords:
1232
+ x.append(crop_coords_list)
1233
+ if return_one_box:
1234
+ x.append(crop_coords)
1235
+ if return_dict:
1236
+ x.append(return_resp)
1237
+ if is_path:
1238
+ img.close()
1239
+ return x
1240
+
1241
+ def _convert_meikiocr_to_oneocr_format(self, meikiocr_results, img_width, img_height):
1242
+ """
1243
+ Convert meikiocr output format to match OneOCR format.
1244
+
1245
+ meikiocr returns: [{"text": "line text", "chars": [{"char": "字", "bbox": [x1, y1, x2, y2], "conf": 0.9}, ...]}, ...]
1246
+
1247
+ OneOCR format expected:
1248
+ {
1249
+ 'text': 'full text',
1250
+ 'text_angle': 0,
1251
+ 'lines': [
1252
+ {
1253
+ 'text': 'line text',
1254
+ 'bounding_rect': {'x1': x1, 'y1': y1, 'x2': x2, 'y2': y2, 'x3': x3, 'y3': y3, 'x4': x4, 'y4': y4},
1255
+ 'words': [{'text': 'char', 'bounding_rect': {...}}, ...]
1256
+ },
1257
+ ...
1258
+ ]
1259
+ }
1260
+ """
1261
+ full_text = ''
1262
+ lines = []
1263
+
1264
+ for line_result in meikiocr_results:
1265
+ line_text = line_result.get('text', '')
1266
+ char_results = line_result.get('chars', [])
1267
+
1268
+ if not line_text or not char_results:
1269
+ continue
1270
+
1271
+ # Convert characters and calculate line bbox from char bboxes
1272
+ words = []
1273
+ all_x_coords = []
1274
+ all_y_coords = []
1275
+
1276
+ for char_info in char_results:
1277
+ char_text = char_info.get('char', '')
1278
+ char_bbox = char_info.get('bbox', [0, 0, 0, 0])
1279
+
1280
+ cx1, cy1, cx2, cy2 = char_bbox
1281
+ all_x_coords.extend([cx1, cx2])
1282
+ all_y_coords.extend([cy1, cy2])
1283
+
1284
+ char_bounding_rect = {
1285
+ 'x1': cx1, 'y1': cy1,
1286
+ 'x2': cx2, 'y2': cy1,
1287
+ 'x3': cx2, 'y3': cy2,
1288
+ 'x4': cx1, 'y4': cy2
1289
+ }
1290
+
1291
+ words.append({
1292
+ 'text': char_text,
1293
+ 'bounding_rect': char_bounding_rect
1294
+ })
1295
+
1296
+ # Calculate line bounding box from all character bboxes
1297
+ if all_x_coords and all_y_coords:
1298
+ x1 = min(all_x_coords)
1299
+ y1 = min(all_y_coords)
1300
+ x2 = max(all_x_coords)
1301
+ y2 = max(all_y_coords)
1302
+
1303
+ line_bounding_rect = {
1304
+ 'x1': x1, 'y1': y1,
1305
+ 'x2': x2, 'y2': y1,
1306
+ 'x3': x2, 'y3': y2,
1307
+ 'x4': x1, 'y4': y2
1308
+ }
1309
+ else:
1310
+ line_bounding_rect = {
1311
+ 'x1': 0, 'y1': 0,
1312
+ 'x2': 0, 'y2': 0,
1313
+ 'x3': 0, 'y3': 0,
1314
+ 'x4': 0, 'y4': 0
1315
+ }
1316
+
1317
+ lines.append({
1318
+ 'text': line_text,
1319
+ 'bounding_rect': line_bounding_rect,
1320
+ 'words': words
1321
+ })
1322
+
1323
+ full_text += line_text + '\n'
1324
+
1325
+ return {
1326
+ 'text': full_text.rstrip('\n'),
1327
+ 'text_angle': 0,
1328
+ 'lines': lines
1329
+ }
1330
+
1331
+ def _preprocess(self, img):
1332
+ return pil_image_to_bytes(img, png_compression=1)
1333
+
1082
1334
 
1083
1335
  class AzureImageAnalysis:
1084
1336
  name = 'azure'
@@ -1584,11 +1836,10 @@ def draw_detections(image: np.ndarray, detections: list, model_name: str) -> np.
1584
1836
 
1585
1837
  class MeikiTextDetector:
1586
1838
  """
1587
- A class to perform text detection using the meiki.text.detect.v0 models.
1839
+ A class to perform text detection using the meikiocr package.
1588
1840
 
1589
- This class handles downloading the ONNX models from the Hugging Face Hub,
1590
- loading them into an ONNX Runtime session, and providing a simple interface
1591
- for inference.
1841
+ This class wraps the MeikiOCR.run_detection method and provides
1842
+ the same output format as the previous implementation.
1592
1843
  """
1593
1844
  name = 'meiki_text_detector'
1594
1845
  readable_name = 'Meiki Text Detector'
@@ -1597,163 +1848,79 @@ class MeikiTextDetector:
1597
1848
 
1598
1849
  def __init__(self, model_name: str = 'small'):
1599
1850
  """
1600
- Initializes the detector by downloading and loading the specified ONNX model.
1851
+ Initializes the detector using the meikiocr package.
1601
1852
 
1602
1853
  Args:
1603
- model_name (str): The model to use, either "tiny" or "small".
1604
- Defaults to "small".
1854
+ model_name (str): Not used in the new implementation (meikiocr uses its own model).
1855
+ Kept for compatibility.
1605
1856
  """
1606
- if model_name not in ['tiny', 'small']:
1607
- raise ValueError("model_name must be either 'tiny' or 'small'")
1608
-
1609
- ort.preload_dlls(cuda=False, cudnn=False, directory=None)
1610
-
1611
- self.model_name = model_name
1612
- self.session = None
1613
-
1614
- # --- Model-specific parameters ---
1615
- if self.model_name == "tiny":
1616
- self.model_size = 320
1617
- self.is_color = False
1618
- self.onnx_filename = "meiki.text.detect.tiny.v0.onnx"
1619
- else: # "small"
1620
- self.model_size = 640
1621
- self.is_color = True
1622
- self.onnx_filename = "meiki.text.detect.small.v0.onnx"
1623
-
1857
+ global meiki_model
1624
1858
  try:
1625
- print(f"Initializing MeikiTextDetector with '{self.model_name}' model...")
1626
- MODEL_REPO = "rtr46/meiki.text.detect.v0"
1627
-
1628
- # Download the model file from the Hub and get its local path
1629
- model_path = hf_hub_download(repo_id=MODEL_REPO, filename=self.onnx_filename)
1630
-
1631
- # Load the ONNX model into an inference session
1632
- # providers = ['CUDAExecutionProvider']
1633
- providers = ['CPUExecutionProvider']
1634
- self.session = ort.InferenceSession(model_path, providers=providers)
1635
-
1636
- self.available = True
1637
- print("Model loaded successfully. MeikiTextDetector is ready.")
1638
-
1859
+ if 'meikiocr' not in sys.modules:
1860
+ logger.warning('meikiocr not available, MeikiTextDetector will not work!')
1861
+ self.available = False
1862
+ return
1863
+ elif meiki_model:
1864
+ self.model = meiki_model
1865
+ self.available = True
1866
+ logger.info('MeikiOCR ready')
1867
+ else:
1868
+ logger.info('Initializing MeikiTextDetector using meikiocr package...')
1869
+ meiki_model = MKOCR()
1870
+ self.model = meiki_model
1871
+ self.available = True
1872
+ logger.info('MeikiTextDetector ready')
1639
1873
  except Exception as e:
1640
- print(f"Error initializing MeikiTextDetector: {e}")
1874
+ logger.warning(f'Error initializing MeikiTextDetector: {e}')
1641
1875
  self.available = False
1642
1876
 
1643
- def _resize_and_pad(self, image: np.ndarray):
1644
- """
1645
- Resizes and pads an image to the model's expected square size,
1646
- preserving the aspect ratio.
1647
- """
1648
- if self.is_color:
1649
- h, w, _ = image.shape
1650
- else:
1651
- h, w = image.shape
1652
-
1653
- size = self.model_size
1654
- ratio = min(size / w, size / h)
1655
- new_w, new_h = int(w * ratio), int(h * ratio)
1656
-
1657
- resized_image = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
1658
-
1659
- if self.is_color:
1660
- padded_image = np.zeros((size, size, 3), dtype=np.uint8)
1661
- else:
1662
- padded_image = np.zeros((size, size), dtype=np.uint8)
1663
-
1664
- pad_w, pad_h = (size - new_w) // 2, (size - new_h) // 2
1665
- padded_image[pad_h:pad_h + new_h, pad_w:pad_w + new_w] = resized_image
1666
-
1667
- return padded_image, ratio, pad_w, pad_h
1668
-
1669
1877
  def __call__(self, img, confidence_threshold: float = 0.4):
1670
1878
  """
1671
1879
  Performs text detection on an input image.
1672
1880
 
1673
1881
  Args:
1674
- img: The input image. Can be a file path, URL, PIL Image, or a NumPy array (BGR format).
1882
+ img: The input image. Can be a PIL Image or a NumPy array (BGR format).
1675
1883
  confidence_threshold (float): The threshold to filter out low-confidence detections.
1676
1884
 
1677
1885
  Returns:
1678
- A list of dictionaries, where each dictionary represents a detected
1679
- text box and contains 'box' (a list of [x_min, y_min, x_max, y_max])
1680
- and 'score' (a float). Returns an empty list if no boxes are found.
1886
+ A tuple of (True, dict) where dict contains:
1887
+ - 'boxes': list of detection dicts with 'box' and 'score'
1888
+ - 'provider': 'meiki'
1889
+ - 'crop_coords': bounding box around all detections
1681
1890
  """
1682
1891
  if confidence_threshold is None:
1683
1892
  confidence_threshold = 0.4
1684
1893
  if not self.available:
1685
1894
  raise RuntimeError("MeikiTextDetector is not available due to an initialization error.")
1686
1895
 
1687
- # --- Input Handling ---
1688
- if isinstance(img, str):
1689
- if img.startswith('http'):
1690
- response = requests.get(img)
1691
- pil_image = Image.open(BytesIO(response.content)).convert("RGB")
1692
- else:
1693
- pil_image = Image.open(img).convert("RGB")
1694
- # Convert PIL (RGB) to OpenCV (BGR) format
1695
- input_image = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
1696
- elif isinstance(img, Image.Image):
1697
- # Convert PIL (RGB) to OpenCV (BGR) format
1698
- input_image = cv2.cvtColor(np.array(img.convert("RGB")), cv2.COLOR_RGB2BGR)
1699
- elif isinstance(img, np.ndarray):
1700
- input_image = img
1701
- else:
1702
- raise TypeError("Unsupported input type for 'img'. Use a file path, URL, PIL Image, or NumPy array.")
1703
-
1704
-
1705
- # --- Preprocessing ---
1706
- if self.is_color:
1707
- image_for_model = input_image
1708
- else:
1709
- image_for_model = cv2.cvtColor(input_image, cv2.COLOR_BGR2GRAY)
1710
-
1711
- padded_image, ratio, pad_w, pad_h = self._resize_and_pad(image_for_model)
1712
- img_normalized = padded_image.astype(np.float32) / 255.0
1713
-
1714
- if self.is_color:
1715
- img_transposed = np.transpose(img_normalized, (2, 0, 1))
1716
- input_tensor = np.expand_dims(img_transposed, axis=0)
1717
- else:
1718
- input_tensor = np.expand_dims(np.expand_dims(img_normalized, axis=0), axis=0)
1719
-
1720
- # --- Inference ---
1721
- sizes_tensor = np.array([[self.model_size, self.model_size]], dtype=np.int64)
1722
- input_names = [inp.name for inp in self.session.get_inputs()]
1723
- inputs = {input_names[0]: input_tensor, input_names[1]: sizes_tensor}
1896
+ # Convert input to numpy array (BGR format)
1897
+ img_pil, is_path = input_to_pil_image(img)
1898
+ if not img_pil:
1899
+ return False, {'boxes': [], 'provider': 'meiki', 'crop_coords': None}
1724
1900
 
1725
- outputs = self.session.run(None, inputs)
1901
+ # Convert PIL to OpenCV BGR format
1902
+ input_image = np.array(img_pil.convert('RGB'))[:, :, ::-1]
1726
1903
 
1727
- # print(outputs)
1728
-
1729
- # --- Post-processing ---
1730
- if self.model_name == "tiny":
1731
- boxes = outputs[0]
1732
- scores = [1.0] * len(boxes) # Tiny model doesn't output scores
1733
- else: # "small"
1734
- _, boxes, scores = outputs
1735
- boxes, scores = boxes[0], scores[0]
1904
+ # Run detection using meikiocr
1905
+ try:
1906
+ text_boxes = self.model.run_detection(input_image, conf_threshold=confidence_threshold)
1907
+ except Exception as e:
1908
+ logger.error(f'MeikiTextDetector error: {e}')
1909
+ return False, {'boxes': [], 'provider': 'meiki', 'crop_coords': None}
1736
1910
 
1911
+ # Convert meikiocr format to expected output format
1912
+ # meikiocr returns: [{'bbox': [x1, y1, x2, y2]}, ...]
1913
+ # we need: [{'box': [x1, y1, x2, y2], 'score': float}, ...]
1737
1914
  detections = []
1738
- for box, score in zip(boxes, scores):
1739
- if score < confidence_threshold:
1740
- continue
1741
-
1742
- x_min, y_min, x_max, y_max = box
1743
-
1744
- # Rescale box coordinates to the original image size
1745
- final_x_min = (x_min - pad_w) / ratio
1746
- final_y_min = (y_min - pad_h) / ratio
1747
- final_x_max = (x_max - pad_w) / ratio
1748
- final_y_max = (y_max - pad_h) / ratio
1749
-
1915
+ for text_box in text_boxes:
1916
+ bbox = text_box.get('bbox', [0, 0, 0, 0])
1917
+ # meikiocr doesn't return confidence scores from run_detection
1918
+ # so we use 1.0 as a placeholder (detection already passed threshold)
1750
1919
  detections.append({
1751
- "box": [final_x_min, final_y_min, final_x_max, final_y_max],
1752
- "score": float(score)
1920
+ "box": [float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3])],
1921
+ "score": 1.0
1753
1922
  })
1754
-
1755
- # print(f"Processed with '{self.model_name}' model. Found {len(detections)} boxes with confidence > {confidence_threshold}.")
1756
-
1923
+
1757
1924
  # Compute crop_coords as padded min/max of all detected boxes
1758
1925
  if detections:
1759
1926
  x_mins = [b['box'][0] for b in detections]
@@ -1783,6 +1950,9 @@ class MeikiTextDetector:
1783
1950
  "provider": 'meiki',
1784
1951
  "crop_coords": crop_coords
1785
1952
  }
1953
+
1954
+ if is_path:
1955
+ img_pil.close()
1786
1956
 
1787
1957
  return True, resp
1788
1958
 
@@ -1420,7 +1420,7 @@ def process_and_write_results(img_or_path, write_to=None, last_result=None, filt
1420
1420
  return orig_text, ''
1421
1421
 
1422
1422
  logger.opt(ansi=True).info(
1423
- f'Text recognized in {end_time - start_time:0.03f}s using <{engine_color}>{engine_instance.readable_name}</{engine_color}>: {text}')
1423
+ f'OCR Run {1 if not is_second_ocr else 2}: Text recognized in {end_time - start_time:0.03f}s using <{engine_color}>{engine_instance.readable_name}</{engine_color}>: {text}')
1424
1424
 
1425
1425
  if write_to == 'websocket':
1426
1426
  websocket_server_thread.send_text(text)
@@ -324,13 +324,15 @@ class AnkiConfirmationDialog(tk.Toplevel):
324
324
  # Clean up audio before closing
325
325
  self._cleanup_audio()
326
326
  # The screenshot_path is now correctly updated if the user chose a new one
327
- self.result = (True, self.sentence_text.get("1.0", tk.END).strip(), self.translation_text.get("1.0", tk.END).strip() if self.translation_text else None, self.screenshot_path, self.nsfw_tag_var.get())
327
+ # Include audio_path in the result tuple so TTS audio can be sent to Anki
328
+ self.result = (True, self.sentence_text.get("1.0", tk.END).strip(), self.translation_text.get("1.0", tk.END).strip() if self.translation_text else None, self.screenshot_path, self.nsfw_tag_var.get(), self.audio_path)
328
329
  self.destroy()
329
330
 
330
331
  def _on_no_voice(self):
331
332
  # Clean up audio before closing
332
333
  self._cleanup_audio()
333
- self.result = (False, self.sentence_text.get("1.0", tk.END).strip(), self.translation_text.get("1.0", tk.END).strip() if self.translation_text else None, self.screenshot_path, self.nsfw_tag_var.get())
334
+ # Include audio_path in the result tuple so TTS audio can be sent to Anki
335
+ self.result = (False, self.sentence_text.get("1.0", tk.END).strip(), self.translation_text.get("1.0", tk.END).strip() if self.translation_text else None, self.screenshot_path, self.nsfw_tag_var.get(), self.audio_path)
334
336
  self.destroy()
335
337
 
336
338
  def _on_cancel(self):
@@ -395,6 +395,7 @@ class ConfigApp:
395
395
  self.obs_password_value = tk.StringVar(value=self.settings.obs.password)
396
396
  self.obs_open_obs_value = tk.BooleanVar(value=self.settings.obs.open_obs)
397
397
  self.obs_close_obs_value = tk.BooleanVar(value=self.settings.obs.close_obs)
398
+ self.obs_path_value = tk.StringVar(value=self.settings.obs.obs_path)
398
399
  self.obs_minimum_replay_size_value = tk.StringVar(value=str(self.settings.obs.minimum_replay_size))
399
400
  self.automatically_manage_replay_buffer_value = tk.BooleanVar(value=self.settings.obs.automatically_manage_replay_buffer)
400
401
 
@@ -720,6 +721,7 @@ class ConfigApp:
720
721
  obs=OBS(
721
722
  open_obs=self.obs_open_obs_value.get(),
722
723
  close_obs=self.obs_close_obs_value.get(),
724
+ obs_path=self.obs_path_value.get(),
723
725
  host=self.obs_host_value.get(),
724
726
  port=int(self.obs_port_value.get()),
725
727
  password=self.obs_password_value.get(),
@@ -1959,6 +1961,16 @@ class ConfigApp:
1959
1961
  column=1, sticky='W', pady=2)
1960
1962
  self.current_row += 1
1961
1963
 
1964
+ obs_path_i18n = obs_i18n.get('obs_path', {})
1965
+ browse_text = self.i18n.get('buttons', {}).get('browse', 'Browse')
1966
+ HoverInfoLabelWidget(obs_frame, text=obs_path_i18n.get('label', '...'), tooltip=obs_path_i18n.get('tooltip', '...'),
1967
+ row=self.current_row, column=0)
1968
+ obs_path_entry = ttk.Entry(obs_frame, width=50, textvariable=self.obs_path_value)
1969
+ obs_path_entry.grid(row=self.current_row, column=1, sticky='EW', pady=2)
1970
+ ttk.Button(obs_frame, text=browse_text, command=lambda: self.browse_file(obs_path_entry),
1971
+ bootstyle="outline").grid(row=self.current_row, column=2, padx=5, pady=2)
1972
+ self.current_row += 1
1973
+
1962
1974
  host_i18n = obs_i18n.get('host', {})
1963
1975
  HoverInfoLabelWidget(obs_frame, text=host_i18n.get('label', '...'), tooltip=host_i18n.get('tooltip', '...'),
1964
1976
  row=self.current_row, column=0)
@@ -568,10 +568,13 @@ class OBS:
568
568
  password: str = "your_password"
569
569
  get_game_from_scene: bool = True
570
570
  minimum_replay_size: int = 0
571
-
572
- def __post__init__(self):
571
+ obs_path: str = ''
572
+
573
+ def __post_init__(self):
573
574
  # Force get_game_from_scene to be True
574
575
  self.get_game_from_scene = True
576
+ if not self.obs_path:
577
+ self.obs_path = os.path.join(get_app_directory(), "obs-studio/bin/64bit/obs64.exe") if is_windows() else "/usr/bin/obs"
575
578
 
576
579
 
577
580
  @dataclass_json
@@ -866,6 +869,7 @@ class StatsConfig:
866
869
  reading_hours_target_date: str = "" # Target date for reading hours goal (ISO format: YYYY-MM-DD)
867
870
  character_count_target_date: str = "" # Target date for character count goal (ISO format: YYYY-MM-DD)
868
871
  games_target_date: str = "" # Target date for games/VNs goal (ISO format: YYYY-MM-DD)
872
+ cards_mined_daily_target: int = 10 # Daily target for cards mined (default: 10 cards per day)
869
873
 
870
874
  @dataclass_json
871
875
  @dataclass
@@ -0,0 +1,12 @@
1
+ """
2
+ Cron system for GameSentenceMiner
3
+
4
+ This package provides scheduled task functionality for GSM.
5
+ """
6
+
7
+ from GameSentenceMiner.util.cron_table import CronTable
8
+ from GameSentenceMiner.util.cron.jiten_update import update_all_jiten_games
9
+ from GameSentenceMiner.util.cron.daily_rollup import run_daily_rollup
10
+ from GameSentenceMiner.util.cron.run_crons import run_due_crons
11
+
12
+ __all__ = ['CronTable', 'update_all_jiten_games', 'run_daily_rollup', 'run_due_crons']