| """ |
| Author: Juan Pablo Triana Martinez |
| Gradio app β LinkNet Semantic Text Segmentation (12-class DocLayNet). |
| Entry-point for HuggingFace Spaces. |
| """ |
| import gradio as gr |
| import matplotlib.pyplot as plt |
| import numpy as np |
| import torch |
| from PIL import Image |
| from torchvision import transforms |
| from typing import Dict |
|
|
| from model import create_semantic_model |
|
|
| |
| MEAN = [0.9329, 0.9343, 0.9341] |
| STD = [0.1651, 0.1592, 0.1623] |
| IMG_SIZE = 512 |
| NUM_CLASSES = 12 |
| MODEL_PATH = "linknet_semantic_doclaynet_20_percent_seed_7_ce_0.25_dice_1.0_.pth" |
|
|
| CLASS_ID_TO_NAME: Dict[int, str] = { |
| 0: "background", |
| 1: "Caption", |
| 2: "Footnote", |
| 3: "Formula", |
| 4: "List-item", |
| 5: "Page-footer", |
| 6: "Page-header", |
| 7: "Picture", |
| 8: "Section-header", |
| 9: "Table", |
| 10: "Text", |
| 11: "Title", |
| } |
|
|
| |
| inference_transform = transforms.Compose([ |
| transforms.Resize((IMG_SIZE, IMG_SIZE)), |
| transforms.ToTensor(), |
| transforms.Normalize(mean=MEAN, std=STD), |
| ]) |
|
|
| api_transform = transforms.Compose([ |
| transforms.Resize((IMG_SIZE, IMG_SIZE)), |
| transforms.ToTensor(), |
| ]) |
|
|
| |
| def _build_colormap(num_classes: int) -> np.ndarray: |
| cmap = plt.get_cmap("tab20", num_classes) |
| return np.array([cmap(i)[:3] for i in range(num_classes)]) |
|
|
|
|
| def _apply_colormap(mask_hw: np.ndarray, lut: np.ndarray) -> np.ndarray: |
| H, W = mask_hw.shape |
| rgb = np.zeros((H, W, 3), dtype=np.float32) |
| for c in range(lut.shape[0]): |
| rgb[mask_hw == c] = lut[c] |
| return rgb |
|
|
|
|
| COLOR_LUT = _build_colormap(NUM_CLASSES) |
|
|
| |
| device = "cpu" |
| model = create_semantic_model().to(device) |
| model.load_state_dict(torch.load(MODEL_PATH, map_location=device)) |
| model.eval() |
|
|
| |
| def _logits_to_mask(logits: torch.Tensor) -> torch.Tensor: |
| return torch.argmax(logits, dim=1) |
|
|
|
|
| def _mask_to_rgb(mask: torch.Tensor) -> np.ndarray: |
| mask_hw = mask.squeeze().cpu().numpy().astype(np.int32) |
| return (_apply_colormap(mask_hw, COLOR_LUT) * 255).clip(0, 255).astype(np.uint8) |
|
|
|
|
| def _overlay_mask(image: Image.Image, mask: torch.Tensor, alpha: float = 0.45) -> np.ndarray: |
| img_np = api_transform(image).permute(1, 2, 0).numpy() |
| mask_rgb = _mask_to_rgb(mask).astype(np.float32) / 255.0 |
| blended = (1 - alpha) * img_np + alpha * mask_rgb |
| return (blended.clip(0, 1) * 255).astype(np.uint8) |
|
|
|
|
| |
| def inference(image: Image.Image, gt_mask: Image.Image = None): |
| img_tensor = inference_transform(image).unsqueeze(0) |
|
|
| with torch.inference_mode(): |
| logits = model(img_tensor) |
| pred_mask = _logits_to_mask(logits) |
|
|
| pred_mask_rgb = _mask_to_rgb(pred_mask) |
| overlay = _overlay_mask(image, pred_mask) |
|
|
| if gt_mask is None: |
| return pred_mask_rgb, overlay, "No ground truth provided β metrics unavailable" |
|
|
| |
| gt_pil = gt_mask.resize((IMG_SIZE, IMG_SIZE)) |
| if gt_pil.mode in ("L", "P"): |
| gt_np = np.array(gt_pil.convert("L"), dtype=np.int64) |
| else: |
| gt_rgb = np.array(gt_pil.convert("RGB"), dtype=np.float32) / 255.0 |
| dists = np.linalg.norm( |
| gt_rgb[:, :, np.newaxis, :] - COLOR_LUT[np.newaxis, np.newaxis, :, :], |
| axis=-1, |
| ) |
| gt_np = np.argmin(dists, axis=-1).astype(np.int64) |
|
|
| gt_tensor = torch.from_numpy(gt_np).unsqueeze(0) |
| pred_flat = pred_mask.squeeze().cpu() |
| gt_flat = gt_tensor.squeeze() |
|
|
| lines = ["=== Per-class IoU (background excluded) ==="] |
| ious = [] |
| for c in range(1, NUM_CLASSES): |
| pred_c = (pred_flat == c) |
| gt_c = (gt_flat == c) |
| inter = (pred_c & gt_c).sum().float() |
| union = (pred_c | gt_c).sum().float() |
| iou = (inter / (union + 1e-7)).item() |
| ious.append(iou) |
| name = CLASS_ID_TO_NAME.get(c, str(c)) |
| lines.append(f" [{c:2d}] {name:<16s}: {iou:.4f}") |
|
|
| import math |
| macro_iou = float(sum(ious) / len(ious)) |
| lines.append(f" Macro IoU (excl. bg): {macro_iou:.4f}") |
|
|
| return pred_mask_rgb, overlay, "".join(lines) |
|
|
|
|
| |
| demo = gr.Interface( |
| fn=inference, |
| inputs=[ |
| gr.Image(type="pil", label="Input Image"), |
| gr.Image(type="pil", label="Ground Truth Semantic Mask (optional)"), |
| ], |
| outputs=[ |
| gr.Image(label="Predicted Semantic Mask"), |
| gr.Image(label="Overlay"), |
| gr.Textbox(label="Metrics"), |
| ], |
| title="LinkNet Semantic Segmentation β DocLayNet ChestNut π°", |
| description=( |
| "Upload a document image to obtain its 12-class semantic segmentation mask. " |
| "Classes: background, Caption, Footnote, Formula, List-item, Page-footer, " |
| "Page-header, Picture, Section-header, Table, Text, Title. " |
| "Optionally upload a ground-truth mask (colour-coded PNG) to compute per-class IoU." |
| ), |
| examples=[ |
| ["examples/semantic_1_img.png", "examples/semantic_1_semantic_mask.png"], |
| ["examples/semantic_2_img.png", "examples/semantic_2_semantic_mask.png"], |
| ["examples/semantic_3_img.png", "examples/semantic_3_semantic_mask.png"], |
| ], |
| ) |
|
|
| if __name__ == "__main__": |
| demo.launch() |
|
|