# Script objective: # Use Python to read images # See README.md for set-up instructions # 1/ Set-up # 1.1/ Find system paths import sys as PI_SYS import os as PI_OS import polars as PI_POLARS ZV_ST_02PROG_FOLDER = PI_OS.path.dirname( PI_OS.path.abspath(__file__) ) ZV_ST_ROOT_FOLDER = PI_OS.path.dirname( ZV_ST_02PROG_FOLDER ) PI_SYS.path.append(ZV_ST_02PROG_FOLDER) # 1.2/ Find and install requirements from Z_SHARED_FUNCTIONS.FC_INSTALL_REQUIREMENTS import FC_INSTALL_REQUIREMENTS FC_INSTALL_REQUIREMENTS(ZVFCI_ST_02PROG_FOLDER=ZV_ST_02PROG_FOLDER) # 1.3/ Find varables and create dictionary from Z_SHARED_FUNCTIONS.FC_CREATE_DI_VARIABLES import FC_CREATE_DI_VARIABLES ZV_DI_VARIABLES= FC_CREATE_DI_VARIABLES(ZVFCI_ST_ROOT_FOLDER=ZV_ST_ROOT_FOLDER) # 1.4/ Import libraries import pandas as PI_PANDAS import easyocr as PI_EASYOCR import re as PI_RE from datetime import datetime as PI_DATETIME # 1.4/ Import other custom functions from Z_SHARED_FUNCTIONS.FC_EXPORT import FC_EXPORT_EXCEL_POLARS # 2/ Variables ZV_LI_EASY_OCR_LANGUAGES = (ZV_DI_VARIABLES.get('ZV_ST_EASY_OCR_LANGUAGES')).split(',') ZV_BO_EASY_OCR_USE_GPU=(ZV_DI_VARIABLES.get('ZV_ST_EASY_OCR_USE_GPU')).upper()=='TRUE' ZV_ST_IMAGE_FILENAME=ZV_DI_VARIABLES.get('ZV_ST_IMAGE_FILENAME') ZV_ST_RESULTS_FILENAME=ZV_DI_VARIABLES.get('ZV_ST_RESULTS_FILENAME') ZV_ST_REGEX_PATTERN_DATE=ZV_DI_VARIABLES.get('ZV_ST_REGEX_PATTERN_DATE') ZV_ST_REGEX_PATTERN_TEXT1=ZV_DI_VARIABLES.get('ZV_ST_REGEX_PATTERN_TEXT1') ZV_ST_REGEX_PATTERN_TEXT2=ZV_DI_VARIABLES.get('ZV_ST_REGEX_PATTERN_TEXT2') ZV_ST_SOURCES_FOLDER = PI_OS.path.join( ZV_ST_ROOT_FOLDER, '01_SOURCES' ) ZV_ST_RESULTS_FOLDER = PI_OS.path.join( ZV_ST_ROOT_FOLDER, '03_RESULTS' ) ZV_OB_REGEX_PATTERN_DATE = PI_RE.compile( ZV_ST_REGEX_PATTERN_DATE ) # 3/ Create OCR reader object ZV_OB_READER = PI_EASYOCR.Reader(ZV_LI_EASY_OCR_LANGUAGES, gpu=ZV_BO_EASY_OCR_USE_GPU) print("✅ EasyOCR object created.") # Step 4/ Run OCR # detail=1 -> return full info: (bbox, text, confidence) # mag_ratio=2.0 -> zoom the image by 2x before OCR # Note You can adjust it down to 1.0 or 1.5 if 2.0 is running too slowly. # - This makes small or blurry text easier to recognize # - 1.0 = original size, 2.0 = double size # - Larger values improve accuracy for small fonts but increase processing time ZV_ST_IMAGE_PATH = PI_OS.path.join( ZV_ST_SOURCES_FOLDER, ZV_ST_IMAGE_FILENAME ) ZV_LI_OCR_RESULTS = ZV_OB_READER.readtext( ZV_ST_IMAGE_PATH, detail=1, mag_ratio=2.0 ) # Step 1.4/ Show raw OCR results for ZV_DI_ITEM in ZV_LI_OCR_RESULTS: ZV_LI_BBOX, ZV_ST_TEXT, ZV_NU_CONF = ZV_DI_ITEM # Bounding box (xbox) -> 4 points [top-left, top-right, bottom-right, bottom-left] # Each point is (x, y) coordinate on the image # Example: [[100,50],[200,50],[200,100],[100,100]] # You can use these coordinates to draw rectangles around text or locate text positions # (100,50) (200,50) # ┌─────────────┐ # │ │ # │ Text │ # │ │ # └─────────────┘ # (100,100) (200,100) print(f"Text: {ZV_ST_TEXT}") # Original OCR text print(f"Bounding Box (XBox): {ZV_LI_BBOX}") # XBox coordinates print(f"Confidence: {ZV_NU_CONF:.2f}") # OCR confidence (0~1) print("-" * 40) # ========================= # STEP 2: Merge OCR Results into Lines and Segments # ========================= # ZV_LI_OCR_RESULTS -> raw OCR output: [(bbox, text, confidence)] # ZV_NU_Y_THRESHOLD -> max vertical gap to consider blocks in the same line # ZV_NU_X_GAP_THRESHOLD -> max horizontal gap to merge blocks into one segment # ======================================================================== # HOW IT WORKS (EXAMPLE SIMPLE CASE): # ------------------------------------------------------------------------ # Suppose OCR outputs three text blocks: # 1. "Hello" -> Bbox: [[10, 10], [50, 10], [50, 30], [10, 30]] # 2. "World" -> Bbox: [[55, 12], [95, 12], [95, 32], [55, 32]] # 3. "Python" -> Bbox: [[200, 10], [260, 10], [260, 30], [200, 30]] # # STEP 1: VERTICAL GROUPING (Y-axis) # - "Hello" (Y:10), "World" (Y:12), "Python" (Y:10) # - All Y-differences <= 15 -> They are all on the SAME LINE. # # STEP 2: HORIZONTAL MERGING (X-axis) # - Gap "Hello" -> "World": 55 (X-left) - 50 (X-right) = 5 # (5 <= 90 -> MERGE into "Hello World") # # - Gap "World" -> "Python": 200 (X-left) - 95 (X-right) = 105 # (105 > 90 -> NEW SEGMENT) # # FINAL STRUCTURE: # Line 1: # - Segment 1: "Hello World" (X: 10 -> 95) # - Segment 2: "Python" (X: 200 -> 260) # ======================================================================== def FC_OCR_MERGE_LINES_WITH_SEGMENTS(ZVFCI_LI_OCR_RESULTS, ZVFCI_NU_Y_THRESHOLD, ZVFCI_NU_X_GAP_THRESHOLD): """ Merge OCR text blocks into lines and segments based on position. - Each OCR block has bounding box, text, confidence. - Lines are grouped by vertical alignment (Y coordinate). - Segments within a line are merged if horizontal gap is small. """ ZV_LI_LINES = [] # Step 1: Group text blocks by vertical alignment (Y-top) for ZV_LI_BBOX, ZV_ST_TEXT, ZV_NU_CONF in ZVFCI_LI_OCR_RESULTS: # Get top-left and top-right positions ZV_NU_Y_TOP = min(ZV_LI_POINT[1] for ZV_LI_POINT in ZV_LI_BBOX) ZV_NU_X_LEFT = min(ZV_LI_POINT[0] for ZV_LI_POINT in ZV_LI_BBOX) ZV_NU_X_RIGHT = max(ZV_LI_POINT[0] for ZV_LI_POINT in ZV_LI_BBOX) # Clean text (optional, can remove extra spaces) ZV_ST_TEXT = ZV_ST_TEXT.strip() # Try to match this block to existing line for ZV_DI_LINE in ZV_LI_LINES: if abs(ZV_DI_LINE["y_top"] - ZV_NU_Y_TOP) < ZVFCI_NU_Y_THRESHOLD: ZV_DI_LINE["items"].append({ "text": ZV_ST_TEXT, "x_left": ZV_NU_X_LEFT, "x_right": ZV_NU_X_RIGHT }) break else: # Create new line if no matching Y-top found ZV_LI_LINES.append({ "y_top": ZV_NU_Y_TOP, "items": [{ "text": ZV_ST_TEXT, "x_left": ZV_NU_X_LEFT, "x_right": ZV_NU_X_RIGHT }] }) # Step 2: Sort lines from top to bottom ZV_LI_LINES.sort(key=lambda ZV_DI_L: ZV_DI_L["y_top"]) ZV_LI_MERGED = [] # Step 3: Merge items horizontally within each line for ZV_DI_LINE in ZV_LI_LINES: # Sort items from left to right ZV_LI_ITEMS = sorted(ZV_DI_LINE["items"], key=lambda ZV_DI_I: ZV_DI_I["x_left"]) ZV_LI_SEGMENTS = [] if not ZV_LI_ITEMS: continue ZV_DI_CURRENT = ZV_LI_ITEMS[0] # Merge items based on horizontal gap for ZV_DI_ITEM in ZV_LI_ITEMS[1:]: if ZV_DI_ITEM["x_left"] - ZV_DI_CURRENT["x_right"] > ZVFCI_NU_X_GAP_THRESHOLD: # Gap too large -> start new segment ZV_LI_SEGMENTS.append(ZV_DI_CURRENT) ZV_DI_CURRENT = ZV_DI_ITEM else: # Merge current segment ZV_DI_CURRENT = { "text": ZV_DI_CURRENT["text"] + " " + ZV_DI_ITEM["text"], "x_left": ZV_DI_CURRENT["x_left"], "x_right": ZV_DI_ITEM["x_right"] } # Add last segment ZV_LI_SEGMENTS.append(ZV_DI_CURRENT) # Add merged line ZV_LI_MERGED.append({ "y_top": ZV_DI_LINE["y_top"], "segments": ZV_LI_SEGMENTS }) return ZV_LI_MERGED # ========================= # DEMO: Show merged lines and segments # ========================= # 1/ Vertical threshold for grouping text blocks into same line ZV_NU_Y_THRESHOLD = 15 # 2/ Horizontal gap threshold for merging/separating text segments ZV_NU_X_GAP_THRESHOLD = 90 ZV_LI_MERGED_LINES = FC_OCR_MERGE_LINES_WITH_SEGMENTS(ZV_LI_OCR_RESULTS, ZV_NU_Y_THRESHOLD, ZV_NU_X_GAP_THRESHOLD) for ZV_DI_LINE in ZV_LI_MERGED_LINES: print(f"Line Y-top: {ZV_DI_LINE['y_top']}") for ZV_DI_SEG in ZV_DI_LINE['segments']: print(f" Segment: {ZV_DI_SEG['text']} (X: {ZV_DI_SEG['x_left']}->{ZV_DI_SEG['x_right']})") print("-" * 40) # ========================= # Step 3: Extract Dates, Titles, and Authors from Merged Lines # ========================= # Logic: # 1. Merge all segments in a line into a single string. # 2. Check if the line is a metadata line (contains "authored"). # - If yes, extract author name. # - Assign the previous line as the title. # - Normalize ADMIN/TEMPO codes in the title. # - Assign the nearest date above the title from detected dates. # 3. If the line is not metadata, search for date strings. # - Save detected dates for later matching with titles/authors. # 4. Append each extracted record (Date / Title / Author) to results. # 5. Finally, create a DataFrame and extract ADMIN/TEMPO ticket numbers. # ------------------------- # Helper functions # ------------------------- def FC_TEXT_CHECK_METADATA_LINE(ZVFCI_ST_TEXT): """Return True if line contains 'authored' (metadata line with author info)""" return ZV_ST_REGEX_PATTERN_TEXT2 in ZVFCI_ST_TEXT.lower() def FC_TEXT_EXTRACT_AUTHOR(ZVFCI_ST_TEXT): """Extract the author name from a metadata line (before 'authored')""" ZV_ST_CLEANED = ZVFCI_ST_TEXT.strip() ZV_OB_REGEX_MATCH_WORD = PI_RE.search( rf'^(.*?)(?={ZV_ST_REGEX_PATTERN_TEXT2})', ZV_ST_CLEANED, PI_RE.I ) ZV_OB_REGEX_MATCH_WORD = PI_RE.search(r'^(.*?)(?=authored)', ZV_ST_CLEANED, PI_RE.I) return ZV_OB_REGEX_MATCH_WORD.group(1).strip() if ZV_OB_REGEX_MATCH_WORD else "Unknown" def FC_TEXT_STRIP_UI_INDEX(ZVFCI_ST_TITLE): """Remove numbering or UI symbols from the title line""" return PI_RE.sub(r'^[\s#\-\–\—_:·\d]+', '', ZVFCI_ST_TITLE).strip() def FC_TEXT_NORMALIZE_ADMIN_CODE(ZVFCI_ST_TEXT): """Normalize ADMIN/TEMPO codes (e.g., 'ADMIN -123' -> 'ADMIN-123')""" return PI_RE.sub( rf'\b({ZV_ST_REGEX_PATTERN_TEXT1})\s*-\s*(\d+)\b', r'\1-\2', ZVFCI_ST_TEXT, flags=PI_RE.I ) def FC_DATE_NORMALIZE(ZVFCI_ST_TEXT): """Convert detected date string into YYYY-MM-DD format""" if not ZVFCI_ST_TEXT: return None ZV_OB_REGEX_MONTHDDYYYY = PI_RE.search(r'([a-zA-Z]{3})[^0-9]*(\d{1,2})[^0-9]*(\d{4})', ZVFCI_ST_TEXT) if not ZV_OB_REGEX_MONTHDDYYYY: return None ZV_ST_MONTH, ZV_ST_DAY, ZV_ST_YEAR = ZV_OB_REGEX_MONTHDDYYYY.group(1).title(), ZV_OB_REGEX_MONTHDDYYYY.group(2).zfill(2), ZV_OB_REGEX_MONTHDDYYYY.group(3) try: ZV_DT_DATE = PI_DATETIME.strptime(f"{ZV_ST_MONTH} {ZV_ST_DAY} {ZV_ST_YEAR}", "%b %d %Y") return ZV_DT_DATE.strftime("%Y-%m-%d") except: return None # ========================= # Process merged lines # ========================= ZV_LI_DI_DATES = [] # Store detected dates with their Y positions ZV_LI_RESULTS = [] # Store final extracted records for ZV_NU_IDX, ZV_DI_LINE in enumerate(ZV_LI_MERGED_LINES): # Merge all segments in the line into a single string ZV_ST_FULL_LINE = " ".join(ZV_DI_SEG['text'] for ZV_DI_SEG in ZV_DI_LINE['segments']).replace('.', '') # ----------------------------- # Step 3a: Metadata line (contains author info) # ----------------------------- if FC_TEXT_CHECK_METADATA_LINE(ZV_ST_FULL_LINE): ZV_ST_AUTHOR_NAME = FC_TEXT_EXTRACT_AUTHOR(ZV_ST_FULL_LINE) ZV_ST_ASSIGNED_DATE = "N/A" ZV_ST_CLEAN_TITLE = "N/A" # Take the previous line as the title if ZV_NU_IDX > 0: ZV_DI_TITLE_LINE = ZV_LI_MERGED_LINES[ZV_NU_IDX - 1] ZV_NU_TITLE_Y = ZV_DI_TITLE_LINE["y_top"] # Assign the nearest date above the title for ZV_DI_DATE in reversed(ZV_LI_DI_DATES): if ZV_DI_DATE["y"] <= ZV_NU_TITLE_Y: ZV_ST_ASSIGNED_DATE = ZV_DI_DATE["date"] break # Clean and normalize the title ZV_ST_RAW_TITLE = ZV_DI_TITLE_LINE["segments"][0]["text"] ZV_ST_CLEAN_TITLE = FC_TEXT_NORMALIZE_ADMIN_CODE( FC_TEXT_STRIP_UI_INDEX(ZV_ST_RAW_TITLE) ) # Append final record ZV_LI_RESULTS.append({ "Date": ZV_ST_ASSIGNED_DATE, "Title": ZV_ST_CLEAN_TITLE, "Author": ZV_ST_AUTHOR_NAME }) # ----------------------------- # Step 3b: Non-metadata line (check for date) # ----------------------------- else: ZV_OB_REGEX_MATCH_DATE = ZV_OB_REGEX_PATTERN_DATE.search(ZV_ST_FULL_LINE) if ZV_OB_REGEX_MATCH_DATE: # Save detected date for later matching with titles ZV_LI_DI_DATES.append({ "date": FC_DATE_NORMALIZE(ZV_OB_REGEX_MATCH_DATE.group(0)), "y": ZV_DI_LINE["y_top"] }) # ========================= # Step 4: Create DataFrame # ========================= ZV_DF_RESULT = PI_PANDAS.DataFrame(ZV_LI_RESULTS) def FC_EXTRACT_TICKET(ZVFCI_ST_TITLE): """Extract ADMIN/TEMPO ticket number from title if exists""" ZV_OB_REGEX_MATCH_ADMINCODE = PI_RE.search(rf'\b({ZV_ST_REGEX_PATTERN_TEXT1})-\d+\b', ZVFCI_ST_TITLE, PI_RE.I) return ZV_OB_REGEX_MATCH_ADMINCODE.group(0).upper() if ZV_OB_REGEX_MATCH_ADMINCODE else None if not ZV_DF_RESULT.empty: ZV_DF_RESULT["Title"] = ZV_DF_RESULT["Title"].astype(str).str.strip() ZV_DF_RESULT["ADMIN/TEMPO number"] = ZV_DF_RESULT["Title"].apply(FC_EXTRACT_TICKET) # Show DataFrame with full content PI_PANDAS.set_option('display.max_colwidth', None) print(ZV_DF_RESULT) ZV_DF_RESULT = PI_POLARS.from_pandas( ZV_DF_RESULT ) # Export to Excel FC_EXPORT_EXCEL_POLARS( ZV_DF_RESULT, ZV_ST_RESULTS_FOLDER, ZV_ST_RESULTS_FILENAME )