novel-downloader 1.4.5__py3-none-any.whl → 2.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.
Files changed (276) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/__init__.py +2 -4
  3. novel_downloader/cli/clean.py +21 -88
  4. novel_downloader/cli/config.py +27 -104
  5. novel_downloader/cli/download.py +78 -66
  6. novel_downloader/cli/export.py +20 -21
  7. novel_downloader/cli/main.py +3 -1
  8. novel_downloader/cli/search.py +120 -0
  9. novel_downloader/cli/ui.py +156 -0
  10. novel_downloader/config/__init__.py +10 -14
  11. novel_downloader/config/adapter.py +195 -99
  12. novel_downloader/config/{loader.py → file_io.py} +53 -27
  13. novel_downloader/core/__init__.py +14 -13
  14. novel_downloader/core/archived/deqixs/fetcher.py +115 -0
  15. novel_downloader/core/archived/deqixs/parser.py +132 -0
  16. novel_downloader/core/archived/deqixs/searcher.py +89 -0
  17. novel_downloader/core/archived/qidian/searcher.py +79 -0
  18. novel_downloader/core/archived/wanbengo/searcher.py +98 -0
  19. novel_downloader/core/archived/xshbook/searcher.py +93 -0
  20. novel_downloader/core/downloaders/__init__.py +8 -30
  21. novel_downloader/core/downloaders/base.py +182 -30
  22. novel_downloader/core/downloaders/common.py +217 -384
  23. novel_downloader/core/downloaders/qianbi.py +332 -4
  24. novel_downloader/core/downloaders/qidian.py +250 -290
  25. novel_downloader/core/downloaders/registry.py +69 -0
  26. novel_downloader/core/downloaders/signals.py +46 -0
  27. novel_downloader/core/exporters/__init__.py +8 -26
  28. novel_downloader/core/exporters/base.py +107 -31
  29. novel_downloader/core/exporters/common/__init__.py +3 -4
  30. novel_downloader/core/exporters/common/epub.py +92 -171
  31. novel_downloader/core/exporters/common/main_exporter.py +14 -67
  32. novel_downloader/core/exporters/common/txt.py +90 -86
  33. novel_downloader/core/exporters/epub_util.py +184 -1327
  34. novel_downloader/core/exporters/linovelib/__init__.py +3 -2
  35. novel_downloader/core/exporters/linovelib/epub.py +165 -222
  36. novel_downloader/core/exporters/linovelib/main_exporter.py +10 -71
  37. novel_downloader/core/exporters/linovelib/txt.py +76 -66
  38. novel_downloader/core/exporters/qidian.py +15 -11
  39. novel_downloader/core/exporters/registry.py +55 -0
  40. novel_downloader/core/exporters/txt_util.py +67 -0
  41. novel_downloader/core/fetchers/__init__.py +57 -56
  42. novel_downloader/core/fetchers/aaatxt.py +83 -0
  43. novel_downloader/core/fetchers/{biquge/session.py → b520.py} +10 -10
  44. novel_downloader/core/fetchers/{base/session.py → base.py} +63 -47
  45. novel_downloader/core/fetchers/biquyuedu.py +83 -0
  46. novel_downloader/core/fetchers/dxmwx.py +110 -0
  47. novel_downloader/core/fetchers/eightnovel.py +139 -0
  48. novel_downloader/core/fetchers/{esjzone/session.py → esjzone.py} +23 -11
  49. novel_downloader/core/fetchers/guidaye.py +85 -0
  50. novel_downloader/core/fetchers/hetushu.py +92 -0
  51. novel_downloader/core/fetchers/{qianbi/browser.py → i25zw.py} +22 -26
  52. novel_downloader/core/fetchers/ixdzs8.py +113 -0
  53. novel_downloader/core/fetchers/jpxs123.py +101 -0
  54. novel_downloader/core/fetchers/{biquge/browser.py → lewenn.py} +15 -15
  55. novel_downloader/core/fetchers/{linovelib/session.py → linovelib.py} +16 -12
  56. novel_downloader/core/fetchers/piaotia.py +105 -0
  57. novel_downloader/core/fetchers/qbtr.py +101 -0
  58. novel_downloader/core/fetchers/{qianbi/session.py → qianbi.py} +9 -9
  59. novel_downloader/core/fetchers/{qidian/session.py → qidian.py} +55 -40
  60. novel_downloader/core/fetchers/quanben5.py +92 -0
  61. novel_downloader/core/fetchers/{base/rate_limiter.py → rate_limiter.py} +2 -2
  62. novel_downloader/core/fetchers/registry.py +60 -0
  63. novel_downloader/core/fetchers/{sfacg/session.py → sfacg.py} +11 -9
  64. novel_downloader/core/fetchers/shencou.py +106 -0
  65. novel_downloader/core/fetchers/{common/browser.py → shuhaige.py} +24 -19
  66. novel_downloader/core/fetchers/tongrenquan.py +84 -0
  67. novel_downloader/core/fetchers/ttkan.py +95 -0
  68. novel_downloader/core/fetchers/{common/session.py → wanbengo.py} +21 -17
  69. novel_downloader/core/fetchers/xiaoshuowu.py +106 -0
  70. novel_downloader/core/fetchers/xiguashuwu.py +177 -0
  71. novel_downloader/core/fetchers/xs63b.py +171 -0
  72. novel_downloader/core/fetchers/xshbook.py +85 -0
  73. novel_downloader/core/fetchers/{yamibo/session.py → yamibo.py} +23 -11
  74. novel_downloader/core/fetchers/yibige.py +114 -0
  75. novel_downloader/core/interfaces/__init__.py +8 -14
  76. novel_downloader/core/interfaces/downloader.py +6 -2
  77. novel_downloader/core/interfaces/exporter.py +7 -7
  78. novel_downloader/core/interfaces/fetcher.py +4 -17
  79. novel_downloader/core/interfaces/parser.py +5 -6
  80. novel_downloader/core/interfaces/searcher.py +26 -0
  81. novel_downloader/core/parsers/__init__.py +58 -22
  82. novel_downloader/core/parsers/aaatxt.py +132 -0
  83. novel_downloader/core/parsers/b520.py +116 -0
  84. novel_downloader/core/parsers/base.py +63 -12
  85. novel_downloader/core/parsers/biquyuedu.py +133 -0
  86. novel_downloader/core/parsers/dxmwx.py +162 -0
  87. novel_downloader/core/parsers/eightnovel.py +224 -0
  88. novel_downloader/core/parsers/{esjzone/main_parser.py → esjzone.py} +67 -67
  89. novel_downloader/core/parsers/guidaye.py +128 -0
  90. novel_downloader/core/parsers/hetushu.py +139 -0
  91. novel_downloader/core/parsers/i25zw.py +137 -0
  92. novel_downloader/core/parsers/ixdzs8.py +186 -0
  93. novel_downloader/core/parsers/jpxs123.py +137 -0
  94. novel_downloader/core/parsers/lewenn.py +142 -0
  95. novel_downloader/core/parsers/{linovelib/main_parser.py → linovelib.py} +54 -65
  96. novel_downloader/core/parsers/piaotia.py +189 -0
  97. novel_downloader/core/parsers/qbtr.py +136 -0
  98. novel_downloader/core/parsers/{qianbi/main_parser.py → qianbi.py} +54 -51
  99. novel_downloader/core/parsers/qidian/__init__.py +2 -2
  100. novel_downloader/core/parsers/qidian/book_info_parser.py +58 -59
  101. novel_downloader/core/parsers/qidian/chapter_encrypted.py +290 -346
  102. novel_downloader/core/parsers/qidian/chapter_normal.py +25 -56
  103. novel_downloader/core/parsers/qidian/main_parser.py +19 -57
  104. novel_downloader/core/parsers/qidian/utils/__init__.py +12 -11
  105. novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +6 -7
  106. novel_downloader/core/parsers/qidian/utils/fontmap_recover.py +143 -0
  107. novel_downloader/core/parsers/qidian/utils/helpers.py +0 -4
  108. novel_downloader/core/parsers/qidian/utils/node_decryptor.py +2 -2
  109. novel_downloader/core/parsers/quanben5.py +103 -0
  110. novel_downloader/core/parsers/registry.py +57 -0
  111. novel_downloader/core/parsers/{sfacg/main_parser.py → sfacg.py} +46 -48
  112. novel_downloader/core/parsers/shencou.py +215 -0
  113. novel_downloader/core/parsers/shuhaige.py +111 -0
  114. novel_downloader/core/parsers/tongrenquan.py +116 -0
  115. novel_downloader/core/parsers/ttkan.py +132 -0
  116. novel_downloader/core/parsers/wanbengo.py +191 -0
  117. novel_downloader/core/parsers/xiaoshuowu.py +173 -0
  118. novel_downloader/core/parsers/xiguashuwu.py +435 -0
  119. novel_downloader/core/parsers/xs63b.py +161 -0
  120. novel_downloader/core/parsers/xshbook.py +134 -0
  121. novel_downloader/core/parsers/yamibo.py +155 -0
  122. novel_downloader/core/parsers/yibige.py +166 -0
  123. novel_downloader/core/searchers/__init__.py +51 -0
  124. novel_downloader/core/searchers/aaatxt.py +107 -0
  125. novel_downloader/core/searchers/b520.py +84 -0
  126. novel_downloader/core/searchers/base.py +168 -0
  127. novel_downloader/core/searchers/dxmwx.py +105 -0
  128. novel_downloader/core/searchers/eightnovel.py +84 -0
  129. novel_downloader/core/searchers/esjzone.py +102 -0
  130. novel_downloader/core/searchers/hetushu.py +92 -0
  131. novel_downloader/core/searchers/i25zw.py +93 -0
  132. novel_downloader/core/searchers/ixdzs8.py +107 -0
  133. novel_downloader/core/searchers/jpxs123.py +107 -0
  134. novel_downloader/core/searchers/piaotia.py +100 -0
  135. novel_downloader/core/searchers/qbtr.py +106 -0
  136. novel_downloader/core/searchers/qianbi.py +165 -0
  137. novel_downloader/core/searchers/quanben5.py +144 -0
  138. novel_downloader/core/searchers/registry.py +79 -0
  139. novel_downloader/core/searchers/shuhaige.py +124 -0
  140. novel_downloader/core/searchers/tongrenquan.py +110 -0
  141. novel_downloader/core/searchers/ttkan.py +92 -0
  142. novel_downloader/core/searchers/xiaoshuowu.py +122 -0
  143. novel_downloader/core/searchers/xiguashuwu.py +95 -0
  144. novel_downloader/core/searchers/xs63b.py +104 -0
  145. novel_downloader/locales/en.json +36 -79
  146. novel_downloader/locales/zh.json +37 -80
  147. novel_downloader/models/__init__.py +23 -50
  148. novel_downloader/models/book.py +44 -0
  149. novel_downloader/models/config.py +16 -43
  150. novel_downloader/models/login.py +1 -1
  151. novel_downloader/models/search.py +21 -0
  152. novel_downloader/resources/config/settings.toml +39 -74
  153. novel_downloader/resources/css_styles/intro.css +83 -0
  154. novel_downloader/resources/css_styles/main.css +30 -89
  155. novel_downloader/resources/json/xiguashuwu.json +718 -0
  156. novel_downloader/utils/__init__.py +43 -0
  157. novel_downloader/utils/chapter_storage.py +247 -226
  158. novel_downloader/utils/constants.py +5 -50
  159. novel_downloader/utils/cookies.py +6 -18
  160. novel_downloader/utils/crypto_utils/__init__.py +13 -0
  161. novel_downloader/utils/crypto_utils/aes_util.py +90 -0
  162. novel_downloader/utils/crypto_utils/aes_v1.py +619 -0
  163. novel_downloader/utils/crypto_utils/aes_v2.py +1143 -0
  164. novel_downloader/utils/{crypto_utils.py → crypto_utils/rc4.py} +3 -10
  165. novel_downloader/utils/epub/__init__.py +34 -0
  166. novel_downloader/utils/epub/builder.py +377 -0
  167. novel_downloader/utils/epub/constants.py +118 -0
  168. novel_downloader/utils/epub/documents.py +297 -0
  169. novel_downloader/utils/epub/models.py +120 -0
  170. novel_downloader/utils/epub/utils.py +179 -0
  171. novel_downloader/utils/file_utils/__init__.py +5 -30
  172. novel_downloader/utils/file_utils/io.py +9 -150
  173. novel_downloader/utils/file_utils/normalize.py +2 -2
  174. novel_downloader/utils/file_utils/sanitize.py +2 -7
  175. novel_downloader/utils/fontocr.py +207 -0
  176. novel_downloader/utils/i18n.py +2 -0
  177. novel_downloader/utils/logger.py +10 -16
  178. novel_downloader/utils/network.py +111 -252
  179. novel_downloader/utils/state.py +5 -90
  180. novel_downloader/utils/text_utils/__init__.py +16 -21
  181. novel_downloader/utils/text_utils/diff_display.py +6 -9
  182. novel_downloader/utils/text_utils/numeric_conversion.py +253 -0
  183. novel_downloader/utils/text_utils/text_cleaner.py +179 -0
  184. novel_downloader/utils/text_utils/truncate_utils.py +62 -0
  185. novel_downloader/utils/time_utils/__init__.py +6 -12
  186. novel_downloader/utils/time_utils/datetime_utils.py +23 -33
  187. novel_downloader/utils/time_utils/sleep_utils.py +5 -10
  188. novel_downloader/web/__init__.py +13 -0
  189. novel_downloader/web/components/__init__.py +11 -0
  190. novel_downloader/web/components/navigation.py +35 -0
  191. novel_downloader/web/main.py +66 -0
  192. novel_downloader/web/pages/__init__.py +17 -0
  193. novel_downloader/web/pages/download.py +78 -0
  194. novel_downloader/web/pages/progress.py +147 -0
  195. novel_downloader/web/pages/search.py +329 -0
  196. novel_downloader/web/services/__init__.py +17 -0
  197. novel_downloader/web/services/client_dialog.py +164 -0
  198. novel_downloader/web/services/cred_broker.py +113 -0
  199. novel_downloader/web/services/cred_models.py +35 -0
  200. novel_downloader/web/services/task_manager.py +264 -0
  201. novel_downloader-2.0.0.dist-info/METADATA +171 -0
  202. novel_downloader-2.0.0.dist-info/RECORD +210 -0
  203. {novel_downloader-1.4.5.dist-info → novel_downloader-2.0.0.dist-info}/entry_points.txt +1 -1
  204. novel_downloader/config/site_rules.py +0 -94
  205. novel_downloader/core/downloaders/biquge.py +0 -25
  206. novel_downloader/core/downloaders/esjzone.py +0 -25
  207. novel_downloader/core/downloaders/linovelib.py +0 -25
  208. novel_downloader/core/downloaders/sfacg.py +0 -25
  209. novel_downloader/core/downloaders/yamibo.py +0 -25
  210. novel_downloader/core/exporters/biquge.py +0 -25
  211. novel_downloader/core/exporters/esjzone.py +0 -25
  212. novel_downloader/core/exporters/qianbi.py +0 -25
  213. novel_downloader/core/exporters/sfacg.py +0 -25
  214. novel_downloader/core/exporters/yamibo.py +0 -25
  215. novel_downloader/core/factory/__init__.py +0 -20
  216. novel_downloader/core/factory/downloader.py +0 -73
  217. novel_downloader/core/factory/exporter.py +0 -58
  218. novel_downloader/core/factory/fetcher.py +0 -96
  219. novel_downloader/core/factory/parser.py +0 -86
  220. novel_downloader/core/fetchers/base/__init__.py +0 -14
  221. novel_downloader/core/fetchers/base/browser.py +0 -403
  222. novel_downloader/core/fetchers/biquge/__init__.py +0 -14
  223. novel_downloader/core/fetchers/common/__init__.py +0 -14
  224. novel_downloader/core/fetchers/esjzone/__init__.py +0 -14
  225. novel_downloader/core/fetchers/esjzone/browser.py +0 -204
  226. novel_downloader/core/fetchers/linovelib/__init__.py +0 -14
  227. novel_downloader/core/fetchers/linovelib/browser.py +0 -193
  228. novel_downloader/core/fetchers/qianbi/__init__.py +0 -14
  229. novel_downloader/core/fetchers/qidian/__init__.py +0 -14
  230. novel_downloader/core/fetchers/qidian/browser.py +0 -318
  231. novel_downloader/core/fetchers/sfacg/__init__.py +0 -14
  232. novel_downloader/core/fetchers/sfacg/browser.py +0 -189
  233. novel_downloader/core/fetchers/yamibo/__init__.py +0 -14
  234. novel_downloader/core/fetchers/yamibo/browser.py +0 -229
  235. novel_downloader/core/parsers/biquge/__init__.py +0 -10
  236. novel_downloader/core/parsers/biquge/main_parser.py +0 -134
  237. novel_downloader/core/parsers/common/__init__.py +0 -13
  238. novel_downloader/core/parsers/common/helper.py +0 -323
  239. novel_downloader/core/parsers/common/main_parser.py +0 -106
  240. novel_downloader/core/parsers/esjzone/__init__.py +0 -10
  241. novel_downloader/core/parsers/linovelib/__init__.py +0 -10
  242. novel_downloader/core/parsers/qianbi/__init__.py +0 -10
  243. novel_downloader/core/parsers/sfacg/__init__.py +0 -10
  244. novel_downloader/core/parsers/yamibo/__init__.py +0 -10
  245. novel_downloader/core/parsers/yamibo/main_parser.py +0 -194
  246. novel_downloader/models/browser.py +0 -21
  247. novel_downloader/models/chapter.py +0 -25
  248. novel_downloader/models/site_rules.py +0 -99
  249. novel_downloader/models/tasks.py +0 -33
  250. novel_downloader/models/types.py +0 -15
  251. novel_downloader/resources/css_styles/volume-intro.css +0 -56
  252. novel_downloader/resources/json/replace_word_map.json +0 -4
  253. novel_downloader/resources/text/blacklist.txt +0 -22
  254. novel_downloader/tui/__init__.py +0 -7
  255. novel_downloader/tui/app.py +0 -32
  256. novel_downloader/tui/main.py +0 -17
  257. novel_downloader/tui/screens/__init__.py +0 -14
  258. novel_downloader/tui/screens/home.py +0 -198
  259. novel_downloader/tui/screens/login.py +0 -74
  260. novel_downloader/tui/styles/home_layout.tcss +0 -79
  261. novel_downloader/tui/widgets/richlog_handler.py +0 -24
  262. novel_downloader/utils/cache.py +0 -24
  263. novel_downloader/utils/fontocr/__init__.py +0 -22
  264. novel_downloader/utils/fontocr/model_loader.py +0 -69
  265. novel_downloader/utils/fontocr/ocr_v1.py +0 -303
  266. novel_downloader/utils/fontocr/ocr_v2.py +0 -752
  267. novel_downloader/utils/hash_store.py +0 -279
  268. novel_downloader/utils/hash_utils.py +0 -103
  269. novel_downloader/utils/text_utils/chapter_formatting.py +0 -46
  270. novel_downloader/utils/text_utils/font_mapping.py +0 -28
  271. novel_downloader/utils/text_utils/text_cleaning.py +0 -107
  272. novel_downloader-1.4.5.dist-info/METADATA +0 -196
  273. novel_downloader-1.4.5.dist-info/RECORD +0 -165
  274. {novel_downloader-1.4.5.dist-info → novel_downloader-2.0.0.dist-info}/WHEEL +0 -0
  275. {novel_downloader-1.4.5.dist-info → novel_downloader-2.0.0.dist-info}/licenses/LICENSE +0 -0
  276. {novel_downloader-1.4.5.dist-info → novel_downloader-2.0.0.dist-info}/top_level.txt +0 -0
