Skip to content

Commit 49d75cd

Browse files
authored
Merge pull request #1 from AzureG03/ppocr
Feat: Add PPOCR-v5 model
2 parents 3ae5e4e + ac44e0c commit 49d75cd

11 files changed

Lines changed: 18712 additions & 1 deletion

File tree

bin/ppocr_models/ppocrv5/det.onnx

4.53 MB
Binary file not shown.

bin/ppocr_models/ppocrv5/label.txt

Lines changed: 18383 additions & 0 deletions
Large diffs are not rendered by default.

bin/ppocr_models/ppocrv5/rec.onnx

15.8 MB
Binary file not shown.

deploy/AidLux/0.92/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ lz4
1010
mxnet==1.6.0
1111
numpy
1212
onepush==1.4.0
13+
onnxruntime==1.10.0
1314
pillow
1415
prettytable==2.2.1
1516
psutil==5.9.3

deploy/docker/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ pypresence==4.2.1
3434
# Ocr
3535
cnocr==1.2.2
3636
mxnet==1.6.0
37+
onnxruntime==1.10.0
3738

3839
# Webui
3940
pywebio==1.6.2

deploy/headless/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ pypresence==4.2.1
3434
# Ocr
3535
cnocr==1.2.2
3636
mxnet==1.6.0
37+
onnxruntime==1.10.0
3738

3839
# Webui
3940
pywebio==1.6.2

module/ocr/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,5 +61,13 @@ def tw(self):
6161
from module.ocr.al_ocr import AlOcr
6262
return AlOcr(model_name='densenet-lite-gru', model_epoch=63, root='./bin/cnocr_models/tw', name='tw')
6363

64+
@cached_property
65+
def ppocr(self):
66+
# Folder: ./bin/ppocr_models/ppocrv5
67+
# Size: 4.52MB(detection) + 15.7MB(recognition)
68+
# Model: PP-OCRv5 from PaddleOCR including detection and recognition
69+
from module.ocr.pp_ocr import PpOcr
70+
return PpOcr(model_name='ppocrv5', root='./bin/ppocr_models/ppocrv5', name='ppocr')
71+
6472

6573
OCR_MODEL = OcrModel()

