ScreenSubtitleReader 0.1.3__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 (31) hide show
  1. ScreenSubtitleReader/__init__.py +1 -0
  2. ScreenSubtitleReader/__main__.py +10 -0
  3. ScreenSubtitleReader/core/__init__.py +0 -0
  4. ScreenSubtitleReader/core/__main__.py +10 -0
  5. ScreenSubtitleReader/core/ocr_fither.py +72 -0
  6. ScreenSubtitleReader/core/screen_reader.py +32 -0
  7. ScreenSubtitleReader/core/string_fither.py +5 -0
  8. ScreenSubtitleReader/ocr_module/__init__.py +1 -0
  9. ScreenSubtitleReader/ocr_module/angnet/__init__.py +1 -0
  10. ScreenSubtitleReader/ocr_module/angnet/angle.py +59 -0
  11. ScreenSubtitleReader/ocr_module/config.py +34 -0
  12. ScreenSubtitleReader/ocr_module/crnn/CRNN.py +102 -0
  13. ScreenSubtitleReader/ocr_module/crnn/__init__.py +1 -0
  14. ScreenSubtitleReader/ocr_module/crnn/keys.py +1 -0
  15. ScreenSubtitleReader/ocr_module/crnn/util.py +89 -0
  16. ScreenSubtitleReader/ocr_module/dbnet/dbnet_infer.py +97 -0
  17. ScreenSubtitleReader/ocr_module/dbnet/decode.py +125 -0
  18. ScreenSubtitleReader/ocr_module/model.py +128 -0
  19. ScreenSubtitleReader/ocr_module/models/__init__.py +1 -0
  20. ScreenSubtitleReader/ocr_module/models/angle_net.onnx +0 -0
  21. ScreenSubtitleReader/ocr_module/models/crnn_lite_lstm.onnx +0 -0
  22. ScreenSubtitleReader/ocr_module/models/dbnet.onnx +0 -0
  23. ScreenSubtitleReader/ocr_module/utils.py +173 -0
  24. ScreenSubtitleReader/setting/setting.py +98 -0
  25. ScreenSubtitleReader/speaker/speaker.py +60 -0
  26. ScreenSubtitleReader/ui/ui.py +101 -0
  27. screensubtitlereader-0.1.3.dist-info/METADATA +21 -0
  28. screensubtitlereader-0.1.3.dist-info/RECORD +31 -0
  29. screensubtitlereader-0.1.3.dist-info/WHEEL +5 -0
  30. screensubtitlereader-0.1.3.dist-info/entry_points.txt +2 -0
  31. screensubtitlereader-0.1.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,125 @@
