Views
No views yet
| 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.
| Label ID | Label |
|---|---|
| 0 | non_illuminated |
| 1 | illuminated |
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 brightness0.12, contrast0.12, saturation0.08, and hue0.02. Validation and test images were evaluated without augmentation.
| Illuminated | Not Illuminated |
|---|---|
![]() | ![]() |
![]() | ![]() |
![]() | ![]() |
![]() | ![]() |
![]() | ![]() |
![]() | ![]() |
pip install onnxruntime pillow numpy1import json
2import numpy as np
3import onnxruntime as ort
4from PIL import Image
5from pathlib import Path
6
7# Model list to test.
8# Check if the models are stored
9# in your expected directory structure
10# (e.g. './Artefacts/{model_name}/onnx/model.onnx')
11MODELS = [
12 "mobilenetv2",
13 "mobilenetv3_large",
14 "mobilenetv3_small",
15 "mobilevitv2",
16]
17
18ARTEFACTS_DIR = Path("./Artefacts")
19
20# Test images
21IMAGE_PATHS = {
22 "illumination": Path("./dataset/test/illustration/gahom_0020__fdf0ee350c94.jpg"),
23 "non_illumination": Path("./dataset/test/non_illustration/CREMMA-Medieval-LAT_00007.jpg"),
24}
25
26
27def softmax(logits: np.ndarray) -> np.ndarray:
28 """Softmax function to convert logits to probabilities.
29
30 :param logits: logits array
31 :type logits: np.ndarray
32 :return: probabilities array
33 :rtype: np.ndarray
34 """
35 logits = logits.astype(np.float32)
36 exp = np.exp(logits - logits.max())
37 return exp / exp.sum()
38
39
40def load_json(path: Path) -> dict:
41 """Load a JSON file and return its content as a dictionary.
42
43 :param path: path to the JSON file
44 :type path: Path
45 :return: content of the JSON file as a dictionary
46 :rtype: dict
47 """
48 if not path.exists():
49 raise FileNotFoundError(f"File not founded: {path}")
50 return json.loads(path.read_text())
51
52
53def preprocess_image(image_path: Path, pre: dict) -> np.ndarray:
54 """Preprocess the image according to the provided configuration.
55
56 :param image_path: path to the image file
57 :type image_path: Path
58 :param pre: preprocessing configuration (expects keys 'img_size', 'mean', 'std
59 :type pre: dict
60 :return: preprocessed image as a numpy array ready for model input
61 :rtype: np.ndarray
62 """
63 if not image_path.exists():
64 raise FileNotFoundError(f"Image not founded: {image_path}")
65
66 img_size = pre["img_size"]
67 mean = np.array(pre["mean"], dtype=np.float32)
68 std = np.array(pre["std"], dtype=np.float32)
69
70 img = Image.open(image_path).convert("RGB").resize((img_size, img_size))
71
72 x = np.asarray(img).astype(np.float32) / 255.0
73 x = (x - mean) / std
74 x = x.transpose(2, 0, 1)[None].astype(np.float32)
75
76 return x
77
78
79def predict(model_name: str, image_path: Path) -> dict:
80 """Run inference on a single model and return the results.
81
82 :param model_name: name of the model to test
83 :type model_name: str
84 :param image_path: path to the image file to test
85 :type image_path: Path
86 :return: dictionary containing the prediction results and probabilities
87 :rtype: dict
88 """
89 run = ARTEFACTS_DIR / model_name
90
91 cfg = load_json(run / "inference_config.json")
92 pre = load_json(run / "preprocess.json")
93
94 model_path = run / "onnx" / "model.onnx"
95 if not model_path.exists():
96 raise FileNotFoundError(f"ONNX model not founded: {model_path}")
97
98 x = preprocess_image(image_path, pre)
99
100 sess = ort.InferenceSession(str(model_path))
101
102 input_name = cfg.get("input_name")
103 if input_name is None:
104 input_name = sess.get_inputs()[0].name
105
106 output = sess.run(None, {input_name: x})[0]
107
108 # Cas standard : shape (1, 2)
109 logits = output[0]
110
111 probs = softmax(logits)
112
113 p_illu = float(probs[1])
114
115 positive_label = cfg.get("positive_label", "illumination")
116 negative_label = cfg.get("negative_label", "non_illumination")
117 threshold = float(cfg.get("threshold", 0.5))
118
119 label = positive_label if p_illu >= threshold else negative_label
120
121 return {
122 "model": model_name,
123 "image": str(image_path),
124 "label": label,
125 "p_illustration": p_illu,
126 "probs": probs.tolist(),
127 "threshold": threshold,
128 }
129
130
131def main():
132 """Main function to run the tests on all models and images."""
133 for image_type, image_path in IMAGE_PATHS.items():
134 print("=" * 80)
135 print(f"Image expected: {image_type}")
136 print(f"Image: {image_path}")
137 print("=" * 80)
138
139 for model_name in MODELS:
140 try:
141 result = predict(model_name, image_path)
142
143 print(
144 f"{result['model']:<20} "
145 f"=> {result['label']:<18} "
146 f"p_illu={result['p_illustration']:.4f} "
147 f"probs={result['probs']}"
148 )
149
150 except Exception as e:
151 print(f"{model_name:<20} → ERROR: {e}")
152
153 print()
154
155
156if __name__ == "__main__":
157 main()pip install huggingface_hub onnxruntime pillow numpy1import json
2import numpy as np
3import onnxruntime as ort
4
5from PIL import Image
6from pathlib import Path
7from huggingface_hub import snapshot_download
8
9# Repository HF that contains the ONNX models and their configs
10REPO_ID = "ENC-PSL/BSICLE"
11
12MODELS = [
13 "mobilenetv2",
14 "mobilenetv3_large",
15 "mobilenetv3_small",
16 "mobilevitv2",
17]
18
19IMAGE_PATHS = {
20 "illumination": Path("./dataset/test/illustration/gahom_0020__fdf0ee350c94.jpg"),
21 "non_illumination": Path("./dataset/test/non_illustration/CREMMA-Medieval-LAT_00007.jpg"),
22}
23
24
25def download_models(repo_id: str, model_names: list[str]) -> Path:
26 """download models and their configs from HF Hub, and return the local path to the snapshot
27
28 :param repo_id: repository id on HF Hub
29 :type repo_id: str
30 :param model_names: list of model names to download (e.g. ["mobilenetv2", "mobilenetv3_large"])
31 :return: local path to the snapshot containing the models and their configs
32 :rtype: Path
33 """
34 allow_patterns = []
35
36 for model_name in model_names:
37 allow_patterns.extend([
38 f"{model_name}/onnx/model.onnx",
39 f"{model_name}/preprocess.json",
40 f"{model_name}/inference_config.json",
41 ])
42
43 snapshot_path = snapshot_download(
44 repo_id=repo_id,
45 allow_patterns=allow_patterns,
46 )
47
48 return Path(snapshot_path)
49
50
51def load_json(path: Path) -> dict:
52 """Load a JSON file and return its content as a dictionary.
53
54 :param path: path to the JSON file
55 :type path: Path
56 :return: content of the JSON file as a dictionary
57 :rtype: dict
58 """
59 if not path.exists():
60 raise FileNotFoundError(f"Fichier introuvable : {path}")
61
62 return json.loads(path.read_text())
63
64
65def softmax(logits: np.ndarray) -> np.ndarray:
66 """Softmax function to convert logits to probabilities.
67
68 :param logits: logits array
69 :type logits: np.ndarray
70 :return: probabilities array
71 :rtype: np.ndarray
72 """
73 logits = logits.astype(np.float32)
74 exp = np.exp(logits - logits.max())
75
76 return exp / exp.sum()
77
78
79def preprocess_image(image_path: Path, pre: dict) -> np.ndarray:
80 """Preprocess the image according to the provided configuration.
81
82 :param image_path: path to the image file
83 :type image_path: Path
84 :param pre: preprocessing configuration (expects keys 'img_size', 'mean', 'std
85 :type pre: dict
86 :return: preprocessed image as a numpy array ready for model input
87 :rtype: np.ndarray
88 """
89 if not image_path.exists():
90 raise FileNotFoundError(f"Image not founded: {image_path}")
91
92 img_size = int(pre["img_size"])
93 mean = np.array(pre["mean"], dtype=np.float32)
94 std = np.array(pre["std"], dtype=np.float32)
95
96 img = Image.open(image_path).convert("RGB").resize((img_size, img_size))
97
98 x = np.asarray(img).astype(np.float32) / 255.0
99 x = (x - mean) / std
100 x = x.transpose(2, 0, 1)[None].astype(np.float32)
101
102 return x
103
104
105def get_labels(cfg: dict) -> list[str]:
106 """Get the list of class labels from the configuration dictionary.
107
108 :param cfg: configuration dictionary that may contain class labels in different keys
109 :type cfg: dict
110 :return: list of class labels
111 :rtype: list[str]
112 """
113 if "class_names" in cfg:
114 return cfg["class_names"]
115
116 if "labels" in cfg:
117 return cfg["labels"]
118
119 if "id2label" in cfg:
120 id2label = cfg["id2label"]
121 return [
122 id2label[str(i)] if str(i) in id2label else id2label[i]
123 for i in range(len(id2label))
124 ]
125
126 return [
127 cfg.get("negative_label", "non_illumination"),
128 cfg.get("positive_label", "illumination"),
129 ]
130
131
132def get_positive_index(labels: list[str], positive_label: str) -> int:
133 """Get the index of the positive label in the labels list.
134
135 :param labels: list of class labels
136 :type labels: list[str]
137 :param positive_label: name of the positive label
138 :type positive_label: str
139 :return: index of the positive label
140 :rtype: int
141 """
142 if positive_label in labels:
143 return labels.index(positive_label)
144
145 if len(labels) > 1:
146 return 1
147
148 raise ValueError(
149 f"Cannot determine positive index: positive_label={positive_label!r} not in labels={labels}"
150 )
151
152
153def predict(model_dir: Path, image_path: Path) -> dict:
154 """Run inference on a single model and return the results.
155
156 :param model_dir: path to the model directory
157 :type model_dir: Path
158 :param image_path: path to the image file to test
159 :type image_path: Path
160 :return: dictionary containing the prediction results and probabilities
161 :rtype: dict
162 """
163 cfg = load_json(model_dir / "inference_config.json")
164 pre = load_json(model_dir / "preprocess.json")
165
166 model_path = model_dir / "onnx" / "model.onnx"
167 if not model_path.exists():
168 raise FileNotFoundError(f"ONNX model not founded: {model_path}")
169
170 x = preprocess_image(image_path, pre)
171
172 sess = ort.InferenceSession(str(model_path))
173
174 input_name = cfg.get("input_name")
175 if input_name is None:
176 input_name = sess.get_inputs()[0].name
177
178 output = sess.run(None, {input_name: x})[0]
179
180 logits = output[0]
181 probs = softmax(logits)
182
183 labels = get_labels(cfg)
184
185 positive_label = cfg.get("positive_label", "illumination")
186 negative_label = cfg.get("negative_label", "non_illumination")
187 threshold = float(cfg.get("threshold", 0.5))
188
189 positive_idx = get_positive_index(labels, positive_label)
190
191 argmax_idx = int(np.argmax(probs))
192 argmax_label = labels[argmax_idx]
193 argmax_score = float(probs[argmax_idx])
194
195 p_illumination = float(probs[positive_idx])
196 threshold_label = positive_label if p_illumination >= threshold else negative_label
197
198 probs_by_label = {
199 labels[i]: float(probs[i])
200 for i in range(len(labels))
201 }
202
203 return {
204 "label_threshold": threshold_label,
205 "p_illumination": p_illumination,
206 "threshold": threshold,
207 "positive_idx": positive_idx,
208 "argmax_idx": argmax_idx,
209 "argmax_label": argmax_label,
210 "score_argmax": argmax_score,
211 "labels": labels,
212 "probs": probs.tolist(),
213 "probs_by_label": probs_by_label,
214 }
215
216
217def main():
218 """Main function to run the tests on all models and images."""
219 snapshot_root = download_models(REPO_ID, MODELS)
220
221 print(f"Model downloaded in: {snapshot_root}")
222 print()
223
224 for image_type, image_path in IMAGE_PATHS.items():
225 print("=" * 100)
226 print(f"Image expected: {image_type}")
227 print(f"Image: {image_path}")
228 print("=" * 100)
229
230 for model_name in MODELS:
231 model_dir = snapshot_root / model_name
232
233 try:
234 result = predict(model_dir, image_path)
235
236 print(
237 f"{model_name:<20} "
238 f"=> predicted={result['label_threshold']:<18} "
239 f"p_illumination={result['p_illumination']:.4f} "
240 f"argmax={result['argmax_idx']}:{result['argmax_label']:<18} "
241 f"score={result['score_argmax']:.4f} "
242 f"probs={result['probs_by_label']}"
243 )
244
245 except Exception as e:
246 print(f"{model_name:<20} => ERROR : {e}")
247
248 print()
249
250
251if __name__ == "__main__":
252 main()pip install torch torchvision pillow numpy timmload_model.1def load_model(run: Path) -> torch.nn.Module:
2 """Load a PyTorch model from a checkpoint.
3
4 :param run: path to the model run directory
5 :type run: Path
6 :return: loaded PyTorch model
7 :rtype: torch.nn.Module
8 """
9 checkpoint_path = run / "checkpoints" / "best.pt"
10
11 if not checkpoint_path.exists():
12 raise FileNotFoundError(f"Checkpoint introuvable : {checkpoint_path}")
13
14 model_name = run.name
15
16 if model_name in {"mobilenetv2", "mobilenet_v2"}:
17 model = models.mobilenet_v2(weights=None)
18 model.classifier[-1] = torch.nn.Linear(model.classifier[-1].in_features, 2)
19
20 elif model_name in {"mobilenetv3_large", "mobilenet_v3_large"}:
21 model = models.mobilenet_v3_large(weights=None)
22 model.classifier[-1] = torch.nn.Linear(model.classifier[-1].in_features, 2)
23
24 elif model_name in {"mobilenetv3_small", "mobilenet_v3_small"}:
25 model = models.mobilenet_v3_small(weights=None)
26 model.classifier[-1] = torch.nn.Linear(model.classifier[-1].in_features, 2)
27
28 elif model_name in {"mobilevitv2", "mobilevit_v2"}:
29 import timm
30
31 model = timm.create_model(
32 "mobilevitv2_050",
33 pretrained=False,
34 num_classes=2,
35 )
36
37 else:
38 raise ValueError(
39 f"Architecture non supportée : {model_name}. "
40 f"Architectures disponibles : mobilenetv2, mobilenetv3_large, "
41 f"mobilenetv3_small, mobilevitv2"
42 )
43
44 state = torch.load(checkpoint_path, map_location="cpu")
45
46 if isinstance(state, dict) and "state_dict" in state:
47 state = state["state_dict"]
48
49 if isinstance(state, dict) and "model_state_dict" in state:
50 state = state["model_state_dict"]
51
52 state = {
53 key.replace("module.", ""): value
54 for key, value in state.items()
55 }
56
57 model.load_state_dict(state)
58 model.eval()
59
60 return model1<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js"></script>
2
3<label for="model">Model:</label>
4<select id="model">
5 <option value="mobilenetv2">mobilenetv2</option>
6 <option value="mobilenetv3_large">mobilenetv3_large</option>
7 <option value="mobilenetv3_small">mobilenetv3_small</option>
8 <option value="mobilevitv2">mobilevitv2</option>
9</select>
10
11<br><br>
12
13<input type="file" id="file" accept="image/*">
14
15<pre id="out">loading...</pre>
16
17<script type="module">
18const REPO_BASE = "https://huggingface.co/ENC-PSL/BSICLE/resolve/main";
19
20let cfg = null;
21let pre = null;
22let sess = null;
23let currentModel = null;
24
25const out = document.querySelector("#out");
26const fileInput = document.querySelector("#file");
27const modelSelect = document.querySelector("#model");
28
29function softmax(a) {
30 const m = Math.max(...a);
31 const e = a.map(x => Math.exp(x - m));
32 const s = e.reduce((x, y) => x + y, 0);
33 return e.map(x => x / s);
34}
35
36function getLabels(cfg) {
37 if (cfg.class_names) {
38 return cfg.class_names;
39 }
40
41 if (cfg.labels) {
42 return cfg.labels;
43 }
44
45 if (cfg.id2label) {
46 return Object.keys(cfg.id2label)
47 .sort((a, b) => Number(a) - Number(b))
48 .map(k => cfg.id2label[k]);
49 }
50
51 return [
52 cfg.negative_label ?? "non_illumination",
53 cfg.positive_label ?? "illumination",
54 ];
55}
56
57function getPositiveIndex(labels, cfg) {
58 if (cfg.positive_index !== undefined) {
59 return Number(cfg.positive_index);
60 }
61
62 const positiveLabel = cfg.positive_label ?? "illumination";
63
64 if (labels.includes(positiveLabel)) {
65 return labels.indexOf(positiveLabel);
66 }
67
68 if (labels.length > 1) {
69 return 1;
70 }
71
72 throw new Error(
73 `Impossible de trouver l'index positif pour positive_label=${positiveLabel}`
74 );
75}
76
77async function loadModel(modelName) {
78 currentModel = modelName;
79
80 const run = `${REPO_BASE}/${modelName}`;
81
82 out.textContent = `Chargement du modèle ${modelName}...`;
83
84 cfg = await fetch(`${run}/inference_config.json`).then(r => {
85 if (!r.ok) {
86 throw new Error(`Impossible de charger inference_config.json pour ${modelName}`);
87 }
88 return r.json();
89 });
90
91 pre = await fetch(`${run}/preprocess.json`).then(r => {
92 if (!r.ok) {
93 throw new Error(`Impossible de charger preprocess.json pour ${modelName}`);
94 }
95 return r.json();
96 });
97
98 sess = await ort.InferenceSession.create(`${run}/onnx/model.onnx`);
99
100 out.textContent = `Loaded model: ${modelName}`;
101}
102
103async function imageToTensor(file) {
104 const img = new Image();
105 img.src = URL.createObjectURL(file);
106 await img.decode();
107
108 const size = Number(pre.img_size);
109
110 const canvas = document.createElement("canvas");
111 canvas.width = size;
112 canvas.height = size;
113
114 const ctx = canvas.getContext("2d");
115 ctx.drawImage(img, 0, 0, size, size);
116
117 const data = ctx.getImageData(0, 0, size, size).data;
118 const x = new Float32Array(1 * 3 * size * size);
119
120 const mean = pre.mean;
121 const std = pre.std;
122
123 for (let i = 0, p = 0; i < data.length; i += 4, p++) {
124 x[p] = (data[i] / 255 - mean[0]) / std[0];
125 x[size * size + p] = (data[i + 1] / 255 - mean[1]) / std[1];
126 x[2 * size * size + p] = (data[i + 2] / 255 - mean[2]) / std[2];
127 }
128
129 URL.revokeObjectURL(img.src);
130
131 return new ort.Tensor("float32", x, [1, 3, size, size]);
132}
133
134async function predict(file) {
135 if (!sess || !cfg || !pre) {
136 throw new Error("No model loaded.");
137 }
138
139 const tensor = await imageToTensor(file);
140
141 const inputName = cfg.input_name ?? sess.inputNames[0];
142 const outputName = cfg.output_name ?? sess.outputNames[0];
143
144 const res = await sess.run({
145 [inputName]: tensor,
146 });
147
148 const logits = Array.from(res[outputName].data);
149 const probs = softmax(logits);
150
151 const labels = getLabels(cfg);
152
153 const positiveLabel = cfg.positive_label ?? "illumination";
154 const negativeLabel = cfg.negative_label ?? "non_illumination";
155 const threshold = Number(cfg.threshold ?? 0.5);
156
157 const positiveIndex = getPositiveIndex(labels, cfg);
158
159 const pIllumination = probs[positiveIndex];
160 const labelThreshold = pIllumination >= threshold ? positiveLabel : negativeLabel;
161
162 const argmaxIdx = probs.indexOf(Math.max(...probs));
163 const argmaxLabel = labels[argmaxIdx];
164 const argmaxScore = probs[argmaxIdx];
165
166 const probsByLabel = {};
167 labels.forEach((label, i) => {
168 probsByLabel[label] = probs[i];
169 });
170
171 return {
172 model: currentModel,
173 predicted: labelThreshold,
174 p_illumination: pIllumination,
175 threshold,
176 positive_index: positiveIndex,
177 argmax: `${argmaxIdx}:${argmaxLabel}`,
178 argmax_score: argmaxScore,
179 labels,
180 probs,
181 probs_by_label: probsByLabel,
182 };
183}
184
185modelSelect.onchange = async () => {
186 try {
187 await loadModel(modelSelect.value);
188
189 if (fileInput.files.length > 0) {
190 const result = await predict(fileInput.files[0]);
191 out.textContent = JSON.stringify(result, null, 2);
192 }
193 } catch (err) {
194 out.textContent = String(err);
195 }
196};
197
198fileInput.onchange = async (e) => {
199 try {
200 const file = e.target.files[0];
201
202 if (!file) {
203 return;
204 }
205
206 const result = await predict(file);
207 out.textContent = JSON.stringify(result, null, 2);
208 } catch (err) {
209 out.textContent = String(err);
210 }
211};
212
213await loadModel(modelSelect.value);@software{terriel_bsicle_2026,
AUTHOR = {Terriel, Lucas and Jolivet, Vincent},
TITLE = {{BSICLE}: Binary System for Illuminated Folio Classification with Lightweight Engines},
YEAR = {2026},
PUBLISHER = {Hugging Face},
INSTITUTION = {{École nationale des chartes -- PSL}},
URL = {https://huggingface.co/ENC-PSL/medieval-illumination-bin-classifier},
NOTE = {Family of lightweight binary image classification models for detecting illuminated folios in medieval manuscripts, developed in the context of the O.D.I.L. project},
LICENSE = {apache-2.0},
VERSION = {0.0.1}
}