module/ocr/ocr.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ def __init__(self, buttons, lang='azur_lane', letter=(255, 255, 255), threshold=
4646
def cnocr(self) -> "AlOcr":
4747
return OCR_MODEL.__getattribute__(self.lang)
4848

49+
@property
50+
def ppocr(self):
51+
return OCR_MODEL.__getattribute__('ppocr')
52+
4953
@property
5054
def buttons(self):
5155
buttons = self._buttons

module/ocr/pp_ocr.py

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import math
2+
import os
3+
4+
import cv2
5+
import numpy as np
6+
import onnxruntime as ort
7+
from module.exception import RequestHumanTakeover
8+
from module.logger import logger
9+
10+
logger.info('Loading PP-OCR dependencies')
11+
12+
13+
class PpOcr:
14+
# cpu only
15+
16+
def __init__(
17+
self,
18+
model_name='ppocrv5',
19+
cand_alphabet=None,
20+
root='./bin/ppocr_models/ppocrv5',
21+
name=None,
22+
):
23+
self._args = (model_name, cand_alphabet, root, name)
24+
self._model_loaded = False
25+
26+
def init(
27+
self,
28+
model_name='ppocrv5',
29+
cand_alphabet=None,
30+
root='./bin/ppocr_models',
31+
name=None,
32+
):
33+
"""
34+
:param model_name: 模型名称
35+
:param cand_alphabet: 待识别字符所在的候选集合。默认为 `None`,表示不限定识别字符范围
36+
:param root: 模型文件所在的根目录
37+
:param name: 正在初始化的这个实例名称。如果需要同时初始化多个实例,需要为不同的实例指定不同的名称。
38+
"""
39+
self._model_name = model_name
40+
self._model_dir = root
41+
42+
self._assert_and_prepare_model_files()
43+
self._alphabet = self._read_charset(
44+
os.path.join(self._model_dir, 'label.txt')
45+
)
46+
47+
# Alphabet will be set before calling ocr.
48+
# self.set_cand_alphabet(cand_alphabet)
49+
self._cand_alphabet = None
50+
51+
# Load model
52+
self._det_model = ort.InferenceSession(os.path.join(self._model_dir, 'det.onnx'))
53+
self._rec_model = ort.InferenceSession(os.path.join(self._model_dir, 'rec.onnx'))
54+
55+
def ocr(self, img, detect=True):
56+
"""
57+
Only support one line OCR
58+
59+
:param img: image file path; or color image np.ndarray,
60+
with shape (height, width, 3), and the channels should be RGB formatted.
61+
:param detect: If true, detect the text region for recognization. Default: True.
62+
:return: List(Char), such as:
63+
['第', '一', '行']
64+
"""
65+
if not self._model_loaded:
66+
self.init(*self._args)
67+
self._model_loaded = True
68+
69+
img = self._load_image(img)
70+
71+
if detect:
72+
image = self._preprocess_image(img, 'det')
73+
boxes = self._detect(image)
74+
if boxes:
75+
boxes.sort(key=lambda box: box[2], reverse=True)
76+
x, y, w, h = boxes[0]
77+
image = img[y:y + h, x:x + w]
78+
else:
79+
image = img
80+
81+
image = self._preprocess_image(image)
82+
preds = self._predict(image)
83+
result = self._postprocess_text(preds)[0]
84+
return result
85+
86+
def atomic_ocr_for_single_lines(self, img_list, cand_alphabet=None, batch_size=10, batch_threshold=20, detect=True):
87+
"""
88+
Multi images, one line OCR
89+
"""
90+
if len(img_list) == 0:
91+
return []
92+
93+
if not self._model_loaded:
94+
self.init(*self._args)
95+
self._model_loaded = True
96+
97+
self.set_cand_alphabet(cand_alphabet)
98+
99+
results = []
100+
101+
if detect:
102+
image_list = [self._load_image(img) for img in img_list]
103+
image_list = [self._preprocess_image(img, 'det') for img in image_list]
104+
for i, img in enumerate(image_list):
105+
boxes = self._detect(img)
106+
if boxes:
107+
boxes.sort(key=lambda box: box[2], reverse=True)
108+
x, y, w, h = boxes[0]
109+
image_list[i] = img_list[i][y:y + h, x:x + w]
110+
else:
111+
image_list[i] = img_list[i]
112+
img_list = image_list
113+
114+
for batch in self._batch_imgs(img_list, batch_size, batch_threshold):
115+
preds = self._predict(batch)
116+
results.extend(self._postprocess_text(preds))
117+
118+
return results
119+
120+
def detect_then_ocr(self, img, pad=10, threshold=0.6, mode=cv2.RETR_EXTERNAL, batch_size=10, batch_threshold=20,
121+
debug=False):
122+
"""
123+
Detect potential text regions and perform OCR.
124+
The order of detected text is not guaranteed.
125+
126+
:param img: image file path; or color image np.ndarray,
127+
with shape (height, width, 3), and the channels should be RGB formatted.
128+
:param pad: pad detected text region, both width and height.
129+
:param threshold: threshold for detect text.
130+
:param mode: cv2.findCounters(), one of [RETR_EXTERNAL, RETR_LIST, RETR_CCOMP, RETR_TREE, RETR_FLOODFILL].
131+
:param batch_size: the batch size of text recognition.
132+
:param batch_threshold: images with width differences less than or equal to batch_threshold are placed in the same batch,
133+
ensuring similar dimensions within a batch and improving memory and computational efficiency.
134+
:param debug: debug mode.
135+
:return: A tuple containing two lists:
136+
the recognized text and its corresponding region (x, y, w, h).
137+
"""
138+
if not self._model_loaded:
139+
self.init(*self._args)
140+
self._model_loaded = True
141+
142+
img = self._load_image(img)
143+
image = img
144+
image = self._preprocess_image(image, 'det')
145+
boxes = self._detect(image, pad, threshold, mode)
146+
boxes.sort(key=lambda box: box[2])
147+
148+
crops, regions = [], []
149+
for box in boxes:
150+
x, y, w, h = box
151+
crop = img[y:y + h, x:x + w, :]
152+
crops.append(crop)
153+
154+
texts, index = [], 0
155+
for batch in self._batch_imgs(crops, batch_size, batch_threshold):
156+
preds = self._predict(batch)
157+
batch_text = self._postprocess_text(preds)
158+
for chars in batch_text:
159+
text = ''.join([c.strip() for c in chars])
160+
if text:
161+
texts.append(text)
162+
regions.append(boxes[index])
163+
164+
if debug:
165+
x, y, w, h = boxes[index]
166+
logger.info(f'[OCR] Text: {text}, Region: ({x}, {y}), ({x + w}, {y + h})')
167+
tmp = cv2.rectangle(img.copy(), (x, y), (x + w, y + h), (255, 0, 0), 2)
168+
cv2.imshow("ppocr", cv2.cvtColor(tmp, cv2.COLOR_RGB2BGR))
169+
cv2.waitKey(0)
170+
171+
index += 1
172+
173+
return texts, regions
174+
175+
def set_cand_alphabet(self, cand_alphabet):
176+
self._cand_alphabet = [c for c in cand_alphabet] if cand_alphabet else None
177+
178+
def _assert_and_prepare_model_files(self):
179+
model_dir = self._model_dir
180+
model_files = [
181+
'label.txt',
182+
'det.onnx',
183+
'rec.onnx'
184+
]
185+
file_prepared = True
186+
for f in model_files:
187+
f = os.path.join(model_dir, f)
188+
if not os.path.exists(f):
189+
file_prepared = False
190+
logger.warning('can not find file %s', f)
191+
break
192+
193+
if file_prepared:
194+
return
195+
196+
logger.warning(f'Ocr model not prepared: {model_dir}')
197+
logger.warning(f'Required files: {model_files}')
198+
logger.critical('Please check if required files of pre-trained OCR model exist')
199+
raise RequestHumanTakeover
200+
201+
def _read_charset(self, filename):
202+
with open(filename, encoding='utf-8') as f:
203+
alphabet = [line.rstrip('\n') for line in f.readlines()]
204+
alphabet.append('')
205+
return np.array(alphabet)
206+
207+
def _load_image(self, img):
208+
if isinstance(img, str):
209+
if not os.path.isfile(img):
210+
raise FileNotFoundError
211+
img = cv2.imread(img)
212+
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
213+
elif isinstance(img, np.ndarray):
214+
img = img
215+
elif isinstance(img, list):
216+
img = np.array(img[0])
217+
else:
218+
raise TypeError
219+
return img
220+
221+
def _preprocess_image(self, img, resize_mode='rec'):
222+
if len(img.shape) == 2:
223+
img = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB)
224+
225+
if resize_mode == 'rec':
226+
if img.shape[0] > img.shape[1] * 1.5:
227+
img = cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE)
228+
229+
# Image's height must be 48
230+
scale = 48 / img.shape[0]
231+
new_w = int(img.shape[1] * scale)
232+
img = cv2.resize(img, (new_w, 48))
233+
234+
elif resize_mode == 'det':
235+
pad_h = math.ceil(img.shape[0] / 32) * 32 - img.shape[0]
236+
pad_w = math.ceil(img.shape[1] / 32) * 32 - img.shape[1]
237+
img = np.pad(img, ((0, pad_h), (0, pad_w), (0, 0)), mode='constant')
238+
239+
img = np.transpose(img, (2, 0, 1))
240+
img = np.expand_dims(img, axis=0)
241+
img = img.astype('float32') / 255.0
242+
img = (img - 0.5) / 0.5
243+
return img
244+
245+
def _batch_imgs(self, imgs, batch_size=None, batch_threshold=20):
246+
def pad_img():
247+
batch_padded = []
248+
for b in batch:
249+
padded = np.pad(b, ((0, 0), (0, 0), (0, 0), (0, max_width - b.shape[3])), mode='constant')
250+
batch_padded.append(padded)
251+
return np.vstack(batch_padded)
252+
253+
batch = []
254+
min_width, max_width = 1280, 0
255+
for img in imgs:
256+
img = self._load_image(img)
257+
img = self._preprocess_image(img)
258+
259+
min_width = min(min_width, img.shape[3])
260+
max_width = max(max_width, img.shape[3])
261+
262+
if isinstance(batch_threshold, int) and max_width - min_width > batch_threshold:
263+
yield pad_img()
264+
batch = []
265+
min_width, max_width = img.shape[3], img.shape[3]
266+
267+
batch.append(img)
268+
269+
if len(batch) == batch_size:
270+
yield pad_img()
271+
batch = []
272+
min_width, max_width = 1280, 0
273+
274+
if batch:
275+
yield pad_img()
276+
277+
def _detect(self, img, pad=10, threshold=0.3, mode=cv2.RETR_EXTERNAL):
278+
input_name = self._det_model.get_inputs()[0].name
279+
preds = self._det_model.run(None, {input_name: img})[0][0, 0]
280+
score_map = (preds > threshold).astype(np.uint8) * 255
281+
contours, _ = cv2.findContours(score_map, mode, cv2.CHAIN_APPROX_SIMPLE)
282+
boxes = [self._expand_rect(cv2.boundingRect(cnt), img.shape, pad) for cnt in contours]
283+
return boxes
284+
285+
def _expand_rect(self, rect, img_shape, pad=10):
286+
x, y, w, h = rect
287+
w, h = min(img_shape[3] - 1, w + pad), min(img_shape[2] - 1, h + pad)
288+
x, y = max(0, x - pad // 2), max(0, y - pad // 2)
289+
return x, y, w, h
290+
291+
def _predict(self, img):
292+
input_name = self._rec_model.get_inputs()[0].name
293+
preds = self._rec_model.run(None, {input_name: img})[0]
294+
preds = preds.argmax(axis=2) - 1
295+
return preds
296+
297+
def _postprocess_text(self, preds):
298+
if len(preds.shape) == 1:
299+
preds = np.expand_dims(preds, 0)
300+
301+
result = []
302+
for pred in preds:
303+
chars = self._alphabet[pred]
304+
if self._cand_alphabet:
305+
mask = np.isin(chars, self._cand_alphabet)
306+
chars = chars[mask]
307+
result.append(chars)
308+
309+
return result

requirements-in.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ pypresence==4.2.1
3434
# Ocr
3535
cnocr==1.2.2
3636
mxnet==1.6.0
37+
onnxruntime==1.10.0
3738

3839
# Webui
3940
pywebio==1.6.2

0 commit comments

Comments
 (0)