AvoCahDoe commited on
Commit
8b4c09f
·
verified ·
1 Parent(s): 1cc902c

Initial Morocco BO region classification pipeline demo

Browse files
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ examples/pages/page_005.png filter=lfs diff=lfs merge=lfs -text
37
+ examples/pages/page_012.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,30 @@
1
  ---
2
- title: Morocco Bo Region Classification Demo
3
- emoji: 😻
4
- colorFrom: indigo
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Morocco BO Region Classification
3
+ emoji: 🏷️
4
+ colorFrom: purple
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 5.49.1
 
8
  app_file: app.py
9
+ python_version: "3.11"
10
+ startup_duration_timeout: 1h
11
+ license: apache-2.0
12
  ---
13
 
14
+ # Morocco BO Region Classification Demo
15
+
16
+ End-to-end demo for Moroccan Bulletin Officiel layout analysis:
17
+
18
+ 1. **Region proposal** — fine-tuned [PP-DocLayout-L](https://huggingface.co/AvoCahDoe/region-proposal-pp-doclayout-l-bo) detects layout boxes
19
+ 2. **OCR** — PP-OCRv3 line detection + [Latin recognizer](https://huggingface.co/AvoCahDoe/morocco-bo-ocr-latin-v3-finetuned) extracts words inside each box
20
+ 3. **Classification** — fine-tuned [LayoutLMv3](https://huggingface.co/AvoCahDoe/layoutlmv3-bo-segments) assigns one of 14 BO classes per region (ARTICLE, TITLE, TABLE, FOOTER, SOMMAIRE, etc.)
21
+
22
+ Upload a full French BO page (PNG/JPG) and click **Detect & classify**.
23
+
24
+ **Note:** First run downloads models (~300 MB total) and may take 1–3 minutes on CPU. Word boxes are approximated from line OCR (proportional split); best results on French BO pages similar to training data.
25
+
26
+ ## Links
27
+
28
+ - **Proposal model:** [AvoCahDoe/region-proposal-pp-doclayout-l-bo](https://huggingface.co/AvoCahDoe/region-proposal-pp-doclayout-l-bo)
29
+ - **Classification model:** [AvoCahDoe/layoutlmv3-bo-segments](https://huggingface.co/AvoCahDoe/layoutlmv3-bo-segments)
30
+ - **OCR model:** [AvoCahDoe/morocco-bo-ocr-latin-v3-finetuned](https://huggingface.co/AvoCahDoe/morocco-bo-ocr-latin-v3-finetuned)
__pycache__/app.cpython-311.pyc ADDED
Binary file (29.4 kB). View file
 
app.py ADDED
@@ -0,0 +1,520 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tempfile
5
+
6
+ os.environ.setdefault("FLAGS_use_mkldnn", "0")
7
+ os.environ.setdefault("FLAGS_enable_pir_api", "0")
8
+ os.environ.setdefault("PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK", "True")
9
+ os.environ.setdefault("OMP_NUM_THREADS", "1")
10
+
11
+ from pathlib import Path
12
+
13
+ import cv2
14
+ import gradio as gr
15
+ import numpy as np
16
+ import pandas as pd
17
+ import torch
18
+ import yaml
19
+ from huggingface_hub import snapshot_download
20
+ from PIL import Image, ImageDraw, ImageFont
21
+ from transformers import LayoutLMv3ForSequenceClassification, LayoutLMv3Processor
22
+
23
+ PROPOSAL_REPO = "AvoCahDoe/region-proposal-pp-doclayout-l-bo"
24
+ PROPOSAL_MODEL_NAME = "PP-DocLayout-L"
25
+ OCR_REPO = "AvoCahDoe/morocco-bo-ocr-latin-v3-finetuned"
26
+ OCR_MODEL_NAME = "latin_PP-OCRv3_mobile_rec"
27
+ CLASSIFIER_REPO = "AvoCahDoe/layoutlmv3-bo-segments"
28
+
29
+ MAX_IMAGE_SIDE = 2400
30
+ WORD_COVERAGE_THRESHOLD = 0.5
31
+ CLASSIFY_BATCH_SIZE = 4
32
+
33
+ PP_COLORS = {
34
+ "text": "#3B8BD4",
35
+ "paragraph_title": "#D85A30",
36
+ "doc_title": "#D85A30",
37
+ "header": "#888780",
38
+ "footer": "#6b7280",
39
+ "table": "#1D9E75",
40
+ "image": "#ec4899",
41
+ "reference": "#14b8a6",
42
+ }
43
+
44
+ BO_COLORS = {
45
+ "ARTICLE": "#3B8BD4",
46
+ "PREAMBLE": "#14b8a6",
47
+ "TITLE": "#D85A30",
48
+ "FOOTER": "#888780",
49
+ "TABLE": "#1D9E75",
50
+ "FIGURE": "#ec4899",
51
+ "ANNEXE_TITLE": "#f59e0b",
52
+ "ANNEXE_TEXT": "#3b82f6",
53
+ "SOMMAIRE": "#ef4444",
54
+ "CHAPTER_CONTENT": "#06b6d4",
55
+ "ANNEXE_LEVEL": "#8b5cf6",
56
+ "CHAPTER_TITLE": "#7F77DD",
57
+ "FORM": "#84cc16",
58
+ "SECTION": "#ec4899",
59
+ }
60
+
61
+ _proposal_infer_dir = None
62
+ _ocr_infer_dir = None
63
+ _layout_detector = None
64
+ _text_detector = None
65
+ _text_recognizer = None
66
+ _classifier_processor = None
67
+ _classifier_model = None
68
+
69
+
70
+ def _hf_token() -> str | None:
71
+ return os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
72
+
73
+
74
+ def _get_proposal_infer_dir() -> Path:
75
+ global _proposal_infer_dir
76
+ if _proposal_infer_dir is None:
77
+ root = snapshot_download(PROPOSAL_REPO, allow_patterns=["inference/*"])
78
+ _proposal_infer_dir = Path(root) / "inference"
79
+ return _proposal_infer_dir
80
+
81
+
82
+ def _get_ocr_infer_dir() -> Path:
83
+ global _ocr_infer_dir
84
+ if _ocr_infer_dir is None:
85
+ root = snapshot_download(OCR_REPO, allow_patterns=["inference/*"])
86
+ _ocr_infer_dir = Path(root) / "inference"
87
+ _ensure_ocr_config(_ocr_infer_dir)
88
+ return _ocr_infer_dir
89
+
90
+
91
+ def _ensure_ocr_config(infer_dir: Path) -> None:
92
+ yml_path = infer_dir / "inference.yml"
93
+ if not yml_path.exists():
94
+ return
95
+ data = yaml.safe_load(yml_path.read_text(encoding="utf-8")) or {}
96
+ global_cfg = data.setdefault("Global", {})
97
+ if global_cfg.get("model_name") != OCR_MODEL_NAME:
98
+ global_cfg["model_name"] = OCR_MODEL_NAME
99
+ yml_path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
100
+
101
+
102
+ def get_layout_detector():
103
+ global _layout_detector
104
+ if _layout_detector is None:
105
+ from paddleocr import LayoutDetection
106
+
107
+ _layout_detector = LayoutDetection(
108
+ model_dir=str(_get_proposal_infer_dir()),
109
+ model_name=PROPOSAL_MODEL_NAME,
110
+ enable_mkldnn=False,
111
+ )
112
+ return _layout_detector
113
+
114
+
115
+ def get_text_detector():
116
+ global _text_detector
117
+ if _text_detector is None:
118
+ from paddleocr import TextDetection
119
+
120
+ _text_detector = TextDetection(
121
+ model_name="PP-OCRv3_mobile_det",
122
+ enable_mkldnn=False,
123
+ limit_side_len=1920,
124
+ )
125
+ return _text_detector
126
+
127
+
128
+ def get_text_recognizer():
129
+ global _text_recognizer
130
+ if _text_recognizer is None:
131
+ from paddleocr import TextRecognition
132
+
133
+ _text_recognizer = TextRecognition(
134
+ model_dir=str(_get_ocr_infer_dir()),
135
+ model_name=OCR_MODEL_NAME,
136
+ enable_mkldnn=False,
137
+ )
138
+ return _text_recognizer
139
+
140
+
141
+ def get_classifier():
142
+ global _classifier_processor, _classifier_model
143
+ if _classifier_processor is None or _classifier_model is None:
144
+ token = _hf_token()
145
+ _classifier_processor = LayoutLMv3Processor.from_pretrained(
146
+ CLASSIFIER_REPO,
147
+ apply_ocr=False,
148
+ token=token,
149
+ )
150
+ _classifier_model = LayoutLMv3ForSequenceClassification.from_pretrained(
151
+ CLASSIFIER_REPO,
152
+ token=token,
153
+ )
154
+ _classifier_model.eval()
155
+ return _classifier_processor, _classifier_model
156
+
157
+
158
+ def _image_path(image) -> str | None:
159
+ if image is None:
160
+ return None
161
+ return image if isinstance(image, str) else getattr(image, "name", str(image))
162
+
163
+
164
+ def _font(size: int = 10):
165
+ for name in ("DejaVuSans.ttf", "arial.ttf"):
166
+ try:
167
+ return ImageFont.truetype(name, size)
168
+ except OSError:
169
+ continue
170
+ return ImageFont.load_default()
171
+
172
+
173
+ def _maybe_resize(image_path: str) -> str:
174
+ img = Image.open(image_path).convert("RGB")
175
+ w, h = img.size
176
+ max_side = max(w, h)
177
+ if max_side <= MAX_IMAGE_SIDE:
178
+ return image_path
179
+ scale = MAX_IMAGE_SIDE / max_side
180
+ resized = img.resize((int(w * scale), int(h * scale)), Image.Resampling.LANCZOS)
181
+ out = Path(image_path).with_name(f"{Path(image_path).stem}_resized.png")
182
+ resized.save(out)
183
+ return str(out)
184
+
185
+
186
+ def parse_proposal_boxes(result) -> list[dict]:
187
+ data = result.json if hasattr(result, "json") else {}
188
+ inner = data.get("res", data)
189
+ regions = []
190
+ for box in inner.get("boxes", []):
191
+ coord = box.get("coordinate", box.get("bbox", []))
192
+ if len(coord) < 4:
193
+ continue
194
+ regions.append({
195
+ "pp_label": str(box.get("label", "unknown")).lower(),
196
+ "proposal_score": float(box.get("score", 0.0)),
197
+ "x0": float(coord[0]),
198
+ "y0": float(coord[1]),
199
+ "x1": float(coord[2]),
200
+ "y1": float(coord[3]),
201
+ })
202
+ return regions
203
+
204
+
205
+ def run_proposals(image_path: str, threshold: float) -> list[dict]:
206
+ output = get_layout_detector().predict(
207
+ image_path,
208
+ batch_size=1,
209
+ layout_nms=True,
210
+ threshold=float(threshold),
211
+ )
212
+ regions: list[dict] = []
213
+ for res in output:
214
+ regions.extend(parse_proposal_boxes(res))
215
+ regions.sort(key=lambda r: (r["y0"], r["x0"]))
216
+ return regions
217
+
218
+
219
+ def _parse_rec_result(result) -> tuple[str, float]:
220
+ if not result:
221
+ return "", 0.0
222
+ item = result[0] if isinstance(result, list) else result
223
+ if isinstance(item, dict):
224
+ text = str(item.get("rec_text") or item.get("text") or "")
225
+ score = item.get("rec_score") or item.get("score")
226
+ return text, float(score) if score is not None else 1.0
227
+ return str(item), 1.0
228
+
229
+
230
+ def line_to_words(text: str, x0: float, y0: float, x1: float, y1: float) -> list[dict]:
231
+ tokens = text.split()
232
+ if not tokens:
233
+ return []
234
+ line_w = max(1.0, x1 - x0)
235
+ char_counts = [max(1, len(t)) for t in tokens]
236
+ total_chars = sum(char_counts)
237
+ words = []
238
+ cursor = x0
239
+ for token, n_chars in zip(tokens, char_counts):
240
+ w = line_w * (n_chars / total_chars)
241
+ words.append({
242
+ "text": token,
243
+ "bbox_px": [cursor, y0, cursor + w, y1],
244
+ })
245
+ cursor += w
246
+ return words
247
+
248
+
249
+ def extract_page_words(image_path: str) -> list[dict]:
250
+ img = cv2.imread(image_path)
251
+ if img is None:
252
+ return []
253
+
254
+ det_result = get_text_detector().predict(image_path)
255
+ if not det_result:
256
+ return []
257
+
258
+ item = det_result[0]
259
+ polys = item.get("dt_polys") or []
260
+ det_scores = item.get("dt_scores") or [1.0] * len(polys)
261
+ recognizer = get_text_recognizer()
262
+ all_words: list[dict] = []
263
+
264
+ with tempfile.TemporaryDirectory() as tmpdir:
265
+ tmp = Path(tmpdir)
266
+ for i, poly in enumerate(polys):
267
+ if i < len(det_scores) and float(det_scores[i]) < 0.5:
268
+ continue
269
+ arr = np.asarray(poly)
270
+ if arr.size == 0:
271
+ continue
272
+ x0, y0 = float(arr[:, 0].min()), float(arr[:, 1].min())
273
+ x1, y1 = float(arr[:, 0].max()), float(arr[:, 1].max())
274
+ crop = img[int(y0) : int(y1), int(x0) : int(x1)]
275
+ if crop.size == 0 or crop.shape[0] < 3 or crop.shape[1] < 3:
276
+ continue
277
+ crop_path = tmp / f"line_{i}.png"
278
+ cv2.imwrite(str(crop_path), crop)
279
+ text, score = _parse_rec_result(recognizer.predict(str(crop_path)))
280
+ text = text.strip()
281
+ if not text or score < 0.5:
282
+ continue
283
+ all_words.extend(line_to_words(text, x0, y0, x1, y1))
284
+
285
+ return all_words
286
+
287
+
288
+ def word_region_coverage(word_bbox: list[float], region_bbox: list[float]) -> float:
289
+ wx0, wy0, wx1, wy1 = word_bbox
290
+ rx0, ry0, rx1, ry1 = region_bbox
291
+ ix0, iy0 = max(wx0, rx0), max(wy0, ry0)
292
+ ix1, iy1 = min(wx1, rx1), min(wy1, ry1)
293
+ inter = max(0.0, ix1 - ix0) * max(0.0, iy1 - iy0)
294
+ word_area = max(1.0, (wx1 - wx0) * (wy1 - wy0))
295
+ return inter / word_area
296
+
297
+
298
+ def words_in_region(
299
+ words: list[dict],
300
+ region_bbox: list[float],
301
+ threshold: float = WORD_COVERAGE_THRESHOLD,
302
+ ) -> list[dict]:
303
+ inside = []
304
+ for w in words:
305
+ if word_region_coverage(w["bbox_px"], region_bbox) >= threshold:
306
+ inside.append(w)
307
+ inside.sort(key=lambda w: (w["bbox_px"][1], w["bbox_px"][0]))
308
+ return inside
309
+
310
+
311
+ def normalize_bbox(bbox_px: list[float], width: int, height: int) -> list[int]:
312
+ x0, y0, x1, y1 = bbox_px
313
+ return [
314
+ int(x0 / width * 1000),
315
+ int(y0 / height * 1000),
316
+ int(x1 / width * 1000),
317
+ int(y1 / height * 1000),
318
+ ]
319
+
320
+
321
+ def build_segments(
322
+ proposals: list[dict],
323
+ words: list[dict],
324
+ image_path: str,
325
+ ) -> tuple[list[dict], int]:
326
+ img = Image.open(image_path)
327
+ w, h = img.size
328
+ segments: list[dict] = []
329
+ skipped = 0
330
+ for reg in proposals:
331
+ bbox = [reg["x0"], reg["y0"], reg["x1"], reg["y1"]]
332
+ inside = words_in_region(words, bbox)
333
+ if not inside:
334
+ skipped += 1
335
+ continue
336
+ segments.append({
337
+ "proposal_score": reg["proposal_score"],
338
+ "pp_label": reg["pp_label"],
339
+ "x0": reg["x0"],
340
+ "y0": reg["y0"],
341
+ "x1": reg["x1"],
342
+ "y1": reg["y1"],
343
+ "words": [x["text"] for x in inside],
344
+ "word_boxes_norm": [normalize_bbox(x["bbox_px"], w, h) for x in inside],
345
+ "word_count": len(inside),
346
+ })
347
+ return segments, skipped
348
+
349
+
350
+ @torch.no_grad()
351
+ def classify_segments(segments: list[dict], image_path: str) -> list[dict]:
352
+ if not segments:
353
+ return []
354
+ processor, model = get_classifier()
355
+ id2label = model.config.id2label
356
+ results: list[dict] = []
357
+
358
+ for i in range(0, len(segments), CLASSIFY_BATCH_SIZE):
359
+ batch = segments[i : i + CLASSIFY_BATCH_SIZE]
360
+ images = [Image.open(image_path).convert("RGB") for _ in batch]
361
+ enc = processor(
362
+ images,
363
+ [s["words"] for s in batch],
364
+ boxes=[s["word_boxes_norm"] for s in batch],
365
+ truncation=True,
366
+ padding=True,
367
+ max_length=512,
368
+ return_tensors="pt",
369
+ )
370
+ outputs = model(**enc)
371
+ probs = torch.softmax(outputs.logits, dim=-1).cpu().numpy()
372
+ for seg, prob_row in zip(batch, probs):
373
+ pred_id = int(prob_row.argmax())
374
+ label = id2label[pred_id] if isinstance(id2label, dict) else id2label[str(pred_id)]
375
+ results.append({
376
+ **seg,
377
+ "bo_class": label,
378
+ "confidence": round(float(prob_row[pred_id]), 4),
379
+ })
380
+ return results
381
+
382
+
383
+ def _scale_preview(img: Image.Image, max_w: int = 1200) -> Image.Image:
384
+ if img.width <= max_w:
385
+ return img
386
+ scale = max_w / img.width
387
+ return img.resize((max_w, int(img.height * scale)), Image.Resampling.LANCZOS)
388
+
389
+
390
+ def draw_proposal_overlay(img: Image.Image, proposals: list[dict]) -> Image.Image:
391
+ out = img.copy()
392
+ draw = ImageDraw.Draw(out)
393
+ font = _font(9)
394
+ for reg in proposals:
395
+ label = reg.get("pp_label", "?")
396
+ color = PP_COLORS.get(label, "#999999")
397
+ x0, y0, x1, y1 = int(reg["x0"]), int(reg["y0"]), int(reg["x1"]), int(reg["y1"])
398
+ draw.rectangle([x0, y0, x1, y1], outline=color, width=2)
399
+ draw.text(
400
+ (x0 + 2, max(0, y0 - 12)),
401
+ f"{label} {reg.get('proposal_score', 0):.2f}",
402
+ fill=color,
403
+ font=font,
404
+ )
405
+ return _scale_preview(out)
406
+
407
+
408
+ def draw_classification_overlay(img: Image.Image, classified: list[dict]) -> Image.Image:
409
+ out = img.copy()
410
+ draw = ImageDraw.Draw(out)
411
+ font = _font(9)
412
+ used: set[str] = set()
413
+ for reg in classified:
414
+ label = reg.get("bo_class", "?")
415
+ used.add(label)
416
+ color = BO_COLORS.get(label, "#999999")
417
+ x0, y0, x1, y1 = int(reg["x0"]), int(reg["y0"]), int(reg["x1"]), int(reg["y1"])
418
+ draw.rectangle([x0, y0, x1, y1], outline=color, width=3)
419
+ draw.text(
420
+ (x0 + 2, max(0, y0 - 14)),
421
+ f"{label} {reg.get('confidence', 0):.2f}",
422
+ fill=color,
423
+ font=font,
424
+ )
425
+
426
+ legend_w = 220
427
+ canvas = Image.new("RGB", (out.width + legend_w, out.height), "#fafafa")
428
+ canvas.paste(out, (0, 0))
429
+ ld = ImageDraw.Draw(canvas)
430
+ x0 = out.width + 10
431
+ ld.text((x0, 8), "BO class (LayoutLMv3)", fill="#111", font=_font(11))
432
+ y = 32
433
+ for cls in sorted(used):
434
+ c = BO_COLORS.get(cls, "#999")
435
+ ld.rectangle([x0, y, x0 + 14, y + 12], outline=c, fill=c)
436
+ ld.text((x0 + 20, y), cls, fill="#222", font=_font(9))
437
+ y += 16
438
+ return _scale_preview(canvas)
439
+
440
+
441
+ def detect_and_classify(
442
+ image,
443
+ threshold: float,
444
+ ) -> tuple[Image.Image | None, Image.Image | None, pd.DataFrame, str, str]:
445
+ empty_df = pd.DataFrame(
446
+ columns=["bo_class", "confidence", "proposal_score", "word_count", "x0", "y0", "x1", "y1"]
447
+ )
448
+ image_path = _image_path(image)
449
+ if not image_path:
450
+ return None, None, empty_df, "0", "0 skipped (no words)"
451
+
452
+ image_path = _maybe_resize(image_path)
453
+ base_img = Image.open(image_path).convert("RGB")
454
+
455
+ proposals = run_proposals(image_path, threshold)
456
+ proposal_preview = draw_proposal_overlay(base_img, proposals) if proposals else _scale_preview(base_img)
457
+
458
+ if not proposals:
459
+ return _scale_preview(base_img), proposal_preview, empty_df, "0", "0 skipped (no words)"
460
+
461
+ words = extract_page_words(image_path)
462
+ segments, skipped = build_segments(proposals, words, image_path)
463
+ classified = classify_segments(segments, image_path)
464
+
465
+ if not classified:
466
+ return _scale_preview(base_img), proposal_preview, empty_df, "0", f"{skipped} skipped (no words)"
467
+
468
+ class_preview = draw_classification_overlay(base_img, classified)
469
+ df = pd.DataFrame(classified)[
470
+ ["bo_class", "confidence", "proposal_score", "word_count", "x0", "y0", "x1", "y1"]
471
+ ]
472
+ skip_msg = f"{skipped} skipped (no words)" if skipped else "0 skipped"
473
+ return class_preview, proposal_preview, df, str(len(classified)), skip_msg
474
+
475
+
476
+ DESCRIPTION = """
477
+ Upload a **full French Bulletin Officiel page** (PNG/JPG) to run the full pipeline:
478
+
479
+ 1. **Region proposal** — fine-tuned PP-DocLayout-L finds layout boxes
480
+ 2. **OCR** — Latin PP-OCRv3 extracts words inside each box
481
+ 3. **Classification** — LayoutLMv3 assigns one of 14 BO classes per region
482
+
483
+ **Note:** First run downloads models (~300 MB) and may take 1–3 minutes on CPU.
484
+ """
485
+
486
+ EXAMPLES = [
487
+ ["examples/pages/page_005.png", 0.5],
488
+ ["examples/pages/page_012.png", 0.5],
489
+ ]
490
+
491
+ with gr.Blocks(title="Morocco BO Region Classification") as demo:
492
+ gr.Markdown("# Morocco BO Region Classification")
493
+ gr.Markdown(DESCRIPTION)
494
+ with gr.Row():
495
+ with gr.Column():
496
+ page_image = gr.Image(type="filepath", label="Page image")
497
+ threshold = gr.Slider(0.1, 0.95, value=0.5, step=0.05, label="Proposal threshold")
498
+ run_btn = gr.Button("Detect & classify", variant="primary")
499
+ with gr.Column():
500
+ class_preview = gr.Image(label="BO classification overlay", type="pil")
501
+ proposal_preview = gr.Image(label="Proposal overlay (PP-DocLayout-L)", type="pil")
502
+ count_out = gr.Textbox(label="Regions classified", lines=1)
503
+ skip_out = gr.Textbox(label="Skipped regions", lines=1)
504
+ table_out = gr.Dataframe(
505
+ label="Classification results",
506
+ headers=["bo_class", "confidence", "proposal_score", "word_count", "x0", "y0", "x1", "y1"],
507
+ interactive=False,
508
+ )
509
+ run_btn.click(
510
+ detect_and_classify,
511
+ inputs=[page_image, threshold],
512
+ outputs=[class_preview, proposal_preview, table_out, count_out, skip_out],
513
+ )
514
+ gr.Examples(examples=EXAMPLES, inputs=[page_image, threshold])
515
+
516
+ demo.launch(
517
+ server_name="0.0.0.0",
518
+ server_port=7860,
519
+ ssr_mode=False,
520
+ )
examples/pages/page_005.png ADDED

Git LFS Details

  • SHA256: 477b7e9d563b906357cd7c254ec866b4e100ee326ae6522f72d4fde71999009e
  • Pointer size: 131 Bytes
  • Size of remote file: 515 kB
examples/pages/page_012.png ADDED

Git LFS Details

  • SHA256: e0edc0e81be3d4d6dbaf7aa05060378740eab99e499a61db433148a5ec0d8d8e
  • Pointer size: 131 Bytes
  • Size of remote file: 413 kB
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ paddlepaddle==3.2.2
2
+ paddleocr>=2.10.0
3
+ transformers>=4.40.0
4
+ accelerate>=0.30.0
5
+ Pillow
6
+ pandas
7
+ numpy
8
+ opencv-python-headless
9
+ PyYAML