Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/main'
Browse files Browse the repository at this point in the history
  • Loading branch information
dhdaines committed Jun 25, 2024
2 parents 1b7e73f + d20d0c1 commit 09b3845
Show file tree
Hide file tree
Showing 4 changed files with 214 additions and 28 deletions.
52 changes: 36 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------------------------------------------
Expand Down
20 changes: 9 additions & 11 deletions alexi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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


Expand Down
159 changes: 159 additions & 0 deletions alexi/annotate.py
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 10 additions & 1 deletion alexi/convert.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 09b3845

Please sign in to comment.