asterocr 1.0.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.
asterocr/studio.py ADDED
@@ -0,0 +1,371 @@
1
+ """
2
+ AsterOCR Standalone Training Studio & Neural Flow Visualizer
3
+ Independent server running on its own dedicated port (default: 7860).
4
+ Completely decoupled from any other application or document extraction project.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import webbrowser
10
+ import threading
11
+ import base64
12
+ from io import BytesIO
13
+
14
+ # Ensure parent directory is in sys.path
15
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
16
+
17
+ if sys.platform == "win32":
18
+ try:
19
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
20
+ sys.stderr.reconfigure(encoding='utf-8', errors='replace')
21
+ except Exception:
22
+ pass
23
+
24
+ import time
25
+ import torch
26
+ from flask import Flask, render_template, request, jsonify, send_file, Response
27
+ from asterocr.trainer.trainer import global_trainer
28
+ from asterocr.synthesizer.generator import default_generator
29
+
30
+ template_dir = os.path.join(os.path.dirname(__file__), "web", "templates")
31
+ app = Flask(__name__, template_folder=template_dir)
32
+
33
+ @app.route("/")
34
+ def index():
35
+ return render_template("index.html")
36
+
37
+ @app.route("/api/asterocr/status")
38
+ def api_status():
39
+ return jsonify(global_trainer.get_state())
40
+
41
+ session_start_chunk = None
42
+
43
+ @app.route("/api/asterocr/start", methods=["POST"])
44
+ def api_start():
45
+ global session_start_chunk
46
+ session_start_chunk = None # Reset relative chunk mapping for new session
47
+ cfg = request.json or {}
48
+ success, msg = global_trainer.start(cfg)
49
+ return jsonify({"status": "ok" if success else "error", "message": msg})
50
+
51
+ @app.route("/api/asterocr/stop", methods=["POST"])
52
+ def api_stop():
53
+ global_trainer.stop()
54
+ return jsonify({"status": "ok", "message": "Stop requested."})
55
+
56
+ @app.route("/api/asterocr/reset", methods=["POST"])
57
+ def api_reset():
58
+ global_trainer.reset_history()
59
+ return jsonify({"status": "ok", "message": "Đã làm sạch toàn bộ biểu đồ và lịch sử về 0."})
60
+
61
+ @app.route("/api/asterocr/preview_synthetic", methods=["POST"])
62
+ def api_preview_synthetic():
63
+ req_data = request.json or {}
64
+ text = req_data.get("text", "CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM")
65
+ images_b64 = []
66
+
67
+ for style in ["clean", "scan", "aged"]:
68
+ try:
69
+ im = default_generator.render_text(text, target_height=32)
70
+ buf = BytesIO()
71
+ im.save(buf, format="PNG")
72
+ images_b64.append("data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("utf-8"))
73
+ except Exception as e:
74
+ print("Preview synth error:", e)
75
+
76
+ return jsonify({"status": "ok", "images": images_b64})
77
+
78
+ # =========================================================================
79
+ # CLOUD GPU STREAMING PIPELINE ENDPOINTS
80
+ # =========================================================================
81
+ from asterocr.kaggle_bridge.chunker import default_chunker
82
+ from asterocr.kaggle_bridge.tunnel import default_tunnel
83
+ import json
84
+
85
+ @app.route("/api/tunnel/info", methods=["GET"])
86
+ def api_tunnel_info():
87
+ cfg_path = os.path.join(os.path.dirname(__file__), "tunnel_config.json")
88
+ custom_u = ""
89
+ if os.path.exists(cfg_path):
90
+ try:
91
+ with open(cfg_path, "r", encoding="utf-8") as f:
92
+ custom_u = json.load(f).get("custom_url", "")
93
+ if "trycloudflare.com" in custom_u:
94
+ custom_u = ""
95
+ except Exception:
96
+ pass
97
+ active_u = default_tunnel.get_url() or ""
98
+ return jsonify({
99
+ "active_url": active_u,
100
+ "custom_url": custom_u,
101
+ "is_alive": default_tunnel.is_alive()
102
+ })
103
+
104
+ @app.route("/api/tunnel/config", methods=["POST"])
105
+ def api_tunnel_config():
106
+ data = request.json or {}
107
+ custom_u = data.get("custom_url", "").strip()
108
+ if "trycloudflare.com" in custom_u:
109
+ return jsonify({
110
+ "status": "error",
111
+ "message": "URL trycloudflare.com là URL tạm thời ngẫu nhiên của Cloudflare, không thể lưu làm tên miền cố định. Vui lòng để trống ô này để hệ thống tự động sinh và kết nối tự động (Zero-Touch)!"
112
+ })
113
+ cfg_path = os.path.join(os.path.dirname(__file__), "tunnel_config.json")
114
+ try:
115
+ existing_cfg = {}
116
+ if os.path.exists(cfg_path):
117
+ try:
118
+ with open(cfg_path, "r", encoding="utf-8") as f:
119
+ existing_cfg = json.load(f)
120
+ except Exception:
121
+ pass
122
+ existing_cfg["custom_url"] = custom_u
123
+ if "tunnel_token" in data and data["tunnel_token"]:
124
+ existing_cfg["tunnel_token"] = data["tunnel_token"]
125
+ with open(cfg_path, "w", encoding="utf-8") as f:
126
+ json.dump(existing_cfg, f, indent=2)
127
+ default_tunnel.tunnel_url = custom_u if custom_u else None
128
+ return jsonify({"status": "ok", "custom_url": custom_u, "message": "Đã lưu cấu hình Tunnel." if custom_u else "Đã xóa tên miền tùy chỉnh. Hệ thống chuyển sang chế độ Tự Động (Zero-Touch)."})
129
+ except Exception as e:
130
+ return jsonify({"status": "error", "message": str(e)})
131
+
132
+ @app.route("/api/tunnel/start", methods=["POST"])
133
+ def api_tunnel_start():
134
+ try:
135
+ url = default_tunnel.start(timeout=35)
136
+ return jsonify({"status": "ok", "url": url, "message": "Cloudflare Tunnel đã kích hoạt thành công."})
137
+ except Exception as e:
138
+ return jsonify({"status": "error", "message": str(e)})
139
+
140
+ @app.route("/api/tunnel/stop", methods=["POST"])
141
+ def api_tunnel_stop():
142
+ try:
143
+ default_tunnel.stop()
144
+ return jsonify({"status": "ok", "message": "Đã dừng Cloudflare Tunnel."})
145
+ except Exception as e:
146
+ return jsonify({"status": "error", "message": str(e)})
147
+
148
+ @app.route("/api/colab/worker.py")
149
+ def api_colab_worker():
150
+ worker_p = os.path.join(os.path.dirname(__file__), "kaggle_bridge", "kaggle_worker.py")
151
+ if os.path.exists(worker_p):
152
+ with open(worker_p, "r", encoding="utf-8") as f:
153
+ code = f.read()
154
+
155
+ import re, json
156
+ # Đọc cấu hình tạm do _train_colab_worker xuất ra
157
+ config_path = os.path.join(os.path.dirname(__file__), "..", "scratch", "colab_run_config.json")
158
+ if os.path.exists(config_path):
159
+ with open(config_path, "r", encoding="utf-8") as f:
160
+ cfg = json.load(f)
161
+
162
+ is_tpu = (cfg.get("device") == "colab_tpu")
163
+ batch_sz = cfg.get("batch_size", 256)
164
+ lr = cfg.get("lr", 0.0005)
165
+ epochs = cfg.get("epochs", 5)
166
+
167
+ # Lấy tunnel url
168
+ tunnel_url = "https://asterai.sglocalhub.com"
169
+ from asterocr.kaggle_bridge.tunnel import default_tunnel
170
+ if default_tunnel.tunnel_url:
171
+ tunnel_url = default_tunnel.tunnel_url
172
+
173
+ code = re.sub(r'IS_TPU_DEVICE\s*=\s*(True|False)', f'IS_TPU_DEVICE = {is_tpu}', code)
174
+ code = re.sub(r'TUNNEL_URL\s*=\s*["\'][^"\']+["\']', f'TUNNEL_URL = "{tunnel_url}"', code)
175
+ code = re.sub(r'BATCH_SIZE\s*=\s*\d+', f'BATCH_SIZE = {batch_sz}', code)
176
+ code = re.sub(r'LEARNING_RATE\s*=\s*[\d\.]+', f'LEARNING_RATE = {lr}', code)
177
+ code = re.sub(r'MAX_EPOCHS\s*=\s*\d+', f'MAX_EPOCHS = {epochs}', code)
178
+
179
+ return Response(code, mimetype="text/plain; charset=utf-8")
180
+ return ("Worker not found", 404)
181
+
182
+ @app.route("/api/kaggle/stream/ping")
183
+ def api_kaggle_ping():
184
+ return jsonify({"status": "ok", "service": "AsterOCR Local Streamer", "time": time.time()})
185
+
186
+ @app.route("/api/kaggle/stream/heartbeat", methods=["POST"])
187
+ def api_kaggle_heartbeat():
188
+ data = request.json or {}
189
+ global_trainer.record_cloud_heartbeat(data)
190
+ return jsonify({"status": "ok", "time": time.time()})
191
+
192
+ @app.route("/api/kaggle/stream/control")
193
+ def api_kaggle_control():
194
+ return jsonify({"should_stop": bool(global_trainer.stop_requested)})
195
+
196
+ @app.route("/api/kaggle/stream/init_weights")
197
+ def api_kaggle_init_weights():
198
+ global_trainer.record_cloud_heartbeat({"event": "init_weights"})
199
+ best_p = os.path.join(global_trainer.output_dir, "asternet_best.pth")
200
+ last_p = os.path.join(global_trainer.output_dir, "asternet_last.pth")
201
+
202
+ # Intelligently select latest / highest step checkpoint
203
+ chosen = None
204
+ if os.path.exists(last_p) and os.path.exists(best_p):
205
+ try:
206
+ c_last = torch.load(last_p, map_location="cpu", weights_only=False)
207
+ c_best = torch.load(best_p, map_location="cpu", weights_only=False)
208
+ s_last = c_last.get("step", 0) or 0
209
+ s_best = c_best.get("step", 0) or 0
210
+ chosen = last_p if s_last >= s_best else best_p
211
+ except Exception:
212
+ chosen = last_p
213
+ elif os.path.exists(last_p):
214
+ chosen = last_p
215
+ elif os.path.exists(best_p):
216
+ chosen = best_p
217
+
218
+ if chosen and os.path.exists(chosen):
219
+ return send_file(chosen, mimetype="application/octet-stream")
220
+ return ("", 204)
221
+
222
+ @app.route("/api/kaggle/stream/get_chunk")
223
+ def api_kaggle_get_chunk():
224
+ global session_start_chunk
225
+ global_trainer.record_cloud_heartbeat({"event": "get_chunk"})
226
+ chunk_idx = int(request.args.get("chunk_idx", 0))
227
+ epochs = int(global_trainer.state.get("total_epochs", 5))
228
+ total_chunks = default_chunker.get_total_chunks()
229
+
230
+ # Dynamic session chunk calibration:
231
+ # When worker connects, it may restore an old checkpoint's chunk_idx (e.g. 10,078).
232
+ # We calibrate the relative chunk to start from 0 for the currently active dataset!
233
+ if session_start_chunk is None or chunk_idx < session_start_chunk:
234
+ session_start_chunk = chunk_idx
235
+
236
+ relative_chunk = max(0, chunk_idx - session_start_chunk)
237
+
238
+ buf = default_chunker.get_chunk_bytes(relative_chunk, max_epochs=epochs)
239
+ if buf is None:
240
+ return ("", 204)
241
+
242
+ cur_epoch = min(epochs, (relative_chunk // total_chunks) + 1)
243
+
244
+ resp = send_file(buf, mimetype="application/octet-stream")
245
+ resp.headers["X-Current-Epoch"] = str(cur_epoch)
246
+ resp.headers["X-Total-Epochs"] = str(epochs)
247
+ resp.headers["X-Total-Chunks"] = str(total_chunks)
248
+ return resp
249
+
250
+ @app.route("/api/kaggle/stream/telemetry", methods=["POST"])
251
+ def api_kaggle_telemetry():
252
+ data = request.json or {}
253
+ global_trainer.update_remote_telemetry(data)
254
+ return jsonify({"status": "ok"})
255
+
256
+ @app.route("/api/kaggle/stream/save_checkpoint", methods=["POST"])
257
+ def api_kaggle_save_checkpoint():
258
+ file = request.files.get("checkpoint")
259
+ if file:
260
+ os.makedirs(global_trainer.output_dir, exist_ok=True)
261
+ last_p = os.path.join(global_trainer.output_dir, "asternet_last.pth")
262
+ best_p = os.path.join(global_trainer.output_dir, "asternet_best.pth")
263
+ temp_p = os.path.join(global_trainer.output_dir, "temp_incoming.pth")
264
+
265
+ file.save(temp_p)
266
+ try:
267
+ test_ckpt = torch.load(temp_p, map_location="cpu", weights_only=False)
268
+ sd = test_ckpt.get("state_dict", test_ckpt.get("model", test_ckpt))
269
+ has_nan = any(torch.isnan(v).any() or torch.isinf(v).any() for v in sd.values() if isinstance(v, torch.Tensor))
270
+ if has_nan:
271
+ print(f"[!] CẢNH BÁO: Checkpoint từ Cloud chứa giá trị NaN/Inf! Đã từ chối lưu để bảo vệ mô hình an toàn!")
272
+ if os.path.exists(temp_p):
273
+ os.remove(temp_p)
274
+ return jsonify({"status": "rejected", "message": "Checkpoint contains NaN"}), 400
275
+ except Exception as e:
276
+ print(f"[!] Lỗi kiểm tra tính toàn vẹn của checkpoint: {e}")
277
+ if os.path.exists(temp_p):
278
+ os.remove(temp_p)
279
+ return jsonify({"status": "error"}), 400
280
+
281
+ # Trọng số sạch: chuyển vào last_p
282
+ import shutil
283
+ shutil.move(temp_p, last_p)
284
+
285
+ step = int(request.form.get("step", 0))
286
+ chunk_idx = int(request.form.get("chunk_idx", 0))
287
+ try:
288
+ loss = float(request.form.get("loss", 999.0))
289
+ best = float(request.form.get("best_loss", 999.0))
290
+ with global_trainer.lock:
291
+ cur_best = float(global_trainer.ledger.get("best_loss", 999.0))
292
+ # Protect against corrupted/dummy zero loss (< 0.01 is invalid in CTC)
293
+ if cur_best < 0.01:
294
+ cur_best = 999.0
295
+ global_trainer.ledger["best_loss"] = 999.0
296
+ global_trainer.state["best_loss"] = 999.0
297
+
298
+ # Only update best model file if loss strictly improved, valid (> 0.01) and reasonable (< 2.0)
299
+ # Chặn không cho giá trị 0.1500 cố định giả tạo ghi đè kỷ lục nếu không thật
300
+ if (0.01 < loss < cur_best and loss < 2.0) or not os.path.exists(best_p):
301
+ shutil.copyfile(last_p, best_p)
302
+ global_trainer.ledger["best_loss"] = round(loss, 4)
303
+ global_trainer.state["best_loss"] = round(loss, 4)
304
+ print(f"[+] Kỷ lục Loss mới đạt được: {loss:.4f}! Đã lưu vào {best_p}")
305
+
306
+ # Maintain strictly monotonic step and chunk accounting
307
+ if step > global_trainer.ledger.get("last_step", 0):
308
+ global_trainer.ledger["last_step"] = step
309
+ if chunk_idx > global_trainer.ledger.get("last_chunk_idx", 0):
310
+ global_trainer.ledger["last_chunk_idx"] = chunk_idx
311
+ global_trainer.ledger["last_updated"] = time.strftime("%Y-%m-%dT%H:%M:%S")
312
+
313
+ # Sync total_samples from cumulative state if available
314
+ ct_samp = global_trainer.state.get("cumulative_training", {}).get("total_samples", 0)
315
+ if ct_samp > global_trainer.ledger.get("total_samples", 0):
316
+ global_trainer.ledger["total_samples"] = ct_samp
317
+
318
+ global_trainer._save_ledger(global_trainer.ledger)
319
+ except Exception as e:
320
+ print(f"[!] Lỗi khi đồng bộ checkpoint metadata vào ledger: {e}")
321
+ print(f"[+] Checkpoint từ Cloud GPU (Step {step}, Chunk {chunk_idx}) đã được đồng bộ an toàn về: {last_p}")
322
+ return jsonify({"status": "ok"})
323
+
324
+ @app.route("/api/kaggle/status", methods=["GET"])
325
+ def api_kaggle_status():
326
+ import subprocess
327
+ slug = request.args.get("slug", "creyt2012/asterocr-cloud-gpu-worker-v2")
328
+ env = os.environ.copy()
329
+ tok_p = os.path.expanduser("~/.kaggle/access_token")
330
+ if os.path.exists(tok_p):
331
+ try:
332
+ with open(tok_p, "r", encoding="utf-8") as f:
333
+ env["KAGGLE_API_TOKEN"] = f.read().strip()
334
+ except Exception:
335
+ pass
336
+ try:
337
+ res = subprocess.run([sys.executable, "-m", "kaggle.cli", "kernels", "status", slug], env=env, capture_output=True, text=True, timeout=15)
338
+ out = (res.stdout + " " + res.stderr).strip()
339
+ return jsonify({"status": "ok", "output": out})
340
+ except Exception as e:
341
+ return jsonify({"status": "error", "message": str(e)})
342
+
343
+ @app.route("/api/kaggle/launch_finetune", methods=["POST"])
344
+ def api_kaggle_launch_finetune():
345
+ data = request.json or {}
346
+ epochs = int(data.get("epochs", 5))
347
+ batch_size = int(data.get("batch_size", 96))
348
+ lr = float(data.get("lr", 0.00005))
349
+ dataset_size = int(data.get("dataset_size", 1558564))
350
+
351
+ cfg = {
352
+ "dataset_mode": "pure_real_scans",
353
+ "dataset_size": dataset_size,
354
+ "epochs": epochs,
355
+ "batch_size": batch_size,
356
+ "lr": lr,
357
+ "device": "kaggle_t4"
358
+ }
359
+ success, msg = global_trainer.start(cfg)
360
+ return jsonify({"status": "ok" if success else "error", "message": msg})
361
+
362
+ def open_browser(port):
363
+ import time
364
+ time.sleep(1.2)
365
+ webbrowser.open(f"http://127.0.0.1:{port}")
366
+
367
+ if __name__ == "__main__":
368
+ PORT = 7860
369
+ print(f"[*] Khởi động AsterOCR Studio độc lập tại: http://127.0.0.1:{PORT}")
370
+ threading.Thread(target=open_browser, args=(PORT,), daemon=True).start()
371
+ app.run(host="127.0.0.1", port=PORT, debug=False, threaded=True)