@@ -1,752 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- novel_downloader.utils.fontocr.ocr_v2
4
- -------------------------------------
5
-
6
- This class provides utility methods for optical character recognition (OCR)
7
- and font mapping, primarily used for decrypting custom font encryption
8
- on web pages (e.g., the Qidian website).
9
- """
10
-
11
- import json
12
- import logging
13
- import math
14
- import os
15
- from collections.abc import Generator
16
- from pathlib import Path
17
- from typing import Any, TypeVar
18
-
19
- import cv2
20
- import numpy as np
21
- import paddle
22
- from fontTools.ttLib import TTFont
23
- from paddle.inference import Config
24
- from paddle.inference import create_predictor as _create_predictor
25
- from PIL import Image, ImageDraw, ImageFont
26
- from PIL.Image import Transpose
27
-
28
- try:
29
- # pip install cupy-cuda11x
30
- import cupy as array_backend # GPU acceleration
31
- except ImportError:
32
- import numpy as array_backend # CPU only
33
-
34
- from novel_downloader.utils.constants import (
35
- REC_CHAR_MODEL_FILES,
36
- REC_IMAGE_SHAPE_MAP,
37
- )
38
- from novel_downloader.utils.hash_store import img_hash_store
39
-
40
- from .model_loader import (
41
- get_rec_char_vector_dir,
42
- get_rec_chinese_char_model_dir,
43
- )
44
-
45
- T = TypeVar("T")
46
- logger = logging.getLogger(__name__)
47
-
48
-
49
- class CTCLabelDecode:
50
- """
51
- Convert between text-index and text-label for CTC-based models.
52
-
53
- :param character_dict_path: Path to the file containing characters, one per line.
54
- :param beg_str: Token representing the start of sequence.
55
- :param end_str: Token representing the end of sequence.
56
- """
57
-
58
- __slots__ = ("idx_to_char", "char_to_idx", "blank_id", "beg_str", "end_str")
59
-
60
- def __init__(
61
- self,
62
- character_dict_path: str | Path,
63
- beg_str: str = "sos",
64
- end_str: str = "eos",
65
- ):
66
- # Store special tokens
67
- self.beg_str = beg_str
68
- self.end_str = end_str
69
-
70
- # Read and clean character list (skip empty lines)
71
- path = Path(character_dict_path)
72
- chars = [
73
- line.strip()
74
- for line in path.read_text(encoding="utf-8").splitlines()
75
- if line.strip()
76
- ]
77
-
78
- # Reserve index 0 for the CTC blank token, then actual characters
79
- self.idx_to_char: list[str] = ["blank"] + chars
80
- self.blank_id: int = 0
81
-
82
- # Build reverse mapping from character to index
83
- self.char_to_idx = {ch: i for i, ch in enumerate(self.idx_to_char)}
84
-
85
- def decode(
86
- self,
87
- text_indices: np.ndarray,
88
- text_probs: np.ndarray | None = None,
89
- ) -> list[tuple[str, float]]:
90
- """
91
- Decode index sequences to strings and compute average confidence.
92
-
93
- :param text_indices: (batch_size, seq_len) class indices.
94
- :param text_probs: Optional per-step probabilities, same shape.
95
- :return: List of (string, avg_confidence) per sample.
96
- """
97
- results: list[tuple[str, float]] = []
98
- batch_size = text_indices.shape[0]
99
-
100
- for i in range(batch_size):
101
- seq = text_indices[i]
102
- # Collapse repeated tokens: keep first of any run
103
- mask = np.concatenate(([True], seq[1:] != seq[:-1]))
104
- # Remove blanks
105
- mask &= seq != self.blank_id
106
-
107
- # Map indices to characters
108
- chars = [self.idx_to_char[idx] for idx in seq[mask]]
109
-
110
- # Compute average confidence, or default to 1.0 if no probs provided
111
- if text_probs is not None:
112
- probs = text_probs[i][mask]
113
- avg_conf = float(probs.mean()) if probs.size else 0.0
114
- else:
115
- avg_conf = 1.0
116
-
117
- results.append(("".join(chars), avg_conf))
118
-
119
- return results
120
-
121
- def __call__(self, preds: Any) -> list[tuple[str, float]]:
122
- """
123
- Decode raw model outputs to final text labels and confidences.
124
-
125
- :param preds: Model output array/tensor of shape (batch, seq_len, num_classes),
126
- or a tuple/list whose last element is that array.
127
- :returns: A list of (decoded_string, average_confidence).
128
- """
129
- # If passed as (logits, ...), take the last element
130
- if isinstance(preds, (tuple | list)):
131
- preds = preds[-1]
132
-
133
- # Convert framework tensor to numpy if needed
134
- if hasattr(preds, "numpy"):
135
- preds = preds.numpy()
136
-
137
- # Get the most likely class index and its probability
138
- text_idx = preds.argmax(axis=2)
139
- text_prob = preds.max(axis=2)
140
-
141
- return self.decode(text_idx, text_prob)
142
-
143
-
144
- class TextRecognizer:
145
- def __init__(
146
- self,
147
- rec_model_dir: str,
148
- rec_image_shape: str,
149
- rec_batch_num: int,
150
- rec_char_dict_path: str,
151
- use_gpu: bool = False,
152
- gpu_mem: int = 500,
153
- gpu_id: int | None = None,
154
- ):
155
- self.rec_batch_num = int(rec_batch_num)
156
- self.rec_image_shape = tuple(map(int, rec_image_shape.split(","))) # (C, H, W)
157
- self.postprocess_op = CTCLabelDecode(
158
- character_dict_path=rec_char_dict_path,
159
- )
160
-
161
- self._create_predictor(
162
- model_dir=rec_model_dir,
163
- use_gpu=use_gpu,
164
- gpu_mem=gpu_mem,
165
- gpu_id=gpu_id,
166
- )
167
-
168
- def _get_infer_gpu_id(self) -> int:
169
- """
170
- Look at CUDA_VISIBLE_DEVICES or HIP_VISIBLE_DEVICES,
171
- pick the first entry and return as integer. Fallback to 0.
172
- """
173
- if not paddle.device.is_compiled_with_rocm:
174
- gpu_env = os.environ.get("CUDA_VISIBLE_DEVICES", "0")
175
- else:
176
- gpu_env = os.environ.get("HIP_VISIBLE_DEVICES", "0")
177
-
178
- first = gpu_env.split(",")[0]
179
- try:
180
- return int(first)
181
- except ValueError:
182
- return 0
183
-
184
- def _create_predictor(
185
- self,
186
- model_dir: str,
187
- use_gpu: bool,
188
- gpu_mem: int,
189
- gpu_id: int | None = None,
190
- ) -> None:
191
- """
192
- Internal helper to build the Paddle predictor + I/O handles
193
- """
194
- model_file = f"{model_dir}/inference.pdmodel"
195
- params_file = f"{model_dir}/inference.pdiparams"
196
-
197
- cfg = Config(model_file, params_file)
198
- if use_gpu:
199
- chosen = gpu_id if gpu_id is not None else self._get_infer_gpu_id()
200
- cfg.enable_use_gpu(gpu_mem, chosen)
201
- else:
202
- cfg.disable_gpu()
203
-
204
- # enable memory optim
205
- cfg.enable_memory_optim()
206
- cfg.disable_glog_info()
207
- # Use zero-copy feed/fetch for speed
208
- cfg.switch_use_feed_fetch_ops(False)
209
- # Enable IR optimizations
210
- cfg.switch_ir_optim(True)
211
-
212
- self.config = cfg
213
- self.predictor = _create_predictor(cfg)
214
-
215
- in_name = self.predictor.get_input_names()[0]
216
- self.input_tensor = self.predictor.get_input_handle(in_name)
217
-
218
- out_names = self.predictor.get_output_names()
219
- preferred = "softmax_0.tmp_0"
220
- selected = [preferred] if preferred in out_names else out_names
221
- self.output_tensors = [self.predictor.get_output_handle(n) for n in selected]
222
-
223
- def __call__(self, img_list: list[np.ndarray]) -> list[tuple[str, float]]:
224
- """
225
- Perform batch OCR on a list of images and return (text, confidence) tuples.
226
- """
227
- img_num = len(img_list)
228
- results: list[tuple[str, float]] = []
229
-
230
- C, H, W0 = self.rec_image_shape
231
-
232
- # Process images in batches
233
- for start in range(0, img_num, self.rec_batch_num):
234
- batch = img_list[start : start + self.rec_batch_num]
235
- # Compute width-to-height ratios for all images in the batch
236
- wh_ratios = [img.shape[1] / float(img.shape[0]) for img in batch]
237
- max_wh = max(W0 / H, *wh_ratios)
238
-
239
- B = len(batch)
240
- # Pre-allocate a numpy array for the batch
241
- batch_tensor = np.zeros(
242
- (B, C, H, int(math.ceil(H * max_wh))), dtype=np.float32
243
- )
244
-
245
- # Normalize and pad each image into the batch tensor
246
- for i, img in enumerate(batch):
247
- norm = self.resize_norm_img(img, max_wh)
248
- batch_tensor[i, :, :, : norm.shape[2]] = norm
249
-
250
- # Run inference
251
- self.input_tensor.copy_from_cpu(batch_tensor)
252
- self.predictor.run()
253
-
254
- # Retrieve and post-process outputs
255
- outputs = [t.copy_to_cpu() for t in self.output_tensors]
256
- preds = outputs[0] if len(outputs) == 1 else outputs
257
-
258
- rec_batch = self.postprocess_op(preds)
259
- results.extend(rec_batch)
260
-
261
- return results
262
-
263
- def resize_norm_img(self, img: np.ndarray, max_wh_ratio: float) -> np.ndarray:
264
- C, H, W0 = self.rec_image_shape
265
- if img.ndim == 2:
266
- # Convert grayscale images to RGB
267
- img = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB)
268
- assert (
269
- img.ndim == 3 and img.shape[2] == C
270
- ), f"Expect {C}-channel image, got {img.shape}"
271
-
272
- h, w = img.shape[:2]
273
- # Determine new width based on the height and max width-height ratio
274
- new_w = min(int(math.ceil(H * (w / h))), int(H * max_wh_ratio))
275
- resized = cv2.resize(img, (new_w, H)).astype("float32")
276
- # Change to CHW format and scale to [0,1]
277
- resized = resized.transpose(2, 0, 1) / 255.0
278
- # Normalize to [-1, 1]
279
- resized = (resized - 0.5) / 0.5
280
-
281
- return resized
282
-
283
-
284
- class FontOCRV2:
285
- """
286
- Version 2 of the FontOCR utility.
287
-
288
- :param use_freq: if True, weight scores by character frequency
289
- :param cache_dir: base path to store font-map JSON data
290
- :param threshold: minimum confidence threshold [0.0-1.0]
291
- :param font_debug: if True, dump per-char debug images under cache_dir
292
- """
293
-
294
- # Default constants
295
- CHAR_IMAGE_SIZE = 64
296
- CHAR_FONT_SIZE = 52
297
- _freq_weight = 0.05
298
-
299
- # shared resources
300
- _global_char_freq_db: dict[str, int] = {}
301
- _global_ocr: TextRecognizer | None = None
302
- _global_vec_db: np.ndarray | None = None
303
- _global_vec_label: tuple[str, ...] = ()
304
- _global_vec_shape: tuple[int, int] = (32, 32)
305
-
306
- def __init__(
307
- self,
308
- cache_dir: str | Path,
309
- use_freq: bool = False,
310
- use_ocr: bool = True,
311
- use_vec: bool = False,
312
- batch_size: int = 32,
313
- gpu_mem: int = 500,
314
- gpu_id: int | None = None,
315
- ocr_weight: float = 0.6,
316
- vec_weight: float = 0.4,
317
- ocr_version: str = "v1.0",
318
- threshold: float = 0.0,
319
- font_debug: bool = False,
320
- **kwargs: Any,
321
- ) -> None:
322
- self.use_freq = use_freq
323
- self.use_ocr = use_ocr
324
- self.use_vec = use_vec
325
- self.batch_size = batch_size
326
- self.gpu_mem = gpu_mem
327
- self.gpu_id = gpu_id
328
- self.ocr_weight = ocr_weight
329
- self.vec_weight = vec_weight
330
- self.ocr_version = ocr_version
331
- self.threshold = threshold
332
- self.font_debug = font_debug
333
- self._max_freq = 5
334
-
335
- self._cache_dir = Path(cache_dir)
336
- self._cache_dir.mkdir(parents=True, exist_ok=True)
337
- self._fixed_map_dir = self._cache_dir / "fixed_font_map"
338
- self._fixed_map_dir.mkdir(parents=True, exist_ok=True)
339
-
340
- if font_debug:
341
- self._debug_dir = self._cache_dir / "font_debug" / "badcase"
342
- self._debug_dir.mkdir(parents=True, exist_ok=True)
343
-
344
- # load shared OCR + frequency DB
345
- if self.use_ocr:
346
- self._load_ocr_model()
347
- if self.use_freq:
348
- self._load_char_freq_db()
349
- if self.use_vec:
350
- self._load_char_vec_db()
351
-
352
- def _load_ocr_model(self) -> None:
353
- """
354
- Initialize the shared PaddleOCR model if not already loaded.
355
- """
356
- if FontOCRV2._global_ocr is not None:
357
- return
358
-
359
- gpu_available = paddle.device.is_compiled_with_cuda()
360
- self._char_model_dir = get_rec_chinese_char_model_dir(self.ocr_version)
361
-
362
- for fname in REC_CHAR_MODEL_FILES:
363
- full_path = self._char_model_dir / fname
364
- if not full_path.exists():
365
- raise FileNotFoundError(f"[FontOCR] Required file missing: {full_path}")
366
-
367
- char_dict_file = self._char_model_dir / "rec_custom_keys.txt"
368
- FontOCRV2._global_ocr = TextRecognizer(
369
- rec_model_dir=str(self._char_model_dir),
370
- rec_char_dict_path=str(char_dict_file),
371
- rec_image_shape=REC_IMAGE_SHAPE_MAP[self.ocr_version],
372
- rec_batch_num=self.batch_size,
373
- use_gpu=gpu_available,
374
- gpu_mem=self.gpu_mem,
375
- gpu_id=self.gpu_id,
376
- )
377
-
378
- def _load_char_freq_db(self) -> bool:
379
- """
380
- Loads character frequency data from a JSON file and
381
- assigns it to the instance variable.
382
-
383
- :return: True if successfully loaded, False otherwise.
384
- """
385
- if FontOCRV2._global_char_freq_db is not None:
386
- return True
387
-
388
- try:
389
- char_freq_map_file = self._char_model_dir / "char_freq.json"
390
- with char_freq_map_file.open("r", encoding="utf-8") as f:
391
- FontOCRV2._global_char_freq_db = json.load(f)
392
- self._max_freq = max(FontOCRV2._global_char_freq_db.values())
393
- return True
394
- except Exception as e:
395
- logger.warning("[FontOCR] Failed to load char freq DB: %s", e)
396
- return False
397
-
398
- def _load_char_vec_db(self) -> None:
399
- """
400
- Initialize the shared Char Vector if not already loaded.
401
- """
402
- if FontOCRV2._global_vec_db is not None:
403
- return
404
-
405
- char_vec_dir = get_rec_char_vector_dir(self.ocr_version)
406
- char_vec_npy_file = char_vec_dir / "char_vectors.npy"
407
- char_vec_label_file = char_vec_dir / "char_vectors.txt"
408
-
409
- # Load and normalize vector database
410
- vec_db = array_backend.load(char_vec_npy_file)
411
- _, dim = vec_db.shape
412
- side = int(np.sqrt(dim))
413
- FontOCRV2._global_vec_shape = (side, side)
414
-
415
- norm = array_backend.linalg.norm(vec_db, axis=1, keepdims=True) + 1e-6
416
- FontOCRV2._global_vec_db = vec_db / norm
417
-
418
- # Load corresponding labels
419
- with open(char_vec_label_file, encoding="utf-8") as f:
420
- FontOCRV2._global_vec_label = tuple(line.strip() for line in f)
421
-
422
- @staticmethod
423
- def _generate_char_image(
424
- char: str,
425
- render_font: ImageFont.FreeTypeFont,
426
- is_reflect: bool = False,
427
- ) -> Image.Image | None:
428
- """
429
- Render a single character into a square image.
430
- If is_reflect is True, flip horizontally.
431
- """
432
- size = FontOCRV2.CHAR_IMAGE_SIZE
433
- img = Image.new("L", (size, size), color=255)
434
- draw = ImageDraw.Draw(img)
435
- bbox = draw.textbbox((0, 0), char, font=render_font)
436
- w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
437
- x = (size - w) // 2 - bbox[0]
438
- y = (size - h) // 2 - bbox[1]
439
- draw.text((x, y), char, fill=0, font=render_font)
440
- if is_reflect:
441
- img = img.transpose(Transpose.FLIP_LEFT_RIGHT)
442
-
443
- img_np = np.array(img)
444
- if np.unique(img_np).size == 1:
445
- return None
446
-
447
- return img
448
-
449
- def match_text_by_embedding(
450
- self,
451
- images: Image.Image | list[Image.Image],
452
- top_k: int = 1,
453
- ) -> list[tuple[str, float]] | list[list[tuple[str, float]]]:
454
- """
455
- Match input image to precomputed character embeddings using cosine similarity.
456
-
457
- :param images: a PIL.Image or a list of PIL.Image to match
458
- :param top_k: int, how many top matches to return
459
-
460
- :return:
461
- - If a single Image was passed in,
462
- returns a list of (label, score) tuples sorted descending.
463
-
464
- - If a list of Images was passed in, returns a list of such lists.
465
- """
466
- if self._global_vec_db is None:
467
- return []
468
- try:
469
- imgs: list[Image.Image] = (
470
- [images] if isinstance(images, Image.Image) else images
471
- )
472
-
473
- # Convert images to normalized 1D vectors
474
- vecs = []
475
- for img in imgs:
476
- pil_gray = img.convert("L").resize(self._global_vec_shape)
477
- arr = np.asarray(pil_gray, dtype=np.float32) / 255.0
478
- v = array_backend.asarray(arr).ravel()
479
- v /= array_backend.linalg.norm(v) + 1e-6
480
- vecs.append(v)
481
-
482
- batch = array_backend.stack(vecs, axis=0) # (N, D)
483
- # Compute all cosine similarities in one batch:
484
- sims_batch = batch.dot(self._global_vec_db.T) # (N, num_chars)
485
-
486
- all_results: list[list[tuple[str, float]]] = []
487
- for sims in sims_batch:
488
- k = min(top_k, sims.shape[0])
489
- top_unsorted = array_backend.argpartition(-sims, k - 1)[:k]
490
- top_idx = top_unsorted[array_backend.argsort(-sims[top_unsorted])]
491
- results = [
492
- (self._global_vec_label[int(i)], float(sims[int(i)]))
493
- for i in top_idx
494
- ]
495
- all_results.append(results)
496
-
497
- # Unwrap single-image case
498
- return all_results[0] if isinstance(images, Image.Image) else all_results
499
- except Exception as e:
500
- logger.warning("[FontOCR] Error: %s", e)
501
- default = [("", 0.0)]
502
- if isinstance(images, Image.Image):
503
- return default
504
- else:
505
- return [default for _ in range(len(images))]
506
-
507
- def run_ocr_on_images(
508
- self,
509
- images: Image.Image | list[Image.Image],
510
- ) -> tuple[str, float] | list[tuple[str, float]]:
511
- """
512
- Run OCR on one or more PIL.Image(s) and return recognized text with confidence
513
-
514
- :param images: A single PIL.Image or list of PIL.Images to recognize.
515
- :return:
516
- - If a single image is passed, returns Tuple[str, float].
517
-
518
- - If a list is passed, returns List[Tuple[str, float]].
519
- """
520
- if self._global_ocr is None:
521
- return []
522
- try:
523
- # Normalize input to a list of numpy arrays (RGB)
524
- img_list = [images] if isinstance(images, Image.Image) else images
525
- np_imgs: list[np.ndarray] = [
526
- np.array(img.convert("RGB")) for img in img_list
527
- ]
528
-
529
- # Run OCR
530
- ocr_results = self._global_ocr(np_imgs)
531
-
532
- # Return result depending on input type
533
- return ocr_results if isinstance(images, list) else ocr_results[0]
534
-
535
- except Exception as e:
536
- logger.warning("[FontOCR] OCR failed: %s", e)
537
- fallback = ("", 0.0)
538
- return (
539
- fallback
540
- if isinstance(images, Image.Image)
541
- else [fallback for _ in images]
542
- )
543
-
544
- def query(
545
- self,
546
- images: Image.Image | list[Image.Image],
547
- top_k: int = 3,
548
- ) -> list[tuple[str, float]] | list[list[tuple[str, float]]]:
549
- """
550
- For each input image, run OCR + embedding match, fuse scores,
551
- and return a sorted list of (char, score) above self.threshold.
552
- """
553
- # normalize to list
554
- single = isinstance(images, Image.Image)
555
- imgs: list[Image.Image] = [images] if single else images
556
-
557
- # try the hash store
558
- hash_batch = [img_hash_store.query(img, k=top_k) or [] for img in imgs]
559
-
560
- fallback_indices = [i for i, h in enumerate(hash_batch) if not h]
561
- fallback_imgs = [imgs[i] for i in fallback_indices]
562
-
563
- # OCR scores
564
- raw_ocr: tuple[str, float] | list[tuple[str, float]] = (
565
- self.run_ocr_on_images(fallback_imgs)
566
- if (self.use_ocr and fallback_imgs)
567
- else []
568
- )
569
- if isinstance(raw_ocr, tuple):
570
- ocr_fallback: list[tuple[str, float]] = [raw_ocr]
571
- else:
572
- ocr_fallback = raw_ocr
573
-
574
- # Vec-embedding scores
575
- raw_vec: list[tuple[str, float]] | list[list[tuple[str, float]]] = (
576
- self.match_text_by_embedding(fallback_imgs, top_k=top_k)
577
- if (self.use_vec and fallback_imgs)
578
- else []
579
- )
580
- if raw_vec and isinstance(raw_vec[0], tuple):
581
- vec_fallback: list[list[tuple[str, float]]] = [raw_vec] # type: ignore
582
- else:
583
- vec_fallback = raw_vec # type: ignore
584
-
585
- # Fuse OCR+vector for the fallback set
586
- fused_fallback: list[list[tuple[str, float]]] = []
587
- for ocr_preds, vec_preds in zip(ocr_fallback, vec_fallback, strict=False):
588
- scores: dict[str, float] = {}
589
-
590
- # OCR weight
591
- if ocr_preds:
592
- ch, s = ocr_preds
593
- scores[ch] = scores.get(ch, 0.0) + self.ocr_weight * s
594
- logger.debug(
595
- "[FontOCR] OCR with weight: scores[%s] = %s", ch, scores[ch]
596
- )
597
- # Vec weight
598
- for ch, s in vec_preds:
599
- scores[ch] = scores.get(ch, 0.0) + self.vec_weight * s
600
- logger.debug(
601
- "[FontOCR] Vec with weight: scores[%s] = %s", ch, scores[ch]
602
- )
603
- # Optional frequency
604
- if self.use_freq:
605
- for ch in list(scores):
606
- level = self._global_char_freq_db.get(ch, self._max_freq)
607
- freq_score = (self._max_freq - level) / max(1, self._max_freq)
608
- scores[ch] += self._freq_weight * freq_score
609
- logger.debug(
610
- "[FontOCR] After Freq weight: scores[%s] = %s", ch, scores[ch]
611
- )
612
-
613
- # Threshold + sort + top_k
614
- filtered = [(ch, sc) for ch, sc in scores.items() if sc >= self.threshold]
615
- filtered.sort(key=lambda x: -x[1])
616
-
617
- fused_fallback.append(filtered[:top_k])
618
-
619
- # Recombine hash hits + fallback in original order
620
- fused_batch: list[list[tuple[str, float]]] = []
621
- fallback_iter = iter(fused_fallback)
622
- for h_preds in hash_batch:
623
- if h_preds:
624
- fused_batch.append(h_preds)
625
- else:
626
- fused_batch.append(next(fallback_iter))
627
-
628
- # Unwrap single-image case
629
- return fused_batch[0] if single else fused_batch
630
-
631
- def _chunked(self, seq: list[T], size: int) -> Generator[list[T], None, None]:
632
- """Yield successive chunks of `seq` of length `size`."""
633
- for i in range(0, len(seq), size):
634
- yield seq[i : i + size]
635
-
636
- def generate_font_map(
637
- self,
638
- fixed_font_path: str | Path,
639
- random_font_path: str | Path,
640
- char_set: set[str],
641
- refl_set: set[str],
642
- chapter_id: str | None = None,
643
- ) -> dict[str, str]:
644
- """
645
- Generates a mapping from encrypted (randomized) font characters to
646
- their real recognized characters by rendering and OCR-based matching.
647
-
648
- :param fixed_font_path: Path to the reference (fixed) font.
649
- :param random_font_path: Path to the obfuscated (random) font.
650
- :param char_set: Characters to process normally.
651
- :param refl_set: Characters to process as horizontally flipped.
652
- :param chapter_id: Chapter ID
653
-
654
- :returns mapping_result: { obf_char: real_char, ... }
655
- """
656
- mapping_result: dict[str, str] = {}
657
- fixed_map_file = self._fixed_map_dir / f"{Path(fixed_font_path).stem}.json"
658
-
659
- # load existing cache
660
- try:
661
- with open(fixed_map_file, encoding="utf-8") as f:
662
- fixed_map = json.load(f)
663
- except Exception:
664
- fixed_map = {}
665
-
666
- # prepare font renderers and cmap sets
667
- try:
668
- fixed_ttf = TTFont(fixed_font_path)
669
- fixed_chars = {chr(c) for c in fixed_ttf.getBestCmap()}
670
- fixed_font = ImageFont.truetype(str(fixed_font_path), self.CHAR_FONT_SIZE)
671
-
672
- random_ttf = TTFont(random_font_path)
673
- random_chars = {chr(c) for c in random_ttf.getBestCmap()}
674
- random_font = ImageFont.truetype(str(random_font_path), self.CHAR_FONT_SIZE)
675
- except Exception as e:
676
- logger.error("[FontOCR] Failed to load TTF fonts: %s", e)
677
- return mapping_result
678
-
679
- def _render_batch(
680
- chars: list[tuple[str, bool]]
681
- ) -> list[tuple[str, Image.Image]]:
682
- out = []
683
- for ch, reflect in chars:
684
- if ch in fixed_chars:
685
- font = fixed_font
686
- elif ch in random_chars:
687
- font = random_font
688
- else:
689
- continue
690
- img = self._generate_char_image(ch, font, reflect)
691
- if img is not None:
692
- out.append((ch, img))
693
- return out
694
-
695
- # process normal and reflected sets together
696
- debug_idx = 1
697
- for chars, reflect in [(list(char_set), False), (list(refl_set), True)]:
698
- for batch_chars in self._chunked(chars, self.batch_size):
699
- # render all images in this batch
700
- to_render = [(ch, reflect) for ch in batch_chars]
701
- rendered = _render_batch(to_render)
702
- if not rendered:
703
- continue
704
-
705
- # query OCR+vec simultaneously
706
- imgs_to_query = [img for (ch, img) in rendered]
707
- fused_raw = self.query(imgs_to_query, top_k=3)
708
- if isinstance(fused_raw[0], tuple):
709
- fused: list[list[tuple[str, float]]] = [fused_raw] # type: ignore
710
- else:
711
- fused = fused_raw # type: ignore
712
-
713
- # pick best per char, apply threshold + cache
714
- for (ch, img), preds in zip(rendered, fused, strict=False):
715
- if ch in fixed_map:
716
- mapping_result[ch] = fixed_map[ch]
717
- logger.debug(
718
- "[FontOCR] Using cached mapping: '%s' -> '%s'",
719
- ch,
720
- fixed_map[ch],
721
- )
722
- continue
723
- if not preds:
724
- if self.font_debug and chapter_id:
725
- dbg_path = (
726
- self._debug_dir / f"{chapter_id}_{debug_idx:04d}.png"
727
- )
728
- img.save(dbg_path)
729
- logger.debug(
730
- "[FontOCR] Saved debug image for '%s': %s", ch, dbg_path
731
- )
732
- debug_idx += 1
733
- continue
734
- real_char, _ = preds[0]
735
- mapping_result[ch] = real_char
736
- fixed_map[ch] = real_char
737
- if self.font_debug:
738
- logger.debug(
739
- "[FontOCR] Prediction for char '%s': top_pred='%s'",
740
- ch,
741
- real_char,
742
- )
743
- logger.debug("[FontOCR] All predictions: %s", preds)
744
-
745
- # persist updated fixed_map
746
- try:
747
- with open(fixed_map_file, "w", encoding="utf-8") as f:
748
- json.dump(fixed_map, f, ensure_ascii=False, indent=2)
749
- except Exception as e:
750
- logger.error("[FontOCR] Failed to save fixed map: %s", e)
751
-
752
- return mapping_result