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.
- GameSentenceMiner/__init__.py +39 -0
- GameSentenceMiner/anki.py +6 -3
- GameSentenceMiner/gametext.py +13 -2
- GameSentenceMiner/gsm.py +40 -3
- GameSentenceMiner/locales/en_us.json +4 -0
- GameSentenceMiner/locales/ja_jp.json +4 -0
- GameSentenceMiner/locales/zh_cn.json +4 -0
- GameSentenceMiner/obs.py +4 -1
- GameSentenceMiner/owocr/owocr/ocr.py +304 -134
- GameSentenceMiner/owocr/owocr/run.py +1 -1
- GameSentenceMiner/ui/anki_confirmation.py +4 -2
- GameSentenceMiner/ui/config_gui.py +12 -0
- GameSentenceMiner/util/configuration.py +6 -2
- GameSentenceMiner/util/cron/__init__.py +12 -0
- GameSentenceMiner/util/cron/daily_rollup.py +613 -0
- GameSentenceMiner/util/cron/jiten_update.py +397 -0
- GameSentenceMiner/util/cron/populate_games.py +154 -0
- GameSentenceMiner/util/cron/run_crons.py +148 -0
- GameSentenceMiner/util/cron/setup_populate_games_cron.py +118 -0
- GameSentenceMiner/util/cron_table.py +334 -0
- GameSentenceMiner/util/db.py +236 -49
- GameSentenceMiner/util/ffmpeg.py +23 -4
- GameSentenceMiner/util/games_table.py +340 -93
- GameSentenceMiner/util/jiten_api_client.py +188 -0
- GameSentenceMiner/util/stats_rollup_table.py +216 -0
- GameSentenceMiner/web/anki_api_endpoints.py +438 -220
- GameSentenceMiner/web/database_api.py +955 -1259
- GameSentenceMiner/web/jiten_database_api.py +1015 -0
- GameSentenceMiner/web/rollup_stats.py +672 -0
- GameSentenceMiner/web/static/css/dashboard-shared.css +75 -13
- GameSentenceMiner/web/static/css/overview.css +604 -47
- GameSentenceMiner/web/static/css/search.css +226 -0
- GameSentenceMiner/web/static/css/shared.css +762 -0
- GameSentenceMiner/web/static/css/stats.css +221 -0
- GameSentenceMiner/web/static/js/components/bar-chart.js +339 -0
- GameSentenceMiner/web/static/js/database-bulk-operations.js +320 -0
- GameSentenceMiner/web/static/js/database-game-data.js +390 -0
- GameSentenceMiner/web/static/js/database-game-operations.js +213 -0
- GameSentenceMiner/web/static/js/database-helpers.js +44 -0
- GameSentenceMiner/web/static/js/database-jiten-integration.js +750 -0
- GameSentenceMiner/web/static/js/database-popups.js +89 -0
- GameSentenceMiner/web/static/js/database-tabs.js +64 -0
- GameSentenceMiner/web/static/js/database-text-management.js +371 -0
- GameSentenceMiner/web/static/js/database.js +86 -718
- GameSentenceMiner/web/static/js/goals.js +79 -18
- GameSentenceMiner/web/static/js/heatmap.js +29 -23
- GameSentenceMiner/web/static/js/overview.js +1205 -339
- GameSentenceMiner/web/static/js/regex-patterns.js +100 -0
- GameSentenceMiner/web/static/js/search.js +215 -18
- GameSentenceMiner/web/static/js/shared.js +193 -39
- GameSentenceMiner/web/static/js/stats.js +1536 -179
- GameSentenceMiner/web/stats.py +1142 -269
- GameSentenceMiner/web/stats_api.py +2104 -0
- GameSentenceMiner/web/templates/anki_stats.html +4 -18
- GameSentenceMiner/web/templates/components/date-range.html +118 -3
- GameSentenceMiner/web/templates/components/html-head.html +40 -6
- GameSentenceMiner/web/templates/components/js-config.html +8 -8
- GameSentenceMiner/web/templates/components/regex-input.html +160 -0
- GameSentenceMiner/web/templates/database.html +564 -117
- GameSentenceMiner/web/templates/goals.html +41 -5
- GameSentenceMiner/web/templates/overview.html +159 -129
- GameSentenceMiner/web/templates/search.html +78 -9
- GameSentenceMiner/web/templates/stats.html +159 -5
- GameSentenceMiner/web/texthooking_page.py +280 -111
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/METADATA +43 -2
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/RECORD +70 -47
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/WHEEL +0 -0
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/entry_points.txt +0 -0
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
1839
|
+
A class to perform text detection using the meikiocr package.
|
|
1588
1840
|
|
|
1589
|
-
This class
|
|
1590
|
-
|
|
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
|
|
1851
|
+
Initializes the detector using the meikiocr package.
|
|
1601
1852
|
|
|
1602
1853
|
Args:
|
|
1603
|
-
model_name (str):
|
|
1604
|
-
|
|
1854
|
+
model_name (str): Not used in the new implementation (meikiocr uses its own model).
|
|
1855
|
+
Kept for compatibility.
|
|
1605
1856
|
"""
|
|
1606
|
-
|
|
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
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1679
|
-
|
|
1680
|
-
|
|
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
|
-
#
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
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
|
-
|
|
1901
|
+
# Convert PIL to OpenCV BGR format
|
|
1902
|
+
input_image = np.array(img_pil.convert('RGB'))[:, :, ::-1]
|
|
1726
1903
|
|
|
1727
|
-
#
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
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
|
|
1739
|
-
|
|
1740
|
-
|
|
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": [
|
|
1752
|
-
"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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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']
|