interpreter-v2 0.1.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.
@@ -0,0 +1,13 @@
1
+ """Interpreter - Offline screen translator for Japanese retro games.
2
+
3
+ This application captures text from a target window, performs OCR using
4
+ MeikiOCR, translates using Sugoi V4, and displays subtitles in a
5
+ transparent overlay.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ # Public API
11
+ from .__main__ import main, list_windows
12
+
13
+ __all__ = ["main", "list_windows", "__version__"]
@@ -0,0 +1,450 @@
1
+ """Main entry point for Interpreter.
2
+
3
+ This module is executed when running:
4
+ - python -m interpreter
5
+ - interpreter (via pyproject.toml entry point)
6
+ """
7
+
8
+ import os
9
+ import sys
10
+
11
+ # Setup CUDA DLLs early (before any CUDA-dependent imports)
12
+ # This must happen before importing ctranslate2 or onnxruntime
13
+ if sys.platform == "win32":
14
+ from .gpu import setup_cuda_dlls
15
+ setup_cuda_dlls()
16
+
17
+ # Suppress harmless onnxruntime semaphore warning on exit
18
+ # Must be set before multiprocessing is imported
19
+ os.environ["PYTHONWARNINGS"] = "ignore::UserWarning:multiprocessing.resource_tracker"
20
+
21
+ # Suppress HuggingFace token warning (public models don't need auth)
22
+ os.environ["HF_HUB_DISABLE_IMPLICIT_TOKEN"] = "1"
23
+
24
+ import argparse
25
+ import time
26
+ from typing import Optional
27
+
28
+ from pynput import keyboard
29
+
30
+ from .capture import WindowCapture
31
+ from .config import Config
32
+ from .ocr import OCR
33
+ from .overlay import Overlay
34
+ from .translate import Translator, text_similarity
35
+
36
+ # Main loop timing constants
37
+ TEXT_SIMILARITY_THRESHOLD = 0.9 # Skip OCR if 90%+ similar to previous
38
+ UI_POLL_INTERVAL = 0.05 # Sleep between UI updates (seconds)
39
+ PAUSED_POLL_INTERVAL = 0.1 # Sleep when overlay paused (seconds)
40
+
41
+
42
+ def list_windows():
43
+ """List all available windows and exit."""
44
+ print("Available windows:")
45
+ print("-" * 60)
46
+
47
+ windows = WindowCapture.list_windows()
48
+ for w in windows:
49
+ title = w["title"][:50] + "..." if len(w["title"]) > 50 else w["title"]
50
+ print(f" {title}")
51
+
52
+ print("-" * 60)
53
+ print(f"Total: {len(windows)} windows")
54
+
55
+
56
+ def _parse_arguments() -> argparse.Namespace:
57
+ """Parse command-line arguments."""
58
+ parser = argparse.ArgumentParser(
59
+ description="Offline screen translator for Japanese games"
60
+ )
61
+ parser.add_argument(
62
+ "--config", "-c",
63
+ type=str,
64
+ default=None,
65
+ help="Path to config file (default: config.yml)"
66
+ )
67
+ parser.add_argument(
68
+ "--window", "-w",
69
+ type=str,
70
+ default=None,
71
+ help="Window title to capture (overrides config)"
72
+ )
73
+ parser.add_argument(
74
+ "--list-windows", "-l",
75
+ action="store_true",
76
+ help="List available windows and exit"
77
+ )
78
+ parser.add_argument(
79
+ "--no-translate",
80
+ action="store_true",
81
+ help="Skip translation (OCR only, for testing)"
82
+ )
83
+ parser.add_argument(
84
+ "--debug",
85
+ action="store_true",
86
+ help="Show per-character OCR confidence scores"
87
+ )
88
+ parser.add_argument(
89
+ "--overlay-mode", "-m",
90
+ type=str,
91
+ choices=["banner", "inplace"],
92
+ default=None,
93
+ help="Overlay mode: banner (subtitle at bottom) or inplace (over game text)"
94
+ )
95
+
96
+ return parser.parse_args()
97
+
98
+
99
+ def _initialize_components(
100
+ config: Config, args: argparse.Namespace
101
+ ) -> tuple[WindowCapture, OCR, Optional[Translator], Overlay]:
102
+ """Initialize capture, OCR, translator, and overlay.
103
+
104
+ Args:
105
+ config: Application configuration.
106
+ args: Parsed command-line arguments.
107
+
108
+ Returns:
109
+ Tuple of (capture, ocr, translator, overlay).
110
+ """
111
+ print("Initializing components...")
112
+
113
+ # Initialize screen capture
114
+ capture = WindowCapture(config.window_title)
115
+ if not capture.find_window():
116
+ print(f"Error: Window '{config.window_title}' not found.")
117
+ print("Use --list-windows to see available windows.")
118
+ sys.exit(1)
119
+ print(f" Window found: {config.window_title}")
120
+
121
+ # Initialize OCR
122
+ ocr = OCR(confidence_threshold=config.ocr_confidence, debug=args.debug)
123
+ print(" OCR: MeikiOCR (will load on first use)")
124
+
125
+ # Initialize translator (lazy loading)
126
+ translator = None
127
+ if not args.no_translate:
128
+ translator = Translator()
129
+ print(" Translator: Sugoi V4 (will load on first use)")
130
+ else:
131
+ print(" Translator: DISABLED (--no-translate)")
132
+
133
+ print(f" Window bounds: {capture.bounds}")
134
+
135
+ # Start capture stream
136
+ if not capture.start_stream():
137
+ print("Error: Could not start capture stream.")
138
+ sys.exit(1)
139
+ print(" Capture stream started")
140
+
141
+ # Wait for first frame for Retina scale detection
142
+ initial_image = None
143
+ for _ in range(50): # Wait up to ~2.5 seconds
144
+ initial_image = capture.get_frame()
145
+ if initial_image is not None:
146
+ break
147
+ time.sleep(0.05)
148
+
149
+ if initial_image is None:
150
+ print("Error: Could not capture initial image for overlay setup.")
151
+ capture.stop_stream()
152
+ sys.exit(1)
153
+ image_size = (initial_image.width, initial_image.height)
154
+
155
+ # Debug: save initial capture to verify what we're capturing
156
+ debug_path = "debug_capture.png"
157
+ initial_image.save(debug_path)
158
+ print(f" Debug: saved capture to {debug_path}")
159
+
160
+ # Create unified overlay
161
+ display_bounds = capture.get_display_bounds()
162
+ print(f" Display bounds: {display_bounds}")
163
+
164
+ overlay = Overlay(
165
+ font_size=config.font_size,
166
+ font_color=config.font_color,
167
+ background_color=config.background_color,
168
+ )
169
+ overlay.create(
170
+ display_bounds=display_bounds,
171
+ window_bounds=capture.bounds,
172
+ image_size=image_size,
173
+ mode=config.overlay_mode,
174
+ )
175
+ print(f" Overlay mode: {config.overlay_mode}")
176
+
177
+ return capture, ocr, translator, overlay
178
+
179
+
180
+ def _create_hotkey_handler() -> tuple[dict, callable]:
181
+ """Create hotkey state and handler function.
182
+
183
+ Returns:
184
+ Tuple of (state_dict, handler_function).
185
+ state_dict contains flags that are set when hotkeys are pressed.
186
+ """
187
+ state = {
188
+ "cycle_mode": False,
189
+ "increase_font": False,
190
+ "decrease_font": False,
191
+ "quit": False,
192
+ }
193
+
194
+ def on_key_press(key):
195
+ try:
196
+ if hasattr(key, 'char'):
197
+ if key.char == 'm':
198
+ state["cycle_mode"] = True
199
+ elif key.char == '=':
200
+ state["increase_font"] = True
201
+ elif key.char == '-':
202
+ state["decrease_font"] = True
203
+ elif key.char == 'q':
204
+ state["quit"] = True
205
+ except AttributeError:
206
+ pass
207
+
208
+ return state, on_key_press
209
+
210
+
211
+ def _run_main_loop(
212
+ overlay: Overlay,
213
+ capture: WindowCapture,
214
+ ocr: OCR,
215
+ translator: Optional[Translator],
216
+ config: Config,
217
+ hotkey_state: dict,
218
+ debug_mode: bool,
219
+ ) -> None:
220
+ """Run the main translation loop.
221
+
222
+ Args:
223
+ overlay: The overlay window.
224
+ capture: Window capture instance.
225
+ ocr: OCR instance.
226
+ translator: Translator instance (may be None).
227
+ config: Application configuration.
228
+ hotkey_state: Dict with hotkey flags.
229
+ debug_mode: Whether to print debug info.
230
+ """
231
+ # Track previous text to avoid re-translating
232
+ previous_text = ""
233
+ last_capture_time = 0
234
+
235
+ while overlay.is_running:
236
+ # Process UI events
237
+ overlay.update()
238
+
239
+ # Handle global hotkeys
240
+ if hotkey_state["cycle_mode"]:
241
+ hotkey_state["cycle_mode"] = False
242
+ # Cycle: off → banner → inplace → off
243
+ if overlay.paused:
244
+ overlay.set_mode("banner")
245
+ overlay.resume()
246
+ print("[MODE] Banner")
247
+ elif overlay.mode == "banner":
248
+ overlay.set_mode("inplace")
249
+ print("[MODE] Inplace")
250
+ else:
251
+ overlay.pause()
252
+ print("[MODE] Off")
253
+
254
+ if hotkey_state["increase_font"]:
255
+ hotkey_state["increase_font"] = False
256
+ overlay.adjust_font_size(2)
257
+ print(f"[FONT] Size: {overlay.font_size}")
258
+
259
+ if hotkey_state["decrease_font"]:
260
+ hotkey_state["decrease_font"] = False
261
+ overlay.adjust_font_size(-2)
262
+ print(f"[FONT] Size: {overlay.font_size}")
263
+
264
+ if hotkey_state["quit"]:
265
+ print("\n[QUIT] Exiting...")
266
+ break
267
+
268
+ # Skip processing if overlay is paused
269
+ if overlay.paused:
270
+ time.sleep(PAUSED_POLL_INTERVAL)
271
+ continue
272
+
273
+ # Check if it's time for a new capture
274
+ current_time = time.time()
275
+ if current_time - last_capture_time < config.refresh_rate:
276
+ time.sleep(UI_POLL_INTERVAL)
277
+ continue
278
+
279
+ last_capture_time = current_time
280
+ loop_start = time.perf_counter()
281
+
282
+ # Get latest frame from stream
283
+ capture_start = time.perf_counter()
284
+ image = capture.get_frame()
285
+ capture_time = time.perf_counter() - capture_start
286
+
287
+ if image is None:
288
+ if overlay.mode == "inplace":
289
+ overlay.update_regions([])
290
+ else:
291
+ overlay.update_text("[Window not found]")
292
+ continue
293
+
294
+ # Update overlay position if game window moved/resized
295
+ overlay.update_position(
296
+ capture.bounds,
297
+ display_bounds=capture.get_display_bounds(),
298
+ image_size=(image.width, image.height)
299
+ )
300
+
301
+ # Extract text
302
+ ocr_start = time.perf_counter()
303
+ try:
304
+ if overlay.mode == "inplace":
305
+ regions = ocr.extract_text_regions(image)
306
+ text = " ".join(r.text for r in regions if r.text)
307
+ else:
308
+ text = ocr.extract_text(image)
309
+ regions = []
310
+ except Exception as e:
311
+ print(f"OCR error: {e}")
312
+ continue
313
+ ocr_time = time.perf_counter() - ocr_start
314
+
315
+ # Skip if text hasn't changed (banner mode only - inplace always updates)
316
+ if overlay.mode != "inplace":
317
+ similarity = text_similarity(text, previous_text)
318
+ if similarity >= TEXT_SIMILARITY_THRESHOLD:
319
+ if debug_mode:
320
+ total_time = time.perf_counter() - loop_start
321
+ print(f"[TIMING] capture: {capture_time*1000:.0f}ms | ocr: {ocr_time*1000:.0f}ms | (similar: {similarity:.0%}) | total: {total_time*1000:.0f}ms")
322
+ continue
323
+ previous_text = text
324
+
325
+ if not text:
326
+ if overlay.mode == "inplace":
327
+ overlay.update_regions([])
328
+ else:
329
+ overlay.update_text("")
330
+ if debug_mode:
331
+ total_time = time.perf_counter() - loop_start
332
+ print(f"[TIMING] capture: {capture_time*1000:.0f}ms | ocr: {ocr_time*1000:.0f}ms | (no text) | total: {total_time*1000:.0f}ms")
333
+ continue
334
+
335
+ if overlay.mode == "inplace" and debug_mode:
336
+ print(f"[DBG] Found {len(regions)} regions")
337
+
338
+ # Translate
339
+ translate_time = 0.0
340
+ was_cached = False
341
+ if overlay.mode == "inplace":
342
+ # Translate each region separately
343
+ translated_regions = []
344
+ translate_start = time.perf_counter()
345
+ for region in regions:
346
+ if translator:
347
+ try:
348
+ translated, cached = translator.translate(region.text)
349
+ was_cached = was_cached or cached
350
+ except Exception as e:
351
+ print(f"Translation error: {e}")
352
+ translated = region.text
353
+ else:
354
+ translated = region.text
355
+ translated_regions.append((translated, region.bbox))
356
+ translate_time = time.perf_counter() - translate_start
357
+
358
+ # Update overlay with all regions
359
+ overlay.update_regions(translated_regions)
360
+
361
+ # Print each region
362
+ for region, (translated, _) in zip(regions, translated_regions):
363
+ print(f"[OCR] {region.text}")
364
+ if translator:
365
+ print(f"[EN] {translated}")
366
+ else:
367
+ # Banner mode: single text block
368
+ if translator:
369
+ translate_start = time.perf_counter()
370
+ try:
371
+ translated, was_cached = translator.translate(text)
372
+ display_text = translated
373
+ except Exception as e:
374
+ print(f"Translation error: {e}")
375
+ display_text = f"[{text}]"
376
+ translate_time = time.perf_counter() - translate_start
377
+ else:
378
+ display_text = text
379
+
380
+ overlay.update_text(display_text)
381
+ print(f"[OCR] {text}")
382
+ if translator:
383
+ cache_indicator = " (cached)" if was_cached else ""
384
+ print(f"[EN] {display_text}{cache_indicator}")
385
+
386
+ # Print timing in debug mode
387
+ if debug_mode:
388
+ total_time = time.perf_counter() - loop_start
389
+ cache_str = " CACHE" if was_cached else ""
390
+ if translator:
391
+ print(f"[TIMING] capture: {capture_time*1000:.0f}ms | ocr: {ocr_time*1000:.0f}ms | translate: {translate_time*1000:.0f}ms{cache_str} | total: {total_time*1000:.0f}ms")
392
+ else:
393
+ print(f"[TIMING] capture: {capture_time*1000:.0f}ms | ocr: {ocr_time*1000:.0f}ms | total: {total_time*1000:.0f}ms")
394
+ print()
395
+
396
+
397
+ def main():
398
+ """Main entry point."""
399
+ args = _parse_arguments()
400
+
401
+ # List windows mode
402
+ if args.list_windows:
403
+ list_windows()
404
+ return
405
+
406
+ # Load configuration
407
+ config = Config.load(args.config)
408
+
409
+ # Override with CLI arguments
410
+ if args.window:
411
+ config.window_title = args.window
412
+ if args.overlay_mode:
413
+ config.overlay_mode = args.overlay_mode
414
+
415
+ print("Interpreter - Offline Screen Translator")
416
+ print("=" * 50)
417
+ print(f"Target window: {config.window_title}")
418
+ print(f"Refresh rate: {config.refresh_rate}s")
419
+ print(f"Overlay mode: {config.overlay_mode}")
420
+ print()
421
+
422
+ # Initialize components
423
+ capture, ocr, translator, overlay = _initialize_components(config, args)
424
+
425
+ # Setup hotkeys
426
+ hotkey_state, on_key_press = _create_hotkey_handler()
427
+ keyboard_listener = keyboard.Listener(on_press=on_key_press)
428
+ keyboard_listener.start()
429
+
430
+ print()
431
+ print("Starting translation loop...")
432
+ print("Press 'm' to cycle mode (off → banner → inplace), '-/=' to adjust font, 'q' to quit")
433
+ print("-" * 50)
434
+
435
+ try:
436
+ _run_main_loop(
437
+ overlay, capture, ocr, translator,
438
+ config, hotkey_state, args.debug
439
+ )
440
+ except KeyboardInterrupt:
441
+ print("\nInterrupted by user")
442
+ finally:
443
+ keyboard_listener.stop()
444
+ capture.stop_stream()
445
+ overlay.quit()
446
+ print("Goodbye!")
447
+
448
+
449
+ if __name__ == "__main__":
450
+ main()
interpreter/capture.py ADDED
@@ -0,0 +1,155 @@
1
+ """Platform-agnostic screen capture interface."""
2
+
3
+ import platform
4
+ from typing import Optional
5
+
6
+ from PIL import Image
7
+
8
+ # Import platform-specific implementation
9
+ _system = platform.system()
10
+
11
+ if _system == "Darwin":
12
+ from .capture_macos import find_window_by_title, capture_window, get_window_list, get_display_bounds_for_window, _get_window_bounds, MacOSCaptureStream
13
+ CaptureStream = MacOSCaptureStream
14
+ elif _system == "Windows":
15
+ from .capture_windows import find_window_by_title, capture_window, get_window_list, _get_window_bounds, WindowsCaptureStream
16
+ CaptureStream = WindowsCaptureStream
17
+ # Windows doesn't have display bounds detection yet
18
+ def get_display_bounds_for_window(window_id: int) -> Optional[dict]:
19
+ return None
20
+ else:
21
+ raise RuntimeError(f"Unsupported platform: {_system}")
22
+
23
+
24
+ class WindowCapture:
25
+ """Captures screenshots of a specific window by title."""
26
+
27
+ def __init__(self, window_title: str):
28
+ """Initialize the window capture.
29
+
30
+ Args:
31
+ window_title: Partial title of the window to capture.
32
+ """
33
+ self.window_title = window_title
34
+ self._window_id: Optional[int] = None
35
+ self._last_bounds: Optional[dict] = None
36
+ self._stream: Optional[CaptureStream] = None
37
+
38
+ def find_window(self) -> bool:
39
+ """Find and cache the window ID.
40
+
41
+ Returns:
42
+ True if window was found, False otherwise.
43
+ """
44
+ window = find_window_by_title(self.window_title)
45
+ if window:
46
+ self._window_id = window["id"]
47
+ self._last_bounds = window["bounds"]
48
+ return True
49
+ return False
50
+
51
+ def capture(self) -> Optional[Image.Image]:
52
+ """Capture a screenshot of the target window.
53
+
54
+ Returns:
55
+ PIL Image of the window, or None if capture failed.
56
+ """
57
+ # Try to find window if we don't have an ID yet
58
+ if self._window_id is None:
59
+ if not self.find_window():
60
+ return None
61
+
62
+ # Capture the window
63
+ image = capture_window(self._window_id)
64
+
65
+ # If capture failed, the window might have been closed
66
+ # Try to find it again
67
+ if image is None:
68
+ self._window_id = None
69
+ if self.find_window():
70
+ image = capture_window(self._window_id)
71
+
72
+ # Update bounds (window may have moved)
73
+ if image is not None and self._window_id is not None:
74
+ self._refresh_bounds()
75
+
76
+ return image
77
+
78
+ def _refresh_bounds(self) -> None:
79
+ """Refresh the cached window bounds."""
80
+ if self._window_id is None:
81
+ return
82
+ # Get updated bounds directly by window ID (more efficient)
83
+ bounds = _get_window_bounds(self._window_id)
84
+ if bounds:
85
+ self._last_bounds = bounds
86
+
87
+ @property
88
+ def window_found(self) -> bool:
89
+ """Check if the target window has been found."""
90
+ return self._window_id is not None
91
+
92
+ @property
93
+ def bounds(self) -> Optional[dict]:
94
+ """Get the bounds of the target window."""
95
+ return self._last_bounds
96
+
97
+ def get_display_bounds(self) -> Optional[dict]:
98
+ """Get the bounds of the display containing the target window."""
99
+ if self._window_id is None:
100
+ return None
101
+ return get_display_bounds_for_window(self._window_id)
102
+
103
+ @staticmethod
104
+ def list_windows() -> list[dict]:
105
+ """List all available windows.
106
+
107
+ Returns:
108
+ List of window dictionaries with id, title, and bounds.
109
+ """
110
+ return get_window_list()
111
+
112
+ def start_stream(self) -> bool:
113
+ """Start the background capture stream.
114
+
115
+ Returns:
116
+ True if stream started successfully, False otherwise.
117
+ """
118
+ # Find window first if needed
119
+ if self._window_id is None:
120
+ if not self.find_window():
121
+ return False
122
+
123
+ # Create platform-specific stream
124
+ if _system == "Windows":
125
+ # Windows uses window title for capture
126
+ self._stream = CaptureStream(self.window_title)
127
+ else:
128
+ # macOS uses window ID for capture
129
+ self._stream = CaptureStream(self._window_id)
130
+
131
+ self._stream.start()
132
+ return True
133
+
134
+ def get_frame(self) -> Optional[Image.Image]:
135
+ """Get the latest frame from the capture stream.
136
+
137
+ Returns:
138
+ PIL Image of the window, or None if no frame available.
139
+ """
140
+ if self._stream is None:
141
+ return None
142
+
143
+ frame = self._stream.get_frame()
144
+
145
+ # Update bounds if we got a frame
146
+ if frame is not None and self._window_id is not None:
147
+ self._refresh_bounds()
148
+
149
+ return frame
150
+
151
+ def stop_stream(self):
152
+ """Stop the background capture stream."""
153
+ if self._stream is not None:
154
+ self._stream.stop()
155
+ self._stream = None