GameSentenceMiner 2.11.2__py3-none-any.whl → 2.11.4__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.
GameSentenceMiner/obs.py CHANGED
@@ -203,6 +203,7 @@ def connect_to_obs_sync(retry=2):
203
203
  obs_connection_manager = OBSConnectionManager()
204
204
  obs_connection_manager.start()
205
205
  update_current_game()
206
+ logger.info("Connected to OBS WebSocket.")
206
207
  break # Exit the loop once connected
207
208
  except Exception as e:
208
209
  if retry <= 0:
@@ -295,6 +296,12 @@ def get_source_from_scene(scene_name):
295
296
  logger.error(f"Error getting source from scene: {e}")
296
297
  return ''
297
298
 
299
+ def get_active_source():
300
+ current_game = get_current_game()
301
+ if not current_game:
302
+ return None
303
+ return get_source_from_scene(current_game)
304
+
298
305
  def get_record_directory():
299
306
  try:
300
307
  response = client.get_record_directory()
@@ -362,7 +369,7 @@ def get_screenshot_base64(compression=0, width=None, height=None):
362
369
  return None
363
370
  response = client.get_source_screenshot(name=current_source_name, img_format='png', quality=compression, width=width, height=height)
364
371
  if response and response.image_data:
365
- return response.image_data
372
+ return response.image_data.split(',', 1)[-1] # Remove data:image/png;base64, prefix if present
366
373
  else:
367
374
  logger.error(f"Error getting base64 screenshot: {response}")
368
375
  return None
@@ -1,12 +1,15 @@
1
- import ctypes
1
+ import os
2
2
  from copy import deepcopy
3
3
  from dataclasses import dataclass
4
4
  from math import floor, ceil
5
+ from pathlib import Path
5
6
 
7
+ from GameSentenceMiner import obs
6
8
  from dataclasses_json import dataclass_json
7
9
  from typing import List, Optional, Union
8
10
 
9
- from GameSentenceMiner.util.configuration import logger
11
+ from GameSentenceMiner.util.configuration import logger, get_app_directory
12
+ from GameSentenceMiner.util.gsm_utils import sanitize_filename
10
13
 
11
14
 
12
15
  @dataclass_json
@@ -50,8 +53,10 @@ class OCRConfig:
50
53
  window: Optional[str] = None
51
54
  language: str = "ja"
52
55
 
53
- def scale_coords(self):
56
+ def __post_init__(self):
54
57
  self.pre_scale_rectangles = deepcopy(self.rectangles)
58
+
59
+ def scale_coords(self):
55
60
  if self.coordinate_system and self.coordinate_system == "percentage" and self.window:
56
61
  import pygetwindow as gw
57
62
  try:
@@ -116,7 +121,28 @@ def get_window(title):
116
121
  return window
117
122
  return ret
118
123
 
119
- # try w10+, fall back to w8.1+
124
+ # if windows, set dpi awareness to per-monitor v2
120
125
  def set_dpi_awareness():
126
+ import sys
127
+ if sys.platform != "win32":
128
+ return
129
+ import ctypes
121
130
  per_monitor_awareness = 2
122
131
  ctypes.windll.shcore.SetProcessDpiAwareness(per_monitor_awareness)
132
+
133
+ def get_scene_ocr_config(use_window_as_config=False, window=""):
134
+ ocr_config_dir = get_ocr_config_path()
135
+ try:
136
+ if use_window_as_config:
137
+ scene = sanitize_filename(window)
138
+ else:
139
+ scene = sanitize_filename(obs.get_current_scene() or "Default")
140
+ except Exception as e:
141
+ print(f"Error getting OBS scene: {e}. Using default config name.")
142
+ scene = "Default"
143
+ return os.path.join(ocr_config_dir, f"{scene}.json")
144
+
145
+ def get_ocr_config_path():
146
+ ocr_config_dir = os.path.join(get_app_directory(), "ocr_config")
147
+ os.makedirs(ocr_config_dir, exist_ok=True)
148
+ return ocr_config_dir
@@ -1,23 +1,19 @@
1
+ import argparse
2
+ import base64
1
3
  import ctypes
4
+ import io
2
5
  import json
3
6
  import sys
4
7
  from multiprocessing import Process, Manager
5
8
  from pathlib import Path
6
9
 
7
- import mss
8
10
  from PIL import Image, ImageTk
9
11
 
10
12
  # Assuming a mock or real obs module exists in this path
11
13
  from GameSentenceMiner import obs
12
- from GameSentenceMiner.ocr.gsm_ocr_config import set_dpi_awareness, get_window
14
+ from GameSentenceMiner.ocr.gsm_ocr_config import set_dpi_awareness, get_window, get_scene_ocr_config
13
15
  from GameSentenceMiner.util.gsm_utils import sanitize_filename
14
16
 
15
- try:
16
- import pygetwindow as gw
17
- except ImportError:
18
- print("Error: pygetwindow library not found. Please install it: pip install pygetwindow")
19
- gw = None
20
-
21
17
  try:
22
18
  import tkinter as tk
23
19
  from tkinter import font as tkfont # NEW: Import for better font control
@@ -34,30 +30,59 @@ COORD_SYSTEM_PERCENTAGE = "percentage"
34
30
 
35
31
 
36
32
  class ScreenSelector:
37
- def __init__(self, result, window_name, use_window_as_config):
38
- if not selector_available or not gw:
39
- raise RuntimeError("tkinter or pygetwindow is not available.")
33
+ def __init__(self, result, window_name, use_window_as_config, use_obs_screenshot=False):
34
+ if not selector_available:
35
+ raise RuntimeError("tkinter is not available.")
40
36
  if not window_name:
41
- raise ValueError("A target window name is required for percentage-based coordinates.")
37
+ raise ValueError("A target window name is required for configuration.")
42
38
 
43
39
  obs.connect_to_obs_sync()
44
40
  self.window_name = window_name
45
- print(f"Targeting window: '{window_name}'")
46
-
47
- self.sct = mss.mss()
48
- self.monitors = self.sct.monitors[1:]
49
- if not self.monitors:
50
- raise RuntimeError("No monitors found by mss.")
51
- for i, monitor in enumerate(self.monitors):
52
- monitor['index'] = i
53
-
54
- # --- Window Awareness is now critical ---
55
- self.target_window = self._find_target_window()
56
- self.target_window_geometry = self._get_window_geometry(self.target_window)
57
- if not self.target_window_geometry:
58
- raise RuntimeError(f"Could not find or get geometry for window '{self.window_name}'.")
59
- print(f"Found target window at: {self.target_window_geometry}")
60
- # ---
41
+ self.use_obs_screenshot = use_obs_screenshot
42
+ self.screenshot_img = None
43
+ try:
44
+ import mss
45
+ self.sct = mss.mss()
46
+ self.monitors = self.sct.monitors[1:]
47
+ if not self.monitors:
48
+ raise RuntimeError("No monitors found by mss.")
49
+ for i, monitor in enumerate(self.monitors):
50
+ monitor['index'] = i
51
+ except ImportError:
52
+ print("Error: mss library not found. Please install it: pip install mss")
53
+ raise RuntimeError("mss is required for screen selection.")
54
+
55
+ if self.use_obs_screenshot:
56
+ print("Using OBS screenshot as target.")
57
+ screenshot_base64 = obs.get_screenshot_base64(compression=75)
58
+ # print(screenshot_base64)
59
+ if not screenshot_base64:
60
+ raise RuntimeError("Failed to get OBS screenshot.")
61
+ try:
62
+ img_data = base64.b64decode(screenshot_base64)
63
+ self.screenshot_img = Image.open(io.BytesIO(img_data))
64
+ # Scale image to 1280x720
65
+ self.screenshot_img = self.screenshot_img.resize((1280, 720), Image.LANCZOS)
66
+ except Exception as e:
67
+ raise RuntimeError(f"Failed to decode or open OBS screenshot: {e}")
68
+
69
+ self.target_window = None
70
+ self.target_window_geometry = {
71
+ "left": 0, "top": 0,
72
+ "width": self.screenshot_img.width,
73
+ "height": self.screenshot_img.height
74
+ }
75
+ print(f"OBS Screenshot dimensions: {self.target_window_geometry}")
76
+ else:
77
+ import pygetwindow as gw
78
+ if not gw:
79
+ raise RuntimeError("pygetwindow is not available for window selection.")
80
+ print(f"Targeting window: '{window_name}'")
81
+ self.target_window = self._find_target_window()
82
+ self.target_window_geometry = self._get_window_geometry(self.target_window)
83
+ if not self.target_window_geometry:
84
+ raise RuntimeError(f"Could not find or get geometry for window '{self.window_name}'.")
85
+ print(f"Found target window at: {self.target_window_geometry}")
61
86
 
