diff --git a/input_pdf_folder/setup_daudoc.pdf b/input_pdf_folder/setup_daudoc.pdf new file mode 100644 index 0000000..99ed847 Binary files /dev/null and b/input_pdf_folder/setup_daudoc.pdf differ diff --git a/models/vgg_transformer.pth b/models/vgg_transformer.pth new file mode 100644 index 0000000..7f77a2a Binary files /dev/null and b/models/vgg_transformer.pth differ diff --git a/ocr_pipeline.py b/ocr_pipeline.py new file mode 100644 index 0000000..c724c6c --- /dev/null +++ b/ocr_pipeline.py @@ -0,0 +1,302 @@ +import os +import gc +import cv2 +import numpy as np +from PIL import Image, ImageFilter, ImageEnhance +from pdf2image import convert_from_path, pdfinfo_from_path +from paddleocr import PaddleOCR +from vietocr.tool.predictor import Predictor +from vietocr.tool.config import Cfg +import torch + +# ============================================================ +# CONFIGURATION - Tuned for MAXIMUM ACCURACY +# ============================================================ +INPUT_FOLDER = "input_pdf_folder" +OUTPUT_FOLDER = "output_txt_folder" +MODEL_FOLDER = "models" +VIETOCR_MODEL_PATH = os.path.join(MODEL_FOLDER, "vgg_transformer.pth") + +# --- Quality Settings --- +DPI = 600 # Maximum practical DPI for OCR +PADDING = 8 # Pixels of padding around each text box +MIN_BOX_WIDTH = 15 # Minimum box width in pixels (filter noise) +MIN_BOX_HEIGHT = 10 # Minimum box height in pixels (filter noise) +MIN_BOX_AREA = 300 # Minimum box area in pixels² (filter tiny artifacts) +Y_TOLERANCE = 20 # Pixels tolerance for same-line grouping (higher DPI = more tolerance) + +# --- PaddleOCR Detection Tuning --- +# Lower thresholds = catch more text, but may also catch noise +# Higher thresholds = miss some text, but results are cleaner +DET_DB_THRESH = 0.25 # Binarization threshold (default 0.3, lower = more sensitive) +DET_DB_BOX_THRESH = 0.45 # Box confidence threshold (default 0.6, lower = more boxes) +DET_DB_UNCLIP_RATIO = 1.8 # Box expansion ratio (default 1.5, higher = larger boxes) +DET_LIMIT_SIDE_LEN = 2560 # Max image side length for detection (default 960, higher = more detail) + +def setup_directories(): + """Create necessary directories if they don't exist.""" + os.makedirs(INPUT_FOLDER, exist_ok=True) + os.makedirs(OUTPUT_FOLDER, exist_ok=True) + os.makedirs(MODEL_FOLDER, exist_ok=True) + +def download_vietocr_weights(): + """Download VietOCR weights if not present with corruption protection.""" + if not os.path.exists(VIETOCR_MODEL_PATH): + print("Downloading VietOCR weights (vgg_transformer)...") + import requests + temp_path = VIETOCR_MODEL_PATH + ".tmp" + url = "https://vocr.vn/data/vietocr/vgg_transformer.pth" + try: + response = requests.get(url, stream=True) + if response.status_code == 200: + with open(temp_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + # Verify the file is a valid torch checkpoint + try: + torch.load(temp_path, map_location='cpu') + except Exception: + os.remove(temp_path) + raise Exception("Downloaded file is corrupted. Retrying on next run.") + os.rename(temp_path, VIETOCR_MODEL_PATH) + print("Download completed and verified successfully.") + else: + raise Exception(f"Failed to download weights. Status code: {response.status_code}") + except Exception as e: + if os.path.exists(temp_path): + os.remove(temp_path) + raise e + +def preprocess_for_detection(pil_img): + """ + Station 1a: Prepare image for PaddleOCR text detection. + Uses CLAHE + denoising on grayscale for optimal box detection. + Returns a 3-channel image ready for PaddleOCR. + """ + img = np.array(pil_img) + + if len(img.shape) == 3 and img.shape[2] == 3: + gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) + else: + gray = img + + # CLAHE for contrast enhancement + clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8, 8)) + enhanced = clahe.apply(gray) + + # Light denoising (preserve edges) + denoised = cv2.fastNlMeansDenoising(enhanced, None, 8, 7, 21) + + # Convert back to 3-channel for PaddleOCR + return cv2.cvtColor(denoised, cv2.COLOR_GRAY2RGB) + +def preprocess_for_recognition(pil_img): + """ + Station 1b: Prepare the ORIGINAL high-res image for VietOCR recognition. + Uses the original color/grayscale image (not the detection-preprocessed one) + with mild sharpening for best character recognition. + Returns a numpy array used for cropping individual text boxes. + """ + # Sharpen the original PIL image mildly + sharpened = pil_img.filter(ImageFilter.SHARPEN) + + # Boost contrast slightly + enhancer = ImageEnhance.Contrast(sharpened) + enhanced = enhancer.enhance(1.3) + + return np.array(enhanced) + +def get_padded_crop(img, box, padding=PADDING): + """ + Extract a padded crop from the image based on the bounding box. + Uses perspective transform for rotated boxes to get a clean crop. + """ + box_arr = np.array(box).astype(np.float32) + + # Calculate width and height of the oriented bounding box + width = int(max( + np.linalg.norm(box_arr[0] - box_arr[1]), + np.linalg.norm(box_arr[2] - box_arr[3]) + )) + height = int(max( + np.linalg.norm(box_arr[0] - box_arr[3]), + np.linalg.norm(box_arr[1] - box_arr[2]) + )) + + if width < MIN_BOX_WIDTH or height < MIN_BOX_HEIGHT: + return None + if width * height < MIN_BOX_AREA: + return None + + # Add padding to the destination dimensions + dst_width = width + 2 * padding + dst_height = height + 2 * padding + + # Define destination points with padding offset + dst_pts = np.array([ + [padding, padding], + [padding + width, padding], + [padding + width, padding + height], + [padding, padding + height] + ], dtype=np.float32) + + # Perspective transform to handle rotated text + M = cv2.getPerspectiveTransform(box_arr, dst_pts) + cropped = cv2.warpPerspective( + img, M, (dst_width, dst_height), + borderMode=cv2.BORDER_REPLICATE + ) + + return cropped + +def sort_boxes(boxes): + """ + Logical Sorting & Column Handling: + Sort bounding boxes primarily by Y-coordinate and secondarily by X-coordinate + to ensure correct reading order for multi-column medical leaflets. + """ + if not boxes: + return [] + + # First sort purely by y-coordinate (top-left y) + boxes.sort(key=lambda b: b[0][1]) + + sorted_boxes = [] + current_line = [] + current_y = boxes[0][0][1] + + for box in boxes: + y1 = box[0][1] + if abs(y1 - current_y) <= Y_TOLERANCE: + current_line.append(box) + else: + current_line.sort(key=lambda b: b[0][0]) + sorted_boxes.extend(current_line) + current_line = [box] + current_y = y1 + + if current_line: + current_line.sort(key=lambda b: b[0][0]) + sorted_boxes.extend(current_line) + + return sorted_boxes + +def process_pdf(pdf_path, detector, recognizer): + """Process a single PDF file through the OCR pipeline.""" + pdf_name = os.path.basename(pdf_path) + txt_filename = os.path.splitext(pdf_name)[0] + ".txt" + txt_filepath = os.path.join(OUTPUT_FOLDER, txt_filename) + + try: + info = pdfinfo_from_path(pdf_path) + total_pages = info["Pages"] + + with open(txt_filepath, 'w', encoding='utf-8') as f_out: + for page_num in range(1, total_pages + 1): + print(f"Processing [{pdf_name}] - Page [{page_num}/{total_pages}]...") + + # Station 0: PDF to Image Conversion (600 DPI for maximum detail) + images = convert_from_path( + pdf_path, dpi=DPI, + first_page=page_num, last_page=page_num + ) + if not images: + continue + pil_img = images[0] + + # Station 1a: Preprocess for detection (grayscale, denoised) + detection_img = preprocess_for_detection(pil_img) + + # Station 1b: Preprocess for recognition (original, sharpened) + recognition_img = preprocess_for_recognition(pil_img) + + # Station 2: Text Detection (PaddleOCR) + dt_boxes = detector.ocr(detection_img, rec=False, cls=False) + + if dt_boxes and len(dt_boxes) > 0 and dt_boxes[0] is not None: + boxes = dt_boxes[0] + boxes = sort_boxes(boxes) + + recognized_count = 0 + for box in boxes: + # Station 3: Crop from the RECOGNITION image (not detection image) + # This gives VietOCR the highest quality input + cropped_cv = get_padded_crop(recognition_img, box) + + if cropped_cv is None: + continue + + # Convert to PIL for VietOCR + cropped_pil = Image.fromarray(cropped_cv) + if cropped_pil.mode != 'RGB': + cropped_pil = cropped_pil.convert('RGB') + + # Recognize text + text = recognizer.predict(cropped_pil) + text = text.strip() + + # Filter out very short garbage text (single chars that are likely noise) + if len(text) >= 1: + f_out.write(text + "\n") + recognized_count += 1 + + print(f" → Detected {len(boxes)} boxes, recognized {recognized_count} text lines.") + + # Memory cleanup + del images, pil_img, detection_img, recognition_img + gc.collect() + + print(f"Processing [{pdf_name}] - Page [{page_num}/{total_pages}]... Done.") + + except Exception as e: + print(f"Error processing {pdf_name}: {e}") + import traceback + traceback.print_exc() + +def main(): + setup_directories() + + # Initialize PaddleOCR detector (CPU ONLY, tuned for maximum accuracy) + print("Initializing Station 2: PaddleOCR Detector (CPU, MAX ACCURACY mode)...") + detector = PaddleOCR( + use_angle_cls=True, # Enable angle classification for rotated text + lang='vi', + use_gpu=False, + show_log=False, + det_db_thresh=DET_DB_THRESH, + det_db_box_thresh=DET_DB_BOX_THRESH, + det_db_unclip_ratio=DET_DB_UNCLIP_RATIO, + det_limit_side_len=DET_LIMIT_SIDE_LEN, + use_dilation=True, # Dilate text regions for better detection + ) + + # Initialize VietOCR recognizer (CPU ONLY, beam search for max accuracy) + print("Initializing Station 3: VietOCR Recognizer (MAX ACCURACY mode)...") + download_vietocr_weights() + config = Cfg.load_config_from_name('vgg_transformer') + config['weights'] = VIETOCR_MODEL_PATH + config['device'] = 'cpu' + config['predictor']['beamsearch'] = True # Beam search is slower but more accurate + recognizer = Predictor(config) + + # Scan input folder for PDFs + pdf_files = [f for f in os.listdir(INPUT_FOLDER) if f.lower().endswith('.pdf')] + if not pdf_files: + print(f"No PDF files found in {INPUT_FOLDER}/. Please add PDF files and run again.") + return + + print(f"Found {len(pdf_files)} PDF files to process.") + print(f"Settings: DPI={DPI}, Padding={PADDING}px, BeamSearch=ON") + print(f"Detection: thresh={DET_DB_THRESH}, box_thresh={DET_DB_BOX_THRESH}, " + f"unclip={DET_DB_UNCLIP_RATIO}, max_side={DET_LIMIT_SIDE_LEN}") + print("=" * 60) + + for pdf_file in pdf_files: + pdf_path = os.path.join(INPUT_FOLDER, pdf_file) + process_pdf(pdf_path, detector, recognizer) + + print("=" * 60) + print("Batch processing completed.") + +if __name__ == "__main__": + main() diff --git a/output_txt_folder/setup_daudoc.txt b/output_txt_folder/setup_daudoc.txt new file mode 100644 index 0000000..4cd31c7 --- /dev/null +++ b/output_txt_folder/setup_daudoc.txt @@ -0,0 +1,211 @@ +S.I.S - SOFT +In Digital Health Solutions +TÀI LIỆU HƯỞNG DÂN +đầu đọc +N +Setup +TP. Hồ Chí Minh, 09/2025 +1/5 +L. CÁCH XỬ LÝ: ÁP DỤNG CHO HẦU HẾT MÁY QUÉT +Thực hiện: +BƯỚC 1: +Trước tiên, bạn cần chuyển máy quét sang chế độ USB-COM bằng cách quét mã bên dưới +(USB-COM) +Sau khi quét thành công, máy sẽ phát ra tiếng TíT TíT liên tục 3 lần +BƯỚC 2: +Ban cần kiểm tra tên và giao thức kết nối có đúng là USB-COM hay không bằng cách KíCH CHUỘT +TRÁI vào My Computer(?This PC ? ?với win 10), sau đó chọn Manager. +Saved to this PC +WPS PDF +Insert +de +es New Roman +13 +AII +Documents +Web +Settings +People +Folders +Photos +Apps +ab +Best match +Font +Device Manager +Control panel +Xs +21 +Search the web +Device Manager +Control panel +Results +Q +2 de - Seemore search results +2 the document has +Deepseek +7 +Open +je due to edits you +ived from others. +Quidepi +itinue and jump to the +Quadellsupport +Q +deepseekai +Apps +Q +Github Desktop +Power Bl Desktop (January 2023) +Defrese Defragment and Optimize Drives +Remote Desktop Connection +Settings +c +Vietnamese +Hộp thoại sẽ xuất hiện như hình bên dưới. Chọn ô: Device Manager - ? mở Ports (Com 8 LPT) +2/5 +Computer Management +0 +x +File +Action +View +Help +Computer Management (Local +KyThuat TanPhat +Actions +'f System Tools +x +1 +Audio inputs and outputs +Device Manager +x +Task Scheduler +x +Ổ +Bluetooth +E] Event Viewer +Computer +More Actions +x +2 +p +2 +E Shared Folders +Disk drives +Local Users and Groups +1 +Display adapters +X +Performance +1 +Firmware +x +Device Manager +Wi Human Interface Devices +2 +Storage +IDE ATA/ATAPI controllers +X +Disk Management +Keyboards +to Is Services and Applications +O Mice and other pointing devices +2 +I Monitors +x +7 Network adapters +- Ports (COM 8 LPT) +Communications Port (COM1) +Bộ USB Serial Device (COM5) - 4 +La Print queues +- A Printers +- DJ Processors +I Software devices +s ly Sound, video and game controllers +Say Storage controllers +- La System devices +It is 3 Universal Serial Bus controllers +Hộp thoại xuất hiện, bạn tiến hành kiểm tra COM port name, tại ví dụ này là COM5 và có chữ USB +SERIAL DEVICE ở đầu dòng (ở mối máy thì COM sẽ khác nhau có thể Coml, Com3, Com 4. +cần lưu ý phải xác định tên COM port cho chính xác, để thực hiện cấu hình tại bước sau) +3/5 +BƯỚC 3: +Sau đó, bạn cần cài đặt ứng dụng, link tải: https:/drive.google.com/file/d/1XDI +jD5pL6 v IpEQJDm/Pgko2YtLPCM/view/usp-sharing +BƯỚC 4: +Ở bước này, bạn sẽ thiết lập cấu hình Encoding cho ứng dụng hãng thiết bị (đã được cài đặt ở bước 3). +Sau khi cài đặt thành công sẽ có một Application Tray tại góc phải bên dưới màn hình với +Icon chữ W (Bottom Right) +v +w +E +Al +4 +120% +4v) +8:48 SA +g +Click chuột phải vào Icon chọn Settings. +About +Settings +Command +Suspend +V +Quit +w +Ef +4 +120% +41) +18:49 sa +g +4/5 +Tại giao diện Setting, chọn đúng theo thông số bên dưới (Bỏ TÍCH USB SCANER Để HIỆN COM +PORT) +w +WIME +coMport +USB Serial Device (com5) +Select coM port to connect from combo box. +JUSB Scanner +Port Settings +Code Page +Select the code page what encodes the label you +65001: utf8 +will scan. +Window Message +Select Windows Message type what is sent to the +WMLCHAR +active window. Try other type if you have any +trouble about output. +Auto Connection +WIME searches selected coM port and try +Enable +connecting if enabled. +Control Characters +Configure how to output control characters, like +Output Settings +Carrige Return, Escape. Select option for each +characters +Delay Time +After Character +0 +[msec] +Set delay time in milli-sencond after character/key +output. Set this time longer if some characters +After Key +50 +[msec] +drop out. +Set Default +OK +Cancel +Lưu ý: phần COM PORT cần phải trùng tên với COM PORT NAME được xác định tại BƯớc 2 +BƯỚC 5: +Nếu lỗi, hoặc không thấy đổ dữ liệu ra Notepad,word... thì hãy khởi động lại máy và kiểm tra kết quả +trên ứng dụng word, excel, notepad.. +GHI CHÚ: Reset máy về chế độ quét bình thường khi không sử dụng để quét căn cước công dân thì +quét vào mã cấu hình sau. +Set All Defaults +5/5 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..527f80a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +pdf2image +opencv-python-headless +paddleocr +paddlepaddle +vietocr +numpy +Pillow