algorembrant commited on
Commit
52960ea
·
verified ·
1 Parent(s): 3430f4a

Upload 10 files

Browse files
Files changed (10) hide show
  1. .gitignore +106 -0
  2. LICENSE +21 -0
  3. README.md +327 -0
  4. ci.yml +82 -0
  5. list_langs.py +10 -0
  6. requirements.txt +52 -0
  7. supported_langs.json +135 -0
  8. test_results.json +6 -0
  9. test_translate.py +18 -0
  10. translator.py +1688 -0
.gitignore ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ChineseScreenTranslator .gitignore
2
+ # Author: algorembrant
3
+
4
+ # Python
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+ *.so
9
+ *.egg
10
+ *.egg-info/
11
+ dist/
12
+ build/
13
+ eggs/
14
+ parts/
15
+ var/
16
+ sdist/
17
+ wheels/
18
+ pip-wheel-metadata/
19
+ .installed.cfg
20
+ *.egg-info
21
+ MANIFEST
22
+
23
+ # Virtual environments
24
+ venv/
25
+ env/
26
+ .venv/
27
+ .env/
28
+ ENV/
29
+ .conda/
30
+ Pipfile.lock
31
+
32
+ # IDE / Editor
33
+ .vscode/
34
+ .idea/
35
+ *.swp
36
+ *.swo
37
+ *~
38
+ .DS_Store
39
+ Thumbs.db
40
+ desktop.ini
41
+
42
+ # Logs
43
+ *.log
44
+ logs/
45
+
46
+ # Config and user data (do not commit personal config)
47
+ ~/.chinese_screen_translator/
48
+ config.json
49
+ history.json
50
+
51
+ # Downloaded ML models (large files - use Git LFS or exclude)
52
+ models/
53
+ *.bin
54
+ *.safetensors
55
+ *.pt
56
+ *.pth
57
+ *.ckpt
58
+ *.onnx
59
+
60
+ # EasyOCR model cache
61
+ .EasyOCR/
62
+
63
+ # Screenshots / temp images
64
+ *.png
65
+ *.jpg
66
+ *.jpeg
67
+ *.bmp
68
+ *.tiff
69
+ screenshots/
70
+ temp/
71
+ tmp/
72
+
73
+ # OS artifacts
74
+ .DS_Store
75
+ .AppleDouble
76
+ .LSOverride
77
+ Icon
78
+ ._*
79
+ .Spotlight-V100
80
+ .Trashes
81
+ ehthumbs.db
82
+ Thumbs.db
83
+
84
+ # Distribution / packaging
85
+ *.tar.gz
86
+ *.zip
87
+ *.whl
88
+
89
+ # Test artifacts
90
+ .pytest_cache/
91
+ .coverage
92
+ htmlcov/
93
+ .tox/
94
+
95
+ # Jupyter
96
+ .ipynb_checkpoints/
97
+ *.ipynb
98
+
99
+ # PyInstaller
100
+ *.spec
101
+ dist/
102
+
103
+ # mypy
104
+ .mypy_cache/
105
+ .dmypy.json
106
+ dmypy.json
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2024 algorembrant
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - zh
4
+ - en
5
+ tags:
6
+ - translation
7
+ - ocr
8
+ - screen-capture
9
+ - chinese
10
+ - easyocr
11
+ - marianmt
12
+ license: mit
13
+ library_name: transformers
14
+ pipeline_tag: translation
15
+ model-index:
16
+ - name: ChineseScreenTranslator
17
+ results:
18
+ - task:
19
+ type: translation
20
+ name: Chinese to English Screen OCR Translation
21
+ metrics:
22
+ - type: bleu
23
+ value: N/A
24
+ name: BLEU
25
+ ---
26
+
27
+ # ChineseScreenTranslator
28
+
29
+ [![Python](https://img.shields.io/badge/Python-3.9%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https://python.org)
30
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](LICENSE)
31
+ [![EasyOCR](https://img.shields.io/badge/OCR-EasyOCR-orange?style=flat-square)](https://github.com/JaidedAI/EasyOCR)
32
+ [![HuggingFace](https://img.shields.io/badge/HuggingFace-Helsinki--NLP-FFD21E?style=flat-square&logo=huggingface&logoColor=black)](https://huggingface.co/Helsinki-NLP/opus-mt-zh-en)
33
+ [![Google Translate](https://img.shields.io/badge/Backend-Google%20Translate-4285F4?style=flat-square&logo=googletranslate&logoColor=white)](https://translate.google.com)
34
+ [![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey?style=flat-square)](https://github.com)
35
+ [![Author](https://img.shields.io/badge/Author-algorembrant-e94560?style=flat-square)](https://github.com/algorembrant)
36
+
37
+ A screen region selection tool that automatically detects and translates Chinese text (Simplified, Traditional, Cantonese, Classical) to English in real time, powered by EasyOCR and neural machine translation.
38
+
39
+ ---
40
+
41
+ ## Features
42
+
43
+ | Feature | Details |
44
+ |---|---|
45
+ | Screen Selection | Click-and-drag rubber-band region selector |
46
+ | OCR Engine | EasyOCR - Simplified + Traditional Chinese |
47
+ | Translation | Google Translate, Microsoft, or offline Helsinki-NLP |
48
+ | Chinese Variants | Simplified, Traditional, Cantonese, Classical, Mixed |
49
+ | Hotkey Trigger | Global configurable hotkey (default: Ctrl+Shift+S) |
50
+ | Image Preprocessing | Upscale, contrast enhance, sharpen for accuracy |
51
+ | History | Persistent JSON history with search and export |
52
+ | Clipboard | Auto-copy or one-click copy |
53
+ | Offline Mode | Helsinki-NLP MarianMT (opus-mt-zh-en) |
54
+ | GPU Support | CUDA acceleration for OCR and offline translation |
55
+ | Settings UI | Built-in settings window |
56
+
57
+ ---
58
+
59
+ ## Screenshots
60
+
61
+ ```
62
+ +--------------------------------------+
63
+ | ChineseScreenTranslator v1.0.0 |
64
+ | Detected Chinese Text |
65
+ | +----------------------------------+|
66
+ | | 你好,欢迎使用本翻译工具。 ||
67
+ | +----------------------------------+|
68
+ | English Translation |
69
+ | +----------------------------------+|
70
+ | | Hello, welcome to this ||
71
+ | | translation tool. ||
72
+ | +----------------------------------+|
73
+ | [Copy Translation] [Copy Source] |
74
+ +--------------------------------------+
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Requirements
80
+
81
+ ### System Requirements
82
+
83
+ | Component | Minimum | Recommended |
84
+ |---|---|---|
85
+ | Python | 3.9 | 3.11+ |
86
+ | RAM | 4 GB | 8 GB+ |
87
+ | Disk Space | 1 GB | 2 GB (models + deps) |
88
+ | GPU (optional) | CUDA 11.8 | CUDA 12.1 |
89
+ | OS | Windows 10 / Ubuntu 20.04 / macOS 12 | Windows 11 / Ubuntu 22.04 |
90
+
91
+ ### First Run Downloads
92
+
93
+ | Component | Size | Purpose |
94
+ |---|---|---|
95
+ | EasyOCR detection model | ~50 MB | Text region detection |
96
+ | EasyOCR ch_sim model | ~90 MB | Simplified Chinese recognition |
97
+ | EasyOCR ch_tra model | ~90 MB | Traditional Chinese recognition |
98
+ | Helsinki-NLP opus-mt-zh-en | ~300 MB | Offline translation (optional) |
99
+
100
+ ---
101
+
102
+ ## Installation
103
+
104
+ ### Step 1 - Clone the repository
105
+
106
+ ```bash
107
+ git clone https://github.com/algorembrant/ChineseScreenTranslator.git
108
+ cd ChineseScreenTranslator
109
+ ```
110
+
111
+ ### Step 2 - Create a virtual environment (recommended)
112
+
113
+ ```bash
114
+ python -m venv venv
115
+
116
+ # Windows
117
+ venv\Scripts\activate
118
+
119
+ # Linux / macOS
120
+ source venv/bin/activate
121
+ ```
122
+
123
+ ### Step 3 - Install PyTorch
124
+
125
+ Choose the command that matches your hardware:
126
+
127
+ | Hardware | Command |
128
+ |---|---|
129
+ | CPU only (any OS) | `pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu` |
130
+ | NVIDIA GPU CUDA 11.8 | `pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118` |
131
+ | NVIDIA GPU CUDA 12.1 | `pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121` |
132
+ | macOS (Apple Silicon) | `pip install torch torchvision` |
133
+
134
+ ### Step 4 - Install remaining dependencies
135
+
136
+ ```bash
137
+ pip install -r requirements.txt
138
+ ```
139
+
140
+ ### Step 5 - Run the application
141
+
142
+ ```bash
143
+ python translator.py
144
+ ```
145
+
146
+ The first launch downloads OCR models (~230 MB total). This only happens once.
147
+
148
+ ---
149
+
150
+ ## Usage
151
+
152
+ ### Basic Usage
153
+
154
+ | Action | How |
155
+ |---|---|
156
+ | Start application | `python translator.py` |
157
+ | Trigger screen capture | Press `Ctrl+Shift+S` |
158
+ | Select region | Click and drag on screen |
159
+ | View translation | Result window appears automatically |
160
+ | Copy translation | Click "Copy Translation" button |
161
+ | View history | Press `Ctrl+Shift+H` |
162
+ | Quit | Press `Ctrl+Shift+Q` or close window |
163
+
164
+ ### Keyboard Shortcuts
165
+
166
+ | Shortcut | Action |
167
+ |---|---|
168
+ | `Ctrl+Shift+S` | Open screen region selector |
169
+ | `Ctrl+Shift+C` | Copy last translation to clipboard |
170
+ | `Ctrl+Shift+H` | Show/hide translation history |
171
+ | `Ctrl+Shift+Q` | Quit application |
172
+ | `Escape` | Cancel selection overlay |
173
+
174
+ ### Command Line Options
175
+
176
+ | Option | Example | Description |
177
+ |---|---|---|
178
+ | `--hotkey` | `--hotkey "ctrl+alt+x"` | Set custom global hotkey |
179
+ | `--lang` | `--lang traditional` | Force OCR language mode |
180
+ | `--backend` | `--backend offline` | Set translation backend |
181
+ | `--offline` | `--offline` | Use offline MarianMT model |
182
+ | `--gpu` | `--gpu` | Enable CUDA GPU acceleration |
183
+ | `--upscale` | `--upscale 3` | Upscale factor for small text |
184
+ | `--confidence` | `--confidence 0.5` | OCR confidence threshold |
185
+ | `--no-preprocess` | `--no-preprocess` | Disable image preprocessing |
186
+ | `--export-history` | `--export-history out.json` | Export history on exit |
187
+ | `--verbose` | `--verbose` | Enable DEBUG logging |
188
+ | `--version` | `--version` | Show version and exit |
189
+ | `--help` | `--help` | Show full help |
190
+
191
+ ### Translation Backends
192
+
193
+ | Backend | Speed | Requires | Quality |
194
+ |---|---|---|---|
195
+ | `google` (default) | Fast | Internet | Excellent |
196
+ | `microsoft` | Fast | Internet + API key | Excellent |
197
+ | `offline` | Moderate | ~300 MB model | Good |
198
+
199
+ For Microsoft Translator, add your Azure Cognitive Services key to `~/.chinese_screen_translator/config.json`:
200
+
201
+ ```json
202
+ {
203
+ "microsoft_api_key": "YOUR_KEY_HERE",
204
+ "microsoft_region": "eastus"
205
+ }
206
+ ```
207
+
208
+ ### Supported Chinese Variants
209
+
210
+ | Variant | Mode | Notes |
211
+ |---|---|---|
212
+ | Simplified Chinese (普通话 / 简体) | `--lang simplified` | Mainland China standard |
213
+ | Traditional Chinese (繁體中文) | `--lang traditional` | Taiwan, Hong Kong |
214
+ | Cantonese / Yue (廣東話) | `--lang traditional` | Use traditional mode |
215
+ | Classical Chinese (文言文) | `--lang auto` | Auto-detected |
216
+ | Mixed Chinese-English | `--lang auto` | Handled by both models |
217
+ | Vertical Chinese text | `--lang auto` | EasyOCR handles automatically |
218
+
219
+ ---
220
+
221
+ ## Configuration
222
+
223
+ Config file location: `~/.chinese_screen_translator/config.json`
224
+
225
+ ```json
226
+ {
227
+ "hotkey": "ctrl+shift+s",
228
+ "lang": "auto",
229
+ "backend": "google",
230
+ "use_gpu": false,
231
+ "upscale_factor": 2,
232
+ "confidence_threshold": 0.30,
233
+ "preprocess": true,
234
+ "auto_copy": false,
235
+ "show_confidence": true,
236
+ "max_history": 500,
237
+ "microsoft_api_key": "",
238
+ "microsoft_region": "eastus",
239
+ "offline_model_dir": "~/.chinese_screen_translator/models"
240
+ }
241
+ ```
242
+
243
+ ---
244
+
245
+ ## Troubleshooting
246
+
247
+ | Problem | Solution |
248
+ |---|---|
249
+ | `easyocr` not found | `pip install easyocr` then install PyTorch |
250
+ | Models not downloading | Check internet connection; proxy may block HuggingFace CDN |
251
+ | Hotkeys not working on Linux | Run as root: `sudo python translator.py` |
252
+ | Low OCR accuracy | Increase `--upscale 3`, zoom in on text, improve lighting |
253
+ | `keyboard` error on macOS | Grant Accessibility permissions in System Preferences |
254
+ | Out of memory | Use CPU mode (no `--gpu`), reduce `--upscale` |
255
+ | No text detected | Lower `--confidence 0.2`, ensure text is clear and large enough |
256
+ | Google Translate blocked | Use `--backend offline` or set up Microsoft API key |
257
+
258
+ ---
259
+
260
+ ## Project Structure
261
+
262
+ ```
263
+ ChineseScreenTranslator/
264
+ translator.py Main application (single file)
265
+ requirements.txt Python dependencies
266
+ README.md This file
267
+ .gitignore Git ignore rules
268
+ .gitattributes Git / HuggingFace LFS config
269
+ LICENSE MIT License
270
+ ```
271
+
272
+ ---
273
+
274
+ ## Architecture
275
+
276
+ ```
277
+ Hotkey / Button
278
+ |
279
+ v
280
+ ScreenOverlay (tkinter fullscreen transparent)
281
+ | click + drag
282
+ v
283
+ _capture_screenshot (mss / PIL)
284
+ |
285
+ v
286
+ ImagePreprocessor
287
+ - Upscale (Lanczos)
288
+ - Contrast enhance
289
+ - Sharpen
290
+ |
291
+ v
292
+ OCREngine (EasyOCR)
293
+ - ch_sim reader
294
+ - ch_tra reader
295
+ - Auto-detect or forced mode
296
+ |
297
+ v
298
+ TranslationEngine
299
+ - Google Translate (deep-translator)
300
+ - Microsoft Translator
301
+ - Helsinki-NLP MarianMT (offline)
302
+ |
303
+ v
304
+ ResultWindow (tkinter)
305
+ HistoryManager (JSON persistence)
306
+ ```
307
+
308
+ ---
309
+
310
+ ## License
311
+
312
+ MIT License. See `LICENSE` for details.
313
+
314
+ ---
315
+
316
+ ## Author
317
+
318
+ **algorembrant**
319
+
320
+ ---
321
+
322
+ ## Acknowledgments
323
+
324
+ - [EasyOCR](https://github.com/JaidedAI/EasyOCR) - OCR engine by JaidedAI
325
+ - [Helsinki-NLP](https://huggingface.co/Helsinki-NLP) - MarianMT translation models
326
+ - [deep-translator](https://github.com/nidhaloff/deep-translator) - Translation backends
327
+ - [mss](https://github.com/BoboTiG/python-mss) - Fast screen capture
ci.yml ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # .github/workflows/ci.yml
2
+ # ChineseScreenTranslator CI
3
+ # Author: algorembrant
4
+
5
+ name: CI
6
+
7
+ on:
8
+ push:
9
+ branches: [ main, develop ]
10
+ pull_request:
11
+ branches: [ main ]
12
+
13
+ jobs:
14
+ lint-and-test:
15
+ runs-on: ${{ matrix.os }}
16
+ strategy:
17
+ matrix:
18
+ os: [ubuntu-latest, windows-latest]
19
+ python-version: ["3.9", "3.11"]
20
+ fail-fast: false
21
+
22
+ steps:
23
+ - name: Checkout repository
24
+ uses: actions/checkout@v4
25
+
26
+ - name: Set up Python ${{ matrix.python-version }}
27
+ uses: actions/setup-python@v5
28
+ with:
29
+ python-version: ${{ matrix.python-version }}
30
+ cache: pip
31
+
32
+ - name: Install PyTorch (CPU)
33
+ run: |
34
+ pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
35
+
36
+ - name: Install dependencies
37
+ run: |
38
+ pip install --upgrade pip
39
+ pip install -r requirements.txt
40
+ pip install flake8 pyflakes
41
+
42
+ - name: Lint with flake8
43
+ run: |
44
+ flake8 translator.py --max-line-length=100 --ignore=E501,W503 --count --statistics
45
+
46
+ - name: Syntax check
47
+ run: |
48
+ python -m py_compile translator.py
49
+ echo "Syntax OK"
50
+
51
+ - name: Import check (no GUI display required)
52
+ run: |
53
+ python -c "
54
+ import sys
55
+ # Mock tkinter display for headless CI
56
+ import os
57
+ os.environ.setdefault('DISPLAY', ':99')
58
+ print('Import check passed')
59
+ print(f'Python {sys.version}')
60
+ "
61
+
62
+ release:
63
+ needs: lint-and-test
64
+ runs-on: ubuntu-latest
65
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
66
+ steps:
67
+ - uses: actions/checkout@v4
68
+ - name: Create release archive
69
+ run: |
70
+ zip -r ChineseScreenTranslator.zip \
71
+ translator.py \
72
+ requirements.txt \
73
+ README.md \
74
+ LICENSE \
75
+ .gitattributes \
76
+ .gitignore
77
+ - name: Upload artifact
78
+ uses: actions/upload-artifact@v4
79
+ with:
80
+ name: ChineseScreenTranslator-release
81
+ path: ChineseScreenTranslator.zip
82
+ retention-days: 30
list_langs.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from deep_translator import GoogleTranslator
2
+ import json
3
+
4
+ try:
5
+ langs = GoogleTranslator().get_supported_languages(as_dict=True)
6
+ with open("supported_langs.json", "w", encoding="utf-8") as f:
7
+ json.dump(langs, f, indent=2)
8
+ print("Languages saved to supported_langs.json")
9
+ except Exception as e:
10
+ print(f"Error: {e}")
requirements.txt ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ChineseScreenTranslator - Python Dependencies
2
+ # Author: algorembrant
3
+ # Version: 1.0.0
4
+ #
5
+ # INSTALL ORDER (important):
6
+ # 1. Install PyTorch first (see note below)
7
+ # 2. pip install -r requirements.txt
8
+ #
9
+ # PyTorch (NOT included here - install separately based on your hardware):
10
+ #
11
+ # CPU only (Windows/Linux):
12
+ # pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
13
+ #
14
+ # CUDA 11.8 (NVIDIA GPU):
15
+ # pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118
16
+ #
17
+ # CUDA 12.1 (NVIDIA GPU):
18
+ # pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121
19
+ #
20
+ # macOS (Apple Silicon / Intel):
21
+ # pip install torch torchvision
22
+ #
23
+ # After PyTorch, install everything else:
24
+ # pip install -r requirements.txt
25
+
26
+ # ── Image Processing ──────────────────────────────────────────────────────────
27
+ Pillow>=10.0.0
28
+ mss>=9.0.0
29
+ numpy>=1.24.0
30
+ opencv-python>=4.8.0
31
+
32
+ # ── OCR ───────────────────────────────────────────────────────────────────────
33
+ easyocr>=1.7.0
34
+
35
+ # ── Translation (Online) ──────────────────────────────────────────────────────
36
+ deep-translator>=1.11.0
37
+
38
+ # ── Translation (Offline / Optional) ─────────────────────────────────────────
39
+ # Uncomment for offline Helsinki-NLP support:
40
+ # transformers>=4.35.0
41
+ # sentencepiece>=0.1.99
42
+ # sacremoses>=0.0.53
43
+
44
+ # ── Hotkeys ───────────────────────────────────────────────────────────────────
45
+ keyboard>=0.13.5
46
+
47
+ # ── Clipboard ─────────────────────────────────────────────────────────────────
48
+ pyperclip>=1.8.2
49
+
50
+ # ── Utilities ─────────────────────────────────────────────────────────────────
51
+ requests>=2.31.0
52
+ urllib3>=2.0.0
supported_langs.json ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "afrikaans": "af",
3
+ "albanian": "sq",
4
+ "amharic": "am",
5
+ "arabic": "ar",
6
+ "armenian": "hy",
7
+ "assamese": "as",
8
+ "aymara": "ay",
9
+ "azerbaijani": "az",
10
+ "bambara": "bm",
11
+ "basque": "eu",
12
+ "belarusian": "be",
13
+ "bengali": "bn",
14
+ "bhojpuri": "bho",
15
+ "bosnian": "bs",
16
+ "bulgarian": "bg",
17
+ "catalan": "ca",
18
+ "cebuano": "ceb",
19
+ "chichewa": "ny",
20
+ "chinese (simplified)": "zh-CN",
21
+ "chinese (traditional)": "zh-TW",
22
+ "corsican": "co",
23
+ "croatian": "hr",
24
+ "czech": "cs",
25
+ "danish": "da",
26
+ "dhivehi": "dv",
27
+ "dogri": "doi",
28
+ "dutch": "nl",
29
+ "english": "en",
30
+ "esperanto": "eo",
31
+ "estonian": "et",
32
+ "ewe": "ee",
33
+ "filipino": "tl",
34
+ "finnish": "fi",
35
+ "french": "fr",
36
+ "frisian": "fy",
37
+ "galician": "gl",
38
+ "georgian": "ka",
39
+ "german": "de",
40
+ "greek": "el",
41
+ "guarani": "gn",
42
+ "gujarati": "gu",
43
+ "haitian creole": "ht",
44
+ "hausa": "ha",
45
+ "hawaiian": "haw",
46
+ "hebrew": "iw",
47
+ "hindi": "hi",
48
+ "hmong": "hmn",
49
+ "hungarian": "hu",
50
+ "icelandic": "is",
51
+ "igbo": "ig",
52
+ "ilocano": "ilo",
53
+ "indonesian": "id",
54
+ "irish": "ga",
55
+ "italian": "it",
56
+ "japanese": "ja",
57
+ "javanese": "jw",
58
+ "kannada": "kn",
59
+ "kazakh": "kk",
60
+ "khmer": "km",
61
+ "kinyarwanda": "rw",
62
+ "konkani": "gom",
63
+ "korean": "ko",
64
+ "krio": "kri",
65
+ "kurdish (kurmanji)": "ku",
66
+ "kurdish (sorani)": "ckb",
67
+ "kyrgyz": "ky",
68
+ "lao": "lo",
69
+ "latin": "la",
70
+ "latvian": "lv",
71
+ "lingala": "ln",
72
+ "lithuanian": "lt",
73
+ "luganda": "lg",
74
+ "luxembourgish": "lb",
75
+ "macedonian": "mk",
76
+ "maithili": "mai",
77
+ "malagasy": "mg",
78
+ "malay": "ms",
79
+ "malayalam": "ml",
80
+ "maltese": "mt",
81
+ "maori": "mi",
82
+ "marathi": "mr",
83
+ "meiteilon (manipuri)": "mni-Mtei",
84
+ "mizo": "lus",
85
+ "mongolian": "mn",
86
+ "myanmar": "my",
87
+ "nepali": "ne",
88
+ "norwegian": "no",
89
+ "odia (oriya)": "or",
90
+ "oromo": "om",
91
+ "pashto": "ps",
92
+ "persian": "fa",
93
+ "polish": "pl",
94
+ "portuguese": "pt",
95
+ "punjabi": "pa",
96
+ "quechua": "qu",
97
+ "romanian": "ro",
98
+ "russian": "ru",
99
+ "samoan": "sm",
100
+ "sanskrit": "sa",
101
+ "scots gaelic": "gd",
102
+ "sepedi": "nso",
103
+ "serbian": "sr",
104
+ "sesotho": "st",
105
+ "shona": "sn",
106
+ "sindhi": "sd",
107
+ "sinhala": "si",
108
+ "slovak": "sk",
109
+ "slovenian": "sl",
110
+ "somali": "so",
111
+ "spanish": "es",
112
+ "sundanese": "su",
113
+ "swahili": "sw",
114
+ "swedish": "sv",
115
+ "tajik": "tg",
116
+ "tamil": "ta",
117
+ "tatar": "tt",
118
+ "telugu": "te",
119
+ "thai": "th",
120
+ "tigrinya": "ti",
121
+ "tsonga": "ts",
122
+ "turkish": "tr",
123
+ "turkmen": "tk",
124
+ "twi": "ak",
125
+ "ukrainian": "uk",
126
+ "urdu": "ur",
127
+ "uyghur": "ug",
128
+ "uzbek": "uz",
129
+ "vietnamese": "vi",
130
+ "welsh": "cy",
131
+ "xhosa": "xh",
132
+ "yiddish": "yi",
133
+ "yoruba": "yo",
134
+ "zulu": "zu"
135
+ }
test_results.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "original": "在尊嚴和權利上",
3
+ "auto": "在尊嚴和權利上",
4
+ "zht": "in dignity and rights",
5
+ "zhc": "in dignity and rights"
6
+ }
test_translate.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from deep_translator import GoogleTranslator
2
+ import json
3
+
4
+ text = "在尊嚴和權利上"
5
+ results = {"original": text}
6
+
7
+ try:
8
+ results["auto"] = GoogleTranslator(source='auto', target='en').translate(text)
9
+
10
+ results["zht"] = GoogleTranslator(source='zh-TW', target='en').translate(text)
11
+
12
+ results["zhc"] = GoogleTranslator(source='zh-CN', target='en').translate(text)
13
+
14
+ except Exception as e:
15
+ results["error"] = str(e)
16
+
17
+ with open("test_results.json", "w", encoding="utf-8") as f:
18
+ json.dump(results, f, indent=2, ensure_ascii=False)
translator.py ADDED
@@ -0,0 +1,1688 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ ChineseScreenTranslator v1.0.0
4
+ ================================
5
+ Author : algorembrant
6
+ License : MIT
7
+ Version : 1.0.0
8
+
9
+ A screen region selection tool that automatically detects and translates
10
+ Chinese text (Simplified, Traditional, Cantonese, Classical) to English
11
+ using OCR (EasyOCR) and neural machine translation.
12
+
13
+ USAGE COMMANDS
14
+ --------------
15
+ Run (default, hotkey mode):
16
+ python translator.py
17
+
18
+ Run with custom hotkey:
19
+ python translator.py --hotkey "ctrl+shift+t"
20
+
21
+ Run in offline translation mode (Helsinki-NLP MarianMT):
22
+ python translator.py --offline
23
+
24
+ Force Simplified Chinese OCR:
25
+ python translator.py --lang simplified
26
+
27
+ Force Traditional Chinese OCR:
28
+ python translator.py --lang traditional
29
+
30
+ Auto-detect Chinese script (default):
31
+ python translator.py --lang auto
32
+
33
+ Enable GPU acceleration for OCR/translation (requires CUDA):
34
+ python translator.py --gpu
35
+
36
+ Set translation backend:
37
+ python translator.py --backend google
38
+ python translator.py --backend microsoft
39
+ python translator.py --backend offline
40
+
41
+ Set confidence threshold (0.0 - 1.0, default 0.3):
42
+ python translator.py --confidence 0.5
43
+
44
+ Enable verbose/debug logging:
45
+ python translator.py --verbose
46
+
47
+ Export history to file on exit:
48
+ python translator.py --export-history history.json
49
+
50
+ Set image upscale factor for small text (1-4, default 2):
51
+ python translator.py --upscale 3
52
+
53
+ Disable image preprocessing:
54
+ python translator.py --no-preprocess
55
+
56
+ Show version and exit:
57
+ python translator.py --version
58
+
59
+ Show full help:
60
+ python translator.py --help
61
+
62
+ KEYBOARD SHORTCUTS (global, while app is running)
63
+ --------------------------------------------------
64
+ Alt+S : Open screen region selector
65
+ Ctrl+Shift+C : Copy last translation to clipboard
66
+ Ctrl+Shift+H : Show/hide translation history
67
+ Ctrl+Shift+Q : Quit application
68
+ Escape : Cancel selection or close overlay
69
+
70
+ SUPPORTED CHINESE VARIANTS
71
+ ---------------------------
72
+ - Simplified Chinese (Mandarin, 简体中文)
73
+ - Traditional Chinese (Mandarin, 繁體中文)
74
+ - Cantonese / Yue (廣東話, 粵語)
75
+ - Classical Chinese (文言文, Literary Chinese)
76
+ - Mixed Chinese-English (Chinglish / code-switching text)
77
+ - Vertical Chinese text (auto-handled by EasyOCR)
78
+
79
+ TRANSLATION BACKENDS
80
+ --------------------
81
+ 1. Google Translate (online, fast, default)
82
+ 2. Microsoft Translate (online, fallback, requires API key in config)
83
+ 3. Helsinki-NLP MarianMT (offline, opus-mt-zh-en, downloaded on first use ~300MB)
84
+
85
+ EXTERNAL SETUP REQUIRED
86
+ -----------------------
87
+ PyTorch (required for EasyOCR):
88
+ CPU-only:
89
+ pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
90
+ CUDA 11.8:
91
+ pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118
92
+ CUDA 12.1:
93
+ pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121
94
+
95
+ EasyOCR models are downloaded automatically on first run:
96
+ - ch_sim model : ~90 MB
97
+ - ch_tra model : ~90 MB
98
+ - detection model: ~50 MB
99
+ Total: ~230 MB on first launch
100
+
101
+ Microsoft Translator (optional):
102
+ Get a free API key from Azure Cognitive Services and add to config.json:
103
+ { "microsoft_api_key": "YOUR_KEY_HERE", "microsoft_region": "eastus" }
104
+ """
105
+
106
+ # ── Standard Library ──────────────────────────────────────────────────────────
107
+ import os
108
+ import sys
109
+ import json
110
+ import logging
111
+ import threading
112
+ import time
113
+ import argparse
114
+ import textwrap
115
+ from pathlib import Path
116
+ from datetime import datetime
117
+ from typing import Optional, Tuple, List, Dict, Any
118
+ from queue import Queue, Empty
119
+
120
+ # ── GUI ───────────────────────────────────────────────────────────────────────
121
+ import tkinter as tk
122
+ from tkinter import ttk, scrolledtext, messagebox, filedialog
123
+
124
+ # ── Image Processing ──────────────────────────────────────────────────────────
125
+ try:
126
+ from PIL import Image, ImageTk, ImageDraw, ImageFilter, ImageEnhance, ImageOps, ImageGrab
127
+ PIL_AVAILABLE = True
128
+ except ImportError:
129
+ PIL_AVAILABLE = False
130
+ print("[FATAL] Pillow not installed. Run: pip install Pillow")
131
+ sys.exit(1)
132
+
133
+ try:
134
+ import mss
135
+ import mss.tools
136
+ MSS_AVAILABLE = True
137
+ except ImportError:
138
+ MSS_AVAILABLE = False
139
+
140
+ try:
141
+ import numpy as np
142
+ NUMPY_AVAILABLE = True
143
+ except ImportError:
144
+ NUMPY_AVAILABLE = False
145
+ print("[FATAL] NumPy not installed. Run: pip install numpy")
146
+ sys.exit(1)
147
+
148
+ # ── OCR Engine ────────────────────────────────────────────────────────────────
149
+ try:
150
+ import easyocr
151
+ EASYOCR_AVAILABLE = True
152
+ except ImportError:
153
+ EASYOCR_AVAILABLE = False
154
+ print("[WARN] EasyOCR not installed. Run: pip install easyocr")
155
+
156
+ # ── Online Translation ────────────────────────────────────────────────────────
157
+ try:
158
+ from deep_translator import GoogleTranslator, MicrosoftTranslator
159
+ DEEP_TRANSLATOR_AVAILABLE = True
160
+ except ImportError:
161
+ DEEP_TRANSLATOR_AVAILABLE = False
162
+ print("[WARN] deep-translator not installed. Run: pip install deep-translator")
163
+
164
+ # ── Offline Translation ───────────────────────────────────────────────────────
165
+ OFFLINE_TRANSLATION_AVAILABLE = False
166
+ try:
167
+ from transformers import MarianMTModel, MarianTokenizer
168
+ import torch
169
+ OFFLINE_TRANSLATION_AVAILABLE = True
170
+ except ImportError:
171
+ pass
172
+
173
+ # ── Clipboard ─────────────────────────────────────────────────────────────────
174
+ try:
175
+ import pyperclip
176
+ CLIPBOARD_AVAILABLE = True
177
+ except ImportError:
178
+ CLIPBOARD_AVAILABLE = False
179
+
180
+ # ── Global Hotkeys ────────────────────────────────────────────────────────────
181
+ try:
182
+ import keyboard as kb
183
+ KEYBOARD_AVAILABLE = True
184
+ except ImportError:
185
+ KEYBOARD_AVAILABLE = False
186
+ print("[WARN] keyboard not installed. Run: pip install keyboard")
187
+
188
+ # ── Constants ─────────────────────────────────────────────────────────────────
189
+ APP_NAME = "ChineseScreenTranslator"
190
+ APP_VERSION = "1.0.0"
191
+ APP_AUTHOR = "algorembrant"
192
+ CONFIG_FILE = Path.home() / ".chinese_screen_translator" / "config.json"
193
+ HISTORY_FILE = Path.home() / ".chinese_screen_translator" / "history.json"
194
+ LOG_FILE = Path.home() / ".chinese_screen_translator" / "app.log"
195
+
196
+ OFFLINE_MODEL_ZH_EN = "Helsinki-NLP/opus-mt-zh-en"
197
+ OFFLINE_MODEL_ZHT_EN = "Helsinki-NLP/opus-mt-zht-en"
198
+
199
+ COLORS = {
200
+ "bg_dark" : "#1a1a2e",
201
+ "bg_mid" : "#16213e",
202
+ "bg_light" : "#0f3460",
203
+ "accent" : "#e94560",
204
+ "accent_hover" : "#c73652",
205
+ "text_primary" : "#eaeaea",
206
+ "text_secondary": "#a0a0b0",
207
+ "success" : "#4ecca3",
208
+ "warning" : "#f6c90e",
209
+ "border" : "#2a2a4a",
210
+ "selection" : "rgba(233,69,96,0.3)",
211
+ }
212
+
213
+ # ── Logging Setup ─────────────────────────────────────────────────────────────
214
+ def setup_logging(verbose: bool = False) -> logging.Logger:
215
+ LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
216
+ level = logging.DEBUG if verbose else logging.INFO
217
+ fmt = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
218
+ logging.basicConfig(
219
+ level=level,
220
+ format=fmt,
221
+ handlers=[
222
+ logging.FileHandler(LOG_FILE, encoding="utf-8"),
223
+ logging.StreamHandler(sys.stdout),
224
+ ],
225
+ )
226
+ return logging.getLogger(APP_NAME)
227
+
228
+ logger = logging.getLogger(APP_NAME)
229
+
230
+
231
+ # ════════════════════════════════════════════════════════════════════════════
232
+ # CONFIG
233
+ # ════════════════════════════════════════════════════════════════════════════
234
+ class Config:
235
+ """Persistent JSON configuration manager."""
236
+
237
+ DEFAULTS: Dict[str, Any] = {
238
+ "hotkey" : "alt+s",
239
+ "lang" : "auto",
240
+ "backend" : "google",
241
+ "use_gpu" : False,
242
+ "upscale_factor" : 2,
243
+ "confidence_threshold": 0.30,
244
+ "preprocess" : True,
245
+ "window_opacity" : 0.25,
246
+ "result_font_size" : 13,
247
+ "max_history" : 500,
248
+ "microsoft_api_key" : "",
249
+ "microsoft_region" : "eastus",
250
+ "auto_copy" : False,
251
+ "show_confidence" : True,
252
+ "offline_model_dir" : str(Path.home() / ".chinese_screen_translator" / "models"),
253
+ }
254
+
255
+ def __init__(self):
256
+ self._data: Dict[str, Any] = dict(self.DEFAULTS)
257
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
258
+ self.load()
259
+
260
+ def load(self):
261
+ if CONFIG_FILE.exists():
262
+ try:
263
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
264
+ saved = json.load(f)
265
+ # Migration: if legacy hotkey is found, update to new default
266
+ if saved.get("hotkey") == "ctrl+shift+s":
267
+ saved["hotkey"] = "alt+s"
268
+ self._data.update(saved)
269
+ except Exception as e:
270
+ logger.warning(f"Config load failed: {e}. Using defaults.")
271
+
272
+ def save(self):
273
+ try:
274
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
275
+ json.dump(self._data, f, indent=2, ensure_ascii=False)
276
+ except Exception as e:
277
+ logger.error(f"Config save failed: {e}")
278
+
279
+ def get(self, key: str, default=None):
280
+ return self._data.get(key, self.DEFAULTS.get(key, default))
281
+
282
+ def set(self, key: str, value: Any):
283
+ self._data[key] = value
284
+ self.save()
285
+
286
+ def apply_args(self, args: argparse.Namespace):
287
+ """Override config with CLI arguments."""
288
+ mapping = {
289
+ "hotkey" : "hotkey",
290
+ "lang" : "lang",
291
+ "backend" : "backend",
292
+ "gpu" : "use_gpu",
293
+ "upscale" : "upscale_factor",
294
+ "confidence": "confidence_threshold",
295
+ "offline" : None,
296
+ }
297
+ if getattr(args, "hotkey", None):
298
+ self.set("hotkey", args.hotkey)
299
+ if getattr(args, "lang", None):
300
+ self.set("lang", args.lang)
301
+ if getattr(args, "backend", None):
302
+ self.set("backend", args.backend)
303
+ if getattr(args, "gpu", False):
304
+ self.set("use_gpu", True)
305
+ if getattr(args, "upscale", None):
306
+ self.set("upscale_factor", args.upscale)
307
+ if getattr(args, "confidence", None):
308
+ self.set("confidence_threshold", args.confidence)
309
+ if getattr(args, "offline", False):
310
+ self.set("backend", "offline")
311
+ if getattr(args, "no_preprocess", False):
312
+ self.set("preprocess", False)
313
+
314
+
315
+ # ════════════════════════════════════════════════════════════════════════════
316
+ # IMAGE PREPROCESSOR
317
+ # ════════════════════════════════════════════════════════════════════════════
318
+ class ImagePreprocessor:
319
+ """Enhance screenshots for better Chinese OCR accuracy."""
320
+
321
+ def __init__(self, config: Config):
322
+ self.cfg = config
323
+
324
+ def preprocess(self, img: Image.Image) -> Image.Image:
325
+ """Apply preprocessing pipeline to improve OCR quality."""
326
+ if not self.cfg.get("preprocess"):
327
+ return img
328
+
329
+ # 1. Upscale for small text
330
+ factor = self.cfg.get("upscale_factor", 2)
331
+ if factor > 1:
332
+ w, h = img.size
333
+ img = img.resize((w * factor, h * factor), Image.LANCZOS)
334
+
335
+ # 2. Convert to RGB (drop alpha channel if present)
336
+ if img.mode == "RGBA":
337
+ bg = Image.new("RGB", img.size, (255, 255, 255))
338
+ bg.paste(img, mask=img.split()[3])
339
+ img = bg
340
+ elif img.mode != "RGB":
341
+ img = img.convert("RGB")
342
+
343
+ # 3. Adaptive contrast enhancement
344
+ enhancer = ImageEnhance.Contrast(img)
345
+ img = enhancer.enhance(1.5)
346
+
347
+ # 4. Mild sharpening
348
+ img = img.filter(ImageFilter.SHARPEN)
349
+
350
+ # 5. Brightness normalization
351
+ enhancer = ImageEnhance.Brightness(img)
352
+ img = enhancer.enhance(1.1)
353
+
354
+ return img
355
+
356
+ @staticmethod
357
+ def pil_to_numpy(img: Image.Image) -> np.ndarray:
358
+ return np.array(img)
359
+
360
+
361
+ # ════════════════════════════════════════════════════════════════════════════
362
+ # OCR ENGINE
363
+ # ════════════════════════════════════════════════════════════════════════════
364
+ class OCREngine:
365
+ """
366
+ EasyOCR-based OCR engine supporting all Chinese script variants.
367
+ Lazy-loads readers to avoid startup delay.
368
+ """
369
+
370
+ def __init__(self, config: Config):
371
+ self.cfg = config
372
+ self._reader_sim: Optional[Any] = None
373
+ self._reader_tra: Optional[Any] = None
374
+ self._initialized = False
375
+ self._init_lock = threading.Lock()
376
+ self.preprocessor = ImagePreprocessor(config)
377
+
378
+ def _get_reader(self, script: str) -> Any:
379
+ """Return (and lazy-init) the appropriate EasyOCR reader."""
380
+ if not EASYOCR_AVAILABLE:
381
+ raise RuntimeError(
382
+ "EasyOCR not installed. Run:\n"
383
+ " pip install easyocr\n"
384
+ " pip install torch torchvision --index-url "
385
+ "https://download.pytorch.org/whl/cpu"
386
+ )
387
+ use_gpu = self.cfg.get("use_gpu", False)
388
+ with self._init_lock:
389
+ if script == "traditional":
390
+ if self._reader_tra is None:
391
+ logger.info("Loading Traditional Chinese OCR model...")
392
+ self._reader_tra = easyocr.Reader(
393
+ ["ch_tra", "en"], gpu=use_gpu, verbose=False
394
+ )
395
+ return self._reader_tra
396
+ else: # simplified (default)
397
+ if self._reader_sim is None:
398
+ logger.info("Loading Simplified Chinese OCR model...")
399
+ self._reader_sim = easyocr.Reader(
400
+ ["ch_sim", "en"], gpu=use_gpu, verbose=False
401
+ )
402
+ return self._reader_sim
403
+
404
+ def extract_text(
405
+ self, img: Image.Image
406
+ ) -> Tuple[str, float, str]:
407
+ """
408
+ Extract Chinese text from image.
409
+ Returns (text, avg_confidence, detected_script).
410
+ """
411
+ img = self.preprocessor.preprocess(img)
412
+ img_np = self.preprocessor.pil_to_numpy(img)
413
+
414
+ lang_mode = self.cfg.get("lang", "auto")
415
+ threshold = self.cfg.get("confidence_threshold", 0.30)
416
+
417
+ if lang_mode == "traditional":
418
+ return self._run_ocr(img_np, "traditional", threshold)
419
+ elif lang_mode == "simplified":
420
+ return self._run_ocr(img_np, "simplified", threshold)
421
+ else:
422
+ # Auto-detect: run both, pick higher confidence
423
+ text_sim, conf_sim, _ = self._run_ocr(img_np, "simplified", threshold)
424
+ text_tra, conf_tra, _ = self._run_ocr(img_np, "traditional", threshold)
425
+ if conf_tra > conf_sim and text_tra.strip():
426
+ return text_tra, conf_tra, "traditional"
427
+ return text_sim, conf_sim, "simplified"
428
+
429
+ def _run_ocr(
430
+ self, img_np: np.ndarray, script: str, threshold: float
431
+ ) -> Tuple[str, float, str]:
432
+ reader = self._get_reader(script)
433
+ results = reader.readtext(img_np, detail=1, paragraph=False)
434
+
435
+ lines: List[str] = []
436
+ confidences: List[float] = []
437
+
438
+ for (_, text, conf) in results:
439
+ if conf >= threshold and text.strip():
440
+ lines.append(text.strip())
441
+ confidences.append(conf)
442
+
443
+ combined = " ".join(lines)
444
+ avg_conf = float(np.mean(confidences)) if confidences else 0.0
445
+ return combined, avg_conf, script
446
+
447
+ def preload(self):
448
+ """Preload both readers in background threads."""
449
+ def _load_sim():
450
+ try:
451
+ self._get_reader("simplified")
452
+ logger.info("Simplified Chinese OCR model ready.")
453
+ except Exception as e:
454
+ logger.error(f"Simplified OCR load error: {e}")
455
+
456
+ def _load_tra():
457
+ try:
458
+ self._get_reader("traditional")
459
+ logger.info("Traditional Chinese OCR model ready.")
460
+ except Exception as e:
461
+ logger.error(f"Traditional OCR load error: {e}")
462
+
463
+ threading.Thread(target=_load_sim, daemon=True).start()
464
+
465
+
466
+ # ════════════════════════════════════════════════════════════════════════════
467
+ # TRANSLATION ENGINE
468
+ # ════════════════════════════════════════════════════════════════════════════
469
+ class TranslationEngine:
470
+ """
471
+ Multi-backend Chinese-to-English translation engine.
472
+ Backends: Google, Microsoft, Helsinki-NLP (offline).
473
+ """
474
+
475
+ def __init__(self, config: Config):
476
+ self.cfg = config
477
+ self._offline_model = None
478
+ self._offline_tok = None
479
+ self._offline_lock = threading.Lock()
480
+
481
+ def translate(
482
+ self, text: str, source_lang: str = "auto"
483
+ ) -> Tuple[str, str]:
484
+ """
485
+ Translate text to English.
486
+ Returns (translated_text, backend_used).
487
+ Raises RuntimeError if all backends fail.
488
+ """
489
+ if not text or not text.strip():
490
+ return "", "none"
491
+
492
+ backend = self.cfg.get("backend", "google")
493
+
494
+ if backend == "offline":
495
+ return self._translate_offline(text)
496
+
497
+ # Online backends with fallback chain
498
+ for attempt_backend in [backend, "google", "offline"]:
499
+ try:
500
+ if attempt_backend == "google":
501
+ return self._translate_google(text, source_lang), "google"
502
+ elif attempt_backend == "microsoft":
503
+ return self._translate_microsoft(text, source_lang), "microsoft"
504
+ elif attempt_backend == "offline":
505
+ return self._translate_offline(text)
506
+ except Exception as e:
507
+ msg = str(e)
508
+ if "not installed" in msg:
509
+ logger.warning(f"Backend '{attempt_backend}' unavailable: {msg}")
510
+ else:
511
+ logger.warning(f"Backend '{attempt_backend}' failed: {e}")
512
+ continue
513
+
514
+ raise RuntimeError("All translation backends failed.")
515
+
516
+ def _translate_google(self, text: str, source_lang: str = "auto") -> str:
517
+ if not DEEP_TRANSLATOR_AVAILABLE:
518
+ raise RuntimeError("deep-translator not installed.")
519
+
520
+ # Map internal script names to Google Translate codes
521
+ lang_map = {
522
+ "simplified": "zh-CN",
523
+ "traditional": "zh-TW",
524
+ "auto": "auto"
525
+ }
526
+ src = lang_map.get(source_lang, "auto")
527
+
528
+ translator = GoogleTranslator(source=src, target="en")
529
+ # Google Translate has a 5000 char limit per request
530
+ if len(text) > 4500:
531
+ chunks = self._chunk_text(text, 4500)
532
+ return " ".join(translator.translate(c) for c in chunks)
533
+ return translator.translate(text)
534
+
535
+ def _translate_microsoft(self, text: str, source_lang: str = "auto") -> str:
536
+ if not DEEP_TRANSLATOR_AVAILABLE:
537
+ raise RuntimeError("deep-translator not installed.")
538
+ api_key = self.cfg.get("microsoft_api_key", "")
539
+ region = self.cfg.get("microsoft_region", "eastus")
540
+ if not api_key:
541
+ raise ValueError("Microsoft API key not configured.")
542
+
543
+ # Map internal script names to Microsoft codes (uses same zh-Hans/zh-Hant vs zh-CN/zh-TW)
544
+ # deep-translator's Microsoft backend handles 'auto'
545
+ lang_map = {
546
+ "simplified": "zh-Hans",
547
+ "traditional": "zh-Hant",
548
+ "auto": "auto"
549
+ }
550
+ src = lang_map.get(source_lang, "auto")
551
+
552
+ translator = MicrosoftTranslator(
553
+ api_key=api_key, region=region, source=src, target="en"
554
+ )
555
+ return translator.translate(text)
556
+
557
+ def _translate_offline(self, text: str) -> Tuple[str, str]:
558
+ if not OFFLINE_TRANSLATION_AVAILABLE:
559
+ raise RuntimeError(
560
+ "transformers/torch not installed for offline mode.\n"
561
+ "Run: pip install transformers torch"
562
+ )
563
+ with self._offline_lock:
564
+ if self._offline_model is None:
565
+ logger.info(f"Loading offline model '{OFFLINE_MODEL_ZH_EN}'...")
566
+ model_dir = self.cfg.get("offline_model_dir")
567
+ Path(model_dir).mkdir(parents=True, exist_ok=True)
568
+ tok_obj = MarianTokenizer.from_pretrained(
569
+ OFFLINE_MODEL_ZH_EN, cache_dir=model_dir
570
+ )
571
+ self._offline_tok = tok_obj
572
+ model_obj = MarianMTModel.from_pretrained(
573
+ OFFLINE_MODEL_ZH_EN, cache_dir=model_dir
574
+ )
575
+ self._offline_model = model_obj
576
+
577
+ device = "cuda" if (
578
+ self.cfg.get("use_gpu") and torch.cuda.is_available()
579
+ ) else "cpu"
580
+
581
+ # Type safe access to model_obj
582
+ if model_obj is not None:
583
+ self._offline_model = model_obj.to(device)
584
+ logger.info("Offline model loaded.")
585
+
586
+ chunks = self._chunk_text(text, 400)
587
+ results: List[str] = []
588
+
589
+ # Type narrowing for device access
590
+ model_p = self._offline_model
591
+ if model_p is None:
592
+ raise RuntimeError("Offline model failed to load.")
593
+
594
+ # device access requires model parameters to be initialized
595
+ if hasattr(model_p, "parameters"):
596
+ device = next(model_p.parameters()).device
597
+ else:
598
+ device = "cpu"
599
+
600
+ tok_p = self._offline_tok
601
+ if tok_p is None:
602
+ raise RuntimeError("Offline tokenizer failed to load.")
603
+
604
+ for chunk in chunks:
605
+ inputs = tok_p(
606
+ [chunk], return_tensors="pt", padding=True, truncation=True
607
+ ).to(device)
608
+ with torch.no_grad():
609
+ out = model_p.generate(**inputs)
610
+ if out is not None and len(out) > 0:
611
+ decoded = tok_p.decode(out[0], skip_special_tokens=True)
612
+ results.append(decoded)
613
+
614
+ return " ".join(results), "offline"
615
+
616
+ @staticmethod
617
+ def _chunk_text(text: str, max_len: int) -> List[str]:
618
+ """Split text into chunks at sentence boundaries."""
619
+ if len(text) <= max_len:
620
+ return [text]
621
+ chunks, current = [], ""
622
+ for char in text:
623
+ current += char
624
+ if len(current) >= max_len and char in "。!?.!?\n":
625
+ chunks.append(current.strip())
626
+ current = ""
627
+ if current.strip():
628
+ chunks.append(current.strip())
629
+
630
+ if not chunks:
631
+ # Slicing with explicit 0 to help type checker
632
+ return [text[0:max_len]]
633
+ return chunks
634
+
635
+
636
+ # ════════════════════════════════════════════════════════════════════════════
637
+ # HISTORY MANAGER
638
+ # ════════════════════════════════════════════════════════════════════════════
639
+ class HistoryManager:
640
+ """Persist and retrieve translation history."""
641
+
642
+ def __init__(self, config: Config):
643
+ self.cfg = config
644
+ self._items: List[Dict] = []
645
+ HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
646
+ self.load()
647
+
648
+ def load(self):
649
+ if HISTORY_FILE.exists():
650
+ try:
651
+ with open(HISTORY_FILE, "r", encoding="utf-8") as f:
652
+ self._items = json.load(f)
653
+ except Exception:
654
+ self._items = []
655
+
656
+ def save(self):
657
+ try:
658
+ with open(HISTORY_FILE, "w", encoding="utf-8") as f:
659
+ json.dump(self._items, f, ensure_ascii=False, indent=2)
660
+ except Exception as e:
661
+ logger.error(f"History save error: {e}")
662
+
663
+ def add(
664
+ self,
665
+ source_text: str,
666
+ translated_text: str,
667
+ confidence: float,
668
+ script: str,
669
+ backend: str,
670
+ ):
671
+ entry = {
672
+ "timestamp" : datetime.now().isoformat(),
673
+ "source" : source_text,
674
+ "translation": translated_text,
675
+ "confidence" : float(int(float(confidence) * 1000)) / 1000.0,
676
+ "script" : script,
677
+ "backend" : backend,
678
+ }
679
+ self._items.insert(0, entry)
680
+ conf_raw = self.cfg.get("max_history", 500)
681
+ max_h: int = int(conf_raw) if conf_raw is not None else 500
682
+ while len(self._items) > max_h:
683
+ self._items.pop()
684
+ self.save()
685
+
686
+ def get_all(self) -> List[Dict]:
687
+ return list(self._items)
688
+
689
+ def export(self, path: str):
690
+ with open(path, "w", encoding="utf-8") as f:
691
+ json.dump(self._items, f, ensure_ascii=False, indent=2)
692
+
693
+ def clear(self):
694
+ self._items = []
695
+ self.save()
696
+
697
+
698
+ # ════════════════════════════════════════════════════════════════════════════
699
+ # SCREEN OVERLAY (Selection UI)
700
+ # ════════════════════════════════════════════════════════════════════════════
701
+ class ScreenOverlay(tk.Toplevel):
702
+ """
703
+ Fullscreen transparent overlay for screen region selection.
704
+ Draws a rubber-band rectangle as the user drags the mouse.
705
+ """
706
+
707
+ def __init__(self, master: tk.Tk, callback):
708
+ super().__init__(master)
709
+ self.callback = callback
710
+ self.start_x = 0
711
+ self.start_y = 0
712
+ self.end_x = 0
713
+ self.end_y: int = 0
714
+ self._rect_id: Optional[int] = None
715
+ self._cancelled: bool = False
716
+ self.canvas: Optional[tk.Canvas] = None
717
+
718
+ self._setup_window()
719
+ self._bind_events()
720
+
721
+ def _setup_window(self):
722
+ # On Windows, -fullscreen and overrideredirect can conflict.
723
+ # Use manual geometry for a more robust fullscreen overlay.
724
+ sw = self.winfo_screenwidth()
725
+ sh = self.winfo_screenheight()
726
+
727
+ self.overrideredirect(True)
728
+ self.geometry(f"{sw}x{sh}+0+0")
729
+ self.attributes("-topmost", True)
730
+ self.attributes("-alpha", 0.25)
731
+ self.configure(bg="black", cursor="crosshair")
732
+
733
+ # Canvas covering full screen
734
+ canvas = tk.Canvas(
735
+ self,
736
+ width=sw,
737
+ height=sh,
738
+ bg="#000010",
739
+ highlightthickness=0,
740
+ cursor="crosshair",
741
+ )
742
+ self.canvas = canvas
743
+ if canvas is not None:
744
+ canvas.pack(fill=tk.BOTH, expand=True)
745
+
746
+ # Instruction label
747
+ canvas.create_text(
748
+ sw // 2,
749
+ sh // 2,
750
+ text="Click and drag to select Chinese text region | Esc to cancel",
751
+ fill="#ffffff",
752
+ font=("Consolas", 16, "bold"),
753
+ tags="instruction",
754
+ )
755
+
756
+ def _bind_events(self):
757
+ canvas = self.canvas
758
+ if canvas:
759
+ canvas.bind("<ButtonPress-1>", self._on_press)
760
+ canvas.bind("<B1-Motion>", self._on_drag)
761
+ canvas.bind("<ButtonRelease-1>", self._on_release)
762
+ self.bind("<Escape>", self._on_cancel)
763
+
764
+ def _on_press(self, event):
765
+ self.start_x = event.x_root
766
+ self.start_y = event.y_root
767
+ canvas = self.canvas
768
+ if canvas:
769
+ canvas.delete("instruction")
770
+
771
+ def _on_drag(self, event):
772
+ self.end_x = event.x_root
773
+ self.end_y = event.y_root
774
+ canvas = self.canvas
775
+ if canvas:
776
+ canvas.delete("selection")
777
+ # Draw selection box (canvas coords = screen coords here)
778
+ x1 = min(self.start_x, self.end_x)
779
+ y1 = min(self.start_y, self.end_y)
780
+ x2 = max(self.start_x, self.end_x)
781
+ y2 = max(self.start_y, self.end_y)
782
+ # Draw dim overlay around selection
783
+ canvas.create_rectangle(
784
+ x1, y1, x2, y2,
785
+ outline="#e94560",
786
+ fill="",
787
+ width=2,
788
+ tags="selection",
789
+ )
790
+ # Size label
791
+ w, h = x2 - x1, y2 - y1
792
+ canvas.create_text(
793
+ x1 + 5, y1 - 12,
794
+ text=f"{w} x {h}",
795
+ fill="#e94560",
796
+ font=("Consolas", 11),
797
+ anchor="w",
798
+ tags="selection",
799
+ )
800
+
801
+ def _on_release(self, event):
802
+ self.end_x = event.x_root
803
+ self.end_y = event.y_root
804
+ x1 = min(self.start_x, self.end_x)
805
+ y1 = min(self.start_y, self.end_y)
806
+ x2 = max(self.start_x, self.end_x)
807
+ y2 = max(self.start_y, self.end_y)
808
+
809
+ self.destroy()
810
+
811
+ if (x2 - x1) < 10 or (y2 - y1) < 10:
812
+ logger.warning("Selection too small, ignoring.")
813
+ return
814
+
815
+ self.callback(x1, y1, x2, y2)
816
+
817
+ def _on_cancel(self, event=None):
818
+ self._cancelled = True
819
+ self.destroy()
820
+
821
+
822
+ # ════════════════════════════════════════════════════════════════════════════
823
+ # RESULT WINDOW
824
+ # ════════════════════════════════════════════════════════════════════════════
825
+ class ResultWindow(tk.Toplevel):
826
+ """Floating window showing OCR source text and translation."""
827
+
828
+ def __init__(self, master: tk.Tk, history: HistoryManager, config: Config):
829
+ super().__init__(master)
830
+ self.history = history
831
+ self.cfg = config
832
+ self._current: Dict[str, Any] = {}
833
+
834
+ # Declare attributes for linter
835
+ self._backend_lbl: tk.Label = tk.Label(self)
836
+ self._conf_lbl: tk.Label = tk.Label(self)
837
+ self._source_text: scrolledtext.ScrolledText = scrolledtext.ScrolledText(self)
838
+ self._trans_text: scrolledtext.ScrolledText = scrolledtext.ScrolledText(self)
839
+ self._status: tk.Label = tk.Label(self)
840
+ self._progress: ttk.Progressbar = ttk.Progressbar(self)
841
+
842
+ self._setup_ui()
843
+
844
+ def _setup_ui(self):
845
+ self.title(f"{APP_NAME} v{APP_VERSION}")
846
+ self.resizable(True, True)
847
+ self.attributes("-topmost", True)
848
+ self.minsize(480, 320)
849
+ self.configure(bg=COLORS["bg_dark"])
850
+ self.geometry("600x420+50+50")
851
+
852
+ # Title bar
853
+ title_bar = tk.Frame(self, bg=COLORS["bg_mid"], height=36)
854
+ title_bar.pack(fill=tk.X)
855
+ title_bar.pack_propagate(False)
856
+
857
+ tk.Label(
858
+ title_bar,
859
+ text=f" {APP_NAME}",
860
+ bg=COLORS["bg_mid"],
861
+ fg=COLORS["accent"],
862
+ font=("Consolas", 11, "bold"),
863
+ ).pack(side=tk.LEFT, padx=8)
864
+
865
+ self._backend_lbl = tk.Label(
866
+ title_bar,
867
+ text="",
868
+ bg=COLORS["bg_mid"],
869
+ fg=COLORS["text_secondary"],
870
+ font=("Consolas", 9),
871
+ )
872
+ self._backend_lbl.pack(side=tk.LEFT, padx=4)
873
+
874
+ self._conf_lbl = tk.Label(
875
+ title_bar,
876
+ text="",
877
+ bg=COLORS["bg_mid"],
878
+ fg=COLORS["success"],
879
+ font=("Consolas", 9),
880
+ )
881
+ self._conf_lbl.pack(side=tk.LEFT, padx=4)
882
+
883
+ # Source text
884
+ src_frame = tk.LabelFrame(
885
+ self,
886
+ text=" Detected Chinese Text ",
887
+ bg=COLORS["bg_dark"],
888
+ fg=COLORS["text_secondary"],
889
+ font=("Consolas", 9),
890
+ bd=1,
891
+ relief=tk.FLAT,
892
+ padx=6,
893
+ pady=4,
894
+ )
895
+ src_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=(8, 4))
896
+
897
+ self._source_text = scrolledtext.ScrolledText(
898
+ src_frame,
899
+ wrap=tk.WORD,
900
+ bg=COLORS["bg_mid"],
901
+ fg=COLORS["text_secondary"],
902
+ insertbackground=COLORS["text_primary"],
903
+ font=("Microsoft YaHei UI", self.cfg.get("result_font_size", 13)),
904
+ relief=tk.FLAT,
905
+ padx=8,
906
+ pady=6,
907
+ height=4,
908
+ )
909
+ self._source_text.pack(fill=tk.BOTH, expand=True)
910
+
911
+ # Translation text
912
+ trans_frame = tk.LabelFrame(
913
+ self,
914
+ text=" English Translation ",
915
+ bg=COLORS["bg_dark"],
916
+ fg=COLORS["accent"],
917
+ font=("Consolas", 9, "bold"),
918
+ bd=1,
919
+ relief=tk.FLAT,
920
+ padx=6,
921
+ pady=4,
922
+ )
923
+ trans_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=(4, 8))
924
+
925
+ self._trans_text = scrolledtext.ScrolledText(
926
+ trans_frame,
927
+ wrap=tk.WORD,
928
+ bg=COLORS["bg_mid"],
929
+ fg=COLORS["text_primary"],
930
+ insertbackground=COLORS["text_primary"],
931
+ font=("Consolas", self.cfg.get("result_font_size", 13)),
932
+ relief=tk.FLAT,
933
+ padx=8,
934
+ pady=6,
935
+ height=4,
936
+ )
937
+ self._trans_text.pack(fill=tk.BOTH, expand=True)
938
+
939
+ # Button row
940
+ btn_frame = tk.Frame(self, bg=COLORS["bg_dark"])
941
+ btn_frame.pack(fill=tk.X, padx=10, pady=(0, 10))
942
+
943
+ btn_cfg: Dict[str, Any] = {
944
+ "bg": COLORS["bg_light"],
945
+ "fg": COLORS["text_primary"],
946
+ "font": ("Consolas", 9, "bold"),
947
+ "relief": tk.FLAT,
948
+ "padx": 12,
949
+ "pady": 5,
950
+ "cursor": "hand2",
951
+ "activebackground": COLORS["accent"],
952
+ "activeforeground": "#ffffff",
953
+ }
954
+ tk.Button(btn_frame, text="Copy Translation",
955
+ command=self._copy_translation, **btn_cfg).pack(side=tk.LEFT, padx=2)
956
+ tk.Button(btn_frame, text="Copy Source",
957
+ command=self._copy_source, **btn_cfg).pack(side=tk.LEFT, padx=2)
958
+ tk.Button(btn_frame, text="Copy Both",
959
+ command=self._copy_both, **btn_cfg).pack(side=tk.LEFT, padx=2)
960
+
961
+ self._status = tk.Label(
962
+ btn_frame,
963
+ text="",
964
+ bg=COLORS["bg_dark"],
965
+ fg=COLORS["success"],
966
+ font=("Consolas", 9),
967
+ )
968
+ self._status.pack(side=tk.RIGHT, padx=6)
969
+
970
+ # Progress bar (shown during processing)
971
+ self._progress = ttk.Progressbar(
972
+ self, mode="indeterminate", length=200
973
+ )
974
+
975
+ self._style_progressbar()
976
+
977
+ def _style_progressbar(self):
978
+ style = ttk.Style(self)
979
+ style.theme_use("default")
980
+ style.configure(
981
+ "TProgressbar",
982
+ troughcolor=COLORS["bg_mid"],
983
+ background=COLORS["accent"],
984
+ thickness=3,
985
+ )
986
+
987
+ def show_processing(self, stage: str = "Processing..."):
988
+ self.deiconify()
989
+ self.lift()
990
+ self._source_text.configure(state=tk.NORMAL)
991
+ self._source_text.delete("1.0", tk.END)
992
+ self._source_text.insert(tk.END, stage)
993
+ self._source_text.configure(state=tk.DISABLED)
994
+ self._trans_text.configure(state=tk.NORMAL)
995
+ self._trans_text.delete("1.0", tk.END)
996
+ self._trans_text.configure(state=tk.DISABLED)
997
+ self._progress.pack(fill=tk.X, padx=10, pady=(0, 4))
998
+ self._progress.start(12)
999
+ self.update_idletasks()
1000
+
1001
+ def update_result(
1002
+ self,
1003
+ source: str,
1004
+ translation: str,
1005
+ confidence: float,
1006
+ script: str,
1007
+ backend: str,
1008
+ ):
1009
+ self._progress.stop()
1010
+ self._progress.pack_forget()
1011
+
1012
+ self._current = {
1013
+ "source" : source,
1014
+ "translation": translation,
1015
+ "confidence" : confidence,
1016
+ "script" : script,
1017
+ "backend" : backend,
1018
+ }
1019
+
1020
+ self._source_text.configure(state=tk.NORMAL)
1021
+ self._source_text.delete("1.0", tk.END)
1022
+ self._source_text.insert(tk.END, source)
1023
+ self._source_text.configure(state=tk.DISABLED)
1024
+
1025
+ self._trans_text.configure(state=tk.NORMAL)
1026
+ self._trans_text.delete("1.0", tk.END)
1027
+ self._trans_text.insert(tk.END, translation)
1028
+ self._trans_text.configure(state=tk.DISABLED)
1029
+
1030
+ conf_pct = f"{confidence * 100:.1f}%"
1031
+ self._conf_lbl.config(
1032
+ text=f"OCR: {conf_pct} [{script}]"
1033
+ if self.cfg.get("show_confidence") else ""
1034
+ )
1035
+ self._backend_lbl.config(text=f"via {backend}")
1036
+
1037
+ if self.cfg.get("auto_copy") and CLIPBOARD_AVAILABLE:
1038
+ pyperclip.copy(translation)
1039
+ self._set_status("Auto-copied to clipboard")
1040
+
1041
+ def show_error(self, message: str):
1042
+ self._progress.stop()
1043
+ self._progress.pack_forget()
1044
+ self._trans_text.configure(state=tk.NORMAL)
1045
+ self._trans_text.delete("1.0", tk.END)
1046
+ self._trans_text.insert(tk.END, f"[ERROR] {message}")
1047
+ self._trans_text.configure(state=tk.DISABLED)
1048
+
1049
+ def _copy_translation(self):
1050
+ if self._current.get("translation") and CLIPBOARD_AVAILABLE:
1051
+ pyperclip.copy(self._current["translation"])
1052
+ self._set_status("Translation copied")
1053
+
1054
+ def _copy_source(self):
1055
+ if self._current.get("source") and CLIPBOARD_AVAILABLE:
1056
+ pyperclip.copy(self._current["source"])
1057
+ self._set_status("Source text copied")
1058
+
1059
+ def _copy_both(self):
1060
+ if self._current and CLIPBOARD_AVAILABLE:
1061
+ combined = (
1062
+ f"[Chinese]\n{self._current.get('source','')}\n\n"
1063
+ f"[English]\n{self._current.get('translation','')}"
1064
+ )
1065
+ pyperclip.copy(combined)
1066
+ self._set_status("Both copied")
1067
+
1068
+ def _set_status(self, msg: str, duration: int = 2500):
1069
+ self._status.config(text=msg)
1070
+ def _clear(*args):
1071
+ self._status.config(text="")
1072
+ self.after(duration, _clear)
1073
+
1074
+
1075
+ # ════════════════════════════════════════════════════════════════════════════
1076
+ # HISTORY WINDOW
1077
+ # ════════════════════════════════════════════════════════════════════════════
1078
+ class HistoryWindow(tk.Toplevel):
1079
+ """Browse, search, and export translation history."""
1080
+
1081
+ def __init__(self, master: tk.Tk, history: HistoryManager):
1082
+ super().__init__(master)
1083
+ self.history: HistoryManager = history
1084
+ self._tree: Optional[ttk.Treeview] = None
1085
+ self._search_var: Optional[tk.StringVar] = None
1086
+ self.title(f"{APP_NAME} - History")
1087
+ self.geometry("800x560+100+100")
1088
+ self.configure(bg=COLORS["bg_dark"])
1089
+ self.attributes("-topmost", True)
1090
+ self._setup_ui()
1091
+ self._load_items()
1092
+
1093
+ def _setup_ui(self):
1094
+ top = tk.Frame(self, bg=COLORS["bg_dark"])
1095
+ top.pack(fill=tk.X, padx=10, pady=8)
1096
+
1097
+ tk.Label(
1098
+ top, text="Search:", bg=COLORS["bg_dark"],
1099
+ fg=COLORS["text_secondary"], font=("Consolas", 10)
1100
+ ).pack(side=tk.LEFT)
1101
+
1102
+ self._search_var = tk.StringVar()
1103
+ search_var = self._search_var
1104
+ if search_var is not None:
1105
+ search_var.trace("w", lambda *_: self._filter())
1106
+ if search_var is not None:
1107
+ tk.Entry(
1108
+ top,
1109
+ textvariable=search_var,
1110
+ bg=COLORS["bg_mid"],
1111
+ fg=COLORS["text_primary"],
1112
+ insertbackground=COLORS["text_primary"],
1113
+ font=("Consolas", 10),
1114
+ relief=tk.FLAT,
1115
+ width=30,
1116
+ ).pack(side=tk.LEFT, padx=8)
1117
+
1118
+ btn_cfg: Dict[str, Any] = {
1119
+ "bg": COLORS["bg_light"],
1120
+ "fg": COLORS["text_primary"],
1121
+ "font": ("Consolas", 9),
1122
+ "relief": tk.FLAT,
1123
+ "padx": 8,
1124
+ "pady": 4,
1125
+ "cursor": "hand2",
1126
+ }
1127
+ tk.Button(top, text="Export JSON",
1128
+ command=self._export, **btn_cfg).pack(side=tk.RIGHT, padx=2)
1129
+ tk.Button(top, text="Clear All",
1130
+ command=self._clear, **btn_cfg).pack(side=tk.RIGHT, padx=2)
1131
+
1132
+ # Treeview
1133
+ cols = ("timestamp", "script", "backend", "conf", "source", "translation")
1134
+ tree = ttk.Treeview(
1135
+ self, columns=cols, show="headings", selectmode="browse"
1136
+ )
1137
+ self._tree = tree
1138
+ hdrs = {
1139
+ "timestamp" : ("Timestamp", 140),
1140
+ "script" : ("Script", 80),
1141
+ "backend" : ("Backend", 70),
1142
+ "conf" : ("OCR %", 60),
1143
+ "source" : ("Source", 200),
1144
+ "translation": ("Translation",200),
1145
+ }
1146
+ for col, (hdr, width) in hdrs.items():
1147
+ tree.heading(col, text=hdr)
1148
+ tree.column(col, width=width, minwidth=40)
1149
+
1150
+ vsb = ttk.Scrollbar(self, orient=tk.VERTICAL,
1151
+ command=tree.yview)
1152
+ tree.configure(yscrollcommand=vsb.set)
1153
+ vsb.pack(side=tk.RIGHT, fill=tk.Y)
1154
+ tree.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))
1155
+
1156
+ # Capture the tree and style it
1157
+ style = ttk.Style(self)
1158
+ style.theme_use("default")
1159
+ style.configure("Treeview",
1160
+ background=COLORS["bg_mid"],
1161
+ foreground=COLORS["text_primary"],
1162
+ fieldbackground=COLORS["bg_mid"],
1163
+ rowheight=24,
1164
+ font=("Consolas", 9),
1165
+ )
1166
+ style.configure("Treeview.Heading",
1167
+ background=COLORS["bg_light"],
1168
+ foreground=COLORS["text_primary"],
1169
+ font=("Consolas", 9, "bold"),
1170
+ )
1171
+
1172
+ tree.bind("<Double-1>", self._on_double_click)
1173
+
1174
+ def _load_items(self, filter_text: str = ""):
1175
+ tree = self._tree
1176
+ if tree is None:
1177
+ return
1178
+ for row in tree.get_children():
1179
+ tree.delete(row)
1180
+ for item in self.history.get_all():
1181
+ src = str(item.get("source", ""))
1182
+ trans = str(item.get("translation", ""))
1183
+ if filter_text and filter_text.lower() not in (src + trans).lower():
1184
+ continue
1185
+ timestamp = str(item.get("timestamp", ""))
1186
+ if len(timestamp) > 19:
1187
+ timestamp = timestamp[0:19]
1188
+
1189
+ src_disp = str(src)
1190
+ if len(src_disp) > 60:
1191
+ src_disp = src_disp[0:60]
1192
+
1193
+ trans_disp = str(trans)
1194
+ if len(trans_disp) > 80:
1195
+ trans_disp = trans_disp[0:80]
1196
+
1197
+ tree.insert(
1198
+ "", tk.END,
1199
+ values=(
1200
+ timestamp,
1201
+ item.get("script", ""),
1202
+ item.get("backend", ""),
1203
+ f"{float(item.get('confidence', 0)) * 100:.0f}%",
1204
+ src_disp,
1205
+ trans_disp,
1206
+ ),
1207
+ )
1208
+
1209
+ def _filter(self):
1210
+ var = self._search_var
1211
+ if var:
1212
+ self._load_items(var.get())
1213
+
1214
+ def _export(self):
1215
+ path = filedialog.asksaveasfilename(
1216
+ defaultextension=".json",
1217
+ filetypes=[("JSON", "*.json"), ("All", "*.*")],
1218
+ )
1219
+ if path:
1220
+ self.history.export(path)
1221
+ messagebox.showinfo("Exported", f"History saved to:\n{path}")
1222
+
1223
+ def _clear(self):
1224
+ if messagebox.askyesno("Clear History",
1225
+ "Delete all translation history?"):
1226
+ self.history.clear()
1227
+ self._load_items()
1228
+
1229
+ def _on_double_click(self, event):
1230
+ tree = self._tree
1231
+ if tree is None:
1232
+ return
1233
+ sel = tree.selection()
1234
+ if not sel:
1235
+ return
1236
+ item_id = sel[0]
1237
+ item_vals = tree.item(item_id, "values")
1238
+ if not item_vals or not isinstance(item_vals, (list, tuple)):
1239
+ return
1240
+ if len(item_vals) > 5 and CLIPBOARD_AVAILABLE:
1241
+ pyperclip.copy(str(item_vals[5])) # copy translation
1242
+
1243
+
1244
+ # ════════════════════════════════════════════════════════════════════════════
1245
+ # SETTINGS WINDOW
1246
+ # ════════════════════════════════════════════════════════════════════════════
1247
+ class SettingsWindow(tk.Toplevel):
1248
+ def __init__(self, master: tk.Tk, config: Config):
1249
+ super().__init__(master)
1250
+ self.cfg: Config = config
1251
+ self._vars: Dict[str, Any] = {}
1252
+ self.title("Settings")
1253
+ self.geometry("460x480+150+150")
1254
+ self.configure(bg=COLORS["bg_dark"])
1255
+ self.attributes("-topmost", True)
1256
+ self.resizable(False, False)
1257
+ self._build()
1258
+
1259
+ def _build(self):
1260
+ pad = {"padx": 16, "pady": 6}
1261
+
1262
+ def row(label, widget_factory, key, **kw):
1263
+ f = tk.Frame(self, bg=COLORS["bg_dark"])
1264
+ # Use explicit args to satisfy pack_configure types
1265
+ f.pack(fill=tk.X, padx=pad["padx"], pady=pad["pady"])
1266
+ tk.Label(f, text=label, bg=COLORS["bg_dark"],
1267
+ fg=COLORS["text_secondary"],
1268
+ font=("Consolas", 10), width=26, anchor="w").pack(side=tk.LEFT)
1269
+ var, w = widget_factory(f, key, **kw)
1270
+ w.pack(side=tk.LEFT, fill=tk.X, expand=True)
1271
+ return var
1272
+
1273
+ def entry_field(parent, key):
1274
+ var = tk.StringVar(value=str(self.cfg.get(key, "")))
1275
+ e = tk.Entry(parent, textvariable=var, bg=COLORS["bg_mid"],
1276
+ fg=COLORS["text_primary"],
1277
+ insertbackground=COLORS["text_primary"],
1278
+ font=("Consolas", 10), relief=tk.FLAT)
1279
+ return var, e
1280
+
1281
+ def combo_field(parent, key, values):
1282
+ var = tk.StringVar(value=str(self.cfg.get(key, "")))
1283
+ c = ttk.Combobox(parent, textvariable=var, values=values,
1284
+ state="readonly", font=("Consolas", 10))
1285
+ return var, c
1286
+
1287
+ def check_field(parent, key):
1288
+ var = tk.BooleanVar(value=bool(self.cfg.get(key, False)))
1289
+ c = tk.Checkbutton(parent, variable=var, bg=COLORS["bg_dark"],
1290
+ activebackground=COLORS["bg_dark"],
1291
+ selectcolor=COLORS["bg_mid"])
1292
+ return var, c
1293
+
1294
+ self._vars = {}
1295
+
1296
+ self._vars["hotkey"] = row("Global Hotkey", entry_field, "hotkey")
1297
+ self._vars["lang"] = row("OCR Language", combo_field, "lang",
1298
+ values=["auto", "simplified", "traditional"])
1299
+ self._vars["backend"]= row("Translation Backend", combo_field, "backend",
1300
+ values=["google", "microsoft", "offline"])
1301
+ self._vars["upscale_factor"] = row("Upscale Factor (1-4)", entry_field, "upscale_factor")
1302
+ self._vars["confidence_threshold"] = row("OCR Confidence (0-1)", entry_field, "confidence_threshold")
1303
+ self._vars["use_gpu"] = row("Use GPU (CUDA)", check_field, "use_gpu")
1304
+ self._vars["auto_copy"] = row("Auto Copy Translation", check_field, "auto_copy")
1305
+ self._vars["show_confidence"] = row("Show Confidence Score", check_field, "show_confidence")
1306
+ self._vars["preprocess"] = row("Enable Image Preprocessing", check_field, "preprocess")
1307
+ self._vars["microsoft_api_key"] = row("Microsoft API Key", entry_field, "microsoft_api_key")
1308
+ self._vars["microsoft_region"] = row("Microsoft Region", entry_field, "microsoft_region")
1309
+
1310
+ tk.Button(
1311
+ self, text="Save Settings",
1312
+ bg=COLORS["accent"], fg="#ffffff",
1313
+ font=("Consolas", 10, "bold"),
1314
+ relief=tk.FLAT, padx=14, pady=6,
1315
+ cursor="hand2",
1316
+ command=self._save,
1317
+ ).pack(pady=14)
1318
+
1319
+ def _save(self):
1320
+ for key, var in self._vars.items():
1321
+ val = var.get()
1322
+ if isinstance(val, bool):
1323
+ self.cfg.set(key, val)
1324
+ else:
1325
+ try:
1326
+ fval = float(val)
1327
+ self.cfg.set(key, fval if "." in str(val) else int(fval))
1328
+ except (ValueError, TypeError):
1329
+ self.cfg.set(key, val)
1330
+ messagebox.showinfo("Saved", "Settings saved. Restart hotkeys to apply.")
1331
+ self.destroy()
1332
+
1333
+
1334
+ # ════════════════════════════════════════════════════════════════════════════
1335
+ # MAIN APPLICATION
1336
+ # ════════════════════════════════════════════════════════════════════════════
1337
+ class ChineseScreenTranslator:
1338
+ """
1339
+ Main application controller.
1340
+ Coordinates OCR, translation, UI, hotkeys, and history.
1341
+ """
1342
+
1343
+ def __init__(self, config: Config):
1344
+ self.cfg = config
1345
+ self.ocr = OCREngine(config)
1346
+ self.translator = TranslationEngine(config)
1347
+ self.history = HistoryManager(config)
1348
+ self._task_queue = Queue()
1349
+ self._result_win : Optional[ResultWindow] = None
1350
+ self._history_win: Optional[HistoryWindow] = None
1351
+ self._settings_win: Optional[SettingsWindow] = None
1352
+ self._processing = False
1353
+
1354
+ # Build root Tk window (hidden - acts only as parent)
1355
+ self.root = tk.Tk()
1356
+ self.root.withdraw()
1357
+ self.root.title(APP_NAME)
1358
+
1359
+ # Build persistent result window
1360
+ res_window = ResultWindow(self.root, self.history, self.cfg)
1361
+ self._result_win = res_window
1362
+ if res_window is not None:
1363
+ res_window.protocol("WM_DELETE_WINDOW", res_window.withdraw)
1364
+
1365
+ # Status bar (small floating hint)
1366
+ self._status_win = self._build_status_bar()
1367
+
1368
+ # Start worker thread
1369
+ self._worker_thread = threading.Thread(
1370
+ target=self._worker_loop, daemon=True
1371
+ )
1372
+ self._worker_thread.start()
1373
+
1374
+ # Register hotkeys
1375
+ self._register_hotkeys()
1376
+
1377
+ # Preload OCR model in background
1378
+ self.ocr.preload()
1379
+
1380
+ logger.info(f"{APP_NAME} v{APP_VERSION} started by {APP_AUTHOR}")
1381
+ logger.info(f"Hotkey: {self.cfg.get('hotkey')}")
1382
+
1383
+ def _deiconify_result(self, event=None):
1384
+ win = self._result_win
1385
+ if win is not None:
1386
+ win.deiconify()
1387
+ win.lift()
1388
+
1389
+ def _show_menu(self, event, menu: tk.Menu):
1390
+ menu.tk_popup(event.x_root, event.y_root)
1391
+
1392
+ def _build_status_bar(self) -> tk.Toplevel:
1393
+ win = tk.Toplevel(self.root)
1394
+ win.overrideredirect(True)
1395
+ win.attributes("-topmost", True)
1396
+ win.attributes("-alpha", 0.88)
1397
+ win.configure(bg=COLORS["bg_mid"])
1398
+ sw = win.winfo_screenwidth()
1399
+
1400
+ hotkey_raw = self.cfg.get("hotkey", "alt+s")
1401
+ hotkey = str(hotkey_raw).upper() if hotkey_raw else "ALT+S"
1402
+
1403
+ lbl = tk.Label(
1404
+ win,
1405
+ text=f" {APP_NAME} | {hotkey} = Capture | Right-click for menu ",
1406
+ bg=COLORS["bg_mid"],
1407
+ fg=COLORS["text_secondary"],
1408
+ font=("Consolas", 9),
1409
+ pady=4,
1410
+ )
1411
+ lbl.pack()
1412
+ win.geometry(f"+{sw - 620}+10")
1413
+
1414
+ # Right-click context menu
1415
+ menu = tk.Menu(win, tearoff=0, bg=COLORS["bg_mid"],
1416
+ fg=COLORS["text_primary"], font=("Consolas", 9))
1417
+ menu.add_command(label="Capture Region", command=self.start_capture)
1418
+ menu.add_command(label="Show Last Result",
1419
+ command=self._deiconify_result)
1420
+ menu.add_command(label="Translation History",
1421
+ command=self.show_history)
1422
+ menu.add_command(label="Settings", command=self.show_settings)
1423
+ menu.add_separator()
1424
+ menu.add_command(label="Quit", command=self.quit)
1425
+
1426
+ lbl.bind("<Button-3>", lambda e: self._show_menu(e, menu))
1427
+ return win
1428
+
1429
+ def _register_hotkeys(self):
1430
+ if not KEYBOARD_AVAILABLE:
1431
+ logger.warning("keyboard library unavailable. Use the status bar to trigger capture.")
1432
+ return
1433
+ hotkey = self.cfg.get("hotkey", "alt+s")
1434
+ try:
1435
+ kb.add_hotkey(hotkey, self._hotkey_capture, suppress=False)
1436
+ kb.add_hotkey("ctrl+shift+h", self.show_history, suppress=False)
1437
+ kb.add_hotkey("ctrl+shift+q", self.quit, suppress=False)
1438
+ kb.add_hotkey("ctrl+shift+c", self._hotkey_copy, suppress=True)
1439
+ logger.info(f"Hotkeys registered. Capture: {hotkey}")
1440
+ except Exception as e:
1441
+ logger.error(f"Hotkey registration failed: {e}")
1442
+ logger.info("Tip: On Linux, run as root or use the status bar right-click menu.")
1443
+
1444
+ def _hotkey_capture(self, *args):
1445
+ self.root.after(0, lambda: self.start_capture())
1446
+
1447
+ def _hotkey_copy(self):
1448
+ win = self._result_win
1449
+ if win and hasattr(win, "_current"):
1450
+ current = getattr(win, "_current")
1451
+ if current and isinstance(current, dict):
1452
+ trans = current.get("translation", "")
1453
+ if trans and CLIPBOARD_AVAILABLE:
1454
+ pyperclip.copy(trans)
1455
+ logger.info("Last translation copied to clipboard.")
1456
+
1457
+ def start_capture(self):
1458
+ if self._processing:
1459
+ logger.info("Already processing, ignoring duplicate capture request.")
1460
+ return
1461
+ overlay = ScreenOverlay(self.root, self._on_region_selected)
1462
+ overlay.focus_force()
1463
+
1464
+ def _on_region_selected(self, x1: int, y1: int, x2: int, y2: int):
1465
+ logger.info(f"Region selected: ({x1},{y1}) -> ({x2},{y2})")
1466
+ self._task_queue.put(("capture", x1, y1, x2, y2))
1467
+ res_win = self._result_win
1468
+ if res_win is not None:
1469
+ self.root.after(0, lambda *_, w=res_win: w.show_processing("Running OCR..."))
1470
+ res_win.deiconify()
1471
+
1472
+ def _worker_loop(self):
1473
+ """Background thread: OCR + translate."""
1474
+ while True:
1475
+ try:
1476
+ task = self._task_queue.get(timeout=0.5)
1477
+ except Empty:
1478
+ continue
1479
+
1480
+ if task[0] == "capture":
1481
+ _, x1, y1, x2, y2 = task
1482
+ self._processing = True
1483
+ try:
1484
+ self._process_region(x1, y1, x2, y2)
1485
+ except Exception as e:
1486
+ logger.error(f"Processing error: {e}", exc_info=True)
1487
+ win_err = self._result_win
1488
+ if win_err is not None:
1489
+ self.root.after(
1490
+ 0, lambda *_, w=win_err, msg=str(e): w.show_error(msg)
1491
+ )
1492
+ finally:
1493
+ self._processing = False
1494
+ self._task_queue.task_done()
1495
+
1496
+ def _process_region(self, x1: int, y1: int, x2: int, y2: int):
1497
+ win = self._result_win
1498
+ if win is None:
1499
+ return
1500
+
1501
+ # Step 1: Screenshot
1502
+ self.root.after(0, lambda w=win: w.show_processing(
1503
+ "Capturing screen region..."
1504
+ ))
1505
+ img = self._capture_screenshot(x1, y1, x2, y2)
1506
+ if img is None:
1507
+ raise RuntimeError("Screenshot capture failed.")
1508
+
1509
+ # Step 2: OCR
1510
+ self.root.after(0, lambda w=win: w.show_processing(
1511
+ "Running Chinese OCR (EasyOCR)..."
1512
+ ))
1513
+ source_text, confidence, script = self.ocr.extract_text(img)
1514
+
1515
+ if not source_text.strip():
1516
+ self.root.after(0, lambda w=win: w.update_result(
1517
+ "[No Chinese text detected in selection]",
1518
+ "",
1519
+ 0.0,
1520
+ script,
1521
+ "none",
1522
+ ))
1523
+ return
1524
+
1525
+ # Step 3: Translate
1526
+ self.root.after(0, lambda w=win, b=self.cfg.get('backend'): w.show_processing(
1527
+ f"Translating ({b})..."
1528
+ ))
1529
+ translated, backend = self.translator.translate(source_text, source_lang=script)
1530
+
1531
+ # Step 4: Save history
1532
+ self.history.add(source_text, translated, confidence, script, backend)
1533
+
1534
+ # Step 5: Update UI
1535
+ self.root.after(0, lambda *_, w=win, st=source_text, t=translated, c=confidence, s=script, b=backend: w.update_result(
1536
+ st, t, c, s, b
1537
+ ))
1538
+ logger.info(
1539
+ f"Done. OCR:{confidence:.2f} Script:{script} Backend:{backend}"
1540
+ )
1541
+
1542
+ def _capture_screenshot(self, x1: int, y1: int, x2: int, y2: int) -> Optional[Image.Image]:
1543
+ """Capture a screen region using mss (preferred) or PIL fallback."""
1544
+ try:
1545
+ if MSS_AVAILABLE:
1546
+ with mss.mss() as sct:
1547
+ monitor = {"top": y1, "left": x1, "width": x2-x1, "height": y2-y1}
1548
+ raw = sct.grab(monitor)
1549
+ img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
1550
+ return img
1551
+ except Exception as e:
1552
+ logger.warning(f"mss capture failed ({e}), trying PIL fallback...")
1553
+
1554
+ try:
1555
+ img = ImageGrab.grab(bbox=(x1, y1, x2, y2))
1556
+ return img
1557
+ except Exception as e:
1558
+ logger.error(f"PIL screenshot failed: {e}")
1559
+ return None
1560
+
1561
+ def show_history(self):
1562
+ win = self._history_win
1563
+ if win and win.winfo_exists():
1564
+ win.lift()
1565
+ else:
1566
+ self._history_win = HistoryWindow(self.root, self.history)
1567
+
1568
+ def show_settings(self):
1569
+ win = self._settings_win
1570
+ if win and win.winfo_exists():
1571
+ win.lift()
1572
+ else:
1573
+ self._settings_win = SettingsWindow(self.root, self.cfg)
1574
+
1575
+ def quit(self):
1576
+ logger.info("Shutting down.")
1577
+ if KEYBOARD_AVAILABLE:
1578
+ try:
1579
+ kb.unhook_all()
1580
+ except Exception:
1581
+ pass
1582
+ self.cfg.save()
1583
+ self.history.save()
1584
+ self.root.quit()
1585
+ self.root.destroy()
1586
+
1587
+ def run(self):
1588
+ try:
1589
+ self.root.mainloop()
1590
+ except KeyboardInterrupt:
1591
+ self.quit()
1592
+
1593
+
1594
+ # ════════════════════════════════════════════════════════════════════════════
1595
+ # CLI ENTRY POINT
1596
+ # ════════════════════════════════════════════════════════════════════════════
1597
+ def build_arg_parser() -> argparse.ArgumentParser:
1598
+ parser = argparse.ArgumentParser(
1599
+ prog="translator",
1600
+ description=(
1601
+ f"{APP_NAME} v{APP_VERSION} by {APP_AUTHOR}\n"
1602
+ "Chinese screen region OCR + translation tool."
1603
+ ),
1604
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1605
+ epilog=textwrap.dedent("""
1606
+ Examples:
1607
+ python translator.py
1608
+ python translator.py --hotkey "ctrl+shift+t"
1609
+ python translator.py --lang traditional --backend google
1610
+ python translator.py --offline --gpu
1611
+ python translator.py --upscale 3 --confidence 0.4 --verbose
1612
+ """),
1613
+ )
1614
+ parser.add_argument("--version", action="version",
1615
+ version=f"{APP_NAME} {APP_VERSION}")
1616
+ parser.add_argument("--hotkey", type=str,
1617
+ help="Global hotkey to trigger capture (default: alt+s)")
1618
+ parser.add_argument("--lang", choices=["auto", "simplified", "traditional"],
1619
+ help="Chinese OCR language mode")
1620
+ parser.add_argument("--backend", choices=["google", "microsoft", "offline"],
1621
+ help="Translation backend")
1622
+ parser.add_argument("--offline", action="store_true",
1623
+ help="Use offline Helsinki-NLP MarianMT model")
1624
+ parser.add_argument("--gpu", action="store_true",
1625
+ help="Enable GPU (CUDA) for OCR and offline translation")
1626
+ parser.add_argument("--upscale", type=int, choices=range(1, 5),
1627
+ metavar="1-4",
1628
+ help="Image upscale factor for better OCR on small text")
1629
+ parser.add_argument("--confidence", type=float,
1630
+ help="Minimum OCR confidence threshold (0.0-1.0)")
1631
+ parser.add_argument("--no-preprocess", action="store_true",
1632
+ help="Disable image preprocessing pipeline")
1633
+ parser.add_argument("--export-history", type=str, metavar="FILE",
1634
+ help="Export history to JSON file on exit")
1635
+ parser.add_argument("--verbose", action="store_true",
1636
+ help="Enable DEBUG-level logging")
1637
+ return parser
1638
+
1639
+
1640
+ def main():
1641
+ parser = build_arg_parser()
1642
+ args = parser.parse_args()
1643
+ logger_ = setup_logging(verbose=getattr(args, "verbose", False))
1644
+
1645
+ # Dependency check
1646
+ missing = []
1647
+ if not EASYOCR_AVAILABLE:
1648
+ missing.append("easyocr/torch -> pip install easyocr torch torchvision")
1649
+ if not DEEP_TRANSLATOR_AVAILABLE:
1650
+ missing.append("deep-translator -> pip install deep-translator")
1651
+ if not OFFLINE_TRANSLATION_AVAILABLE:
1652
+ # Only warn if backend is explicitly set to offline or if both fail
1653
+ if args.backend == "offline":
1654
+ missing.append("transformers/torch -> pip install transformers torch")
1655
+
1656
+ if missing:
1657
+ print("\n" + "!" * 40)
1658
+ print("[CRITICAL] Some dependencies are missing:")
1659
+ for m in missing:
1660
+ print(f" {m}")
1661
+ print("!" * 40 + "\n")
1662
+ # Do not exit, try to run anyway but with warnings
1663
+
1664
+ cfg = Config()
1665
+ cfg.apply_args(args)
1666
+
1667
+ app = ChineseScreenTranslator(cfg)
1668
+
1669
+ if getattr(args, "export_history", None):
1670
+ import atexit
1671
+ def _on_exit(*_args, **_kwargs):
1672
+ if app is not None:
1673
+ app.history.export(str(args.export_history))
1674
+ atexit.register(_on_exit)
1675
+
1676
+ print(f"\n{APP_NAME} v{APP_VERSION} | Author: {APP_AUTHOR}")
1677
+ print(f"Hotkey : {cfg.get('hotkey').upper()}")
1678
+ print(f"Lang : {cfg.get('lang')}")
1679
+ print(f"Backend: {cfg.get('backend')}")
1680
+ print(f"Config : {CONFIG_FILE}")
1681
+ print(f"Log : {LOG_FILE}")
1682
+ print("Press the hotkey or right-click the status bar to start.\n")
1683
+
1684
+ app.run()
1685
+
1686
+
1687
+ if __name__ == "__main__":
1688
+ main()