GameSentenceMiner 2.14.9__py3-none-any.whl → 2.14.11__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.
Files changed (62) hide show
  1. GameSentenceMiner/ai/__init__.py +0 -0
  2. GameSentenceMiner/ai/ai_prompting.py +473 -0
  3. GameSentenceMiner/ocr/__init__.py +0 -0
  4. GameSentenceMiner/ocr/gsm_ocr_config.py +174 -0
  5. GameSentenceMiner/ocr/ocrconfig.py +129 -0
  6. GameSentenceMiner/ocr/owocr_area_selector.py +629 -0
  7. GameSentenceMiner/ocr/owocr_helper.py +638 -0
  8. GameSentenceMiner/ocr/ss_picker.py +140 -0
  9. GameSentenceMiner/owocr/owocr/__init__.py +1 -0
  10. GameSentenceMiner/owocr/owocr/__main__.py +9 -0
  11. GameSentenceMiner/owocr/owocr/config.py +148 -0
  12. GameSentenceMiner/owocr/owocr/lens_betterproto.py +1238 -0
  13. GameSentenceMiner/owocr/owocr/ocr.py +1690 -0
  14. GameSentenceMiner/owocr/owocr/run.py +1818 -0
  15. GameSentenceMiner/owocr/owocr/screen_coordinate_picker.py +109 -0
  16. GameSentenceMiner/tools/__init__.py +0 -0
  17. GameSentenceMiner/tools/audio_offset_selector.py +215 -0
  18. GameSentenceMiner/tools/ss_selector.py +135 -0
  19. GameSentenceMiner/tools/window_transparency.py +214 -0
  20. GameSentenceMiner/util/__init__.py +0 -0
  21. GameSentenceMiner/util/communication/__init__.py +22 -0
  22. GameSentenceMiner/util/communication/send.py +7 -0
  23. GameSentenceMiner/util/communication/websocket.py +94 -0
  24. GameSentenceMiner/util/configuration.py +1199 -0
  25. GameSentenceMiner/util/db.py +408 -0
  26. GameSentenceMiner/util/downloader/Untitled_json.py +472 -0
  27. GameSentenceMiner/util/downloader/__init__.py +0 -0
  28. GameSentenceMiner/util/downloader/download_tools.py +194 -0
  29. GameSentenceMiner/util/downloader/oneocr_dl.py +250 -0
  30. GameSentenceMiner/util/electron_config.py +259 -0
  31. GameSentenceMiner/util/ffmpeg.py +571 -0
  32. GameSentenceMiner/util/get_overlay_coords.py +366 -0
  33. GameSentenceMiner/util/gsm_utils.py +323 -0
  34. GameSentenceMiner/util/model.py +206 -0
  35. GameSentenceMiner/util/notification.py +157 -0
  36. GameSentenceMiner/util/text_log.py +214 -0
  37. GameSentenceMiner/util/win10toast/__init__.py +154 -0
  38. GameSentenceMiner/util/win10toast/__main__.py +22 -0
  39. GameSentenceMiner/web/__init__.py +0 -0
  40. GameSentenceMiner/web/service.py +132 -0
  41. GameSentenceMiner/web/static/__init__.py +0 -0
  42. GameSentenceMiner/web/static/apple-touch-icon.png +0 -0
  43. GameSentenceMiner/web/static/favicon-96x96.png +0 -0
  44. GameSentenceMiner/web/static/favicon.ico +0 -0
  45. GameSentenceMiner/web/static/favicon.svg +3 -0
  46. GameSentenceMiner/web/static/site.webmanifest +21 -0
  47. GameSentenceMiner/web/static/style.css +292 -0
  48. GameSentenceMiner/web/static/web-app-manifest-192x192.png +0 -0
  49. GameSentenceMiner/web/static/web-app-manifest-512x512.png +0 -0
  50. GameSentenceMiner/web/templates/__init__.py +0 -0
  51. GameSentenceMiner/web/templates/index.html +50 -0
  52. GameSentenceMiner/web/templates/text_replacements.html +238 -0
  53. GameSentenceMiner/web/templates/utility.html +483 -0
  54. GameSentenceMiner/web/texthooking_page.py +584 -0
  55. GameSentenceMiner/wip/__init___.py +0 -0
  56. {gamesentenceminer-2.14.9.dist-info → gamesentenceminer-2.14.11.dist-info}/METADATA +1 -1
  57. gamesentenceminer-2.14.11.dist-info/RECORD +79 -0
  58. gamesentenceminer-2.14.9.dist-info/RECORD +0 -24
  59. {gamesentenceminer-2.14.9.dist-info → gamesentenceminer-2.14.11.dist-info}/WHEEL +0 -0
  60. {gamesentenceminer-2.14.9.dist-info → gamesentenceminer-2.14.11.dist-info}/entry_points.txt +0 -0
  61. {gamesentenceminer-2.14.9.dist-info → gamesentenceminer-2.14.11.dist-info}/licenses/LICENSE +0 -0
  62. {gamesentenceminer-2.14.9.dist-info → gamesentenceminer-2.14.11.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,629 @@
1
+ import argparse
2
+ import base64
3
+ import ctypes
4
+ import io
5
+ import json
6
+ import sys
7
+ from multiprocessing import Process, Manager
8
+ from pathlib import Path
9
+
10
+ from PIL import Image, ImageTk
11
+
12
+ # Assuming a mock or real obs module exists in this path
13
+ from GameSentenceMiner import obs
14
+ from GameSentenceMiner.ocr.gsm_ocr_config import set_dpi_awareness, get_window, get_scene_ocr_config_path
15
+ from GameSentenceMiner.util.gsm_utils import sanitize_filename
16
+ from GameSentenceMiner.util.configuration import logger
17
+
18
+ try:
19
+ import tkinter as tk
20
+ from tkinter import font as tkfont # NEW: Import for better font control
21
+
22
+ selector_available = True
23
+ except ImportError:
24
+ print("Error: tkinter library not found. GUI selection is unavailable.")
25
+ selector_available = False
26
+
27
+ MIN_RECT_WIDTH = 25
28
+ MIN_RECT_HEIGHT = 25
29
+
30
+ COORD_SYSTEM_PERCENTAGE = "percentage"
31
+
32
+
33
+ class ScreenSelector:
34
+ def __init__(self, result, window_name, use_window_as_config, use_obs_screenshot=False):
35
+ if not selector_available:
36
+ raise RuntimeError("tkinter is not available.")
37
+ if not window_name and not use_obs_screenshot:
38
+ raise ValueError("A target window name is required for configuration.")
39
+
40
+ obs.connect_to_obs_sync()
41
+ self.window_name = window_name
42
+ self.use_obs_screenshot = use_obs_screenshot
43
+ self.screenshot_img = None
44
+ try:
45
+ import mss
46
+ self.sct = mss.mss()
47
+ self.monitors = self.sct.monitors[1:]
48
+ if not self.monitors:
49
+ raise RuntimeError("No monitors found by mss.")
50
+ for i, monitor in enumerate(self.monitors):
51
+ monitor['index'] = i
52
+ except ImportError:
53
+ print("Error: mss library not found. Please install it: pip install mss")
54
+ raise RuntimeError("mss is required for screen selection.")
55
+
56
+ if self.use_obs_screenshot:
57
+ print("Using OBS screenshot as target.")
58
+ self.screenshot_img = obs.get_screenshot_PIL(compression=75)
59
+ # print(screenshot_base64)
60
+ if not self.screenshot_img:
61
+ raise RuntimeError("Failed to get OBS screenshot.")
62
+ try:
63
+ # Scale image to 1280x720
64
+ self.screenshot_img = self.screenshot_img.resize(self.scale_down_width_height(self.screenshot_img.width, self.screenshot_img.height), Image.LANCZOS)
65
+ except Exception as e:
66
+ raise RuntimeError(f"Failed to decode or open OBS screenshot: {e}")
67
+
68
+ self.target_window = None
69
+ self.target_window_geometry = {
70
+ "left": 0, "top": 0,
71
+ "width": self.screenshot_img.width,
72
+ "height": self.screenshot_img.height
73
+ }
74
+ print(f"OBS Screenshot dimensions: {self.target_window_geometry}")
75
+ else:
76
+ import pygetwindow as gw
77
+ if not gw:
78
+ raise RuntimeError("pygetwindow is not available for window selection.")
79
+ print(f"Targeting window: '{window_name}'")
80
+ self.target_window = self._find_target_window()
81
+ self.target_window_geometry = self._get_window_geometry(self.target_window)
82
+ if not self.target_window_geometry:
83
+ raise RuntimeError(f"Could not find or get geometry for window '{self.window_name}'.")
84
+ print(f"Found target window at: {self.target_window_geometry}")
85
+
86
+ self.root = None
87
+ self.scene = ''
88
+ self.use_window_as_config = use_window_as_config
89
+ self.result = result
90
+ self.rectangles = [] # Internal storage is ALWAYS absolute pixels for drawing
91
+ self.drawn_rect_ids = []
92
+ self.current_rect_id = None
93
+ self.start_x = self.start_y = None
94
+ self.image_mode = True
95
+ self.redo_stack = []
96
+ self.bounding_box = {} # Geometry of the single large canvas window
97
+ self.instructions_showing = True
98
+
99
+ self.canvas = None
100
+ self.window = None
101
+ self.instructions_widget = None
102
+ self.instructions_window_id = None
103
+
104
+ self.load_existing_rectangles()
105
+
106
+ def scale_down_width_height(self, width, height):
107
+ if width == 0 or height == 0:
108
+ return width, height
109
+ aspect_ratio = width / height
110
+ if aspect_ratio > 2.66:
111
+ # Ultra-wide (32:9) - use 1920x540
112
+ return 1920, 540
113
+ elif aspect_ratio > 2.33:
114
+ # 21:9 - use 1920x800
115
+ return 1920, 800
116
+ elif aspect_ratio > 1.77:
117
+ # 16:9 - use 1280x720
118
+ return 1280, 720
119
+ elif aspect_ratio > 1.6:
120
+ # 16:10 - use 1280x800
121
+ return 1280, 800
122
+ elif aspect_ratio > 1.33:
123
+ # 4:3 - use 960x720
124
+ return 960, 720
125
+ elif aspect_ratio > 1.25:
126
+ # 5:4 - use 900x720
127
+ return 900, 720
128
+ elif aspect_ratio > 1.5:
129
+ # 3:2 - use 1080x720
130
+ return 1080, 720
131
+ else:
132
+ # Default/fallback - use original resolution
133
+ print(f"Unrecognized aspect ratio {aspect_ratio}. Using original resolution.")
134
+ return width, height
135
+
136
+ def _find_target_window(self):
137
+ try:
138
+ return get_window(self.window_name)
139
+ except Exception as e:
140
+ print(f"Error finding window '{self.window_name}': {e}")
141
+ return None
142
+
143
+ def _get_window_geometry(self, window):
144
+ if window:
145
+ try:
146
+ # Ensure width/height are positive and non-zero
147
+ width = max(1, window.width)
148
+ height = max(1, window.height)
149
+ return {"left": window.left, "top": window.top, "width": width, "height": height}
150
+ except Exception:
151
+ return None
152
+ return None
153
+
154
+ def load_existing_rectangles(self):
155
+ """Loads rectangles from config, converting from percentage to absolute pixels for use."""
156
+ config_path = get_scene_ocr_config_path(self.use_window_as_config, self.window_name)
157
+ win_geom = self.target_window_geometry # Use current geometry for conversion
158
+ win_w, win_h, win_l, win_t = win_geom['width'], win_geom['height'], win_geom['left'], win_geom['top']
159
+
160
+ try:
161
+ with open(config_path, 'r', encoding='utf-8') as f:
162
+ config_data = json.load(f)
163
+
164
+ if config_data.get("coordinate_system") != COORD_SYSTEM_PERCENTAGE:
165
+ print(
166
+ f"Warning: Config file '{config_path}' does not use '{COORD_SYSTEM_PERCENTAGE}' system. Please re-create selections.")
167
+ return
168
+
169
+ print(f"Loading rectangles from {config_path}...")
170
+ self.rectangles = []
171
+ loaded_count = 0
172
+
173
+ for rect_data in config_data.get("rectangles", []):
174
+ try:
175
+ coords_pct = rect_data["coordinates"]
176
+ x_pct, y_pct, w_pct, h_pct = map(float, coords_pct)
177
+
178
+ # Convert from percentage to absolute pixel coordinates
179
+ x_abs = (x_pct * win_w) + win_l
180
+ y_abs = (y_pct * win_h) + win_t
181
+ w_abs = w_pct * win_w
182
+ h_abs = h_pct * win_h
183
+ abs_coords = (int(x_abs), int(y_abs), int(w_abs), int(h_abs))
184
+
185
+ monitor_index = rect_data["monitor"]['index']
186
+ target_monitor = next((m for m in self.monitors if m['index'] == monitor_index), None)
187
+ if target_monitor:
188
+ self.rectangles.append((target_monitor, abs_coords, rect_data["is_excluded"], rect_data.get("is_secondary", False)))
189
+ loaded_count += 1
190
+ except (KeyError, ValueError, TypeError) as e:
191
+ print(f"Skipping malformed rectangle data: {rect_data}, Error: {e}")
192
+
193
+ print(f"Loaded {loaded_count} valid rectangles.")
194
+ except FileNotFoundError:
195
+ print(f"No config found at {config_path}. Starting fresh.")
196
+ except Exception as e:
197
+ print(f"Error loading config: {e}. Starting fresh.")
198
+
199
+ def save_rects(self, event=None):
200
+ """Saves rectangles to config, converting from absolute pixels to percentages."""
201
+ config_path = get_scene_ocr_config_path(self.use_window_as_config, self.window_name)
202
+ win_geom = self.target_window_geometry
203
+ win_l, win_t, win_w, win_h = win_geom['left'], win_geom['top'], win_geom['width'], win_geom['height']
204
+ print(f"Saving rectangles to: {config_path} relative to window: {win_geom}")
205
+
206
+ serializable_rects = []
207
+ for monitor_dict, abs_coords, is_excluded, is_secondary in self.rectangles:
208
+ x_abs, y_abs, w_abs, h_abs = abs_coords
209
+
210
+ # Convert absolute pixel coordinates to percentages
211
+ x_pct = (x_abs - win_l) / win_w
212
+ y_pct = (y_abs - win_t) / win_h
213
+ w_pct = w_abs / win_w
214
+ h_pct = h_abs / win_h
215
+ coords_to_save = [x_pct, y_pct, w_pct, h_pct]
216
+
217
+ serializable_rects.append({
218
+ "monitor": {'index': monitor_dict['index']},
219
+ "coordinates": coords_to_save,
220
+ "is_excluded": is_excluded,
221
+ "is_secondary": is_secondary
222
+ })
223
+
224
+ save_data = {
225
+ "scene": self.scene or "",
226
+ "window": self.window_name,
227
+ "coordinate_system": COORD_SYSTEM_PERCENTAGE, # Always save as percentage
228
+ "window_geometry": win_geom, # Save the geometry used for conversion
229
+ "rectangles": serializable_rects
230
+ }
231
+
232
+ with open(config_path, 'w', encoding="utf-8") as f:
233
+ json.dump(save_data, f, indent=4, ensure_ascii=False)
234
+
235
+ print(f"Successfully saved {len(serializable_rects)} rectangles.")
236
+ # Pass back the internal absolute coords for any immediate post-processing
237
+ self.result['rectangles'] = [(r[0], list(r[1]), r[2]) for r in self.rectangles]
238
+ self.result['window_geometry'] = win_geom
239
+ self.result['coordinate_system'] = COORD_SYSTEM_PERCENTAGE
240
+ self.quit_app()
241
+
242
+ def undo_last_rect(self, event=None):
243
+ if self.rectangles and self.drawn_rect_ids:
244
+ last_rect_tuple = self.rectangles.pop()
245
+ last_rect_id = self.drawn_rect_ids.pop()
246
+ self.redo_stack.append((*last_rect_tuple, last_rect_id))
247
+ event.widget.winfo_toplevel().winfo_children()[0].delete(last_rect_id)
248
+ print("Undo: Removed last rectangle.")
249
+
250
+ def toggle_image_mode(self, e=None):
251
+ self.image_mode = not self.image_mode
252
+ # Only change alpha of the main window, not the text widget
253
+ self.window.attributes("-alpha", 1.0 if self.image_mode else 0.25)
254
+ print("Toggled background visibility.")
255
+
256
+ def redo_last_rect(self, event=None):
257
+ if not self.redo_stack: return
258
+ monitor, abs_coords, is_excluded, is_secondary, old_rect_id = self.redo_stack.pop()
259
+ canvas = event.widget.winfo_toplevel().winfo_children()[0]
260
+ x_abs, y_abs, w_abs, h_abs = abs_coords
261
+ canvas_x, canvas_y = x_abs - self.bounding_box['left'], y_abs - self.bounding_box['top']
262
+ outline_color = 'purple' if is_secondary else ('orange' if is_excluded else 'green')
263
+ new_rect_id = canvas.create_rectangle(canvas_x, canvas_y, canvas_x + w_abs, canvas_y + h_abs,
264
+ outline=outline_color, width=2)
265
+ self.rectangles.append((monitor, abs_coords, is_excluded, is_secondary))
266
+ self.drawn_rect_ids.append(new_rect_id)
267
+ print("Redo: Restored rectangle.")
268
+
269
+ # --- NEW METHOD TO DISPLAY INSTRUCTIONS ---
270
+ def _create_instructions_widget(self, parent_canvas):
271
+ """Creates a separate, persistent window for instructions and control buttons."""
272
+ if self.instructions_widget and self.instructions_widget.winfo_exists():
273
+ self.instructions_widget.lift()
274
+ return
275
+
276
+ self.instructions_widget = tk.Toplevel(parent_canvas)
277
+ self.instructions_widget.title("Controls")
278
+
279
+ # --- Position it near the main window ---
280
+ parent_window = parent_canvas.winfo_toplevel()
281
+ # Make the instructions window transient to the main window to keep it on top
282
+ # self.instructions_widget.transient(parent_window)
283
+ self.instructions_widget.attributes('-topmost', 1)
284
+ # parent_window.update_idletasks() # Ensure dimensions are up-to-date
285
+ pos_x = parent_window.winfo_x() + 50
286
+ pos_y = parent_window.winfo_y() + 50
287
+ self.instructions_widget.geometry(f"+{pos_x}+{pos_y}")
288
+
289
+ main_frame = tk.Frame(self.instructions_widget, padx=10, pady=10)
290
+ main_frame.pack(fill=tk.BOTH, expand=True)
291
+
292
+ instructions_text = (
293
+ "How to Use:\n"
294
+ "• Left Click + Drag: Create a capture area (green).\n"
295
+ "• Shift + Left Click + Drag: Create an exclusion area (orange).\n"
296
+ "• Ctrl + Left Click + Drag: Create a secondary (menu) area (purple).\n"
297
+ "• Right-Click on a box: Delete it."
298
+ )
299
+ tk.Label(main_frame, text=instructions_text, justify=tk.LEFT, anchor="w").pack(pady=(0, 10), fill=tk.X)
300
+
301
+ button_frame = tk.Frame(main_frame)
302
+ button_frame.pack(fill=tk.X, pady=5)
303
+
304
+ def canvas_event_wrapper(func):
305
+ class MockEvent:
306
+ def __init__(self, widget):
307
+ self.widget = widget
308
+ return lambda: func(MockEvent(self.canvas))
309
+
310
+ def root_event_wrapper(func):
311
+ return lambda: func(None)
312
+
313
+ tk.Button(button_frame, text="Save and Quit (Ctrl+S)", command=root_event_wrapper(self.save_rects)).pack(fill=tk.X, pady=2)
314
+ tk.Button(button_frame, text="Undo (Ctrl+Z)", command=canvas_event_wrapper(self.undo_last_rect)).pack(fill=tk.X, pady=2)
315
+ tk.Button(button_frame, text="Redo (Ctrl+Y)", command=canvas_event_wrapper(self.redo_last_rect)).pack(fill=tk.X, pady=2)
316
+ tk.Button(button_frame, text="Toggle Background (M)", command=root_event_wrapper(self.toggle_image_mode)).pack(fill=tk.X, pady=2)
317
+ tk.Button(button_frame, text="Quit without Saving (Esc)", command=root_event_wrapper(self.quit_app)).pack(fill=tk.X, pady=2)
318
+ tk.Button(button_frame, text="Toggle Instructions (I)", command=canvas_event_wrapper(self.toggle_instructions)).pack(fill=tk.X, pady=2)
319
+
320
+ # hotkeys_text = "\n• I: Toggle this instruction panel"
321
+ # tk.Label(main_frame, text=hotkeys_text, justify=tk.LEFT, anchor="w").pack(pady=(10, 0), fill=tk.X)
322
+
323
+
324
+ # --- NEW METHOD TO DISPLAY INSTRUCTIONS ---
325
+ def print_instructions_box(self, canvas):
326
+ """Creates a separate, persistent window for instructions and control buttons."""
327
+ instructions_text = (
328
+ "How to Use:\n"
329
+ " • Left Click + Drag: Create a capture area (green).\n"
330
+ " • Shift + Left Click + Drag: Create an exclusion area (orange).\n"
331
+ " • Right-Click on a box: Delete it.\n\n"
332
+ "Hotkeys:\n"
333
+ " • Ctrl + S: Save and Quit\n"
334
+ " • Ctrl + Z / Ctrl + Y: Undo / Redo\n"
335
+ " • M: Toggle background visibility\n"
336
+ " • I: Toggle these instructions\n"
337
+ " • Esc: Quit without saving"
338
+ " "
339
+ )
340
+
341
+ # Use a common, readable font
342
+ instruction_font = tkfont.Font(family="Segoe UI", size=10, weight="normal")
343
+
344
+ # Create the text item first to get its size
345
+ self.instructions_overlay = canvas.create_text(
346
+ 20, 20, # Position with a small margin
347
+ text=instructions_text,
348
+ anchor=tk.NW,
349
+ fill='white',
350
+ font=instruction_font,
351
+ justify=tk.LEFT
352
+ )
353
+
354
+ # Get the bounding box of the text to draw a background
355
+ text_bbox = canvas.bbox(self.instructions_overlay)
356
+
357
+ # Create a background rectangle with padding
358
+ self.instructions_rect = canvas.create_rectangle(
359
+ text_bbox[0] - 10, # left
360
+ text_bbox[1] - 10, # top
361
+ text_bbox[2] + 10, # right
362
+ text_bbox[3] + 10, # bottom
363
+ fill='#2B2B2B', # Dark, semi-opaque background
364
+ outline='white',
365
+ width=1
366
+ )
367
+
368
+ # Lower the rectangle so it's behind the text
369
+ canvas.tag_lower(self.instructions_rect, self.instructions_overlay)
370
+
371
+ # Add hover effect: make rectangle transparent on mouse over
372
+ def on_motion(event):
373
+ # Check if mouse is over the rectangle
374
+ x, y = event.x, event.y
375
+ rect_bbox = canvas.bbox(self.instructions_rect)
376
+ if rect_bbox and rect_bbox[0] <= x <= rect_bbox[2] and rect_bbox[1] <= y <= y <= rect_bbox[3]:
377
+ # Set fill to more transparent using denser stipple
378
+ canvas.itemconfigure(self.instructions_rect, fill='#2B2B2B', stipple='gray12')
379
+ # Make text more transparent by changing its color to a lighter gray
380
+ canvas.itemconfigure(self.instructions_overlay, fill='#CCCCCC')
381
+ else:
382
+ # Restore solid fill and opaque text
383
+ canvas.itemconfigure(self.instructions_rect, fill='#2B2B2B', stipple='')
384
+ canvas.itemconfigure(self.instructions_overlay, fill='white')
385
+
386
+ canvas.bind('<Motion>', on_motion)
387
+
388
+
389
+ def toggle_instructions(self, event=None):
390
+ canvas = event.widget.winfo_toplevel().winfo_children()[0]
391
+ for element in [self.instructions_overlay, self.instructions_rect]:
392
+ current_state = canvas.itemcget(element, 'state')
393
+ new_state = tk.NORMAL if current_state == tk.HIDDEN else tk.HIDDEN
394
+ canvas.itemconfigure(element, state=new_state)
395
+
396
+ # if self.instructions_showing:
397
+ # self.instructions_widget.withdraw()
398
+ # logger.info(f"Toggled instructions visibility: OFF")
399
+ # self.instructions_showing = False
400
+ # else:
401
+ # self.instructions_widget.deiconify()
402
+ # self.instructions_widget.lift()
403
+ # self.canvas.focus_set()
404
+ # self.instructions_widget.update_idletasks() # Ensure it is fully rendered
405
+ # logger.info("Toggled instructions visibility: ON")
406
+ # self.instructions_showing = True
407
+
408
+ def start(self):
409
+ self.root = tk.Tk()
410
+ self.root.withdraw()
411
+
412
+ if self.use_obs_screenshot:
413
+ # Use the pre-loaded OBS screenshot
414
+ img = self.screenshot_img
415
+ self.bounding_box = self.target_window_geometry
416
+ # Center the window on the primary monitor
417
+ primary_monitor = self.sct.monitors[1] if len(self.sct.monitors) > 1 else self.sct.monitors[0]
418
+ win_x = primary_monitor['left'] + (primary_monitor['width'] - img.width) // 2
419
+ win_y = primary_monitor['top'] + (primary_monitor['height'] - img.height) // 2
420
+ window_geometry = f"{img.width}x{img.height}+{int(win_x)}+{int(win_y)}"
421
+ else:
422
+ # Calculate bounding box of all monitors for the overlay
423
+ left = min(m['left'] for m in self.monitors)
424
+ top = min(m['top'] for m in self.monitors)
425
+ right = max(m['left'] + m['width'] for m in self.monitors)
426
+ bottom = max(m['top'] + m['height'] for m in self.monitors)
427
+ self.bounding_box = {'left': left, 'top': top, 'width': right - left, 'height': bottom - top}
428
+
429
+ # Capture the entire desktop area covered by all monitors
430
+ sct_img = self.sct.grab(self.bounding_box)
431
+ img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
432
+ window_geometry = f"{self.bounding_box['width']}x{self.bounding_box['height']}+{left}+{top}"
433
+
434
+ self.window = tk.Toplevel(self.root)
435
+ self.window.geometry(window_geometry)
436
+ self.window.overrideredirect(1)
437
+ self.window.attributes('-topmost', 1)
438
+
439
+ self.photo_image = ImageTk.PhotoImage(img)
440
+ self.canvas = tk.Canvas(self.window, cursor='cross', highlightthickness=0)
441
+ self.canvas.pack(fill=tk.BOTH, expand=True)
442
+ self.canvas.create_image(0, 0, image=self.photo_image, anchor=tk.NW)
443
+
444
+ # --- MODIFIED: CALL THE INSTRUCTION WIDGET CREATOR ---
445
+ # self._create_instructions_widget(self.canvas)
446
+ # --- END MODIFICATION ---
447
+
448
+ # Draw existing rectangles (which were converted to absolute pixels on load)
449
+ for _, abs_coords, is_excluded, is_secondary in self.rectangles:
450
+ x_abs, y_abs, w_abs, h_abs = abs_coords
451
+ canvas_x = x_abs - self.bounding_box['left']
452
+ canvas_y = y_abs - self.bounding_box['top']
453
+ outline_color = 'purple' if is_secondary else ('orange' if is_excluded else 'green')
454
+ rect_id = self.canvas.create_rectangle(canvas_x, canvas_y, canvas_x + w_abs, canvas_y + h_abs,
455
+ outline=outline_color, width=2)
456
+ self.drawn_rect_ids.append(rect_id)
457
+
458
+ def on_click(event):
459
+ self.start_x, self.start_y = event.x, event.y
460
+ ctrl_held = bool(event.state & 0x0004)
461
+ shift_held = bool(event.state & 0x0001)
462
+ if ctrl_held:
463
+ outline = 'purple'
464
+ elif shift_held:
465
+ outline = 'orange'
466
+ else:
467
+ outline = 'green'
468
+ self.current_rect_id = self.canvas.create_rectangle(self.start_x, self.start_y, self.start_x, self.start_y,
469
+ outline=outline, width=2)
470
+
471
+ def on_drag(event):
472
+ if self.current_rect_id: self.canvas.coords(self.current_rect_id, self.start_x, self.start_y, event.x, event.y)
473
+
474
+ def on_release(event):
475
+ if not self.current_rect_id: return
476
+ coords = self.canvas.coords(self.current_rect_id)
477
+ x_abs = int(min(coords[0], coords[2]) + self.bounding_box['left'])
478
+ y_abs = int(min(coords[1], coords[3]) + self.bounding_box['top'])
479
+ w, h = int(abs(coords[2] - coords[0])), int(abs(coords[3] - coords[1]))
480
+
481
+ if w >= MIN_RECT_WIDTH and h >= MIN_RECT_HEIGHT:
482
+ ctrl_held = bool(event.state & 0x0004)
483
+ shift_held = bool(event.state & 0x0001)
484
+ is_excl = shift_held
485
+ is_secondary = ctrl_held
486
+ outline_color = 'purple' if is_secondary else ('orange' if is_excl else 'green')
487
+ self.canvas.itemconfig(self.current_rect_id, outline=outline_color)
488
+
489
+ center_x, center_y = x_abs + w / 2, y_abs + h / 2
490
+ target_mon = self.monitors[0]
491
+ for mon in self.monitors:
492
+ if mon['left'] <= center_x < mon['left'] + mon['width'] and mon['top'] <= center_y < mon['top'] + \
493
+ mon['height']:
494
+ target_mon = mon
495
+ break
496
+
497
+ self.rectangles.append((target_mon, (x_abs, y_abs, w, h), is_excl, is_secondary))
498
+ self.drawn_rect_ids.append(self.current_rect_id)
499
+ self.redo_stack.clear()
500
+ else:
501
+ self.canvas.delete(self.current_rect_id)
502
+ self.current_rect_id = self.start_x = self.start_y = None
503
+
504
+ def on_right_click(event):
505
+ # Iterate through our rectangles in reverse to find the topmost one.
506
+ for i in range(len(self.rectangles) - 1, -1, -1):
507
+ _monitor, abs_coords, _is_excluded, _is_secondary = self.rectangles[i]
508
+ x_abs, y_abs, w_abs, h_abs = abs_coords
509
+ canvas_x1 = x_abs - self.bounding_box['left']
510
+ canvas_y1 = y_abs - self.bounding_box['top']
511
+ canvas_x2 = canvas_x1 + w_abs
512
+ canvas_y2 = canvas_y1 + h_abs
513
+
514
+ if canvas_x1 <= event.x <= canvas_x2 and canvas_y1 <= event.y <= canvas_y2:
515
+ # --- UNDO/REDO CHANGE ---
516
+ # We found the rectangle. Prepare the 'remove' action.
517
+ # We need to save the data AND its original index to restore it correctly.
518
+ rect_tuple_to_del = self.rectangles[i]
519
+ item_id_to_del = self.drawn_rect_ids[i]
520
+
521
+ self.redo_stack.append((*rect_tuple_to_del, i))
522
+
523
+ # Now, perform the deletion
524
+ del self.rectangles[i]
525
+ del self.drawn_rect_ids[i]
526
+ self.canvas.delete(item_id_to_del)
527
+ print("Deleted rectangle.")
528
+
529
+ break # Stop after deleting the topmost one
530
+
531
+ def on_enter(e=None):
532
+ self.canvas.focus_set()
533
+
534
+ self.canvas.bind('<Enter>', on_enter)
535
+ self.canvas.bind('<ButtonPress-1>', on_click)
536
+ self.canvas.bind('<B1-Motion>', on_drag)
537
+ self.canvas.bind('<ButtonRelease-1>', on_release)
538
+ self.canvas.bind('<Button-3>', on_right_click)
539
+ self.canvas.bind('<Control-s>', self.save_rects)
540
+ self.canvas.bind('<Control-y>', self.redo_last_rect)
541
+ self.canvas.bind('<Control-z>', self.undo_last_rect)
542
+ self.canvas.bind("<Escape>", self.quit_app)
543
+ self.canvas.bind("<m>", self.toggle_image_mode)
544
+ self.canvas.bind("<i>", self.toggle_instructions)
545
+
546
+ self.canvas.focus_set()
547
+ self._create_instructions_widget(self.window)
548
+ self.window.winfo_toplevel().update_idletasks()
549
+ self.print_instructions_box(self.canvas)
550
+ # The print message is now redundant but kept for console feedback
551
+ print("Starting UI. See on-screen instructions. Press Esc to quit, Ctrl+S to save.")
552
+ # self.canvas.update_idletasks()
553
+ self.root.mainloop()
554
+
555
+ def quit_app(self, event=None):
556
+ if self.instructions_widget and self.instructions_widget.winfo_exists():
557
+ self.instructions_widget.destroy()
558
+ if self.root and self.root.winfo_exists(): self.root.destroy()
559
+ self.root = None
560
+
561
+
562
+ def run_screen_selector(result_dict, window_name, use_window_as_config, use_obs_screenshot):
563
+ try:
564
+ selector = ScreenSelector(result_dict, window_name, use_window_as_config, use_obs_screenshot)
565
+ selector.start()
566
+ except Exception as e:
567
+ print(f"Error in selector process: {e}", file=sys.stderr)
568
+ import traceback
569
+ traceback.print_exc()
570
+ result_dict['error'] = str(e)
571
+
572
+
573
+ def get_screen_selection(window_name, use_window_as_config=False, use_obs_screenshot=False):
574
+ if not selector_available: return None
575
+ if not window_name and not use_obs_screenshot:
576
+ print("Error: A target window name must be provided.", file=sys.stderr)
577
+ return None
578
+
579
+ with Manager() as manager:
580
+ result_data = manager.dict()
581
+ process = Process(target=run_screen_selector, args=(result_data, window_name, use_window_as_config, use_obs_screenshot))
582
+ print(f"Starting ScreenSelector process...")
583
+ process.start()
584
+ process.join()
585
+
586
+ if 'error' in result_data:
587
+ print(f"Selector process failed: {result_data['error']}", file=sys.stderr)
588
+ return None
589
+ elif 'rectangles' in result_data:
590
+ print("Screen selection successful.")
591
+ return dict(result_data)
592
+ else:
593
+ print("Selection was cancelled by the user.")
594
+ return {}
595
+
596
+
597
+ if __name__ == "__main__":
598
+ set_dpi_awareness()
599
+
600
+ parser = argparse.ArgumentParser(description="Screen Selector Arguments")
601
+ parser.add_argument("window_title", nargs="?", default="", help="Target window title")
602
+ parser.add_argument("--obs_ocr", action="store_true", default=True, help="Use OBS screenshot")
603
+ parser.add_argument("--use_window_for_config", action="store_true", help="Use window for config")
604
+ args = parser.parse_args()
605
+
606
+ target_window_title = args.window_title
607
+ use_obs_screenshot = args.obs_ocr
608
+ use_window_as_config = args.use_window_for_config
609
+
610
+ print(f"Arguments: Window Title='{target_window_title}', Use OBS Screenshot={use_obs_screenshot}, Use Window for Config={use_window_as_config}")
611
+
612
+ # Example of how to call it
613
+ selection_result = get_screen_selection(target_window_title, use_window_as_config, use_obs_screenshot)
614
+
615
+ if selection_result is None:
616
+ print("--- Screen selection failed. ---")
617
+ elif not selection_result:
618
+ print("\n--- Screen selection cancelled. ---")
619
+ elif 'rectangles' in selection_result:
620
+ print("\n--- Selection Result ---")
621
+ rects = selection_result.get('rectangles', [])
622
+ win_geom = selection_result.get('window_geometry')
623
+ print(f"Saved relative to window: {win_geom}")
624
+ print(f"Selected rectangles ({len(rects)}):")
625
+ # The returned coordinates are absolute pixels for immediate use
626
+ for i, (monitor, coords, is_excluded) in enumerate(rects):
627
+ coord_str = f"(X:{coords[0]}, Y:{coords[1]}, W:{coords[2]}, H:{coords[3]})"
628
+ print(
629
+ f" Rect {i + 1}: On Monitor Idx:{monitor.get('index', 'N/A')}, Coords={coord_str}, Excluded={is_excluded}")