| """ |
| Table-layout cleaning utilities (in-memory version of process_table_pipeline.py). |
| |
| Exposes: |
| - run_detection(model, image_np, conf, iou, imgsz) -> detections dict |
| - create_labelme_json(...) : build labelme JSON + apply expand/dedupe logic |
| - clean_labelme(...) : fix row widths & column heights |
| - process_image(model, pil_image, conf, iou, imgsz) -> (labelme_json, shapes) |
| |
| These functions work on numpy/PIL data so they can be called from a Gradio app |
| without touching the filesystem. |
| """ |
|
|
| import logging |
|
|
| import numpy as np |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| |
| |
| |
|
|
| def calculate_iou(box1, box2): |
| """IoU between two 4-point polygons.""" |
| def coords(points): |
| a = np.array(points) |
| return np.min(a[:, 0]), np.min(a[:, 1]), np.max(a[:, 0]), np.max(a[:, 1]) |
|
|
| x1_min, y1_min, x1_max, y1_max = coords(box1) |
| x2_min, y2_min, x2_max, y2_max = coords(box2) |
|
|
| ixmin = max(x1_min, x2_min) |
| iymin = max(y1_min, y2_min) |
| ixmax = min(x1_max, x2_max) |
| iymax = min(y1_max, y2_max) |
|
|
| if ixmax < ixmin or iymax < iymin: |
| return 0.0 |
|
|
| inter = (ixmax - ixmin) * (iymax - iymin) |
| a1 = (x1_max - x1_min) * (y1_max - y1_min) |
| a2 = (x2_max - x2_min) * (y2_max - y2_min) |
| union = a1 + a2 - inter |
| return inter / union if union else 0.0 |
|
|
|
|
| def expand_rows_to_column_width(boxes): |
| """Expand row/header width to match leftmost/rightmost column x-coordinates.""" |
| columns = [b for b in boxes if 'column' in b['class_name'].lower()] |
| if not columns: |
| return boxes |
|
|
| all_x = [] |
| for col in columns: |
| all_x.extend(np.array(col['points'])[:, 0]) |
| if not all_x: |
| return boxes |
|
|
| col_min_x, col_max_x = min(all_x), max(all_x) |
|
|
| out = [] |
| for box in boxes: |
| if any(k in box['class_name'].lower() for k in ['row', 'header']): |
| a = np.array(box['points']) |
| min_y, max_y = np.min(a[:, 1]), np.max(a[:, 1]) |
| box_copy = box.copy() |
| box_copy['points'] = [ |
| [float(col_min_x), float(min_y)], |
| [float(col_max_x), float(min_y)], |
| [float(col_max_x), float(max_y)], |
| [float(col_min_x), float(max_y)], |
| ] |
| out.append(box_copy) |
| else: |
| out.append(box) |
| return out |
|
|
|
|
| def remove_duplicate_boxes(boxes, iou_threshold=0.3): |
| """Keep only the highest-confidence box among overlapping ones.""" |
| if not boxes: |
| return boxes |
| sorted_boxes = sorted(boxes, key=lambda b: b['confidence'], reverse=True) |
| unique = [] |
| for cur in sorted_boxes: |
| if all(calculate_iou(cur['points'], k['points']) < iou_threshold for k in unique): |
| unique.append(cur) |
| return unique |
|
|
|
|
| def expand_line_width(points, image_width): |
| if len(points) != 4: |
| return points |
| a = np.array(points) |
| min_y, max_y = np.min(a[:, 1]), np.max(a[:, 1]) |
| return [ |
| [0.0, min_y], [float(image_width), min_y], |
| [float(image_width), max_y], [0.0, max_y], |
| ] |
|
|
|
|
| def expand_column_height(points, image_height): |
| if len(points) != 4: |
| return points |
| a = np.array(points) |
| min_x, max_x = np.min(a[:, 0]), np.max(a[:, 0]) |
| return [ |
| [min_x, 0.0], [max_x, 0.0], |
| [max_x, float(image_height)], [min_x, float(image_height)], |
| ] |
|
|
|
|
| |
| |
| |
|
|
| def run_detection(model, image_np, conf=0.1, iou=0.1, imgsz=640): |
| """Run YOLO on a numpy image (BGR or RGB) and return a detections dict.""" |
| results = model.predict(image_np, save=False, verbose=False, |
| conf=conf, iou=iou, imgsz=imgsz) |
|
|
| detections = {'boxes': []} |
| if not results: |
| return detections |
|
|
| result = results[0] |
| if result.boxes is None or len(result.boxes) == 0: |
| return detections |
|
|
| bboxes = result.boxes.xyxy |
| confs = result.boxes.conf |
| classes = result.boxes.cls |
|
|
| for i, bbox in enumerate(bboxes): |
| try: |
| if hasattr(bbox, 'cpu'): |
| bbox_np = bbox.cpu().numpy() |
| elif hasattr(bbox, 'numpy'): |
| bbox_np = bbox.numpy() |
| else: |
| bbox_np = np.asarray(bbox) |
|
|
| x1, y1, x2, y2 = bbox_np |
| points = [ |
| [float(x1), float(y1)], [float(x2), float(y1)], |
| [float(x2), float(y2)], [float(x1), float(y2)], |
| ] |
| conf_v = float(confs[i].item() if hasattr(confs[i], 'item') else confs[i]) |
| cls = int(classes[i].item() if hasattr(classes[i], 'item') else classes[i]) |
| class_name = model.names.get(cls, f"unknown_class_{cls}") |
|
|
| detections['boxes'].append({ |
| 'points': points, |
| 'confidence': conf_v, |
| 'class_name': class_name, |
| 'class_id': cls, |
| }) |
| except Exception as e: |
| logger.error(f"Error processing bbox {i}: {e}") |
|
|
| return detections |
|
|
|
|
| |
| |
| |
|
|
| def create_labelme_json(image_name, detections, image_height, image_width, |
| iou_threshold=0.3): |
| expanded = expand_rows_to_column_width(detections['boxes']) |
| filtered = remove_duplicate_boxes(expanded, iou_threshold) |
|
|
| shapes = [] |
| for box_data in filtered: |
| points = box_data['points'] |
| label = box_data['class_name'] |
| confidence = box_data['confidence'] |
|
|
| if 'line' in label.lower(): |
| points = expand_line_width(points, image_width) |
| elif 'column' in label.lower(): |
| points = expand_column_height(points, image_height) |
|
|
| a = np.array(points) |
| rect_points = [ |
| [float(np.min(a[:, 0])), float(np.min(a[:, 1]))], |
| [float(np.max(a[:, 0])), float(np.max(a[:, 1]))], |
| ] |
| shapes.append({ |
| "label": label, |
| "points": rect_points, |
| "group_id": None, |
| "description": f"confidence: {confidence:.2f}", |
| "shape_type": "rectangle", |
| "flags": {}, |
| }) |
|
|
| return { |
| "version": "5.0.1", |
| "flags": {}, |
| "shapes": shapes, |
| "imagePath": image_name, |
| "imageData": None, |
| "imageHeight": image_height, |
| "imageWidth": image_width, |
| } |
|
|
|
|
| def _x_bounds(shapes): |
| columns = [s for s in shapes if 'column' in s['label'].lower()] |
| if not columns: |
| return None, None |
| xs = [] |
| for c in columns: |
| xs.extend(np.array(c['points'])[:, 0]) |
| return (min(xs), max(xs)) if xs else (None, None) |
|
|
|
|
| def _y_bounds(shapes): |
| headers = [s for s in shapes if 'header' in s['label'].lower()] |
| rows = [s for s in shapes if 'row' in s['label'].lower()] |
| if not headers and not rows: |
| return None, None |
|
|
| top_y = None |
| src = headers if headers else rows |
| if src: |
| ys = [] |
| for s in src: |
| ys.extend(np.array(s['points'])[:, 1]) |
| top_y = min(ys) if ys else None |
|
|
| bottom_y = None |
| if rows: |
| ys = [] |
| for r in rows: |
| ys.extend(np.array(r['points'])[:, 1]) |
| bottom_y = max(ys) if ys else None |
|
|
| return top_y, bottom_y |
|
|
|
|
| def fix_row_widths(labelme_data): |
| shapes = labelme_data.get('shapes', []) |
| rows = [s for s in shapes if 'row' in s['label'].lower()] |
| if not rows: |
| return labelme_data |
| left_x, right_x = _x_bounds(shapes) |
| if left_x is None: |
| return labelme_data |
| for r in rows: |
| a = np.array(r['points']) |
| min_y, max_y = np.min(a[:, 1]), np.max(a[:, 1]) |
| r['points'] = [[float(left_x), float(min_y)], [float(right_x), float(max_y)]] |
| return labelme_data |
|
|
|
|
| def fix_column_heights(labelme_data): |
| shapes = labelme_data.get('shapes', []) |
| columns = [s for s in shapes if 'column' in s['label'].lower()] |
| if not columns: |
| return labelme_data |
| top_y, bottom_y = _y_bounds(shapes) |
| if top_y is None or bottom_y is None: |
| return labelme_data |
| for c in columns: |
| a = np.array(c['points']) |
| min_x, max_x = np.min(a[:, 0]), np.max(a[:, 0]) |
| c['points'] = [[float(min_x), float(top_y)], [float(max_x), float(bottom_y)]] |
| return labelme_data |
|
|
|
|
| def clean_labelme(labelme_data): |
| """Fix row widths then column heights (in place).""" |
| labelme_data = fix_row_widths(labelme_data) |
| labelme_data = fix_column_heights(labelme_data) |
| return labelme_data |
|
|
|
|
| |
| |
| |
|
|
| def process_image(model, image_np, image_name="image.png", |
| conf=0.1, iou=0.1, imgsz=640, dedupe_iou=0.3): |
| """ |
| Full pipeline on a single numpy image (RGB or BGR). |
| Returns (cleaned_labelme_json, shapes_list). |
| """ |
| height, width = image_np.shape[:2] |
| detections = run_detection(model, image_np, conf=conf, iou=iou, imgsz=imgsz) |
| labelme_json = create_labelme_json(image_name, detections, height, width, dedupe_iou) |
| labelme_json = clean_labelme(labelme_json) |
| return labelme_json, labelme_json["shapes"] |
|
|