62
87
  self.root = None
63
88
  self.scene = ''
@@ -71,6 +96,11 @@ class ScreenSelector:
71
96
  self.redo_stack = []
72
97
  self.bounding_box = {} # Geometry of the single large canvas window
73
98
 
99
+ self.canvas = None
100
+ self.window = None
101
+ self.instructions_widget = None
102
+ self.instructions_window_id = None
103
+
74
104
  self.load_existing_rectangles()
75
105
 
76
106
  def _find_target_window(self):
@@ -91,23 +121,9 @@ class ScreenSelector:
91
121
  return None
92
122
  return None
93
123
 
94
- def get_scene_ocr_config(self):
95
- app_dir = Path.home() / "AppData" / "Roaming" / "GameSentenceMiner"
96
- ocr_config_dir = app_dir / "ocr_config"
97
- ocr_config_dir.mkdir(parents=True, exist_ok=True)
98
- try:
99
- if self.use_window_as_config:
100
- self.scene = sanitize_filename(self.window_name)
101
- else:
102
- self.scene = sanitize_filename(obs.get_current_scene() or "")
103
- except Exception as e:
104
- print(f"Error getting OBS scene: {e}. Using default config name.")
105
- self.scene = ""
106
- return ocr_config_dir / f"{self.scene}.json"
107
-
108
124
  def load_existing_rectangles(self):
109
125
  """Loads rectangles from config, converting from percentage to absolute pixels for use."""
110
- config_path = self.get_scene_ocr_config()
126
+ config_path = get_scene_ocr_config(self.use_window_as_config, self.window_name)
111
127
  win_geom = self.target_window_geometry # Use current geometry for conversion
112
128
  win_w, win_h, win_l, win_t = win_geom['width'], win_geom['height'], win_geom['left'], win_geom['top']
113
129
 
@@ -152,7 +168,7 @@ class ScreenSelector:
152
168
 
153
169
  def save_rects(self, event=None):
154
170
  """Saves rectangles to config, converting from absolute pixels to percentages."""
155
- config_path = self.get_scene_ocr_config()
171
+ config_path = get_scene_ocr_config(self.use_window_as_config, self.window_name)
156
172
  win_geom = self.target_window_geometry
157
173
  win_l, win_t, win_w, win_h = win_geom['left'], win_geom['top'], win_geom['width'], win_geom['height']
158
174
  print(f"Saving rectangles to: {config_path} relative to window: {win_geom}")
@@ -200,6 +216,12 @@ class ScreenSelector:
200
216
  event.widget.winfo_toplevel().winfo_children()[0].delete(last_rect_id)
201
217
  print("Undo: Removed last rectangle.")
202
218
 
219
+ def toggle_image_mode(self, e=None):
220
+ self.image_mode = not self.image_mode
221
+ # Only change alpha of the main window, not the text widget
222
+ self.window.attributes("-alpha", 1.0 if self.image_mode else 0.25)
223
+ print("Toggled background visibility.")
224
+
203
225
  def redo_last_rect(self, event=None):
204
226
  if not self.redo_stack: return
205
227
  monitor, abs_coords, is_excluded, old_rect_id = self.redo_stack.pop()
@@ -213,8 +235,63 @@ class ScreenSelector:
213
235
  print("Redo: Restored rectangle.")
214
236
 
215
237
  # --- NEW METHOD TO DISPLAY INSTRUCTIONS ---
