CoolFace
Apppublic

sai-Rohan/Dark_Pattern_Detection_API

sourceHugging Faceupdated 20d agoView on Hugging Face
0likes
inference.py4788 linesDownload Raw Back to root
1"""2Optimized inference pipeline for Multimodal Dark Pattern Detection.3Pipeline4--------5Image6  |7  +--> DINOv2 --------------------+8  |                               |9  +--> YOLO --> UI Graph --> GAT -+--> Fusion --> Classifier10  |                               |11  +--> EasyOCR --> Structured Text12                                  |13                                  +--> ModernBERT14Designed for:15    - FastAPI16    - Hugging Face Docker Space17    - GPU/CPU execution18    - One-time model loading19    - PIL Image input20    - No training code21    - No dataset dependencies22    - No disk-based inference cache23"""24 25from __future__ import annotations26 27import copy28import dataclasses29import logging30import math31import os32import re33import unicodedata34import difflib35import json36 37from dataclasses import dataclass38from typing import Any, Dict, List, Optional, Tuple, Union39 40import numpy as np41from PIL import Image42from huggingface_hub import snapshot_download43import torch44import torch.nn as nn45import torch.nn.functional as F46 47from torch_geometric.data import Data48from torch_geometric.nn import (49    GATv2Conv,50    global_mean_pool,51)52 53import easyocr54 55from transformers import (56    AutoTokenizer,57    AutoModel,58    Dinov2Model,59)60 61from huggingface_hub import hf_hub_download62 63 64# ============================================================65# LOGGING66# ============================================================67 68logging.basicConfig(69    level=logging.INFO,70    format="%(asctime)s | %(levelname)s | %(message)s",71)72 73logger = logging.getLogger("dark-pattern-inference")74 75 76# ============================================================77# DEVICE78# ============================================================79 80DEVICE = torch.device(81    "cuda" if torch.cuda.is_available() else "cpu"82)83 84logger.info("Device: %s", DEVICE)85 86if DEVICE.type == "cuda":87    logger.info(88        "GPU: %s",89        torch.cuda.get_device_name(0)90    )91 92 93# ============================================================94# MODEL REPOSITORY95# ============================================================96 97MODEL_REPO = "sai-Rohan/Dark_pattern_Detection_models"98 99 100# ============================================================101# INFERENCE CONFIG102# ============================================================103 104@dataclass105class Config:106 107    # --------------------------------------------------------108    # DINOv2109    # --------------------------------------------------------110 111    DINO_IMAGE_SIZE: int = 224112    DINO_OUTPUT_DIM: int = 768113 114    # --------------------------------------------------------115    # YOLO116    # --------------------------------------------------------117 118    YOLO_CONF: float = 0.25119    YOLO_IOU: float = 0.45120    YOLO_IMAGE_SIZE: int = 640121    YOLO_MAX_DETECTIONS: int = 300122 123    # --------------------------------------------------------124    # OCR125    # --------------------------------------------------------126 127    OCR_CONF: float = 0.65128    MIN_TEXT_CHARS: int = 2129 130    # --------------------------------------------------------131    # ModernBERT132    # --------------------------------------------------------133 134    TEXT_MAX_LENGTH: int = 256135    TEXT_OUTPUT_DIM: int = 768136    TEXT_POOLING: str = "mean"137 138    # --------------------------------------------------------139    # Fusion140    # --------------------------------------------------------141 142    FUSION_DIM: int = 256143    PROJECTION_DROPOUT: float = 0.2144 145    FUSION_MODE: str = "weighted"146 147    CLASSIFIER_DROPOUT_1: float = 0.3148    CLASSIFIER_DROPOUT_2: float = 0.2149 150    # --------------------------------------------------------151    # Graph152    # --------------------------------------------------------153 154    CONTAINMENT_THRESHOLD: float = 0.80155    CONTAINMENT_MIN_AREA_RATIO: float = 1.15156 157    ALIGNMENT_TOLERANCE: float = 0.02158 159    LARGE_BOX_WIDTH_RATIO: float = 0.85160    LARGE_BOX_HEIGHT_RATIO: float = 0.85161 162    ROW_OVERLAP_THRESHOLD: float = 0.5163    ROW_CENTER_TOLERANCE: float = 0.035164 165    COLUMN_OVERLAP_THRESHOLD: float = 0.5166    COLUMN_CENTER_TOLERANCE: float = 0.035167 168    OVERLAP_IOU_THRESHOLD: float = 0.20169 170    PROXIMITY_DISTANCE_THRESHOLD: float = 0.15171 172    READING_ORDER_ROW_TOLERANCE: float = 0.035173 174    ENABLE_CONNECTIVITY_FALLBACK: bool = True175 176    MIN_VALID_BOX_SIZE: float = 0.001177 178    # --------------------------------------------------------179    # Localization (Leave-One-Component-Out attribution)180    #181    # IMPORTANT:182    # There is no trained localization checkpoint. These183    # settings only control the *post-hoc* perturbation-based184    # attribution procedure that reuses the existing trained185    # classifier. See `leave_one_component_out_attribution`.186    # --------------------------------------------------------187 188    LOCALIZATION_TOP_K: int = 5189 190    # Components with attribution_logit <= this value are not191    # considered "supporting" the dark-pattern prediction.192    LOCALIZATION_MIN_ATTRIBUTION: float = 0.0193 194 195CFG = Config()196 197 198 199def get_device():200    """201    Determine the device at runtime.202 203    This must be called after the ZeroGPU allocation.204    """205 206    if torch.cuda.is_available():207        device = torch.device("cuda")208    else:209        device = torch.device("cpu")210 211    logger.info("Using device: %s", device)212 213    if device.type == "cuda":214        logger.info(215            "GPU: %s",216            torch.cuda.get_device_name(0)217        )218 219    return device220 221 222 223 224# ============================================================225# CHECKPOINT DOWNLOADER226# ============================================================227 228def download_model(filename: str) -> str:229    """230    Download one model file from the Hugging Face model repository.231    huggingface_hub automatically caches downloaded files.232    """233 234    logger.info("Preparing model: %s", filename)235 236    return hf_hub_download(237        repo_id=MODEL_REPO,238        filename=filename,239    )240 241 242# ============================================================243# DINO PREPROCESSOR244# ============================================================245 246IMAGENET_MEAN = np.array(247    [0.485, 0.456, 0.406],248    dtype=np.float32,249)250 251IMAGENET_STD = np.array(252    [0.229, 0.224, 0.225],253    dtype=np.float32,254)255 256 257class DinoPreprocessor:258 259    def __init__(self, image_size: int = 224):260        self.image_size = image_size261        self.resize_size = int(262            image_size * 256 / 224263        )264 265    def __call__(266        self,267        image: Image.Image,268    ) -> torch.Tensor:269 270        image = image.convert("RGB")271 272        w, h = image.size273 274        scale = self.resize_size / min(w, h)275 276        new_w = max(277            1,278            round(w * scale)279        )280 281        new_h = max(282            1,283            round(h * scale)284        )285 286        image = image.resize(287            (new_w, new_h),288            Image.Resampling.BICUBIC,289        )290 291        left = (new_w - self.image_size) // 2292        top = (new_h - self.image_size) // 2293 294        image = image.crop(295            (296                left,297                top,298                left + self.image_size,299                top + self.image_size,300            )301        )302 303        arr = (304            np.asarray(image).astype(305                np.float32306            ) / 255.0307        )308 309        arr = (310            arr - IMAGENET_MEAN311        ) / IMAGENET_STD312 313        tensor = torch.from_numpy(314            arr.transpose(2, 0, 1)315        ).float()316 317        return tensor318 319 320# ============================================================321# DINOv2322# ============================================================323 324class DinoExtractor(nn.Module):325 326    def __init__(327        self,328        checkpoint_path: str,329        output_dim: int = 768,330        device=None,331    ):332 333        super().__init__()334        self.device = (device if device is not None else torch.device("cpu"))335 336        self.output_dim = output_dim337 338        logger.info(339            "Loading DINOv2 checkpoint..."340        )341 342        checkpoint = torch.load(343            checkpoint_path,344            map_location="cpu",345            weights_only=False,346        )347 348        if "teacher" in checkpoint:349            raw_state = checkpoint["teacher"]350 351        elif "student" in checkpoint:352            raw_state = checkpoint["student"]353 354        else:355            raw_state = checkpoint356 357        state_dict = {}358 359        for key, value in raw_state.items():360 361            key = key.replace(362                "module.",363                ""364            )365 366            if key.startswith(367                "backbone."368            ):369                key = key[370                    len("backbone.") :371                ]372 373                state_dict[key] = value374 375        logger.info(376            "DINO backbone parameters: %d",377            len(state_dict),378        )379 380        model = Dinov2Model.from_pretrained(381            "facebook/dinov2-base"382        )383 384        missing, unexpected = (385            model.load_state_dict(386                state_dict,387                strict=False,388            )389        )390 391        if len(missing) > 10:392 393            raise RuntimeError(394                "DINOv2 checkpoint mismatch: "395                f"{len(missing)} missing keys"396            )397 398        if model.config.hidden_size != output_dim:399 400            raise RuntimeError(401                f"DINO output dimension is "402                f"{model.config.hidden_size}, "403                f"expected {output_dim}"404            )405 406        self.model = model.to(self.device)407 408        self.model.eval()409 410        for parameter in self.model.parameters():411            parameter.requires_grad_(False)412 413        logger.info(414            "DINOv2 loaded successfully"415        )416 417    @torch.inference_mode()418    def forward(419        self,420        pixel_values: torch.Tensor,421    ) -> torch.Tensor:422 423        output = self.model(424            pixel_values=pixel_values425        )426 427        if (428            getattr(429                output,430                "pooler_output",431                None,432            )433            is not None434        ):435            feature = output.pooler_output436 437        else:438            feature = (439                output440                .last_hidden_state[:, 0]441            )442 443        if feature.shape[-1] != self.output_dim:444            raise RuntimeError(445                f"DINO feature dimension "446                f"{feature.shape[-1]} != "447                f"{self.output_dim}"448            )449 450        return feature451 452 453# ============================================================454# YOLO455# ============================================================456 457class YoloDetector:458 459    def __init__(460        self,461        checkpoint_path: str,462        conf: float,463        iou: float,464        image_size: int,465        max_det: int,466        device=None,467    ):468 469        470        self.device = (device if device is not None else torch.device("cpu"))471 472        from ultralytics.models.yolo.detect import (473            DetectionPredictor,474        )475 476        self.DetectionPredictor = (477            DetectionPredictor478        )479 480        logger.info(481            "Loading YOLO..."482        )483 484        checkpoint = torch.load(485            checkpoint_path,486            map_location=self.device,487            weights_only=False,488        )489 490        if checkpoint.get("ema") is not None:491 492            self.model = checkpoint["ema"]493 494        elif checkpoint.get("model") is not None:495 496            self.model = checkpoint["model"]497 498        else:499 500            raise RuntimeError(501                "YOLO checkpoint contains "502                "neither 'ema' nor 'model'"503            )504 505        self.model = (506            self.model507            .to(self.device)508            .eval()509        )510 511        for parameter in self.model.parameters():512            parameter.requires_grad_(False)513 514        self.conf = conf515        self.iou = iou516        self.image_size = image_size517        self.max_det = max_det518 519        names = self.model.names520 521        if isinstance(names, dict):522 523            self.class_names = dict(524                names525            )526 527        else:528 529            self.class_names = {530                i: name531                for i, name in enumerate(names)532            }533 534        logger.info(535            "YOLO classes: %s",536            self.class_names,537        )538 539        # IMPORTANT:540        # Build predictor ONCE instead of541        # once per request.542        self.predictor = (543            self.DetectionPredictor(544                overrides={545                    "conf": self.conf,546                    "iou": self.iou,547                    "imgsz": self.image_size,548                    "max_det": self.max_det,549                    "device": self.device,550                    "verbose": False,551                }552            )553        )554 555        logger.info(556            "YOLO loaded successfully"557        )558 559    @torch.inference_mode()560    def detect(561        self,562        image: Image.Image,563    ) -> List[Dict[str, Any]]:564 565        image = image.convert("RGB")566 567        results = self.predictor(568            source=image,569            model=self.model,570        )571 572        if not results:573            return []574 575        result = results[0]576 577        if (578            result.boxes is None579            or len(result.boxes) == 0580        ):581            return []582 583        boxes = (584            result.boxes.xyxy585            .detach()586            .cpu()587            .numpy()588        )589 590        confidences = (591            result.boxes.conf592            .detach()593            .cpu()594            .numpy()595        )596 597        class_ids = (598            result.boxes.cls599            .detach()600            .cpu()601            .numpy()602            .astype(int)603        )604 605        img_h, img_w = (606            result.orig_shape607        )608 609        detections = []610 611        for box, conf, class_id in zip(612            boxes,613            confidences,614            class_ids,615        ):616 617            x1, y1, x2, y2 = box618 619            detections.append(620                {621                    "class_id": int(class_id),622 623                    "class_name":624                        self.class_names.get(625                            int(class_id),626                            "Other",627                        ),628 629                    "confidence":630                        float(conf),631 632                    "bbox": [633                        float(x1),634                        float(y1),635                        float(x2),636                        float(y2),637                    ],638 639                    "image_width":640                        int(img_w),641 642                    "image_height":643                        int(img_h),644                }645            )646 647        return detections648 649 650# ============================================================651# GRAPH CONFIG652# ============================================================653 654@dataclass655class GraphConfig:656 657    containment_threshold: float = 0.80658    containment_min_area_ratio: float = 1.15659 660    alignment_tolerance: float = 0.02661 662    large_box_width_ratio: float = 0.85663    large_box_height_ratio: float = 0.85664 665    row_overlap_threshold: float = 0.5666    row_center_tolerance: float = 0.035667 668    column_overlap_threshold: float = 0.5669    column_center_tolerance: float = 0.035670 671    overlap_iou_threshold: float = 0.20672 673    proximity_distance_threshold: float = 0.15674 675    reading_order_row_tolerance: float = 0.035676 677    enable_connectivity_fallback: bool = True678 679    min_valid_box_size: float = 0.001680 681 682# ============================================================683# GRAPH SCHEMA684# ============================================================685 686@dataclass687class GraphSchema:688 689    class_names: List[str]690 691    num_classes: int692 693    node_feature_names: List[str]694 695    node_feature_dim: int696 697    edge_relation_names: List[str]698 699    edge_continuous_names: List[str]700 701    edge_feature_dim: int702 703    graph_config: Dict[str, Any]704 705    schema_version: str = "1.0"706 707    @staticmethod708    def load(path: str):709 710        with open(711            path,712            "r",713            encoding="utf-8",714        ) as f:715 716            data = json.load(f)717 718        return GraphSchema(719            **data720        )721 722 723# ============================================================724# GRAPH DEFINITIONS725# ============================================================726 727EDGE_RELATION_NAMES = [728 729    "contains",730    "inside",731 732    "left_aligned",733    "right_aligned",734 735    "top_aligned",736    "bottom_aligned",737 738    "horizontal_center_aligned",739    "vertical_center_aligned",740 741    "same_row",742    "same_column",743 744    "overlap",745 746    "reading_order",747 748    "controlled_proximity",749 750    "connectivity_fallback",751]752 753NUM_RELATIONS = len(754    EDGE_RELATION_NAMES755)756 757EDGE_CONTINUOUS_NAMES = [758    "norm_center_distance",759    "angle_sin",760    "angle_cos",761]762 763NODE_NUMERIC_NAMES = [764    "norm_center_x",765    "norm_center_y",766    "norm_width",767    "norm_height",768    "norm_area",769    "confidence",770]771 772 773# ============================================================774# BOX775# ============================================================776 777class Box:778 779    __slots__ = (780        "x1",781        "y1",782        "x2",783        "y2",784        "cx",785        "cy",786        "w",787        "h",788        "area",789    )790 791    def __init__(792        self,793        x1,794        y1,795        x2,796        y2,797    ):798 799        self.x1 = x1800        self.y1 = y1801        self.x2 = x2802        self.y2 = y2803 804        self.cx = (805            x1 + x2806        ) / 2.0807 808        self.cy = (809            y1 + y2810        ) / 2.0811 812        self.w = max(813            x2 - x1,814            0.0,815        )816 817        self.h = max(818            y2 - y1,819            0.0,820        )821 822        self.area = (823            self.w * self.h824        )825 826 827def intersection_area(828    a: Box,829    b: Box,830) -> float:831 832    ix1 = max(833        a.x1,834        b.x1,835    )836 837    iy1 = max(838        a.y1,839        b.y1,840    )841 842    ix2 = min(843        a.x2,844        b.x2,845    )846 847    iy2 = min(848        a.y2,849        b.y2,850    )851 852    return (853        max(854            0.0,855            ix2 - ix1,856        )857        *858        max(859            0.0,860            iy2 - iy1,861        )862    )863 864 865def box_iou(866    a: Box,867    b: Box,868) -> float:869 870    intersection = (871        intersection_area(872            a,873            b,874        )875    )876 877    union = (878        a.area879        + b.area880        - intersection881    )882 883    return (884        intersection / union885        if union > 0886        else 0.0887    )888 889 890def directed_containment(891    a: Box,892    b: Box,893    cfg: GraphConfig,894) -> bool:895 896    if b.area <= 0:897        return False898 899    intersection = (900        intersection_area(901            a,902            b,903        )904    )905 906    coverage = (907        intersection / b.area908    )909 910    return (911        coverage912        >= cfg.containment_threshold913        and914        a.area915        >= b.area916        * cfg.containment_min_area_ratio917    )918 919 920# ============================================================921# GRAPH RELATIONSHIPS922# ============================================================923 924def alignment_flags(925    a: Box,926    b: Box,927    cfg: GraphConfig,928):929 930    a_wide = (931        a.w >= cfg.large_box_width_ratio932    )933 934    b_wide = (935        b.w >= cfg.large_box_width_ratio936    )937 938    a_tall = (939        a.h >= cfg.large_box_height_ratio940    )941 942    b_tall = (943        b.h >= cfg.large_box_height_ratio944    )945 946    tolerance = (947        cfg.alignment_tolerance948    )949 950    flags = {951        "left_aligned": False,952        "right_aligned": False,953        "top_aligned": False,954        "bottom_aligned": False,955        "horizontal_center_aligned": False,956        "vertical_center_aligned": False,957    }958 959    if not (a_wide or b_wide):960 961        flags["left_aligned"] = (962            abs(a.x1 - b.x1)963            <= tolerance964        )965 966        flags["right_aligned"] = (967            abs(a.x2 - b.x2)968            <= tolerance969        )970 971        flags[972            "horizontal_center_aligned"973        ] = (974            abs(a.cx - b.cx)975            <= tolerance976        )977 978    if not (a_tall or b_tall):979 980        flags["top_aligned"] = (981            abs(a.y1 - b.y1)982            <= tolerance983        )984 985        flags["bottom_aligned"] = (986            abs(a.y2 - b.y2)987            <= tolerance988        )989 990        flags[991            "vertical_center_aligned"992        ] = (993            abs(a.cy - b.cy)994            <= tolerance995        )996 997    return flags998 999 1000def vertical_overlap_ratio(1001    a: Box,1002    b: Box,1003):1004 1005    iy1 = max(1006        a.y1,1007        b.y1,1008    )1009 1010    iy2 = min(1011        a.y2,1012        b.y2,1013    )1014 1015    overlap = max(1016        0.0,1017        iy2 - iy1,1018    )1019 1020    minimum = max(1021        min(a.h, b.h),1022        1e-6,1023    )1024 1025    return overlap / minimum1026 1027 1028def horizontal_overlap_ratio(1029    a: Box,1030    b: Box,1031):1032 1033    ix1 = max(1034        a.x1,1035        b.x1,1036    )1037 1038    ix2 = min(1039        a.x2,1040        b.x2,1041    )1042 1043    overlap = max(1044        0.0,1045        ix2 - ix1,1046    )1047 1048    minimum = max(1049        min(a.w, b.w),1050        1e-6,1051    )1052 1053    return overlap / minimum1054 1055 1056def same_row(1057    a: Box,1058    b: Box,1059    cfg: GraphConfig,1060):1061 1062    return (1063        vertical_overlap_ratio(1064            a,1065            b,1066        )1067        >= cfg.row_overlap_threshold1068        and1069        abs(a.cy - b.cy)1070        <= cfg.row_center_tolerance1071    )1072 1073 1074def same_column(1075    a: Box,1076    b: Box,1077    cfg: GraphConfig,1078):1079 1080    return (1081        horizontal_overlap_ratio(1082            a,1083            b,1084        )1085        >= cfg.column_overlap_threshold1086        and1087        abs(a.cx - b.cx)1088        <= cfg.column_center_tolerance1089    )1090 1091 1092def reading_order_edges(1093    boxes: List[Box],1094    cfg: GraphConfig,1095):1096 1097    if not boxes:1098        return []1099 1100    ordered = sorted(1101        range(len(boxes)),1102        key=lambda i: boxes[i].cy,1103    )1104 1105    rows = []1106 1107    current = [ordered[0]]1108 1109    current_y = boxes[1110        ordered[0]1111    ].cy1112 1113    for idx in ordered[1:]:1114 1115        y = boxes[idx].cy1116 1117        if (1118            abs(y - current_y)1119            <= cfg.reading_order_row_tolerance1120        ):1121 1122            current.append(idx)1123 1124        else:1125 1126            rows.append(current)1127 1128            current = [idx]1129 1130            current_y = y1131 1132    rows.append(current)1133 1134    final_order = []1135 1136    for row in rows:1137 1138        final_order.extend(1139            sorted(1140                row,1141                key=lambda i:1142                    boxes[i].cx,1143            )1144        )1145 1146    return list(1147        zip(1148            final_order[:-1],1149            final_order[1:],1150        )1151    )1152 1153 1154# ============================================================1155# GRAPH BUILDER1156# ============================================================1157 1158def empty_graph(1159    schema: GraphSchema,1160):1161 1162    data = Data(1163        x=torch.zeros(1164            (1165                0,1166                schema.node_feature_dim,1167            ),1168            dtype=torch.float32,1169        ),1170 1171        edge_index=torch.zeros(1172            (2, 0),1173            dtype=torch.long,1174        ),1175 1176        edge_attr=torch.zeros(1177            (1178                0,1179                schema.edge_feature_dim,1180            ),1181            dtype=torch.float32,1182        ),1183 1184        # Explicit (empty) mapping from graph node -> original1185        # YOLO detection index. Kept even for the empty graph so1186        # downstream code can always rely on `detection_indices`1187        # being present.1188        detection_indices=torch.zeros(1189            (0,),1190            dtype=torch.long,1191        ),1192 1193        num_nodes=0,1194    )1195 1196    data.is_empty = True1197 1198    return data1199 1200 

Showing the first 1,200 of 4788 lines. Download the file for the rest.