From d20d0c1fc1ededad969e69f53b9a1ef18e888c65 Mon Sep 17 00:00:00 2001 From: David Huggins-Daines Date: Thu, 6 Jun 2024 12:30:51 -0400 Subject: [PATCH] Nouvelle commande `alexi annotate` pour corriger erreurs plus vite (#26) * feat: alexi annotate pour corriger erreurs plus vite * feat: marquer les elements de sequence aussi * fix: ignore ignore ignore --- README.md | 52 ++++++++++----- alexi/__init__.py | 20 +++--- alexi/annotate.py | 159 ++++++++++++++++++++++++++++++++++++++++++++++ alexi/convert.py | 11 +++- 4 files changed, 214 insertions(+), 28 deletions(-) create mode 100644 alexi/annotate.py diff --git a/README.md b/README.md index 6e69fda..abfa54c 100644 --- a/README.md +++ b/README.md @@ -52,36 +52,56 @@ La commande `alexi annotate` vise à faciliter ce processus. Par exemple, si l'on veut corriger l'extraction de la page 1 du règlement 1314-2023-DEM, on peut d'abord extraire les données et une -visualisation de la segmentation et classification avec: +visualisation de la segmentation et classification avec cette commande +(on peut spécifier le nom de base de fichiers comme deuxième argument): alexi annotate --pages 1 \ - download/2023-03-20-Rgl-1314-2023-DEM-Adoption-_1.pdf + download/2023-03-20-Rgl-1314-2023-DEM-Adoption-_1.pdf \ + 1314-page1 + +Cela créera les fichers `1314-page1.pdf` et `1314-page1.csv`. Notez +qu'il est possible de spécifier plusieurs pages à extraire et +annoter, par exemple: + + --pages 1,2,3 + +Dans le PDF, pour le moment, des rectangles colorés sont utiliser pour +représenter les blocs annotés et aider à répérer les erreurs. +Notamment: -Par défaut, cela créera des fichiers dans le même repertoire que -`alexi extract`, alors -`export/2023-03-20-Rgl-1314-2023-DEM-Adoption-_1`. L'option -`--outdir` peut être utilisée pour spécifier un autre repertoire de -base. Les fichiers générer sont: +- Les chapitres et annexes sont en rouge +- Les sections et articles sont en rose (plus foncé plus le type + d'élément est large) +- Les listes sont en bleu-vert (parce qu'elles sont souvent confondues + avec les articles) +- Les en-têtes et pieds de page sont en jaune-vert-couleur-de-bile +- Tout le reste est en noir (alinéas, tableaux, figures) - page1.pdf # PDF annoté avec la segmentation - page1.csv # Traits distintcifs utilisés pour le modèle +Pour les éléments de séquence (il y a juste les titres et les numéros) +ceux-ci sont indiqués par un remplissage vert clair transparent. Avec un logiciel de feuilles de calcul dont LibreOffice ou Excel, on -peut alors modifier `page1.csv` pour corriger la segmentation. Il est -*très important* de spécifier ces paramètres lorsqu'on ouvre et +peut alors modifier `1314-page1.csv` pour corriger la segmentation. +Il est *très important* de spécifier ces paramètres lorsqu'on ouvre et sauvegarde le fichier CSV: - La colonne "text" doit avoir le type "Texte" (et pas "Standard") - Le seul séparateur de colonne devrait être la virgule (pas de point-virgule, tab, etc) -Par la suite la visualisation s'effectue avec: +Une fois les erreurs corrigés, le résultat peut être vu avec: + + alexi annotate --pages 1 \ + --csv 1314-page1.csv \ + download/2023-03-20-Rgl-1314-2023-DEM-Adoption-_1.pdf + 1314-page1 - alexi annotate export/2023-03-20-Rgl-1314-2023-DEM-Adoption-_1 +Cela mettra à jour le fichier `1314-page1.pdf` avec les nouvelles +annotations. -Une fois les erreurs corrigés, il suffit de copier -`export/2023-03-20-Rgl-1314-2023-DEM-Adoption-_1/page1.csv` vers le -repertoire `data` et réentrainer le modèle avec `scripts/retrain.sh`. +Une fois satisfait du résultat, il suffira de copier `1314-page1.csv` +vers le repertoire `data` et réentrainer le modèle avec +`scripts/retrain.sh`. Extraction de catégories pertinentes du zonage ---------------------------------------------- diff --git a/alexi/__init__.py b/alexi/__init__.py index d6a3c1f..af2607b 100644 --- a/alexi/__init__.py +++ b/alexi/__init__.py @@ -13,11 +13,10 @@ import operator import sys from pathlib import Path -from typing import Any, Iterable, TextIO -from . import download, extract +from . import annotate, download, extract from .analyse import Analyseur, Bloc, merge_overlaps -from .convert import FIELDNAMES, Converteur +from .convert import Converteur, write_csv from .format import format_html from .index import index from .label import DEFAULT_MODEL as DEFAULT_LABEL_MODEL @@ -30,14 +29,6 @@ VERSION = "0.4.0" -def write_csv( - doc: Iterable[dict[str, Any]], outfh: TextIO, fieldnames: list[str] = FIELDNAMES -): - writer = csv.DictWriter(outfh, fieldnames, extrasaction="ignore") - writer.writeheader() - writer.writerows(doc) - - def convert_main(args: argparse.Namespace): """Convertir les PDF en CSV""" if args.pages: @@ -223,6 +214,13 @@ def make_argparse() -> argparse.ArgumentParser: ) search.add_argument("query", help="Requête", nargs="+") search.set_defaults(func=search_main) + + annotate_command = subp.add_parser( + "annotate", help="Annoter un PDF pour corriger erreurs" + ) + annotate.add_arguments(annotate_command) + annotate_command.set_defaults(func=annotate.main) + return parser diff --git a/alexi/annotate.py b/alexi/annotate.py new file mode 100644 index 0000000..9ec71f6 --- /dev/null +++ b/alexi/annotate.py @@ -0,0 +1,159 @@ +""" +Générer des PDF et CSV annotés pour corriger le modèle. +""" + +import argparse +import csv +import itertools +import logging +from operator import attrgetter +from pathlib import Path +from typing import Any + +import pypdfium2 as pdfium # type: ignore +import pypdfium2.raw as pdfium_c # type: ignore + +from alexi.analyse import group_iob +from alexi.convert import Converteur, write_csv +from alexi.label import DEFAULT_MODEL as DEFAULT_LABEL_MODEL +from alexi.label import Identificateur +from alexi.segment import DEFAULT_MODEL as DEFAULT_SEGMENT_MODEL +from alexi.segment import DEFAULT_MODEL_NOSTRUCT, Segmenteur + +LOGGER = logging.getLogger(Path(__file__).stem) + + +def add_arguments(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + """Add the arguments to the argparse""" + parser.add_argument( + "--segment-model", + help="Modele CRF", + type=Path, + ) + parser.add_argument( + "--label-model", help="Modele CRF", type=Path, default=DEFAULT_LABEL_MODEL + ) + parser.add_argument( + "--pages", help="Liste de numéros de page à extraire, séparés par virgule" + ) + parser.add_argument( + "--csv", help="Fichier CSV corriger pour mettre à jour la visualisation" + ) + parser.add_argument("doc", help="Document en PDF", type=Path) + parser.add_argument("out", help="Nom de base des fichiers de sortie", type=Path) + return parser + + +def annotate_pdf( + path: Path, pages: list[int], iob: list[dict[str, Any]], outpath: Path +) -> None: + """ + Marquer les blocs de texte extraits par ALEXI dans un PDF. + """ + pdf = pdfium.PdfDocument(path) + inpage = 0 + outpage = 0 + if pages: + for pagenum in pages: + # Delete up to the current page + idx = pagenum - 1 + while inpage < idx: + pdf.del_page(outpage) + inpage += 1 + # Don't delete the current page :) + inpage += 1 + outpage += 1 + while len(pdf) > len(pages): + pdf.del_page(outpage) + for page, (page_number, group) in zip( + pdf, itertools.groupby(group_iob(iob), attrgetter("page_number")) + ): + page_height = page.get_height() + LOGGER.info("page %d", page_number) + for bloc in group: + x0, top, x1, bottom = bloc.bbox + width = x1 - x0 + height = bottom - top + y = page_height - bottom + LOGGER.info("bloc %s à %d, %d, %d, %d", bloc.type, x0, y, width, height) + path = pdfium_c.FPDFPageObj_CreateNewRect( + x0 - 1, y - 1, width + 2, height + 2 + ) + pdfium_c.FPDFPath_SetDrawMode(path, pdfium_c.FPDF_FILLMODE_NONE, True) + if bloc.type in ("Chapitre", "Annexe"): # Rouge + pdfium_c.FPDFPageObj_SetStrokeColor(path, 255, 0, 0, 255) + elif bloc.type == "Section": # Rose foncé + pdfium_c.FPDFPageObj_SetStrokeColor(path, 255, 50, 50, 255) + elif bloc.type == "SousSection": # Rose moins foncé + pdfium_c.FPDFPageObj_SetStrokeColor(path, 255, 150, 150, 255) + elif bloc.type == "Article": # Rose clair + pdfium_c.FPDFPageObj_SetStrokeColor(path, 255, 200, 200, 255) + elif bloc.type == "Liste": # Bleu-vert (pas du tout rose) + pdfium_c.FPDFPageObj_SetStrokeColor(path, 0, 200, 150, 255) + elif bloc.type in ("Tete", "Pied"): # Jaunâtre + pdfium_c.FPDFPageObj_SetStrokeColor(path, 200, 200, 50, 255) + # Autrement noir + pdfium_c.FPDFPageObj_SetStrokeWidth(path, 1) + pdfium_c.FPDFPage_InsertObject(page, path) + pdfium_c.FPDFPage_GenerateContent(page) + for page, (page_number, group) in zip( + pdf, itertools.groupby(group_iob(iob, "sequence"), attrgetter("page_number")) + ): + page_height = page.get_height() + LOGGER.info("page %d", page_number) + for bloc in group: + x0, top, x1, bottom = bloc.bbox + width = x1 - x0 + height = bottom - top + y = page_height - bottom + LOGGER.info("element %s à %d, %d, %d, %d", bloc.type, x0, y, width, height) + path = pdfium_c.FPDFPageObj_CreateNewRect( + x0 - 1, y - 1, width + 2, height + 2 + ) + pdfium_c.FPDFPath_SetDrawMode(path, pdfium_c.FPDF_FILLMODE_ALTERNATE, False) + pdfium_c.FPDFPageObj_SetFillColor(path, 50, 200, 50, 50) + pdfium_c.FPDFPageObj_SetStrokeWidth(path, 1) + pdfium_c.FPDFPage_InsertObject(page, path) + pdfium_c.FPDFPage_GenerateContent(page) + pdf.save(outpath) + + +def main(args: argparse.Namespace) -> None: + """Ajouter des anotations à un PDF selon l'extraction ALEXI""" + if args.csv is not None: + with open(args.csv, "rt", encoding="utf-8-sig") as infh: + iob = list(csv.DictReader(infh)) + pages = [] + else: + if args.segment_model is not None: + crf = Segmenteur(args.segment_model) + crf_n = crf + else: + crf = Segmenteur(DEFAULT_SEGMENT_MODEL) + crf_n = Segmenteur(DEFAULT_MODEL_NOSTRUCT) + crf_s = Identificateur(args.label_model) + conv = Converteur(args.doc) + pages = [int(x.strip()) for x in args.pages.split(",")] + pages.sort() + feats = conv.extract_words(pages) + if conv.tree is None: + LOGGER.warning("Structure logique absente: %s", args.doc) + segs = crf_n(feats) + else: + segs = crf(feats) + iob = list(crf_s(segs)) + with open(args.out.with_suffix(".csv"), "wt") as outfh: + write_csv(iob, outfh) + annotate_pdf(args.doc, pages, iob, args.out.with_suffix(".pdf")) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + add_arguments(parser) + # Done by top-level alexi if not running this as script + parser.add_argument( + "-v", "--verbose", help="Notification plus verbose", action="store_true" + ) + args = parser.parse_args() + logging.basicConfig(level=logging.INFO if args.verbose else logging.WARNING) + main(args) diff --git a/alexi/convert.py b/alexi/convert.py index e49cd2c..a17bff7 100644 --- a/alexi/convert.py +++ b/alexi/convert.py @@ -1,12 +1,13 @@ """Conversion de PDF en CSV""" +import csv import itertools import logging import operator from collections import deque from io import BufferedReader, BytesIO from pathlib import Path -from typing import Iterable, Iterator, Optional, Union +from typing import Any, Iterable, Iterator, Optional, TextIO, Union from pdfplumber import PDF from pdfplumber.page import Page @@ -38,6 +39,14 @@ ] +def write_csv( + doc: Iterable[dict[str, Any]], outfh: TextIO, fieldnames: list[str] = FIELDNAMES +): + writer = csv.DictWriter(outfh, fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(doc) + + def bbox_contains(bbox: T_bbox, ibox: T_bbox) -> bool: """Déterminer si une BBox est contenu entièrement par une autre.""" x0, top, x1, bottom = bbox