216
- def _create_instructions_widget(self, canvas):
217
- """Creates a text box with usage instructions on the canvas."""
238
+ def _create_instructions_widget(self, parent_canvas):
239
+ """Creates a separate, persistent window for instructions and control buttons."""
240
+ if self.instructions_widget and self.instructions_widget.winfo_exists():
241
+ self.instructions_widget.lift()
242
+ return
243
+
244
+ self.instructions_widget = tk.Toplevel(parent_canvas)
245
+ self.instructions_widget.title("Controls")
246
+
247
+ # --- Position it near the main window ---
248
+ parent_window = parent_canvas.winfo_toplevel()
249
+ # Make the instructions window transient to the main window to keep it on top
250
+ # self.instructions_widget.transient(parent_window)
251
+ self.instructions_widget.attributes('-topmost', 1)
252
+ # parent_window.update_idletasks() # Ensure dimensions are up-to-date
253
+ pos_x = parent_window.winfo_x() + 50
254
+ pos_y = parent_window.winfo_y() + 50
255
+ self.instructions_widget.geometry(f"+{pos_x}+{pos_y}")
256
+
257
+ main_frame = tk.Frame(self.instructions_widget, padx=10, pady=10)
258
+ main_frame.pack(fill=tk.BOTH, expand=True)
259
+
260
+ instructions_text = (
261
+ "How to Use:\n"
262
+ "• Left Click + Drag: Create a capture area (green).\n"
263
+ "• Shift + Left Click + Drag: Create an exclusion area (orange).\n"
264
+ "• Right-Click on a box: Delete it."
265
+ )
266
+ tk.Label(main_frame, text=instructions_text, justify=tk.LEFT, anchor="w").pack(pady=(0, 10), fill=tk.X)
267
+
268
+ button_frame = tk.Frame(main_frame)
269
+ button_frame.pack(fill=tk.X, pady=5)
270
+
271
+ def canvas_event_wrapper(func):
272
+ class MockEvent:
273
+ def __init__(self, widget):
274
+ self.widget = widget
275
+ return lambda: func(MockEvent(self.canvas))
276
+
277
+ def root_event_wrapper(func):
278
+ return lambda: func(None)
279
+
280
+ tk.Button(button_frame, text="Save and Quit (Ctrl+S)", command=root_event_wrapper(self.save_rects)).pack(fill=tk.X, pady=2)
281
+ tk.Button(button_frame, text="Undo (Ctrl+Z)", command=canvas_event_wrapper(self.undo_last_rect)).pack(fill=tk.X, pady=2)
282
+ tk.Button(button_frame, text="Redo (Ctrl+Y)", command=canvas_event_wrapper(self.redo_last_rect)).pack(fill=tk.X, pady=2)
283
+ tk.Button(button_frame, text="Toggle Background (M)", command=root_event_wrapper(self.toggle_image_mode)).pack(fill=tk.X, pady=2)
284
+ tk.Button(button_frame, text="Quit without Saving (Esc)", command=root_event_wrapper(self.quit_app)).pack(fill=tk.X, pady=2)
285
+
286
+ hotkeys_text = "\n• I: Toggle this instruction panel"
287
+ tk.Label(main_frame, text=hotkeys_text, justify=tk.LEFT, anchor="w").pack(pady=(10, 0), fill=tk.X)
288
+
289
+ self.instructions_widget.protocol("WM_DELETE_WINDOW", self.toggle_instructions)
290
+
291
+
292
+ # --- NEW METHOD TO DISPLAY INSTRUCTIONS ---
293
+ def print_instructions_box(self, canvas):
294
+ """Creates a separate, persistent window for instructions and control buttons."""
218
295
  instructions_text = (
219
296
  "How to Use:\n"
220
297
  " • Left Click + Drag: Create a capture area (green).\n"
@@ -260,44 +337,51 @@ class ScreenSelector:
260
337
  canvas.tag_lower(rect_id, text_id)
261
338
 
262
339
  def toggle_instructions(self, event=None):
263
- canvas = event.widget.winfo_toplevel().winfo_children()[0]
264
- # Find all text and rectangle items (assuming only one of each for instructions)
265
- text_items = [item for item in canvas.find_all() if canvas.type(item) == 'text']
266
- rect_items = [item for item in canvas.find_all() if canvas.type(item) == 'rectangle']
267
-
268
- if text_items and rect_items:
269
- current_state = canvas.itemcget(text_items[0], 'state')
270
- new_state = tk.NORMAL if current_state == tk.HIDDEN else tk.HIDDEN
271
- for item in text_items + rect_items:
272
- canvas.itemconfigure(item, state=new_state)
273
- print("Toggled instructions visibility.")
340
+ if self.instructions_widget and self.instructions_widget.winfo_exists() and self.instructions_widget.state() == "normal":
341
+ self.instructions_widget.withdraw()
342
+ print("Toggled instructions visibility: OFF")
343
+ else:
344
+ self._create_instructions_widget(self.canvas)
345
+ print("Toggled instructions visibility: ON")
274
346
 
275
347
  def start(self):
276
348
  self.root = tk.Tk()
277
349
  self.root.withdraw()
278
350
 
279
- # Calculate bounding box of all monitors
280
- left = min(m['left'] for m in self.monitors)
281
- top = min(m['top'] for m in self.monitors)
282
- right = max(m['left'] + m['width'] for m in self.monitors)
283
- bottom = max(m['top'] + m['height'] for m in self.monitors)
284
- self.bounding_box = {'left': left, 'top': top, 'width': right - left, 'height': bottom - top}
285
-
286
- sct_img = self.sct.grab(self.sct.monitors[0])
287
- img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
288
-
289
- window = tk.Toplevel(self.root)
290
- window.geometry(f"{self.bounding_box['width']}x{self.bounding_box['height']}+{left}+{top}")
291
- window.overrideredirect(1)
292
- window.attributes('-topmost', 1)
351
+ if self.use_obs_screenshot:
352
+ # Use the pre-loaded OBS screenshot
353
+ img = self.screenshot_img
354
+ self.bounding_box = self.target_window_geometry
355
+ # Center the window on the primary monitor
356
+ primary_monitor = self.sct.monitors[1] if len(self.sct.monitors) > 1 else self.sct.monitors[0]
357
+ win_x = primary_monitor['left'] + (primary_monitor['width'] - img.width) // 2
358
+ win_y = primary_monitor['top'] + (primary_monitor['height'] - img.height) // 2
359
+ window_geometry = f"{img.width}x{img.height}+{int(win_x)}+{int(win_y)}"
360
+ else:
361
+ # Calculate bounding box of all monitors for the overlay
362
+ left = min(m['left'] for m in self.monitors)
363
+ top = min(m['top'] for m in self.monitors)
364
+ right = max(m['left'] + m['width'] for m in self.monitors)
365
+ bottom = max(m['top'] + m['height'] for m in self.monitors)
366
+ self.bounding_box = {'left': left, 'top': top, 'width': right - left, 'height': bottom - top}
367
+
368
+ # Capture the entire desktop area covered by all monitors
369
+ sct_img = self.sct.grab(self.bounding_box)
370
+ img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
371
+ window_geometry = f"{self.bounding_box['width']}x{self.bounding_box['height']}+{left}+{top}"
372
+
373
+ self.window = tk.Toplevel(self.root)
374
+ self.window.geometry(window_geometry)
375
+ self.window.overrideredirect(1)
376
+ self.window.attributes('-topmost', 1)
293
377
 
294
378
  self.photo_image = ImageTk.PhotoImage(img)
295
- canvas = tk.Canvas(window, cursor='cross', highlightthickness=0)
296
- canvas.pack(fill=tk.BOTH, expand=True)
297
- canvas.create_image(0, 0, image=self.photo_image, anchor=tk.NW)
379
+ self.canvas = tk.Canvas(self.window, cursor='cross', highlightthickness=0)
380
+ self.canvas.pack(fill=tk.BOTH, expand=True)
381
+ self.canvas.create_image(0, 0, image=self.photo_image, anchor=tk.NW)
298
382
 
299
383
  # --- MODIFIED: CALL THE INSTRUCTION WIDGET CREATOR ---
300
- self._create_instructions_widget(canvas)
384
+ # self._create_instructions_widget(self.canvas)
301
385
  # --- END MODIFICATION ---
302
386
 
303
387
  # Draw existing rectangles (which were converted to absolute pixels on load)
@@ -305,29 +389,29 @@ class ScreenSelector:
305
389
  x_abs, y_abs, w_abs, h_abs = abs_coords
306
390
  canvas_x = x_abs - self.bounding_box['left']
307
391
  canvas_y = y_abs - self.bounding_box['top']
308
- rect_id = canvas.create_rectangle(canvas_x, canvas_y, canvas_x + w_abs, canvas_y + h_abs,
392
+ rect_id = self.canvas.create_rectangle(canvas_x, canvas_y, canvas_x + w_abs, canvas_y + h_abs,
309
393
  outline='orange' if is_excluded else 'green', width=2)
310
394
  self.drawn_rect_ids.append(rect_id)
311
395
 
312
396
  def on_click(event):
313
397
  self.start_x, self.start_y = event.x, event.y
314
398
  outline = 'purple' if bool(event.state & 0x0001) else 'red'
315
- self.current_rect_id = canvas.create_rectangle(self.start_x, self.start_y, self.start_x, self.start_y,
399
+ self.current_rect_id = self.canvas.create_rectangle(self.start_x, self.start_y, self.start_x, self.start_y,
316
400
  outline=outline, width=2)
317
401
 
318
402
  def on_drag(event):
319
- if self.current_rect_id: canvas.coords(self.current_rect_id, self.start_x, self.start_y, event.x, event.y)
403
+ if self.current_rect_id: self.canvas.coords(self.current_rect_id, self.start_x, self.start_y, event.x, event.y)
320
404
 
321
405
  def on_release(event):
322
406
  if not self.current_rect_id: return
323
- coords = canvas.coords(self.current_rect_id)
407
+ coords = self.canvas.coords(self.current_rect_id)
324
408
  x_abs = int(min(coords[0], coords[2]) + self.bounding_box['left'])
325
409
  y_abs = int(min(coords[1], coords[3]) + self.bounding_box['top'])
326
410
  w, h = int(abs(coords[2] - coords[0])), int(abs(coords[3] - coords[1]))
327
411
 
328
412
  if w >= MIN_RECT_WIDTH and h >= MIN_RECT_HEIGHT:
329
413
  is_excl = bool(event.state & 0x0001)
330
- canvas.itemconfig(self.current_rect_id, outline='orange' if is_excl else 'green')
414
+ self.canvas.itemconfig(self.current_rect_id, outline='orange' if is_excl else 'green')
331
415
 
332
416
  center_x, center_y = x_abs + w / 2, y_abs + h / 2
333
417
  target_mon = self.monitors[0]
@@ -341,7 +425,7 @@ class ScreenSelector:
341
425
  self.drawn_rect_ids.append(self.current_rect_id)
342
426
  self.redo_stack.clear()
343
427
  else:
344
- canvas.delete(self.current_rect_id)
428
+ self.canvas.delete(self.current_rect_id)
345
429
  self.current_rect_id = self.start_x = self.start_y = None
346
430
 
347
431
  def on_right_click(event):
@@ -366,45 +450,45 @@ class ScreenSelector:
366
450
  # Now, perform the deletion
367
451
  del self.rectangles[i]
368
452
  del self.drawn_rect_ids[i]
369
- canvas.delete(item_id_to_del)
453
+ self.canvas.delete(item_id_to_del)
370
454
  print("Deleted rectangle.")
371
455
 
372
456
  break # Stop after deleting the topmost one
373
457
 
374
- def toggle_image_mode(e=None):
375
- self.image_mode = not self.image_mode
376
- # Only change alpha of the main window, not the text widget
377
- window.attributes("-alpha", 1.0 if self.image_mode else 0.25)
378
- print("Toggled background visibility.")
379
-
380
458
  def on_enter(e=None):
381
- canvas.focus_set()
382
-
383
- canvas.bind('<Enter>', on_enter)
384
- canvas.bind('<ButtonPress-1>', on_click)
385
- canvas.bind('<B1-Motion>', on_drag)
386
- canvas.bind('<ButtonRelease-1>', on_release)
387
- canvas.bind('<Button-3>', on_right_click)
388
- canvas.bind('<Control-s>', self.save_rects)
389
- canvas.bind('<Control-y>', self.redo_last_rect)
390
- canvas.bind('<Control-z>', self.undo_last_rect)
391
- canvas.bind("<Escape>", self.quit_app)
392
- canvas.bind("<m>", toggle_image_mode)
393
- canvas.bind("<i>", self.toggle_instructions)
394
-
395
- canvas.focus_set()
459
+ self.canvas.focus_set()
460
+
461
+ self.canvas.bind('<Enter>', on_enter)
462
+ self.canvas.bind('<ButtonPress-1>', on_click)
463
+ self.canvas.bind('<B1-Motion>', on_drag)
464
+ self.canvas.bind('<ButtonRelease-1>', on_release)
465
+ self.canvas.bind('<Button-3>', on_right_click)
466
+ self.canvas.bind('<Control-s>', self.save_rects)
467
+ self.canvas.bind('<Control-y>', self.redo_last_rect)
468
+ self.canvas.bind('<Control-z>', self.undo_last_rect)
469
+ self.canvas.bind("<Escape>", self.quit_app)
470
+ self.canvas.bind("<m>", self.toggle_image_mode)
471
+ self.canvas.bind("<i>", self.toggle_instructions)
472
+
473
+ self.canvas.focus_set()
474
+ self._create_instructions_widget(self.window)
475
+ self.window.winfo_toplevel().update_idletasks()
476
+ self.print_instructions_box(self.canvas)
396
477
  # The print message is now redundant but kept for console feedback
397
478
  print("Starting UI. See on-screen instructions. Press Esc to quit, Ctrl+S to save.")
479
+ # self.canvas.update_idletasks()
398
480
  self.root.mainloop()
399
481
 
400
482
  def quit_app(self, event=None):
483
+ if self.instructions_widget and self.instructions_widget.winfo_exists():
484
+ self.instructions_widget.destroy()
401
485
  if self.root and self.root.winfo_exists(): self.root.destroy()
402
486
  self.root = None
403
487
 
404
488
 
405
- def run_screen_selector(result_dict, window_name, use_window_as_config):
489
+ def run_screen_selector(result_dict, window_name, use_window_as_config, use_obs_screenshot):
406
490
  try:
407
- selector = ScreenSelector(result_dict, window_name, use_window_as_config)
491
+ selector = ScreenSelector(result_dict, window_name, use_window_as_config, use_obs_screenshot)
408
492
  selector.start()
409
493
  except Exception as e:
410
494
  print(f"Error in selector process: {e}", file=sys.stderr)
@@ -413,15 +497,15 @@ def run_screen_selector(result_dict, window_name, use_window_as_config):
413
497
  result_dict['error'] = str(e)
414
498
 
415
499
 
416
- def get_screen_selection(window_name, use_window_as_config=False):
417
- if not selector_available or not gw: return None
500
+ def get_screen_selection(window_name, use_window_as_config=False, use_obs_screenshot=False):
501
+ if not selector_available: return None
418
502
  if not window_name:
419
503
  print("Error: A target window name must be provided.", file=sys.stderr)
420
504
  return None
421
505
 
422
506
  with Manager() as manager:
423
507
  result_data = manager.dict()
424
- process = Process(target=run_screen_selector, args=(result_data, window_name, use_window_as_config))
508
+ process = Process(target=run_screen_selector, args=(result_data, window_name, use_window_as_config, use_obs_screenshot))
425
509
  print(f"Starting ScreenSelector process...")
426
510
  process.start()
427
511
  process.join()
@@ -439,18 +523,24 @@ def get_screen_selection(window_name, use_window_as_config=False):
439
523
 
440
524
  if __name__ == "__main__":
441
525
  set_dpi_awareness()
442
- target_window_title = "YouTube - JP"
443
- use_window_as_config = False
444
- if len(sys.argv) > 1:
445
- target_window_title = sys.argv[1]
446
- if len(sys.argv) > 2:
447
- use_window_as_config = True
448
- target_window_title = sys.argv[1]
449
526
 
450
- selection_result = get_screen_selection(target_window_title, use_window_as_config)
527
+ parser = argparse.ArgumentParser(description="Screen Selector Arguments")
528
+ parser.add_argument("window_title", nargs="?", default="", help="Target window title")
529
+ parser.add_argument("--obs_ocr", action="store_true", help="Use OBS screenshot")
530
+ parser.add_argument("--use_window_for_config", action="store_true", help="Use window for config")
531
+ args = parser.parse_args()
532
+
533
+ target_window_title = args.window_title
534
+ use_obs_screenshot = args.obs_ocr
535
+ use_window_as_config = args.use_window_for_config
536
+
537
+ print(f"Arguments: Window Title='{target_window_title}', Use OBS Screenshot={use_obs_screenshot}, Use Window for Config={use_window_as_config}")
538
+
539
+ # Example of how to call it
540
+ selection_result = get_screen_selection(target_window_title, use_window_as_config, use_obs_screenshot)
451
541
 
452
542
  if selection_result is None:
453
- print("\n--- Screen selection failed. ---")
543
+ print("--- Screen selection failed. ---")
454
544
  elif not selection_result:
455
545
  print("\n--- Screen selection cancelled. ---")
456
546
  elif 'rectangles' in selection_result:
@@ -22,7 +22,7 @@ from GameSentenceMiner.ocr.ss_picker import ScreenCropper
22
22
  from GameSentenceMiner.owocr.owocr.run import TextFiltering
23
23
  from GameSentenceMiner.util.configuration import get_config, get_app_directory, get_temporary_directory
24
24
  from GameSentenceMiner.util.electron_config import get_ocr_scan_rate, get_requires_open_window
25
- from GameSentenceMiner.ocr.gsm_ocr_config import OCRConfig, set_dpi_awareness, get_window
25
+ from GameSentenceMiner.ocr.gsm_ocr_config import OCRConfig, set_dpi_awareness, get_window, get_ocr_config_path
26
26
  from GameSentenceMiner.owocr.owocr import screen_coordinate_picker, run
27
27
  from GameSentenceMiner.util.gsm_utils import sanitize_filename, do_text_replacements, OCR_REPLACEMENTS_FILE
28
28
 
@@ -49,15 +49,13 @@ logger.addHandler(console_handler)
49
49
 
50
50
  def get_ocr_config(window=None, use_window_for_config=False) -> OCRConfig:
51
51
  """Loads and updates screen capture areas from the corresponding JSON file."""
52
- app_dir = Path.home() / "AppData" / "Roaming" / "GameSentenceMiner"
53
- ocr_config_dir = app_dir / "ocr_config"
54
- os.makedirs(ocr_config_dir, exist_ok=True)
52
+ ocr_config_dir = get_ocr_config_path()
55
53
  obs.connect_to_obs_sync(retry=0)
56
54
  if use_window_for_config and window:
57
55
  scene = sanitize_filename(window)
58
56
  else:
59
57
  scene = sanitize_filename(obs.get_current_scene())
60
- config_path = ocr_config_dir / f"{scene}.json"
58
+ config_path = Path(ocr_config_dir) / f"{scene}.json"
61
59
  if not config_path.exists():
62
60
  ocr_config = OCRConfig(scene=scene, window=window, rectangles=[], coordinate_system="percentage")
63
61
  with open(config_path, 'w', encoding="utf-8") as f:
@@ -202,7 +200,8 @@ def do_second_ocr(ocr1_text, time, img, filtering, ignore_furigana_filter=False,
202
200
  engine=ocr2, furigana_filter_sensitivity=furigana_filter_sensitivity if not ignore_furigana_filter else 0)
203
201
 
204
202
  if compare_ocr_results(last_ocr2_result, orig_text):
205
- logger.info("Detected similar text from previous OCR2 result, not sending")
203
+ if text:
204
+ logger.info("Seems like Text we already sent, not doing anything.")
206
205
  return
207
206
  save_result_image(img)
208
207
  last_ocr2_result = orig_text
@@ -257,7 +256,8 @@ def text_callback(text, orig_text, time, img=None, came_from_ss=False, filtering
257
256
 
258
257
  if manual or not twopassocr:
259
258
  if compare_ocr_results(previous_orig_text, orig_text_string):
260
- logger.info("Seems like Text we already sent, not doing anything.")
259
+ if text:
260
+ logger.info("Seems like Text we already sent, not doing anything.")
261
261
  return
262
262
  save_result_image(img)
263
263
  asyncio.run(send_result(text, line_start_time))
@@ -275,7 +275,8 @@ def text_callback(text, orig_text, time, img=None, came_from_ss=False, filtering
275
275
  stable_time = text_stable_start_time
276
276
  previous_img_local = previous_img
277
277
  if compare_ocr_results(previous_orig_text, orig_text_string):
278
- logger.info("Seems like Text we already sent, not doing anything.")
278
+ if text:
279
+ logger.info("Seems like Text we already sent, not doing anything.")
279
280
  previous_text = None
280
281
  return
281
282
  previous_orig_text = orig_text_string
@@ -292,6 +293,10 @@ def text_callback(text, orig_text, time, img=None, came_from_ss=False, filtering
292
293
  previous_text = None
293
294
  return
294
295
 
296
+ # Make sure it's an actual new line before starting the timer
297
+ if compare_ocr_results(orig_text_string, previous_orig_text):
298
+ return
299
+
295
300
  if not text_stable_start_time:
296
301
  text_stable_start_time = line_start_time
297
302
  previous_text = text
@@ -327,8 +332,14 @@ def run_oneocr(ocr_config: OCRConfig, rectangles):
327
332
 
328
333
  run.init_config(False)
329
334
  try:
330
- run.run(read_from="screencapture" if window else "",
331
- read_from_secondary="clipboard" if ss_clipboard else None,
335
+ read_from = ""
336
+ if obs_ocr:
337
+ read_from = "obs"
338
+ elif window:
339
+ read_from = "screencapture"
340
+ read_from_secondary = "clipboard" if ss_clipboard else None
341
+ run.run(read_from=read_from,
342
+ read_from_secondary=read_from_secondary,
332
343
  write_to="callback",
333
344
  screen_capture_area=screen_area,
334
345
  # screen_capture_monitor=monitor_config['index'],
@@ -405,7 +416,7 @@ def set_force_stable_hotkey():
405
416
 
406
417
  if __name__ == "__main__":
407
418
  try:
408
- global ocr1, ocr2, twopassocr, language, ss_clipboard, ss, ocr_config, furigana_filter_sensitivity, area_select_ocr_hotkey, window, optimize_second_scan, use_window_for_config, keep_newline
419
+ global ocr1, ocr2, twopassocr, language, ss_clipboard, ss, ocr_config, furigana_filter_sensitivity, area_select_ocr_hotkey, window, optimize_second_scan, use_window_for_config, keep_newline, obs_ocr
409
420
  import sys
410
421
 
411
422
  import argparse
@@ -430,6 +441,7 @@ if __name__ == "__main__":
430
441
  parser.add_argument("--use_window_for_config", action="store_true",
431
442
  help="Use the specified window for loading OCR configuration")
432
443
  parser.add_argument("--keep_newline", action="store_true", help="Keep new lines in OCR output")
444
+ parser.add_argument('--obs_ocr', action='store_true', help='Use OBS for Picture Source (not implemented)')
433
445
 
434
446
  args = parser.parse_args()
435
447
 
@@ -449,12 +461,13 @@ if __name__ == "__main__":
449
461
  optimize_second_scan = args.optimize_second_scan
450
462
  use_window_for_config = args.use_window_for_config
451
463
  keep_newline = args.keep_newline
464
+ obs_ocr = args.obs_ocr
452
465
 
453
466
  window = None
454
467
  logger.info(f"Received arguments: {vars(args)}")
455
468
  # set_force_stable_hotkey()
456
469
  ocr_config: OCRConfig = get_ocr_config(window=window_name, use_window_for_config=use_window_for_config)
457
- if ocr_config:
470
+ if ocr_config and not obs_ocr:
458
471
  if ocr_config.window:
459
472
  start_time = time.time()
460
473
  while time.time() - start_time < 30:
@@ -326,7 +326,6 @@ class GoogleLens:
326
326
  # logger.info(f"Vertical space: {vertical_space}, Average height: {avg_height}")
327
327
  # logger.info(avg_height * 2)
328
328
  if vertical_space > avg_height * 2:
329
- logger.info('Adding blank line')
330
329
  res += 'BLANK_LINE'
331
330
  for line in paragraph['lines']:
332
331
  if furigana_filter_sensitivity:
@@ -55,7 +55,7 @@ except ImportError:
55
55
  pass
56
56
  from .config import Config
57
57
  from .screen_coordinate_picker import get_screen_selection
58
- from GameSentenceMiner.util.configuration import get_temporary_directory
58
+ from GameSentenceMiner.util.configuration import get_temporary_directory, get_config
59
59
 
60
60
  config = None
61
61
 
@@ -763,6 +763,100 @@ class ScreenshotThread(threading.Thread):
763
763
  elif self.windows_window_tracker_instance:
764
764
  self.windows_window_tracker_instance.join()
765
765
 
766
+ # Use OBS for Screenshot Source (i.e. Linux)
767
+ class OBSScreenshotThread(threading.Thread):
768
+ def __init__(self, ocr_config, screen_capture_on_combo, width=1280, height=720, interval=1):
769
+ super().__init__(daemon=True)
770
+ self.ocr_config = ocr_config
771
+ self.interval = interval
772
+ self.obs_client = None
773
+ self.websocket = None
774
+ self.width = width
775
+ self.height = height
776
+ self.use_periodic_queue = not screen_capture_on_combo
777
+
778
+ def write_result(self, result):
779
+ if self.use_periodic_queue:
780
+ periodic_screenshot_queue.put(result)
781
+ else:
782
+ image_queue.put((result, True))
783
+
784
+ def connect_obs(self):
785
+ try:
786
+ import obsws_python as obs
787
+ self.obs_client = obs.ReqClient(
788
+ host=get_config().obs.host,
789
+ port=get_config().obs.port,
790
+ password=get_config().obs.password,
791
+ timeout=10
792
+ )
793
+ logger.info("Connected to OBS WebSocket.")
794
+ except Exception as e:
795
+ logger.error(f"Failed to connect to OBS: {e}")
796
+ self.obs_client = None
797
+
798
+ def run(self):
799
+ import base64
800
+ import io
801
+ from PIL import Image
802
+ import GameSentenceMiner.obs as obs
803
+
804
+ loop = asyncio.new_event_loop()
805
+ asyncio.set_event_loop(loop)
806
+
807
+ self.connect_obs()
808
+ self.ocr_config.scale_to_custom_size(self.width, self.height)
809
+ current_source = obs.get_active_source()
810
+ current_source_name = current_source.get('sourceName') if isinstance(current_source, dict) else None
811
+
812
+ while not terminated:
813
+ try:
814
+ response = self.obs_client.get_source_screenshot(
815
+ name=current_source_name,
816
+ img_format='png',
817
+ quality=75,
818
+ width=self.width,
819
+ height=self.height,
820
+ )
821
+
822
+ if response.image_data:
823
+ image_data = base64.b64decode(response.image_data.split(",")[1])
824
+ img = Image.open(io.BytesIO(image_data)).convert("RGBA")
825
+
826
+ for rectangle in self.ocr_config.rectangles:
827
+ if rectangle.is_excluded:
828
+ left, top, width, height = rectangle.coordinates
829
+ draw = ImageDraw.Draw(img)
830
+ draw.rectangle((left, top, left + width, top + height), fill=(0, 0, 0, 0))
831
+
832
+ cropped_sections = []
833
+ for rectangle in [r for r in self.ocr_config.rectangles if not r.is_excluded]:
834
+ area = rectangle.coordinates
835
+ cropped_sections.append(img.crop((area[0], area[1], area[0] + area[2], area[1] + area[3])))
836
+
837
+ if len(cropped_sections) > 1:
838
+ combined_width = max(section.width for section in cropped_sections)
839
+ combined_height = sum(section.height for section in cropped_sections) + (
840
+ len(cropped_sections) - 1) * 10
841
+ combined_img = Image.new("RGBA", (combined_width, combined_height))
842
+ y_offset = 0
843
+ for section in cropped_sections:
844
+ combined_img.paste(section, (0, y_offset))
845
+ y_offset += section.height + 50
846
+ img = combined_img
847
+ elif cropped_sections:
848
+ img = cropped_sections[0]
849
+
850
+ self.write_result(img)
851
+ else:
852
+ logger.error("Failed to get screenshot data from OBS.")
853
+
854
+ except Exception as e:
855
+ logger.error(f"An unexpected error occurred with OBS connection: {e}")
856
+ continue
857
+
858
+ time.sleep(self.interval)
859
+
766
860
  class AutopauseTimer:
767
861
  def __init__(self, timeout):
768
862
  self.stop_event = threading.Event()
@@ -1137,7 +1231,7 @@ def run(read_from=None,
1137
1231
  prefix_to_use = ""
1138
1232
  delay_secs = config.get_general('delay_secs')
1139
1233
 
1140
- non_path_inputs = ('screencapture', 'clipboard', 'websocket', 'unixsocket')
1234
+ non_path_inputs = ('screencapture', 'clipboard', 'websocket', 'unixsocket', 'obs')
1141
1235
  read_from_path = None
1142
1236
  read_from_readable = []
1143
1237
  terminated = False
@@ -1176,22 +1270,33 @@ def run(read_from=None,
1176
1270
  global txt_callback
1177
1271
  txt_callback = text_callback
1178
1272
 
1179
- if 'screencapture' in (read_from, read_from_secondary):
1180
- global take_screenshot
1273
+ if 'screencapture' in (read_from, read_from_secondary) or 'obs' in (read_from, read_from_secondary):
1181
1274
  global screenshot_event
1182
- last_screenshot_time = 0
1183
- last_result = ([], engine_index)
1275
+ global take_screenshot
1184
1276
  if screen_capture_combo != '':
1185
1277
  screen_capture_on_combo = True
1186
1278
  key_combos[screen_capture_combo] = on_screenshot_combo
1187
1279
  else:
1188
1280
  global periodic_screenshot_queue
1189
1281
  periodic_screenshot_queue = queue.Queue()
1282
+
1283
+ if 'screencapture' in (read_from, read_from_secondary):
1284
+ last_screenshot_time = 0
1285
+ last_result = ([], engine_index)
1286
+
1190
1287
  screenshot_event = threading.Event()
1191
1288
  screenshot_thread = ScreenshotThread(screen_capture_area, screen_capture_window, screen_capture_exclusions, screen_capture_only_active_windows, screen_capture_areas, screen_capture_on_combo)
1192
1289
  screenshot_thread.start()
1193
1290
  filtering = TextFiltering()
1194
1291
  read_from_readable.append('screen capture')
1292
+ if 'obs' in (read_from, read_from_secondary):
1293
+ last_screenshot_time = 0
1294
+ last_result = ([], engine_index)
1295
+ screenshot_event = threading.Event()
1296
+ obs_screenshot_thread = OBSScreenshotThread(gsm_ocr_config, screen_capture_on_combo, interval=screen_capture_delay_secs)
1297
+ obs_screenshot_thread.start()
1298
+ filtering = TextFiltering()
1299
+ read_from_readable.append('obs')
1195
1300
  if 'websocket' in (read_from, read_from_secondary):
1196
1301
  read_from_readable.append('websocket')
1197
1302
  if 'unixsocket' in (read_from, read_from_secondary):
@@ -1231,7 +1336,7 @@ def run(read_from=None,
1231
1336
  write_to_readable = f'file {write_to}'
1232
1337
 
1233
1338
  process_queue = (any(i in ('clipboard', 'websocket', 'unixsocket') for i in (read_from, read_from_secondary)) or read_from_path or screen_capture_on_combo)
1234
- process_screenshots = 'screencapture' in (read_from, read_from_secondary) and not screen_capture_on_combo
1339
+ process_screenshots = any(x in ('screencapture', 'obs') for x in (read_from, read_from_secondary)) and not screen_capture_on_combo
1235
1340
  if threading.current_thread() == threading.main_thread():
1236
1341
  signal.signal(signal.SIGINT, signal_handler)
1237
1342
  if (not process_screenshots) and auto_pause != 0:
@@ -1256,7 +1361,7 @@ def run(read_from=None,
1256
1361
  pass
1257
1362
 
1258
1363
  if (not img) and process_screenshots:
1259
- if (not paused) and screenshot_thread.screencapture_window_active and screenshot_thread.screencapture_window_visible and (time.time() - last_screenshot_time) > screen_capture_delay_secs:
1364
+ if (not paused) and (not screenshot_thread or (screenshot_thread.screencapture_window_active and screenshot_thread.screencapture_window_visible)) and (time.time() - last_screenshot_time) > screen_capture_delay_secs:
1260
1365
  screenshot_event.set()
1261
1366
  img = periodic_screenshot_queue.get()
1262
1367
  filter_img = True
@@ -35,6 +35,9 @@ class GameLine:
35
35
  def set_TL(self, tl: str):
36
36
  self.TL = tl
37
37
 
38
+ def get_stripped_text(self):
39
+ return self.text.replace('\n', '').strip()
40
+
38
41
  def __str__(self):
39
42
  return str({"text": self.text, "time": self.time})
40
43
 
@@ -151,10 +154,10 @@ def get_line_and_future_lines(last_note):
151
154
  found = False
152
155
  for line in game_log.values:
153
156
  if found:
154
- found_lines.append(line.text)
157
+ found_lines.append(line)
155
158
  if lines_match(line.text, remove_html_and_cloze_tags(sentence)): # 80% similarity threshold
156
159
  found = True
157
- found_lines.append(line.text)
160
+ found_lines.append(line)
158
161
  return found_lines
159
162
 
160
163
 
@@ -168,7 +171,7 @@ def get_mined_line(last_note: AnkiCard, lines=None):
168
171
 
169
172
  sentence = last_note.get_field(get_config().anki.sentence_field)
170
173
  for line in reversed(lines):
171
- if lines_match(line.text, remove_html_and_cloze_tags(sentence)):
174
+ if lines_match(line.get_stripped_text(), remove_html_and_cloze_tags(sentence)):
172
175
  return line
173
176
  return lines[-1]
174
177
 
@@ -0,0 +1,168 @@
1
+ import win32gui
2
+ import win32con
3
+ import win32api
4
+ import keyboard
5
+ import time
6
+ import threading
7
+
8
+ from GameSentenceMiner.util.configuration import logger
9
+
10
+ # --- Configuration (equivalent to AHK top-level variables) ---
11
+ TRANSPARENT_LEVEL = 1 # Almost invisible (0-255 scale)
12
+ OPAQUE_LEVEL = 255 # Fully opaque
13
+ HOTKEY = 'ctrl+alt+y'
14
+
15
+ # --- Global State Variables (equivalent to AHK global variables) ---
16
+ is_toggled = False
17
+ target_hwnd = None
18
+ # A lock to prevent race conditions when accessing global state from different threads
19
+ state_lock = threading.Lock()
20
+
21
+ # --- Core Functions (equivalent to AHK functions) ---
22
+
23
+ def set_window_transparency(hwnd, transparency):
24
+ """
25
+ Sets the transparency of a window.
26
+ This is the Python equivalent of WinSetTransparent.
27
+ """
28
+ if not hwnd or not win32gui.IsWindow(hwnd):
29
+ return
30
+ try:
31
+ # Get the current window style
32
+ style = win32gui.GetWindowLong(hwnd, win32con.GWL_EXSTYLE)
33
+ # Add the WS_EX_LAYERED style, which is required for transparency
34
+ win32gui.SetWindowLong(hwnd, win32con.GWL_EXSTYLE, style | win32con.WS_EX_LAYERED)
35
+ # Set the transparency
36
+ win32gui.SetLayeredWindowAttributes(hwnd, 0, transparency, win32con.LWA_ALPHA)
37
+ except Exception as e:
38
+ # Some windows (like system or elevated ones) might deny permission
39
+ # logger.info(f"Error setting transparency for HWND {hwnd}: {e}")
40
+ pass
41
+
42
+ def set_always_on_top(hwnd, is_on_top):
43
+ """
44
+ Sets or removes the "Always on Top" status for a window.
45
+ This is the Python equivalent of WinSetAlwaysOnTop.
46
+ """
47
+ if not hwnd or not win32gui.IsWindow(hwnd):
48
+ return
49
+ try:
50
+ rect = win32gui.GetWindowRect(hwnd)
51
+ position = win32con.HWND_TOPMOST if is_on_top else win32con.HWND_NOTOPMOST
52
+ # Set the window position without moving or resizing it
53
+ win32gui.SetWindowPos(hwnd, position, rect[0], rect[1], 0, 0,
54
+ win32con.SWP_NOMOVE | win32con.SWP_NOSIZE)
55
+ except Exception as e:
56
+ # logger.info(f"Error setting always-on-top for HWND {hwnd}: {e}")
57
+ pass
58
+
59
+ def reset_window_state(hwnd):
60
+ """A helper to reset a window to its default state."""
61
+ set_window_transparency(hwnd, OPAQUE_LEVEL)
62
+ set_always_on_top(hwnd, False)
63
+
64
+ # --- Hotkey Callback (equivalent to AHK ^!y::) ---
65
+
66
+ def toggle_functionality():
67
+ """
68
+ This function is called when the hotkey is pressed.
69
+ It manages the toggling logic.
70
+ """
71
+ global is_toggled, target_hwnd
72
+
73
+ # Get the currently focused window (equivalent to WinGetID("A"))
74
+ current_hwnd = win32gui.GetForegroundWindow()
75
+ if not current_hwnd:
76
+ logger.info("No window is currently active!")
77
+ return
78
+
79
+ with state_lock:
80
+ # Case 1: The hotkey is pressed on the currently toggled window to disable it.
81
+ if is_toggled and target_hwnd == current_hwnd:
82
+ logger.info(f"Disabling functionality for window: {win32gui.GetWindowText(current_hwnd)}")
83
+ reset_window_state(current_hwnd)
84
+ is_toggled = False
85
+ target_hwnd = None
86
+ # Case 2: Enable functionality for a new window, or switch to a new one.
87
+ else:
88
+ # If another window was already toggled, reset it first.
89
+ if is_toggled and target_hwnd is not None:
90
+ logger.info(f"Resetting old window: {win32gui.GetWindowText(target_hwnd)}")
91
+ reset_window_state(target_hwnd)
92
+
93
+ # Enable functionality for the new window.
94
+ logger.info(f"Enabling functionality for window: {win32gui.GetWindowText(current_hwnd)}")
95
+ is_toggled = True
96
+ target_hwnd = current_hwnd
97
+ set_always_on_top(target_hwnd, True)
98
+ # The mouse_monitor_loop will handle setting the initial transparency
99
+
100
+ # --- Mouse Monitoring (equivalent to AHK Loop) ---
101
+
102
+ def mouse_monitor_loop():
103
+ """
104
+ A loop that runs in a separate thread to monitor the mouse position.
105
+ """
106
+ global is_toggled, target_hwnd
107
+
108
+ while True:
109
+ # We check the state without a lock first for performance,
110
+ # then use the lock when we need to read the shared variable.
111
+ if is_toggled:
112
+ with state_lock:
113
+ # Make a local copy of the target handle to work with
114
+ monitored_hwnd = target_hwnd
115
+
116
+ if monitored_hwnd:
117
+ # Get mouse position and the window handle under the cursor
118
+ pos = win32gui.GetCursorPos()
119
+ hwnd_under_mouse = win32gui.WindowFromPoint(pos)
120
+
121
+ # WindowFromPoint can return a child window (like a button).
122
+ # We need to walk up the parent chain to see if it belongs to our target window.
123
+ is_mouse_over_target = False
124
+ current_hwnd = hwnd_under_mouse
125
+ while current_hwnd != 0:
126
+ if current_hwnd == monitored_hwnd:
127
+ is_mouse_over_target = True
128
+ break
129
+ current_hwnd = win32gui.GetParent(current_hwnd)
130
+
131
+ # Apply transparency based on mouse position
132
+ if is_mouse_over_target:
133
+ set_window_transparency(monitored_hwnd, OPAQUE_LEVEL)
134
+ else:
135
+ set_window_transparency(monitored_hwnd, TRANSPARENT_LEVEL)
136
+
137
+ # A small delay to reduce CPU usage
138
+ time.sleep(0.1)
139
+
140
+ # --- Main Execution Block ---
141
+
142
+ if __name__ == "__main__":
143
+ import argparse
144
+ # Start the mouse monitor in a separate, non-blocking thread.
145
+ # daemon=True ensures the thread will exit when the main script does.
146
+ monitor_thread = threading.Thread(target=mouse_monitor_loop, daemon=True)
147
+ monitor_thread.start()
148
+
149
+ # get hotkey from args
150
+ parser = argparse.ArgumentParser(description="Window Transparency Toggle Script")
151
+ parser.add_argument('--hotkey', type=str, default=HOTKEY, help='Hotkey to toggle transparency (default: ctrl+alt+y)')
152
+
153
+ hotkey = parser.parse_args().hotkey.lower()
154
+
155
+ # Register the global hotkey
156
+ keyboard.add_hotkey(hotkey, toggle_functionality)
157
+
158
+ logger.info(f"Script running. Press '{hotkey}' on a window to toggle transparency.")
159
+ logger.info("Press Ctrl+C in this console to exit.")
160
+
161
+ # Keep the script running to listen for the hotkey.
162
+ # keyboard.wait() is a blocking call that waits indefinitely.
163
+ try:
164
+ keyboard.wait()
165
+ except KeyboardInterrupt:
166
+ if is_toggled and target_hwnd:
167
+ reset_window_state(target_hwnd)
168
+ logger.info("\nScript terminated by user.")
GameSentenceMiner/vad.py CHANGED
@@ -53,18 +53,10 @@ class VADSystem:
53
53
  match model:
54
54
  case configuration.OFF:
55
55
  return VADResult(False, 0, 0, "OFF")
56
- # case configuration.GROQ:
57
- # if not self.groq:
58
- # self.groq = GroqVADProcessor()
59
- # return self.groq.process_audio(input_audio, output_audio, game_line)
60
56
  case configuration.SILERO:
61
57
  if not self.silero:
62
58
  self.silero = SileroVADProcessor()
63
59
  return self.silero.process_audio(input_audio, output_audio, game_line)
64
- # case configuration.VOSK:
65
- # if not self.vosk:
66
- # self.vosk = VoskVADProcessor()
67
- # return self.vosk.process_audio(input_audio, output_audio, game_line)
68
60
  case configuration.WHISPER:
69
61
  if not self.whisper:
70
62
  self.whisper = WhisperVADProcessor()
@@ -121,8 +113,6 @@ class VADProcessor(ABC):
121
113
  logger.info("No voice activity detected in the audio.")
122
114
  return VADResult(False, 0, 0, self.vad_system_name)
123
115
 
124
- print(voice_activity)
125
-
126
116
  start_time = voice_activity[0]['start'] if voice_activity else 0
127
117
  end_time = voice_activity[-1]['end'] if voice_activity else 0
128
118
 
@@ -132,6 +122,17 @@ class VADProcessor(ABC):
132
122
  if 0 > audio_length - voice_activity[-1]['start'] + get_config().audio.beginning_offset:
133
123
  end_time = voice_activity[-2]['end']
134
124
 
125
+ # if detected text is much shorter than game_line.text, if no text, guess based on length
126
+ if 'text' in voice_activity[0]:
127
+ dectected_text = ''.join([item['text'] for item in voice_activity])
128
+ if game_line and game_line.text and len(dectected_text) < len(game_line.text) / 2:
129
+ logger.info(f"Detected text '{dectected_text}' is much shorter than expected '{game_line.text}', skipping.")
130
+ return VADResult(False, 0, 0, self.vad_system_name)
131
+ else:
132
+ if game_line and game_line.text and (end_time - start_time) < max(0.5, len(game_line.text) * 0.05):
133
+ logger.info(f"Detected audio length {end_time - start_time} is much shorter than expected for text '{game_line.text}', skipping.")
134
+ return VADResult(False, 0, 0, self.vad_system_name)
135
+
135
136
  if get_config().vad.cut_and_splice_segments:
136
137
  self.extract_audio_and_combine_segments(input_audio, voice_activity, output_audio, padding=get_config().vad.splice_padding)
137
138
  else:
@@ -186,7 +187,7 @@ class WhisperVADProcessor(VADProcessor):
186
187
 
187
188
  # Process the segments to extract tokens, timestamps, and confidence
188
189
  for i, segment in enumerate(result.segments):
189
- if len(segment.text) == 1 and (i > 1 and segment.start - result.segments[i - 1].end > 1.0) or (i < len(result.segments) - 1 and result.segments[i + 1].start - segment.end > 1.0):
190
+ if len(segment.text) <= 2 and ((i > 1 and segment.start - result.segments[i - 1].end > 1.0) or (i < len(result.segments) - 1 and result.segments[i + 1].start - segment.end > 1.0)):
190
191
  if segment.text in ['えー', 'ん']:
191
192
  logger.debug(f"Skipping filler segment: {segment.text} at {segment.start}-{segment.end}")
192
193
  continue
@@ -194,6 +195,7 @@ class WhisperVADProcessor(VADProcessor):
194
195
  logger.info(
195
196
  "Unknown single character segment, not skipping, but logging, please report if this is a mistake: " + segment.text)
196
197
 
198
+
197
199
  logger.debug(segment.to_dict())
198
200
  voice_activity.append({
199
201
  'text': segment.text,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: GameSentenceMiner
3
- Version: 2.11.2
3
+ Version: 2.11.4
4
4
  Summary: A tool for mining sentences from games. Update: Full UI Re-design
5
5
  Author-email: Beangate <bpwhelan95@gmail.com>
6
6
  License: MIT License
@@ -3,8 +3,8 @@ GameSentenceMiner/anki.py,sha256=3BVFXAM7tpJAxHMbsMpnMHUoDfyqHQ1JSYJThW18QWA,168
3
3
  GameSentenceMiner/config_gui.py,sha256=QTK1yBDcfHaIUR_JyekkRQY9CVI_rh3Cae0bi7lviIo,99198
4
4
  GameSentenceMiner/gametext.py,sha256=6VkjmBeiuZfPk8T6PHFdIAElBH2Y_oLVYvmcafqN7RM,6747
5
5
  GameSentenceMiner/gsm.py,sha256=wTERcvG37SeDel51TCFusoQqk5B_b11YY4QZMTF0a6s,24954
6
- GameSentenceMiner/obs.py,sha256=o_I6213VZvXqYkZDdUBgUg2KWi9SbnNZZjjUnKnQkK4,15190
7
- GameSentenceMiner/vad.py,sha256=A3CvBQ67w3c7L8s7mTMxo6U_9ZQXlCToIpGUbePotfA,18321
6
+ GameSentenceMiner/obs.py,sha256=rapxY9PTDczGr7e8_41hVuD5VoRExe3IFFbSWZcYDsQ,15470
7
+ GameSentenceMiner/vad.py,sha256=Xj_9TM0fiaz9K8JcmW0QqGYASFnPEmYepsTHQrxP38c,18711
8
8
  GameSentenceMiner/ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  GameSentenceMiner/ai/ai_prompting.py,sha256=ojp7i_xg2YB1zALgFbivwtXPMVkThnSbPoUiAs-nz_g,25892
10
10
  GameSentenceMiner/assets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -16,17 +16,17 @@ GameSentenceMiner/assets/icon512.png,sha256=HxUj2GHjyQsk8NV433256UxU9phPhtjCY-YB
16
16
  GameSentenceMiner/assets/icon64.png,sha256=N8xgdZXvhqVQP9QUK3wX5iqxX9LxHljD7c-Bmgim6tM,9301
17
17
  GameSentenceMiner/assets/pickaxe.png,sha256=VfIGyXyIZdzEnVcc4PmG3wszPMO1W4KCT7Q_nFK6eSE,1403829
18
18
  GameSentenceMiner/ocr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
- GameSentenceMiner/ocr/gsm_ocr_config.py,sha256=jtTzAWtMAx8GuA1XIJ_BmyNn3aYaO3u_c5Q7m5D4gS8,4056
19
+ GameSentenceMiner/ocr/gsm_ocr_config.py,sha256=76IuoOMsBxNvU8z8lixqz58YSZpenNVugnHjrUXgCf4,4963
20
20
  GameSentenceMiner/ocr/ocrconfig.py,sha256=_tY8mjnzHMJrLS8E5pHqYXZjMuLoGKYgJwdhYgN-ny4,6466
21
- GameSentenceMiner/ocr/owocr_area_selector.py,sha256=lHMVZuEE_-_wICfDr6jDJNSJIyZd2PnF7dIajknaHCU,20255
22
- GameSentenceMiner/ocr/owocr_helper.py,sha256=OV31PCPGwLUYq3HBFdS6UoFB2hAyJE8yHm1UoDYoY38,22431
21
+ GameSentenceMiner/ocr/owocr_area_selector.py,sha256=Aj6t-cCePPeYNSF-XxQKo2gVNWmWqK3f3qR-0vxdtuE,25523
22
+ GameSentenceMiner/ocr/owocr_helper.py,sha256=sxmZcori9_ujldclwQFpmMwTyfJyflAQ3mn_3BvIdQs,22888
23
23
  GameSentenceMiner/ocr/ss_picker.py,sha256=0IhxUdaKruFpZyBL-8SpxWg7bPrlGpy3lhTcMMZ5rwo,5224
24
24
  GameSentenceMiner/owocr/owocr/__init__.py,sha256=87hfN5u_PbL_onLfMACbc0F5j4KyIK9lKnRCj6oZgR0,49
25
25
  GameSentenceMiner/owocr/owocr/__main__.py,sha256=XQaqZY99EKoCpU-gWQjNbTs7Kg17HvBVE7JY8LqIE0o,157
26
26
  GameSentenceMiner/owocr/owocr/config.py,sha256=qM7kISHdUhuygGXOxmgU6Ef2nwBShrZtdqu4InDCViE,8103
27
27
  GameSentenceMiner/owocr/owocr/lens_betterproto.py,sha256=oNoISsPilVVRBBPVDtb4-roJtAhp8ZAuFTci3TGXtMc,39141
28
- GameSentenceMiner/owocr/owocr/ocr.py,sha256=S1unC9FShXApl5mxL0NAdvcH3wISB8KcolZcAxOGdnM,59450
29
- GameSentenceMiner/owocr/owocr/run.py,sha256=lwZOj5nmQWgXlQOA_b2FYrMjhGWCPA7ZZCBg-cj57-k,56518
28
+ GameSentenceMiner/owocr/owocr/ocr.py,sha256=xAhqCfVY2xKKvUhskAiAaYiL3yQrAl8oYi5GU46NOgI,59392
29
+ GameSentenceMiner/owocr/owocr/run.py,sha256=824KFS5v3c4ZLx7RYafBOezvFmnB4Idexf4mJAJhfp8,61100
30
30
  GameSentenceMiner/owocr/owocr/screen_coordinate_picker.py,sha256=Na6XStbQBtpQUSdbN3QhEswtKuU1JjReFk_K8t5ezQE,3395
31
31
  GameSentenceMiner/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
32
  GameSentenceMiner/util/audio_offset_selector.py,sha256=8Stk3BP-XVIuzRv9nl9Eqd2D-1yD3JrgU-CamBywJmY,8542
@@ -38,7 +38,8 @@ GameSentenceMiner/util/model.py,sha256=AaOzgqSbaN7yks_rr1dQpLQR45FpBYdoLebMbrIYm
38
38
  GameSentenceMiner/util/notification.py,sha256=0OnEYjn3DUEZ6c6OtPjdVZe-DG-QSoMAl9fetjjCvNU,3874
39
39
  GameSentenceMiner/util/package.py,sha256=u1ym5z869lw5EHvIviC9h9uH97bzUXSXXA8KIn8rUvk,1157
40
40
  GameSentenceMiner/util/ss_selector.py,sha256=cbjMxiKOCuOfbRvLR_PCRlykBrGtm1LXd6u5czPqkmc,4793
41
- GameSentenceMiner/util/text_log.py,sha256=_zGqpbsUIdBVYZofK0XuBbU_tZnz0xmw_xfkJAZbctA,5907
41
+ GameSentenceMiner/util/text_log.py,sha256=jhG7ny8-DAilMAAPauN5HLoBNSIJ-cXAm68NLBxGNT8,5997
42
+ GameSentenceMiner/util/window_transparency.py,sha256=eQZausQ8A7-2Vd5cbBEJrJMKhaEPkLjJEa16kcnK6Ec,6592
42
43
  GameSentenceMiner/util/communication/__init__.py,sha256=xh__yn2MhzXi9eLi89PeZWlJPn-cbBSjskhi1BRraXg,643
43
44
  GameSentenceMiner/util/communication/send.py,sha256=Wki9qIY2CgYnuHbmnyKVIYkcKAN_oYS4up93XMikBaI,222
44
45
  GameSentenceMiner/util/communication/websocket.py,sha256=TbphRGmxVrgEupS7tNdifsmQfWDfIp0Hio2cSiUKgsk,3317
@@ -62,9 +63,9 @@ GameSentenceMiner/web/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm
62
63
  GameSentenceMiner/web/templates/index.html,sha256=Gv3CJvNnhAzIVV_QxhNq4OD-pXDt1vKCu9k6WdHSXuA,215343
63
64
  GameSentenceMiner/web/templates/text_replacements.html,sha256=tV5c8mCaWSt_vKuUpbdbLAzXZ3ATZeDvQ9PnnAfqY0M,8598
64
65
  GameSentenceMiner/web/templates/utility.html,sha256=3flZinKNqUJ7pvrZk6xu__v67z44rXnaK7UTZ303R-8,16946
65
- gamesentenceminer-2.11.2.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
66
- gamesentenceminer-2.11.2.dist-info/METADATA,sha256=zpe9LIoyiK5AyBRjnGH7Blp8z5rksCm3Nrxng5CKhyg,7319
67
- gamesentenceminer-2.11.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
68
- gamesentenceminer-2.11.2.dist-info/entry_points.txt,sha256=2APEP25DbfjSxGeHtwBstMH8mulVhLkqF_b9bqzU6vQ,65
69
- gamesentenceminer-2.11.2.dist-info/top_level.txt,sha256=V1hUY6xVSyUEohb0uDoN4UIE6rUZ_JYx8yMyPGX4PgQ,18
70
- gamesentenceminer-2.11.2.dist-info/RECORD,,
66
+ gamesentenceminer-2.11.4.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
67
+ gamesentenceminer-2.11.4.dist-info/METADATA,sha256=YP4JZMGhOyWAZVLztniBajXelAKy9biY_ZoRH0CHXXM,7319
68
+ gamesentenceminer-2.11.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
69
+ gamesentenceminer-2.11.4.dist-info/entry_points.txt,sha256=2APEP25DbfjSxGeHtwBstMH8mulVhLkqF_b9bqzU6vQ,65
70
+ gamesentenceminer-2.11.4.dist-info/top_level.txt,sha256=V1hUY6xVSyUEohb0uDoN4UIE6rUZ_JYx8yMyPGX4PgQ,18
71
+ gamesentenceminer-2.11.4.dist-info/RECORD,,