1
+ import cv2
2
+ import numpy as np
3
+ import pyclipper
4
+ from shapely.geometry import Polygon
5
+
6
+
7
+ class SegDetectorRepresenter:
8
+ def __init__(self, thresh=0.3, box_thresh=0.5, max_candidates=1000, unclip_ratio=2.0):
9
+ self.min_size = 3
10
+ self.thresh = thresh
11
+ self.box_thresh = box_thresh
12
+ self.max_candidates = max_candidates
13
+ self.unclip_ratio = unclip_ratio
14
+
15
+ def __call__(self, pred, height, width):
16
+ """
17
+ batch: (image, polygons, ignore_tags
18
+ batch: a dict produced by dataloaders.
19
+ image: tensor of shape (N, C, H, W).
20
+ polygons: tensor of shape (N, K, 4, 2), the polygons of objective regions.
21
+ ignore_tags: tensor of shape (N, K), indicates whether a region is ignorable or not.
22
+ shape: the original shape of images.
23
+ filename: the original filenames of images.
24
+ pred:
25
+ binary: text region segmentation map, with shape (N, H, W)
26
+ thresh: [if exists] thresh hold prediction with shape (N, H, W)
27
+ thresh_binary: [if exists] binarized with threshhold, (N, H, W)
28
+ """
29
+
30
+ pred = pred[0, :, :]
31
+ segmentation = self.binarize(pred)
32
+
33
+ boxes, scores = self.boxes_from_bitmap(pred, segmentation, width, height)
34
+
35
+ return boxes, scores
36
+
37
+ def binarize(self, pred):
38
+ return pred > self.thresh
39
+
40
+ def boxes_from_bitmap(self, pred, bitmap, dest_width, dest_height):
41
+ """
42
+ _bitmap: single map with shape (H, W),
43
+ whose values are binarized as {0, 1}
44
+ """
45
+
46
+ assert len(bitmap.shape) == 2
47
+ # bitmap = _bitmap.cpu().numpy() # The first channel
48
+ # pred = pred.cpu().detach().numpy()
49
+ height, width = bitmap.shape
50
+ # print(bitmap)
51
+ # cv2.imwrite("./test/output/test.jpg",(bitmap * 255).astype(np.uint8))
52
+ contours, _ = cv2.findContours((bitmap * 255).astype(np.uint8), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
53
+ num_contours = min(len(contours), self.max_candidates)
54
+ boxes = np.zeros((num_contours, 4, 2), dtype=np.int16)
55
+ scores = np.zeros((num_contours,), dtype=np.float32)
56
+ rects = []
57
+ for index in range(num_contours):
58
+ contour = contours[index].squeeze(1)
59
+ points, sside = self.get_mini_boxes(contour)
60
+ if sside < self.min_size:
61
+ continue
62
+ points = np.array(points)
63
+ score = self.box_score_fast(pred, contour)
64
+ if self.box_thresh > score:
65
+ continue
66
+ # print(points)
67
+ box = self.unclip(points, unclip_ratio=self.unclip_ratio).reshape(-1, 1, 2)
68
+ # print(box)
69
+ box, sside = self.get_mini_boxes(box)
70
+ if sside < self.min_size + 2:
71
+ continue
72
+ box = np.array(box)
73
+ if not isinstance(dest_width, int):
74
+ dest_width = dest_width.item()
75
+ dest_height = dest_height.item()
76
+
77
+ box[:, 0] = np.clip(np.round(box[:, 0] / width * dest_width), 0, dest_width)
78
+ box[:, 1] = np.clip(np.round(box[:, 1] / height * dest_height), 0, dest_height)
79
+ boxes[index, :, :] = box.astype(np.int16)
80
+ scores[index] = score
81
+ return boxes, scores
82
+
83
+ def unclip(self, box, unclip_ratio=1.5):
84
+ poly = Polygon(box)
85
+
86
+ distance = poly.area * unclip_ratio / (poly.length )
87
+ offset = pyclipper.PyclipperOffset()
88
+ offset.AddPath(box, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON)
89
+ expanded = np.array(offset.Execute(distance))
90
+ return expanded
91
+
92
+ def get_mini_boxes(self, contour):
93
+ bounding_box = cv2.minAreaRect(contour)
94
+ points = sorted(list(cv2.boxPoints(bounding_box)), key=lambda x: x[0])
95
+
96
+ index_1, index_2, index_3, index_4 = 0, 1, 2, 3
97
+ if points[1][1] > points[0][1]:
98
+ index_1 = 0
99
+ index_4 = 1
100
+ else:
101
+ index_1 = 1
102
+ index_4 = 0
103
+ if points[3][1] > points[2][1]:
104
+ index_2 = 2
105
+ index_3 = 3
106
+ else:
107
+ index_2 = 3
108
+ index_3 = 2
109
+
110
+ box = [points[index_1], points[index_2], points[index_3], points[index_4]]
111
+ return box, min(bounding_box[1])
112
+
113
+ def box_score_fast(self, bitmap, _box):
114
+ h, w = bitmap.shape[:2]
115
+ box = _box.copy()
116
+ xmin = np.clip(np.floor(box[:, 0].min()).astype(int), 0, w - 1)
117
+ xmax = np.clip(np.ceil(box[:, 0].max()).astype(int), 0, w - 1)
118
+ ymin = np.clip(np.floor(box[:, 1].min()).astype(int), 0, h - 1)
119
+ ymax = np.clip(np.ceil(box[:, 1].max()).astype(int), 0, h - 1)
120
+
121
+ mask = np.zeros((ymax - ymin + 1, xmax - xmin + 1), dtype=np.uint8)
122
+ box[:, 0] = box[:, 0] - xmin
123
+ box[:, 1] = box[:, 1] - ymin
124
+ cv2.fillPoly(mask, box.reshape(1, -1, 2).astype(np.int32), 1)
125
+ return cv2.mean(bitmap[ymin:ymax + 1, xmin:xmax + 1], mask)[0]
@@ -0,0 +1,128 @@
1
+ from .config import *
2
+ from .crnn import CRNNHandle
3
+ from .angnet import AngleNetHandle
4
+ from .utils import draw_bbox, crop_rect, sorted_boxes, get_rotate_crop_image
5
+ from PIL import Image
6
+ import numpy as np
7
+ import cv2
8
+ import copy
9
+ from .dbnet.dbnet_infer import DBNET
10
+ import time
11
+ import traceback
12
+
13
+ class OcrHandle(object):
14
+ def __init__(self):
15
+ self.text_handle = DBNET(model_path)
16
+ self.crnn_handle = CRNNHandle(crnn_model_path)
17
+ if angle_detect:
18
+ self.angle_handle = AngleNetHandle(angle_net_path)
19
+
20
+
21
+ def crnnRecWithBox(self,im, boxes_list,score_list):
22
+ """
23
+ crnn模型,ocr识别
24
+ @@model,
25
+ @@converter,
26
+ @@im:Array
27
+ @@text_recs:text box
28
+ @@ifIm:是否输出box对应的img
29
+
30
+ """
31
+ results = []
32
+ boxes_list = sorted_boxes(np.array(boxes_list))
33
+
34
+ line_imgs = []
35
+ for index, (box, score) in enumerate(zip(boxes_list[:angle_detect_num], score_list[:angle_detect_num])):
36
+ tmp_box = copy.deepcopy(box)
37
+ partImg_array = get_rotate_crop_image(im, tmp_box.astype(np.float32))
38
+ partImg = Image.fromarray(partImg_array).convert("RGB")
39
+ line_imgs.append(partImg)
40
+
41
+ angle_res = False
42
+ if angle_detect:
43
+ angle_res = self.angle_handle.predict_rbgs(line_imgs)
44
+
45
+ count = 1
46
+ for index, (box ,score) in enumerate(zip(boxes_list,score_list)):
47
+
48
+ tmp_box = copy.deepcopy(box)
49
+ partImg_array = get_rotate_crop_image(im, tmp_box.astype(np.float32))
50
+
51
+
52
+ partImg = Image.fromarray(partImg_array).convert("RGB")
53
+
54
+ if angle_detect and angle_res:
55
+ partImg = partImg.rotate(180)
56
+
57
+
58
+ if not is_rgb:
59
+ partImg = partImg.convert('L')
60
+
61
+ try:
62
+ if is_rgb:
63
+ simPred = self.crnn_handle.predict_rbg(partImg) ##识别的文本
64
+ else:
65
+ simPred = self.crnn_handle.predict(partImg) ##识别的文本
66
+ except Exception as e:
67
+ print(traceback.format_exc())
68
+ continue
69
+
70
+ if simPred.strip() != '':
71
+ results.append([tmp_box,simPred,score])
72
+ count += 1
73
+
74
+ return results
75
+
76
+
77
+ def text_predict(self,img,short_size):
78
+ boxes_list, score_list = self.text_handle.process(np.asarray(img).astype(np.uint8),short_size=short_size)
79
+ result = self.crnnRecWithBox(np.array(img), boxes_list,score_list)
80
+
81
+ return result
82
+
83
+
84
+ if __name__ == "__main__":
85
+ from PIL import ImageGrab
86
+
87
+
88
+ print("正在初始化 OCR 模型,请稍候...")
89
+ try:
90
+ ocr = OcrHandle()
91
+ print("✅ 模型初始化成功!")
92
+ except Exception as e:
93
+ print(f"❌ 模型初始化失败。\n错误信息: {e}")
94
+ exit(1)
95
+
96
+ print("📸 正在截取屏幕...")
97
+ # 使用 ImageGrab 截取全屏
98
+ im = ImageGrab.grab()
99
+
100
+ # 【关键修正】颜色通道转换:
101
+ # ImageGrab 截取的是 RGB 格式的 PIL 图像,而原项目默认使用 cv2.imread (BGR 格式)。
102
+ # 为了保证模型识别效果不出偏差,这里将其转为 OpenCV 标准的 BGR numpy 数组
103
+ img = cv2.cvtColor(np.array(im), cv2.COLOR_RGB2BGR)
104
+
105
+ print("🔍 开始执行 OCR 识别...")
106
+ start_time = time.time()
107
+
108
+ # 调用识别接口 (如果是全屏高分辨率截图,short_size 可以保持 960,或者视情况调高)
109
+ results = ocr.text_predict(img, short_size=960)
110
+
111
+ cost_time = time.time() - start_time
112
+ print(f"✅ 识别完成!耗时: {cost_time:.3f} 秒")
113
+ print("=" * 50)
114
+
115
+ if not results:
116
+ print("未能识别到任何文字。")
117
+ else:
118
+ print("部分识别结果如下 (最多显示前20条):")
119
+ # 截屏识别出来的文本通常很多,测试时为了清爽只打印前 20 条
120
+ for i, res in enumerate(results[:20]):
121
+ text = res[1]
122
+ score = res[2]
123
+ print(f"文本: {text.strip()} | 置信度: {score:.4f}")
124
+
125
+ if len(results) > 20:
126
+ print(f"... (还有 {len(results) - 20} 条未显示)")
127
+
128
+ print("=" * 50)
@@ -0,0 +1 @@
1
+ """Packaged ONNX model files for chineseocr_lite."""
@@ -0,0 +1,173 @@
1
+ import numpy as np
2
+ import cv2
3
+ from PIL import Image
4
+
5
+
6
+ def rotate_cut_img(im, degree, x_center, y_center, w, h, leftAdjust=False, rightAdjust=False, alph=0.2):
7
+ # degree_ = degree * 180.0 / np.pi
8
+ # print(degree_)
9
+ right = 0
10
+ left = 0
11
+ if rightAdjust:
12
+ right = 1
13
+ if leftAdjust:
14
+ left = 1
15
+
16
+ box = (max(1, x_center - w / 2 - left * alph * (w / 2))
17
+ , y_center - h / 2, # ymin
18
+ min(x_center + w / 2 + right * alph * (w / 2), im.size[0] - 1)
19
+ , y_center + h / 2) # ymax
20
+
21
+ newW = box[2] - box[0]
22
+ newH = box[3] - box[1]
23
+ tmpImg = im.rotate(degree, center=(x_center, y_center)).crop(box)
24
+
25
+ return tmpImg, newW, newH
26
+
27
+
28
+ def crop_rect(img, rect, alph=0.15):
29
+ img = np.asarray(img)
30
+ # get the parameter of the small rectangle
31
+ # print("rect!")
32
+ # print(rect)
33
+ center, size, angle = rect[0], rect[1], rect[2]
34
+ min_size = min(size)
35
+
36
+ if angle > -45:
37
+ center, size = tuple(map(int, center)), tuple(map(int, size))
38
+ # angle-=270
39
+ size = (int(size[0] + min_size * alph), int(size[1] + min_size * alph))
40
+ height, width = img.shape[0], img.shape[1]
41
+ M = cv2.getRotationMatrix2D(center, angle, 1)
42
+ # size = tuple([int(rect[1][1]), int(rect[1][0])])
43
+ img_rot = cv2.warpAffine(img, M, (width, height))
44
+ # cv2.imwrite("debug_im/img_rot.jpg", img_rot)
45
+ img_crop = cv2.getRectSubPix(img_rot, size, center)
46
+ else:
47
+ center = tuple(map(int, center))
48
+ size = tuple([int(rect[1][1]), int(rect[1][0])])
49
+ size = (int(size[0] + min_size * alph), int(size[1] + min_size * alph))
50
+ angle -= 270
51
+ height, width = img.shape[0], img.shape[1]
52
+ M = cv2.getRotationMatrix2D(center, angle, 1)
53
+ img_rot = cv2.warpAffine(img, M, (width, height))
54
+ # cv2.imwrite("debug_im/img_rot.jpg", img_rot)
55
+ img_crop = cv2.getRectSubPix(img_rot, size, center)
56
+ img_crop = Image.fromarray(img_crop)
57
+ return img_crop
58
+
59
+
60
+ def draw_bbox(img_path, result, color=(255, 0, 0), thickness=2):
61
+ if isinstance(img_path, str):
62
+ img_path = cv2.imread(img_path)
63
+ # img_path = cv2.cvtColor(img_path, cv2.COLOR_BGR2RGB)
64
+ img_path = img_path.copy()
65
+ for point in result:
66
+ point = point.astype(int)
67
+ cv2.line(img_path, tuple(point[0]), tuple(point[1]), color, thickness)
68
+ cv2.line(img_path, tuple(point[1]), tuple(point[2]), color, thickness)
69
+ cv2.line(img_path, tuple(point[2]), tuple(point[3]), color, thickness)
70
+ cv2.line(img_path, tuple(point[3]), tuple(point[0]), color, thickness)
71
+ return img_path
72
+
73
+
74
+ def sort_box(boxs):
75
+ res = []
76
+ for box in boxs:
77
+ # box = [x if x>0 else 0 for x in box ]
78
+ x1, y1, x2, y2, x3, y3, x4, y4 = box[:8]
79
+ newBox = [[x1, y1], [x2, y2], [x3, y3], [x4, y4]]
80
+ # sort x
81
+ newBox = sorted(newBox, key=lambda x: x[0])
82
+ x1, y1 = sorted(newBox[:2], key=lambda x: x[1])[0]
83
+ index = newBox.index([x1, y1])
84
+ newBox.pop(index)
85
+ newBox = sorted(newBox, key=lambda x: -x[1])
86
+ x4, y4 = sorted(newBox[:2], key=lambda x: x[0])[0]
87
+ index = newBox.index([x4, y4])
88
+ newBox.pop(index)
89
+ newBox = sorted(newBox, key=lambda x: -x[0])
90
+ x2, y2 = sorted(newBox[:2], key=lambda x: x[1])[0]
91
+ index = newBox.index([x2, y2])
92
+ newBox.pop(index)
93
+
94
+ newBox = sorted(newBox, key=lambda x: -x[1])
95
+ x3, y3 = sorted(newBox[:2], key=lambda x: x[0])[0]
96
+
97
+ res.append([x1, y1, x2, y2, x3, y3, x4, y4])
98
+ return res
99
+
100
+
101
+ def solve(box):
102
+ """
103
+ 绕 cx,cy点 w,h 旋转 angle 的坐标
104
+ x = cx-w/2
105
+ y = cy-h/2
106
+ x1-cx = -w/2*cos(angle) +h/2*sin(angle)
107
+ y1 -cy= -w/2*sin(angle) -h/2*cos(angle)
108
+
109
+ h(x1-cx) = -wh/2*cos(angle) +hh/2*sin(angle)
110
+ w(y1 -cy)= -ww/2*sin(angle) -hw/2*cos(angle)
111
+ (hh+ww)/2sin(angle) = h(x1-cx)-w(y1 -cy)
112
+
113
+ """
114
+ x1, y1, x2, y2, x3, y3, x4, y4 = box[:8]
115
+ cx = (x1 + x3 + x2 + x4) / 4.0
116
+ cy = (y1 + y3 + y4 + y2) / 4.0
117
+ w = (np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) + np.sqrt((x3 - x4) ** 2 + (y3 - y4) ** 2)) / 2
118
+ h = (np.sqrt((x2 - x3) ** 2 + (y2 - y3) ** 2) + np.sqrt((x1 - x4) ** 2 + (y1 - y4) ** 2)) / 2
119
+
120
+ sinA = (h * (x1 - cx) - w * (y1 - cy)) * 1.0 / (h * h + w * w) * 2
121
+ angle = np.arcsin(sinA)
122
+ return angle, w, h, cx, cy
123
+
124
+
125
+ def sorted_boxes(dt_boxes):
126
+ """
127
+ Sort text boxes in order from top to bottom, left to right
128
+ args:
129
+ dt_boxes(array):detected text boxes with shape [4, 2]
130
+ return:
131
+ sorted boxes(array) with shape [4, 2]
132
+ """
133
+ num_boxes = dt_boxes.shape[0]
134
+ sorted_boxes = sorted(dt_boxes, key=lambda x: (x[0][1], x[0][0]))
135
+ _boxes = list(sorted_boxes)
136
+
137
+ for i in range(num_boxes - 1):
138
+ if abs(_boxes[i+1][0][1] - _boxes[i][0][1]) < 10 and \
139
+ (_boxes[i + 1][0][0] < _boxes[i][0][0]):
140
+ tmp = _boxes[i]
141
+ _boxes[i] = _boxes[i + 1]
142
+ _boxes[i + 1] = tmp
143
+ return _boxes
144
+
145
+
146
+ def get_rotate_crop_image(img, points):
147
+ img_height, img_width = img.shape[0:2]
148
+ left = int(np.min(points[:, 0]))
149
+ right = int(np.max(points[:, 0]))
150
+ top = int(np.min(points[:, 1]))
151
+ bottom = int(np.max(points[:, 1]))
152
+ img_crop = img[top:bottom, left:right, :].copy()
153
+ points[:, 0] = points[:, 0] - left
154
+ points[:, 1] = points[:, 1] - top
155
+ img_crop_width = int(np.linalg.norm(points[0] - points[1]))
156
+ img_crop_height = int(np.linalg.norm(points[0] - points[3]))
157
+ pts_std = np.float32([[0, 0], [img_crop_width, 0],\
158
+ [img_crop_width, img_crop_height], [0, img_crop_height]])
159
+
160
+ M = cv2.getPerspectiveTransform(points, pts_std)
161
+ dst_img = cv2.warpPerspective(
162
+ img_crop,
163
+ M, (img_crop_width, img_crop_height),
164
+ borderMode=cv2.BORDER_REPLICATE)
165
+ dst_img_height, dst_img_width = dst_img.shape[0:2]
166
+ if dst_img_height * 1.0 / dst_img_width >= 1.5:
167
+ dst_img = np.rot90(dst_img)
168
+ return dst_img
169
+
170
+
171
+ def app_url(version, name):
172
+ url = '/{}/{}'.format(version, name)
173
+ return url
@@ -0,0 +1,98 @@
1
+ import json
2
+ import threading
3
+ from pathlib import Path
4
+ # 如果尚未安装,可以运行: pip install platformdirs
5
+ from platformdirs import user_data_dir
6
+
7
+ class AppConfig:
8
+ _instance = None
9
+ _lock = threading.Lock() # 确保线程安全的单例模式
10
+
11
+ def __new__(cls, *args, **kwargs):
12
+ """单例模式:确保全局只有一个配置实例"""
13
+ if not cls._instance:
14
+ with cls._lock:
15
+ if not cls._instance:
16
+ cls._instance = super().__new__(cls)
17
+ # 标记是否已经初始化,防止 __init__ 重复调用
18
+ cls._instance._initialized = False
19
+ return cls._instance
20
+
21
+ def __init__(self, app_name="ScreenSubtitleReader"):
22
+ if self._initialized:
23
+ return
24
+
25
+ # 1. 确定跨平台的 AppData 存储路径
26
+ # 在 Windows 上通常是: C:\Users\用户名\AppData\Local\MyTkinterApp
27
+ self.config_dir = Path(user_data_dir(app_name))
28
+ self.config_file = self.config_dir / "config.json"
29
+
30
+ # 2. 默认属性
31
+ self._volume = 100
32
+ self._speed = 270
33
+
34
+ # 3. 启动初始化:自动读取或创建文件
35
+ self._load_or_create_config()
36
+ self._initialized = True
37
+
38
+ def _load_or_create_config(self):
39
+ """读取配置文件,如果不存在则创建默认配置"""
40
+ try:
41
+ if self.config_file.exists():
42
+ # 文件存在,读取数据
43
+ with open(self.config_file, "r", encoding="utf-8") as f:
44
+ data = json.load(f)
45
+ # 使用 get 提供降级容错,防止 JSON 被人为改坏导致崩溃
46
+ self._volume = int(data.get("volume", 100))
47
+ self._speed = int(data.get("speed", 270))
48
+ print(f"成功加载配置: {self.config_file}")
49
+ else:
50
+ # 文件不存在,保存当前的默认值
51
+ self.save()
52
+ print(f"创建默认配置文件: {self.config_file}")
53
+ except Exception as e:
54
+ print(f"配置加载或初始化失败,使用默认值。错误: {e}")
55
+
56
+ def save(self):
57
+ """将当前属性序列化并自动存盘"""
58
+ try:
59
+ # 确保应用数据文件夹存在(不存在则自动递归创建)
60
+ self.config_dir.mkdir(parents=True, exist_ok=True)
61
+
62
+ data = {
63
+ "volume": self._volume,
64
+ "speed": self._speed
65
+ }
66
+ # 写入文件
67
+ with open(self.config_file, "w", encoding="utf-8") as f:
68
+ json.dump(data, f, indent=4, ensure_ascii=False)
69
+ print("配置已自动保存到磁盘。")
70
+ except Exception as e:
71
+ print(f"配置保存失败: {e}")
72
+
73
+ # --- 使用 Property 属性装饰器,既能像属性一样访问,又能触发自动存盘 ---
74
+ @property
75
+ def volume(self) -> int:
76
+ return self._volume
77
+
78
+ @volume.setter
79
+ def volume(self, value: int):
80
+ if not isinstance(value, int):
81
+ raise TypeError("音量必须是整型 (int)")
82
+ self._volume = value
83
+ self.save() # 更改时自动存盘
84
+
85
+ @property
86
+ def speed(self) -> int:
87
+ return self._speed
88
+
89
+ @speed.setter
90
+ def speed(self, value: int):
91
+ if not isinstance(value, int):
92
+ raise TypeError("语速必须是整型 (int)")
93
+ self._speed = value
94
+ self.save() # 更改时自动存盘
95
+
96
+ if __name__ == "__main__":
97
+ apc = AppConfig()
98
+ print(apc.speed)
@@ -0,0 +1,60 @@
1
+ import pyttsx3
2
+ import queue
3
+ import time
4
+ import threading
5
+ from ..setting.setting import AppConfig
6
+
7
+ class Speaker:
8
+ _instance = None
9
+ _lock = threading.Lock() # 确保线程安全的单例模式
10
+ def __new__(cls, *args, **kwargs):
11
+ """单例模式:确保全局只有一个配置实例"""
12
+ if not cls._instance:
13
+ with cls._lock:
14
+ if not cls._instance:
15
+ cls._instance = super().__new__(cls)
16
+ # 标记是否已经初始化,防止 __init__ 重复调用
17
+ cls._instance._initialized = False
18
+ return cls._instance
19
+
20
+ def __init__(self,volum = None ,speed = None) -> None:
21
+ if self._initialized:
22
+ return
23
+
24
+ config_manager = AppConfig()
25
+ self.volum = volum if volum else config_manager.volume
26
+ self.speed = speed if speed else config_manager.speed
27
+
28
+ self.voice_tasks = queue.Queue()
29
+
30
+ # 启动工作线程
31
+ self.thread = threading.Thread(target=self.speak_text_block_thread, daemon=True)
32
+ self.thread.start()
33
+
34
+ self._initialized = True
35
+
36
+ def add_sentence(self, sentence: str):
37
+ self.voice_tasks.put(sentence)
38
+
39
+ def speak_text_block_thread(self):
40
+ # 【关键修改】在子线程内部初始化引擎
41
+ engine = pyttsx3.init()
42
+ engine.setProperty('rate',self.speed )
43
+
44
+ while True:
45
+ # queue.get() 默认是阻塞的,没有任务时会一直等在这里,不会消耗CPU
46
+ speak_sentence = self.voice_tasks.get()
47
+
48
+ if speak_sentence:
49
+ engine.say(speak_sentence)
50
+ engine.runAndWait()
51
+
52
+ # 通知队列当前任务已处理完毕(良好的队列使用习惯)
53
+ self.voice_tasks.task_done()
54
+
55
+ if __name__ == "__main__":
56
+ sp = Speaker()
57
+ for i in range(99):
58
+ sp.add_sentence(f"阿巴阿巴阿巴 {i}")
59
+
60
+ time.sleep(100)