--- library_name: onnxruntime pipeline_tag: image-classification tags: - onnx - image-classification - medieval-manuscripts - illumination-detection - mobilenet - mobilevit - glam - iiif - cultural-heritage - digital-humanities - medieval-folio - medieval - medieval-illuminations - MobileNet - MobileVit license: apache-2.0 datasets: - ENC-PSL/BSICLE-medieval-illumination-folio-bin-class-dataset base_model: - timm/mobilenetv3_small_100.lamb_in1k - timm/mobilenetv3_large_100.ra_in1k - timm/mobilenetv2_100.ra_in1k - apple/mobilevitv2-1.0-imagenet1k-256 --- # BSICLE — Binary System for Illuminated Folio Classification with Lightweight Engines [![Open in Spaces](https://huggingface.co/datasets/huggingface/badges/raw/main/open-in-hf-spaces-md.svg)](https://huggingface.co/spaces/ENC-PSL/Medieval-Illumination-Detector) [![GitHub Pages](https://img.shields.io/badge/GitHub%20Pages-online-brightgreen?logo=github)](https://odil-ai.github.io/medieval-illumination-detector/) BSICLE (pronounced bé-si-cle ; /be.zikl/) is a family of lightweight binary models for classify **illuminated folios in medieval manuscripts**. Theses models are developed at the [École nationale des chartes – PSL](https://www.chartes.psl.eu/) in context of [O.D.I.L. project](https://projet.biblissima.fr/fr/appels-projets/projets-retenus/odil-objet-detection-illuminations). These models classify manuscript pages as: - **illuminated** (miniatures, historiated initials, decorated pages etc.) - **non-illuminated** (plain text folio, printer marks, tables, cover, blank folios etc.) # Use cases Models are optimized to run **locally (CPU) or in the browser** using **edge-compatibility architecture** (MobileNet, MobileViT) and **ONNX inference** for exemple to build **IIIF filter pipelines** or to build **specialized corpora**. Try the demo web application on [HF Spaces](https://huggingface.co/spaces/ENC-PSL/Medieval-Illumination-Detector) or on [GH Pages](https://odil-ai.github.io/medieval-illumination-detector/) # Models & Results The finetuned models available in this repository are based on following architecture: - [MobileNetV2](https://huggingface.co/timm/mobilenetv2_100.ra_in1k) - [MobileNetV3](https://huggingface.co/timm/mobilenetv3_small_100.lamb_in1k) (small and large version) - [MobileViT v2](https://huggingface.co/apple/mobilevitv2-1.0-imagenet1k-256) | Architecture | Validation Accuracy | Test Accuracy | |--------------|--------------------:|--------------:| | MobileNetV2 | 0.995 | 0.982 | | MobileNetV3 Small | 0.991 | 0.968 | | MobileNetV3 Large | 1.0 | 0.986 | | MobileViT v2 | 0.995 | 0.977 | > These results should be interpreted with care. Although the models reach very high scores on the current splits, the task may be partially dataset-dependent. # Labels | Label ID | Label | |---------|------| | 0 | non_illuminated | | 1 | illuminated | # Dataset Training data comes from: [ENC-PSL/BSICLE-medieval-illumination-folio-bin-class-dataset](https://huggingface.co/datasets/ENC-PSL/BSICLE-medieval-illumination-folio-bin-class-dataset) ## Distribution of data - illuminated - train: 519 - dev : 112 - test : 111 - non_illuminated - train: 519 - dev : 111 - test : 112 > **Data augmentation.** During training, data augmentation was applied to the training split only in order to improve robustness and reduce overfitting. > The augmentation pipeline included random horizontal flips, small random rotations up to 5°, and light color jittering with brightness `0.12`, contrast `0.12`, saturation `0.08`, and hue `0.02`. > Validation and test images were evaluated without augmentation. # What counts as "illuminated"? ### Positive (illuminated) Examples include: - miniatures - historiated initials - decorative initials - scientific diagrams - maps - decorated manuscript pages ### Negative (non-illuminated) Examples include: - plain text folios - marginal decorations without images - printer marks - tables - cover - blank folios - rubricated text without illumination ### Examples | Illuminated | Not Illuminated | |:-----------:|:---------------:| | | | | | | | | | | | | | | | | | | # Usage ## Python — ONNX local ```bash pip install onnxruntime pillow numpy ``` ```python import json import numpy as np import onnxruntime as ort from PIL import Image from pathlib import Path # Model list to test. # Check if the models are stored # in your expected directory structure # (e.g. './Artefacts/{model_name}/onnx/model.onnx') MODELS = [ "mobilenetv2", "mobilenetv3_large", "mobilenetv3_small", "mobilevitv2", ] ARTEFACTS_DIR = Path("./Artefacts") # Test images IMAGE_PATHS = { "illumination": Path("./dataset/test/illustration/gahom_0020__fdf0ee350c94.jpg"), "non_illumination": Path("./dataset/test/non_illustration/CREMMA-Medieval-LAT_00007.jpg"), } def softmax(logits: np.ndarray) -> np.ndarray: """Softmax function to convert logits to probabilities. :param logits: logits array :type logits: np.ndarray :return: probabilities array :rtype: np.ndarray """ logits = logits.astype(np.float32) exp = np.exp(logits - logits.max()) return exp / exp.sum() def load_json(path: Path) -> dict: """Load a JSON file and return its content as a dictionary. :param path: path to the JSON file :type path: Path :return: content of the JSON file as a dictionary :rtype: dict """ if not path.exists(): raise FileNotFoundError(f"File not founded: {path}") return json.loads(path.read_text()) def preprocess_image(image_path: Path, pre: dict) -> np.ndarray: """Preprocess the image according to the provided configuration. :param image_path: path to the image file :type image_path: Path :param pre: preprocessing configuration (expects keys 'img_size', 'mean', 'std :type pre: dict :return: preprocessed image as a numpy array ready for model input :rtype: np.ndarray """ if not image_path.exists(): raise FileNotFoundError(f"Image not founded: {image_path}") img_size = pre["img_size"] mean = np.array(pre["mean"], dtype=np.float32) std = np.array(pre["std"], dtype=np.float32) img = Image.open(image_path).convert("RGB").resize((img_size, img_size)) x = np.asarray(img).astype(np.float32) / 255.0 x = (x - mean) / std x = x.transpose(2, 0, 1)[None].astype(np.float32) return x def predict(model_name: str, image_path: Path) -> dict: """Run inference on a single model and return the results. :param model_name: name of the model to test :type model_name: str :param image_path: path to the image file to test :type image_path: Path :return: dictionary containing the prediction results and probabilities :rtype: dict """ run = ARTEFACTS_DIR / model_name cfg = load_json(run / "inference_config.json") pre = load_json(run / "preprocess.json") model_path = run / "onnx" / "model.onnx" if not model_path.exists(): raise FileNotFoundError(f"ONNX model not founded: {model_path}") x = preprocess_image(image_path, pre) sess = ort.InferenceSession(str(model_path)) input_name = cfg.get("input_name") if input_name is None: input_name = sess.get_inputs()[0].name output = sess.run(None, {input_name: x})[0] # Cas standard : shape (1, 2) logits = output[0] probs = softmax(logits) p_illu = float(probs[1]) positive_label = cfg.get("positive_label", "illumination") negative_label = cfg.get("negative_label", "non_illumination") threshold = float(cfg.get("threshold", 0.5)) label = positive_label if p_illu >= threshold else negative_label return { "model": model_name, "image": str(image_path), "label": label, "p_illustration": p_illu, "probs": probs.tolist(), "threshold": threshold, } def main(): """Main function to run the tests on all models and images.""" for image_type, image_path in IMAGE_PATHS.items(): print("=" * 80) print(f"Image expected: {image_type}") print(f"Image: {image_path}") print("=" * 80) for model_name in MODELS: try: result = predict(model_name, image_path) print( f"{result['model']:<20} " f"=> {result['label']:<18} " f"p_illu={result['p_illustration']:.4f} " f"probs={result['probs']}" ) except Exception as e: print(f"{model_name:<20} → ERROR: {e}") print() if __name__ == "__main__": main() ``` ## Python — ONNX from Hugging Face ```bash pip install huggingface_hub onnxruntime pillow numpy ``` ```python import json import numpy as np import onnxruntime as ort from PIL import Image from pathlib import Path from huggingface_hub import snapshot_download # Repository HF that contains the ONNX models and their configs REPO_ID = "ENC-PSL/BSICLE" MODELS = [ "mobilenetv2", "mobilenetv3_large", "mobilenetv3_small", "mobilevitv2", ] IMAGE_PATHS = { "illumination": Path("./dataset/test/illustration/gahom_0020__fdf0ee350c94.jpg"), "non_illumination": Path("./dataset/test/non_illustration/CREMMA-Medieval-LAT_00007.jpg"), } def download_models(repo_id: str, model_names: list[str]) -> Path: """download models and their configs from HF Hub, and return the local path to the snapshot :param repo_id: repository id on HF Hub :type repo_id: str :param model_names: list of model names to download (e.g. ["mobilenetv2", "mobilenetv3_large"]) :return: local path to the snapshot containing the models and their configs :rtype: Path """ allow_patterns = [] for model_name in model_names: allow_patterns.extend([ f"{model_name}/onnx/model.onnx", f"{model_name}/preprocess.json", f"{model_name}/inference_config.json", ]) snapshot_path = snapshot_download( repo_id=repo_id, allow_patterns=allow_patterns, ) return Path(snapshot_path) def load_json(path: Path) -> dict: """Load a JSON file and return its content as a dictionary. :param path: path to the JSON file :type path: Path :return: content of the JSON file as a dictionary :rtype: dict """ if not path.exists(): raise FileNotFoundError(f"Fichier introuvable : {path}") return json.loads(path.read_text()) def softmax(logits: np.ndarray) -> np.ndarray: """Softmax function to convert logits to probabilities. :param logits: logits array :type logits: np.ndarray :return: probabilities array :rtype: np.ndarray """ logits = logits.astype(np.float32) exp = np.exp(logits - logits.max()) return exp / exp.sum() def preprocess_image(image_path: Path, pre: dict) -> np.ndarray: """Preprocess the image according to the provided configuration. :param image_path: path to the image file :type image_path: Path :param pre: preprocessing configuration (expects keys 'img_size', 'mean', 'std :type pre: dict :return: preprocessed image as a numpy array ready for model input :rtype: np.ndarray """ if not image_path.exists(): raise FileNotFoundError(f"Image not founded: {image_path}") img_size = int(pre["img_size"]) mean = np.array(pre["mean"], dtype=np.float32) std = np.array(pre["std"], dtype=np.float32) img = Image.open(image_path).convert("RGB").resize((img_size, img_size)) x = np.asarray(img).astype(np.float32) / 255.0 x = (x - mean) / std x = x.transpose(2, 0, 1)[None].astype(np.float32) return x def get_labels(cfg: dict) -> list[str]: """Get the list of class labels from the configuration dictionary. :param cfg: configuration dictionary that may contain class labels in different keys :type cfg: dict :return: list of class labels :rtype: list[str] """ if "class_names" in cfg: return cfg["class_names"] if "labels" in cfg: return cfg["labels"] if "id2label" in cfg: id2label = cfg["id2label"] return [ id2label[str(i)] if str(i) in id2label else id2label[i] for i in range(len(id2label)) ] return [ cfg.get("negative_label", "non_illumination"), cfg.get("positive_label", "illumination"), ] def get_positive_index(labels: list[str], positive_label: str) -> int: """Get the index of the positive label in the labels list. :param labels: list of class labels :type labels: list[str] :param positive_label: name of the positive label :type positive_label: str :return: index of the positive label :rtype: int """ if positive_label in labels: return labels.index(positive_label) if len(labels) > 1: return 1 raise ValueError( f"Cannot determine positive index: positive_label={positive_label!r} not in labels={labels}" ) def predict(model_dir: Path, image_path: Path) -> dict: """Run inference on a single model and return the results. :param model_dir: path to the model directory :type model_dir: Path :param image_path: path to the image file to test :type image_path: Path :return: dictionary containing the prediction results and probabilities :rtype: dict """ cfg = load_json(model_dir / "inference_config.json") pre = load_json(model_dir / "preprocess.json") model_path = model_dir / "onnx" / "model.onnx" if not model_path.exists(): raise FileNotFoundError(f"ONNX model not founded: {model_path}") x = preprocess_image(image_path, pre) sess = ort.InferenceSession(str(model_path)) input_name = cfg.get("input_name") if input_name is None: input_name = sess.get_inputs()[0].name output = sess.run(None, {input_name: x})[0] logits = output[0] probs = softmax(logits) labels = get_labels(cfg) positive_label = cfg.get("positive_label", "illumination") negative_label = cfg.get("negative_label", "non_illumination") threshold = float(cfg.get("threshold", 0.5)) positive_idx = get_positive_index(labels, positive_label) argmax_idx = int(np.argmax(probs)) argmax_label = labels[argmax_idx] argmax_score = float(probs[argmax_idx]) p_illumination = float(probs[positive_idx]) threshold_label = positive_label if p_illumination >= threshold else negative_label probs_by_label = { labels[i]: float(probs[i]) for i in range(len(labels)) } return { "label_threshold": threshold_label, "p_illumination": p_illumination, "threshold": threshold, "positive_idx": positive_idx, "argmax_idx": argmax_idx, "argmax_label": argmax_label, "score_argmax": argmax_score, "labels": labels, "probs": probs.tolist(), "probs_by_label": probs_by_label, } def main(): """Main function to run the tests on all models and images.""" snapshot_root = download_models(REPO_ID, MODELS) print(f"Model downloaded in: {snapshot_root}") print() for image_type, image_path in IMAGE_PATHS.items(): print("=" * 100) print(f"Image expected: {image_type}") print(f"Image: {image_path}") print("=" * 100) for model_name in MODELS: model_dir = snapshot_root / model_name try: result = predict(model_dir, image_path) print( f"{model_name:<20} " f"=> predicted={result['label_threshold']:<18} " f"p_illumination={result['p_illumination']:.4f} " f"argmax={result['argmax_idx']}:{result['argmax_label']:<18} " f"score={result['score_argmax']:.4f} " f"probs={result['probs_by_label']}" ) except Exception as e: print(f"{model_name:<20} => ERROR : {e}") print() if __name__ == "__main__": main() ``` ## Python — PyTorch / non-ONNX local & Hugging Face ```bash pip install torch torchvision pillow numpy timm ``` Use the same code as above, just change the function `load_model`. ```python def load_model(run: Path) -> torch.nn.Module: """Load a PyTorch model from a checkpoint. :param run: path to the model run directory :type run: Path :return: loaded PyTorch model :rtype: torch.nn.Module """ checkpoint_path = run / "checkpoints" / "best.pt" if not checkpoint_path.exists(): raise FileNotFoundError(f"Checkpoint introuvable : {checkpoint_path}") model_name = run.name if model_name in {"mobilenetv2", "mobilenet_v2"}: model = models.mobilenet_v2(weights=None) model.classifier[-1] = torch.nn.Linear(model.classifier[-1].in_features, 2) elif model_name in {"mobilenetv3_large", "mobilenet_v3_large"}: model = models.mobilenet_v3_large(weights=None) model.classifier[-1] = torch.nn.Linear(model.classifier[-1].in_features, 2) elif model_name in {"mobilenetv3_small", "mobilenet_v3_small"}: model = models.mobilenet_v3_small(weights=None) model.classifier[-1] = torch.nn.Linear(model.classifier[-1].in_features, 2) elif model_name in {"mobilevitv2", "mobilevit_v2"}: import timm model = timm.create_model( "mobilevitv2_050", pretrained=False, num_classes=2, ) else: raise ValueError( f"Architecture non supportée : {model_name}. " f"Architectures disponibles : mobilenetv2, mobilenetv3_large, " f"mobilenetv3_small, mobilevitv2" ) state = torch.load(checkpoint_path, map_location="cpu") if isinstance(state, dict) and "state_dict" in state: state = state["state_dict"] if isinstance(state, dict) and "model_state_dict" in state: state = state["model_state_dict"] state = { key.replace("module.", ""): value for key, value in state.items() } model.load_state_dict(state) model.eval() return model ``` ## JS (HF - ONNX) ```javascript

loading...