吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 789|回复: 18
上一主题 下一主题
收起左侧

[Python 原创] 更新版 - 个人 OCR 小脚本 (单文件自包含,增加翻译功能)

[复制链接]
跳转到指定楼层
楼主
pyjiujiu 发表于 2024-11-26 19:37 回帖奖励
**前言
上一篇是 紧跟潮流 也写个 OCR 图像识别 (调用本地 http )


原先调用的是 Umi-ocr 软件的 http 服务,也就需要原软件 保持后台运行,不是特别方便
这回换个路线,直接调用 封装好的OCR引擎组件(还是 umi 的作者团队的作品) 仓库地址

效果是,不再需要原 umi 软件,脚本自己即可使用(方便很多)

#这回又写了很多代码,加上侧重点 和上篇有很大的差异,还有一些新的理解,再更新在上篇,篇幅会非常感人
#所以重新开一篇


**简单介绍


*实现全部界面参数 都可调(上一帖没做完)
*加上 大模型翻译模块 自动判断中英文(参考了 沉浸式翻译插件的 prompt)
*维持了原先简洁的风格(自以为的)

*小功能(ctrl+z,ctrl+y undo 和 redo,#是tkinter 自带的 )
*umi-ocr 本身有个规律,越用内存占用越大(峰值大概2G),内存占用越高,识别越快(所以开始会慢点)


#不过也因如此,代码写很多,逼近屎山,敬请见谅


**文件下载(仓库)

因为这回是自包含的,需要额外的「模型文件」 和 「排版解析模块
如图


*其中OCR(自包含).py 是下文的代码
其他两个文件夹,都在同一个github仓库下载,仓库地址
*PaddleOCR-json_v1.4.1Releases
*tbpu 在 仓库的 api/python 文件夹内 (还有其他文件,可以自行研究或忽略)


操作很简单,将两者下载到同一个文件夹内即可使用,如上图,注意版本号
#原仓库有很多说明,下载的代码内 也有详尽的注释


---分割线---


**翻译模块
参照下图说明:



想法:必须选中文本,考虑到 OCR 识别有很多识别错误,直接全部一键翻译是不太符合直觉的
想法:常见的翻译需求就是 中英互译,所以固定自动识别,不再提供参数选择(有特殊需要,可以修改代码)


翻译前,先在脚本内输入 API 的参数,包括API-KEY 和 MODEL 还有 BASE_URL  (代码内有参考)




**忽略区域设置(参数之一)





简单说,就是输入 左上角 和 右下角的坐标 ((x1,y1),(x2,y2))   
x1,y1 --> 代表左上角的坐标,
这两个点,定义了识别的边界,,要求是结果的 小block 完全在边界内才行


忽略区域,可以直接输入,不需要的话清空,或选 None 都可


---分割线---


**下面是代码
[Python] 纯文本查看 复制代码
#脚本基于 https://github.com/hiroi-sora/PaddleOCR-json ,是再次封装(感谢原作者)
import os
import time
import atexit  
import subprocess  
import re  
from json import loads as jsonLoads, dumps as jsonDumps
from sys import platform as sysPlatform  
from base64 import b64encode  
import pathlib

#翻译模块用的 prompt,可自行修改,注意格式化两个变量 to 和 ori_text
PROMPT_TEMPLATE = ''' Translate the following source text to {to}.if html-only Output translation directly without any additional text,
the only preserve part of {to} is the uncommon word which is surrounded by bracket () for annotation. 
Source Text:
{ori_text}
Translated Text:'''
#####模型参数设定  #####################
# 初始化 OpenAI API
#API_KEY= 'sk-xxxxxxxxxxx'  # 替换为您的 OpenAI API 密钥
API_KEY =  ''

#MODEL = "deepseek-chat"  #deepseek
MODEL = "grok-beta"     #xai

#BASE_URL = "https://api.deepseek.com"  #deepseek 注意没有 v1 
BASE_URL = "https://api.x.ai/v1"   #xai
####################################

from tbpu import GetParser  
#取消 modelsPath参数,只用同目录
#取消剪贴板的 类内部实现
#修改rundict --> 统一为 run
class PPOCR_pipe:  
    def __init__(self, exe_path: str=None, argument: dict = None):
        """初始化识别器(管道模式)。\n
        `exe_path`: 识别器`PaddleOCR_json.exe`的路径。\n
        `modelsPath`: 识别库`models`文件夹的路径。若为None则默认识别库与识别器在同一目录下。\n
        `argument`: 启动参数,字典`{"键":值}`。参数说明见 https://github.com/hiroi-sora/PaddleOCR-json
        """
        if not exe_path:
            exe_path="./PaddleOCR-json_v1.4.1/PaddleOCR-json.exe" #路径绑定,目录结构改变 需要修改这个
        self.__ENABLE_CLIPBOARD = False
        exe_path = pathlib.Path(exe_path).resolve() 
        cwd = exe_path.parent  
        cmds = [exe_path]
        if isinstance(argument, dict):
            for key, value in argument.items():    
                if isinstance(value, bool):
                    cmds += [f"--{key}={value}"]  
                else:
                    cmds += [f"--{key}", str(value)]
        self.ret = None
        startupinfo = None
        if "win32" in str(sysPlatform).lower():
            startupinfo = subprocess.STARTUPINFO()
            startupinfo.dwFlags = (
                subprocess.CREATE_NEW_CONSOLE | subprocess.STARTF_USESHOWWINDOW
            )
            startupinfo.wShowWindow = subprocess.SW_HIDE
        self.ret = subprocess.Popen(cmds,cwd=cwd,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,  
                                    startupinfo=startupinfo,  
        )
        # 启动子进程
        while True:
            if not self.ret.poll() == None:  # 子进程已退出,初始化失败
                raise Exception(f"OCR init fail.")
            initStr = self.ret.stdout.readline().decode("utf-8", errors="ignore")
            if "OCR init completed." in initStr:  # 初始化成功
                break
            elif "OCR clipboard enbaled." in initStr:  
                self.__ENABLE_CLIPBOARD = True
        atexit.register(self.exit)  
        
    def getRunningMode(self) -> str:
        return "local"
    #整合原运行函数
    def run(self, writeDict: dict):
        """传入指令字典,发送给引擎进程。\n
        `writeDict`: 指令字典。image_path or image_base64 \n
        `return`:  {"code": 识别码, "data": 内容列表或错误信息字符串}\n"""
        if not self.ret:
            return {"code": 901, "data": f"引擎实例不存在。"}
        if not self.ret.poll() == None:
            return {"code": 902, "data": f"子进程已崩溃。"}
        writeStr = jsonDumps(writeDict, ensure_ascii=True, indent=None) + "\n"
        try:
            self.ret.stdin.write(writeStr.encode("utf-8")) # 文件名image_path,也可是image_base64
            self.ret.stdin.flush()
        except Exception as e:
            return {
                "code": 902,
                "data": f"向识别器进程传入指令失败,疑似子进程已崩溃。{e}",
            }
        try:
            getStr = self.ret.stdout.readline().decode("utf-8", errors="ignore")
        except Exception as e:
            return {"code": 903, "data": f"读取识别器进程输出值失败。异常信息:[{e}]"}
        try:
            return jsonLoads(getStr) 
        except Exception as e:
            return {
                "code": 904,
                "data": f"识别器输出值反序列化JSON失败。异常信息:[{e}]。原始内容:[{getStr}]",
            }
 
    def exit(self):
        """关闭引擎子进程"""
        if hasattr(self, "ret"):
            if not self.ret:
                return
            try:
                self.ret.kill()  
            except Exception as e:
                print(f"[Error] ret.kill() {e}")
        self.ret = None
        atexit.unregister(self.exit)  
        print("###  PPOCR引擎子进程关闭!")
    @staticmethod
    def printResult(res: dict,tbpu_parser):
        """用于调试,格式化打印识别结果。\n
        `res`: OCR识别结果。"""
        if tbpu_parser == "multi_para":
            spliter = ' '
        else:
            spliter = ''
        if res["code"] == 100:
            text_new = []
            for line in res["data"]:
                end="\n" if line.get("end", "") == "\n" else spliter
                line_text = line['text'] + end
                text_new.append(line_text)
            return ''.join(text_new)
        elif res["code"] == 100:
            print("图片中未识别出文字。")
        else:
            print(f"图片识别失败。错误码:{res['code']},错误信息:{res['data']}")
    def __del__(self):
        self.exit()
        
##下面是 文本块后处理
import tkinter as tk
from tkinter import filedialog, messagebox,scrolledtext
from tkinter import ttk
from tkinterdnd2 import TkinterDnD, DND_FILES
import io
from PIL import ImageGrab   #剪切板读图
import threading 
import ctypes 
ctypes.windll.shcore.SetProcessDpiAwareness(2)

def img_base64(img_file):
    if isinstance(img_file,io.BytesIO):
        img_bytes = img_file.getvalue()
    else:
        img_bytes = pathlib.Path(img_file).read_bytes()
    return b64encode(img_bytes).decode('utf-8')

class OptTranslator:
    '''en --> zh'''
    __slots__=('ocr_language','tbpu_parser','opt_name')
    def __init__(self) -> None:
        self.opt_name:dict = {"ocr.language":"语言:","ocr.cls":"对正文字:","ocr.limit_side_len":"限制边长:","tbpu.parser":"排版:","tbpu.ignoreArea":"忽略区域:","data.format":"数据格式:",}
        self.ocr_language:dict={"简体中文":"models/config_chinese.txt",
                        "English":"models/config_en.txt",
                            "繁體中文":"models/config_chinese_cht.txt",
                            "日本語":"models/config_japan.txt",
                            "한국어":"models/config_korean.txt",
                            "Русский":"models/config_cyrillic.txt"}
        self.tbpu_parser:dict = {"单栏-按自然段换行":"single_para",
                            "单栏-总是换行":"single_line",
                            "多栏-按自然段换行":"multi_para",
                            "多栏-总是换行":"multi_line",
                            "多栏-无换行":"multi_none",
                            "单栏-按自然段换行":"single_para",
                            "单栏-总是换行":"single_line",
                            "单栏-无换行":"single_none",
                            "单栏-保留缩进":"single_code",
                            "不做处理":"none"}
#处理忽略区域
#用box[] 左上 和 右下 确定x 和 y 的边界(和官方原理一致,必须完全在范围内)
#{'code': 100, 'data': [{'box': [[24, 6], [50, 6], [50, 22], [24, 22]],}
def is_valid(point:list,rect:tuple):
    '''界限检测,不包括边界'''
    x_l,y_l,x_r,y_r= (*point[0],*point[2])
    if x_l < rect[0][0] or y_l <rect[0][1] or x_r > rect[1][0] or y_r > rect[1][1]:
        return False
    return True

def is_zh(string_): #计算主要中文 还是英文
    char_state = 0
    for char in string_:
        if '\u4e00' <= char <= '\u9fa5': char_state += 1
        elif char.isascii(): char_state -= 1
    return char_state >= 0

def trans_worker(ori_text,client,text_widget,sel_start_index,tran_button):
    target_lang = 'English' if is_zh(ori_text) else '中文'
    user_prompt = PROMPT_TEMPLATE.format(to=target_lang,ori_text=ori_text)
    tran_button["text"] = "翻译中..." 
    try:
        response = client.chat.completions.create(
                model= MODEL,  
                messages=[
                    {"role": "system", "content": "You are a professional, authentic machine translation engine."},
                    {"role": "user", "content": user_prompt}
                ]
            )
        translated = response.choices[0].message.content
    except :
        tran_button["text"] = "翻译"
        return
    #add space
    modified_content, num1 = re.subn(r'([\u4e00-\u9fff])([\u0041-\u007A])', r'\1 \2', translated)
    modified_content, num2 = re.subn(r'([\u0041-\u007A])([\u4e00-\u9fff])', r'\1 \2', modified_content)
    if translated:
        line_number = int(sel_start_index.split('.')[0])  
        if float(sel_start_index) + 1 >= float(text_widget.index(tk.END)):
                text_widget.insert(f"{line_number + 1}.0", '\n\n' + translated + '\n\n')
                return
        text_widget.insert(f"{line_number + 1}.0", '\n' + translated + '\n\n')
    tran_button["text"] = "翻译完毕"
    time.sleep(0.8)
    tran_button["text"] = "翻译"

def translate(ori_text:str,text_widget,sel_start_index,tran_button):
    try:
        import openai
        from openai import DefaultHttpxClient
        custom_client = DefaultHttpxClient(timeout=4) 
        client  = openai.OpenAI(base_url=BASE_URL,api_key=API_KEY,http_client=custom_client)
        
        t = threading.Thread(target=trans_worker,args=(ori_text,client,text_widget,sel_start_index,tran_button))
        t.start()
    except ModuleNotFoundError:
        messagebox.showwarning("警告","翻译需先安装 openai 库")
    except Exception as e:
        print(e)

class App:
    def __init__(self, root):
        self.root = root
        self.root.title("OCR")
        self.root.geometry("520x450")
        self.file_path_label = tk.Label(root, text="File Path:")
        self.file_path_label.grid(row=0, column=0, sticky="w", padx=10, pady=10)
        self.file_path_entry = tk.Entry(root, width=50)
        self.file_path_entry.grid(row=0, column=1, padx=10, pady=10)
        self.open_button = tk.Button(root, text="Open", command=self.open_file)
        self.open_button.grid(row=0, column=2, padx=10, pady=10)
        self.root.drop_target_register(DND_FILES)
        self.root.dnd_bind('<<Drop>>', self.on_file_drop)
        self.clipboard = None
        self.root.bind("<Control-v>", self.on_paste)
        self.arg_vars = [tk.StringVar() for _ in range(6)]
        self.arg_comboboxes = []  
        self.arg_comboboxes_label = []
        self.OptTranslator = OptTranslator()
        self.option_list = [{'opt_name':'ocr.language','value':[ ("models/config_chinese.txt","简体中文"),
                                                                ("models/config_en.txt","English"),
                                                                ("models/config_chinese_cht(v2).txt","繁體中文"),
                                                                ("models/config_japan.txt","日本語"),
                                                                ("models/config_korean.txt","&#54620;&#44397;&#50612;"),
                                                                ("models/config_cyrillic.txt","Русский")]},
                            {'opt_name':'ocr.limit_side_len','value':("960", "2880", "4320", "999999")},
                            {'opt_name':'tbpu.parser','value':[
                                                                ("single_para","单栏-按自然段换行"),
                                                                ("single_line","单栏-总是换行"),
                                                                ("multi_para","多栏-按自然段换行"),
                                                                ("multi_line","多栏-总是换行"),
                                                                ("multi_none","多栏-无换行"),
                                                                ("single_none","单栏-无换行"),
                                                                ("single_code","单栏-保留缩进"),
                                                                ("none","不做处理")]},
                            {'opt_name':'ocr.cls','value':(False, True)},
                            {'opt_name':'tbpu.ignoreArea','value':['',None]},
                            {'opt_name':'data.format','value':("text","dict")}]
        for i in range(6):
            arg_label = tk.Label(root, text=self.OptTranslator.opt_name[self.option_list[i]['opt_name']])
            if self.option_list[i]['opt_name'] == 'tbpu.ignoreArea':
                arg_combobox = ttk.Combobox(root, textvariable=self.arg_vars[i], state="normal", width=20)
            else:
                arg_combobox = ttk.Combobox(root, textvariable=self.arg_vars[i], state="readonly", width=20)
            if i < 3:
                arg_label.grid(row=i+1, column=0, sticky="w", padx=10, pady=2)
                arg_combobox.grid(row=i+1, column=1, padx=10, pady=2)
            _value_dict = self.option_list[i]
            if  _value_dict['opt_name'] =='tbpu.parser' or _value_dict['opt_name']== 'ocr.language':
                arg_combobox['value'] = [item[1] for item in _value_dict['value']]
            else:
                arg_combobox['value'] = _value_dict['value']
            arg_combobox.current(0)
            self.arg_comboboxes.append(arg_combobox)
            self.arg_comboboxes_label.append(arg_label)
        # Operate button    
        self.operate_button = tk.Button(root, text="Operate", command=self.operate)
        self.operate_button.grid(row=7, column=1, pady=20)
        #hide or display 部分 options
        self.hide_toggle = tk.Button(root, text="更多选项", command=self.visible_toggle)
        self.hide_toggle.grid(row=7, column=0, pady=20)
        #翻译按钮
        self.translate_button = tk.Button(root, text="翻译", command=self.to_translate)
        self.translate_button.grid(row=7, column=2, pady=20)
        
        self.result_text =scrolledtext.ScrolledText(root, width=70,undo=True, height=14,wrap=tk.WORD)
        self.result_text.grid(row=8, column=0, columnspan=3,padx=10, pady=10)
        self.root.grid_rowconfigure(8,weight=1) 
        _temp = ("models/config_chinese_cht.txt",False,False,"900")
        self.opt_before  = set(_temp) #监控是否改变,determine if 重新实例化
        self.umi_ocr = PPOCR_pipe(argument=dict(zip(('config_path','cls','use_angle_cls','limit_side_len'),_temp)))
        
    def visible_toggle(self):
        for i,(label,combo) in enumerate(zip(self.arg_comboboxes_label[3:],self.arg_comboboxes[3:])):
            if label.grid_info() or combo.grid_info() :  
                label.grid_forget()
                combo.grid_forget()
            else:
                label.grid(row=i+1+3, column=0, sticky="w", padx=10, pady=2)
                combo.grid(row=i+1+3, column=1, padx=10, pady=2)
    def open_file(self):
        """Open file dialog to select a file."""
        file_path = filedialog.askopenfilename()
        if file_path:
            self.file_path_entry.delete(0, tk.END)
            self.file_path_entry.insert(0, file_path)
    def on_file_drop(self, event):
        """Handles the drag-and-drop event."""
        file_path = event.data.strip('{}')
        self.file_path_entry.delete(0, tk.END)
        self.file_path_entry.insert(0, file_path)
        if pathlib.Path(file_path).is_file():
            try:
                self.operate()
            except Exception as e:
                print(e)
    def on_paste(self,event):
        try:
            image = ImageGrab.grabclipboard()
            if image:
                img_data =  io.BytesIO()
                image.convert('RGB').save(img_data,'PNG')
                self.clipboard = img_data
                self.operate(clipboard=True)
                self.clipboard = None
            else:
                pass 
        except Exception as e:
            messagebox.showerror("Error", f"An error occurred: {e}")
    def to_translate(self):
        try:
            selected_text = self.result_text.get(tk.SEL_FIRST, tk.SEL_LAST)
            if not selected_text.strip():raise ValueError("翻译文本不能为空")
            sel_start_index = self.result_text.index(tk.SEL_LAST)
        except Exception as e:
            messagebox.showinfo("Info","翻译需先选中文本")
            print(e)
        translate(selected_text,self.result_text,sel_start_index,self.translate_button)
    def check_model(self,oprtions:dict):
        '''check the options, initialize PPOCR_pipe again or not'''
        ocr_opt = {}
        for k,v in oprtions.items():
            if k == 'ocr.language':
                ocr_opt['config_path'] = v
            elif k == 'ocr.cls':
                ocr_opt['cls'],ocr_opt['use_angle_cls'] = v,v
            elif k == 'ocr.limit_side_len':
                ocr_opt['limit_side_len'] = v
        if self.opt_before == set(ocr_opt.values()):
            return  
        self.opt_before  = set(ocr_opt.values()) 
        self.umi_ocr = PPOCR_pipe(argument=ocr_opt)
        return 
    def operate(self,clipboard=False):
        """Run the operation and display results."""
        file_path = self.file_path_entry.get()
        if clipboard:
            file_path = self.clipboard
        args = [var.get() for var in self.arg_vars]
        options = {}
        for index,box in enumerate(self.arg_comboboxes):
            key = self.option_list[index]['opt_name']
            value = box.get()
            if key == 'ocr.language' or key == 'tbpu.parser':
                value = getattr(self.OptTranslator,key.replace('.','_'))[value]
            options[key] = value
        self.check_model(options) 
        result = self.umi_ocr.run(writeDict={'image_base64':img_base64(file_path)})
        
        #性能备注:忽略区域的处理,相关于block本身数量,遇到表格,才会达到 毫秒级别(如0.005s)
        if limit_:=options['tbpu.ignoreArea']:  #要求((x1,y1),(x2,y2)) 方位顺序固定  不然会出 bug
            limit_ = limit_.replace(',',',').replace('(','(').replace(')',')')
            limit_rect = eval(limit_) 
            new_list = []
            for item in result['data']:
                if is_valid(item['box'],limit_rect):
                    new_list.append(item)
            result['data'] = new_list
        if options['data.format'] == "dict":
            self.result_text.delete(1.0, tk.END)
            self.result_text.insert(tk.END, result['data'])
            return
        #进行排版处理
        parser = GetParser(options['tbpu.parser'])
        textBlocksNew = parser.run(result['data'])
        result['data'] = textBlocksNew
        text_new = self.umi_ocr.printResult(result,options['tbpu.parser'])
        
        self.result_text.delete(1.0, tk.END)  
        self.result_text.insert(tk.END, text_new)
if __name__=="__main__":
    root = TkinterDnD.Tk()
    app = App(root)
    root.mainloop()



---分割线---


**代码 & 说明
*本篇代码是 基于官方的 PPOCR_api.py 进行修剪,然后加上自己的 gui 模块
官方有两条路线 pipe 和 socket ,本代码剪去 socket部分(即无法用作服务器),然后几个运行的函数,归于run()(为了节约篇幅)
*模块差异比较大,为了方便区分,故分开写 import ,没有都放在开头


*写完这篇才发现,
-参数的忽略区域 ignoreArea 是可自己实现的,就用左上右下坐标,和bbox (每个识别block的四个坐标) 进行对比
-参数的 parser 排版(即多行,单行)是基于【间隙·树·排序算法】 GapTree_Sort_Algorithm
-对正文字 其实是两个参数,use_angle_cls 和 cls 同时为 True, 数据会多返回两个指标

    # cls_label :方向分类标签,整数。0 表示文字方向是顺时针 0°或90°,1 表示 180°或270° 。
    # cls_score :方向分类置信度,0~1的浮点数。越接近1表示方向分类越可信。
-关于软件:内存占用,是软件自行控制,峰值可以达到2G左右(原来以为是小软件)


**最后
再来点体会:之前觉得 沉浸式翻译很不错的功能,但了解设置背后的细节,才发现不如 自己整合 翻译的 workflow 来的专业,好处是能快速提供一个草稿(仅个人看法)

欢迎学习交流  

免费评分

参与人数 2吾爱币 +2 热心值 +1 收起 理由
xiaozhangz + 1 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
wuming4 + 1 用心讨论,共获提升!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
提拉米苏子冉 发表于 2024-11-27 08:20
大佬,仓库地址在哪啊,没看到啊
推荐
 楼主| pyjiujiu 发表于 2024-11-27 10:03 |楼主
提拉米苏子冉 发表于 2024-11-27 08:20
大佬,仓库地址在哪啊,没看到啊

不好意思,用的论坛 排版界面上插入链接的按钮,好像没效果
这里统一 回复下  https://github.com/hiroi-sora/PaddleOCR-json

#还有在代码第一行,也有备注(算是站在了仓库团队肩膀上)
沙发
zhu8487 发表于 2024-11-27 07:36
3#
lq123456789 发表于 2024-11-27 08:09
似乎可以哦
5#
Appkbox 发表于 2024-11-27 08:23
楼主好像忘记贴仓库地址了
6#
小草草 发表于 2024-11-27 08:45
收藏先,有时间了一定要好好研究一下
7#
mutong123 发表于 2024-11-27 08:47
膜拜大佬   
8#
zixiangcode 发表于 2024-11-27 09:07
能识别 LaTeX 吗?
9#
jinqiaoa1a 发表于 2024-11-27 09:43
楼主牛人,支持一个
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - LCG - LSG ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2024-11-28 01:09

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表