From 9cd65daba29d6373c81a2c4b2167a74e43f24113 Mon Sep 17 00:00:00 2001 From: David Huggins-Daines Date: Thu, 6 Jun 2024 12:14:55 -0400 Subject: [PATCH 1/3] feat: alexi annotate pour corriger erreurs plus vite --- README.md | 49 ++++++++++------ alexi/__init__.py | 20 +++---- alexi/annotate.py | 143 ++++++++++++++++++++++++++++++++++++++++++++++ alexi/convert.py | 11 +++- 4 files changed, 195 insertions(+), 28 deletions(-) create mode 100644 alexi/annotate.py diff --git a/README.md b/README.md index 6e69fda..57ae6e9 100644 --- a/README.md +++ b/README.md @@ -52,36 +52,53 @@ 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 -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: +Dans le PDF, pour le moment, des rectangles colorés sont utiliser pour +représenter les différents éléments annotés et aider à répérer les +erreurs. Notamment: - page1.pdf # PDF annoté avec la segmentation - page1.csv # Traits distintcifs utilisés pour le modèle +- 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) 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..052268a 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 download, extract, annotate 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..3a2cb6e --- /dev/null +++ b/alexi/annotate.py @@ -0,0 +1,143 @@ +""" +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 +import pypdfium2.raw as pdfium_c + +from alexi.analyse import group_iob +from alexi.convert import Converteur, write_csv +from alexi.label import Identificateur, DEFAULT_MODEL as DEFAULT_LABEL_MODEL +from alexi.segment import ( + Segmenteur, + DEFAULT_MODEL as DEFAULT_SEGMENT_MODEL, + DEFAULT_MODEL_NOSTRUCT, +) + +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) + blocs = group_iob(iob) + for page, (page_number, group) in zip( + pdf, itertools.groupby(blocs, 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) + 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 From 33f72317521adbde108ea4b762b1bc7c8c2fd004 Mon Sep 17 00:00:00 2001 From: David Huggins-Daines Date: Thu, 6 Jun 2024 12:21:54 -0400 Subject: [PATCH 2/3] feat: marquer les elements de sequence aussi --- README.md | 7 +++++-- alexi/__init__.py | 2 +- alexi/annotate.py | 32 ++++++++++++++++++++++++-------- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 57ae6e9..abfa54c 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,8 @@ annoter, par exemple: --pages 1,2,3 Dans le PDF, pour le moment, des rectangles colorés sont utiliser pour -représenter les différents éléments annotés et aider à répérer les -erreurs. Notamment: +représenter les blocs annotés et aider à répérer les erreurs. +Notamment: - Les chapitres et annexes sont en rouge - Les sections et articles sont en rose (plus foncé plus le type @@ -77,6 +77,9 @@ erreurs. Notamment: - 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) +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 `1314-page1.csv` pour corriger la segmentation. Il est *très important* de spécifier ces paramètres lorsqu'on ouvre et diff --git a/alexi/__init__.py b/alexi/__init__.py index 052268a..af2607b 100644 --- a/alexi/__init__.py +++ b/alexi/__init__.py @@ -14,7 +14,7 @@ import sys from pathlib import Path -from . import download, extract, annotate +from . import annotate, download, extract from .analyse import Analyseur, Bloc, merge_overlaps from .convert import Converteur, write_csv from .format import format_html diff --git a/alexi/annotate.py b/alexi/annotate.py index 3a2cb6e..831a93a 100644 --- a/alexi/annotate.py +++ b/alexi/annotate.py @@ -15,12 +15,10 @@ from alexi.analyse import group_iob from alexi.convert import Converteur, write_csv -from alexi.label import Identificateur, DEFAULT_MODEL as DEFAULT_LABEL_MODEL -from alexi.segment import ( - Segmenteur, - DEFAULT_MODEL as DEFAULT_SEGMENT_MODEL, - DEFAULT_MODEL_NOSTRUCT, -) +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) @@ -67,9 +65,8 @@ def annotate_pdf( outpage += 1 while len(pdf) > len(pages): pdf.del_page(outpage) - blocs = group_iob(iob) for page, (page_number, group) in zip( - pdf, itertools.groupby(blocs, attrgetter("page_number")) + pdf, itertools.groupby(group_iob(iob), attrgetter("page_number")) ): page_height = page.get_height() LOGGER.info("page %d", page_number) @@ -99,6 +96,25 @@ def annotate_pdf( 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) From 2f0b28a6e66c66a712fce8e5af9e42841519116b Mon Sep 17 00:00:00 2001 From: David Huggins-Daines Date: Thu, 6 Jun 2024 12:24:49 -0400 Subject: [PATCH 3/3] fix: ignore ignore ignore --- alexi/annotate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/alexi/annotate.py b/alexi/annotate.py index 831a93a..9ec71f6 100644 --- a/alexi/annotate.py +++ b/alexi/annotate.py @@ -10,8 +10,8 @@ from pathlib import Path from typing import Any -import pypdfium2 as pdfium -import pypdfium2.raw as pdfium_c +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