Compare commits

...
35 Commits
Author SHA1 Message Date
sebastien 0a453403cf améliorations diverses 2026-09-17 22:19:05 +02:00
sebastien 5b8215e7f5 modest improvement to cropping (safer) 2026-09-15 15:13:41 +02:00
sebastien 9b22a8a137 Miscs improvements (Interro02) 2026-09-15 14:18:22 +02:00
sebastien 0a86403ca6 miscs (Interro02) : horizontal cutting resolution 2026-09-14 22:20:46 +02:00
sebastien 5080274e8f small changes to prompts 2026-09-14 09:49:01 +02:00
sebastien d60d5479d6 Miscs personal GUI improvement 2026-09-14 09:42:13 +02:00
sebastien e8b8a11c5b Configuration de l'output final 2026-09-12 23:56:07 +02:00
sebastien 060859ddef cropping summaries 2026-09-10 20:03:23 +02:00
sebastien dfce79f240 GUI documentation 2026-09-10 07:23:39 +02:00
sebastien f7b3689f23 Remove `Arguments supplémentaires' boxes 2026-09-09 18:11:00 +02:00
sebastien 0a167072ed Cropping of individual exos 2026-09-09 15:24:39 +02:00
sebastien 3969580e01 Initial cropping support 2026-09-09 14:53:04 +02:00
sebastien 359c62004c Disable flash-lite for labels 2026-09-08 20:04:17 +02:00
sebastien 13a08f6cbd fix sorting 2026-09-08 17:00:18 +02:00
sebastien bf05272797 Miscs 2026-09-08 16:41:04 +02:00
sebastien db4ed2ef31 start-gui.sh et améliorations diverses 2026-09-08 12:04:18 +02:00
sebastien 06d7bad04e Tradution en français des prompts 2026-09-06 19:28:54 +02:00
sebastien 2313d51a62 Refaire session. 2026-09-06 19:20:51 +02:00
sebastien 2ff1a9b7b9 Refaire fixes and GUI support 2026-09-06 19:06:42 +02:00
sebastien a9897606ac mac support 2026-08-26 13:45:15 +02:00
sebastien 15d1319374 Clean command 2026-08-22 17:17:30 +02:00
sebastien 576e3c9452 Fix bug de tri des labels dans le fichier .json 2026-08-22 13:52:24 +02:00
sebastien 2921d5ec8e dependencies et .gitignore 2026-08-22 13:45:04 +02:00
sebastien b9e9d00e7d Restructuration de l'application 2026-08-22 13:39:20 +02:00
sebastien dc25b5fefa Splitting of Readme.org 2026-08-22 13:17:54 +02:00
sebastien 5a7ddd407f Reuse enonce groups if availabel in annotating_by_label 2026-08-22 12:22:46 +02:00
sebastien 92a9f9883e extension de export/import aux autres modes de génération 2026-08-22 12:14:20 +02:00
sebastien 9af05f2c86 Améliorations GUI 2026-08-22 11:55:17 +02:00
sebastien ce9f385a0a Untrack binaries 2026-08-22 11:50:20 +02:00
sebastien 81dc658640 Améliorations GUI 2026-08-22 11:49:13 +02:00
sebastien 3a8d0fe3ff Standardisation 9 2026-08-20 15:23:45 +02:00
sebastien b19d3b0db6 Standardisation 8 2026-08-20 15:18:08 +02:00
sebastien 18d1e5e2bb Standardisation 7 2026-08-20 15:06:14 +02:00
sebastien aa40e58dd1 Standardisation 6 2026-08-20 15:01:17 +02:00
sebastien 644e287586 Standardisation 5 2026-08-20 14:53:06 +02:00
116 changed files with 16385 additions and 11881 deletions
+13
View File
@@ -0,0 +1,13 @@
OLD/
/Interro*/
/DS*/
__pycache__/
*.py[cod]
.venv/
build/
dist/
*.egg-info/
config.py
.copienator-gui.json
.copienator/
tmp/
+115
View File
@@ -0,0 +1,115 @@
#+title: Architecture et conventions de développement
#+author: Sébastien Miquel
#+date: 22-08-2026
#+OPTIONS:
Ce document décrit les invariants techniques du projet. Il s'adresse
aux développeurs et aux agents logiciels qui modifient le dépôt.
- [[file:Readme.org][Guide de démarrage]]
- [[file:Script.org][Référence des étapes et des scripts]]
* API commune pour les scripts
Le paquet =copienator= centralise les chemins d'une évaluation et les
écritures JSON sûres. Un script ne devrait donc plus reconstruire les
chemins partagés à la main :
#+BEGIN_SRC python
from copienator import EvaluationWorkspace, atomic_write_json
workspace = EvaluationWorkspace("Interro")
atomic_write_json(workspace.correction_file, corrections)
#+END_SRC
=EvaluationWorkspace.discover(path)= retrouve également la racine d'une
évaluation à partir d'un fichier ou d'un sous-dossier. Sa construction
ne crée aucun fichier. La création explicite de
=.copienator/logs/= et =.copienator/runs/= se fait avec
=workspace.ensure_control_directories()=. Le chemin
=.copienator/state.sqlite3= est réservé à une future couche d'état
transactionnelle.
=atomic_write_json= écrit d'abord dans un fichier temporaire situé dans
le même dossier, synchronise son contenu, puis remplace la destination.
Une interruption ne laisse donc pas un JSON partiellement écrit. Pour
une modification concurrente de type lire-modifier-écrire, utiliser
=atomic_update_json= : cet utilitaire protège l'opération complète avec
un verrou inter-processus Linux, Windows et macOS.
* Convention des scripts standardisés
Les scripts standardisés exposent =build_parser()=, =run(...)= et
=main(argv=None)=. Leur import ne lance aucun traitement. Ils acceptent
le dossier d'évaluation comme premier argument positionnel, utilisent
=EvaluationWorkspace= pour les chemins partagés et peuvent afficher la
trace complète d'une erreur avec =--verbose=.
Les codes de sortie communs sont :
| Code | Signification |
|------+---------------|
| 0 | réussite |
| 1 | erreur de traitement |
| 2 | arguments invalides |
| 3 | évaluation ou prérequis invalides |
| 4 | traitement partiel, avec avertissements |
| 130 | interruption par l'utilisateur |
Le GUI distingue notamment un traitement partiel d'un échec. Toutes les
commandes du paquet suivent désormais cette convention. Elles sont
regroupées dans =copienator.commands= et exposées par le répartiteur
=python -m copienator=.
* Organisation du code
- Le paquet =copienator= contient les abstractions partagées par les
scripts, notamment =EvaluationWorkspace= et les écritures atomiques.
- Le paquet =copienator_gui= contient la définition du workflow, l'état
persistant, le lanceur de processus et l'application Tk.
- Le sous-paquet =copienator.commands= contient les points d'entrée.
Leur import ne doit pas déclencher de traitement.
- =copienator.dispatcher= expose le CLI unifié et =copienator_gui=
contient l'application Tk.
* État et exécution du GUI
L'état d'une évaluation est conservé dans =.copienator-gui.json=. Les
valeurs des arguments, le dernier mode exécuté, les statuts et un
historique borné y sont enregistrés atomiquement. Les sorties des
processus sont conservées dans =.copienator/logs/=. Le GUI lance les
scripts dans des processus séparés afin de pouvoir afficher leur sortie,
leur transmettre une saisie et les interrompre.
Les retours en arrière ne suppriment pas les artefacts. Ils marquent les
étapes suivantes comme étant à revalider. Les automatismes de première
visite sont également persistés et ne doivent pas être rejoués lors d'un
retour en arrière.
* Invariants à préserver
- Utiliser =EvaluationWorkspace= pour les chemins partagés d'une
évaluation au lieu de reconstruire ces chemins dans chaque script.
- Publier les JSON et les ensembles de fichiers importants de façon
atomique. Une interruption ou un résultat partiel doit conserver la
dernière sortie complète lorsqu'elle existe.
- Conserver la convention =build_parser()=, =run(...)= et
=main(argv=None)= pour les scripts standardisés.
- Ne lancer aucun traitement lors de l'import d'un module.
- Retourner les codes de sortie communs afin que le GUI distingue une
réussite, un résultat partiel, un échec et une interruption.
- Préserver les modifications manuelles et les fichiers déjà présents,
sauf lorsqu'une option explicite comme =--overwrite= ou =--reset=
autorise leur remplacement.
- Les labels deviennent des noms de fichiers. Ils doivent donc respecter
les contraintes de la plateforme, notamment l'interdiction de =:= sous
Windows.
* Règle de classement de la documentation
- Une information nécessaire pour installer ou démarrer l'application
appartient à [[file:Readme.org][Readme.org]].
- Une commande, un argument ou une étape du workflow appartient à
[[file:Script.org][Script.org]].
- Une convention interne, un invariant ou une décision d'architecture
appartient au présent document.
+204 -412
View File
@@ -1,10 +1,10 @@
#+title: Script
#+title: Copienator
#+author: Sébastien Miquel
#+date: 14-03-2026
# Time-stamp: <20-08-26 11:37>
# Time-stamp: <22-08-26 12:22>
#+OPTIONS:
* Méta
* Présentation
** Quézaco
Ce dépôt contient un certain nombre de script Python que j'utilise
@@ -21,23 +21,6 @@ pour faire corriger des copies par Gemini.
4. Ces annotations manuscrites sont lues et recompilées en une
version de la copie pour l'élève.
** Disclaimer
J'utilise régulièrement cet outil et j'en suis satisfait, mais j'ai
fait peu d'efforts pour le rendre universel et simple à l'emploi.
Plusieurs parties de mon workflow sont spécifiques à mes
représentations internes des sujets d'examens et/ou à mon environment.
L'utilisation de ce système nécessite une familiarité avec python et
la ligne de commande unix ; cette familiarité n'est probablement pas
suffisante en l'état, mais en théorie il devrait être possible de le
faire tourner sur le dossier =Example= qui contient une copie
initiale, les fichiers correspondant au sujet de l'interro, et des
examples du rendu final (dans le sous dossier =BGnot=).
Cette situation s'améliorera peut-être, mais faciliter l'utilisation
de ce système n'est pas une priorité.
** Limitations
Pour l'instant, la correction est faite question par question : le LLM
@@ -47,37 +30,120 @@ question précédente ou autre.
*** Noms de labels sous Windows
Certains labels sont utilisés directement comme noms de fichiers et de
dossiers. Les labels contenant notamment =:= ne sont pas acceptés par
Windows. Sous Windows, les scripts refusent ces labels avant de
poursuivre et affichent la liste des valeurs à corriger. Aucune
conversion silencieuse n'est appliquée ; utiliser par exemple
=Ex 1 - a)= au lieu de =Ex 1 : a)=. Le diagnostic du GUI permet de les
repérer avant de lancer une étape.
Les labels de questions sont utilisés comme noms de fichiers. Les
labels contenant notamment =:= ne sont pas acceptés par Windows.
** Requirements
** Prérequis et installation
*** Python
*** Python 3.11 ou plus récent
Libraries :
Sous macOS avec Homebrew, installer Python et la version correspondante
de Tkinter avant de créer l'environnement virtuel. Par exemple :
#+BEGIN_SRC bash
pip install numpy pandas matplotlib pillow pydantic pypdf pdf2image reportlab img2pdf pymupdf ftfy ezodf google
brew install python@3.13 python-tk@3.13
#+END_SRC
*** Poppler (for pdf2image)
Utiliser alors =python3.13= à la place de =python= dans les commandes
de création de l'environnement si la commande non versionnée n'est pas
disponible.
+ Linux : install poppler-utils
+ Windows : Download from: https://github.com/oschwartz10612/poppler-windows
and add it to your PATH
Créer et activer un environnement virtuel est recommandé :
#+BEGIN_SRC bash
python -m venv .venv
#+END_SRC
Sous Linux et macOS :
#+BEGIN_SRC bash
source .venv/bin/activate
#+END_SRC
Sous Windows (PowerShell) :
#+BEGIN_SRC powershell
.venv\Scripts\Activate.ps1
#+END_SRC
Installer ensuite Copienator et ses dépendances Python depuis la
racine du dépôt :
#+BEGIN_SRC bash
python -m pip install --upgrade pip
python -m pip install -e .
#+END_SRC
Cette commande installe les modules contrôlés par le diagnostic du GUI
(NumPy, pandas, Matplotlib, Pillow, pydantic, pypdf, pdf2image,
ReportLab, img2pdf, PyMuPDF, ftfy, ezodf et Google Gen AI), ainsi que
les exécutables =copienator= et =copienator-gui=. Le paquet fournissant
=google.genai= est =google-genai=, et non =google=.
Les commandes restent également accessibles avec
=python -m copienator= depuis la racine du dépôt.
*** Programmes externes obligatoires
Le diagnostic vérifie que Poppler et LaTeX sont accessibles depuis
=PATH=.
**** Linux (Debian et Ubuntu)
#+BEGIN_SRC bash
sudo apt install poppler-utils python3-tk texlive-latex-extra texlive-fonts-extra lmodern python3-pygments
#+END_SRC
Cette sélection couvre les modèles LaTeX de la configuration par
défaut, notamment =standalone=, =lmodern=, =mathabx= et =minted=. Selon
les commandes LaTeX utilisées dans vos propres énoncés, d'autres
paquets TeX peuvent être nécessaires. =python3-tk= fournit Tkinter sur
les installations Python qui ne l'incluent pas d'origine.
**** Windows
1. Télécharger [[https://github.com/oschwartz10612/poppler-windows][Poppler pour Windows]] et ajouter son dossier =bin= à
=PATH=.
2. Installer une distribution LaTeX, par exemple MiKTeX ou TeX Live,
et vérifier que =pdflatex= est accessible depuis =PATH=.
Fermer puis rouvrir le terminal et le GUI après une modification de
=PATH=.
**** macOS
Avec Homebrew, installer Poppler et MacTeX :
#+BEGIN_SRC bash
brew install poppler
brew install --cask mactex
#+END_SRC
MacTeX fournit la distribution TeX Live complète utilisée par les
modèles de Copienator. Après son installation, rouvrir le terminal ou
exécuter :
#+BEGIN_SRC bash
eval "$(/usr/libexec/path_helper)"
#+END_SRC
Copienator recherche aussi les exécutables dans =/opt/homebrew/bin=,
=/usr/local/bin= et =/Library/TeX/texbin=, notamment lorsque le GUI ne
récupère pas le =PATH= du terminal. Ces instructions conviennent aux
Mac Intel et Apple Silicon sous macOS 11 ou plus récent.
*** Programme externe facultatif
PDF Arranger permet d'ouvrir et de réorganiser plus facilement les
PDF. Son absence est signalée comme facultative dans le diagnostic. Il
doit fournir la commande =pdf-arranger= ou =pdfarranger= dans =PATH=.
Sous macOS, Copienator utilise automatiquement Aperçu comme solution de
repli.
*** Accès à Gemini
Il faut créer une clef API pour Gemini (pas facile).
NB : Lors de la création, google offre (offrait ?) 300€ d'utilisation,
mais seulement pendant les trois mois à venir.
Puis ajouter =GEMINI_API_KEY= à l'environnement avec :
#+BEGIN_SRC bash
@@ -91,91 +157,6 @@ ou éventuellement, la renseigner directement dans le fichier
Copier `default_config.py` en `config.py`. Éventuellement le modifier.
*** Interface graphique
Lancer l'assistant avec :
#+BEGIN_SRC bash
python gui.py
#+END_SRC
On peut aussi ouvrir directement une évaluation avec
=python gui.py Interro=. L'interface conserve l'état et l'historique
des étapes dans =Interro/.copienator-gui.json=, et les sorties complètes
dans =Interro/.copienator/logs/=. Une relance d'une étape antérieure ne
supprime aucun résultat ; les étapes suivantes sont seulement marquées
comme étant à revalider.
La variable =SHOW_PERSONAL_STEPS= de =config.py= permet d'afficher ou de
masquer les étapes propres au workflow personnel.
Le bouton =Diagnostic…= vérifie les modules Python, Poppler, LaTeX,
PDF Arranger et la configuration Gemini. Sous Windows, les exécutables
externes doivent être accessibles depuis =PATH=. Quand la création de
liens symboliques ou physiques n'est pas autorisée, l'export et la
préparation de =A Rendre= utilisent automatiquement une copie normale.
Les chemins des étapes personnelles peuvent être adaptés avec
=CURRENT_SCORE_ODS_PATH=, =FINAL_SCORE_ODS_PATH=,
=FINAL_SCORE_OUTPUT_DIR= et =FINAL_SCORE_FONT_PATH= dans =config.py=.
*** API commune pour les scripts
Le paquet =copienator= centralise les chemins d'une évaluation et les
écritures JSON sûres. Un script ne devrait donc plus reconstruire les
chemins partagés à la main :
#+BEGIN_SRC python
from copienator import EvaluationWorkspace, atomic_write_json
workspace = EvaluationWorkspace("Interro")
atomic_write_json(workspace.correction_file, corrections)
#+END_SRC
=EvaluationWorkspace.discover(path)= retrouve également la racine d'une
évaluation à partir d'un fichier ou d'un sous-dossier. Sa construction
ne crée aucun fichier. La création explicite de
=.copienator/logs/= et =.copienator/runs/= se fait avec
=workspace.ensure_control_directories()=. Le chemin
=.copienator/state.sqlite3= est réservé à une future couche d'état
transactionnelle.
=atomic_write_json= écrit d'abord dans un fichier temporaire situé dans
le même dossier, synchronise son contenu, puis remplace la destination.
Une interruption ne laisse donc pas un JSON partiellement écrit. Pour
une modification concurrente de type lire-modifier-écrire, utiliser
=atomic_update_json= : cet utilitaire protège l'opération complète avec
un verrou inter-processus Linux/Windows.
*** Convention des scripts standardisés
Les scripts standardisés exposent =build_parser()=, =run(...)= et
=main(argv=None)=. Leur import ne lance aucun traitement. Ils acceptent
le dossier d'évaluation comme premier argument positionnel, utilisent
=EvaluationWorkspace= pour les chemins partagés et peuvent afficher la
trace complète d'une erreur avec =--verbose=.
Les codes de sortie communs sont :
| Code | Signification |
|------+---------------|
| 0 | réussite |
| 1 | erreur de traitement |
| 2 | arguments invalides |
| 3 | évaluation ou prérequis invalides |
| 4 | traitement partiel, avec avertissements |
| 130 | interruption par l'utilisateur |
Le GUI distingue notamment un traitement partiel d'un échec. Les
scripts migrés vers cette convention sont actuellement :
- =copies_tools.py=, =grouping.py= et =verify_groups.py= ;
- =post-correction.py= et =resolve_manual.py= ;
- =annotating.py=, =annotating_with_checks.py= et
=annotating_by_label.py= ;
- =reading_annotations.py= et =reading_grouped_annotations.py= ;
- =export.py=, =import.py= et =giving_names.py=.
** Correction d'un paquet de copies
1. Créer un fichier =names= dans le dossier courant, avec les
@@ -184,292 +165,103 @@ scripts migrés vers cette convention sont actuellement :
suite)
3. Suivre les instructions suivantes
* Étapes et Script
Utiliser `python gui.py` ou `python gui.py Interro` pour lancer un GUI
qui suit automatiquement les étapes décrites ci-dessous.
** Prétraitement de l'énoncé
Dans le dossier de l'évaluation, mettre les fichiers suivants de l'évaluation :
`enonce.pdf`, `enonce.tex`, `correction.tex`.
- `python gemini_for_enonce.py Interro` or
`python gemini_for_enonce.py Interro --restart`
À partir des trois fichiers précédents, se charge de détecter les
labels des questions et leur contenu.
Les questions vont également être regroupées. Par la suite, quand
des requêtes de corrections seront effectuées sur une question,
seulement les énoncés des questions du groupe seront envoyés (et le
corrigé de la question). Il faut donc que chaque groupe contienne
si possible le contexte nécessaire pour comprendre la question.
Une fenêtre s'ouvre pour permettre d'éditer le résultat. Ne pas
hésiter à faire des groupes plus gros que les groupes par défaut.
Après relecture le script génère :
+ un fichier `labels` avec les labels des questions
+ Un dossier `Text` avec le contenu textuel des questions,
regroupées.
+ Un dossier `Sol` avec le contenu textuel du corrigé, question par
question.
+ Un dossier `Text2`, qui compile un fichier `.tex` pour chaque
question (utilisé pour compiler un rendu pdf du corrigé pour
chaque question)
+ Un dossier `Sol2`, qui compile un fichier `.tex` pour chaque
correction de chaque question.
+ Un dossier `Persp` avec des instruction de barème pour chaque
question.
Éventuellement : vérifier et modifier les barèmes dans `Persp`.
- Alternative personnelle : `python enonce_info.py Interro`
** Prétraitement des copies
Mettre les copies scannées au format pdf dans =Interro=.
1. =python copies_tools.py rotate Interro= (facultatif)
Retourne tous les pdf de 180°, si la photocopie a été faite à
l'envers.
2. =python copies_tools.py rename Interro=
change le nom des copies en =Copie{id}.pdf=
3. =python page_splitter.py Interro=
Découpe des copies A3 en pages A4, en retirant les pages vides.
Pour chaque page double il est possible
+ de garder les deux pages
+ de ne garder qu'une des deux pages.
+ de jeter les deux pages
+ de déplacer la délimitation à droite/gauche
Fix issues with =python page_splitter.py Interro14/Copies/Copie01.pdf=
4. =python cutleft.py Interro=
Découpe la partie gauche des copies, là où il devrait y avoir les
labels des exercices/questions.
=python cutleft.py Interro --fullpage= to use the full page always.
Rerun on a single file with =python cutleft.py Interro/Copies/Copie01.pdf=
** Labelisation et regroupement
Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
1. =python gemini_for_labels.py Interro=, avec éventuellement =--overwrite=
Fait des requêtes à Gemini pour identifier les labels des
questions dans images générées à partir des parties gauches des copies.
2. =python plotting.py Interro=
Permet de vérifier visuellement les labels trouvés.
+ Sous linux, on peut faire =e= pour ouvrir le fichier .json et
l'éditer a la main.
+ Quand un label est manquant, il est possible de cliquer sur
l'image, ce qui copie les coordonnées dans le presse papier
(sous linux…), puis on peut l'ajouter à la main.
+ Utilisation de `_`, `|…` et `…|` :
+ `|…` n'est pas arrêté verticalement par son type opposé.
+ `…|` est stoppé horizontalement par le `|…` le plus proche.
Pour modifier une seule copie :
=python plotting.py Interro/Copies/Copie01.pdf=
It also generates les =Copie01.json=, à partir des =Copie01_01.json=
En cas de soucis, (par exemple les pages ne sont pas dans le bon ordre)
- Réordonner les pages du fichier pdf
- Rerun =python cutleft.py Interro/Copie{id}=
- Rerun =python gemini_for_labels.py Interro/Copie{id}=
3. =python splitting_int.py Interro=
Découpe les copies suivant les exercices
Peut-être appelé avec une seule copie.
4. =python grouping.py Interro=
Regroupe les mêmes questions de différentes copies en groupes de
tailles raisonnables.
5. Facultatif : =python verify_groups.py Interro=
Vérifie que chaque réponse PDF apparaît bien dans les métadonnées
des groupes. La commande renvoie un code non nul si une réponse est
absente ou si la vérification est incomplète.
** Correction et annotation
Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
1. Il faut créer des persp, pour indication de comment corriger, et
relancer =enonce_info.py=
2. =python correction.py Interro --limit 240= OU
=python correction.py Interro/Par\ label/Ex\ 2/Group_1.jpg= OU
=python correction.py Interro --overwrite=
Fais les requêtes de correction à Gemini.
L'argument =limit= limite le nombre de requêtes à Gemini Pro
(chères), pour une version low cost, passer =--limit 0=, toutes
les requêtes seront sur Gemini Flash.
Will it resume ? It seems so. Best to wait a bit.
Pour diminuer le coût, il est possible de batch les requêtes, qui
seront alors traitées sous au plus 24h.
+ =python correction.py Interro --batch=
+ OU =python correction.py Interro --batch-from 'Ex 4'=
+ =python submit_batches.py Interro=
+ =python batch_status.py=
+ =python fetch_batched_results.py Interro=
+ =python correction.py Interro --deal-with-batched=
3. =python post-correction.py Interro=
- Essaye de corriger des erreurs d'encodage/d'accents dans
=correction.json=.
- aussi échappe les `_` en dehors du mode math, pour LaTeX.
4. Résolution manuel de conflits, s'il y en a.
Edit `manual_resolutions.txt`. Use :
+ `->` or `x>` : Here set a pipe `|` before or after the new_label name
+ `-x` : replace the goal
+ `ss` : do nothing
+ `sx` : stay, and remove goal.
+ `xx` : move to goal.
+ `xs` : remove old, keep goal.
Then call `python resolve_manual.py Interro`
5. Call `python correction.py Interro --refaire`.
** Génération des copies annotées
1. =python annotating.py Interro= (facultatif)
Ajoute les annotations Gemini aux copies, enregistrées dans le dossier =Anot=.
On peut passer l'argument =--overwrite=.
OU
2. =python annotating_with_checks.py Interro=
Ajoute les annotations Gemini, et des checkboxes à cocher.
Enregistrées dans le dossier =Bnot=,
=--overwrite=
Une seule copie peut être ciblée avec, par exemple,
=python annotating_with_checks.py Interro/Copies/Copie01.pdf=.
Le mode =--refaire= exige un fichier =refaire.json= et écrit dans
=BRnot=.
OU
2. =python annotating_by_label.py Interro= dans =BGnot=
Ajoute les annotations Gemini, et des checkboxes, et regroupe les
réponses par question.
Enregistrées dans =BGnot=
_Needs_ : label_groups file (made automatically by this function),
qui dit quelles questions regrouper.
Dans ces trois modes, les métadonnées JSON sont écrites atomiquement.
Lors d'une régénération, les nouvelles sorties sont préparées dans un
dossier temporaire voisin. Une erreur de rendu conserve donc la sortie
précédente ; pour =BGnot --overwrite=, le dossier complet n'est remplacé
que si tous les groupes ont été produits.
3. =python export.py Interro= (gestion perso)
Cela déplace les groupes dans le dossier configuré par =EXPORT_DIR=
(par défaut =Export=).
Il faut ensuite annoter les fichiers dans `EXPORT_DIR` avec une
tablette graphique.
** Lecture de la correction manuscrite
_Before_ : vider le dossier configuré par =IMPORT_DIR= (par défaut
=Import=), puis y copier ou synchroniser les fichiers depuis la tablette.
1. =python import.py Interro=
Une fois les corrections manuelles appliquées aux fichiers
=Concat.pdf=, il faut enregistrer le fichier annoté au même endroit,
sous le nom =Concat_annotated.pdf=.
2. =python reading_annotations.py Interro=
Lit les =Concat_annotated= dans =Bnot=, regénère les copies avec
les modifications. Les fichiers générés (=score.json=,
=Concat.jpg=, etc.) sont préparés séparément puis installés
ensemble. Les entrées du dossier =Bnot= restent en place et une
erreur de génération conserve les anciennes sorties.
OU
2. =python reading_grouped_annotations.py Interro=
Idem, mais pour =BGnot=. Les tâches parallèles remontent leurs
erreurs au processus principal au lieu de les ignorer.
3. =python giving_names.py Interro BGnot=
Crée un dossier =A Rendre= avec des liens symboliques vers
+ La copie à rendre
+ un fichier =score.json= qui contient les notes par question
Si un nom est =Unknown= : renommer à la main le dossier et le fichier dedans.
4. Éventuellement, faire des modifications manuelles aux =score.json=.
Puis
- `python reading_annotations.py --update-score Interro`
- `python reading_grouped_annotations.py --update-score Interro`
pour mettre à jour les scores dans les images.
4. (gestion perso)
+ =gestion_classe ne= pour créer l'interro puis
+ =gestion_classe we= (set barème here)
+ =python update_ods.py Interro=
ou =python update_ods.py Interro --sum= (en l'absence de barème)
+ =gestion_classe re=
+ =gestion_classe wsent=
+ =python add_final_score.py Interro21=
(this makes files in =Server/copies=)
5. (gestion perso)
+ Deploy =miqmacs-copies-assets=, and
+ update the copies from =miqmacs.fr/admin=.
6. (gestion perso) Impression d'une copie. Via Evince » print to pdf.
* Autres
** Recorrection d'une seule copie (peu testé)
!! Attention, refaire ne marchera pas si tu fais une annotation non
groupée into refaire !!
1. Redécoupage
+ =python plotting.py InterroTest/Copie01.pdf=
+ =python splitting_int.py InterroTest/Copie20.pdf=
2. Créer =refaire.json=, avec un contenu comme
: [["Copie02", []],
: ["Copie01", ["Ex 1 : 1)"]]]
3. Appeler =correction= avec --refaire. Il doit créer des groupes
individuels, faire des requêtes, et remplacer les corrections
précédentes (à sauver ailleurs).
Ou non, si tu veux le faire à la main.
4. ?? Si je fais refaire, avant d'avoir créer les annotating with
checks, que se passe-t-il ???
5. Appeler =annotating_with_checks.py --refaire --overwrite=
6. =python export.py --refaire Interro24=
6. =python import.py --refaire Interro24=
7. =python reading_grouped_annotations.py --refaire Interro24=
Avec =--refaire=, =refaire.json= et le dossier =BRnot= sont des
prérequis obligatoires ; leur absence produit le code de sortie 3.
** Exemple de replotting, refaire d'une copie
1. replot it.
2. `python splitting_int.py DS09VA/Copies/Copie25.pdf`
this will get rid of old/new.
!! Attention, et si ça dégage un new : bad bad bad.
3. Make `refaire.json`, avec la copie, et les labels à refaire.
4. `python correction.py DS09VA --refaire`
5. `python annotating_with_checks.py DS09VA --refaire`
6. `python import.py Interro24 --refaire`
** Interface graphique
Lancer l'assistant avec :
#+BEGIN_SRC bash
python -m copienator gui
#+END_SRC
Sous Linux ou macOS, le lanceur exécutable =./start-gui.sh= démarre aussi
linterface, depuis nimporte quel répertoire. Il utilise le Python de
=.venv= sil existe, sinon =python3= du PATH. On peut lui passer le dossier
d’évaluation : =./start-gui.sh Interro=. Sans argument, il ouvre le
sous-dossier immédiat non masqué le plus récemment modifié du répertoire
courant, en excluant =copienator=, =copienator_gui=, =tests=, =OLD=,
=__pycache__=, =build=, =dist=, =*.egg-info=, =venv=, =env= et
=node_modules=. Sil ny a aucun dossier admissible, linterface démarre
sans évaluation. Un chemin explicite reste utilisable même sil est exclu
de la sélection automatique.
=Recharger — vérifier à nouveau= relit les fichiers dentrée du dossier.
Pour reprendre le découpage dune copie, sélectionnez-la dans =Copies
détectées=, cliquez sur =Refaire la copie sélectionnée=, puis sur
=Exécuter=. Le découpage repart de loriginal conservé.
Dans la fenêtre de découpage, =i= inverse lordre de toutes les pages
(dernière vers première) et reprend à la nouvelle première page. Les choix
de découpage déjà saisis sont effacés ; les rotations globales sont conservées.
Ce raccourci est configurable via =PAGE_SPLITTER_KB["reverse_pages"]=.
=Copier la commande= copie les commandes complètes (à lancer depuis la
racine du projet). Dans la console, les boutons de copie et le clic droit
permettent de copier la sélection ou toute la sortie ; =Ctrl+C= et
=Ctrl+A= sont également disponibles (=Cmd= sous macOS).
Pendant =Découper une partie à gauche pour détection des labels=, =n= décale
la zone de 50 px vers la droite, =N= de 100 px, =t= de 50 px vers la
gauche et =l= l’élargit de 50 px. =1= utilise les pages entières. =s=
signale la copie en erreur et passe à la suivante ; =Entrée= valide et
enregistre la découpe affichée.
Une copie ignorée conserve ses anciennes découpes. Les signalements sont
conservés dans =.copienator/copy_errors.json=, même après fermeture.
Dans =Séparer et réordonner les pages=, =Traiter les copies signalées=
reprend ces copies à partir des originaux conservés. Le même bouton dans
=Découper une partie à gauche pour détection des labels= reprend leur
découpage ; chaque signalement
est effacé seulement après validation et enregistrement avec =Entrée=.
Fermer la fenêtre, appuyer à nouveau sur =s= ou rencontrer une erreur
conserve le signalement. Les commandes =page-split= et =crop-labels=
acceptent aussi =--marked= pour traiter les copies signalées de l’évaluation.
Avec =SHOW_PERSONAL_STEPS = True=, =Analyser l’énoncé= propose le choix
entre Gemini et =Énoncés et solutions personnels (SHEETINFO)=. Ce dernier
lance =python -m copienator statement-personal Interro= : il lit
=enonce.tex=, utilise le service dexercices sur =localhost:8080= pour
générer =Text=, =Sol=, =Text2=, =Sol2= et les barèmes personnels =Persp=,
et écrit un groupe par exercice dans =label_groups=.
Deux boutons ouvrent ensuite des actions facultatives avec aperçu de
commande et bouton =Exécuter= : =Regrouper avec Gemini…= remplace seulement
=label_groups= (=statement Interro --groups-only=) ; =Remplacer Persp avec
Gemini…= régénère seulement les barèmes des groupes actuels
(=statement Interro --persp-only=), avec le même prompt que le parcours
Gemini complet. Une réponse incomplète ou un échec laisse les anciens
barèmes en place. On peut ignorer ces étapes et conserver les résultats
personnels.
On peut aussi ouvrir directement une évaluation avec =python -m copienator gui
Interro=. L'interface conserve l'état et l'historique des étapes dans
=Interro/.copienator-gui.json=, et les sorties complètes dans
=Interro/.copienator/logs/=. Une relance d'une étape antérieure ne
supprime aucun résultat ; les étapes suivantes sont seulement marquées
comme étant à revalider.
Après la réussite d'un script, l'interface sélectionne automatiquement
l'étape suivante. =Découper les réponses par question= et =Regrouper
les réponses= démarrent automatiquement lors de leur première visite.
Avec le mode =Correction immédiate=, les trois étapes batch sont alors
marquées =Ignorée= à leur première visite. L'étape de résolution
manuelle est traitée de la même façon si =manual_resolutions.txt= est
absent, vide ou ne contient que des commentaires. Ces automatismes ne
se répètent pas lors d'un retour en arrière.
Le bouton =Diagnostic…= vérifie les modules Python, Poppler, LaTeX,
PDF Arranger (ou Aperçu sous macOS) et la configuration Gemini. Sous
Windows, les exécutables externes doivent être accessibles depuis
=PATH=. Sous macOS, les emplacements standards de Homebrew et MacTeX
sont également inspectés. Quand la création de liens symboliques ou
physiques n'est pas autorisée, l'export et la préparation de =A Rendre=
utilisent automatiquement une copie normale.
Les chemins des étapes personnelles peuvent être adaptés avec
=CURRENT_SCORE_ODS_PATH=, =FINAL_SCORE_ODS_PATH=,
=FINAL_SCORE_OUTPUT_DIR= et =FINAL_SCORE_FONT_PATH= dans =config.py=.
* Documentation complémentaire
- [[file:docs/final_output.md][Fichiers finaux dans A Rendre]] : contenu du JPEG, sélection et
pagination du PDF, JPEG par réponse, =score.json=, =info.json= et diffusion.
- [[file:Script.org][Référence des étapes et des scripts]] : commandes, arguments,
prérequis, fichiers produits et parcours alternatifs.
- [[file:Architecture.org][Architecture et conventions de développement]] : API commune,
état partagé, écritures sûres et règles de maintenance.
+506
View File
@@ -0,0 +1,506 @@
#+title: Référence des étapes et des scripts
#+author: Sébastien Miquel
#+date: 22-08-2026
#+OPTIONS:
Ce document décrit le workflow détaillé, les commandes disponibles et
les parcours alternatifs.
- [[file:Readme.org][Guide de démarrage]]
- [[file:Architecture.org][Architecture et conventions de développement]]
* Étapes
Utiliser `python -m copienator gui` ou `python -m copienator gui Interro` pour lancer un GUI
qui suit automatiquement les étapes décrites ci-dessous.
** Prétraitement de l'énoncé
Dans le dossier de l'évaluation, mettre les fichiers suivants de l'évaluation :
`enonce.pdf`, `enonce.tex`, `correction.tex`.
- `python -m copienator statement Interro` or
`python -m copienator statement Interro --restart`
À partir des trois fichiers précédents, se charge de détecter les
labels des questions et leur contenu.
Les questions vont également être regroupées. Par la suite, quand
des requêtes de corrections seront effectuées sur une question,
seulement les énoncés des questions du groupe seront envoyés (et le
corrigé de la question). Il faut donc que chaque groupe contienne
si possible le contexte nécessaire pour comprendre la question.
Une fenêtre s'ouvre pour permettre d'éditer le résultat. Ne pas
hésiter à faire des groupes plus gros que les groupes par défaut.
Après relecture le script génère :
+ un fichier `labels` avec les labels des questions
+ Un dossier `Text` avec le contenu textuel des questions,
regroupées.
+ Un dossier `Sol` avec le contenu textuel du corrigé, question par
question.
+ Un dossier `Text2`, qui compile un fichier `.tex` pour chaque
question (utilisé pour compiler un rendu pdf du corrigé pour
chaque question)
+ Un dossier `Sol2`, qui compile un fichier `.tex` pour chaque
correction de chaque question.
+ Un dossier `Persp` avec des instruction de barème pour chaque
question.
Éventuellement : vérifier et modifier les barèmes dans `Persp`.
- Alternative personnelle : `python -m copienator statement-personal Interro`
Ces deux commandes suivent la convention des scripts standardisés.
Leur import ne lance aucun traitement et les erreurs partielles sont
distinguées des échecs. Les réponses d'extraction mises en cache par
=copienator statement= et le fichier =labels= sont publiés
atomiquement.
** Prétraitement des copies
Mettre les copies scannées au format pdf dans =Interro=.
1. =python -m copienator copies rotate Interro= (facultatif)
Retourne tous les pdf de 180°, si la photocopie a été faite à
l'envers.
2. =python -m copienator copies rename Interro=
change le nom des copies en =Copie{id}.pdf=
3. =python -m copienator page-split Interro=
Découpe des copies A3 en pages A4, en retirant les pages vides.
Pour chaque page double il est possible
+ de garder les deux pages
+ de ne garder qu'une des deux pages.
+ de jeter les deux pages
+ de déplacer la délimitation à droite/gauche
Fix issues with =python -m copienator page-split Interro14/Copies/Copie01.pdf=
Le PDF transformé est construit dans un dossier temporaire. La
copie produite et la sauvegarde dans =Copies Originales= sont
ensuite installées avec rollback : une erreur conserve les deux
versions précédentes. Une relance ciblée lit directement la
sauvegarde originale sans la déplacer au préalable.
4. =python -m copienator crop-labels Interro=
Découpe la partie gauche des copies, là où il devrait y avoir les
labels des exercices/questions.
=python -m copienator crop-labels Interro --fullpage= to use the full page always.
Rerun on a single file with =python -m copienator crop-labels Interro/Copies/Copie01.pdf=
Les images et le fichier =_schema.json= d'une copie sont remplacés
ensemble. Fermer l'outil juste après la dernière validation ne peut
donc plus interrompre un thread de sauvegarde en arrière-plan.
** Labelisation et regroupement
Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
1. =python -m copienator labels Interro=, avec éventuellement =--overwrite=
Fait des requêtes à Gemini pour identifier les labels des
questions dans images générées à partir des parties gauches des copies.
Une copie PDF ou une image précise de =Cutleft= peut également être
ciblée. Plusieurs cibles de la même évaluation sont acceptées. Les
parties d'une copie restent traitées séquentiellement afin de
conserver les labels précédents comme contexte, tandis que les
copies différentes sont traitées en parallèle. Chaque réponse JSON
validée est écrite atomiquement. Une cible sans image correspondante
produit le code de sortie 4.
2. =python -m copienator review-labels Interro=
Permet de vérifier visuellement les labels trouvés.
+ Sous Linux et macOS, on peut faire =e= pour ouvrir le fichier .json et
l'éditer a la main.
+ Quand un label est manquant, il est possible de cliquer sur
l'image, ce qui copie les coordonnées dans le presse papier
puis on peut l'ajouter à la main.
+ Utilisation de `_`, `|…` et `…|` :
+ `|…` n'est pas arrêté verticalement par son type opposé.
+ `…|` est stoppé horizontalement par le `|…` le plus proche.
Pour modifier une seule copie :
=python -m copienator review-labels Interro/Copies/Copie01.pdf=
Les coordonnées agrégées sont écrites atomiquement dans le JSON de
la copie. Fermer la fenêtre avant la fin d'une copie ne remplace pas
son JSON par un résultat incomplet.
It also generates les =Copie01.json=, à partir des =Copie01_01.json=
En cas de soucis, (par exemple les pages ne sont pas dans le bon ordre)
- Réordonner les pages du fichier pdf
- Rerun =python -m copienator crop-labels Interro/Copie{id}=
- Rerun =python -m copienator labels Interro/Copie{id}=
3. =python -m copienator split-answers Interro=
Découpe les copies suivant les exercices
Peut-être appelé avec une seule copie.
Les réponses d'une copie sont préparées dans un dossier temporaire,
puis remplacent ensemble le dossier précédent. En cas d'erreur,
l'ancienne version est conservée. Les réponses devenues obsolètes
restent archivées dans le sous-dossier =Missing=.
4. =python -m copienator group-answers Interro=
Regroupe les mêmes questions de différentes copies en groupes de
tailles raisonnables.
5. Facultatif : =python -m copienator verify-groups Interro=
Vérifie que chaque réponse PDF apparaît bien dans les métadonnées
des groupes. La commande renvoie un code non nul si une réponse est
absente ou si la vérification est incomplète.
** Correction et annotation
Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
1. Il faut créer des persp, pour indication de comment corriger, et
relancer =copienator statement-personal=
2. =python -m copienator correct Interro --limit 240= OU
=python -m copienator correct Interro/Par\ label/Ex\ 2/Group_1.jpg= OU
=python -m copienator correct Interro --overwrite=
Fais les requêtes de correction à Gemini.
=copienator correct= peut être relancé sans supprimer son état. Les
fichiers =correction.json= et =correction_progress.json= sont mis à
jour atomiquement. Avec =--overwrite=, leur version précédente reste
en place jusqu'à la première écriture réussie de la nouvelle
exécution. =--reset= est la seule option qui supprime explicitement
cet état et restaure les fichiers =*_old.pdf=.
L'argument =limit= limite le nombre de requêtes à Gemini Pro
(chères), pour une version low cost, passer =--limit 0=, toutes
les requêtes seront sur Gemini Flash.
Pour diminuer le coût, il est possible de batch les requêtes, qui
seront alors traitées sous au plus 24h.
+ =python -m copienator correct Interro --batch=
+ OU =python -m copienator correct Interro --batch-from 'Ex 4'=
+ =python -m copienator batch-submit Interro=
+ =python -m copienator batch-status=
+ =python -m copienator batch-fetch Interro=
+ =python -m copienator correct Interro --deal-with-batched=
Les quatre commandes de ce flux suivent la convention des scripts
standardisés. Les fichiers de requêtes et le résultat JSONL combiné
sont publiés atomiquement : une interruption ne laisse pas de fichier
final partiellement écrit. =copienator batch-submit= conserve aussi les
identifiants distants dans =batch_jobs.json= ; la récupération les
utilise en priorité et garde la recherche par nom pour les anciens
batchs. =python -m copienator batch-status --download JOB --output
resultat.jsonl= permet aussi de télécharger atomiquement le résultat
d'un job particulier.
3. =python -m copienator post-correction Interro=
- Essaye de corriger des erreurs d'encodage/d'accents dans
=correction.json=.
- aussi échappe les `_` en dehors du mode math, pour LaTeX.
4. Résolution manuel de conflits, s'il y en a.
Edit `manual_resolutions.txt`. Use :
+ `->` or `x>` : Here set a pipe `|` before or after the new_label name
+ `-x` : replace the goal
+ `ss` : do nothing
+ `sx` : stay, and remove goal.
+ `xx` : move to goal.
+ `xs` : remove old, keep goal.
Then call `python -m copienator resolve-manual Interro`
5. Call `python -m copienator correct Interro --refaire`.
** Génération des copies annotées
1. =python -m copienator annotate-simple Interro= (facultatif)
Ajoute les annotations Gemini aux copies, enregistrées dans le dossier =Anot=.
On peut passer l'argument =--overwrite=.
OU
2. =python -m copienator annotate-checks Interro=
Ajoute les annotations Gemini, et des checkboxes à cocher.
Enregistrées dans le dossier =Bnot=,
=--overwrite=
Une seule copie peut être ciblée avec, par exemple,
=python -m copienator annotate-checks Interro/Copies/Copie01.pdf=.
Le mode =--refaire= exige un fichier =refaire.json= et écrit dans
=BRnot=.
OU
2. =python -m copienator annotate-grouped Interro= dans =BGnot=
Ajoute les annotations Gemini, et des checkboxes, et regroupe les
réponses par question.
Enregistrées dans =BGnot=
_Needs_ : label_groups file (made automatically by this function),
qui dit quelles questions regrouper.
3. =python -m copienator export Interro BGnot= (gestion perso)
Cela déplace les groupes dans le dossier configuré par =EXPORT_DIR=
(par défaut =Export=).
Le second argument peut être =BGnot=, =Bnot= ou =Anot= et reste
facultatif (=BGnot= par défaut). =Anot= exporte =Concat.jpg= ; les
deux autres modes exportent =Concat.pdf=. Dans le GUI, seuls les
dossiers présents sont proposés et le mode de la dernière génération
d'annotations est présélectionné.
Il faut ensuite annoter les fichiers dans `EXPORT_DIR` avec une
tablette graphique.
** Lecture de la correction manuscrite
_Before_ : vider le dossier configuré par =IMPORT_DIR= (par défaut
=Import=), puis y copier ou synchroniser les fichiers depuis la tablette.
1. =python -m copienator import Interro BGnot=
Une fois les corrections manuelles appliquées aux fichiers
=Concat.pdf=, il faut enregistrer le fichier annoté au même endroit,
sous le nom =Concat_annotated.pdf=.
Comme pour l'export, le second argument accepte =BGnot=, =Bnot= ou
=Anot=. Le GUI présélectionne le dossier utilisé lors du dernier
export. Pour =Anot=, l'image est importée sous le nom
=Concat_annotated.jpg=.
2. =python -m copienator read-annotations Interro=
Lit les =Concat_annotated= dans =Bnot=, regénère les copies avec
les modifications. Les fichiers générés (=score.json=,
=Concat.jpg=, etc.) sont préparés séparément puis installés
ensemble. Les entrées du dossier =Bnot= restent en place et une
erreur de génération conserve les anciennes sorties.
OU
2. =python -m copienator read-grouped Interro=
Idem, mais pour =BGnot=. Les tâches parallèles remontent leurs
erreurs au processus principal au lieu de les ignorer.
3. =python -m copienator giving-names Interro BGnot=
Crée un dossier =A Rendre= avec des liens symboliques vers
+ =<nom>.jpg= : la correction complète concaténée (=Concat.jpg=)
+ =<nom>.pdf=, si disponible : la correction filtrée, avec contexte,
énoncés et solutions dans le parcours =BGnot= (=Concat_F.pdf=)
+ un fichier =score.json= qui contient les notes par question
+ =info.json= : pour chaque label, =present= (réponse fournie),
=not_empty= (réponse non vide compilée), =touched= (présente dans le
PDF filtré) et =score= (même valeur que dans =score.json=)
+ =answers/*.jpg=, si =RETURN_ANSWERS_ENABLED= est activé : un JPEG
par réponse non vide, contenant toujours la réponse annotée
Le PDF n'est donc pas une conversion du JPEG. Voir la
[[file:docs/final_output.md][documentation des fichiers finaux]] pour les règles de sélection,
la pagination et les différences entre parcours.
=RETURN_JPEG_ENABLED= et =RETURN_PDF_ENABLED= dans =config.py=
permettent de désactiver ces sorties séparément (activées par défaut).
Relancer =giving-names= retire les fichiers nommés désactivés en
conservant leurs sources. =score.json= et =info.json= sont toujours inclus.
=RETURN_ANSWERS_ENABLED= est désactivé par défaut, activé dans la
configuration personnelle. =RETURN_ANSWERS_CONTEXT=,
=RETURN_ANSWERS_QUESTION= et =RETURN_ANSWERS_SOLUTION= choisissent les
documents ajoutés avant chaque réponse (seule la question est activée
par défaut). Recompiler les anciennes annotations une fois avant cet
export pour produire les images finales et leurs métadonnées.
Si un nom est =Unknown= : renommer à la main le dossier et le fichier dedans.
4. Éventuellement, faire des modifications manuelles aux =score.json=.
Puis
- `python -m copienator read-annotations --update-score Interro`
- `python -m copienator read-grouped --update-score Interro`
pour mettre à jour les scores dans les images.
4. (gestion perso)
+ =gestion_classe ne= pour créer l'interro puis
+ =gestion_classe we= (set barème here)
+ =python -m copienator update-ods Interro=
ou =python -m copienator update-ods Interro --sum= (en l'absence de barème)
+ =gestion_classe re=
+ =gestion_classe wsent=
+ =python -m copienator add-final-score Interro21=
(this makes files in =Server/copies=)
5. (gestion perso)
+ Deploy =miqmacs-copies-assets=, and
+ update the copies from =miqmacs.fr/admin=.
6. (gestion perso) Impression d'une copie. Via Evince » print to pdf.
** Archivage et nettoyage
Une fois l'évaluation terminée, =python -m copienator clean Interro=
supprime les fichiers intermédiaires et régénérables. La commande ne
conserve que :
+ les PDF =Copies/*.pdf= produits après le découpage des pages ;
+ les sources et sorties textuelles du prétraitement de l'énoncé :
=enonce.tex=, =correction.tex=, =labels=, =label_groups=, =Text=,
=Sol=, =Persp=, les fichiers TeX de =Text2= et =Sol2=, =Cache= et
=Tmp= ;
+ le résultat final =correction.json= ;
+ les journaux de =.copienator/logs= et les journaux placés à la
racine, comme =correction_log= ;
+ les images (y compris =answers=), PDF et fichiers =score.json= et
=info.json= présents dans =A Rendre=.
Les liens symboliques conservés dans =A Rendre= sont remplacés par de
véritables fichiers avant la suppression de leurs cibles. La commande
refuse de démarrer si les copies traitées, =correction.json= ou une
image/un score d'élève sont absents (l'image est facultative si
=RETURN_JPEG_ENABLED= vaut =False=). Elle affiche d'abord un résumé et
demande de saisir le nom de l'évaluation pour confirmer.
Dans le GUI, cette commande apparaît comme dernière étape facultative
dans la section =Archivage=. Un avertissement rappelle que la
progression du GUI et les données binaires permettant de reprendre les
étapes seront définitivement perdues.
Utiliser =python -m copienator clean Interro --dry-run= pour afficher
le plan sans rien supprimer, et =--yes= pour omettre la confirmation
interactive.
* Autres
** Recorrection d'une copie ou de quelques questions
Dans le GUI, ouvrir la section =Refaire des copies (facultatif)=,
repliée par défaut. Ajouter les copies et leurs questions depuis les
listes déroulantes, ou choisir =Toute la copie=, puis enregistrer la
sélection. Le dossier du passage principal est présélectionné d'après
le dernier mode utilisé et les dossiers présents ; vérifier ce choix.
Pour reprendre une même question dans toute la classe, choisir
=Toutes les copies=, sélectionner la question, puis =+ Ajouter=.
Seules les copies ayant un PDF de réponse pour cette question (normal
ou =_new=) sont ajoutées ; le nombre de copies sans réponse est affiché.
Une copie déjà sélectionnée entièrement reste sélectionnée entièrement.
Les boutons =Corrigés (Sol)= et =Consignes de notation (Persp)= ouvrent
les dossiers des textes utilisés par la correction. Modifier et enregistrer
les fichiers des questions concernées avant de relancer =Refaire la correction=.
Ces boutons sont aussi disponibles dans le parcours principal.
Le choix =PDF à vérifier= propose =Automatique=, =Par question (groupé)=
ou =Par copie=. En automatique, une question présente dans plusieurs
copies déclenche le regroupement ; sinon les PDF sont produits par copie.
Les groupes sont limités en hauteur : une question pour toute la classe
peut donc produire quelques PDF plutôt qu'un fichier par élève.
Le GUI écrit =refaire.json= et guide ensuite le parcours ci-dessous.
La vérification du découpage, le redécoupage et la recorrection peuvent
être ignorés. Pour plusieurs copies, les vérifications et découpages
s'exécutent successivement ; un échec ou une interruption arrête la suite.
Ce parcours a sa propre progression : les boutons de navigation du
passage principal ne l'ouvrent pas automatiquement. Après la fusion,
les étapes de restitution déjà terminées sont marquées à revalider.
Pour enchaîner les reprises, deux boutons sont disponibles dans ce parcours :
- =Nouvelle reprise= vide la sélection et remet les étapes de reprise à zéro.
- =Refaire la même sélection= conserve les copies et les questions du dernier
enregistrement, mais remet également les étapes de reprise à zéro.
Les choix du passage principal et de présentation des PDF sont conservés.
Enregistrer ensuite la sélection avant de poursuivre.
Attention : appeler =Nouvelle reprise= seulement après avoir importé les
résultats et exécuté =Mettre à jour les copies finales=. La même précaution
s'applique à =Refaire la même sélection=. Un avertissement est affiché avant
les deux actions, avec une mention supplémentaire si la fusion n'est pas
marquée réussie. Annuler conserve la reprise actuelle. Après une fusion
réussie, utiliser ces boutons pour recommencer, plutôt que modifier la
sélection du passage terminé.
Chaque nouveau passage utilise =Reprises/reprise-DATE-HEURE-ID/BRnot=.
Le passage précédent garde ses PDF, ses retours manuscrits, sa sélection
et une copie de la progression du GUI. Au premier changement de passage,
l'ancien =BRnot= à la racine est également copié dans =Reprises=.
Ces archives concernent les fichiers de vérification, pas un mécanisme
permettant d'annuler les modifications des copies finales.
Le fichier =refaire-session.json= désigne le passage actif ; les commandes
habituelles =--refaire= le suivent automatiquement. Sans ce fichier, le
fonctionnement historique dans =BRnot= à la racine reste disponible.
Dans la suite, =BRnot= désigne le dossier de la reprise active.
L'export d'un passage identifié utilise son propre sous-dossier dans
=EXPORT_DIR/Évaluation= et préfixe les noms des PDF par son identifiant.
Conserver les noms complets au retour et placer les PDF directement dans
=IMPORT_DIR=. L'import ignore les retours des autres passages ; aucun retour
du passage actif donne un résultat partiel. Ainsi, deux reprises de la même
question ne partagent pas les mêmes noms de fichiers exportés.
Ce flux fonctionne après =annotate-grouped= (=BGnot=),
=annotate-checks= (=Bnot=) ou =annotate-simple= (=Anot=).
Terminer d'abord la lecture des annotations du passage principal
(=read-grouped= ou =read-annotations= pour les modes à cases).
Conserver les dossiers d'annotation et leurs fichiers de référence.
1. Si nécessaire, reprendre le découpage :
+ =python -m copienator review-labels Interro/Copies/Copie01.pdf=
+ =python -m copienator split-answers Interro/Copies/Copie01.pdf=
Vérifier les réponses découpées avant de relancer la correction,
notamment les fichiers =_new= et =_old= issus de résolutions manuelles.
2. Créer =Interro/refaire.json= :
: [["Copie02", []],
: ["Copie01", ["Ex 1 : 1)"]]]
Une liste vide sélectionne toute la copie ; sinon donner les labels
exacts des questions (pas seulement le nom de l'exercice).
3. =python -m copienator correct Interro --refaire=
Crée des groupes individuels et remplace les corrections sélectionnées.
Les anciennes corrections sont conservées dans =overwritten_correction.json=.
Cette étape peut être omise si les corrections sont modifiées à la main.
4. Générer les PDF de vérification, selon la présentation souhaitée :
+ Par question : =python -m copienator annotate-grouped Interro --refaire --overwrite=
+ Par copie : =python -m copienator annotate-checks Interro --refaire --overwrite=
Les deux commandes produisent uniquement les réponses sélectionnées
dans =BRnot=, avec des cases à cocher, quel que soit le mode du passage
principal. Le mode groupé garde les identifiants des élèves et regroupe
les réponses par label sans demander de modifier =label_groups=.
Cela ne nécessite pas d'avoir généré =Bnot= ou =BGnot= auparavant.
Attention : =--overwrite= remplace le contenu du =BRnot= actif,
y compris ses annotations manuscrites, mais pas les autres reprises. Une génération incomplète
conserve l'ancien =BRnot=. Sans =--overwrite=, un =BRnot= existant est refusé.
5. Vider les dossiers personnels d'export/import des anciens fichiers,
puis =python -m copienator export Interro --refaire=.
Annoter les PDF sur la tablette, puis placer les PDF retournés dans
=IMPORT_DIR= en conservant leur nom exporté (nom de groupe ou =Copie01.pdf=).
Même sans modification manuscrite, retourner le PDF pour valider ce passage.
6. =python -m copienator import Interro --refaire=
7. Fusionner dans le dossier du passage principal :
+ Groupé : =python -m copienator read-grouped Interro --refaire=
+ Cases : =python -m copienator read-grouped Interro --refaire --annotation-dir Bnot=
+ Simple : =python -m copienator read-grouped Interro --refaire --annotation-dir Anot=
Le lecteur reconnaît les retours par question comme les retours par
copie grâce aux métadonnées et réattribue les cases et notes à chaque
élève. Un groupe manquant laisse intactes les copies qui en dépendent.
Il reconstruit la copie complète, conserve les réponses non
sélectionnées et leurs scores, et remplace les réponses sélectionnées
par celles de =BRnot=. Dans la compilation filtrée, les images déjà
enregistrées des questions non sélectionnées sont conservées par
prudence, même si leur score est parfait, pour ne pas perdre de notes.
Les anciennes cases et notes manuscrites des
questions refaites sont remplacées. Les autres copies restent intactes.
En mode simple, une image =Concat_annotated.jpg= ou =.jpeg= importée
doit conserver les dimensions de l'image exportée ; les parties non
sélectionnées sont conservées. Le fichier =refaire_simple_layout.json=
mémorise le découpage de cette image pour les passages suivants.
Les sorties finales (=Concat.jpg=, images par question, =score.json=
et compilation filtrée) sont mises à jour dans =BGnot=, =Bnot= ou
=Anot= ; les PDF et références du passage principal restent ceux de
ce passage. Pour une nouvelle retouche, reprendre ce flux =--refaire=,
sans relire ensuite les anciennes annotations avec le lecteur normal.
Relancer ensuite les étapes habituelles de calcul des notes et de diffusion.
=refaire.json=, =BRnot= et le dossier du passage principal sont
obligatoires (code de sortie 3 s'ils manquent). Une copie dont les
fichiers de retour sont incomplets est laissée intacte (code 4).
Ne pas ajouter =--update-score= sauf pour imposer volontairement les
anciens scores, y compris ceux des questions refaites.
-125
View File
@@ -1,125 +0,0 @@
import argparse
import math
import shutil
import sys
from pathlib import Path
import pandas as pd
from PIL import Image, ImageDraw, ImageFont
from config import FINAL_SCORE_FONT_PATH, FINAL_SCORE_ODS_PATH, FINAL_SCORE_OUTPUT_DIR
# Configuration constants
ODS_PATH = Path(FINAL_SCORE_ODS_PATH).expanduser()
OUTPUT_DIR = Path(FINAL_SCORE_OUTPUT_DIR).expanduser()
def score_font(size):
candidates = [FINAL_SCORE_FONT_PATH, "DejaVuSans-Bold.ttf", "arial.ttf"]
for candidate in candidates:
if not candidate:
continue
try:
return ImageFont.truetype(str(candidate), size)
except OSError:
continue
return ImageFont.load_default()
def get_rounded_score(score):
"""Round score to one decimal place below (floor)."""
try:
val = float(score)
return math.floor(val * 10) / 10
except (ValueError, TypeError):
return None
def process_images(base_dir, output_dir):
# 1. Load Data
try:
# header=None assumes the file starts directly with data.
# If row 0 is a header, change to header=0
df = pd.read_excel(ODS_PATH, engine="odf", header=None)
# Create a lookup dictionary: {Name: Score}
score_db = dict(zip(df[0], df[1]))
except Exception as e:
print(f"CRITICAL ERROR: Could not read ODS file.\n{e}")
sys.exit(1)
# 2. Prepare Output Directory
output_dir.mkdir(parents=True, exist_ok=True)
# 3. Iterate Files
# Structure: Dir/A Rendre/{name}/{name}.jpg
search_path = base_dir / "A Rendre"
if not search_path.exists():
print(f"Error: Directory '{search_path}' not found.")
sys.exit(1)
for img_path in sorted(search_path.glob("*/*.jpg")):
student_name = img_path.stem # Filename without extension
# 4. Find Score
if student_name not in score_db:
print(f"Error: Student '{student_name}' not found in ODS file.")
continue
raw_score = score_db[student_name]
score = get_rounded_score(raw_score)
if score is None:
print(f"Error: Invalid score '{raw_score}' for '{student_name}'.")
continue
# 5. Process Image
try:
with Image.open(img_path) as img:
img = img.convert("RGB")
draw = ImageDraw.Draw(img)
width, height = img.size
# Dynamic font size (15% of image height)
font_size = int(width * 0.08)
font = score_font(font_size)
text = str(score)
# Calculate text size and position (Top Right)
bbox = draw.textbbox((0, 0), text, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
# 30px padding
x = width - text_w - 30
y = 30
# Draw Text (Red)
draw.text((x, y), text, fill=(255, 0, 0), font=font)
# Save
save_path = output_dir / f"{student_name}.jpg"
img.save(save_path)
print(f"Processed: {student_name} -> {score}")
except Exception as e:
print(f"Error processing image for '{student_name}': {e}")
for pdf_path in sorted(search_path.glob("*/*.pdf")):
student_name = pdf_path.stem # Filename without extension
save_path = output_dir / f"{student_name}.pdf"
shutil.copy(str(pdf_path), str(save_path))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Stamp scores on exam copies.")
parser.add_argument("dir", type=Path, help="Root directory containing 'A Rendre' folder")
args = parser.parse_args()
base_dir = args.dir.expanduser().resolve()
output_dir = OUTPUT_DIR / base_dir.name
output_dir.mkdir(parents=True, exist_ok=True)
process_images(base_dir, output_dir)
-88
View File
@@ -1,88 +0,0 @@
import os
import sys
import argparse
from google import genai
if "GEMINI_API_KEY" not in os.environ:
sys.exit("Error: GEMINI_API_KEY environment variable not set.")
client = genai.Client()
def list_jobs():
print("Fetching recent batch jobs...\n")
try:
batch_jobs = client.batches.list()
jobs_found = False
for job in batch_jobs:
jobs_found = True
state = job.state.name if hasattr(job.state, 'name') else job.state
print("-" * 60)
print(f"Job Name: {job.name}")
if hasattr(job, 'display_name') and job.display_name:
print(f"Display Name: {job.display_name}")
print(f"State: {state}")
if state == 'JOB_STATE_FAILED' and hasattr(job, 'error'):
print(f"Error: {job.error}")
if state == 'JOB_STATE_SUCCEEDED' and hasattr(job, 'dest') and job.dest:
if hasattr(job.dest, 'file_name') and job.dest.file_name:
print(f"Output File: {job.dest.file_name}")
if not jobs_found:
print("No batch jobs found.")
else:
print("-" * 60)
print("\nTo download a completed job, run:")
print("python batch_status.py --download batches/<YOUR_BATCH_ID>")
except Exception as e:
sys.exit(f"An error occurred while listing jobs: {e}")
def download_job(job_name):
print(f"Checking status for {job_name}...\n")
try:
job = client.batches.get(name=job_name)
state = job.state.name if hasattr(job.state, 'name') else job.state
print(f"State: {state}")
if state != 'JOB_STATE_SUCCEEDED':
print("Job is not ready yet or has failed.")
if state == 'JOB_STATE_FAILED' and hasattr(job, 'error'):
print(f"Error: {job.error}")
return
if hasattr(job, 'dest') and job.dest and hasattr(job.dest, 'file_name') and job.dest.file_name:
result_file_name = job.dest.file_name
print(f"Downloading results from {result_file_name}...")
file_content_bytes = client.files.download(file=result_file_name)
output_path = f"results_{job_name.replace('/', '_')}.jsonl"
with open(output_path, "wb") as f:
f.write(file_content_bytes)
print(f"Success! Saved to {output_path}")
print(f"You can now feed this to your correction script using: --deal-with-batched {output_path}")
else:
print("Job succeeded but no output file was found.")
except Exception as e:
sys.exit(f"An error occurred while fetching the job: {e}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Manage Gemini Batch Jobs")
parser.add_argument("--download", type=str, metavar="JOB_NAME",
help="Download the results for a specific batch job (e.g. batches/123456)")
args = parser.parse_args()
if args.download:
download_job(args.download)
else:
list_jobs()
+2
View File
@@ -14,6 +14,7 @@ from .cli import (
from .json_io import (
JsonLockTimeout,
atomic_update_json,
atomic_write_bytes,
atomic_write_json,
atomic_write_text,
read_json,
@@ -32,6 +33,7 @@ __all__ = [
"WorkspaceNotFoundError",
"WorkspaceValidationError",
"atomic_update_json",
"atomic_write_bytes",
"atomic_write_json",
"atomic_write_text",
"evaluation_parser",
+5
View File
@@ -0,0 +1,5 @@
from .dispatcher import main
if __name__ == "__main__":
raise SystemExit(main())
+5 -2
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Any
from .json_io import read_json
from .feedback_boxes import valid_feedback_box
Log = Callable[[str], None]
@@ -25,8 +26,10 @@ def apply_checkbox_actions(
continue
result = labels_data[label]["result"]
feedbacks = result.get("feedback", [])
global_feedbacks = [item for item in feedbacks if not item.get("box_2d")]
local_feedbacks = [item for item in feedbacks if item.get("box_2d")]
# Match the renderer's fallback for invalid boxes so checkbox indices
# still address the right comment when returned annotations are read.
global_feedbacks = [item for item in feedbacks if not valid_feedback_box(item.get("box_2d"))]
local_feedbacks = [item for item in feedbacks if valid_feedback_box(item.get("box_2d"))]
local_feedbacks.sort(key=lambda item: item["box_2d"][0])
for action in label_actions:
+14 -4
View File
@@ -8,6 +8,7 @@ from typing import Any
from PIL import Image
from .json_io import read_json
from .feedback_boxes import valid_feedback_box
from .workspace import EvaluationWorkspace
AnnotationData = dict[str, dict[str, dict[str, Any]]]
@@ -36,7 +37,12 @@ def _coordinate_index(
if not workspace.groups_dir.is_dir():
return index, [f"Group directory not found: {workspace.groups_dir}"]
for metadata_path in sorted(workspace.groups_dir.glob("*/Group_*.json")):
# A redo appends a new numbered group; its coordinates supersede the old group.
for metadata_path in sorted(
workspace.groups_dir.glob("*/Group_*.json"),
key=lambda path: int(path.stem.removeprefix("Group_")),
reverse=True,
):
image_path = metadata_path.with_suffix(".jpg")
try:
entries = read_json(metadata_path)
@@ -64,7 +70,7 @@ def _scaled_result(result: dict[str, Any], coordinates: GroupCoordinates | None)
return scaled
for feedback in scaled.get("feedback", []):
box = feedback.get("box_2d")
if not box or len(box) != 4:
if not box or not valid_feedback_box(box):
continue
box[0] = int(box[0] * coordinates.height) // 1000
box[2] = int(box[2] * coordinates.height) // 1000
@@ -160,12 +166,16 @@ def load_annotation_data(
warnings.append(f"Ignoring malformed correction batch for {label!r}")
continue
for item in raw_batch:
if not isinstance(item, dict) or not isinstance(item.get("result"), dict):
if not isinstance(item, dict) or not isinstance(
item.get("result"), dict
):
warnings.append(f"Ignoring malformed correction item for {label!r}")
continue
student_id = str(item.get("id", ""))
if not student_id:
warnings.append(f"Ignoring correction item without an id for {label!r}")
warnings.append(
f"Ignoring correction item without an id for {label!r}"
)
continue
result = item["result"]
suffix = str(result.get("suffix", ""))
+22
View File
@@ -0,0 +1,22 @@
from collections.abc import Collection, Mapping
from typing import Any
def build_answer_info(
scores: Mapping[str, str],
present_labels: Collection[str],
rendered_labels: Collection[str],
touched: Mapping[str, bool] | None = None,
) -> dict[str, dict[str, Any]]:
"""Describe every question using the answers actually compiled for a copy."""
present = set(present_labels)
rendered = set(rendered_labels)
return {
label: {
"present": label in present,
"not_empty": label in rendered,
"touched": (touched or {}).get(label, False),
"score": score,
}
for label, score in scores.items()
}
+5
View File
@@ -0,0 +1,5 @@
"""Executable Copienator commands.
Command modules expose a ``main(argv=None)`` entry point and may also expose
their processing functions for reuse and tests.
"""
+167
View File
@@ -0,0 +1,167 @@
import argparse
import math
import shutil
import sys
from pathlib import Path
import pandas as pd
from PIL import Image, ImageDraw, ImageFont
from copienator.configuration import (
FINAL_SCORE_FONT_PATH,
FINAL_SCORE_HISTOGRAM_PATH,
FINAL_SCORE_ODS_PATH,
FINAL_SCORE_OUTPUT_DIR,
)
# Configuration constants
ODS_PATH = Path(FINAL_SCORE_ODS_PATH).expanduser()
OUTPUT_DIR = Path(FINAL_SCORE_OUTPUT_DIR).expanduser()
HISTOGRAM_PATH = Path(FINAL_SCORE_HISTOGRAM_PATH).expanduser()
def score_font(size):
candidates = [FINAL_SCORE_FONT_PATH, "DejaVuSans-Bold.ttf", "arial.ttf"]
for candidate in candidates:
if not candidate:
continue
try:
return ImageFont.truetype(str(candidate), size)
except OSError:
continue
return ImageFont.load_default()
def get_rounded_score(score):
"""Round score to one decimal place below (floor)."""
try:
val = float(score)
return math.floor(val * 10) / 10
except (ValueError, TypeError):
return None
def copy_return_artifacts(source_dir: Path, destination_dir: Path) -> None:
"""Copy the metadata and optional individual answers for one student."""
for filename in ("score.json", "info.json"):
source = source_dir / filename
if source.is_file():
shutil.copy2(source, destination_dir / filename)
else:
print(f"Warning: Missing '{source}'.")
answers_source = source_dir / "answers"
answers_destination = destination_dir / "answers"
if answers_destination.is_symlink():
answers_destination.unlink()
elif answers_destination.is_dir():
shutil.rmtree(answers_destination)
if answers_source.is_dir():
shutil.copytree(answers_source, answers_destination)
def copy_histogram(output_dir: Path) -> None:
"""Copy the score histogram beside the per-student output folders."""
if not HISTOGRAM_PATH.is_file():
print(f"Warning: Missing histogram '{HISTOGRAM_PATH}'.")
return
destination = output_dir / "histogramme.pdf"
shutil.copy2(HISTOGRAM_PATH, destination)
print(f"Copied histogram: {destination}")
def process_images(base_dir, output_dir):
# 1. Load Data
try:
# header=None assumes the file starts directly with data.
# If row 0 is a header, change to header=0
df = pd.read_excel(ODS_PATH, engine="odf", header=None)
# Create a lookup dictionary: {Name: Score}
score_db = dict(zip(df[0], df[1]))
except Exception as e:
print(f"CRITICAL ERROR: Could not read ODS file.\n{e}")
sys.exit(1)
# 2. Prepare Output Directory
output_dir.mkdir(parents=True, exist_ok=True)
# 3. Iterate Files
# Structure: Dir/A Rendre/{name}/{name}.jpg
search_path = base_dir / "A Rendre"
if not search_path.exists():
print(f"Error: Directory '{search_path}' not found.")
sys.exit(1)
for student_source in sorted(path for path in search_path.iterdir() if path.is_dir()):
image_paths = sorted(student_source.glob("*.jpg"))
pdf_paths = sorted(student_source.glob("*.pdf"))
media_paths = image_paths or pdf_paths
if not media_paths:
print(f"Error: No JPG or PDF found in '{student_source}'.")
continue
student_name = media_paths[0].stem
student_output = output_dir / student_name
student_output.mkdir(parents=True, exist_ok=True)
# Remove files produced by the former flat output layout when migrating
# an existing export directory.
for suffix in (".jpg", ".pdf"):
legacy_output = output_dir / f"{student_name}{suffix}"
if legacy_output.is_file() or legacy_output.is_symlink():
legacy_output.unlink()
copy_return_artifacts(student_source, student_output)
# 4. Find Score
if student_name not in score_db:
print(f"Error: Student '{student_name}' not found in ODS file.")
else:
raw_score = score_db[student_name]
score = get_rounded_score(raw_score)
if score is None:
print(f"Error: Invalid score '{raw_score}' for '{student_name}'.")
else:
# 5. Process Images
for img_path in image_paths:
try:
with Image.open(img_path) as img:
img = img.convert("RGB")
draw = ImageDraw.Draw(img)
width, _height = img.size
font_size = int(width * 0.08)
font = score_font(font_size)
text = str(score)
bbox = draw.textbbox((0, 0), text, font=font)
text_w = bbox[2] - bbox[0]
# 30px padding, top right.
x = width - text_w - 30
y = 30
draw.text((x, y), text, fill=(255, 0, 0), font=font)
img.save(student_output / img_path.name)
print(f"Processed: {student_name} -> {score}")
except Exception as e:
print(f"Error processing image for '{student_name}': {e}")
for pdf_path in pdf_paths:
shutil.copy2(pdf_path, student_output / pdf_path.name)
copy_histogram(output_dir)
def main(argv=None):
parser = argparse.ArgumentParser(description="Stamp scores on exam copies.")
parser.add_argument("dir", type=Path, help="Root directory containing 'A Rendre' folder")
args = parser.parse_args(argv)
base_dir = args.dir.expanduser().resolve()
output_dir = OUTPUT_DIR / base_dir.name
output_dir.mkdir(parents=True, exist_ok=True)
process_images(base_dir, output_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -19,8 +19,8 @@ from matplotlib import pyplot as plt
from pdf2image import convert_from_path
from PIL import Image, ImageDraw, ImageFont
import utils
from config import LATEX_ANOT_AFTER, LATEX_ANOT_BEFORE
from copienator import utils
from copienator.configuration import LATEX_ANOT_AFTER, LATEX_ANOT_BEFORE
from copienator import (
EvaluationWorkspace,
ExitCode,
@@ -30,8 +30,10 @@ from copienator import (
workspace_from_args,
)
from copienator.annotation_data import load_annotation_data
from copienator.answer_info import build_answer_info
from copienator.filesystem import staged_directory
from utils import natural_key
from copienator.feedback_boxes import valid_feedback_box
from copienator.utils import natural_key
MARGIN_LEFT = 300
ANNOT_WIDTH = 600
@@ -325,6 +327,16 @@ def compose_label_image(base_img, label, result, hmin,
# Filter deleted items (used by reading_annotations.py)
feedbacks = [f for f in feedbacks if "to_delete" not in f]
# Never guess where an invalid rectangle belongs, or lose its comment.
# Use a copy so rendering cannot mutate saved correction data.
normalized = []
for feedback in feedbacks:
box = feedback.get("box_2d")
if box is not None and not valid_feedback_box(box):
print(f"Warning: Copie{with_id or ''} {label}: invalid feedback box {box!r}; displaying the comment without a rectangle.")
feedback = {**feedback, "box_2d": None}
normalized.append(feedback)
feedbacks = normalized
global_fb = [f for f in feedbacks if not f.get('box_2d')]
local_fb = [f for f in feedbacks if f.get('box_2d')]
@@ -446,6 +458,7 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
with staged_directory(output_dir) as staging:
d_notes = dict.fromkeys(all_labels, "")
label_images = []
answer_labels = []
sorted_labels = sorted(labels_data.items(), key=lambda item: natural_key(item[0]))
for label, content in sorted_labels:
@@ -475,8 +488,12 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
final_img.save(staging / f"{label}.jpg")
if result.get('error', "") != "empty-answer":
label_images.append(final_img)
answer_labels.append(label)
atomic_write_json(staging / "score.json", d_notes)
atomic_write_json(staging / "info.json", build_answer_info(
d_notes, labels_data, answer_labels
))
if label_images:
max_w = max(image.width for image in label_images)
total_h = sum(image.height for image in label_images)
@@ -10,9 +10,6 @@ from typing import Any
from PIL import Image, ImageDraw
from reportlab.pdfgen import canvas
import annotating
import annotating_with_checks
import utils
from copienator import (
CliError,
EvaluationWorkspace,
@@ -22,11 +19,13 @@ from copienator import (
evaluation_parser,
execute,
read_json,
utils,
workspace_from_args,
)
from copienator.annotation_data import load_annotation_data
from copienator.commands import annotating, annotating_with_checks
from copienator.filesystem import staged_directory
from utils import natural_key
from copienator.utils import natural_key
MAX_HEIGHT_PX = 25000
@@ -125,11 +124,82 @@ def _initial_label_groups(labels: list[str]) -> str:
return "".join(",".join(items) + "\n" for items in groups.values())
def _load_label_groups(workspace: EvaluationWorkspace, labels: list[str]) -> list[list[str]]:
label_groups = workspace.root / "label_groups"
def _gemini_label_groups(
workspace: EvaluationWorkspace, labels: list[str]
) -> list[list[str]] | None:
source = workspace.gemini_exam_items_file
if not source.is_file():
return None
groups: list[list[str]] = []
current: list[str] = []
try:
source_lines = source.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError) as exc:
print(f"Warning: could not read Gemini question groups from {source}: {exc}")
return None
for raw_line in source_lines:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line == "---":
if current:
groups.append(current)
current = []
continue
if " ### " not in line:
continue
label = line.split(" ### ", 1)[0].strip()
if label and label != "CONTEXT":
current.append(label)
if current:
groups.append(current)
flattened = [label for group in groups for label in group]
known = set(labels)
if (
not flattened
or len(flattened) != len(set(flattened))
or set(flattened) != known
):
missing = sorted(known.difference(flattened), key=natural_key)
unknown = sorted(set(flattened).difference(known), key=natural_key)
details = []
if missing:
details.append("missing: " + ", ".join(missing))
if unknown:
details.append("unknown: " + ", ".join(unknown))
if len(flattened) != len(set(flattened)):
details.append("duplicate labels")
print(
f"Warning: ignoring incompatible Gemini question groups in {source}"
+ (f" ({'; '.join(details)})" if details else "")
)
return None
return groups
def _serialize_label_groups(groups: list[list[str]]) -> str:
return "".join(",".join(group) + "\n" for group in groups)
def _load_label_groups(
workspace: EvaluationWorkspace, labels: list[str]
) -> list[list[str]]:
label_groups = workspace.label_groups_file
if not label_groups.exists():
atomic_write_text(label_groups, _initial_label_groups(labels))
print(f"Created {label_groups}; review the groups before continuing.")
gemini_groups = _gemini_label_groups(workspace, labels)
if gemini_groups is not None:
initial_content = _serialize_label_groups(gemini_groups)
source_description = "the groups selected in gemini_for_enonce.py"
else:
initial_content = _initial_label_groups(labels)
source_description = "the label-prefix fallback"
atomic_write_text(label_groups, initial_content)
print(
f"Created {label_groups} from {source_description}; "
"review the groups before continuing."
)
utils.edit_file_and_enter(label_groups)
known_labels = set(labels)
groups: list[list[str]] = []
@@ -259,20 +329,38 @@ def _generate_groups(
return generated, problems
def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
def run(
workspace: EvaluationWorkspace, *, overwrite: bool = False, refaire: bool = False
) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label")
labels = utils.read_all_labels(workspace.root)
groups = _load_label_groups(workspace, labels)
loaded = load_annotation_data(workspace)
refaire_list = annotating_with_checks._load_refaire(workspace) if refaire else None
loaded = load_annotation_data(workspace, refaire_list=refaire_list)
groups = (
[
[label]
for label in sorted(
{label for answers in loaded.data.values() for label in answers},
key=natural_key,
)
]
if refaire
else _load_label_groups(workspace, labels)
)
for warning in loaded.warnings:
print(f"Warning: {warning}")
if not loaded.data:
print("Warning: no annotation data was found.")
return ExitCode.PARTIAL
output_root = workspace.annotation_dir("grouped")
if overwrite:
output_root = workspace.annotation_dir("refaire" if refaire else "grouped")
if refaire and output_root.exists() and not overwrite:
raise CliError(
"BRnot already exists; use --overwrite to replace the previous redo."
)
if overwrite or refaire:
class IncompleteGroupedOutput(Exception):
pass
@@ -287,7 +375,9 @@ def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
if generated == 0 or problems or loaded.warnings:
raise IncompleteGroupedOutput
except IncompleteGroupedOutput:
print("Warning: grouped overwrite was incomplete; previous BGnot was preserved.")
print(
f"Warning: grouped overwrite was incomplete; previous {output_root.name} was preserved."
)
return ExitCode.PARTIAL
else:
output_root.mkdir(parents=True, exist_ok=True)
@@ -306,7 +396,14 @@ def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Generate annotated PDFs grouped by labels.")
parser.add_argument("--overwrite", action="store_true", help="Replace BGnot safely")
parser.add_argument(
"--overwrite", action="store_true", help="Replace annotation outputs safely"
)
parser.add_argument(
"--refaire",
action="store_true",
help="Group only the answers in refaire.json, writing to BRnot",
)
return parser
@@ -315,7 +412,9 @@ def main(argv: Sequence[str] | None = None) -> int:
return execute(
parser,
argv,
lambda args: run(workspace_from_args(args), overwrite=args.overwrite),
lambda args: run(
workspace_from_args(args), overwrite=args.overwrite, refaire=args.refaire
),
)
@@ -14,8 +14,6 @@ matplotlib.use("Agg")
from PIL import Image, ImageFont
from reportlab.pdfgen import canvas
import annotating
import utils
from copienator import (
CliError,
EvaluationWorkspace,
@@ -24,11 +22,13 @@ from copienator import (
execute,
read_json,
target_parser,
utils,
workspace_from_target,
)
from copienator.annotation_data import load_annotation_data
from copienator.commands import annotating
from copienator.filesystem import staged_directory
from utils import natural_key
from copienator.utils import natural_key
BOX_SIZE = 30
SCORE_BOX_SIZE = 40
@@ -46,7 +46,9 @@ except OSError:
def draw_checkbox(draw, x, y, size=BOX_SIZE, label=None, fill="white"):
if label:
draw.text((x - BOX_SIZE - 5, y + 2), str(label), fill="black", font=CHECKBOX_FONT)
draw.text(
(x - BOX_SIZE - 5, y + 2), str(label), fill="black", font=CHECKBOX_FONT
)
draw.rectangle([x, y, x + size, y + size], fill=fill, outline="black", width=2)
return [x, y, x + size, y + size]
@@ -150,8 +152,13 @@ def _render_student(
*,
overwrite: bool,
output_mode: str,
output_root: Path | None = None,
) -> str:
output_dir = workspace.annotation_dir(output_mode) / f"Copie{student_id}"
output_dir = (
output_root
if output_root is not None
else workspace.annotation_dir(output_mode)
) / f"Copie{student_id}"
if _output_complete(output_dir) and not overwrite:
print(f"Skipping {student_id}: output is complete.")
return "skipped"
@@ -178,6 +185,7 @@ def _render_student(
draw_callback=checkbox_renderer.callback,
)
if final_image is None:
problems = True
continue
label_images.append(final_image)
checkbox_groups.append(checkbox_renderer.checkboxes)
@@ -280,6 +288,39 @@ def run(
output_mode = "refaire" if refaire else "checks"
tasks = sorted(loaded.data.items(), key=lambda item: natural_key(item[0]))
if refaire:
output_root = workspace.annotation_dir("refaire")
if output_root.exists() and not overwrite:
raise CliError(
"BRnot already exists; use --overwrite to replace the previous redo."
)
class IncompleteRedo(Exception):
pass
try:
with staged_directory(output_root) as staging:
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(
_render_student,
workspace,
student_id,
labels,
overwrite=True,
output_mode="refaire",
output_root=staging,
)
for student_id, labels in tasks
]
statuses = [future.result() for future in futures]
if loaded.warnings or any(status != "success" for status in statuses):
raise IncompleteRedo
except IncompleteRedo:
print("Warning: incomplete redo generation; previous BRnot was preserved.")
return ExitCode.PARTIAL
return ExitCode.SUCCESS
statuses: list[str] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
futures = [
@@ -302,7 +343,9 @@ def run(
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Generate annotated PDFs with checkboxes.")
parser.add_argument("--overwrite", action="store_true", help="Replace existing outputs")
parser.add_argument(
"--overwrite", action="store_true", help="Replace existing outputs"
)
parser.add_argument(
"--refaire",
action="store_true",
+138
View File
@@ -0,0 +1,138 @@
from __future__ import annotations
import argparse
from collections.abc import Sequence
from pathlib import Path
from google import genai
from copienator import configuration as config
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_bytes,
execute,
read_json,
standard_parser,
workspace_from_args,
)
def _client():
if not config.API_KEY:
raise CliError("GEMINI_API_KEY is not configured")
return genai.Client(api_key=config.API_KEY)
def list_jobs(*, client=None) -> ExitCode:
client = client or _client()
print("Fetching recent batch jobs...")
jobs = list(client.batches.list())
for job in jobs:
state = job.state.name if hasattr(job.state, "name") else job.state
print(f"{job.name}: {state}")
if getattr(job, "display_name", None):
print(f" Display name: {job.display_name}")
if state == "JOB_STATE_FAILED" and getattr(job, "error", None):
print(f" Error: {job.error}")
destination = getattr(job, "dest", None)
if state == "JOB_STATE_SUCCEEDED" and getattr(
destination, "file_name", None
):
print(f" Output file: {destination.file_name}")
if not jobs:
print("No batch jobs found.")
return ExitCode.SUCCESS
def check_evaluation_jobs(workspace: EvaluationWorkspace, *, client=None) -> ExitCode:
"""Only report readiness after checking every job recorded for this evaluation."""
if not workspace.batch_jobs_file.is_file():
print("Impossible de vérifier les batchs : batch_jobs.json est absent.")
return ExitCode.PARTIAL
manifest = read_json(workspace.batch_jobs_file)
jobs = manifest.get("jobs") if isinstance(manifest, dict) else None
if not isinstance(jobs, dict):
raise CliError(f"Invalid batch manifest: {workspace.batch_jobs_file}")
if not jobs:
print("Aucun job enregistré pour cette évaluation.")
return ExitCode.PARTIAL
if any(not isinstance(entry, dict) or not isinstance(entry.get("name"), str)
or not entry["name"].strip() for entry in jobs.values()):
raise CliError(f"Invalid batch job in {workspace.batch_jobs_file}")
client = client or _client()
ready = True
for tier, entry in jobs.items():
job = client.batches.get(name=entry["name"])
state = job.state.name if hasattr(job.state, "name") else job.state
print(f"{tier}{entry['name']}: {state}")
if state != "JOB_STATE_SUCCEEDED":
ready = False
if getattr(job, "error", None):
print(f" Erreur : {job.error}")
elif not getattr(getattr(job, "dest", None), "file_name", None):
ready = False
print(" Le fichier de résultats nest pas encore disponible.")
if ready:
print("Tous les batchs de l’évaluation ont réussi. Les résultats sont prêts à récupérer.")
return ExitCode.SUCCESS
print("Les résultats ne sont pas tous prêts. Consultez à nouveau cette étape plus tard.")
return ExitCode.PARTIAL
def download_job(
job_name: str,
*,
output: Path | None = None,
client=None,
) -> ExitCode:
client = client or _client()
job = client.batches.get(name=job_name)
state = job.state.name if hasattr(job.state, "name") else job.state
print(f"State: {state}")
if state != "JOB_STATE_SUCCEEDED":
if state == "JOB_STATE_FAILED" and getattr(job, "error", None):
print(f"Error: {job.error}")
return ExitCode.PARTIAL
destination = getattr(job, "dest", None)
file_name = getattr(destination, "file_name", None)
if not file_name:
print("Job succeeded but no output file was found.")
return ExitCode.PARTIAL
payload = client.files.download(file=file_name)
output_path = output or Path(f"results_{job_name.replace('/', '_')}.jsonl")
atomic_write_bytes(output_path, payload)
print(f"Saved batch results to {output_path}")
return ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = standard_parser("List or download Gemini correction batch jobs")
parser.add_argument("--download", metavar="JOB_NAME")
parser.add_argument("--output", type=Path, help="Downloaded JSONL destination")
parser.add_argument("--evaluation", type=Path,
help="Check readiness of jobs recorded in this evaluation's batch_jobs.json")
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
if args.evaluation is not None:
if args.download or args.output is not None:
raise CliError("--evaluation cannot be combined with --download or --output",
ExitCode.INVALID_ARGUMENTS)
return check_evaluation_jobs(workspace_from_args(args))
if args.output is not None and not args.download:
raise CliError("--output requires --download", ExitCode.INVALID_ARGUMENTS)
if args.download:
return download_job(args.download, output=args.output)
return list_jobs()
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+333
View File
@@ -0,0 +1,333 @@
from __future__ import annotations
import argparse
import os
import shutil
import uuid
from collections import Counter
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from pathlib import Path
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
configuration,
evaluation_parser,
execute,
workspace_from_args,
)
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"}
STATEMENT_ROOT_FILES = {"enonce.tex", "correction.tex", "labels", "label_groups"}
STATEMENT_TEXT_DIRECTORIES = {"Text", "Sol", "Persp", "Cache", "Tmp"}
@dataclass(frozen=True, slots=True)
class CleanupPlan:
kept_files: tuple[Path, ...]
deleted_files: tuple[Path, ...]
deleted_directories: tuple[Path, ...]
bytes_to_delete: int
def _workspace_entries(root: Path) -> tuple[list[Path], list[Path]]:
"""List files/symlinks and real directories without following symlinks."""
files: list[Path] = []
directories: list[Path] = []
for current, directory_names, file_names in os.walk(
root, topdown=True, followlinks=False
):
current_path = Path(current)
traversable: list[str] = []
for name in directory_names:
path = current_path / name
if path.is_symlink():
files.append(path)
else:
directories.append(path)
traversable.append(name)
directory_names[:] = traversable
files.extend(current_path / name for name in file_names)
return files, directories
def _return_artifacts(workspace: EvaluationWorkspace) -> list[Path]:
return_dir = workspace.return_dir
if not return_dir.is_dir() or return_dir.is_symlink():
raise CliError(
f"Return directory not found or invalid: {return_dir}",
ExitCode.INVALID_WORKSPACE,
)
student_directories = sorted(
(
path
for path in return_dir.iterdir()
if path.is_dir() and not path.is_symlink()
),
key=lambda path: path.name.casefold(),
)
if not student_directories:
raise CliError(
f"No student directories found in {return_dir}",
ExitCode.INVALID_WORKSPACE,
)
artifacts: list[Path] = []
incomplete: list[str] = []
for directory in student_directories:
files = [path for path in directory.rglob("*") if path.is_file()]
images = [
path for path in files if path.suffix.casefold() in IMAGE_SUFFIXES
]
scores = [path for path in files if path.name.casefold() == "score.json"]
pdfs = [path for path in files if path.suffix.casefold() == ".pdf"]
if (configuration.RETURN_JPEG_ENABLED and not images) or not scores:
missing = []
if configuration.RETURN_JPEG_ENABLED and not images:
missing.append("image")
if not scores:
missing.append("score.json")
incomplete.append(f"{directory.name} ({', '.join(missing)})")
artifacts.extend(images)
artifacts.extend(scores)
artifacts.extend(pdfs)
artifacts.extend(path for path in files if path.name.casefold() == "info.json")
if incomplete:
details = "\n".join(f" - {item}" for item in incomplete)
raise CliError(
"A Rendre is incomplete; cleanup was refused:\n" + details,
ExitCode.INVALID_WORKSPACE,
)
return artifacts
def _is_statement_text_file(workspace: EvaluationWorkspace, path: Path) -> bool:
relative = path.relative_to(workspace.root)
if len(relative.parts) == 1:
return relative.name in STATEMENT_ROOT_FILES
top_level = relative.parts[0]
if top_level in STATEMENT_TEXT_DIRECTORIES:
return path.suffix.casefold() in {"", ".json", ".tex", ".txt"}
if top_level in {"Text2", "Sol2"}:
return path.suffix.casefold() == ".tex"
return False
def _is_log_file(workspace: EvaluationWorkspace, path: Path) -> bool:
if path.is_relative_to(workspace.logs_dir):
return True
relative = path.relative_to(workspace.root)
return len(relative.parts) == 1 and (
path.suffix.casefold() == ".log"
or path.name.casefold().endswith("_log")
)
def build_cleanup_plan(workspace: EvaluationWorkspace) -> CleanupPlan:
processed_copies = sorted(
(
path
for path in workspace.copies_dir.glob("*.pdf")
if path.is_file()
),
key=lambda path: path.name.casefold(),
)
if not processed_copies:
raise CliError(
f"No processed PDF copies found in {workspace.copies_dir}",
ExitCode.INVALID_WORKSPACE,
)
if not workspace.correction_file.is_file():
raise CliError(
f"Correction result not found: {workspace.correction_file}",
ExitCode.INVALID_WORKSPACE,
)
files, directories = _workspace_entries(workspace.root)
kept_files = set(processed_copies)
kept_files.add(workspace.correction_file)
kept_files.update(_return_artifacts(workspace))
kept_files.update(
path
for path in files
if _is_statement_text_file(workspace, path)
or _is_log_file(workspace, path)
)
kept_directories = {workspace.root}
for path in kept_files:
kept_directories.update(
parent
for parent in path.parents
if parent == workspace.root or workspace.root in parent.parents
)
deleted_files = tuple(sorted(set(files) - kept_files, key=str))
deleted_directories = tuple(
sorted(
set(directories) - kept_directories,
key=lambda path: (len(path.parts), str(path)),
reverse=True,
)
)
bytes_to_delete = sum(
path.stat(follow_symlinks=False).st_size
for path in deleted_files
if path.exists() or path.is_symlink()
)
return CleanupPlan(
kept_files=tuple(sorted(kept_files, key=str)),
deleted_files=deleted_files,
deleted_directories=deleted_directories,
bytes_to_delete=bytes_to_delete,
)
def _human_size(size: int) -> str:
value = float(size)
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if value < 1024 or unit == "TiB":
return f"{value:.1f} {unit}"
value /= 1024
return f"{size} B"
def _top_level_counts(
workspace: EvaluationWorkspace, paths: Iterable[Path]
) -> Counter[str]:
counts: Counter[str] = Counter()
for path in paths:
relative = path.relative_to(workspace.root)
counts[relative.parts[0]] += 1
return counts
def print_plan(workspace: EvaluationWorkspace, plan: CleanupPlan, *, verbose: bool) -> None:
processed_count = sum(path.parent == workspace.copies_dir for path in plan.kept_files)
return_count = sum(
path.is_relative_to(workspace.return_dir) for path in plan.kept_files
)
statement_count = sum(
_is_statement_text_file(workspace, path) for path in plan.kept_files
)
log_count = sum(_is_log_file(workspace, path) for path in plan.kept_files)
print(f"Evaluation: {workspace.root}")
print("Will keep:")
print(f" - {processed_count} processed PDF copies in Copies")
print(f" - {statement_count} textual statement files")
print(" - correction.json")
print(f" - {log_count} log files")
print(f" - {return_count} image/PDF/score artifacts in A Rendre")
print(
f"Will delete {len(plan.deleted_files)} files and "
f"{len(plan.deleted_directories)} directories "
f"({_human_size(plan.bytes_to_delete)} in file entries)."
)
counts = _top_level_counts(workspace, plan.deleted_files)
if counts:
print("Files removed by top-level location:")
for name, count in sorted(counts.items(), key=lambda item: item[0].casefold()):
print(f" - {name}: {count}")
if verbose:
print("Deletion list:")
for path in plan.deleted_files:
print(f" - {path.relative_to(workspace.root)}")
def _materialize_return_links(workspace: EvaluationWorkspace, paths: Iterable[Path]) -> None:
for path in paths:
if not path.is_relative_to(workspace.return_dir) or not path.is_symlink():
continue
try:
source = path.resolve(strict=True)
except OSError as exc:
raise CliError(f"Broken return link {path}: {exc}") from exc
if not source.is_file():
raise CliError(f"Return link does not target a file: {path} -> {source}")
temporary = path.with_name(f".{path.name}.materialize-{uuid.uuid4().hex}.tmp")
try:
shutil.copy2(source, temporary)
temporary.replace(path)
finally:
temporary.unlink(missing_ok=True)
print(f"Materialized: {path.relative_to(workspace.root)}")
def apply_cleanup(workspace: EvaluationWorkspace, plan: CleanupPlan) -> None:
_materialize_return_links(workspace, plan.kept_files)
for path in plan.deleted_files:
path.unlink(missing_ok=True)
for path in plan.deleted_directories:
try:
path.rmdir()
except FileNotFoundError:
pass
print(
f"Cleanup complete: deleted {len(plan.deleted_files)} files and "
f"{len(plan.deleted_directories)} directories."
)
def run(
workspace: EvaluationWorkspace,
*,
dry_run: bool = False,
assume_yes: bool = False,
verbose: bool = False,
) -> ExitCode:
plan = build_cleanup_plan(workspace)
print_plan(workspace, plan, verbose=verbose)
if dry_run:
print("Dry run: nothing was deleted.")
return ExitCode.SUCCESS
if not assume_yes:
expected = workspace.name
answer = input(
f"This cannot be undone. Type {expected!r} to confirm cleanup: "
).strip()
if answer != expected:
print("Cleanup cancelled; nothing was deleted.")
return ExitCode.SUCCESS
apply_cleanup(workspace, plan)
return ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser(
"Archive an evaluation by deleting regenerable and intermediate files."
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be kept and deleted without changing anything",
)
parser.add_argument(
"--yes",
action="store_true",
help="Skip the interactive evaluation-name confirmation",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
return run(
workspace_from_args(args),
dry_run=args.dry_run,
assume_yes=args.yes,
verbose=args.verbose,
)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
@@ -134,3 +134,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,226 @@
"""Optionally replace split-answer PDFs with large bottom crops."""
from __future__ import annotations
import argparse
import hashlib
import json
import multiprocessing
import shutil
import signal
import tempfile
from collections.abc import Sequence
from pathlib import Path
import cv2
from copienator.cli import CliError, ExitCode, execute, target_parser, workspace_from_target
from copienator.crop_exercise_bottoms import _full_page_height, process_exercise_pdf
from copienator.workspace import EvaluationWorkspace
def selected_files(workspace: EvaluationWorkspace, target: Path) -> list[Path]:
workspace.require_directories("Copies")
resolved = target.resolve()
if resolved not in {workspace.root.resolve(), workspace.copies_dir.resolve()}:
raise CliError(
"La cible doit être l’évaluation ou son dossier Copies.",
ExitCode.INVALID_ARGUMENTS,
)
files = sorted(
workspace.copies_dir.glob("Copie*/*.pdf"),
key=lambda path: (path.parent.name.casefold(), path.name.casefold()),
)
for source in files:
if source.is_symlink():
raise CliError(f"Lien symbolique non pris en charge : {source}")
return files
def _initialize_worker() -> None:
cv2.setNumThreads(1)
signal.signal(signal.SIGINT, signal.SIG_IGN)
def _process_file(job: tuple[Path, Path, float]) -> list[dict]:
source, destination, full_height = job
records = process_exercise_pdf(
source, destination, None, full_height, dpi=200, padding_mm=6
)
changed = sum(record["status"] == "cropped" for record in records)
if changed:
print(
f"{source.parent.name}/{source.name} : {changed}/{len(records)} page(s) rognée(s)",
flush=True,
)
return records
def process_files(
workspace: EvaluationWorkspace,
files: list[Path],
staging: Path,
workers: int,
) -> list[dict]:
heights: dict[str, float] = {}
for copy_name in sorted({source.parent.name for source in files}):
copy_pdf = workspace.copies_dir / f"{copy_name}.pdf"
if not copy_pdf.is_file():
raise CliError(f"PDF source introuvable : {copy_pdf}")
heights[copy_name] = _full_page_height(copy_pdf)
jobs = [
(
source,
staging / source.relative_to(workspace.copies_dir),
heights[source.parent.name],
)
for source in files
]
count = min(workers, len(jobs))
print(
f"Analyse de {len(files)} PDF de réponses avec {count} traitement(s) en parallèle.",
flush=True,
)
if count == 1:
previous_threads = cv2.getNumThreads()
cv2.setNumThreads(1)
try:
batches = [_process_file(job) for job in jobs]
finally:
cv2.setNumThreads(previous_threads)
else:
with multiprocessing.get_context("spawn").Pool(
count, _initialize_worker
) as pool:
batches = list(pool.imap_unordered(_process_file, jobs))
order = {source.as_posix(): index for index, source in enumerate(files)}
return sorted(
(record for batch in batches for record in batch),
key=lambda record: (order[record["file"]], record["page"]),
)
def _publish(
workspace: EvaluationWorkspace,
changed_files: list[Path],
staging: Path,
records: list[dict],
) -> Path:
workspace.runs_dir.mkdir(parents=True, exist_ok=True)
run_dir = Path(
tempfile.mkdtemp(prefix="crop-exercise-bottoms-", dir=workspace.runs_dir)
)
backup_root = run_dir / "Copies"
replaced: list[Path] = []
try:
for source in changed_files:
backup = backup_root / source.relative_to(workspace.copies_dir)
backup.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, backup)
(run_dir / "report.json").write_text(
json.dumps(records, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
for source in changed_files:
prepared = staging / source.relative_to(workspace.copies_dir)
prepared.replace(source)
replaced.append(source)
except BaseException:
for source in replaced:
backup = backup_root / source.relative_to(workspace.copies_dir)
if backup.is_file():
shutil.copy2(backup, source)
shutil.rmtree(run_dir, ignore_errors=True)
raise
return backup_root
def crop_statistics(records: list[dict]) -> tuple[int, float]:
"""Return cropped exercise count and mean removed percentage per exercise."""
totals: dict[str, list[float]] = {}
cropped_files: set[str] = set()
for record in records:
total_height, removed_height = totals.setdefault(record["file"], [0.0, 0.0])
totals[record["file"]] = [
total_height + float(record["height_lines"]),
removed_height + float(record["bottom_removed_lines"]),
]
if record["status"] == "cropped":
cropped_files.add(record["file"])
percentages = [
min(100.0, totals[file_name][1] / totals[file_name][0] * 100)
for file_name in cropped_files
if totals[file_name][0] > 0
]
mean_percentage = sum(percentages) / len(percentages) if percentages else 0.0
return len(cropped_files), mean_percentage
def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> ExitCode:
if workers < 1:
raise CliError(
"Le nombre de traitements parallèles doit être positif.",
ExitCode.INVALID_ARGUMENTS,
)
files = selected_files(workspace, target)
if not files:
raise CliError(
"Aucun PDF de réponse trouvé dans Copies/CopieXX/.",
ExitCode.INVALID_ARGUMENTS,
)
with tempfile.TemporaryDirectory(
prefix=".crop-exercise-bottoms-", dir=workspace.root
) as directory:
staging = Path(directory)
records = process_files(workspace, files, staging, workers)
changed_names = {
record["file"] for record in records if record["status"] == "cropped"
}
changed_files = [source for source in files if source.as_posix() in changed_names]
digests = {record["file"]: record["source_sha256"] for record in records}
for source in files:
if hashlib.sha256(source.read_bytes()).hexdigest() != digests[source.as_posix()]:
raise CliError(
f"{source} a changé pendant lanalyse. Aucun PDF remplacé."
)
cropped_exercises, mean_percentage = crop_statistics(records)
if not changed_files:
print("Terminé : aucun exercice ne remplit les critères de rognage.", flush=True)
print(
"Rognage moyen des exercices modifiés : 0.0 %.", flush=True
)
return ExitCode.SUCCESS
backup = _publish(workspace, changed_files, staging, records)
cropped_pages = sum(record["status"] == "cropped" for record in records)
print(f"Sauvegarde des PDF non rognés : {backup}", flush=True)
print(
f"Terminé : {cropped_exercises} exercice(s) rogné(s), soit "
f"{cropped_pages} page(s) dans {len(changed_files)} PDF remplacé(s).",
flush=True,
)
print(
f"Rognage moyen des exercices modifiés : {mean_percentage:.1f} %.",
flush=True,
)
return ExitCode.SUCCESS
def main(argv: Sequence[str] | None = None) -> int:
parser = target_parser(
"Rogner les grands espaces vides au bas des réponses déjà découpées"
)
parser.add_argument(
"--workers",
type=int,
default=5,
help="Nombre de PDF traités en parallèle (défaut : 5)",
)
def handle(arguments: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(arguments)
return run(workspace, target, workers=arguments.workers)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+156
View File
@@ -0,0 +1,156 @@
"""Optional preprocessing: replace split copies with ink-guided crops."""
from __future__ import annotations
import argparse
import hashlib
import json
import multiprocessing
import signal
import shutil
import tempfile
import cv2
from collections.abc import Sequence
from pathlib import Path
from copienator.cli import CliError, ExitCode, execute, target_parser, workspace_from_target
from copienator.crop_blank_margins import process_pdf
from copienator.filesystem import staged_files
from copienator.workspace import EvaluationWorkspace
def selected_files(workspace: EvaluationWorkspace, target: Path) -> list[Path]:
workspace.require_directories("Copies")
copies = workspace.copies_dir.resolve()
target = target.resolve()
if target.is_file():
if target.parent != copies or target.suffix.lower() != ".pdf":
raise CliError("La cible doit être un PDF du dossier Copies.", ExitCode.INVALID_ARGUMENTS)
files = [target]
elif target in (workspace.root, copies):
files = sorted(copies.glob("*.pdf"), key=lambda path: path.name.casefold())
else:
raise CliError("Cible attendue : évaluation, dossier Copies ou PDF dans Copies.",
ExitCode.INVALID_ARGUMENTS)
for source in files:
if source.is_symlink():
raise CliError(f"Lien symbolique non pris en charge : {source}")
if source.with_suffix(".json").exists():
raise CliError(
f"{source.name} possède déjà des coordonnées de labels. "
"Le rognage doit précéder leur détection. Pour reprendre le prétraitement, "
"mettez de côté le JSON associé, puis régénérez la découpe des marges et les labels. "
"Aucun PDF na été remplacé.")
return files
def _initialize_worker() -> None:
# Five copies should use five cores, not five OpenCV thread pools. MuPDF
# must also stay isolated in separate processes rather than Python threads.
cv2.setNumThreads(1)
signal.signal(signal.SIGINT, signal.SIG_IGN)
def _process_copy(job: tuple[Path, Path]) -> list[dict]:
source, destination = job
def progress(page, total, row):
removed = row["top_removed_mm"]+row["bottom_removed_mm"]
print(f"{source.name} — Page {page}/{total} : {removed:.1f} mm retirés", flush=True)
return process_pdf(source, destination, None, 200, 6, 5, progress=progress)
def process_copies(files: list[Path], staging: Path, workers: int) -> list[dict]:
jobs = [(source, staging/source.name) for source in files]
count = min(workers, len(jobs))
print(f"Rognage de {len(files)} copies avec {count} traitement(s) en parallèle.", flush=True)
if count == 1:
previous_threads = cv2.getNumThreads()
cv2.setNumThreads(1)
try:
batches = [_process_copy(job) for job in jobs]
finally:
cv2.setNumThreads(previous_threads)
else:
# spawn works on Windows and avoids inheriting GUI/native-library state.
# Pool's context terminates and joins workers on errors or cancellation
# before staged_files removes the unpublished PDFs.
with multiprocessing.get_context("spawn").Pool(count, _initialize_worker) as pool:
batches = list(pool.imap_unordered(_process_copy, jobs))
order = {source.name: i for i, source in enumerate(files)}
return sorted((row for batch in batches for row in batch),
key=lambda row: (order[row["file"]], row["page"]))
def crop_statistics(records: list[dict]) -> tuple[int, float, int]:
"""Return cropped page count, their mean removed percentage, and >30% count."""
percentages: list[float] = []
for record in records:
removed_mm = record["top_removed_mm"] + record["bottom_removed_mm"]
if removed_mm <= 0:
continue
x0, y0, x1, y1 = record["original_cropbox"]
original_height_points = (
x1 - x0 if record.get("rotation", 0) % 180 else y1 - y0
)
if original_height_points <= 0:
continue
original_height_mm = original_height_points * 25.4 / 72
percentages.append(min(100.0, removed_mm / original_height_mm * 100))
mean_percentage = sum(percentages) / len(percentages) if percentages else 0.0
over_thirty = sum(percentage > 30 for percentage in percentages)
return len(percentages), mean_percentage, over_thirty
def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> ExitCode:
if workers < 1:
raise CliError("Le nombre de traitements parallèles doit être positif.",
ExitCode.INVALID_ARGUMENTS)
files = selected_files(workspace, target)
if not files:
raise CliError("Aucun PDF trouvé dans Copies.", ExitCode.INVALID_ARGUMENTS)
# Prepare the whole batch before replacing any copy. A detection failure or
# interruption leaves the working PDFs intact; commit errors roll back.
with staged_files(workspace.copies_dir) as staging:
records = process_copies(files, staging, workers)
# Detect edits made while the batch was being analysed, before saving
# backups or publishing results derived from an obsolete source.
digests = {row["file"]: row["source_sha256"] for row in records}
for source in files:
if hashlib.sha256(source.read_bytes()).hexdigest() != digests[source.name]:
raise CliError(f"{source.name} a changé pendant lanalyse. Aucun PDF remplacé.")
workspace.runs_dir.mkdir(parents=True, exist_ok=True)
backup = Path(tempfile.mkdtemp(prefix="crop-margins-", dir=workspace.runs_dir))
originals = backup/"Copies"
originals.mkdir()
for source in files:
shutil.copy2(source, originals/source.name)
(backup/"report.json").write_text(json.dumps(records, ensure_ascii=False, indent=2),
encoding="utf-8")
print(f"Sauvegarde des PDF non rognés : {originals}", flush=True)
cropped, mean_percentage, over_thirty = crop_statistics(records)
print(f"Terminé : {cropped}/{len(records)} pages rognées ; "
f"{len(files)} PDF remplacés dans Copies.", flush=True)
print(
f"Rognage moyen des pages modifiées : {mean_percentage:.1f} % ; "
f"{over_thirty} page(s) rognée(s) de plus de 30 %.",
flush=True,
)
return ExitCode.SUCCESS
def main(argv: Sequence[str] | None = None) -> int:
parser = target_parser("Rogner les zones vides des PDF dans Copies, avant les labels")
parser.add_argument("--workers", type=int, default=5,
help="Nombre de copies traitées en parallèle (défaut : 5)")
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target, workers=args.workers)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+412
View File
@@ -0,0 +1,412 @@
from __future__ import annotations
import argparse
import threading
import tkinter as tk
from tkinter import messagebox
from collections.abc import Sequence
from functools import lru_cache
from pathlib import Path
from queue import Empty, Queue
from threading import Thread
from pdf2image import convert_from_path
from PIL import Image, ImageTk
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_json,
execute,
target_parser,
workspace_from_target,
)
from copienator.filesystem import staged_files
from copienator.copy_errors import clear_copy_error, mark_copy_error, marked_copy_paths
DELIMITER_WIDTH = 5
DELIMITER_COLOR = (0, 0, 0)
OUTPUT_SIZE = (1800, 1000)
CROP_SHIFT_STEP = 50
CROP_WIDTH_STEP = 50
pdf_cache_lock = threading.Lock()
def distribute_pages(total_pages: int, max_per_file: int = 5) -> list[int]:
"""Distribute pages into balanced chunks no larger than max_per_file."""
if total_pages == 0:
return []
number_of_files = (total_pages + max_per_file - 1) // max_per_file
base_count, remainder = divmod(total_pages, number_of_files)
return [
base_count + (1 if index < remainder else 0)
for index in range(number_of_files)
]
def stitch_images(image_list: list[Image.Image]) -> Image.Image | None:
if not image_list:
return None
total_width = sum(image.width for image in image_list)
total_width += (len(image_list) - 1) * DELIMITER_WIDTH
max_height = max(image.height for image in image_list)
combined = Image.new("RGB", (total_width, max_height), color="white")
x_offset = 0
for index, image in enumerate(image_list):
combined.paste(image, (x_offset, 0))
x_offset += image.width
if index < len(image_list) - 1:
delimiter = Image.new(
"RGB", (DELIMITER_WIDTH, max_height), color=DELIMITER_COLOR
)
combined.paste(delimiter, (x_offset, 0))
x_offset += DELIMITER_WIDTH
return combined
@lru_cache(maxsize=3)
def _get_pdf_pages_cached(pdf_path: Path) -> list[Image.Image]:
# Label coordinates use the full, displayed MediaBox, including on PDFs
# previously cropped by crop-margins. Keep this in sync with split-answers.
return convert_from_path(pdf_path, use_cropbox=False)
def get_pdf_pages(pdf_path: Path) -> list[Image.Image]:
"""Thread-safe wrapper around the small PDF conversion cache."""
with pdf_cache_lock:
return _get_pdf_pages_cached(pdf_path)
def process_single_pdf(
pdf_path: Path,
shift_offset: int = 0,
max_per_file: int = 5,
width_offset: int = 0,
) -> tuple[Image.Image, list[Image.Image], dict[str, object]] | None:
"""Convert one PDF into a preview, full-resolution splits and metadata."""
try:
cropped_images = []
for image in get_pdf_pages(pdf_path):
width, height = image.size
if max_per_file == 1:
left, right = 0, width
else:
left = max(0, 100 + shift_offset)
right = min(
width,
width // 3 + 100 + shift_offset + max(0, width_offset),
)
if right > left:
cropped_images.append(image.crop((left, 0, right, height)))
if not cropped_images:
return None
distribution = distribute_pages(len(cropped_images), max_per_file)
split_images = []
current_index = 0
for count in distribution:
stitched = stitch_images(cropped_images[current_index : current_index + count])
if stitched is not None:
split_images.append(stitched)
current_index += count
full_stitch = stitch_images(cropped_images)
if full_stitch is None:
return None
preview = full_stitch.resize(OUTPUT_SIZE, Image.Resampling.BILINEAR)
schema: dict[str, object] = {
"original_filename": pdf_path.name,
"total_pages": len(cropped_images),
"number_of_files": len(split_images),
"columns_per_file": distribution,
}
return preview, split_images, schema
except Exception as exc: # noqa: BLE001 - interactive item failure
print(f"Error processing {pdf_path.name}: {exc}")
return None
def _previous_cutleft_outputs(output_dir: Path, base_name: str) -> set[str]:
if not output_dir.is_dir():
return set()
result = {f"{base_name}_schema.json"}
for path in output_dir.glob(f"{base_name}_*.jpg"):
suffix = path.stem.removeprefix(f"{base_name}_")
if suffix.isdigit():
result.add(path.name)
return result
def save_results(
result: tuple[Image.Image, list[Image.Image], dict[str, object]],
pdf_path: Path,
output_dir: Path,
) -> None:
"""Atomically replace every Cutleft output associated with one copy."""
_, splits, schema = result
base_name = pdf_path.stem
previous = _previous_cutleft_outputs(output_dir, base_name)
with staged_files(output_dir, remove=previous) as staging:
for index, image in enumerate(splits, start=1):
filename = f"{base_name}_{index:02d}.jpg"
image.save(staging / filename, "JPEG", quality=95)
atomic_write_json(staging / f"{base_name}_schema.json", schema)
for index in range(1, len(splits) + 1):
print(f"Saved: {base_name}_{index:02d}.jpg")
print(f"Saved schema: {base_name}_schema.json")
class ImageReviewer:
def __init__(
self,
files: list[Path],
output_dir: Path,
default_max_per_file: int = 5,
) -> None:
self.files = files
self.output_dir = output_dir
self.workspace = EvaluationWorkspace(output_dir.parent)
self.completed = False
self.had_errors = False
self.stop_prefetch = threading.Event()
self.current_result = None
self.index = 0
self.current_shift = 0
self.current_width_offset = 0
self.default_max_per_file = default_max_per_file
self.current_max_per_file = default_max_per_file
self.current_preview: Image.Image | None = None
self.is_processing = False
self.manual_queue: Queue[
tuple[Image.Image, list[Image.Image], dict[str, object]] | None
] = Queue()
self.root = tk.Tk()
self.root.title("PDF Cropper")
self.root.geometry("+100+100")
self.label_img = tk.Label(self.root)
self.label_img.pack()
self.label_info = tk.Label(self.root, text="", font=("Arial", 12, "bold"))
self.label_info.pack(pady=5)
self.root.bind("<Return>", self.on_next)
self.root.bind("s", self.on_skip)
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
self.root.bind("n", lambda _event: self.on_shift(CROP_SHIFT_STEP))
self.root.bind("N", lambda _event: self.on_shift(2 * CROP_SHIFT_STEP))
self.root.bind("t", lambda _event: self.on_shift(-CROP_SHIFT_STEP))
self.root.bind("l", lambda _event: self.on_enlarge(CROP_WIDTH_STEP))
self.root.bind("1", lambda _event: self.on_set_max_pages(1))
Thread(target=self.prefetch_worker, daemon=True).start()
self.load_current_image()
self.root.lift()
self.root.focus_force()
self.root.mainloop()
def on_set_max_pages(self, count: int) -> None:
if self.is_processing:
return
self.current_max_per_file = count
print(f"Setting max pages per file: {count}")
self.trigger_processing(self.files[self.index], self.current_shift)
def prefetch_worker(self) -> None:
processed_index = -1
while not self.stop_prefetch.is_set():
target = self.index + 1
if target < len(self.files) and target != processed_index:
try:
get_pdf_pages(self.files[target])
except Exception:
pass # The foreground review reports and flags conversion errors.
processed_index = target
self.stop_prefetch.wait(0.05)
def load_current_image(self) -> None:
if self.index >= len(self.files):
print("All files processed.")
self.completed = True
self.on_close()
return
self.is_processing = False
self.current_shift = 0
self.current_width_offset = 0
self.current_result = None
self.trigger_processing(self.files[self.index], self.current_shift)
def trigger_processing(self, pdf_path: Path, shift: int) -> None:
self.is_processing = True
self.label_info.configure(
text=f"Processing {pdf_path.name} (Shift {shift})... Please wait.",
fg="red",
)
def worker() -> None:
self.manual_queue.put(
process_single_pdf(
pdf_path,
shift,
self.current_max_per_file,
self.current_width_offset,
)
)
Thread(target=worker, daemon=True).start()
self.check_manual_queue(pdf_path)
def check_manual_queue(self, pdf_path: Path) -> None:
try:
result = self.manual_queue.get_nowait()
self.is_processing = False
if result is None:
print(f"Failed to process {pdf_path.name}, skipping.")
self._mark_error(pdf_path, "Échec de la conversion pour la découpe des marges")
self._advance()
else:
self.handle_processing_result(result, pdf_path)
except Empty:
self.root.after(100, lambda: self.check_manual_queue(pdf_path))
def handle_processing_result(
self,
result: tuple[Image.Image, list[Image.Image], dict[str, object]],
pdf_path: Path,
) -> None:
self.current_preview = result[0]
self.current_result = result
self.update_display(pdf_path.name, result[2])
def update_display(self, filename: str, schema: dict[str, object]) -> None:
if self.current_preview is None:
return
tk_image = ImageTk.PhotoImage(self.current_preview)
self.label_img.configure(image=tk_image)
self.label_img.image = tk_image
self.label_info.configure(
text=(
f"[{self.index + 1}/{len(self.files)}] {filename} | "
f"Shift: {self.current_shift}px | "
f"Extra width: {self.current_width_offset}px\n"
f"Files: {schema['number_of_files']} | "
f"Cols: {schema['columns_per_file']}\n"
"Enter: Save and next | s: flag error and skip | n: +50 | N: +100 | t: -50 | "
"l: widen by 50 | 1: use full pages"
),
fg="black",
)
def on_shift(self, amount: int) -> None:
if self.is_processing:
return
self.current_shift += amount
print(f"Applying shift: {self.current_shift}")
self.trigger_processing(self.files[self.index], self.current_shift)
def on_enlarge(self, amount: int) -> None:
if self.is_processing:
return
self.current_width_offset += amount
print(f"Applying extra width: {self.current_width_offset}")
self.trigger_processing(self.files[self.index], self.current_shift)
def on_next(self, _event: object) -> None:
if self.is_processing or self.current_result is None:
return
pdf_path = self.files[self.index]
try:
save_results(self.current_result, pdf_path, self.output_dir)
clear_copy_error(self.workspace, pdf_path)
except Exception as exc:
self._mark_error(pdf_path, f"Échec de lenregistrement : {exc}")
messagebox.showerror("Enregistrement impossible", str(exc), parent=self.root)
return
self._advance()
def _mark_error(self, pdf_path: Path, reason: str) -> None:
mark_copy_error(self.workspace, pdf_path, reason)
self.had_errors = True
print(f"[Copie signalée] {pdf_path.name}: {reason}")
def on_skip(self, _event=None) -> None:
if self.is_processing or self.index >= len(self.files):
return
self._mark_error(self.files[self.index], "Problème repéré pendant la découpe des marges")
self._advance()
def on_close(self) -> None:
self.stop_prefetch.set()
self.root.destroy()
def _advance(self) -> None:
self.index += 1
self.current_shift = 0
self.current_width_offset = 0
self.current_max_per_file = self.default_max_per_file
self.load_current_image()
def _selected_files(
workspace: EvaluationWorkspace,
target: Path,
) -> list[Path]:
workspace.require_directories("Copies")
if target.is_file():
if target.suffix.casefold() != ".pdf":
raise CliError(f"Target is not a PDF: {target}", ExitCode.INVALID_ARGUMENTS)
return [target]
return sorted(
(
path
for path in workspace.copies_dir.glob("*.pdf")
if "nonc" not in path.name.casefold()
),
key=lambda path: path.name.casefold(),
)
def run(
workspace: EvaluationWorkspace,
target: Path,
*,
fullpage: bool = False,
marked: bool = False,
) -> ExitCode:
files = marked_copy_paths(workspace) if marked else _selected_files(workspace, target)
if not files:
print("No PDF files found.")
return ExitCode.SUCCESS
workspace.cutleft_dir.mkdir(parents=True, exist_ok=True)
_get_pdf_pages_cached.cache_clear()
reviewer = ImageReviewer(
files,
workspace.cutleft_dir,
default_max_per_file=1 if fullpage else 5,
)
if not reviewer.completed:
return ExitCode.INTERRUPTED
return ExitCode.PARTIAL if reviewer.had_errors else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Interactively crop the label margin from PDF copies")
parser.add_argument("--marked", action="store_true", help="Review flagged copies and clear each flag after saving with Enter")
parser.add_argument(
"--fullpage",
action="store_true",
help="Use each complete page instead of cropping the label margin",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target, fullpage=args.fullpage, marked=args.marked)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,15 +1,26 @@
import sys
import os
from __future__ import annotations
import argparse
import glob
import json
import urllib.request
import os
import re
import subprocess
import tempfile
import shutil
import urllib.request
from collections.abc import Sequence
from uuid import uuid4
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_text,
evaluation_parser,
execute,
workspace_from_args,
)
from copienator.platform import WindowsLabelError, validate_windows_labels
from copienator.utils import compile_to_pdf
from platform_utils import WindowsLabelError, validate_windows_labels
from utils import compile_to_pdf
def fetch_and_save_sub_text(ex_id, indices, label, text_path):
"""Fetches text for a specific sub-question and saves it to Text/{label}.tex"""
@@ -29,6 +40,7 @@ def fetch_and_save_sub_text(ex_id, indices, label, text_path):
compile_to_pdf(content, pdf_file)
except Exception as e:
print(f"Error fetching sub-text from {url}: {e}")
raise
def fetch_and_save_sub_sol(ex_id, indices, label, sol_path):
"""Fetches text for a specific sub-question and saves it to Text/{label}.tex"""
@@ -48,6 +60,7 @@ def fetch_and_save_sub_sol(ex_id, indices, label, sol_path):
compile_to_pdf(content, pdf_file)
except Exception as e:
print(f"Error fetching sub-text from {url}: {e}")
raise
ROMANS_CAP = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"]
@@ -127,18 +140,22 @@ def save_split_content(text, path, base_fname, problem):
f.write(chunk)
def process_directory(directory):
def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
directory = str(workspace.root)
# Find the first .tex file in the directory
tex_files = glob.glob(os.path.join(directory, "*.tex"))
enonce = workspace.root / "enonce.tex"
tex_files = [str(enonce)] if enonce.is_file() else sorted(glob.glob(os.path.join(directory, "*.tex")))
if not tex_files:
print(f"No .tex file found in {directory}. Looking in /Staging/Interro/")
int_name = directory[:-1] if directory.endswith("/") else directory
int_name = workspace.root.name
tex_path = os.path.join(os.path.expanduser("~"), "Prépa/Staging/Interro", f"{int_name}.tex")
if os.path.exists(tex_path):
tex_file = tex_path
else:
print("Not found in ", tex_path)
return
raise CliError(
f"No .tex input found in {workspace.root}",
ExitCode.INVALID_WORKSPACE,
)
else:
tex_file = tex_files[0]
@@ -153,8 +170,11 @@ def process_directory(directory):
for p in paths.values():
os.makedirs(p, exist_ok=True)
labels_file = os.path.join(directory, "labels")
labels_file = workspace.labels_file
labels_staging = labels_file.with_name(f".{labels_file.name}.{uuid4().hex}.tmp")
current_ex_num = 1
had_errors = False
exercise_groups = []
# Read entirely to allow chunking
with open(tex_file, 'r', encoding='utf-8') as f_in:
@@ -162,8 +182,11 @@ def process_directory(directory):
# Split by the specific SHEETINFO tag
blocks = content.split("%%SHEETINFO :")
if len(blocks) == 1:
print(f"No SHEETINFO blocks found in {tex_file}")
return ExitCode.PARTIAL
with open(labels_file, 'w', encoding='utf-8') as f_labels:
with open(labels_staging, 'w', encoding='utf-8') as f_labels:
# Skip blocks[0] (content before first SHEETINFO)
for block in blocks[1:]:
parts_line = block.split("\n", 1)
@@ -177,6 +200,7 @@ def process_directory(directory):
try:
data = json.loads(json_str)
block_labels = []
# Construct 'ids' parameter
ex_id = str(data['id'])
selection = data.get('select')
@@ -193,7 +217,7 @@ def process_directory(directory):
if not indexes:
label = f"Ex {current_ex_num}"
validate_windows_labels([label])
f_labels.write(f"{label}\n")
block_labels.append(label)
fetch_and_save_sub_text(ids, [], label, paths['Text2'])
fetch_and_save_sub_sol(ids, [], label, paths['Sol2'])
else:
@@ -201,7 +225,7 @@ def process_directory(directory):
suffix = format_indices(item['indices'], problem)
label = f"Ex {current_ex_num}" + (f" : {suffix}" if suffix else "")
validate_windows_labels([label])
f_labels.write(f"{label}\n")
block_labels.append(label)
fetch_and_save_sub_text(ids, item['indices'], label, paths['Text2'])
fetch_and_save_sub_sol(ids, item['indices'], label, paths['Sol2'])
@@ -244,18 +268,34 @@ def process_directory(directory):
save_split_content(s_text, paths['Sol'], base_filename, problem)
save_split_content(p_text, paths['Persp'], base_filename, problem)
for label in block_labels:
f_labels.write(f"{label}\n")
exercise_groups.append(block_labels)
current_ex_num += 1
except WindowsLabelError:
labels_staging.unlink(missing_ok=True)
raise
except json.JSONDecodeError:
print(f"Error decoding JSON in block: {json_str}")
except Exception as e:
had_errors = True
except Exception as e: # noqa: BLE001 - one malformed exercise is partial
print(f"Error processing block {ex_id if 'ex_id' in locals() else 'unknown'}: {e}")
had_errors = True
labels_staging.replace(labels_file)
atomic_write_text(workspace.label_groups_file,
"".join(", ".join(group) + "\n" for group in exercise_groups))
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Generate statement metadata from SHEETINFO blocks")
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: process_directory(workspace_from_args(args)))
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python script.py <Dir>")
sys.exit(1)
process_directory(sys.argv[1])
raise SystemExit(main())
+42 -10
View File
@@ -3,7 +3,6 @@ import sys
from collections.abc import Sequence
from pathlib import Path
from config import EXPORT_DIR
from copienator import (
EvaluationWorkspace,
ExitCode,
@@ -11,7 +10,10 @@ from copienator import (
execute,
workspace_from_args,
)
from platform_utils import replace_with_link_or_copy
from copienator.configuration import EXPORT_DIR
from copienator.platform import replace_with_link_or_copy
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
def export_directory(
@@ -19,8 +21,12 @@ def export_directory(
source_dir_name: str,
) -> ExitCode:
workspace.require_directories(source_dir_name)
source_dir = workspace.root / source_dir_name
source_dir = workspace.annotation_dir("refaire") if source_dir_name == "BRnot" else workspace.root / source_dir_name
session_id = workspace.refaire_session_id if source_dir_name == "BRnot" else None
prefix = f"{session_id}__" if session_id else ""
sync_dir = Path(EXPORT_DIR).expanduser() / workspace.name
if session_id:
sync_dir /= session_id
sync_dir.mkdir(parents=True, exist_ok=True)
subdirs = [directory for directory in source_dir.iterdir() if directory.is_dir()]
@@ -31,12 +37,22 @@ def export_directory(
missing_outputs = 0
for subdir in subdirs:
concat_file = subdir / "Concat.pdf"
if not concat_file.is_file():
print(f"Warning: file not found: {concat_file}", file=sys.stderr)
concat_file = next(
(
candidate
for candidate in (subdir / "Concat.pdf", subdir / "Concat.jpg")
if candidate.is_file()
),
None,
)
if concat_file is None:
print(
f"Warning: no Concat.pdf or Concat.jpg found in {subdir}",
file=sys.stderr,
)
missing_outputs += 1
continue
destination = sync_dir / f"{subdir.name}.pdf"
destination = sync_dir / f"{prefix}{subdir.name}{concat_file.suffix.lower()}"
method = replace_with_link_or_copy(concat_file, destination, prefer="hardlink")
print(f"Exported: {destination} ({method})")
return ExitCode.PARTIAL if missing_outputs else ExitCode.SUCCESS
@@ -44,12 +60,24 @@ def export_directory(
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Export annotated PDFs to the tablet directory.")
parser.add_argument(
"annotation_dir",
nargs="?",
choices=ANNOTATION_DIRECTORIES,
default="BGnot",
help="Annotation directory to export (default: BGnot)",
)
parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json")
return parser
def run(workspace: EvaluationWorkspace, *, refaire: bool = False) -> ExitCode:
return export_directory(workspace, "BRnot" if refaire else "BGnot")
def run(
workspace: EvaluationWorkspace,
*,
annotation_dir: str = "BGnot",
refaire: bool = False,
) -> ExitCode:
return export_directory(workspace, "BRnot" if refaire else annotation_dir)
def main(argv: Sequence[str] | None = None) -> int:
@@ -57,7 +85,11 @@ def main(argv: Sequence[str] | None = None) -> int:
return execute(
parser,
argv,
lambda args: run(workspace_from_args(args), refaire=args.refaire),
lambda args: run(
workspace_from_args(args),
annotation_dir=args.annotation_dir,
refaire=args.refaire,
),
)
@@ -0,0 +1,83 @@
from __future__ import annotations
import argparse
from collections.abc import Sequence
from google import genai
from copienator import configuration as config
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_bytes,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
def run(workspace: EvaluationWorkspace, *, client=None) -> ExitCode:
if client is None:
if not config.API_KEY:
raise CliError("GEMINI_API_KEY is not configured")
client = genai.Client(api_key=config.API_KEY)
matching = []
if workspace.batch_jobs_file.is_file():
manifest = read_json(workspace.batch_jobs_file)
jobs = manifest.get("jobs") if isinstance(manifest, dict) else None
if not isinstance(jobs, dict):
raise CliError(f"Invalid batch manifest: {workspace.batch_jobs_file}")
matching = [
client.batches.get(name=entry["name"])
for entry in jobs.values()
if isinstance(entry, dict) and isinstance(entry.get("name"), str)
]
else:
matching = [
job
for job in client.batches.list()
if workspace.name in str(getattr(job, "display_name", ""))
]
if not matching:
raise CliError(
f"No batch jobs found for evaluation {workspace.name!r}"
)
for job in matching:
state = job.state.name if hasattr(job.state, "name") else job.state
print(f"{job.display_name}: {state}")
if state != "JOB_STATE_SUCCEEDED":
print("Not all matching jobs have succeeded yet.")
return ExitCode.PARTIAL
chunks = []
incomplete = False
for job in matching:
destination = getattr(job, "dest", None)
file_name = getattr(destination, "file_name", None)
if not file_name:
print(f"Warning: {job.display_name} has no output file.")
incomplete = True
continue
payload = client.files.download(file=file_name)
chunks.append(payload.rstrip(b"\n"))
if not chunks:
return ExitCode.PARTIAL
output_path = workspace.batched_correction_result_file
atomic_write_bytes(output_path, b"\n".join(chunks) + b"\n")
print(f"Saved combined batch results to {output_path}")
return ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Download and combine correction batch results")
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,17 +1,30 @@
import re
import os
import utils
import subprocess
import sys
from __future__ import annotations
import argparse
import re
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from pydantic import BaseModel, Field
from typing import List, Union
from google import genai
from google.genai import types
from concurrent.futures import ThreadPoolExecutor
from platform_utils import validate_windows_labels
from utils import compile_to_pdf
from pydantic import BaseModel, Field
from copienator import configuration as config
from copienator import utils
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_text,
evaluation_parser,
execute,
workspace_from_args,
)
from copienator.platform import validate_windows_labels
from copienator.filesystem import staged_directory
from copienator.utils import compile_to_pdf
def get_lcp(s1: str, s2: str) -> str:
i = 0
@@ -25,54 +38,67 @@ def get_lcp(s1: str, s2: str) -> str:
return lcp
import config
MODEL_ID = config.MODEL_LITE_ID
api_key = config.API_KEY
# --- Modèles pour la Requête 1 ---
class QuestionOnlyItem(BaseModel):
label: str = Field(description="The unique label of the question (e.g., '1.a', 'Exercice 1')")
question_content: str = Field(description="The source text of the question, strictly extracted from the enonce file, EXCLUDING the label itself.")
label: str = Field(description="Label unique de la question (par exemple '1.a' ou 'Exercice 1').")
question_content: str = Field(description="Texte source de la question, extrait exactement du fichier d’énoncé, SANS le label lui-même.")
class ExamQuestions(BaseModel):
questions: List[QuestionOnlyItem]
questions: list[QuestionOnlyItem]
# --- Modèles pour la Requête 2 ---
class SolutionOnlyItem(BaseModel):
label: str = Field(description="The exact unique label of the question provided in the input.")
solution_content: str = Field(description="The source text of the solution, strictly extracted from the correction file.")
label: str = Field(description="Label exact de la question fourni en entrée, à conserver sans traduction.")
solution_content: str = Field(description="Texte source de la solution, extrait exactement du fichier de correction.")
class ExamSolutions(BaseModel):
solutions: List[SolutionOnlyItem]
solutions: list[SolutionOnlyItem]
# --- Modèles pour la Requête 3 ---
class ExtractedContext(BaseModel):
target_question_label: str = Field(description="The exact label of the FIRST question that comes immediately AFTER this information in the exam.")
last_question_label: str = Field(description="The exact label of the LAST question that uses or relies on this information.")
context_content: str = Field(description="The source text of the definitions, notations, or hypotheses, extracted from the enonce.")
target_question_label: str = Field(description="Label exact de la PREMIÈRE question située immédiatement APRÈS cette information dans l’énoncé.")
last_question_label: str = Field(description="Label exact de la DERNIÈRE question qui utilise cette information.")
context_content: str = Field(description="Texte source des définitions, notations ou hypothèses, extrait de l’énoncé.")
class ExamContext(BaseModel):
contexts: List[ExtractedContext]
contexts: list[ExtractedContext]
# --- Modèles pour la Requête 4 (Barèmes) ---
class RubricItem(BaseModel):
label: str = Field(description="The exact label of the question.")
rubric_content: str = Field(description="Le barème détaillé en français.")
label: str = Field(description="Label exact de la question, à conserver sans traduction.")
rubric_content: str = Field(description="Barème détaillé sur 4 points : toutes les consignes, explications et justifications doivent être rédigées en français.")
class GroupRubrics(BaseModel):
rubrics: List[RubricItem]
rubrics: list[RubricItem]
class LabelGroups(BaseModel):
groups: list[list[str]]
PROMPT_4 = """Je te fournis les questions, le contexte éventuel, et les corrections pour un groupe de questions d'un examen.
Ta tâche :
Établir un barème de correction détaillé en français pour CHAQUE question.
Établir un barème de correction détaillé pour CHAQUE question.
Rédige intégralement en français le contenu de chaque champ `rubric_content`,
y compris les consignes de notation, les explications et les justifications,
même si certains textes fournis sont dans une autre langue.
Conserve les formules mathématiques, les labels exacts des questions et les
clés JSON `rubrics`, `label` et `rubric_content` sans les traduire.
Chaque question DOIT être notée sur exactement 4 points. Propose une répartition logique de ces points.
Il est inutile d'indiquer dans `rubric_content` que le barème totalise 4 points :
ce total est toujours implicite.
N'utilise pas de caractères mathématiques Unicode dans `rubric_content`.
Écris les expressions mathématiques en LaTeX, par exemple
`$\\lfloor \\sqrt{k} \\rfloor$` plutôt qu'avec des symboles Unicode.
Par exemple :
- Au moins 2 points si le résultat est correct.
- Mettre la moitié des points si le raisonnement est correct mais pas le résultat.
- Retirer 1.5 points si les hypothèses d'un théorème ou d'une question précédente ne sont pas vérifiées.
- Retirer 1,5 point si les hypothèses d'un théorème ou d'une question précédente ne sont pas vérifiées.
Renvoie le résultat sous forme de liste JSON correspondant aux labels des questions fournies.
Renvoie uniquement un objet JSON contenant une liste `rubrics`. Pour chaque
question fournie, cette liste contient un objet avec son `label` exact et
son barème en français dans `rubric_content`.
"""
# --- Modèle fusionné (pour le reste du script) ---
@@ -87,66 +113,185 @@ class ContextItem(BaseModel):
content: str # Juste une string encapsulée pour le différencier facilement
class ExamExtraction(BaseModel):
items: List[Union[QuestionItem, ContextItem]] # Liste mixte
items: list[QuestionItem | ContextItem] # Liste mixte
class GroupedExamExtraction(BaseModel):
groups: List[List[Union[QuestionItem, ContextItem]]]
groups: list[list[QuestionItem | ContextItem]]
PROMPT_1 = """I am providing:
1. A PDF of an exam (`enonce.pdf`)
2. The source code of the exam questions (`enonce` file)
PROMPT_1 = """Je te fournis :
1. Le PDF d'un examen (`enonce.pdf`).
2. Le code source de ses questions (fichier `enonce`).
Your task:
1. Identify all distinct question labels using the PDF document.
These labels should be unique : use `Ex 1 : 1)a)` or `I)1)b)`.
2. For each label, extract its exact corresponding question text
from the `enonce` source file. Do not include the label itself
in this extracted text (nor LaTeX like `item` nor org-mode list
labelling like `2.`).
Return the result as a JSON list in the exact reading order of the document.
Ta tâche :
1. Identifie tous les labels distincts des questions à l'aide du PDF.
Ils doivent être uniques : utilise par exemple `Ex 1 : 1)a)` ou `I)1)b)`.
2. Pour chaque label, extrais exactement le texte de la question
correspondante dans le fichier source `enonce`. N'inclus ni le label
lui-même, ni les commandes de liste LaTeX comme `item`, ni les marques
de liste org-mode comme `2.`.
Ne reformule pas et ne traduis pas le texte extrait ; conserve le LaTeX.
Renvoie les questions dans l'ordre exact de lecture du document, dans la
liste `questions` de l'objet JSON attendu. Conserve les clés `label` et
`question_content`.
"""
PROMPT_2 = """I am providing:
1. A JSON list of question labels and their texts extracted from an exam.
2. The source code of the exam solutions (`correction` file).
PROMPT_2 = """Je te fournis :
1. Une liste JSON des labels des questions d'un examen et de leurs textes.
2. Le code source du corrigé de l'examen (fichier `correction`).
Your task:
For each question label provided in the JSON, extract its exact corresponding solution textual
content from the `correction` source file. Return the result as a JSON list in the exact same order.
Pour chaque label fourni, extrais exactement le texte de la solution
correspondante dans le fichier source `correction`. Ne reformule pas et
ne traduis pas le texte extrait ; conserve le LaTeX.
Renvoie les solutions dans le même ordre que les questions, dans la liste
`solutions` de l'objet JSON attendu. Conserve les clés `label` et
`solution_content` ainsi que les labels exacts des questions.
"""
PROMPT_3 = """I am providing:
1. A JSON list of question labels and their texts extracted from an exam.
2. The source code of the exam questions (`enonce` file).
PROMPT_3 = """Je te fournis :
1. Une liste JSON des labels des questions d'un examen et de leurs textes.
2. Le code source des questions de l'examen (fichier `enonce`).
Your task:
Extract important information necessary to understand the questions (e.g., definitions of objects, global notations, hypotheses, context) that are NOT part of the question texts themselves. Often, this information can be in a previous \\item that is not itself a question, but contains the question items.
Extrais les informations importantes nécessaires à la compréhension des
questions, mais qui ne font PAS partie des textes des questions :
définitions des objets, notations générales, hypothèses ou contexte.
Ces informations figurent souvent dans un \\item précédent qui ne constitue
pas lui-même une question, mais contient une liste de questions.
For example, given LaTeX code like
Par exemple, dans ce code LaTeX :
\\item Let N, M be two commutating matrices
\\item Soient N et M deux matrices qui commutent.
\\begin{itemize}
\\item Prove that N, M have a common eigenvector
\\item Prove that N, M are co-trigonalizable.
\\item Montrer que N et M ont un vecteur propre commun.
\\item Montrer que N et M sont simultanément trigonalisables.
\\end{itemize}
the `Let N, M be two commutating matrices` part is not a question itself, and is important information to understand the next two questions.
La phrase « Soient N et M deux matrices qui commutent » n'est pas une
question ; elle est nécessaire pour comprendre les deux questions suivantes.
For each extracted piece of information, identify:
1. The label of the FIRST question that comes immediately AFTER this information in the exam.
2. The label of the LAST question that uses or relies on this information.
Return the result as a JSON list.
Pour chaque information extraite, identifie :
1. Le label de la PREMIÈRE question située immédiatement APRÈS cette
information dans l'énoncé (`target_question_label`).
2. Le label de la DERNIÈRE question qui utilise cette information
(`last_question_label`).
Conserve le texte source dans `context_content`, sans le reformuler ni le
traduire, et conserve le LaTeX ainsi que les labels exacts.
Renvoie le résultat dans la liste `contexts` de l'objet JSON attendu.
"""
def find_file(folder: Path, base_name: str) -> Path:
def find_file(folder: Path, base_name: str) -> Path | None:
for ext in [".org", ".tex"]:
path = folder / f"{base_name}{ext}"
if path.is_file():
return path
return None
def process_exam(folder_path: str, restart: bool = False):
folder = Path(folder_path)
def generate_rubrics(client, group_context_text: str) -> dict[str, str]:
"""Use the same rubric request for full and selective statement generation."""
response = client.models.generate_content(
model=MODEL_ID,
contents=[types.Content(role="user", parts=[
types.Part.from_text(text=PROMPT_4),
types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"),
])],
config=types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
system_instruction="Rédige tous les barèmes et consignes de notation en français. Conserve les clés JSON, les labels et les formules mathématiques.",
temperature=0.2,
response_mime_type="application/json",
response_json_schema=GroupRubrics.model_json_schema(),
),
)
rubrics = GroupRubrics.model_validate_json(response.text).rubrics
if len({item.label for item in rubrics}) != len(rubrics):
raise ValueError("Gemini returned duplicate rubric labels")
return {item.label: item.rubric_content for item in rubrics}
def validate_groups(groups: list[list[str]], labels: list[str]) -> None:
flattened = [label for group in groups for label in group]
if (not groups or any(not group for group in groups)
or len(flattened) != len(set(flattened)) or set(flattened) != set(labels)):
raise CliError("Groups must contain every existing label exactly once")
def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=None) -> ExitCode:
"""Regroup or replace rubrics without regenerating statements or solutions."""
labels = workspace.read_labels()
if not labels or len(labels) != len(set(labels)):
raise CliError("Generate unique question labels before refining the statement")
validate_windows_labels(labels)
questions = {}
for label in labels:
safe_label = label.replace("/", "_")
parts = []
for directory, title in (("Text2", "Question"), ("Sol2", "Correction")):
path = workspace.root / directory / f"{safe_label}.tex"
if not path.is_file():
raise CliError(f"Missing {path}; generate statements and solutions first")
parts.append(f"{title} [{label}]:\n{path.read_text(encoding='utf-8')}")
questions[label] = "\n".join(parts)
context = utils.enonce_total(workspace.root)
if api_client is None:
if not api_key:
raise CliError("GEMINI_API_KEY is not configured")
api_client = genai.Client(api_key=api_key)
if mode == "groups":
response = api_client.models.generate_content(
model=MODEL_ID,
contents=[types.Content(role="user", parts=[types.Part.from_text(text=(
"Regroupe ces questions dexamen en groupes cohérents pour la correction "
"et lannotation, selon leurs dépendances et leur contexte commun. "
"Ne mélange pas des exercices différents. Conserve lordre des questions. "
"Chaque label doit apparaître exactement une fois, sans modification. "
"Renvoie uniquement un objet JSON groups contenant des listes de labels.\n\n"
+ context + "\n\n" + "\n\n".join(questions.values())
))])],
config=types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=0.1, response_mime_type="application/json",
response_json_schema=LabelGroups.model_json_schema(),
),
)
groups = LabelGroups.model_validate_json(response.text).groups
validate_groups(groups, labels)
atomic_write_text(workspace.label_groups_file,
"".join(", ".join(group) + "\n" for group in groups))
print(f"Updated label_groups: {len(groups)} Gemini groups.")
elif mode == "persp":
if not workspace.label_groups_file.is_file():
raise CliError("Generate label_groups before generating rubrics")
groups = [[label.strip() for label in line.split(",") if label.strip()]
for line in workspace.label_groups_file.read_text(encoding="utf-8").splitlines()
if line.strip()]
validate_groups(groups, labels)
with staged_directory(workspace.root / "Persp") as staging:
for group in groups:
print(f"Generating rubric (Persp) for group: {', '.join(group)}...")
group_content = (
"Contexte général de lexamen, fourni uniquement pour comprendre les questions :\n"
+ context + "\n\nProduis des barèmes UNIQUEMENT pour les labels suivants : "
+ ", ".join(group) + "\n\n" + "\n\n".join(questions[label] for label in group)
)
rubrics = generate_rubrics(api_client, group_content)
if set(rubrics) != set(group) or any(not value.strip() for value in rubrics.values()):
raise CliError("Incomplete or unexpected Gemini rubrics; previous Persp preserved")
for label, rubric in rubrics.items():
(staging / label.replace("/", "_")).write_text(
f"{label}\n{rubric}", encoding="utf-8")
print("Replaced Persp with Gemini rubrics.")
else:
raise ValueError(f"Unknown statement refinement: {mode}")
return ExitCode.SUCCESS
def process_exam(
workspace: EvaluationWorkspace,
restart: bool = False,
*,
api_client=None,
) -> ExitCode:
folder = workspace.root
cache_dir = folder / "Cache"
tmp_dir = folder / "Tmp"
@@ -168,15 +313,21 @@ def process_exam(folder_path: str, restart: bool = False):
if not correction_path: missing.append("correction.org or correction.tex")
if missing:
print(f"Error: Missing files in {folder}: {', '.join(missing)}")
sys.exit(1)
raise CliError(
f"Missing files in {folder}: {', '.join(missing)}",
ExitCode.INVALID_WORKSPACE,
)
print("Reading files...")
pdf_bytes = pdf_path.read_bytes()
enonce_text = enonce_path.read_text(encoding="utf-8")
correction_text = correction_path.read_text(encoding="utf-8")
client = genai.Client(api_key=api_key)
if api_client is None:
if not api_key:
raise CliError("GEMINI_API_KEY is not configured")
api_client = genai.Client(api_key=api_key)
client = api_client
# ==========================================
# REQUÊTE 1 : Extraction des Énoncés
@@ -193,6 +344,7 @@ def process_exam(folder_path: str, restart: bool = False):
]
config_1 = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=0.1,
response_mime_type="application/json",
response_json_schema=ExamQuestions.model_json_schema(),
@@ -210,7 +362,7 @@ def process_exam(folder_path: str, restart: bool = False):
)
response_q_text = response_q.text
print("Saving questions to cache...")
cache_q_file.write_text(response_q_text, encoding="utf-8")
atomic_write_text(cache_q_file, response_q_text)
questions_data = ExamQuestions.model_validate_json(response_q_text)
@@ -224,13 +376,14 @@ def process_exam(folder_path: str, restart: bool = False):
role="user",
parts=[
types.Part.from_text(text=PROMPT_2),
types.Part.from_text(text=f"--- EXTRACTED QUESTIONS ---\n{extracted_questions_json}"),
types.Part.from_text(text=f"--- QUESTIONS EXTRAITES ---\n{extracted_questions_json}"),
types.Part.from_text(text=f"--- CORRECTION SOURCE ({correction_path.name}) ---\n{correction_text}"),
],
)
]
config_2 = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=0.1,
response_mime_type="application/json",
response_json_schema=ExamSolutions.model_json_schema(),
@@ -248,7 +401,7 @@ def process_exam(folder_path: str, restart: bool = False):
)
response_s_text = response_s.text
print("Saving solutions to cache...")
cache_s_file.write_text(response_s_text, encoding="utf-8")
atomic_write_text(cache_s_file, response_s_text)
solutions_data = ExamSolutions.model_validate_json(response_s_text)
@@ -260,13 +413,14 @@ def process_exam(folder_path: str, restart: bool = False):
role="user",
parts=[
types.Part.from_text(text=PROMPT_3),
types.Part.from_text(text=f"--- EXTRACTED QUESTIONS ---\n{extracted_questions_json}"),
types.Part.from_text(text=f"--- QUESTIONS EXTRAITES ---\n{extracted_questions_json}"),
types.Part.from_text(text=f"--- ENONCE SOURCE ({enonce_path.name}) ---\n{enonce_text}"),
],
)
]
config_3 = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=0.1,
response_mime_type="application/json",
response_json_schema=ExamContext.model_json_schema(),
@@ -284,7 +438,7 @@ def process_exam(folder_path: str, restart: bool = False):
)
response_c_text = response_c.text
print("Saving context to cache...")
cache_c_file.write_text(response_c_text, encoding="utf-8")
atomic_write_text(cache_c_file, response_c_text)
context_data = ExamContext.model_validate_json(response_c_text)
@@ -388,7 +542,6 @@ def process_exam(folder_path: str, restart: bool = False):
for g_indices in q_group_indices:
group_items = []
first_q_idx = g_indices[0]
last_q_idx = g_indices[-1]
for q_idx in g_indices:
q_item = questions_only[q_idx]
@@ -497,14 +650,13 @@ def process_exam(folder_path: str, restart: bool = False):
# 2. Actual Parsing
grouped_items = []
current_raw_group = [] # Stores (is_context, label_or_flag, content)
all_new_q_labels = []
# Pass 1: Read all edited lines and collect question labels in sequence
for line in edited_lines:
if line == "---" or " ### " not in line:
continue
lbl, content_raw = line.split(" ### ", 1)
lbl, _content_raw = line.split(" ### ", 1)
lbl = lbl.strip()
if lbl != "CONTEXT":
all_new_q_labels.append(lbl)
@@ -606,10 +758,6 @@ def process_exam(folder_path: str, restart: bool = False):
validate_windows_labels(labels_list)
# Save labels and proceed
with open(folder / "labels", 'w', encoding='utf-8') as f_labels:
for label in labels_list:
f_labels.write(f"{label}\n")
grouped_extraction = GroupedExamExtraction(groups=grouped_items)
# 2. Setup output directories
@@ -628,8 +776,7 @@ def process_exam(folder_path: str, restart: bool = False):
).strip().lower()
if answer not in ("y", "yes"):
print("Aborted.")
sys.exit(1)
raise CliError("Output replacement aborted", ExitCode.INVALID_ARGUMENTS)
# Empty each directory
for d in dirs:
if d.exists():
@@ -645,6 +792,7 @@ def process_exam(folder_path: str, restart: bool = False):
print("Writing grouped question and solution files...")
processing_errors = []
for group in grouped_extraction.groups:
q_items = [item for item in group if isinstance(item, QuestionItem)]
@@ -665,33 +813,12 @@ def process_exam(folder_path: str, restart: bool = False):
group_context_text = "\n\n---\n\n".join(group_text_parts)
contents_4 = [
types.Content(
role="user",
parts=[
types.Part.from_text(text=PROMPT_4),
types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"),
],
)
]
config_4 = types.GenerateContentConfig(
temperature=0.2,
response_mime_type="application/json",
response_json_schema=GroupRubrics.model_json_schema(),
)
print(f"Generating rubric (Persp) for group: {', '.join(labels)}...")
try:
response_r = client.models.generate_content(
model=MODEL_ID,
contents=contents_4,
config=config_4
)
rubrics_data = GroupRubrics.model_validate_json(response_r.text)
rubrics_map = {r.label: r.rubric_content for r in rubrics_data.rubrics}
except Exception as e:
rubrics_map = generate_rubrics(client, group_context_text)
except Exception as e: # noqa: BLE001 - remote API boundary
print(f"Error generating rubric for group {labels[0]}: {e}")
processing_errors.append(str(e))
rubrics_map = {}
# 1. Compute the common prefix for the group
@@ -737,7 +864,7 @@ def process_exam(folder_path: str, restart: bool = False):
elif isinstance(item, ContextItem):
raw_ctx = item.content.strip()
tabulated_ctx = "\t" + re.sub(r'\n\s*', '\n\t', raw_ctx)
text_content_lines.append(f"CONTEXT :")
text_content_lines.append("CONTEXT :")
text_content_lines.append(tabulated_ctx)
# --- Save context to Text2 (Concatenating if exists) ---
@@ -762,29 +889,66 @@ def process_exam(folder_path: str, restart: bool = False):
# ==========================================
all_tex_files = list(text2_dir.glob("*.tex")) + list(sol2_dir.glob("*.tex"))
def compile_worker(tex_file: Path):
def compile_worker(tex_file: Path) -> str | None:
"""Helper to read content and call the utility function."""
try:
content = tex_file.read_text(encoding="utf-8")
pdf_path = tex_file.with_suffix(".pdf")
compile_to_pdf(content, pdf_path)
except Exception as e:
print(f"Error compiling {tex_file.name}: {e}")
except Exception as e: # noqa: BLE001 - compiler worker boundary
return f"Error compiling {tex_file.name}: {e}"
return None
print(f"Compiling {len(all_tex_files)} files to PDF using 4 threads...")
with ThreadPoolExecutor(max_workers=4) as executor:
executor.map(compile_worker, all_tex_files)
compile_errors = [
error for error in executor.map(compile_worker, all_tex_files) if error
]
for error in compile_errors:
print(error)
processing_errors.extend(compile_errors)
atomic_write_text(
workspace.labels_file,
"".join(f"{label}\n" for label in labels_list),
)
atomic_write_text(
workspace.label_groups_file,
"".join(", ".join(item.label for item in group if isinstance(item, QuestionItem)) + "\n"
for group in grouped_extraction.groups
if any(isinstance(item, QuestionItem) for item in group)),
)
return ExitCode.PARTIAL if processing_errors else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Extract exam and solution code via Gemini")
actions = parser.add_mutually_exclusive_group()
actions.add_argument("--groups-only", action="store_true",
help="Regroup existing questions with Gemini; update only label_groups")
actions.add_argument("--persp-only", action="store_true",
help="Replace only Persp with Gemini rubrics for existing groups")
parser.add_argument(
"--restart",
action="store_true",
help="Ignore cached Gemini extraction responses",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: refine_existing(workspace_from_args(args),
"groups" if args.groups_only else "persp")
if args.groups_only or args.persp_only else process_exam(
workspace_from_args(args),
restart=args.restart,
),
)
if __name__ == "__main__":
if not api_key:
print("Error: GEMINI_API_KEY environment variable is not set.")
sys.exit(1)
parser = argparse.ArgumentParser(description="Extract exam and solution code via Gemini.")
parser.add_argument("folder", help="Directory containing the exam files")
parser.add_argument("--restart", action="store_true", help="Ignore cache files and re-run extraction requests.")
args = parser.parse_args()
process_exam(args.folder, restart=args.restart)
raise SystemExit(main())
+599
View File
@@ -0,0 +1,599 @@
from __future__ import annotations
import argparse
import re
import time
import typing
from collections import defaultdict
from collections.abc import Callable, Sequence
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from google import genai
from google.genai import types
from pydantic import BaseModel, Field
from copienator import configuration as config
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_json,
execute,
read_json,
target_parser,
workspace_from_target,
)
from copienator.utils import natural_key, read_all_labels
MODEL_ID = config.MODEL_FOR_LABEL_ID
api_key = config.API_KEY
my_prompt = """I'm giving you an image of the left columns of a written exam.
Students answer several exercises, which can have several questions.
The image consists of several columns, separated by vertical black
lines. The image should be read top to bottom and then left to right,
meaning first column, then second column, etc.
In their sheet, students delimit exercises and questions using
delimiters such as `Ex 1`, or `Exercice 1`, and `1)` or `a)`. You need
to give me the bounding boxes of each delimiter.
When giving the bounding box of the first question of an exercise, the
box should be large enough to contain both the exercice label
(`Exercice i`) and the question label (`1)`) parts. If they are
horizontally far apart (example : if the `1)` is to the left and the
`Exercice i` is either to the right, or in the middle) then give only
the bounding box of the question label `1)` part. You should still
label it as `Exercice i : 1)` though.
You also need to give me the student name. It should appear on the top
left of the image. Disregard any mention of `MPSI 3`, it is their
class. A list of possible student names will be given below.
You will answer with a JSON object, containing a `name` field with the
name, and a `list` field, with the list of the bounding boxes and
their labels. The box_2d should be [ymin, xmin, ymax, xmax] normalized
to 0-1000.
Here is an example :
{\"name\" : \"John Doe\", \"list\" : [{\"box_2d\": (10, 20, 30, 40), \"label\" : \"Ex 1 : 1)\"}]}
Do not provide a box_2d for the name. Only for the labels. Order the
box_2d by their position in the page, column by column : first column
(top to bottom), then second column, etc.
You may find the same label present several times, as a student either
recall the current label on a new page, or adds content to its answer
later on. Give the position of each instance of each label.
For this exam you should look for the labels given below, separated by
newlines. A student need not have answered every question, so some may
be missing.
##labels##
##wrong_labels##
##wrong_label_text_context##
Here's a list of the names of the students, pick the one that matches
the best or `\"Unknown\"` if you cannot read the name
##names##"""
my_prompt2 = """I'm giving you an image of the left columns of a written exam.
Students answer several exercises, which can have several questions.
The image consists of several columns, separated by vertical black
lines. The image should be read top to bottom and then left to right,
meaning first column, then second column, etc.
In their sheet, students delimit exercises and questions using
delimiters such as `Ex 1`, or `Exercice 1`, and `1)` or `a)`. You need
to give me the bounding boxes of each delimiter.
When giving the bounding box of the first question of an exercise, the
box should be large enough to contain both the exercice label
(`Exercice i`) and the question label (`1)`) parts.
You also need to give me the student name. It should appear on the top
left of the image. Disregard any mention of `MPSI 3`, it is their
class. A list of possible student names will be given below.
You will answer with a JSON object, containing a `name` field with the
name, and a `list` field, with the list of the bounding boxes and
their labels. The box_2d should be [ymin, xmin, ymax, xmax] normalized
to 0-1000.
Here is an example :
{\"name\" : \"John Doe\", \"list\" : [{\"box_2d\": (10, 20, 30, 40), \"label\" : \"Ex 1 : 1)\"}]}
Do not provide a box_2d for the name. Only for the labels.
You may find the same label present several times, as a student either
recall the current label on a new page, or adds content to its answer
later on. Give the position of each instance of each label.
This image is one part of a sequence (e.g., part 2 of 3) for a single
student. Here is the list of labels found in the *previous* parts of
this copy:
[
##prev_context##
]
If the first column starts with a number like =3)= or =c)=, look at
the labels in the list above. If the last relevant label was =Ex 4 :
2)=, you should label the new box =Ex 4 : 3)=.
For this exam you should look for the labels given below, separated by
newlines. A student need not have answered every question, so some may
be missing.
##labels##
##wrong_labels##
##wrong_label_text_context##
Since this copy isn't the first part of a sequence, simply set the
name to `\"Continued\"`."""
class BoxItem(BaseModel):
box_2d: list[int] = Field(description="Bounding box coordinates (e.g., [ymin, xmin, ymax, xmax])")
label: str = Field(description="The label associated with the specific box")
class AnnotationData(BaseModel):
name: str = Field(description="The name identifier")
list: typing.List[BoxItem] = Field( # noqa: UP006 - field name shadows list
description="List of bounding box items"
)
TEXT_CONTEXT_MAX_CHARS = 4000
def _label_filename(path: Path) -> str:
return path.stem if path.suffix.casefold() in {".tex", ".txt"} else path.name
def _common_prefix_length(left: str, right: str) -> int:
left_folded = left.casefold()
right_folded = right.casefold()
limit = min(len(left_folded), len(right_folded))
for index in range(limit):
if left_folded[index] != right_folded[index]:
return index
return limit
def closest_text_context(
workspace: EvaluationWorkspace, wrong_labels: list[str]
) -> tuple[Path | None, str]:
"""Return a bounded excerpt from the Text file closest to an invalid label."""
text_dir = workspace.root / "Text"
if not wrong_labels or not text_dir.is_dir():
return None, ""
ranked: list[tuple[int, str, Path]] = []
for path in text_dir.iterdir():
if not path.is_file() or path.suffix.casefold() == ".pdf":
continue
filename = _label_filename(path)
prefix_length = max(
_common_prefix_length(filename, wrong_label)
for wrong_label in wrong_labels
)
if prefix_length:
ranked.append((prefix_length, filename.casefold(), path))
for _prefix_length, _filename, path in sorted(
ranked, key=lambda item: (-item[0], item[1])
):
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeError):
continue
if len(content) > TEXT_CONTEXT_MAX_CHARS:
content = content[:TEXT_CONTEXT_MAX_CHARS] + "\n[excerpt truncated]"
return path, content
return None, ""
def generate_request(
file,
labels,
names,
context_labels,
wrong_labels,
wrong_label_text_context="",
wrong_label_text_file: Path | None = None,
seed: int = 0,
):
"""Generates request for Gemini with context."""
image_path = Path(file)
# Format context list as a string
context_str = ", ".join([f'"{l}"' for l in context_labels]) if context_labels else "No previous context"
if context_labels == []:
text = my_prompt.replace("##labels##", labels)\
.replace("##names##", names)
else:
text = my_prompt2.replace("##labels##", labels)\
.replace("##prev_context##", context_str)
if wrong_labels:
formatted_wrong_labels = "\n".join(f'- "{label}"' for label in wrong_labels)
text = text.replace(
"##wrong_labels##",
"On the previous request for this image, you answered with these "
"invalid labels:\n"
f"{formatted_wrong_labels}\n"
"They are wrong because they do not exactly match any label in the "
"valid list above.\n\n"
"CRITICAL RETRY CONSTRAINT: NEVER return any of the invalid labels "
"listed above again. Your answer must use only exact labels copied "
"verbatim from the valid list. If the handwriting resembles an "
"invalid label, choose the closest exact valid label instead.",
)
else:
text = text.replace("##wrong_labels##", "")
if wrong_label_text_context and wrong_label_text_file:
text = text.replace(
"##wrong_label_text_context##",
"Here is an excerpt from the exam text file whose name has the "
"longest prefix in common with the invalid label(s), "
f"`{wrong_label_text_file.name}`:\n\n"
"<exam_text_excerpt>\n"
f"{wrong_label_text_context}\n"
"</exam_text_excerpt>\n\n"
"Use this excerpt as extra context for identifying the handwritten "
"label, but return only an exact label from the valid list above.",
)
else:
text = text.replace("##wrong_label_text_context##", "")
contents = [
types.Content(
role="user",
parts=[
types.Part.from_bytes(
data=image_path.read_bytes(),
mime_type="image/jpeg"
),
types.Part.from_text(text=text),
],
)
]
generate_content_config = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=1.0,
top_p=0.95,
seed=seed,
max_output_tokens=65535,
response_mime_type= "application/json",
response_json_schema= AnnotationData.model_json_schema(),
)
return (contents, generate_content_config)
TARGET_INTERVAL = 3.5
Sleep = Callable[[float], None]
def selected_images(
workspace: EvaluationWorkspace,
targets: list[Path],
) -> tuple[list[Path], list[str]]:
"""Resolve evaluation, copy-PDF, or Cutleft-image targets."""
workspace.require_directories("Copies", "Cutleft")
images: list[Path] = []
warnings: list[str] = []
for target in targets:
if target.is_dir():
copy_pdfs = sorted(
workspace.copies_dir.glob("Copie*.pdf"), key=natural_key
)
if not copy_pdfs:
warnings.append(f"No Copie*.pdf files found in {workspace.copies_dir}")
stems = [path.stem for path in copy_pdfs]
elif target.suffix.casefold() in {".jpg", ".jpeg"}:
if target.parent != workspace.cutleft_dir:
raise CliError(
f"Image target is not in {workspace.cutleft_dir}: {target}",
ExitCode.INVALID_ARGUMENTS,
)
images.append(target)
continue
elif target.suffix.casefold() == ".pdf":
stems = [target.stem]
else:
raise CliError(
f"Unsupported target for label detection: {target}",
ExitCode.INVALID_ARGUMENTS,
)
for stem in stems:
found = sorted(
workspace.cutleft_dir.glob(f"{stem}_*.jpg"), key=natural_key
)
if found:
images.extend(found)
else:
warnings.append(
f"No Cutleft image variants found for {stem} in "
f"{workspace.cutleft_dir}"
)
return list(dict.fromkeys(images)), warnings
def group_images(image_files: list[Path]) -> dict[str, list[Path]]:
groups: defaultdict[str, list[Path]] = defaultdict(list)
for image in image_files:
match = re.match(r"(.+)_(\d+)$", image.stem)
groups[match.group(1) if match else image.stem].append(image)
for files in groups.values():
files.sort(key=natural_key)
return dict(groups)
def sort_boxes_for_image(
workspace: EvaluationWorkspace,
image_file: Path,
boxes: list[BoxItem],
) -> list[BoxItem]:
"""Sort boxes in the image's page-column reading order when schema exists."""
match = re.match(r"(.+)_(\d+)$", image_file.stem)
if not match:
return boxes
schema_path = workspace.cutleft_dir / f"{match.group(1)}_schema.json"
try:
schema = read_json(schema_path)
columns_per_file = schema["columns_per_file"]
column_count = int(columns_per_file[int(match.group(2)) - 1])
if column_count < 1:
return boxes
except (OSError, KeyError, IndexError, TypeError, ValueError):
return boxes
def position(item: BoxItem) -> tuple[int, int, int]:
ymin, xmin, _ymax, xmax = item.box_2d
center_x = (xmin + xmax) // 2
column = min(column_count - 1, max(0, center_x * column_count // 1000))
return column, ymin, xmin
return sorted(boxes, key=position)
def _existing_context(output_json: Path) -> list[str]:
try:
loaded = read_json(output_json)
if not isinstance(loaded, dict):
return []
return [
str(item["label"])
for item in loaded.get("list", [])
if isinstance(item, dict) and "label" in item
]
except (OSError, TypeError, ValueError):
return []
def process_copy_group(
workspace: EvaluationWorkspace,
group_key: str,
files: list[Path],
*,
client,
labels_text: str,
names_text: str,
valid_labels: set[str],
valid_names: set[str],
overwrite: bool,
sleep: Sleep = time.sleep,
target_interval: float = TARGET_INTERVAL,
) -> int:
"""Process one student's image parts sequentially to preserve context."""
accumulated_labels: list[str] = []
generated = 0
for image_file in files:
started = time.monotonic()
output_json = workspace.copies_dir / f"{image_file.stem}.json"
if output_json.exists() and not overwrite:
print(f"[{group_key}] Skipping {image_file.name}, output exists.")
accumulated_labels.extend(_existing_context(output_json))
continue
print(
f"[{group_key}] Processing {image_file.name} with "
f"{len(accumulated_labels)} accumulated labels..."
)
attempt = 0
label_retry_count = 0
wrong_labels: list[str] = []
while True:
if attempt > 0:
sleep(10 * attempt)
try:
text_context_file, text_context = closest_text_context(
workspace, wrong_labels
)
if text_context_file:
print(
f"[{group_key}] Retry context for {image_file.name}: "
f"{text_context_file.relative_to(workspace.root)}"
)
request_seed = max(0, label_retry_count - 1)
contents, request_config = generate_request(
image_file,
labels_text,
names_text,
accumulated_labels,
wrong_labels,
text_context,
text_context_file,
seed=request_seed,
)
response = client.models.generate_content(
model=MODEL_ID,
contents=contents,
config=request_config,
)
annotation = AnnotationData.model_validate_json(response.text)
unknown = [
item.label
for item in annotation.list
if item.label not in valid_labels
]
if unknown:
print(
f"Error: {image_file.name} contained unknown labels: "
f"{unknown}"
)
unique_unknown = list(dict.fromkeys(unknown))
if (
label_retry_count >= 2
and set(unique_unknown) == set(wrong_labels)
):
for item in annotation.list:
if item.label in unique_unknown:
item.label = f"??{item.label}"
print(
f"Warning: {image_file.name} repeated the same unknown "
"label(s) on the third try; keeping them with a ?? prefix."
)
else:
wrong_labels = unique_unknown
label_retry_count += 1
attempt += 1
continue
if annotation.name not in valid_names:
print(
f"Error: {image_file.name} returned unknown name: "
f"{annotation.name}"
)
if attempt == 0:
attempt += 1
continue
annotation.name = "Unknown"
annotation.list = sort_boxes_for_image(
workspace, image_file, annotation.list
)
atomic_write_json(output_json, annotation.model_dump())
accumulated_labels.extend(box.label for box in annotation.list)
generated += 1
break
except KeyboardInterrupt:
raise
except Exception as exc: # noqa: BLE001 - remote API retry boundary
print(
f"Error processing {image_file.name}: {exc}\n"
"\tIt will be retried."
)
attempt += 1
sleep(max(0.0, target_interval - (time.monotonic() - started)))
return generated
def run(
workspace: EvaluationWorkspace,
targets: list[Path],
*,
overwrite: bool = False,
client=None,
sleep: Sleep = time.sleep,
max_workers: int = 12,
) -> ExitCode:
workspace.require_files("labels")
images, warnings = selected_images(workspace, targets)
for warning in warnings:
print(f"Warning: {warning}")
if not images:
return ExitCode.PARTIAL
all_labels = read_all_labels(workspace.root)
labels_text = "\n".join(all_labels) + "\n"
names_path = workspace.names_file()
if not names_path.is_file():
raise CliError(f"Names file not found: {names_path}", ExitCode.INVALID_WORKSPACE)
names_text = names_path.read_text(encoding="utf-8")
valid_names = {
line.strip() for line in names_text.splitlines() if line.strip()
} | {"Unknown", "Continued"}
if client is None:
client = genai.Client(api_key=api_key)
groups = group_images(images)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(
process_copy_group,
workspace,
group_key,
files,
client=client,
labels_text=labels_text,
names_text=names_text,
valid_labels=set(all_labels),
valid_names=valid_names,
overwrite=overwrite,
sleep=sleep,
)
for group_key, files in groups.items()
]
for future in futures:
future.result()
return ExitCode.PARTIAL if warnings else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Detect handwritten question labels with Gemini")
parser.add_argument(
"additional_targets",
nargs="*",
type=Path,
help="Additional copy PDFs or Cutleft images from the same evaluation",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Regenerate JSON outputs that already exist",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(
args, repository=Path(__file__).resolve().parents[2]
)
targets = [target]
for additional in args.additional_targets:
resolved = additional.expanduser().resolve()
if not resolved.exists():
raise CliError(
f"Target does not exist: {resolved}",
ExitCode.INVALID_WORKSPACE,
)
additional_workspace = EvaluationWorkspace.discover(
resolved, repository=workspace.repository
)
if additional_workspace.root != workspace.root:
raise CliError(
"All targets must belong to the same evaluation",
ExitCode.INVALID_ARGUMENTS,
)
targets.append(resolved)
return run(workspace, targets, overwrite=args.overwrite)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
@@ -10,12 +10,14 @@ from pathlib import Path
from copienator import (
EvaluationWorkspace,
ExitCode,
configuration,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
from platform_utils import replace_with_link_or_copy, safe_filename
from copienator.platform import replace_with_link_or_copy, safe_filename
from copienator.return_answers import publish_answer_returns
ANNOTATION_CHOICES = ("BGnot", "Bnot", "Anot")
@@ -27,9 +29,20 @@ def build_parser() -> argparse.ArgumentParser:
choices=ANNOTATION_CHOICES,
help="Annotation directory to use",
)
parser.add_argument(
"--update",
action="store_true",
help=(
"Update only the individual images in existing A Rendre/answers "
"directories, matching folders by their trailing copy ID"
),
)
return parser
RETURN_COPY_ID = re.compile(r"\((\d+)\)$")
def _read_expected_names(workspace: EvaluationWorkspace) -> set[str]:
names_path = workspace.names_file()
if not names_path.exists():
@@ -45,6 +58,70 @@ def _read_expected_names(workspace: EvaluationWorkspace) -> set[str]:
}
def _annotation_source(
workspace: EvaluationWorkspace,
annotation_dir_name: str,
copy_id: str,
) -> Path | None:
selected = workspace.root / annotation_dir_name / f"Copie{copy_id}"
fallback = workspace.annotation_dir("simple") / f"Copie{copy_id}"
for candidate in (selected, fallback):
if (candidate / "score.json").is_file() and (
(candidate / "Concat.jpg").is_file()
or (candidate / "info.json").is_file()
):
return candidate
return None
def update_named_return_answers(
workspace: EvaluationWorkspace,
annotation_dir_name: str,
) -> ExitCode:
"""Refresh only answers/ in existing returns, preserving manual names."""
workspace.require_directories(annotation_dir_name, "A Rendre")
had_errors = False
found = False
for destination in sorted(workspace.return_dir.iterdir()):
if not destination.is_dir():
continue
match = RETURN_COPY_ID.search(destination.name)
if match is None:
print(
f"Warning: cannot identify a copy ID in {destination.name!r}; skipped",
file=sys.stderr,
)
had_errors = True
continue
found = True
copy_id = match.group(1)
source_folder = _annotation_source(
workspace, annotation_dir_name, copy_id
)
if source_folder is None:
print(
f"Warning: no annotation source found for Copie{copy_id}; skipped",
file=sys.stderr,
)
had_errors = True
continue
try:
publish_answer_returns(
workspace.root,
source_folder,
destination,
answers_only=True,
)
print(f"Updated answers for {destination.name} from Copie{copy_id}")
except (OSError, TypeError, ValueError) as exc:
print(f"Error updating answers for {destination.name}: {exc}", file=sys.stderr)
had_errors = True
if not found:
print("Warning: no identifiable student folders found in A Rendre", file=sys.stderr)
had_errors = True
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
def prepare_named_returns(
workspace: EvaluationWorkspace,
annotation_dir_name: str,
@@ -72,9 +149,6 @@ def prepare_named_returns(
had_errors = True
assigned_names: set[str] = set()
selected_annotations = workspace.root / annotation_dir_name
fallback_annotations = workspace.annotation_dir("simple")
for name, copy_ids in copies_map.items():
if name == "Unknown":
print(
@@ -90,34 +164,40 @@ def prepare_named_returns(
safe_name = safe_filename(name)
for copy_id in copy_ids:
selected = selected_annotations / f"Copie{copy_id}"
fallback = fallback_annotations / f"Copie{copy_id}"
source_folder = None
for candidate in (selected, fallback):
if (candidate / "Concat.jpg").exists() and (
candidate / "score.json"
).exists():
source_folder = candidate
break
source_folder = _annotation_source(
workspace, annotation_dir_name, copy_id
)
if source_folder is None:
continue
assigned_names.add(name)
destination = workspace.return_dir / f"{safe_name} ({copy_id})"
destination.mkdir(parents=True, exist_ok=True)
try:
publish_answer_returns(workspace.root, source_folder, destination)
except (OSError, TypeError, ValueError) as exc:
print(f"Error preparing answers for {destination.name}: {exc}", file=sys.stderr)
had_errors = True
continue
links = (
("Concat.jpg", f"{safe_name}.jpg"),
("Concat_F.pdf", f"{safe_name}.pdf"),
("score.json", "score.json"),
("Concat.jpg", f"{safe_name}.jpg", configuration.RETURN_JPEG_ENABLED),
("Concat_F.pdf", f"{safe_name}.pdf", configuration.RETURN_PDF_ENABLED),
("score.json", "score.json", True),
)
for source_name, destination_name in links:
for source_name, destination_name, enabled in links:
source = source_folder / source_name
if not source.exists():
continue
target = destination / destination_name
try:
if not enabled:
# Remove only the named return entry, never its link target.
target.unlink(missing_ok=True)
continue
if not source.exists():
target.unlink(missing_ok=True)
continue
method = replace_with_link_or_copy(
source,
destination / destination_name,
target,
prefer="symlink",
)
if method == "copy":
@@ -145,7 +225,10 @@ def run(
workspace: EvaluationWorkspace,
*,
annotation_dir: str,
update: bool = False,
) -> ExitCode:
if update:
return update_named_return_answers(workspace, annotation_dir)
return prepare_named_returns(workspace, annotation_dir)
@@ -157,6 +240,7 @@ def main(argv: Sequence[str] | None = None) -> int:
lambda args: run(
workspace_from_args(args, repository=Path.cwd()),
annotation_dir=args.annotation_dir,
update=args.update,
),
)
@@ -219,7 +219,7 @@ def create_jpg(identifier, group_index, group, root_dir):
print(f"Saved {output_path} with {len(group)} ({os.path.getsize(output_path)/1024/1024:.2f} MB)")
from utils import natural_key
from copienator.utils import natural_key
def process_identifier(identifier, files_info, output_dir):
@@ -275,3 +275,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__":
raise SystemExit(main())
+105
View File
@@ -0,0 +1,105 @@
import argparse
import shutil
import sys
from collections.abc import Sequence
from pathlib import Path
from copienator import (
EvaluationWorkspace,
ExitCode,
evaluation_parser,
execute,
workspace_from_args,
)
from copienator.configuration import IMPORT_DIR
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
def sync_annotated(
workspace: EvaluationWorkspace,
*,
annotation_dir_name: str,
import_dir: Path,
) -> ExitCode:
workspace.require_directories(annotation_dir_name)
annotation_dir = workspace.annotation_dir("refaire") if annotation_dir_name == "BRnot" else workspace.root / annotation_dir_name
session_id = workspace.refaire_session_id if annotation_dir_name == "BRnot" else None
prefix = f"{session_id}__" if session_id else ""
accepted = 0
annotated_dir = Path(import_dir).expanduser()
if not annotated_dir.is_dir():
print(f"Error: directory does not exist: {annotated_dir}", file=sys.stderr)
return ExitCode.INVALID_WORKSPACE
missing_targets = 0
annotated_files = sorted(
(
path
for path in annotated_dir.iterdir()
if path.is_file() and path.suffix.casefold() in {".pdf", ".jpg", ".jpeg"}
),
key=lambda path: path.name.casefold(),
)
for annotated_file in annotated_files:
if prefix and not annotated_file.stem.startswith(prefix):
print(f"Ignoring return from another pass: {annotated_file.name}")
continue
target_subdir = annotation_dir / annotated_file.stem.removeprefix(prefix)
if not target_subdir.is_dir():
print(f"Warning: directory not found: {target_subdir}", file=sys.stderr)
missing_targets += 1
else:
suffix = annotated_file.suffix.lower()
dest_file = target_subdir / f"Concat_annotated{suffix}"
print(f"Copying {annotated_file} to {dest_file}")
shutil.copy2(annotated_file, dest_file)
accepted += 1
if prefix and not accepted:
print(f"No returns for the active pass {session_id} were imported.")
return ExitCode.PARTIAL
return ExitCode.PARTIAL if missing_targets else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Import handwritten annotations from the tablet directory.")
parser.add_argument(
"annotation_dir",
nargs="?",
choices=ANNOTATION_DIRECTORIES,
default="BGnot",
help="Annotation directory receiving imported PDFs (default: BGnot)",
)
parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json")
return parser
def run(
workspace: EvaluationWorkspace,
*,
annotation_dir: str = "BGnot",
refaire: bool = False,
) -> ExitCode:
return sync_annotated(
workspace,
annotation_dir_name="BRnot" if refaire else annotation_dir,
import_dir=Path(IMPORT_DIR),
)
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: run(
workspace_from_args(args),
annotation_dir=args.annotation_dir,
refaire=args.refaire,
),
)
if __name__ == "__main__":
raise SystemExit(main())
+87
View File
@@ -0,0 +1,87 @@
import json
import sys
import concurrent.futures
from pathlib import Path
from copienator.commands import correction
def get_missing_tasks():
"""
Identifies tasks (groups) where NONE of the student IDs in that group
appear in the existing results for that label in correction.json.
"""
missing = []
# correction.results is already loaded from correction.json during 'from copienator.commands import correction'
# correction.tasks is populated with (filepath, label) during 'from copienator.commands import correction'
for task in correction.tasks:
file_path, label = task
# Find the group metadata file (Group_X.json) to know which IDs are inside
meta_path = Path(file_path).with_suffix('.json')
if not meta_path.exists():
print("Missing meta_path :", meta_path)
continue
with open(meta_path, 'r', encoding="utf-8") as f:
# group_data entries: [pid, ymin, ymax, width_ratio]
group_data = json.load(f)
pids_in_group = [str(item[0]) for item in group_data]
# Check correction.json results for this specific label
label_results = correction.results.get(label, [])
# Collect all student IDs that have already been processed for this label
covered_ids = set()
for result_list in label_results:
for entry in result_list:
covered_ids.add(str(entry.get('id')))
# Logic: Only process if EVERY ID in this group is missing from correction.json
if all(pid not in covered_ids for pid in pids_in_group):
missing.append(task)
return missing
def main(argv=None):
if argv:
print("missing-correction does not accept command-line arguments.", file=sys.stderr)
return 2
missing_tasks = get_missing_tasks()
print("\n Total nb of tasks : ", len(correction.tasks))
if not missing_tasks:
print("All groups are already present in correction.json. Nothing to do.")
return
print("\nThe following groups are missing from correction.json:")
for path, label in missing_tasks:
print(f" - [{label}] {path}")
confirm = input(f"\nFound {len(missing_tasks)} missing groups. Start processing? (y/N): ")
if confirm.lower() != 'y':
print("Aborted.")
return
print(f"Processing {len(missing_tasks)} tasks with {correction.NB_THREADS} threads...")
with concurrent.futures.ThreadPoolExecutor(max_workers=correction.NB_THREADS) as executor:
# Map tasks to the processing function defined in correction.py
futures = {executor.submit(correction.process_single_task, t): t for t in missing_tasks}
for future in concurrent.futures.as_completed(futures):
try:
# Handle potential sub-tasks (like label errors) generated during processing
new_generated_tasks = future.result()
if new_generated_tasks:
for nt in new_generated_tasks:
executor.submit(correction.process_single_task, nt)
except Exception as e:
t = futures[future]
print(f"Error processing {t[0]}: {e}")
print("\nProcessing complete.")
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,26 +1,98 @@
from __future__ import annotations
import argparse
import glob
import os
import re
import shutil
import sys
import tempfile
import tkinter as tk
import uuid
from collections.abc import Sequence
from pathlib import Path
from tkinter import messagebox
import fitz # PyMuPDF
import pymupdf # PyMuPDF
from PIL import Image, ImageDraw, ImageTk
from pypdf import PdfReader, PdfWriter
from config import PAGE_SPLITTER_KB
from platform_utils import launch_pdf_arranger
from copienator.configuration import PAGE_SPLITTER_KB
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
execute,
target_parser,
workspace_from_target,
)
from copienator.platform import launch_pdf_arranger
from copienator.copy_errors import marked_copy_paths
# Keep the new shortcut available with older personal configuration files.
PAGE_SPLITTER_KB = {"reverse_pages": "i", **PAGE_SPLITTER_KB}
# --- Constants ---
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
CM_TO_POINTS = (1 / 2.54) * 72
def list_pdf_files(directory):
l = list(reversed(sorted(glob.glob(os.path.join(directory, "*.pdf")))))
return [u for u in l if "enonce" not in u]
def list_pdf_files(directory: str | Path) -> list[Path]:
paths = sorted(Path(directory).glob("*.pdf"), key=lambda path: path.name.casefold())
return [path for path in paths if "enonce" not in path.name.casefold()]
def _temporary_sibling(path: Path, purpose: str) -> Path:
return path.with_name(f".{path.name}.{purpose}.{uuid.uuid4().hex}.tmp")
def commit_processed_pdf(
workspace: EvaluationWorkspace,
original_path: Path,
generated_path: Path,
) -> Path:
"""Commit a processed copy and its original backup with rollback."""
backup_path = workspace.original_copies_dir / original_path.name
output_path = workspace.copies_dir / original_path.name
workspace.original_copies_dir.mkdir(parents=True, exist_ok=True)
workspace.copies_dir.mkdir(parents=True, exist_ok=True)
staged_backup = None
if original_path.resolve() != backup_path.resolve():
staged_backup = _temporary_sibling(backup_path, "new-original")
shutil.copy2(original_path, staged_backup)
saved_backup = _temporary_sibling(backup_path, "old-original")
saved_output = _temporary_sibling(output_path, "old-output")
backup_replaced = False
output_replaced = False
try:
if staged_backup is not None:
if backup_path.exists():
backup_path.replace(saved_backup)
staged_backup.replace(backup_path)
backup_replaced = True
if output_path.exists():
output_path.replace(saved_output)
generated_path.replace(output_path)
output_replaced = True
if original_path.resolve() not in {
backup_path.resolve(),
output_path.resolve(),
}:
original_path.unlink()
except Exception:
if output_replaced and output_path.exists():
output_path.unlink()
if saved_output.exists():
saved_output.replace(output_path)
if backup_replaced and backup_path.exists():
backup_path.unlink()
if saved_backup.exists():
saved_backup.replace(backup_path)
raise
finally:
for temporary in (staged_backup, saved_backup, saved_output):
if temporary is not None and temporary.exists():
temporary.unlink()
return output_path
class PDFPreviewer:
@@ -30,26 +102,36 @@ class PDFPreviewer:
return False
self.pdf_path = self.inputs.pop()
self.file_rotation = 0
self.base_name = os.path.splitext(os.path.basename(self.pdf_path))[0]
self.split_dir = f"{self.base_name}_split"
self.reorder_dir = f"{self.base_name}_reorder"
# Create a temporary output file
self.final_file = f"{self.base_name}_temp.pdf"
self.base_name = self.pdf_path.stem
self._temporary_directory = tempfile.TemporaryDirectory(
prefix=f".{self.base_name}.page-splitter.",
dir=self.workspace.root,
)
working_dir = Path(self._temporary_directory.name)
self.split_dir = working_dir / "split"
self.reorder_dir = working_dir / "reorder"
self.final_file = working_dir / f"{self.base_name}.pdf"
self.current_page_index = 0
self.page_settings = []
self.processing = False # Flag to prevent multiple finish calls
try:
self.doc = fitz.open(self.pdf_path)
except Exception as e:
self.doc = pymupdf.open(self.pdf_path)
except (OSError, RuntimeError, ValueError) as e:
self.failed = True
self._temporary_directory.cleanup()
messagebox.showerror("Error", f"Failed to open PDF file: {e}")
self.master.destroy()
return
self.master.title(f"PDF Splitter - {os.path.basename(self.pdf_path)}")
self.master.title(f"PDF Splitter - {self.pdf_path.name}")
return True
def __init__(self, master, path):
def __init__(
self,
master: tk.Tk,
workspace: EvaluationWorkspace,
inputs: list[Path],
) -> None:
"""
Initializes the application.
@@ -57,40 +139,16 @@ class PDFPreviewer:
master (tk.Tk): The root Tkinter window.
pdf_path (str): The path to the input PDF file.
"""
if not os.path.exists(path):
messagebox.showerror("Error", f"File not found: {path}")
master.destroy()
return
if os.path.isdir(path):
self.inputs = list_pdf_files(path)
else:
# Check for existing original in backup and restore if found
dir_name = os.path.dirname(os.path.abspath(path))
file_name = os.path.basename(path)
if os.path.basename(dir_name) == "Copies":
dir_name = os.path.dirname(dir_name)
path = os.path.join(dir_name, file_name)
backup_path = os.path.join(dir_name, "Copies Originales", file_name)
if os.path.exists(backup_path):
try:
shutil.move(backup_path, path)
print(f"Restored original file from: {backup_path}")
except Exception as e:
messagebox.showerror("Error", f"Failed to restore original file: {e}")
master.destroy()
return
self.inputs = [path]
self.workspace = workspace
self.inputs = inputs
self.output_dir = None
self.master = master
self.num = 0
self.global_rotation = 0 # Rotation appliquée à tous les fichiers
self.history = []
self.failed = False
if not self.setup_next_file():
print(f"Aucun fichier PDF valide trouvé dans : {path}")
print(f"No PDF files found in {workspace.root}")
master.destroy()
return
@@ -112,6 +170,7 @@ class PDFPreviewer:
f"'{fmt('rotate_page')}': Rotate page 180°, '{fmt('rotate_all_pages')}' : rotate all pages, '{fmt('rotate_all_files')}' : rotate all files\n"
f"{fmt('keep_left')} {fmt('next_page')} {fmt('discard_page')} {fmt('keep_right')} {fmt('keep_as_is')}: keep left, next page, keep none, keep right, keep as is\n"
f"{fmt('send_end')}: send page to end, '{fmt('arranger')}': pdf arranger, '{fmt('restart_file')}': restart file, '{fmt('prev_file')}': previous file\n"
f"'{fmt('reverse_pages')}': reverse page order and restart from the new first page\n"
)
self.info_label = tk.Label(master, text=instructions, justify=tk.LEFT)
@@ -138,6 +197,7 @@ class PDFPreviewer:
"next_page": self.confirm_and_next_page,
"discard_page": self.discard_page,
"send_end": self.send_page_end,
"reverse_pages": self.reverse_pages,
"restart_file": self.restart_current_file,
"arranger": self.start_arranger,
"prev_file": self.go_to_previous_file,
@@ -217,7 +277,7 @@ class PDFPreviewer:
self.current_zoom = min(zoom_x, zoom_y) * 0.98
# --- Render Page ---
mat = fitz.Matrix(self.current_zoom, self.current_zoom)
mat = pymupdf.Matrix(self.current_zoom, self.current_zoom)
pix = page.get_pixmap(matrix=mat, alpha=False)
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
@@ -248,8 +308,8 @@ class PDFPreviewer:
# Re-open the file from disk to reset changes (like moved pages)
try:
self.doc = fitz.open(self.pdf_path)
except Exception as e:
self.doc = pymupdf.open(self.pdf_path)
except (OSError, RuntimeError, ValueError) as e:
messagebox.showerror("Error", f"Failed to reopen PDF file: {e}")
self.master.destroy()
return
@@ -265,6 +325,16 @@ class PDFPreviewer:
self.load_page()
def reverse_pages(self, event=None):
"""Reverse the current document and discard earlier page decisions."""
if self.processing or not len(self.doc):
return
self.doc.select(list(reversed(range(len(self.doc)))))
self.current_page_index = 0
self.page_settings = []
self._initialize_current_page_settings()
self.load_page()
def move_line_left(self, event=None):
"""Moves the split line to the left."""
self.current_line_x = max(0, self.current_line_x - CM_TO_POINTS / 2)
@@ -328,7 +398,8 @@ class PDFPreviewer:
self._initialize_current_page_settings()
self.load_page()
else:
self.finish_and_process()
if not self.finish_and_process():
return
self.history.append(self.pdf_path)
if self.setup_next_file():
self._initialize_current_page_settings()
@@ -336,59 +407,24 @@ class PDFPreviewer:
else:
self.master.destroy()
def finish_and_process(self):
"""Starts the PDF splitting process and moves files."""
self.split_pdf()
# print("Debug : ", self.page_settings)
# input("Splitting done. Continue ?")
self.reorder_pdfs()
# input("Reorder done. Continue ?")
self.concate_files()
# Logic to move original to backup and replace with new file
def finish_and_process(self) -> bool:
"""Render and transactionally install the processed PDF."""
try:
abs_path = os.path.abspath(self.pdf_path)
dir_name = os.path.dirname(abs_path)
file_name = os.path.basename(abs_path)
backup_dir = os.path.join(dir_name, "Copies Originales")
copies_dir = os.path.join(dir_name, "Copies")
os.makedirs(backup_dir, exist_ok=True)
os.makedirs(copies_dir, exist_ok=True)
backup_path = os.path.join(backup_dir, file_name)
copies_path = os.path.join(copies_dir, file_name)
# Remove backup if it already exists (overwrite)
if os.path.exists(backup_path):
os.remove(backup_path)
# Move the original file to "Copies Originales"
shutil.move(self.pdf_path, backup_path)
# Move the temp output file to replace the original
shutil.move(self.final_file, copies_path)
# print(f"Original moved to {backup_path}, new file saved at {self.pdf_path}")
except Exception as e:
messagebox.showerror("Error", f"Failed to move/replace files: {e}")
self.remove_dirs()
def _restore_original(self, path):
"""Restores the original file from the 'Copies Originales' backup."""
dir_name = os.path.dirname(os.path.abspath(path))
file_name = os.path.basename(path)
backup_path = os.path.join(dir_name, "Copies Originales", file_name)
if os.path.exists(backup_path):
try:
# Moving overwrites the generated PDF with the original backup
shutil.move(backup_path, path)
print(f"Restored original file from: {backup_path}")
except Exception as e:
print(f"Failed to restore original file: {e}")
self.split_pdf()
self.reorder_pdfs()
self.concate_files()
commit_processed_pdf(self.workspace, self.pdf_path, self.final_file)
except Exception as exc: # noqa: BLE001 - interactive boundary
self.failed = True
self.processing = False
print(f"Failed to process {self.pdf_path}: {exc}")
messagebox.showerror("Error", f"Failed to process PDF: {exc}")
self._temporary_directory.cleanup()
self.master.destroy()
return False
else:
self._temporary_directory.cleanup()
return True
def go_to_previous_file(self, event=None):
"""Goes back to the beginning of the previously completed file."""
@@ -398,14 +434,16 @@ class PDFPreviewer:
# Close the currently open document to avoid lock issues
if hasattr(self, 'doc'):
self.doc.close()
if hasattr(self, "_temporary_directory"):
self._temporary_directory.cleanup()
# 1. Push current file back onto the stack so it processes next
self.inputs.append(self.pdf_path)
# 2. Get the previous file, restore its original state, and push to stack
# 2. Reprocess the previous file from its preserved original backup
prev_file = self.history.pop()
self._restore_original(prev_file)
self.inputs.append(prev_file)
backup = self.workspace.original_copies_dir / Path(prev_file).name
self.inputs.append(backup if backup.is_file() else Path(prev_file))
# 3. Reload environment (setup_next_file will pop prev_file back off the stack)
self.setup_next_file()
@@ -426,13 +464,9 @@ class PDFPreviewer:
for pdf in pdf_files:
try:
os.remove(pdf)
except Exception as e:
except OSError as e:
print(f"Error deleting {pdf}: {e}")
def remove_dirs(self):
shutil.rmtree(self.split_dir)
shutil.rmtree(self.reorder_dir)
def split_pdf(self):
"""Splits each page of the PDF according to the saved settings."""
print("Starting PDF processing...")
@@ -447,7 +481,7 @@ class PDFPreviewer:
self.file_rotation + self.global_rotation) % 360
if keep == "as_is":
doc_full = fitz.open()
doc_full = pymupdf.open()
page_full = doc_full.new_page(width=page.rect.width, height=page.rect.height)
page_full.show_pdf_page(page_full.rect, self.doc, i)
page_full.set_rotation(rotation)
@@ -459,13 +493,13 @@ class PDFPreviewer:
# --- Create Left Part ---
if rotation == 0:
rect_left = fitz.Rect(0, 0, line_x, page.rect.height)
rect_left = pymupdf.Rect(0, 0, line_x, page.rect.height)
else:
rect_left = fitz.Rect(page.rect.width-line_x, 0, page.rect.width, page.rect.height)
rect_left = pymupdf.Rect(page.rect.width-line_x, 0, page.rect.width, page.rect.height)
if (keep == "both" or keep == "left") and line_x > 0:
doc_left = fitz.open()
doc_left = pymupdf.open()
page_left = doc_left.new_page(width=rect_left.width, height=rect_left.height)
page_left.show_pdf_page(page_left.rect, self.doc, i, clip=rect_left)
page_left.set_rotation(rotation)
@@ -476,11 +510,11 @@ class PDFPreviewer:
# --- Create Right Part ---
if rotation == 0:
rect_right = fitz.Rect(line_x, 0, page.rect.width, page.rect.height)
rect_right = pymupdf.Rect(line_x, 0, page.rect.width, page.rect.height)
else:
rect_right = fitz.Rect(0, 0, page.rect.width-line_x, page.rect.height)
rect_right = pymupdf.Rect(0, 0, page.rect.width-line_x, page.rect.height)
if (keep == "both" or keep == "right") and line_x < page.rect.width:
doc_right = fitz.open()
doc_right = pymupdf.open()
page_right = doc_right.new_page(width=rect_right.width, height=rect_right.height)
page_right.show_pdf_page(page_right.rect, self.doc, i, clip=rect_right)
page_right.set_rotation(rotation)
@@ -588,13 +622,58 @@ class PDFPreviewer:
print(f"Created merged PDF: {self.final_file}")
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python script_name.py <path_to_pdf_file>")
sys.exit(1)
def _selected_inputs(
workspace: EvaluationWorkspace,
target: Path,
) -> list[Path]:
if target.is_file():
if target.suffix.casefold() != ".pdf":
raise CliError(f"Target is not a PDF: {target}", ExitCode.INVALID_ARGUMENTS)
backup = workspace.original_copies_dir / target.name
return [backup if backup.is_file() else target]
pdf_file_path = sys.argv[1]
directory = target
if target == workspace.copies_dir:
candidates = list_pdf_files(workspace.copies_dir)
candidates = [
(
workspace.original_copies_dir / path.name
if (workspace.original_copies_dir / path.name).is_file()
else path
)
for path in candidates
]
else:
candidates = list_pdf_files(directory)
return list(reversed(candidates))
def run(workspace: EvaluationWorkspace, target: Path, *, marked: bool = False) -> ExitCode:
inputs = list(reversed(marked_copy_paths(workspace, originals=True))) if marked else _selected_inputs(workspace, target)
if not inputs:
print(f"No PDF files found in {target}")
return ExitCode.SUCCESS
root = tk.Tk()
app = PDFPreviewer(root, pdf_file_path)
application = PDFPreviewer(root, workspace, inputs)
root.mainloop()
return ExitCode.FAILURE if application.failed else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Interactively split and reorder scanned PDF pages")
parser.add_argument("--marked", action="store_true", help="Process only copies flagged during margin review")
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target, marked=args.marked)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+246 -105
View File
@@ -1,24 +1,34 @@
import json
from __future__ import annotations
import argparse
import copy
import queue
import sys
import re
import threading
import tkinter as tk
from collections.abc import Sequence
from pathlib import Path
from tkinter import messagebox
from PIL import Image, ImageDraw, ImageFont, ImageTk
from platform_utils import open_path
print("o to open pdf, O original pdf, e to emacs part, p to go back, i to interro, click for coordinates")
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
execute,
read_json,
target_parser,
workspace_from_target,
)
from copienator.platform import open_path
from copienator.utils import natural_key, read_all_labels
# --- Configuration & Globals ---
padding = 60
valid_labels_set = None
# Queue payload: (pil_image, json_path, metadata)
# metadata is a dict: {'copie': str, 'part': int, 'schema': dict}
image_queue = queue.Queue(maxsize=5)
MISSING_LABEL_COLOR = "orange"
COMMON_MISSING_LABEL_COLOR = "#403a00"
COMMON_MISSING_THRESHOLD = 0.66
try:
font = ImageFont.truetype("DejaVuSans.ttf", size=30)
@@ -32,6 +42,19 @@ def page_number(b, nb_pages):
center_x = (b[1] + b[3]) // 2
return center_x // column_width
def sort_bounding_boxes(bounding_boxes, nb_pages):
"""Return label boxes in reading order: columns first, then top to bottom."""
return sorted(
bounding_boxes,
key=lambda entry: (
page_number(entry["box_2d"], nb_pages),
entry["box_2d"][0],
entry["box_2d"][1],
),
)
def convert_box2d(b, pn_ori, npn, tot_ori, tot_dest):
l = b.copy()
l[1] = (l[1] - (1000 // tot_ori) * (pn_ori-1)) * tot_ori // tot_dest\
@@ -55,16 +78,91 @@ def convert_list(l, group_id, json_schema):
ll.append(ee)
return ll
def prepare_image(image_path: str, bounding_boxes, all_labels, nb_pages, last_label_index):
def normalized_labels(entries):
return [
str(value["label"]).removeprefix("|").removesuffix("|")
for value in entries
if str(value["label"]) != "_"
]
def frequently_missing_labels(
copies_dir: Path,
all_labels: list[str],
threshold: float = COMMON_MISSING_THRESHOLD,
) -> set[str]:
"""Return labels absent from at least ``threshold`` of detected copies."""
labels_by_copy: dict[str, set[str]] = {}
for json_path in copies_dir.glob("*.json"):
match = re.fullmatch(r"(.+)_\d+", json_path.stem)
if match is None:
continue
try:
data = read_json(json_path)
entries = data["list"]
if not isinstance(entries, list):
continue
present = set(normalized_labels(entries))
except (OSError, KeyError, TypeError, ValueError):
continue
labels_by_copy.setdefault(match.group(1), set()).update(present)
copy_count = len(labels_by_copy)
if copy_count == 0:
return set()
return {
label
for label in all_labels
if sum(label not in present for present in labels_by_copy.values())
>= threshold * copy_count
}
def label_color(
label: str | None,
all_labels: list[str],
last_label_index: int,
common_missing: set[str],
) -> tuple[str, int]:
"""Choose a label color and return the updated chronological index."""
color = "black"
if not label or label not in all_labels:
return color, last_label_index
current_index = all_labels.index(label)
if current_index < last_label_index or (
last_label_index == -1 and current_index != 0
):
color = "red"
elif current_index > last_label_index + 1:
only_previous_is_missing = current_index == last_label_index + 2
previous_label = all_labels[current_index - 1]
color = (
COMMON_MISSING_LABEL_COLOR
if only_previous_is_missing and previous_label in common_missing
else MISSING_LABEL_COLOR
)
return color, current_index
def prepare_image(
image_path: str,
bounding_boxes,
all_labels,
nb_pages,
last_label_index,
common_missing: set[str] | None = None,
):
im = Image.open(image_path)
im.load()
width, height = im.size
new_im = Image.new(im.mode, (width + padding, height), "white")
new_im.paste(im, (0, 0))
draw = ImageDraw.Draw(new_im)
bounding_boxes.sort(key=lambda b: (page_number(b["box_2d"], nb_pages), b["box_2d"][0]))
common_missing = common_missing or set()
for bbox in bounding_boxes:
for bbox in sort_bounding_boxes(bounding_boxes, nb_pages):
raw_y_min = int(bbox["box_2d"][0] * height / 1000)
raw_x_min = int(bbox["box_2d"][1] * width / 1000)
raw_y_max = int(bbox["box_2d"][2] * height / 1000)
@@ -74,15 +172,13 @@ def prepare_image(image_path: str, bounding_boxes, all_labels, nb_pages, last_la
abs_y_max = min(height, raw_y_max + 10)
abs_x_max = min(width, raw_x_max + 10)
color = "black"
label = bbox.get("label")
if label and label in all_labels:
current_index = all_labels.index(label)
if current_index < last_label_index or (last_label_index == -1 and current_index != 0):
color = "red"
elif current_index > last_label_index + 1:
color = "orange"
last_label_index = current_index
color, last_label_index = label_color(
label,
all_labels,
last_label_index,
common_missing,
)
draw.rectangle(((abs_x_min, abs_y_min), (abs_x_max, abs_y_max)), outline=color, width=4)
if label:
@@ -94,13 +190,14 @@ def prepare_image(image_path: str, bounding_boxes, all_labels, nb_pages, last_la
# --- Processing Logic (Worker Thread) ---
def worker_thread(base_dir, files_to_process, all_labels):
def _worker_items(base_dir, files_to_process, all_labels, output_queue):
"""
Iterates through files, prepares VISUALS only, and puts metadata in queue.
Does NOT write final JSON files anymore.
"""
previous_copie = None
last_label_index = None
common_missing = frequently_missing_labels(base_dir / "Copies", all_labels)
for img_path in files_to_process:
json_path = base_dir / "Copies" / f"{img_path.stem}.json"
copie_part = int(img_path.stem[-2:])
@@ -111,9 +208,8 @@ def worker_thread(base_dir, files_to_process, all_labels):
json_schema_path = base_dir / 'Cutleft' / f"{copie}_schema.json"
try:
with open(json_schema_path, 'r') as f:
json_schema = json.load(f)
except:
json_schema = read_json(json_schema_path)
except (OSError, TypeError, ValueError):
print("No json_schema : ", json_schema_path)
continue
@@ -124,21 +220,27 @@ def worker_thread(base_dir, files_to_process, all_labels):
bb_list = []
json_name = ""
try:
with open(json_path, 'r') as f:
json_result = json.load(f)
json_result = read_json(json_path)
bb_list = json_result.get("list", [])
json_name = json_result.get("name", "")
except Exception as e:
except Exception as e: # noqa: BLE001 - malformed user-editable JSON
print(f"Warning: {json_path.name} is malformed! Loading blank. {e}")
# We do NOT skip; we continue so the user can fix it in the GUI
try:
print(f"Buffering {img_path.name}...")
(pil_image, last_label_index) = \
prepare_image(str(img_path), bb_list, all_labels, nb_pages, last_label_index)
prepare_image(
str(img_path),
bb_list,
all_labels,
nb_pages,
last_label_index,
common_missing,
)
error_msg = None
except Exception as e:
except Exception as e: # noqa: BLE001 - keep the item editable in the GUI
print(f"Error processing {img_path.name}: {e}")
pil_image = Image.open(str(img_path))
error_msg = str(e)
@@ -151,15 +253,24 @@ def worker_thread(base_dir, files_to_process, all_labels):
"error": error_msg
}
image_queue.put((pil_image, json_path, metadata))
output_queue.put((pil_image, json_path, metadata))
# Sentinel to indicate finished
image_queue.put((None, None, None))
def worker_thread(base_dir, files_to_process, all_labels, output_queue):
"""Prepare queue items and always terminate the GUI stream."""
failure = None
try:
_worker_items(base_dir, files_to_process, all_labels, output_queue)
except Exception as exc: # noqa: BLE001 - worker boundary
failure = str(exc)
print(f"Plotting worker failed: {exc}")
finally:
metadata = {"worker_error": failure} if failure else None
output_queue.put((None, None, metadata))
# --- GUI Logic (Main Thread) ---
class ImageViewer:
def __init__(self, root, base_dir):
def __init__(self, root, workspace, valid_labels, input_queue):
self.root = root
self.root.resizable(False, False) # If you resize, coordinates will be wrong
@@ -171,7 +282,10 @@ class ImageViewer:
root.geometry(f"+{x}+{y}")
self.base_dir = base_dir
self.workspace = workspace
self.base_dir = workspace.root
self.valid_labels = valid_labels
self.image_queue = input_queue
self.root.title("Bounding Box Viewer")
self.label = tk.Label(root, text="Waiting for images...")
self.label.pack(expand=True, fill="both")
@@ -192,6 +306,7 @@ class ImageViewer:
self.history = []
self.forward_stack = []
self.current_pil_image = None
self.failed = False
from config import PLOTTING_KB
@@ -202,7 +317,8 @@ class ImageViewer:
self.root.bind(PLOTTING_KB["open pdf"], self.on_open_pdf)
self.root.bind(PLOTTING_KB["open original pdf"], self.on_open_ori_pdf)
self.root.bind(PLOTTING_KB["open eval"], self.on_open_interro)
self.root.bind('<Escape>', lambda e: self.root.quit())
self.root.bind('<Escape>', lambda _event: self.close())
self.root.protocol("WM_DELETE_WINDOW", self.close)
self.label.bind('<Button-1>', self.on_click)
self.poll_queue()
@@ -214,10 +330,15 @@ class ImageViewer:
if self.forward_stack:
pil_image, json_path, metadata = self.forward_stack.pop()
else:
pil_image, json_path, metadata = image_queue.get_nowait()
pil_image, json_path, metadata = self.image_queue.get_nowait()
# Handle End of Stream
if pil_image is None:
if metadata and metadata.get("worker_error"):
self.failed = True
messagebox.showerror(
"Processing Error", metadata["worker_error"]
)
self.save_current_batch() # Save any remaining data
print("All images processed.")
self.root.quit()
@@ -229,7 +350,6 @@ class ImageViewer:
# Start new batch
self.active_copie_name = metadata["copie"]
self.accumulated_results = {"name": metadata["name"], "list": []}
self.history.clear()
self.display_image(pil_image, json_path, metadata)
except queue.Empty:
@@ -241,24 +361,33 @@ class ImageViewer:
if self.active_copie_name and self.accumulated_results:
main_json_path = self.base_dir / "Copies" / f"{self.active_copie_name}.json"
print(f"Writing aggregated result to {main_json_path}")
with open(main_json_path, 'w') as f:
json.dump(self.accumulated_results, f)
atomic_write_json(main_json_path, self.accumulated_results)
self.accumulated_results = None
def close(self):
self.root.quit()
def on_previous(self, event):
if self.is_viewing and self.history:
print("Going back to previous image...")
prev_pil, prev_json, prev_meta, num_added = self.history.pop()
# Undo the accumulation to prevent duplicates when we hit Enter again
if self.accumulated_results and num_added > 0:
self.accumulated_results["list"] = self.accumulated_results["list"][:-num_added]
(
prev_pil,
prev_json,
prev_meta,
previous_copie_name,
previous_results,
) = self.history.pop()
# Push current image to the forward stack so we don't lose it
self.forward_stack.append((self.current_pil_image,
self.current_json_path, self.current_meta))
# Restore the aggregation exactly as it was before validating the
# previous image. This also makes navigation across copies safe.
self.active_copie_name = previous_copie_name
self.accumulated_results = previous_results
# Display the previous image immediately
self.display_image(prev_pil, prev_json, prev_meta)
def display_image(self, pil_image, json_path, metadata):
@@ -285,32 +414,30 @@ class ImageViewer:
def on_enter(self, event):
if self.is_viewing:
print(f"Committing data for {self.current_json_path.name}...")
num_added = 0 # ADD THIS LINE
previous_copie_name = self.active_copie_name
previous_results = copy.deepcopy(self.accumulated_results)
try:
with open(self.current_json_path, 'r') as f:
current_data = json.load(f)
current_data = read_json(self.current_json_path)
items = current_data["list"]
# Perform the conversion now, post-edit
converted_items = convert_list(
current_data["list"],
items,
self.current_meta["part"],
self.current_meta["schema"]
)
labels = [v["label"] for v in current_data["list"]]
labels = [label for label in labels if label != "_"]
labels = [label[1:] for label in labels if label[0] == "|"]
labels = [label[:-1] for label in labels if label[-1] == "|"]
false_labels = [label for label in labels if label not in valid_labels_set]
labels = normalized_labels(items)
false_labels = [
label for label in labels if label not in self.valid_labels
]
if false_labels:
msg = f"Wrong label in {self.current_json_path.name}: {false_labels}\n\n\tPlease press 'e' to fix it, then press Enter again."
print(msg)
messagebox.showerror("Label Error", msg)
return
num_added = len(converted_items)
# Add to accumulator
if self.accumulated_results:
self.accumulated_results["list"].extend(converted_items)
@@ -318,15 +445,22 @@ class ImageViewer:
if "name" in current_data and current_data["name"] != "Continued":
self.accumulated_results["name"] = current_data["name"]
except Exception as e:
except Exception as e: # noqa: BLE001 - interactive validation boundary
# Warn user and STOP (do not advance to next image)
msg = f"Error reading {self.current_json_path.name}:\n\n{e}\n\nPlease press 'e' to fix it, then press Enter again."
print(msg)
messagebox.showerror("JSON Error", msg)
return # Abort advancement
self.history.append((self.current_pil_image, self.current_json_path,
self.current_meta, num_added))
self.history.append(
(
self.current_pil_image,
self.current_json_path,
self.current_meta,
previous_copie_name,
previous_results,
)
)
# Advance UI
self.is_viewing = False
@@ -387,51 +521,58 @@ class ImageViewer:
self.root.clipboard_clear()
self.root.clipboard_append(box_str)
from utils import natural_key, read_all_labels
def _selected_images(workspace: EvaluationWorkspace, target: Path) -> list[Path]:
workspace.require_directories("Cutleft", "Copies")
if target.is_file():
stem = target.stem
exact = workspace.cutleft_dir / f"{stem}.jpg"
if exact.is_file():
return [exact]
return sorted(
workspace.cutleft_dir.glob(f"{stem}_*.jpg"),
key=natural_key,
)
return sorted(workspace.cutleft_dir.glob("*.jpg"), key=natural_key)
def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
workspace.require_files("labels")
all_labels = read_all_labels(workspace.root)
files_to_process = _selected_images(workspace, target)
if not files_to_process:
print(f"No Cutleft images found for {target}")
return ExitCode.PARTIAL
print(
"o to open pdf, O original pdf, e to edit part, p to go back, "
"i to open the statement, click for coordinates"
)
input_queue = queue.Queue(maxsize=5)
worker = threading.Thread(
target=worker_thread,
args=(workspace.root, files_to_process, all_labels, input_queue),
daemon=True,
)
worker.start()
root = tk.Tk()
application = ImageViewer(root, workspace, set(all_labels), input_queue)
root.mainloop()
return ExitCode.PARTIAL if application.failed else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
return target_parser("Interactively verify detected label coordinates")
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target)
return execute(parser, argv, handle)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python plotting.py <directory_or_file>")
sys.exit(1)
input_path = Path(sys.argv[1])
files_to_process = []
if input_path.is_file():
# Correctly identify base_dir if we are in 'Copies' or 'Cutleft'
if input_path.parent.name in ["Copies", "Cutleft"]:
base_dir = input_path.parent.parent
else:
base_dir = input_path.parent
stem = input_path.stem
cutleft_dir = base_dir / "Cutleft"
img_path = cutleft_dir / f"{stem}.jpg"
if img_path.exists():
files_to_process = [img_path]
else:
# We're given something like Copie01.pdf, look for its split image parts
files_to_process = sorted(list(cutleft_dir.glob(f"{stem}_*.jpg")), key=natural_key)
else:
base_dir = input_path
cutleft_dir = base_dir / "Cutleft"
if not cutleft_dir.exists():
print(f"Error: {cutleft_dir} does not exist.")
sys.exit(1)
files_to_process = sorted(cutleft_dir.glob("*.jpg"))
try:
all_labels = read_all_labels(base_dir)
except FileNotFoundError:
all_labels = []
valid_labels_set = set(all_labels)
t = threading.Thread(target=worker_thread, args=(base_dir, files_to_process, all_labels))
t.daemon = True
t.start()
root = tk.Tk()
app = ImageViewer(root, base_dir)
root.mainloop()
raise SystemExit(main())
@@ -9,6 +9,7 @@ from typing import Any
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_bytes,
atomic_write_json,
evaluation_parser,
execute,
@@ -16,8 +17,12 @@ from copienator import (
workspace_from_args,
)
WORD_LIST_FILE = Path(__file__).with_name("liste_francais.txt")
WORD_LIST_FILE = Path(__file__).resolve().parents[1] / "data" / "liste_francais.txt"
ACCENT_PATTERN = re.compile(r"[éèêëàâäîïôöùûüçœÉÈÊËÀÂÄÎÏÔÖÙÛÜÇŒ]")
MATH_PATTERN = re.compile(
r"(\$\$.*?\$\$|\$.*?\$|\\\(.*?\\\)|\\\[.*?\\\])",
re.DOTALL,
)
def build_parser() -> argparse.ArgumentParser:
@@ -25,22 +30,37 @@ def build_parser() -> argparse.ArgumentParser:
def escape_latex_underscores(text: str) -> str:
r"""Escape underscores outside LaTeX math environments."""
math_pattern = re.compile(
r"(\$\$.*?\$\$|\$.*?\$|\\\(.*?\\\)|\\\[.*?\\\])",
re.DOTALL,
)
r"""Escape underscores outside math without double-escaping existing ones."""
def escape_plain(value: str) -> str:
# Collapse any existing escape run as well, making cleanup idempotent.
return re.sub(r"\\*_", lambda _match: r"\_", value)
parts: list[str] = []
last_end = 0
for match in math_pattern.finditer(text):
for match in MATH_PATTERN.finditer(text):
start, end = match.span()
parts.append(text[last_end:start].replace("_", r"\_"))
parts.append(escape_plain(text[last_end:start]))
parts.append(match.group(0))
last_end = end
parts.append(text[last_end:].replace("_", r"\_"))
parts.append(escape_plain(text[last_end:]))
return "".join(parts)
def normalize_overescaped_latex_commands(text: str) -> str:
r"""Collapse doubled command escapes inside LaTeX math environments.
Model responses occasionally contain ``\\mathbb`` after JSON decoding where
LaTeX requires ``\mathbb``. A doubled backslash followed by whitespace is a
legitimate row break (for example in ``cases``), so it must be preserved.
"""
def normalize_math(match: re.Match[str]) -> str:
return re.sub(r"\\\\(?=[A-Za-z{}])", r"\\", match.group(0))
return MATH_PATTERN.sub(normalize_math, text)
def build_lookup_map(word_list_path: Path = WORD_LIST_FILE) -> dict[str, str]:
words = word_list_path.read_text(encoding="utf-8").splitlines()
lookup: dict[str, str] = {}
@@ -67,8 +87,23 @@ def fix_hex_corruption_safe(text: str) -> str:
)
def some_other_replacements(text: str) -> str:
return text.replace("\neq", "\\neq").replace("\not", "\\not")
def repair_json_escape_corruption(text: str) -> str:
r"""Restore observed LaTeX commands consumed as JSON control escapes."""
replacements = (
("\x0crac", r"\frac"),
("\x0ceuille", r"\equiv"),
("\theta", r"\theta"),
("\times", r"\times"),
("\textbackslash ", "\\"),
("\negthinspace", r"\negthinspace"),
("\neq", r"\neq"),
("\not", r"\not"),
("", r"\ensuremath{\in}"),
("", r"\ensuremath{\subset}"),
)
for broken, repaired in replacements:
text = text.replace(broken, repaired)
return text
def clean_string(text: str, lookup: dict[str, str]) -> str:
@@ -79,7 +114,9 @@ def clean_string(text: str, lookup: dict[str, str]) -> str:
text = re.sub(r" \x00{1,2} ", " à ", text)
if "\x00" in text:
text = fast_fix(text, lookup).replace("\x00", "")
return escape_latex_underscores(some_other_replacements(text))
text = repair_json_escape_corruption(text)
text = normalize_overescaped_latex_commands(text)
return escape_latex_underscores(text)
def clean_obj(value: Any, lookup: dict[str, str]) -> Any:
@@ -104,6 +141,9 @@ def run(
lookup = build_lookup_map(word_list_path)
data = read_json(workspace.correction_file)
cleaned = clean_obj(data, lookup)
backup = workspace.root / "correction_precleanup.json"
atomic_write_bytes(backup, workspace.correction_file.read_bytes())
print(f"Original JSON backed up to {backup}")
atomic_write_json(workspace.correction_file, cleaned)
print(f"Fixed JSON saved to {workspace.correction_file}")
return ExitCode.SUCCESS
@@ -9,8 +9,8 @@ import numpy as np
from pdf2image import convert_from_path
from PIL import Image, ImageChops, ImageDraw, ImageFilter
import annotating
import utils
from copienator.commands import annotating
from copienator import configuration, utils
from copienator import (
EvaluationWorkspace,
ExitCode,
@@ -21,8 +21,10 @@ from copienator import (
workspace_from_args,
)
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.answer_info import build_answer_info
from copienator.annotation_data import AnnotationData, load_annotation_data
from copienator.filesystem import staged_files
from copienator.return_answers import save_return_answer_options
Image.MAX_IMAGE_PIXELS = None
@@ -68,10 +70,11 @@ def detect_checks_and_notes(
print(f" Resizing annotated PDF from {user_image.size} to {reference.size}")
user_image = user_image.resize(reference.size, Image.Resampling.LANCZOS)
difference = np.abs(
np.array(reference).astype(int) - np.array(user_image).astype(int)
).astype(np.uint8)
difference_gray = np.mean(difference, axis=2)
# Keep the full-size difference in uint8. Converting both tall group images
# to the platform ``int`` dtype used several gigabytes per scan worker.
difference = np.asarray(
ImageChops.difference(reference, user_image), dtype=np.uint8
)
keep_mask = Image.new("L", reference.size, 255)
mask_draw = ImageDraw.Draw(keep_mask)
actions: list[dict[str, Any]] = []
@@ -82,10 +85,14 @@ def detect_checks_and_notes(
x1, y1, x2, y2 = map(int, raw_box["global_box"])
x1, y1 = max(0, x1), max(0, y1)
x2, y2 = min(reference.width, x2), min(reference.height, y2)
region = difference_gray[y1 + 5 : y2 - 5, x1 + 5 : x2 - 5]
region = difference[y1 + 5 : y2 - 5, x1 + 5 : x2 - 5]
if region.size == 0:
continue
density = np.sum(region > 30) / region.size
# Preserve the previous mean-across-RGB threshold, but allocate its
# temporary float array only for the small checkbox region.
density = np.count_nonzero(np.mean(region, axis=2) > 30) / (
region.shape[0] * region.shape[1]
)
if density > 0.05:
actions.append(raw_box)
mask_draw.rectangle([x1 - 15, y1 - 15, x2 + 15, y2 + 15], fill=0)
@@ -94,13 +101,17 @@ def detect_checks_and_notes(
if raw_box.get("type") == "score" and raw_box.get("value") == 0.0:
mask_draw.rectangle([0, y1 - 10, reference.width, y2 + 10], fill=0)
del difference
reference_blur = reference.filter(ImageFilter.GaussianBlur(2))
user_blur = user_image.filter(ImageFilter.GaussianBlur(2))
diff_image = ImageChops.difference(reference_blur, user_blur).convert("L")
alpha = np.where(np.array(diff_image) > 50, 255, 0).astype(np.uint8)
final_alpha = np.minimum(alpha, np.array(keep_mask))
del reference_blur, user_blur
alpha = np.asarray(diff_image, dtype=np.uint8).copy()
np.greater(alpha, 50, out=alpha)
alpha *= np.uint8(255)
np.minimum(alpha, np.asarray(keep_mask, dtype=np.uint8), out=alpha)
notes = user_image.convert("RGBA")
notes.putalpha(Image.fromarray(final_alpha))
notes.putalpha(Image.fromarray(alpha))
return actions, notes
@@ -148,11 +159,14 @@ def apply_actions_and_regenerate(
raise TypeError(f"Expected a JSON object in {bnote_path}")
labels_data = data[student_id]
dirty_labels = apply_checkbox_actions(labels_data, actions, print)
apply_checkbox_actions(labels_data, actions, print)
score_path = output_dir / "score.json"
preserve_score_file = update_score and score_path.is_file()
if update_score:
dirty_labels |= apply_score_overrides(labels_data, output_dir / "score.json", print)
apply_score_overrides(labels_data, score_path, print)
scores = dict.fromkeys(all_labels, "")
answer_labels: list[str] = []
dirty_images: dict[str, Image.Image] = {}
concatenated: list[Image.Image] = []
filtered: list[Image.Image] = []
@@ -169,6 +183,8 @@ def apply_actions_and_regenerate(
content = labels_data[label]
result = content["result"]
scores[label] = str(result.get("score", 0))
if result.get("error") == "empty-answer":
continue
sub_note = None
if notes_layer is not None:
@@ -204,30 +220,63 @@ def apply_actions_and_regenerate(
body = sub_note.crop((0, old_header_height, width, height))
final_image.paste(body, (0, new_header_height), mask=body)
if label in dirty_labels or has_notes:
dirty_images[label] = final_image
dirty_images[label] = final_image
answer_labels.append(label)
concatenated.append(final_image)
if float(scores[label]) != 4.0 or result.get("feedback", []):
filtered.append(final_image)
concat_image = concatenate(concatenated)
filtered_image = concatenate(filtered)
with staged_files(output_dir) as staging:
with staged_files(output_dir, remove=("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf",
"touched.json", "answer_labels.json")) as staging:
for label, image in dirty_images.items():
image.save(staging / f"{label}.jpg")
atomic_write_json(staging / "score.json", scores)
if not preserve_score_file:
atomic_write_json(staging / "score.json", scores)
atomic_write_json(staging / "info.json", build_answer_info(
scores, labels_data, answer_labels
))
if concat_image is not None:
concat_image.save(staging / "Concat.jpg")
if filtered_image is not None:
filtered_image.save(staging / "Concat_F.jpg")
if preserve_score_file:
print(f" Preserved existing score.json in {output_dir}")
print(f" Saved regenerated files in {output_dir}")
return ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS
def run(workspace: EvaluationWorkspace, *, update_score: bool = False) -> ExitCode:
def run(
workspace: EvaluationWorkspace,
*,
update_score: bool = False,
return_answers_context: bool | None = None,
return_answers_question: bool | None = None,
return_answers_solution: bool | None = None,
) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label", "Bnot")
if configuration.RETURN_ANSWERS_ENABLED:
save_return_answer_options(
workspace.root,
context=(
configuration.RETURN_ANSWERS_CONTEXT
if return_answers_context is None
else return_answers_context
),
question=(
configuration.RETURN_ANSWERS_QUESTION
if return_answers_question is None
else return_answers_question
),
solution=(
configuration.RETURN_ANSWERS_SOLUTION
if return_answers_solution is None
else return_answers_solution
),
)
all_labels = utils.read_all_labels(workspace.root)
loaded = load_annotation_data(workspace)
for warning in loaded.warnings:
@@ -268,7 +317,28 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--update-score",
action="store_true",
help="Override generated scores with values from existing score.json files",
help=(
"Regenerate images with current statement/solution PDFs while "
"preserving and applying existing score.json values"
),
)
parser.add_argument(
"--return-answers-context",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_CONTEXT,
help="Include applicable context pages in individual answer exports",
)
parser.add_argument(
"--return-answers-question",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_QUESTION,
help="Include the current question PDF in individual answer exports",
)
parser.add_argument(
"--return-answers-solution",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_SOLUTION,
help="Include the current solution PDF in individual answer exports",
)
return parser
@@ -277,7 +347,13 @@ def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
return run(workspace_from_args(args), update_score=args.update_score)
return run(
workspace_from_args(args),
update_score=args.update_score,
return_answers_context=args.return_answers_context,
return_answers_question=args.return_answers_question,
return_answers_solution=args.return_answers_solution,
)
return execute(parser, argv, handle)
@@ -0,0 +1,681 @@
from __future__ import annotations
import argparse
import concurrent.futures
from collections import defaultdict
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from PIL import Image, ImageDraw
from copienator import configuration
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
evaluation_parser,
execute,
read_json,
utils,
workspace_from_args,
)
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.answer_info import build_answer_info
from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data
from copienator.commands import annotating
from copienator.commands.reading_annotations import (
concatenate,
detect_checks_and_notes,
has_significant_notes,
)
from copienator.filesystem import staged_files
from copienator.return_answers import save_return_answer_options
LabelNotes = dict[str, dict[str, Any]]
ScanResult = tuple[dict[str, list[dict[str, Any]]], dict[str, LabelNotes]]
SCAN_WORKERS = 2
def get_extra_pdfs_as_images(
root_dir: str | Path,
label: str,
annotating_module: Any,
all_labels: list[str],
) -> list[Image.Image]:
"""Convert the context, question and solution PDFs associated with a label."""
paths = [
*utils.pdf_images_of_contexts(root_dir, label, all_labels),
utils.pdf_image_of_enonce(root_dir, label),
utils.pdf_image_of_solution(root_dir, label),
]
images = []
for path in paths:
if path:
image, _, _ = annotating_module.make_base_image(path)
if image is not None:
images.append(image)
return images
def save_paginated_pdf(
image_groups: list[list[Image.Image]], output_path: Path
) -> None:
"""Paginate vertically concatenated image groups and save them as a PDF."""
non_empty = [group for group in image_groups if group]
if not non_empty:
return
max_width = max(image.width for group in non_empty for image in group)
max_page_height = int(max_width * 1.414 * 1.25)
border = int((0.2 / 2.54) * 100)
left_margin = int((0.3 / 2.54) * 100)
vertical_margin = int((0.2 / 2.54) * 100)
max_content_height = max_page_height - 2 * vertical_margin
pages: list[Image.Image] = []
page_images: list[Image.Image] = []
page_height = 0
def finish_page() -> None:
nonlocal page_images, page_height
if not page_images:
return
page = Image.new(
"RGB",
(max_width + left_margin, page_height + 2 * vertical_margin),
"white",
)
current_y = vertical_margin
for image in page_images:
page.paste(image, (left_margin, current_y))
current_y += image.height
pages.append(page)
page_images = []
page_height = 0
for group in non_empty:
processed: list[Image.Image] = []
for index, image in enumerate(group):
if index in (0, 1):
image = image.copy()
color = "black" if index == 0 else "blue"
ImageDraw.Draw(image).rectangle(
[0, 0, image.width - 1, image.height - 1],
outline=color,
width=border,
)
processed.append(image)
group_height = sum(image.height for image in processed)
if page_images and page_height + group_height > max_content_height:
finish_page()
page_images.extend(processed)
page_height += group_height
finish_page()
pages[0].save(
output_path,
"PDF",
resolution=100.0,
save_all=True,
append_images=pages[1:],
)
def _scan_annotation_directory(
directory: Path,
only_ids: set[str] | None = None,
default_student_id: str | None = None,
*,
required: bool = False,
) -> ScanResult:
bnote_path = directory / "bnote.json"
if not bnote_path.is_file():
raise FileNotFoundError(f"Missing {bnote_path}")
bnote = read_json(bnote_path)
if not isinstance(bnote, dict):
raise TypeError(f"Expected a JSON object in {bnote_path}")
images = [item for item in bnote.get("images", []) if isinstance(item, dict)]
if only_ids and not any(
str(item.get("id", default_student_id)) in only_ids for item in images
):
return {}, {}
actions, notes_image = detect_checks_and_notes(directory)
if notes_image is None:
if required:
raise ValueError(f"Could not read annotations in {directory}")
return {}, {}
actions_by_student: dict[str, list[dict[str, Any]]] = defaultdict(list)
notes_by_student: dict[str, LabelNotes] = defaultdict(dict)
for action in actions:
raw_student_id = action.get("student_id", default_student_id)
if raw_student_id is not None:
actions_by_student[str(raw_student_id)].append(action)
for image_info in images:
student_id = str(image_info.get("id", default_student_id or ""))
label = str(image_info.get("label", ""))
hmin = int(image_info.get("hmin", 0))
hmax = int(image_info.get("hmax", 0))
if student_id and label and hmax > hmin:
crop = notes_image.crop((0, hmin, notes_image.width, hmax))
if has_significant_notes(crop):
notes_by_student[student_id][label] = {
"img": crop,
"old_header_h": int(image_info.get("header_height", 0)),
}
return dict(actions_by_student), dict(notes_by_student)
def _merge_scan_result(
target_actions: dict[str, list[dict[str, Any]]],
target_notes: dict[str, LabelNotes],
result: ScanResult,
) -> None:
actions, notes = result
for student_id, student_actions in actions.items():
target_actions[student_id].extend(student_actions)
for student_id, student_notes in notes.items():
target_notes[student_id].update(student_notes)
def apply_actions_and_regenerate_grouped(
workspace: EvaluationWorkspace,
data: AnnotationData,
student_id: str,
actions: list[dict[str, Any]],
label_notes: LabelNotes,
all_labels: list[str],
*,
update_score: bool = False,
annotation_dir: str = "BGnot",
selected_labels: set[str] | None = None,
) -> tuple[ExitCode, str]:
"""Regenerate a copy, preserving reviewed images outside the redo selection."""
logs = [f"\nProcessing compilation for: Copie{student_id}"]
output_dir = workspace.root / annotation_dir / f"Copie{student_id}"
labels_data = data.get(student_id, {})
apply_checkbox_actions(labels_data, actions, logs.append)
score_path = output_dir / "score.json"
preserve_score_file = update_score and score_path.is_file()
if update_score:
apply_score_overrides(
labels_data, score_path, logs.append
)
selected_labels = selected_labels if selected_labels is not None else set()
simple_layout = None
simple_annotated = None
if selected_labels and annotation_dir == "Anot":
imported = next(
(
output_dir / name
for name in ("Concat_annotated.jpg", "Concat_annotated.jpeg")
if (output_dir / name).is_file()
),
None,
)
if imported is not None:
layout_path = output_dir / "refaire_simple_layout.json"
if layout_path.is_file():
simple_layout = read_json(layout_path)
else:
simple_layout = {"images": {}, "replaced": []}
y = 0
for label in sorted(labels_data, key=utils.natural_key):
path = output_dir / f"{label}.jpg"
if (
path.is_file()
and labels_data[label]["result"].get("error") != "empty-answer"
):
with Image.open(path) as saved:
simple_layout["images"][label] = [y, y + saved.height]
y += saved.height
with Image.open(imported) as saved:
simple_annotated = saved.convert("RGB").copy()
expected_height = max(
(bounds[1] for bounds in simple_layout["images"].values()), default=0
)
if simple_annotated.height != expected_height:
raise ValueError(
"Imported simple image height does not match the original copy layout"
)
old_scores = (
read_json(output_dir / "score.json")
if selected_labels and (output_dir / "score.json").is_file()
else {}
)
scores = dict.fromkeys(all_labels, "")
touched = dict.fromkeys(all_labels, False)
answer_labels: list[str] = []
dirty_images: dict[str, Image.Image] = {}
concat_images: list[Image.Image] = []
filtered_groups: list[list[Image.Image]] = []
incomplete = False
for label, content in sorted(
labels_data.items(), key=lambda item: utils.natural_key(item[0])
):
result = content["result"]
if (
selected_labels
and label not in selected_labels
and old_scores.get(label, "") != ""
):
result["score"] = old_scores[label]
scores[label] = str(result.get("score", 0))
touched[label] = False
if result.get("error") == "empty-answer":
continue
saved_image = output_dir / f"{label}.jpg"
if selected_labels and label not in selected_labels and saved_image.is_file():
with Image.open(saved_image) as saved:
final_image = saved.convert("RGB").copy()
if (
simple_annotated is not None
and label in simple_layout["images"]
and label not in simple_layout["replaced"]
):
hmin, hmax = simple_layout["images"][label]
final_image = simple_annotated.crop(
(0, hmin, simple_annotated.width, hmax)
)
dirty_images[label] = final_image
scores[label] = str(old_scores.get(label, scores[label]))
concat_images.append(final_image)
answer_labels.append(label)
# Keep previously reviewed content, including handwriting.
if annotation_dir == "BGnot":
extras = get_extra_pdfs_as_images(
workspace.root, label, annotating, all_labels
)
filtered_groups.append([*extras, final_image])
touched[label] = True
else:
filtered_groups.append([final_image])
continue
pdf_path = Path(content["pdf_path"])
if not pdf_path.is_file():
logs.append(f" Missing answer PDF: {pdf_path}")
incomplete = True
continue
base_image, _, _ = annotating.make_base_image(pdf_path)
final_image, new_header_height = annotating.compose_label_image(
base_image,
label,
result,
content["coordinates"][0],
with_error=False,
)
if final_image is None:
incomplete = True
continue
has_notes = False
if label in label_notes:
sub_note = label_notes[label]["img"]
old_header_height = int(label_notes[label]["old_header_h"])
has_notes = has_significant_notes(sub_note)
if has_notes:
width, height = sub_note.size
if old_header_height > 0:
header = sub_note.crop(
(0, 0, width, min(height, old_header_height))
)
final_image.paste(header, (0, 0), mask=header)
if height > old_header_height:
body = sub_note.crop((0, old_header_height, width, height))
final_image.paste(body, (0, new_header_height), mask=body)
# Persist every final block, including unchanged answers, for returns.
dirty_images[label] = final_image
answer_labels.append(label)
concat_images.append(final_image)
feedbacks = result.get("feedback", [])
perfect = float(scores[label]) >= 4.0 and all(
feedback.get("to_delete", False) for feedback in feedbacks
)
if not perfect or has_notes:
extras = (
get_extra_pdfs_as_images(workspace.root, label, annotating, all_labels)
if annotation_dir == "BGnot"
else []
)
filtered_groups.append([*extras, final_image])
touched[label] = annotation_dir == "BGnot"
concat_image = concatenate(concat_images)
if incomplete:
return ExitCode.PARTIAL, "\n".join(logs)
with staged_files(output_dir, remove=("Concat.jpg", "Concat_F.pdf", "Concat_F.jpg",
"touched.json", "answer_labels.json")) as staging:
if simple_layout is not None:
simple_layout["replaced"] = sorted(
set(simple_layout["replaced"]) | selected_labels
)
atomic_write_json(staging / "refaire_simple_layout.json", simple_layout)
for label, image in dirty_images.items():
image.save(staging / f"{label}.jpg")
if not preserve_score_file:
atomic_write_json(staging / "score.json", scores)
atomic_write_json(staging / "info.json", build_answer_info(
scores, labels_data, answer_labels, touched
))
if concat_image is not None:
concat_image.save(staging / "Concat.jpg")
if filtered_groups:
if annotation_dir == "BGnot":
save_paginated_pdf(filtered_groups, staging / "Concat_F.pdf")
else:
filtered_image = concatenate(
[image for group in filtered_groups for image in group]
)
filtered_image.save(staging / "Concat_F.jpg")
if preserve_score_file:
logs.append(f" Preserved existing score.json in {output_dir}")
logs.append(f" Saved regenerated files in {output_dir}")
status = ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS
return status, "\n".join(logs)
def _read_refaire(
workspace: EvaluationWorkspace,
) -> tuple[RefaireList, dict[str, list[str]]]:
loaded = read_json(workspace.refaire_file)
if not isinstance(loaded, list):
raise TypeError("refaire.json must contain a JSON array")
entries: RefaireList = []
by_student: dict[str, list[str]] = {}
for entry in loaded:
if (
not isinstance(entry, list)
or len(entry) != 2
or not isinstance(entry[1], list)
):
raise TypeError(f"Malformed refaire entry: {entry!r}")
copy_name, labels = entry
student_id = str(copy_name).removeprefix("Copie")
normalized_labels = [str(label) for label in labels]
entries.append([str(copy_name), normalized_labels])
by_student[student_id] = normalized_labels
return entries, by_student
def _scan_redo_annotations(
directory: Path,
expected: dict[str, set[str]],
) -> tuple[dict[str, list[dict[str, Any]]], dict[str, LabelNotes], set[str]]:
"""Read either grouped or per-copy redo PDFs using their student/label metadata."""
actions: dict[str, list[dict[str, Any]]] = defaultdict(list)
notes: dict[str, LabelNotes] = defaultdict(dict)
seen: dict[str, set[str]] = defaultdict(set)
incomplete: set[str] = set()
plans = []
required = ("checkboxes.json", "Reference.jpg", "Concat_annotated.pdf")
for path in sorted(directory.iterdir()):
if not path.is_dir():
continue
default_id = (
path.name.removeprefix("Copie") if path.name.startswith("Copie") else None
)
try:
metadata = read_json(path / "bnote.json")
pairs = [
(str(item.get("id", default_id)), str(item["label"]))
for item in metadata["images"]
]
except (OSError, ValueError, TypeError, KeyError) as exc:
print(f"Warning: unreadable redo metadata in {path}: {exc}")
incomplete.update(expected)
continue
students = {
student_id for student_id, _label in pairs if student_id in expected
}
if not students:
continue
for student_id, label in pairs:
if student_id not in expected:
continue
if label in seen[student_id]:
incomplete.add(student_id)
seen[student_id].add(label)
if any(not (path / name).is_file() for name in required):
print(f"Warning: missing returned redo inputs in {path}")
incomplete.update(students)
else:
plans.append((path, default_id, students))
for student_id, labels in expected.items():
if seen[student_id] != labels:
print(
f"Warning: redo labels do not match refaire.json for Copie{student_id}; regenerate BRnot"
)
incomplete.add(student_id)
for path, default_id, students in plans:
if students <= incomplete:
continue
try:
result = _scan_annotation_directory(
path, default_student_id=default_id, required=True
)
_merge_scan_result(actions, notes, result)
except (OSError, ValueError, TypeError) as exc:
print(f"Warning: could not read redo annotations in {path}: {exc}")
incomplete.update(students)
return dict(actions), dict(notes), incomplete
def run(
workspace: EvaluationWorkspace,
*,
refaire: bool = False,
update_score: bool = False,
annotation_dir: str = "BGnot",
return_answers_context: bool | None = None,
return_answers_question: bool | None = None,
return_answers_solution: bool | None = None,
) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label", annotation_dir)
refaire_list: RefaireList | None = None
refaire_by_student: dict[str, list[str]] = {}
if refaire:
workspace.require_files("refaire.json")
workspace.require_directories("BRnot")
refaire_list, refaire_by_student = _read_refaire(workspace)
if configuration.RETURN_ANSWERS_ENABLED:
save_return_answer_options(
workspace.root,
context=(
configuration.RETURN_ANSWERS_CONTEXT
if return_answers_context is None
else return_answers_context
),
question=(
configuration.RETURN_ANSWERS_QUESTION
if return_answers_question is None
else return_answers_question
),
solution=(
configuration.RETURN_ANSWERS_SOLUTION
if return_answers_solution is None
else return_answers_solution
),
)
all_labels = utils.read_all_labels(workspace.root)
loaded = load_annotation_data(workspace)
if refaire_list:
# Add explicitly requested answers without filtering out the rest of a copy.
selected_data = load_annotation_data(workspace, refaire_list=refaire_list)
for student_id, labels in selected_data.data.items():
loaded.data.setdefault(student_id, {}).update(labels)
for warning in loaded.warnings:
print(f"Warning: {warning}")
if not loaded.data:
print("No annotation data found.")
return ExitCode.PARTIAL
actions_by_student: dict[str, list[dict[str, Any]]] = defaultdict(list)
notes_by_student: dict[str, LabelNotes] = defaultdict(dict)
only_ids = set(refaire_by_student) or None
group_dirs = [
path
for path in (workspace.root / annotation_dir).iterdir()
if annotation_dir == "BGnot"
and path.is_dir()
and not path.name.startswith("Copie")
]
# Each worker decodes a full-height returned group and its reference image.
# Keep this stage deliberately narrow; answer regeneration below has its own
# parallel executor and a much smaller per-task memory footprint.
with concurrent.futures.ThreadPoolExecutor(max_workers=SCAN_WORKERS) as executor:
futures = [
executor.submit(_scan_annotation_directory, path, only_ids)
for path in group_dirs
]
for future in concurrent.futures.as_completed(futures):
_merge_scan_result(actions_by_student, notes_by_student, future.result())
if annotation_dir == "Bnot":
for student_id in refaire_by_student:
directory = workspace.root / annotation_dir / f"Copie{student_id}"
if directory.is_dir():
_merge_scan_result(
actions_by_student,
notes_by_student,
_scan_annotation_directory(
directory, default_student_id=student_id
),
)
skipped_students: set[str] = set()
if refaire:
expected = {
student_id: set(labels or loaded.data.get(student_id, {}))
for student_id, labels in refaire_by_student.items()
}
redo_actions, redo_notes, skipped_students = _scan_redo_annotations(
workspace.annotation_dir("refaire"), expected
)
for student_id, selected in expected.items():
if student_id in skipped_students:
continue
actions_by_student[student_id] = [
action
for action in actions_by_student[student_id]
if str(action.get("label")) not in selected
]
for label in selected:
notes_by_student[student_id].pop(label, None)
actions_by_student[student_id].extend(
action
for action in redo_actions.get(student_id, [])
if str(action.get("label")) in selected
)
notes_by_student[student_id].update(
{
label: note
for label, note in redo_notes.get(student_id, {}).items()
if label in selected
}
)
status = (
ExitCode.PARTIAL if loaded.warnings or skipped_students else ExitCode.SUCCESS
)
student_ids = (
list(refaire_by_student)
if refaire
else sorted(loaded.data, key=utils.natural_key)
)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
futures = {
executor.submit(
apply_actions_and_regenerate_grouped,
workspace,
loaded.data,
student_id,
actions_by_student[student_id],
notes_by_student[student_id],
all_labels,
update_score=update_score,
annotation_dir=annotation_dir,
selected_labels=(
set(refaire_by_student[student_id] or loaded.data[student_id])
if refaire
else None
),
): student_id
for student_id in student_ids
if student_id in loaded.data and student_id not in skipped_students
}
for future in concurrent.futures.as_completed(futures):
result, output = future.result()
print(output)
if result != ExitCode.SUCCESS:
status = ExitCode.PARTIAL
return status
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Read grouped annotations and regenerate copies")
parser.add_argument(
"--annotation-dir",
choices=("BGnot", "Bnot", "Anot"),
default="BGnot",
help="Original annotation directory for --refaire (default: BGnot)",
)
parser.add_argument(
"--refaire",
action="store_true",
help="Use refaire.json and merge annotations from BRnot",
)
parser.add_argument(
"--update-score",
action="store_true",
help=(
"Regenerate images with current statement/solution PDFs while "
"preserving and applying existing score.json values"
),
)
parser.add_argument(
"--return-answers-context",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_CONTEXT,
help="Include applicable context pages in individual answer exports",
)
parser.add_argument(
"--return-answers-question",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_QUESTION,
help="Include the current question PDF in individual answer exports",
)
parser.add_argument(
"--return-answers-solution",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_SOLUTION,
help="Include the current solution PDF in individual answer exports",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
if args.annotation_dir != "BGnot" and not args.refaire:
parser.error("--annotation-dir requires --refaire")
return run(
workspace_from_args(args),
refaire=args.refaire,
update_score=args.update_score,
annotation_dir=args.annotation_dir,
return_answers_context=args.return_answers_context,
return_answers_question=args.return_answers_question,
return_answers_solution=args.return_answers_solution,
)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
@@ -9,6 +9,7 @@ from pathlib import Path
from typing import Any
from pypdf import PdfWriter
from copienator.pdf_cut import split_pdf
from copienator import (
CliError,
@@ -22,7 +23,8 @@ from copienator import (
)
OPERATORS = ("-x", "->", "x>", "ss", "sx", "xx", "xs")
OPERATOR_PATTERN = re.compile(r"\s+(-x|->|x>|ss|sx|xx|xs)\s+")
CUT_PATTERN = re.compile(r"c\{(\d+(?:\.\d+)?)\}([12])([x>])")
OPERATOR_PATTERN = re.compile(r"\s+(-x|->|x>|ss|sx|xx|xs|c\{\d+(?:\.\d+)?\}[12][x>])\s+")
COPY_PATTERN = re.compile(r"Copie(\d+)\s+(.+)")
@@ -34,6 +36,11 @@ class ManualInstruction:
new_label: str
pipe_first: bool
@property
def cut(self) -> tuple[float, int] | None:
match = CUT_PATTERN.fullmatch(self.operator)
return (float(match[1]), int(match[2])) if match else None
@property
def should_merge(self) -> bool:
return self.operator.endswith(">")
@@ -48,10 +55,14 @@ def build_parser() -> argparse.ArgumentParser:
def parse_instructions(path: Path) -> list[ManualInstruction]:
return parse_instruction_text(path.read_text(encoding="utf-8"))
def parse_instruction_text(text: str) -> list[ManualInstruction]:
instructions: list[ManualInstruction] = []
malformed: list[int] = []
for line_number, raw_line in enumerate(
path.read_text(encoding="utf-8").splitlines(), start=1
text.splitlines(), start=1
):
line = raw_line.strip()
if not line or line.startswith("###"):
@@ -64,7 +75,10 @@ def parse_instructions(path: Path) -> list[ManualInstruction]:
right = line[operator_match.end() :].strip()
copy_match = COPY_PATTERN.fullmatch(left)
new_label = right.strip("|").strip()
if copy_match is None or not new_label:
cut_match = CUT_PATTERN.fullmatch(operator_match.group(1))
if (copy_match is None or not new_label
or (cut_match and (not 0 < float(cut_match[1]) < 100
or copy_match.group(2).strip() == new_label))):
malformed.append(line_number)
continue
instructions.append(
@@ -97,15 +111,25 @@ def set_suffix_and_clean_error(
item["result"]["suffix"] = suffix
error = item["result"].get("error", "")
if new_label_target:
if f"wrg-lbl:{new_label_target}?delayed" in error:
item["result"]["error"] = (
f"wrg-lbl-moved-to:{new_label_target}"
)
if f"(delayed){new_label_target}" in error:
item["result"]["error"] = error.replace(
f"(delayed){new_label_target}",
f"(->){new_label_target}",
)
# This instruction acknowledges this source/target conflict,
# including decisions to keep/discard PDFs without merging.
# Leave other pending targets (and other copies) untouched.
result = item["result"]
if "delayed" in result:
pending = [entry for entry in result["delayed"]
if entry not in (["wrong-label", new_label_target],
["add-label", new_label_target])]
if pending:
result["delayed"] = pending
else:
result.pop("delayed")
if error in {f"wrg-lbl:{new_label_target}?",
f"wrg-lbl:{new_label_target}?delayed",
f"wrg-lbl:{new_label_target}?exists"}:
error = f"wrg-lbl-moved-to:{new_label_target}"
error = error.replace(f"(delayed){new_label_target}", f"(->){new_label_target}")
error = error.replace(f"(->){new_label_target}?", f"(->){new_label_target}")
result["error"] = error
def get_actual_pdf(copies_dir: Path, copy_id: str, label: str) -> Path:
@@ -154,6 +178,9 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
raise CliError("correction.json must contain a JSON object")
results: dict[str, Any] = loaded
instructions = parse_instructions(workspace.manual_resolutions_file)
cut_sources = [(item.copy_id, item.old_label) for item in instructions if item.cut]
if len(set(cut_sources)) != len(cut_sources):
raise CliError("Une seule coupe par label source est autorisée dans une résolution.")
initial_paths: dict[tuple[str, str], Path] = {}
current_paths: dict[tuple[str, str], Path] = {}
@@ -174,6 +201,15 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
key_new = (instruction.copy_id, instruction.new_label)
source = initial_paths[key_old]
destination = current_paths[key_new]
if instruction.cut:
percent, keep = instruction.cut
first = source.parent / f"temp_{len(temp_files)}.pdf"
second = source.parent / f"temp_{len(temp_files) + 1}.pdf"
temp_files.extend((first, second))
split_pdf(source, percent, first, second)
retained, source = (first, second) if keep == 1 else (second, first)
current_paths[key_old] = retained
files_to_old.add(initial_paths[key_old])
temp_output = (
workspace.copies_dir
/ f"Copie{instruction.copy_id}"
@@ -259,7 +295,7 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
if refaire_tasks:
print(
f"File {workspace.refaire_file.name} generated. Run "
f'`python correction.py "{workspace.command_argument()}" --refaire` '
f'`python -m copienator correct "{workspace.command_argument()}" --refaire` '
"to process updates."
)
else:
+293
View File
@@ -0,0 +1,293 @@
from __future__ import annotations
import argparse
import math
import shutil
import tempfile
from collections import defaultdict
from collections.abc import Sequence
from pathlib import Path
import pymupdf
from pypdf import PdfReader, PdfWriter
from copienator import utils
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
execute,
read_json,
target_parser,
workspace_from_target,
)
from copienator.filesystem import staged_directory
SQUARE = 1000 // 38
ANSWER_TOP_PADDING_POINTS = 4 * 72 / 25.4
Coordinate = tuple[str, int, int, int, int, int]
ParsedCoordinate = tuple[str, str, int, int, int, int, int]
def decode_json(pdf_file: str | Path) -> tuple[str, list[Coordinate]]:
"""Read verified label coordinates associated with one copy PDF."""
pdf_path = Path(pdf_file)
loaded = read_json(pdf_path.with_suffix(".json"))
if not isinstance(loaded, dict):
raise TypeError(f"Expected a JSON object for {pdf_path}")
boxes = loaded.get("list")
if not isinstance(boxes, list):
raise TypeError(f"Expected a list of labels for {pdf_path}")
page_count = len(PdfReader(pdf_path).pages)
if page_count == 0:
raise ValueError(f"PDF contains no pages: {pdf_path}")
column_width = 1000 // page_count
result: list[Coordinate] = []
for entry in boxes:
if not isinstance(entry, dict):
raise TypeError(f"Malformed label entry for {pdf_path}: {entry!r}")
box = entry["box_2d"]
label = str(entry["label"])
page_number = ((box[1] + box[3]) // 2) // column_width
result.append(
(label, page_number, box[0] - SQUARE, box[2] - SQUARE, box[1], box[3])
)
result.sort(key=lambda item: (item[1], item[2]))
return str(loaded.get("name", "")), result
def _parse_coordinates(coords_list: list[Coordinate]) -> list[ParsedCoordinate]:
parsed: list[ParsedCoordinate] = []
for label, page, y0, y1, x0, x1 in coords_list:
if label.startswith("|"):
kind, clean_label = "L", label[1:]
elif label.endswith("|"):
kind, clean_label = "R", label[:-1]
else:
kind, clean_label = "N", label
parsed.append((clean_label, kind, page, y0, y1, x0, x1))
filtered: list[ParsedCoordinate] = []
for item in parsed:
if not filtered or item[0] != filtered[-1][0]:
filtered.append(item)
return filtered
def _save_cropped_page(
document: pymupdf.Document,
page_number: int,
x0: float,
y0: float,
x1: float,
y1: float,
output_path: Path,
) -> None:
# The source has been normalized by _prepare_split_pages: no rotation or
# CropBox translation remains in the coordinates passed to show_pdf_page.
visual_crop = pymupdf.Rect(x0, y0, x1, y1)
cropped = pymupdf.open()
try:
target_page = cropped.new_page(width=visual_crop.width, height=visual_crop.height)
target_page.show_pdf_page(
target_page.rect,
document,
page_number,
clip=visual_crop,
)
cropped.save(output_path)
finally:
cropped.close()
def _prepare_split_pages(document: pymupdf.Document) -> list[pymupdf.Rect]:
"""Use the label preview's full-page coordinates; retain visible bounds.
Work only on the in-memory document. Baking rotation into the content after
restoring the MediaBox avoids show_pdf_page's rotated CropBox offsets.
Intersecting with the saved bounds later preserves prior margin cropping.
"""
visible_bounds = []
for page in document:
crop = page.cropbox
media = page.mediabox
full_crop = pymupdf.Rect(media.x0, 0, media.x1, media.height)
page.set_cropbox(full_crop)
crop -= (full_crop.x0, full_crop.y0, full_crop.x0, full_crop.y0)
visible_bounds.append(crop * page.rotation_matrix)
page.remove_rotation()
return visible_bounds
def _render_split_outputs(
input_pdf: Path,
coords_list: list[Coordinate],
staging: Path,
) -> set[str]:
"""Render every current answer into an otherwise empty staging directory."""
document = pymupdf.open(input_pdf)
try:
visible_bounds = _prepare_split_pages(document)
parsed = _parse_coordinates(coords_list)
parts_by_label: defaultdict[str, list[Path]] = defaultdict(list)
with tempfile.TemporaryDirectory(prefix="copienator-split-") as temp_directory:
temporary = Path(temp_directory)
for index, item in enumerate(parsed):
clean_label, kind, start_page, y_start, _y_end, x0_raw, _x1_raw = item
if clean_label == "_":
continue
if not 0 <= start_page < document.page_count:
raise ValueError(
f"Invalid page {start_page} for {input_pdf.name}"
)
end_page = document.page_count - 1
end_y = 1000
for next_item in parsed[index + 1 :]:
_next_label, next_kind, next_page, next_y, *_rest = next_item
if (
(kind == "L" and next_kind in {"L", "N"})
or (kind == "R" and next_kind in {"R", "N"})
or kind == "N"
):
end_page = next_page
end_y = min(next_y + int(1.5 * SQUARE), 1000)
break
column_width = 1000 / document.page_count
if kind == "L":
fraction_x0 = (x0_raw % column_width) / column_width
fraction_x1 = 1.0
end_y = min(1000, end_y + 40)
elif kind == "R":
fraction_x0 = 0.0
left_labels = [entry for entry in parsed if entry[1] == "L"]
if left_labels:
closest = min(left_labels, key=lambda entry: abs(entry[3] - y_start))
center = (closest[5] + closest[6]) / 2.0
fraction_x1 = (center % column_width) / column_width
if fraction_x1 <= fraction_x0:
fraction_x1 = 1.0
else:
fraction_x1 = 1.0
else:
fraction_x0, fraction_x1 = 0.0, 1.0
for page_number in range(start_page, end_page + 1):
page = document[page_number]
y0 = (
math.floor(max(
0,
(y_start / 1000) * page.rect.height
- ANSWER_TOP_PADDING_POINTS,
))
if page_number == start_page
else 0
)
y1 = (end_y / 1000) * page.rect.height if page_number == end_page else page.rect.height
clip = pymupdf.Rect(
fraction_x0 * page.rect.width, y0,
fraction_x1 * page.rect.width, y1,
) & visible_bounds[page_number] & page.rect
if clip.is_empty or clip.height <= 1 or clip.width <= 1:
continue
part_path = temporary / f"part-{index}-{page_number}.pdf"
_save_cropped_page(
document,
page_number,
clip.x0,
clip.y0,
clip.x1,
clip.y1,
part_path,
)
parts_by_label[clean_label].append(part_path)
generated: set[str] = set()
for label, parts in parts_by_label.items():
filename = f"{label}.pdf"
merger = PdfWriter()
try:
for part in parts:
merger.append(part)
merger.write(staging / filename)
finally:
merger.close()
generated.add(filename)
return generated
finally:
document.close()
def _preserve_previous_outputs(
output_dir: Path,
staging: Path,
generated_files: set[str],
) -> None:
if not output_dir.is_dir():
return
for directory in (path for path in output_dir.iterdir() if path.is_dir()):
shutil.copytree(directory, staging / directory.name, dirs_exist_ok=True)
missing_dir = staging / "Missing"
for item in (path for path in output_dir.iterdir() if path.is_file()):
if item.name in generated_files:
continue
print(f"ALERT: File '{item.name}' not generated. Moving to {missing_dir}")
missing_dir.mkdir(exist_ok=True)
shutil.copy2(item, missing_dir / item.name)
def split_an_interro(
workspace: EvaluationWorkspace,
input_pdf: Path,
coords_list: list[Coordinate],
) -> None:
"""Regenerate one copy's answers and preserve obsolete ones under Missing."""
output_dir = workspace.copies_dir / input_pdf.stem
with staged_directory(output_dir) as staging:
generated = _render_split_outputs(input_pdf, coords_list, staging)
_preserve_previous_outputs(output_dir, staging, generated)
def _selected_pdfs(workspace: EvaluationWorkspace, target: Path) -> list[Path]:
workspace.require_directories("Copies")
if target.is_file():
if target.suffix.casefold() != ".pdf":
raise CliError(f"Target is not a PDF: {target}", ExitCode.INVALID_ARGUMENTS)
return [target]
return sorted(workspace.copies_dir.glob("*.pdf"), key=lambda path: path.name.casefold())
def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
workspace.require_files("labels")
utils.read_all_labels(workspace.root)
pdf_files = _selected_pdfs(workspace, target)
status = ExitCode.SUCCESS
for pdf_path in pdf_files:
json_path = pdf_path.with_suffix(".json")
if not json_path.is_file():
print(f"Warning: No JSON found for {pdf_path.name}")
status = ExitCode.PARTIAL
continue
name, coordinates = decode_json(pdf_path)
print(f"Decoded name: {name}")
split_an_interro(workspace, pdf_path, coordinates)
if not pdf_files:
print("No PDF copies found.")
return status
def build_parser() -> argparse.ArgumentParser:
return target_parser("Split verified PDF copies into answers by label")
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import argparse
from collections.abc import Sequence
from google import genai
from google.genai import types
from copienator import configuration as config
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_json,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
def run(workspace: EvaluationWorkspace, *, client=None) -> ExitCode:
if client is None:
if not config.API_KEY:
raise CliError("GEMINI_API_KEY is not configured")
client = genai.Client(api_key=config.API_KEY)
batches = (
(
"flash",
workspace.root / "batch_requests_flash.jsonl",
config.MODEL_FLASH_ID,
f"flash-correction-{workspace.name}",
),
(
"pro",
workspace.root / "batch_requests_pro.jsonl",
config.MODEL_PRO_ID,
f"pro-correction-{workspace.name}",
),
)
manifest = {
"version": 1,
"evaluation": workspace.name,
"jobs": {},
}
if workspace.batch_jobs_file.is_file():
previous = read_json(workspace.batch_jobs_file)
if isinstance(previous, dict) and isinstance(previous.get("jobs"), dict):
manifest["jobs"] = previous["jobs"]
started = 0
for tier, file_path, model_id, display_name in batches:
if not file_path.is_file():
print(f"Skipping {model_id}: {file_path.name} does not exist.")
continue
if file_path.stat().st_size == 0:
print(f"Skipping {model_id}: {file_path.name} is empty.")
continue
print(f"Uploading {file_path.name} for model {model_id}...")
uploaded = client.files.upload(
file=str(file_path),
config=types.UploadFileConfig(
display_name=f"{display_name}-input",
mime_type="jsonl",
),
)
job = client.batches.create(
model=model_id,
src=uploaded.name,
config={"display_name": display_name},
)
started += 1
manifest["jobs"][tier] = {
"name": job.name,
"display_name": display_name,
"model": model_id,
"request_file": file_path.name,
}
atomic_write_json(workspace.batch_jobs_file, manifest)
print(f"Started batch job: {job.name}")
if not started:
print("No non-empty batch request files were found.")
return ExitCode.PARTIAL
return ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Upload correction JSONL files and start Gemini batches")
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
if __name__ == "__main__":
raise SystemExit(main())
@@ -6,18 +6,18 @@ from pathlib import Path
import ezodf
from config import CURRENT_SCORE_ODS_PATH
from utils import read_all_labels
from copienator.configuration import CURRENT_SCORE_ODS_PATH
from copienator.utils import read_all_labels
# Configuration
ODS_PATH = Path(CURRENT_SCORE_ODS_PATH).expanduser()
TARGET_DIR_NAME = "A Rendre"
def main():
def main(argv=None):
parser = argparse.ArgumentParser(description="Update ODS with student scores.")
parser.add_argument("work_dir", nargs="?", default=os.getcwd(), help="Directory to process")
parser.add_argument("--sum", action="store_true", help="Write only the total sum per student")
args = parser.parse_args()
args = parser.parse_args(argv)
work_dir = os.path.abspath(args.work_dir)
@@ -169,4 +169,4 @@ def main():
print("Done.")
if __name__ == "__main__":
main()
raise SystemExit(main())
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import argparse
import re
import sys
from collections.abc import Sequence
from pathlib import Path
from copienator import (
EvaluationWorkspace,
ExitCode,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
COPY_PATTERN = re.compile(r"Copie(\d+)")
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Verify that every answer PDF appears in group metadata.")
def collect_source_pdfs(copies_dir: Path) -> set[tuple[str, str]]:
source_pdfs: set[tuple[str, str]] = set()
for copy_dir in copies_dir.iterdir():
match = COPY_PATTERN.fullmatch(copy_dir.name)
if not match or not copy_dir.is_dir():
continue
copy_id = match.group(1)
for pdf_path in copy_dir.glob("*.pdf"):
source_pdfs.add((pdf_path.stem, copy_id))
return source_pdfs
def collect_grouped_pdfs(groups_dir: Path) -> tuple[set[tuple[str, str]], int]:
grouped: set[tuple[str, str]] = set()
read_errors = 0
for json_path in groups_dir.glob("*/Group_*.json"):
try:
data = read_json(json_path)
if not isinstance(data, list):
raise TypeError("expected a JSON array")
for entry in data:
grouped.add((str(entry[4]), str(entry[0])))
except (IndexError, OSError, TypeError, ValueError) as exc:
print(f"Error reading {json_path}: {exc}", file=sys.stderr)
read_errors += 1
return grouped, read_errors
def verify_groups(workspace: EvaluationWorkspace) -> ExitCode:
workspace.require_directories("Copies", "Par label")
source_pdfs = collect_source_pdfs(workspace.copies_dir)
grouped_pdfs, read_errors = collect_grouped_pdfs(workspace.groups_dir)
missing = source_pdfs - grouped_pdfs
if missing:
print(f"Verification failed: {len(missing)} files missing from groups:")
for label, copy_id in sorted(missing):
print(f"Copie{copy_id}/{label}.pdf")
return ExitCode.FAILURE
if read_errors:
print("Verification incomplete because some metadata could not be read.")
return ExitCode.PARTIAL
print("Verification successful: all files accounted for.")
return ExitCode.SUCCESS
def run(workspace: EvaluationWorkspace) -> ExitCode:
return verify_groups(workspace)
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
if __name__ == "__main__":
raise SystemExit(main())
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import importlib.util
import os
from pathlib import Path
from types import ModuleType
def _user_config_path() -> Path | None:
explicit = os.environ.get("COPIENATOR_CONFIG", "").strip()
if explicit:
return Path(explicit).expanduser().resolve()
local = Path.cwd() / "config.py"
return local.resolve() if local.is_file() else None
def _load_user_config(path: Path) -> ModuleType:
if not path.is_file():
raise FileNotFoundError(f"Copienator configuration not found: {path}")
spec = importlib.util.spec_from_file_location("_copienator_user_config", path)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load Copienator configuration: {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
CONFIG_PATH = _user_config_path()
if CONFIG_PATH is None:
import default_config as _configuration
else:
_configuration = _load_user_config(CONFIG_PATH)
# Keep new optional settings compatible with older personal configuration files.
ALWAYS_CROP = False
RETURN_JPEG_ENABLED = True
RETURN_PDF_ENABLED = True
RETURN_ANSWERS_ENABLED = False
RETURN_ANSWERS_CONTEXT = False
RETURN_ANSWERS_QUESTION = True
RETURN_ANSWERS_SOLUTION = False
FINAL_SCORE_HISTOGRAM_PATH = Path("histogramme.pdf")
for _name in dir(_configuration):
if not _name.startswith("_"):
globals()[_name] = getattr(_configuration, _name)
+48
View File
@@ -0,0 +1,48 @@
"""Persistent copy flags shared by page splitting, margin review and the GUI."""
from pathlib import Path
from copienator import CliError, EvaluationWorkspace, atomic_update_json, read_json
def _path(workspace: EvaluationWorkspace) -> Path:
return workspace.metadata_dir / "copy_errors.json"
def _validate(value) -> dict[str, str]:
if not isinstance(value, dict) or any(
not isinstance(name, str) or not isinstance(reason, str)
or "/" in name or "\\" in name or Path(name).suffix.casefold() != ".pdf"
for name, reason in value.items()
):
raise CliError("Invalid copy_errors.json: expected PDF filenames and error descriptions")
return value
def copy_errors(workspace: EvaluationWorkspace) -> dict[str, str]:
return _validate(read_json(_path(workspace), default={}))
def mark_copy_error(workspace: EvaluationWorkspace, pdf: Path, reason: str) -> None:
def update(errors):
_validate(errors)[pdf.name] = reason
atomic_update_json(_path(workspace), update, default_factory=dict)
def clear_copy_error(workspace: EvaluationWorkspace, pdf: Path) -> None:
if not _path(workspace).exists():
return
def update(errors):
_validate(errors).pop(pdf.name, None)
atomic_update_json(_path(workspace), update, default_factory=dict)
def marked_copy_paths(workspace: EvaluationWorkspace, *, originals: bool = False) -> list[Path]:
directories = ([workspace.original_copies_dir, workspace.copies_dir, workspace.root]
if originals else [workspace.copies_dir])
result = []
for name in sorted(copy_errors(workspace), key=str.casefold):
path = next((directory / name for directory in directories if (directory / name).is_file()), None)
if path is None:
raise CliError(f"Marked copy not found: {name}")
result.append(path)
return result
+138
View File
@@ -0,0 +1,138 @@
"""Propose conservative top/bottom crops for scanned, optionally ruled PDFs.
Run with ``python -m copienator.crop_blank_margins INPUT_DIR OUTPUT_DIR``.
Only PDFs directly in INPUT_DIR are processed. Originals are never modified.
Analysis is deskewed; output keeps the original scan and changes its CropBox.
This is a heuristic review utility, not a guarantee that a scan contains no ink.
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import html
import json
from pathlib import Path
import cv2
import numpy as np
import pymupdf
from PIL import Image, ImageDraw
from copienator.ink_detection import detect_bounds
def apply_bounds(page: pymupdf.Page, top: float, bottom: float) -> None:
"""Apply fractional bounds in displayed orientation, respecting old CropBox."""
rect = page.rect
visible = pymupdf.Rect(0, top*rect.height, rect.width, bottom*rect.height)
box = visible * page.derotation_matrix
box += (page.cropbox_position.x, page.cropbox_position.y,
page.cropbox_position.x, page.cropbox_position.y)
page.set_cropbox(box)
def process_pdf(source: Path, destination: Path, review: Path | None,
dpi: float, padding_mm: float, min_crop_mm: float,
progress=None) -> list[dict]:
digest = hashlib.sha256(source.read_bytes()).hexdigest()
rows = []
with pymupdf.open(source) as doc:
for index, page in enumerate(doc):
pix = page.get_pixmap(dpi=round(dpi), colorspace=pymupdf.csRGB, alpha=False)
rgb = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, 3)
result = detect_bounds(rgb, dpi, padding_mm, min_crop_mm)
top, bottom = result.pop('top_px'), result.pop('bottom_px')
row = dict(file=source.name, page=index+1, **result,
top_removed_mm=round(top/pix.height*page.rect.height*25.4/72, 2),
bottom_removed_mm=round((pix.height-bottom)/pix.height*page.rect.height*25.4/72, 2),
original_cropbox=list(page.cropbox), rotation=page.rotation,
source_sha256=digest)
if review is not None:
preview = Image.fromarray(rgb)
preview.thumbnail((500, 700))
overlay = Image.new('RGBA', preview.size)
draw = ImageDraw.Draw(overlay)
y0, y1 = top/pix.height*preview.height, bottom/pix.height*preview.height
if top:
draw.rectangle((0, 0, preview.width, y0), fill=(255, 40, 40, 85))
draw.line((0, y0, preview.width, y0), fill=(230, 0, 0, 255), width=2)
if bottom < pix.height:
draw.rectangle((0, y1, preview.width, preview.height), fill=(255, 40, 40, 85))
draw.line((0, y1, preview.width, y1), fill=(230, 0, 0, 255), width=2)
preview = Image.alpha_composite(preview.convert('RGBA'), overlay).convert('RGB')
preview.save(review/f'{source.stem}-{index+1:03}.jpg', quality=85)
if top or bottom < pix.height:
apply_bounds(page, top/pix.height, bottom/pix.height)
row['output_cropbox'] = list(page.cropbox)
rows.append(row)
if progress is not None:
progress(index+1, len(doc), row)
doc.save(destination, garbage=3, deflate=True)
with pymupdf.open(destination) as check:
if len(check) != len(rows):
raise RuntimeError(f'Page count changed: {source}')
for page in check:
if page.rect.is_empty:
raise RuntimeError(f'Empty output page: {destination}')
if hashlib.sha256(source.read_bytes()).hexdigest() != digest:
raise RuntimeError(f'Source changed during processing: {source}')
return rows
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('input', type=Path)
parser.add_argument('output', type=Path)
parser.add_argument('--dpi', type=int, default=200)
parser.add_argument('--padding-mm', type=float, default=6)
parser.add_argument('--min-crop-mm', type=float, default=5)
args = parser.parse_args()
if args.dpi < 100 or args.padding_mm < 0 or args.min_crop_mm < 0:
parser.error('Use dpi >= 100 and nonnegative margins.')
sources = sorted(args.input.glob('*.pdf')) if args.input.is_dir() else [args.input]
if not sources or any(not p.is_file() for p in sources):
parser.error('No input PDFs found.')
if any(p.resolve() == (args.output/p.name).resolve() for p in sources):
parser.error('Output must not overwrite input PDFs.')
args.output.mkdir(parents=True, exist_ok=True)
review = args.output/'review'
review.mkdir(exist_ok=True)
cv2.setNumThreads(2)
rows = []
for i, source in enumerate(sources):
batch = process_pdf(source, args.output/source.name, review,
args.dpi, args.padding_mm, args.min_crop_mm)
rows.extend(batch)
print(f'[{i+1}/{len(sources)}] {source.name}: {len(batch)} pages, '
f'{sum(r["top_removed_mm"] > 0 or r["bottom_removed_mm"] > 0 for r in batch)} cropped',
flush=True)
(args.output/'report.json').write_text(json.dumps(rows, indent=2)+'\n')
with (args.output/'report.csv').open('w') as stream:
fieldnames = list(dict.fromkeys(key for row in rows for key in row))
writer = csv.DictWriter(stream, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
cards = []
for row in rows:
stem = Path(row['file']).stem
cards.append(f'<article><a href="{html.escape(row["file"])}#page={row["page"]}">'
f'{html.escape(stem)} / {row["page"]}</a>'
f'<p>Top: {row["top_removed_mm"]} mm · Bottom: {row["bottom_removed_mm"]} mm'
f' · {row["status"]}</p>'
f'<img loading="lazy" src="review/{html.escape(stem)}-{row["page"]:03}.jpg"></article>')
(args.output/'index.html').write_text(
'<!doctype html><meta charset="utf-8"><title>Crop review</title>'
'<style>body{font:15px system-ui;background:#eee;margin:24px}'
'main{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:20px}'
'article{background:white;padding:12px}img{width:100%}p{font-size:12px}</style>'
'<h1>Crop review</h1><p>Red shading shows the removed areas on the original scan. '
'Click a page title to open the processed PDF. Review statuses flag uncertain detections.</p>'
'<main>'+''.join(cards)+'</main>')
changed = sum(r['top_removed_mm'] > 0 or r['bottom_removed_mm'] > 0 for r in rows)
print(f'Done: {len(sources)} PDFs, {len(rows)} pages, {changed} cropped. Review: {args.output / "index.html"}')
if __name__ == '__main__':
main()
+298
View File
@@ -0,0 +1,298 @@
"""Propose large bottom-only crops for PDFs already split into exercises."""
from __future__ import annotations
import argparse
import csv
import hashlib
import html
import json
import multiprocessing
import signal
from pathlib import Path
from urllib.parse import quote
import cv2
import numpy as np
import pymupdf
from PIL import Image, ImageDraw
from copienator.crop_blank_margins import apply_bounds
from copienator.filesystem import staged_directory
from copienator.ink_detection import detect_bounds
LINES_PER_PAGE = 36
MINIMUM_HEIGHT_LINES = 10
IGNORED_BOTTOM_LINES = 0.75
MINIMUM_CROP_LINES = 4
def _displayed_media_height(page: pymupdf.Page) -> float:
"""Return the uncropped sheet height in the page's displayed orientation."""
return page.mediabox.width if page.rotation % 180 else page.mediabox.height
def _full_page_height(copy_pdf: Path) -> float:
with pymupdf.open(copy_pdf) as document:
if not len(document):
raise ValueError(f"PDF sans page : {copy_pdf}")
return max(_displayed_media_height(page) for page in document)
def _save_review(rgb: np.ndarray, bottom: int, destination: Path) -> None:
preview = Image.fromarray(rgb)
preview.thumbnail((500, 700))
overlay = Image.new("RGBA", preview.size)
draw = ImageDraw.Draw(overlay)
y = bottom / rgb.shape[0] * preview.height
draw.rectangle((0, y, preview.width, preview.height), fill=(255, 40, 40, 85))
draw.line((0, y, preview.width, y), fill=(230, 0, 0, 255), width=2)
destination.parent.mkdir(parents=True, exist_ok=True)
Image.alpha_composite(preview.convert("RGBA"), overlay).convert("RGB").save(
destination, quality=88
)
def process_exercise_pdf(
source: Path,
destination: Path,
review_dir: Path | None,
full_page_height: float,
*,
dpi: int = 200,
padding_mm: float = 6,
) -> list[dict]:
"""Crop qualifying pages and save the PDF only when at least one changes."""
digest = hashlib.sha256(source.read_bytes()).hexdigest()
line_points = full_page_height / LINES_PER_PAGE
records: list[dict] = []
changed = False
with pymupdf.open(source) as document:
page_count = len(document)
for index, page in enumerate(document):
visible_height = page.rect.height
record = {
"file": source.as_posix(),
"page": index + 1,
"page_count": page_count,
"source_sha256": digest,
"height_lines": round(visible_height / line_points, 2),
"bottom_removed_lines": 0.0,
"bottom_removed_mm": 0.0,
"status": "skipped-short",
}
if visible_height + 1e-6 < MINIMUM_HEIGHT_LINES * line_points:
records.append(record)
continue
pixmap = page.get_pixmap(
dpi=dpi, colorspace=pymupdf.csRGB, alpha=False
)
rgb = np.frombuffer(pixmap.samples, np.uint8).reshape(
pixmap.height, pixmap.width, 3
)
pixels_per_point = pixmap.height / visible_height
ignored_pixels = min(
pixmap.height - 1,
round(IGNORED_BOTTOM_LINES * line_points * pixels_per_point),
)
analysis_bottom = pixmap.height - ignored_pixels
detection = detect_bounds(
rgb[:analysis_bottom], dpi=dpi, padding_mm=padding_mm, min_crop_mm=0
)
proposed_bottom = detection["bottom_px"]
removed_points = (pixmap.height - proposed_bottom) / pixels_per_point
removed_lines = removed_points / line_points
record["detector_status"] = detection["status"]
record["proposed_bottom_removed_lines"] = round(removed_lines, 2)
if removed_lines + 1e-6 < MINIMUM_CROP_LINES:
record["status"] = "unchanged-small-crop"
records.append(record)
continue
apply_bounds(page, 0, proposed_bottom / pixmap.height)
record.update(
bottom_removed_lines=round(removed_lines, 2),
bottom_removed_mm=round(removed_points * 25.4 / 72, 2),
status="cropped",
output_height_points=round(page.rect.height, 3),
)
if review_dir is not None:
record["output"] = destination.relative_to(review_dir.parent).as_posix()
review_path = review_dir / source.parent.name / (
f"{source.stem}-p{index + 1:02}.jpg"
)
_save_review(rgb, proposed_bottom, review_path)
record["review"] = review_path.relative_to(review_dir.parent).as_posix()
changed = True
records.append(record)
if changed:
destination.parent.mkdir(parents=True, exist_ok=True)
document.save(destination, garbage=3, deflate=True)
if changed:
with pymupdf.open(destination) as check:
if len(check) != page_count:
raise RuntimeError(f"Nombre de pages modifié : {source}")
for page in check:
if page.rect.is_empty:
raise RuntimeError(f"Page vide produite : {destination}")
page.get_pixmap(matrix=pymupdf.Matrix(0.25, 0.25))
if hashlib.sha256(source.read_bytes()).hexdigest() != digest:
raise RuntimeError(f"PDF source modifié pendant le rognage : {source}")
return records
def _initialize_worker() -> None:
cv2.setNumThreads(1)
signal.signal(signal.SIGINT, signal.SIG_IGN)
def _process_job(job: tuple[Path, Path, Path, float, int, float]) -> list[dict]:
source, destination, review_dir, full_height, dpi, padding = job
records = process_exercise_pdf(
source, destination, review_dir, full_height, dpi=dpi, padding_mm=padding
)
changed = sum(record["status"] == "cropped" for record in records)
print(f"{source.parent.name}/{source.name} : {changed}/{len(records)} page(s) rognée(s)",
flush=True)
return records
def _write_index(output: Path, records: list[dict]) -> None:
changed = [record for record in records if record["status"] == "cropped"]
changed_files: dict[str, list[int]] = {}
for record in changed:
changed_files.setdefault(record["file"], []).append(record["page"])
(output / "cropped-files.txt").write_text(
"".join(
f"{path} - page(s) {', '.join(map(str, pages))}\n"
for path, pages in changed_files.items()
),
encoding="utf-8",
)
cards = []
for record in changed:
relative = Path(record["file"])
output_pdf = quote(record["output"])
preview = quote(record["review"])
name = html.escape(f"{relative.parent.name}/{relative.name} - page {record['page']}")
cards.append(
f'<article><a href="{output_pdf}#page={record["page"]}">{name}</a>'
f'<p>{record["bottom_removed_lines"]:.2f} lignes '
f'({record["bottom_removed_mm"]:.1f} mm) retirées</p>'
f'<img loading="lazy" src="{preview}"></article>'
)
(output / "index.html").write_text(
'<!doctype html><meta charset="utf-8"><title>Rognage bas des exercices</title>'
'<style>body{font:15px system-ui;background:#eee;margin:24px}'
'main{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:20px}'
'article{background:white;padding:12px}img{width:100%}p{font-size:12px}</style>'
f'<h1>{len(changed)} pages rognées</h1>'
'<p>Le rouge montre la zone retirée. Seuls les PDF modifiés sont présents dans cropped/.</p>'
'<main>' + ''.join(cards) + '</main>',
encoding="utf-8",
)
def run(
input_path: Path,
output: Path,
*,
dpi: int = 200,
padding_mm: float = 6,
workers: int = 5,
) -> list[dict]:
copies = input_path / "Copies" if (input_path / "Copies").is_dir() else input_path
if not copies.is_dir():
raise ValueError(f"Dossier Copies introuvable : {input_path}")
sources = sorted(
copies.glob("Copie*/*.pdf"),
key=lambda path: (path.parent.name.casefold(), path.name.casefold()),
)
if not sources:
raise ValueError(f"Aucun PDF d'exercice trouvé dans {copies}")
if workers < 1:
raise ValueError("Le nombre de traitements parallèles doit être positif")
heights: dict[str, float] = {}
for copy_name in sorted({source.parent.name for source in sources}):
copy_pdf = copies / f"{copy_name}.pdf"
if not copy_pdf.is_file():
raise ValueError(f"PDF source introuvable : {copy_pdf}")
heights[copy_name] = _full_page_height(copy_pdf)
with staged_directory(output) as staging:
cropped_dir = staging / "cropped"
review_dir = staging / "review"
jobs = [
(
source,
cropped_dir / source.relative_to(copies),
review_dir,
heights[source.parent.name],
dpi,
padding_mm,
)
for source in sources
]
count = min(workers, len(jobs))
if count == 1:
previous_threads = cv2.getNumThreads()
cv2.setNumThreads(1)
try:
batches = [_process_job(job) for job in jobs]
finally:
cv2.setNumThreads(previous_threads)
else:
with multiprocessing.get_context("spawn").Pool(
count, _initialize_worker
) as pool:
batches = list(pool.imap_unordered(_process_job, jobs))
order = {source.as_posix(): i for i, source in enumerate(sources)}
records = sorted(
(record for batch in batches for record in batch),
key=lambda record: (order[record["file"]], record["page"]),
)
(staging / "report.json").write_text(
json.dumps(records, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
with (staging / "report.csv").open("w", encoding="utf-8", newline="") as stream:
fields = sorted({key for record in records for key in record})
writer = csv.DictWriter(stream, fieldnames=fields)
writer.writeheader()
writer.writerows(records)
_write_index(staging, records)
return records
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", type=Path, help="Évaluation ou dossier Copies")
parser.add_argument("output", type=Path)
parser.add_argument("--dpi", type=int, default=200)
parser.add_argument("--padding-mm", type=float, default=6)
parser.add_argument("--workers", type=int, default=5)
arguments = parser.parse_args()
if arguments.dpi < 100 or arguments.padding_mm < 0:
parser.error("Utilisez dpi >= 100 et une marge positive ou nulle")
try:
records = run(
arguments.input,
arguments.output,
dpi=arguments.dpi,
padding_mm=arguments.padding_mm,
workers=arguments.workers,
)
except ValueError as error:
parser.error(str(error))
cropped = sum(record["status"] == "cropped" for record in records)
files = len({record["file"] for record in records if record["status"] == "cropped"})
print(f"Terminé : {cropped} page(s) dans {files} PDF rognée(s). Revue : "
f"{arguments.output / 'index.html'}")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import importlib
import sys
from collections.abc import Sequence
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Command:
module: str
description: str
COMMANDS: dict[str, Command] = {
"statement": Command("gemini_for_enonce", "Analyse the exam statement with Gemini"),
"statement-personal": Command("enonce_info", "Generate personal statement metadata"),
"copies": Command("copies_tools", "Rotate or rename scanned copies"),
"page-split": Command("page_splitter", "Split and reorder scanned PDF pages"),
"crop-margins": Command("crop_margins", "Trim blank top and bottom margins in Copies"),
"crop-answer-bottoms": Command(
"crop_exercise_bottoms", "Trim large blank bottoms from split answers"
),
"crop-labels": Command("cutleft", "Crop the label margin from copies"),
"labels": Command("gemini_for_labels", "Detect question labels with Gemini"),
"review-labels": Command("plotting", "Review detected labels interactively"),
"split-answers": Command("splitting_int", "Split copies into answers"),
"group-answers": Command("grouping", "Group answers by question"),
"verify-groups": Command("verify_groups", "Verify grouped answer metadata"),
"correct": Command("correction", "Generate or integrate corrections"),
"batch-submit": Command("submit_batches", "Submit Gemini batch jobs"),
"batch-status": Command("batch_status", "Inspect Gemini batch jobs"),
"batch-fetch": Command("fetch_batched_results", "Fetch Gemini batch results"),
"post-correction": Command("post_correction", "Clean generated correction text"),
"resolve-manual": Command("resolve_manual", "Resolve manual label conflicts"),
"annotate-simple": Command("annotating", "Generate simple annotations"),
"annotate-checks": Command("annotating_with_checks", "Generate checkable annotations"),
"annotate-grouped": Command("annotating_by_label", "Generate grouped annotations"),
"export": Command("export", "Export annotations"),
"import": Command("import_annotations", "Import handwritten annotations"),
"read-annotations": Command("reading_annotations", "Read checkable annotations"),
"read-grouped": Command("reading_grouped_annotations", "Read grouped annotations"),
"giving-names": Command("giving_names", "Name copies and prepare A Rendre"),
"update-ods": Command("update_ods", "Update the configured score spreadsheet"),
"add-final-score": Command("add_final_score", "Stamp final scores on copies"),
"clean": Command("clean", "Delete intermediate files from a finished evaluation"),
"gui": Command("@gui", "Launch the graphical workflow assistant"),
}
def _print_help() -> None:
print("Usage: copienator COMMAND [ARGUMENTS]")
print(" python -m copienator COMMAND [ARGUMENTS]")
print("\nCommands:")
width = max(len(name) for name in COMMANDS)
for name, command in COMMANDS.items():
print(f" {name:<{width}} {command.description}")
print("\nUse 'copienator COMMAND --help' for command-specific help.")
def main(argv: Sequence[str] | None = None) -> int:
arguments = list(sys.argv[1:] if argv is None else argv)
if not arguments or arguments[0] in {"-h", "--help"}:
_print_help()
return 0
command_name = arguments.pop(0)
command = COMMANDS.get(command_name)
if command is None:
print(f"Unknown command: {command_name}", file=sys.stderr)
print("Use 'copienator --help' to list commands.", file=sys.stderr)
return 2
module_name = (
"copienator_gui.__main__"
if command.module == "@gui"
else f"copienator.commands.{command.module}"
)
module = importlib.import_module(module_name)
result = module.main(arguments)
return int(result or 0)
+13
View File
@@ -0,0 +1,13 @@
"""Geometry checks shared by correction and annotation rendering."""
import math
from numbers import Real
def valid_feedback_box(box) -> bool:
return (
isinstance(box, (list, tuple)) and len(box) == 4
and all(isinstance(value, Real) and not isinstance(value, bool)
and math.isfinite(value) for value in box)
and box[0] < box[2] and box[1] < box[3]
)
+12 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import shutil
import uuid
from collections.abc import Iterable
from contextlib import contextmanager
from pathlib import Path
@@ -44,7 +45,11 @@ def staged_directory(destination: str | Path):
@contextmanager
def staged_files(destination: str | Path):
def staged_files(
destination: str | Path,
*,
remove: Iterable[str] = (),
):
"""Stage a set of files and merge them into a directory with rollback."""
target = Path(destination)
target.parent.mkdir(parents=True, exist_ok=True)
@@ -56,9 +61,15 @@ def staged_files(destination: str | Path):
try:
yield staging
staged = sorted(path for path in staging.iterdir() if path.is_file())
staged_names = {path.name for path in staged}
removed_names = set(remove) - staged_names
target.mkdir(parents=True, exist_ok=True)
backup.mkdir()
try:
for name in sorted(removed_names):
destination_path = target / name
if destination_path.is_file() or destination_path.is_symlink():
destination_path.replace(backup / name)
for source in staged:
destination_path = target / source.name
if destination_path.exists() or destination_path.is_symlink():
+273
View File
@@ -0,0 +1,273 @@
"""Detect top and bottom content bounds in scanned student work.
Strong coloured strokes are never erased merely because they coincide with
paper ruling. The detector supports coloured handwriting and dark ruled scans;
it remains conservative for pencil-only scans and heavily saturated grids.
"""
from functools import lru_cache
import cv2
import numpy as np
from copienator.paper_background import _skew, _ruling, _foreground, _content_mask
@lru_cache(maxsize=8)
def _paper_kernel(sigma: float) -> np.ndarray:
"""Match OpenCV's uint8 Gaussian coefficients, including error diffusion.
See getGaussianKernelFixedPoint_ED in OpenCV's smooth.dispatch.cpp.
The 8-bit coefficients allow exact sums in the faster float filter path.
"""
size = round(sigma*6+1) | 1
kernel = cv2.getGaussianKernel(size, sigma).ravel()
fixed = np.zeros(size, np.float32)
error = 0.0
for i in range(size//2):
value = kernel[i]*256+error
weight = round(value)
error = value-weight
fixed[i] = fixed[-1-i] = weight
fixed[size//2] = 256-fixed.sum()
fixed /= 256
return fixed
def _paper_blur(gray: np.ndarray, sigma: float) -> np.ndarray:
kernel = _paper_kernel(sigma)
blurred = cv2.sepFilter2D(gray, cv2.CV_32F, kernel, kernel)
# GaussianBlur rounds positive half-integers upward, rather than to even.
np.add(blurred, .5, out=blurred)
np.floor(blurred, out=blurred)
return blurred.astype(np.uint8)
def _large_blank_ink(gray: np.ndarray, clean: np.ndarray, dpi: float):
"""Refine confirmed ruling, only accepting substantial extra blank margins.
Short directional openings tolerate locally bent/broken paper lines. A
physical component-size threshold rejects their remaining tiny fragments.
This is deliberately limited to already-confirmed ruled paper.
"""
px = dpi / 25.4
height, width = gray.shape
dark = 255-gray
length = max(9, round(2.5*px))
tolerance = max(3, round(.6*px) | 1)
lines = []
for horizontal in (True, False):
broadened = cv2.dilate(dark, np.ones(
(tolerance, 1) if horizontal else (1, tolerance), np.uint8))
lines.append(cv2.morphologyEx(broadened, cv2.MORPH_OPEN, np.ones(
(1, length) if horizontal else (length, 1), np.uint8)))
residual = cv2.subtract(dark, np.maximum(*lines))
n, labels, stats, centers = cv2.connectedComponentsWithStats(
(residual > 35).astype(np.uint8), 8)
keep = np.zeros(n, bool)
xx = stats[1:, 0]
ww, hh, area = stats[1:, 2], stats[1:, 3], stats[1:, 4]
density = area / (ww*hh)
shortest, longest = np.minimum(ww, hh), np.maximum(ww, hh)
compact_ink = ((area >= .8*px*px) & (shortest >= .6*px)
& (density > .3) & (longest < 4*shortest))
# Ruling suppression can fragment faint pencil handwriting into sparse,
# elongated components. Admit those moderately more readily in the central
# 80% of the sheet. The outermost 9% deliberately uses a stricter filter:
# punched holes, torn binding edges, and page numbers usually occur there.
center_x = xx + ww/2
central = (center_x > width*.1) & (center_x < width*.9)
central_ink = (central & (area >= .55*px*px) & (shortest >= .45*px)
& (density > .18) & (longest < 6*shortest))
outer = (center_x < width*.09) | (center_x > width*.91)
outer_ink = (outer & (area >= 1.2*px*px) & (shortest >= .8*px)
& (density >= .4) & (longest < 3*shortest))
keep[1:] = np.where(outer, outer_ink, compact_ink | central_ink)
# Use side columns as a prior, then require repeated size and alignment. An
# isolated note in the same column remains eligible to protect the margin.
candidates = np.flatnonzero(
((xx < 15*px) | (xx+ww > width-15*px))
& (ww > px) & (ww < 9*px) & (hh > px) & (hh < 12*px)) + 1
if len(candidates) > 128:
# Bound the matching cost and retain ambiguous, very noisy margins.
return clean, 0, False
holes = set()
for i in candidates:
matches = []
for j in candidates:
if (abs(centers[i, 0]-centers[j, 0]) < 2*px
and .6 < stats[j, 2]/stats[i, 2] < 1.6
and .6 < stats[j, 3]/stats[i, 3] < 1.6):
matches.append(j)
if len(matches) >= 3 and np.ptp(centers[matches, 1]) > height*.35:
holes.update(matches)
keep[list(holes)] = False
refined = keep[labels]
# Directional opening also removes long fraction bars. Protect very dark,
# thick straight strokes independently, even if ruling crosses their ends.
long_strokes = cv2.morphologyEx((gray < 50).astype(np.uint8), cv2.MORPH_OPEN,
np.ones((1, max(9, round(width*.1))), np.uint8))
count, lab, st, _ = cv2.connectedComponentsWithStats(long_strokes, 8)
bars = np.zeros(count, bool)
bw, bh, ba = st[1:, 2], st[1:, 3], st[1:, 4]
bars[1:] = ((bw > width*.1) & (bh >= .3*px) & (bh < height*.015)
& (bw > 8*bh) & (ba/(bw*bh) > .5))
strong_ruling, strong_lines = _ruling((gray < 50).astype(np.uint8)*255, True)
if strong_lines:
# A family of equally dark parallel lines is paper, not fraction bars.
overlap = np.bincount(lab[strong_ruling > 0], minlength=count)
bars &= overlap < st[:, 4]*.5
refined |= bars[lab]
ys = np.flatnonzero(np.any(refined, axis=1))
original = np.flatnonzero(np.any(clean, axis=1))
if not len(ys) or not len(original):
return clean, 0, False
# Leave ordinary small crops to the more permissive detector. Keep a
# recovery neighbourhood around the refined bounds for broken/faint strokes.
top, bottom = max(0, int(ys.min()-2*px)), min(height, int(ys.max()+1+2*px))
result = clean.copy()
changed = False
if top-original.min() >= 30*px:
result[:top] = 0
changed = True
if original.max()+1-bottom >= 30*px:
result[bottom:] = 0
changed = True
return result, len(holes), changed
def _neutral_paper_foreground(gray: np.ndarray, chroma: np.ndarray, dpi: float):
"""Clean confirmed dark ruling before it can seed whole pages.
Returns None for ordinary ink components or unconfirmed paper geometry.
The mask is transformed back to the original displayed pixel coordinates.
"""
height, width = gray.shape
# Only neutral darkness is relevant here: long blue equations are not
# evidence of dark paper. Broken ruling can form several medium-sized
# components instead of one page-spanning component. The broader threshold
# also admits faded gray grids; periodic ruling must still be confirmed.
_, _, stats, _ = cv2.connectedComponentsWithStats(
((gray < 160) & (chroma < 30)).astype(np.uint8), 8)
spans = np.maximum(stats[1:,2]/width, stats[1:,3]/height)
if not (np.any(spans > .35) or np.count_nonzero(spans > .1) >= 3):
return None, dict(paper_cleanup=False)
angle = _skew(gray, angle_step=.5)
matrix = cv2.getRotationMatrix2D((width/2, height/2), angle, 1)
corners = np.array([[0,0],[width,0],[0,height],[width,height]], dtype=float)
corners = cv2.transform(corners[None], matrix)[0]
origin = np.floor(corners.min(axis=0))
size = np.ceil(corners.max(axis=0)-origin).astype(int)
matrix[:, 2] -= origin
deskewed = cv2.warpAffine(gray, matrix, tuple(size), borderValue=255)
background = _paper_blur(deskewed, dpi/8)
normalized = cv2.divide(deskewed, np.maximum(background,1), scale=255)
block = max(15, int(dpi/5) | 1)
binary = cv2.adaptiveThreshold(normalized,255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV,block,9)
horizontal, nh = _ruling(binary,True)
vertical, nv = _ruling(binary,False)
if not (nh or nv):
return None, dict(paper_cleanup=False)
# The permissive mask retains faint, isolated marks.
# Bands identify where ruling is expected, but only actual dark pixels
# inside them may be suppressed. Erasing the complete band loses faint
# writing alongside a dark grid line.
paper_pixels = (horizontal|vertical) & ((normalized < 160).astype(np.uint8)*255)
clean, holes = _content_mask(_foreground(normalized,dpi,9,paper_pixels),dpi,nh,nv)
clean, repeated_holes, refined = _large_blank_ink(deskewed, clean, dpi)
restored = cv2.warpAffine(clean, cv2.invertAffineTransform(matrix),
(width,height), flags=cv2.INTER_NEAREST, borderValue=0)
return restored > 0, dict(paper_cleanup=True, angle_deg=round(angle,3),
horizontal_lines=nh,vertical_lines=nv,
edge_artifacts=holes+repeated_holes,
large_blank_refinement=refined)
def _seed_mask(mask: np.ndarray, px: float) -> np.ndarray:
n, labels, stats, _ = cv2.connectedComponentsWithStats(mask.astype(np.uint8), 8)
keep = np.zeros(n, bool)
keep[1:] = ((stats[1:, 4] >= max(4, .12*px*px))
& (stats[1:, 2] >= max(2, round(.35*px)))
& (stats[1:, 3] >= max(2, round(.35*px))))
return keep[labels]
def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
min_crop_mm: float = 5) -> dict:
"""Locate strong ink, recover adjacent faint strokes, and retain padding.
Neutral punched-hole shadows usually have neither sufficient chroma nor
sufficient darkness to seed a region. Nothing is discarded merely because
it is in a side margin. Two seed thresholds expose unstable boundaries.
"""
px = dpi/25.4
h, w = rgb.shape[:2]
red, green, blue = cv2.split(rgb)
lowest = cv2.min(cv2.min(red, green), blue)
chroma = cv2.subtract(cv2.max(cv2.max(red, green), blue), lowest)
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
darkness = 255-lowest
length = max(15, round(5.2*px)) | 1
horizontal = cv2.morphologyEx(darkness, cv2.MORPH_OPEN,
np.ones((1, length), np.uint8))
vertical = cv2.morphologyEx(darkness, cv2.MORPH_OPEN,
np.ones((length, 1), np.uint8))
residual = cv2.subtract(darkness, np.maximum(horizontal, vertical))
# Strong strokes bypass the line-background estimate entirely: even a long
# isolated black or coloured fraction bar must survive. Local contrast is
# used only to recover weaker surrounding strokes.
neutral_ink = gray < 95
cleaned, paper_info = _neutral_paper_foreground(gray, chroma, dpi)
if cleaned is not None:
neutral_ink = cleaned
weak = ((chroma > 60) | ((gray < 175) & (residual > 25))).astype(np.uint8)
radius = max(1, round(2*px))
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2*radius+1, 2*radius+1))
extents = []
counts = []
same_thresholds = not np.any((chroma > 100) & (chroma <= 115) & ~neutral_ink)
for threshold in (100, 115):
if threshold == 115 and same_thresholds:
counts.append(counts[0])
if extents:
extents.append(extents[0])
continue
seeds = _seed_mask((chroma > threshold) | neutral_ink, px)
counts.append(int(np.count_nonzero(seeds)))
if not np.any(seeds):
continue
# Limit weak recovery to a physical neighbourhood: faint grid lines
# connected to a letter cannot grow into a full-page foreground mask.
nearby = cv2.dilate(seeds.astype(np.uint8), kernel)
candidate = (weak & nearby) | seeds.astype(np.uint8)
n, labels = cv2.connectedComponents(candidate, 8)
seeded_labels = np.zeros(n, bool)
seeded_labels[np.unique(labels[seeds])] = True
seeded_labels[0] = False
ys = np.flatnonzero(np.any(seeded_labels[labels], axis=1))
extents.append((int(ys.min()), int(ys.max())+1))
result = dict(top_px=0, bottom_px=h, angle_deg=None,
horizontal_lines=0, vertical_lines=0, edge_artifacts=0,
detector="ink", seed_pixels=counts, status="review-no-ink-seeds")
result.update(paper_info)
if not extents:
return result
pad = padding_mm*px
top = max(0, int(np.floor(min(e[0] for e in extents)-pad)))
bottom = min(h, int(np.ceil(max(e[1] for e in extents)+pad)))
uncertain = []
if len(extents) < 2:
uncertain.append('seed-threshold')
else:
for edge, name in ((0, 'top'), (1, 'bottom')):
if abs(extents[0][edge]-extents[1][edge]) > max(pad, 3*px):
uncertain.append(name)
if top < min_crop_mm*px:
top = 0
if h-bottom < min_crop_mm*px:
bottom = h
result.update(top_px=top, bottom_px=bottom,
status=('review-'+'-'.join(uncertain) if uncertain else
'cropped' if top or bottom < h else 'unchanged'))
return result
+4
View File
@@ -78,6 +78,10 @@ def atomic_write_text(
_atomic_write(Path(path), text.encode(encoding))
def atomic_write_bytes(path: str | Path, payload: bytes) -> None:
_atomic_write(Path(path), payload)
def atomic_write_json(
path: str | Path,
value: JsonValue,
+146
View File
@@ -0,0 +1,146 @@
"""Shared geometry and foreground helpers for scanned paper backgrounds."""
import cv2
import numpy as np
def _runs(values: np.ndarray) -> list[tuple[int, int]]:
edges = np.diff(np.r_[False, values, False].astype(np.int8))
return list(zip(np.flatnonzero(edges == 1), np.flatnonzero(edges == -1)))
def _skew(gray: np.ndarray, angle_step: float = .1) -> float:
"""Use the dominant near-horizontal/vertical Hough angle, at reduced size."""
scale = min(1.0, 1200 / max(gray.shape))
small = cv2.resize(gray, None, fx=scale, fy=scale)
edges = cv2.Canny(small, 40, 120)
lines = cv2.HoughLinesP(edges, 1, np.deg2rad(angle_step), 60,
minLineLength=min(small.shape) * .16, maxLineGap=12)
if lines is None:
return 0.0
angles, weights = [], []
for x0, y0, x1, y1 in lines.reshape(-1, 4):
a = (np.degrees(np.arctan2(y1-y0, x1-x0)) + 45) % 90 - 45
if abs(a) <= 5:
angles.append(a)
weights.append(np.hypot(x1-x0, y1-y0))
return _dominant_angle(angles, weights)
def _dominant_angle(angles, weights) -> float:
if len(angles) < 4:
return 0.0
angles, weights = np.array(angles), np.array(weights)
bins = np.arange(-5.125, 5.126, .25)
hist, _ = np.histogram(angles, bins, weights=weights)
peak = (bins[hist.argmax()] + bins[hist.argmax()+1]) / 2
near = abs(angles-peak) < .4
if weights[near].sum() < .35 * weights.sum():
return 0.0
return float(np.average(angles[near], weights=weights[near]))
def _ruling(binary: np.ndarray, horizontal: bool) -> tuple[np.ndarray, int]:
"""Accept a family of long lines only when positions are largely periodic."""
h, w = binary.shape
length = max(25, int((w if horizontal else h) * .12))
kernel = cv2.getStructuringElement(cv2.MORPH_RECT,
(length, 1) if horizontal else (1, length))
connected = cv2.morphologyEx(binary, cv2.MORPH_CLOSE,
np.ones((1, 3) if horizontal else (3, 1), np.uint8))
lines = cv2.morphologyEx(connected, cv2.MORPH_OPEN, kernel)
counts = np.count_nonzero(lines, axis=1 if horizontal else 0)
bands = _runs(counts > (w if horizontal else h) * .18)
if len(bands) < 5:
return np.zeros_like(binary), 0
centers = np.array([(a+b)/2 for a, b in bands])
gaps = np.diff(centers)
# Missing lines and major/minor rulings may have integer-multiple spacing.
candidates = gaps[gaps >= 4]
regular = any(np.mean(abs(gaps / d - np.round(gaps / d)) < .16) >= .75
for d in candidates)
if not regular:
return np.zeros_like(binary), 0
accepted = np.zeros_like(binary)
for a, b in bands:
if horizontal:
accepted[max(0, a-2):b+2] = 255
else:
accepted[:, max(0, a-2):b+2] = 255
# Real scans have local warp as well as global skew. Recover shorter line
# segments close to the established ruling family, without extending the
# entire family into large empty gaps.
short = max(25, int((w if horizontal else h)*.035))
joined = cv2.morphologyEx(binary, cv2.MORPH_CLOSE,
np.ones((1, 7) if horizontal else (7, 1), np.uint8))
tolerant = cv2.dilate(joined, np.ones((3, 1) if horizontal else (1, 3), np.uint8))
fragments = cv2.morphologyEx(tolerant, cv2.MORPH_OPEN,
np.ones((1, short) if horizontal else (short, 1), np.uint8))
nearby = cv2.dilate(accepted, np.ones((15, 1) if horizontal else (1, 15), np.uint8))
accepted |= fragments & nearby if horizontal else fragments
return accepted, len(bands)
def _foreground(gray: np.ndarray, dpi: float, threshold: int,
ruling: np.ndarray) -> np.ndarray:
block = max(15, int(dpi / 5) | 1)
binary = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV, block, threshold)
# Only suppress near the fitted paper lines. Preserve unusually dark ink
# crossing pale ruling by comparing against the typical line intensity.
mask = cv2.dilate(ruling, np.ones((3, 3), np.uint8)) > 0
if np.any(ruling):
samples = ((ruling > 0) & (binary > 0)).astype(np.float32)
window = max(31, int(dpi*.4) | 1)
weight = cv2.boxFilter(samples, -1, (window, window))
total = cv2.boxFilter(gray.astype(np.float32)*samples, -1, (window, window))
typical = total / np.maximum(weight, 1e-6)
excess_ink = (gray.astype(float) < typical - 40).astype(np.uint8)
# A dark, one-pixel remnant of a paper line is still paper. Only retain
# locally thicker excess strokes inside the suppression mask.
excess_ink = cv2.erode(excess_ink, np.ones((2, 2), np.uint8)) > 0
mask &= ~excess_ink
binary[mask] = 0
return binary
def _content_mask(binary: np.ndarray, dpi: float, nh: int = 0,
nv: int = 0) -> tuple[np.ndarray, int]:
"""Filter only tiny speckles and repeated, matching edge-hole components."""
px = dpi / 25.4
# Small closing reconnects strokes interrupted by paper-line suppression.
grouped = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, np.ones((3, 3), np.uint8))
n, labels, stats, _ = cv2.connectedComponentsWithStats(grouped, 8)
keep = np.zeros(n, dtype=bool)
x, y, w, h, area = stats[1:].T
keep[1:] = (area >= max(4, .035*px*px)) & (np.maximum(w, h) >= .35*px)
# Thin straight residuals on confirmed ruled paper are not credible ink.
thin = max(2, round(.3*px))
if nv:
keep[1:] &= ~((w <= thin) & (h >= 3*w))
if nh:
keep[1:] &= ~((h <= thin) & (w >= 3*h))
# Hole shadows form recurring shapes close to a physical side edge. Never
# discard an entire margin strip: other writing there must remain visible.
height, width = binary.shape
candidates = np.flatnonzero(keep[1:]
& ((x+w < 12*px) | (x > width-12*px))
& (w > .8*px) & (w < 9*px) & (h > px) & (h < 12*px)) + 1
holes = set()
for i in candidates:
x, y, w, h, area = stats[i]
similar = []
a = cv2.resize((labels[y:y+h, x:x+w] == i).astype(np.uint8), (24, 32)) > 0
for j in candidates:
xx, yy, ww, hh, aa = stats[j]
if abs(x-xx) > 2*px or not (.7 < ww/w < 1.4 and .7 < hh/h < 1.4):
continue
b = cv2.resize((labels[yy:yy+hh, xx:xx+ww] == j).astype(np.uint8), (24, 32)) > 0
if np.count_nonzero(a & b) / max(1, np.count_nonzero(a | b)) > .60:
similar.append(j)
if len(similar) >= 4 and np.ptp(stats[similar, 1]) > height*.45:
holes.update(similar)
if holes:
keep[list(holes)] = False
# Bound original residual ink, not the expanded/grouped mask.
return (keep[labels] & (binary > 0)).astype(np.uint8), len(holes)
+57
View File
@@ -0,0 +1,57 @@
"""Split a PDF along its cumulative visible page height, in reading order."""
import math
from pathlib import Path
import pymupdf
from copienator.commands.splitting_int import _prepare_split_pages
# Percentages emitted by the GUI have six decimal places. Recover exact page
# boundaries despite rounding, without turning nearby in-page cuts into breaks.
BOUNDARY_TOLERANCE_PERCENT = 0.000001
def cut_position(heights: list[float], percent: float) -> tuple[int, float]:
"""Return (page index, offset); offset zero denotes an exact page break."""
if not heights or any(height <= 0 for height in heights):
raise ValueError("Le PDF doit contenir des pages non vides.")
if not math.isfinite(percent) or not 0 < percent < 100:
raise ValueError("Le pourcentage de coupe doit être strictement entre 0 et 100.")
total = sum(heights)
position = total * percent / 100
start = 0.0
for index, height in enumerate(heights):
if index and abs(percent - start / total * 100) <= BOUNDARY_TOLERANCE_PERCENT:
return index, 0.0
end = start + height
if index + 1 < len(heights) and abs(percent - end / total * 100) <= BOUNDARY_TOLERANCE_PERCENT:
return index + 1, 0.0
if position < end:
return index, position - start
start = end
raise ValueError("Coupe hors du document.")
def split_pdf(source: Path, percent: float, first_path: Path, second_path: Path) -> None:
"""Preserve whole pages; clip only the page actually crossed by the cut."""
with pymupdf.open(source) as document, pymupdf.open() as first, pymupdf.open() as second:
index, offset = cut_position([page.rect.height for page in document], percent)
if index:
first.insert_pdf(document, from_page=0, to_page=index - 1)
if offset == 0:
second.insert_pdf(document, from_page=index)
else:
with pymupdf.open() as page_document:
page_document.insert_pdf(document, from_page=index, to_page=index)
visible = _prepare_split_pages(page_document)[0]
for target, clip in (
(first, pymupdf.Rect(visible.x0, visible.y0, visible.x1, visible.y0 + offset)),
(second, pymupdf.Rect(visible.x0, visible.y0 + offset, visible.x1, visible.y1)),
):
page = target.new_page(width=clip.width, height=clip.height)
page.show_pdf_page(page.rect, page_document, 0, clip=clip)
if index + 1 < len(document):
second.insert_pdf(document, from_page=index + 1)
first.save(first_path)
second.save(second_path)
+51 -6
View File
@@ -8,6 +8,12 @@ import sys
from pathlib import Path
from typing import Literal
MACOS_EXECUTABLE_DIRECTORIES = (
Path("/opt/homebrew/bin"),
Path("/usr/local/bin"),
Path("/Library/TeX/texbin"),
)
WINDOWS_RESERVED_NAMES = {
"CON",
"PRN",
@@ -71,6 +77,36 @@ def safe_filename(value: str, fallback: str = "Unknown") -> str:
return cleaned
def add_platform_executable_paths(
environment: dict[str, str], platform_name: str | None = None
) -> dict[str, str]:
"""Expose common desktop-installed executables to child processes."""
result = dict(environment)
if (platform_name or sys.platform) != "darwin":
return result
existing = result.get("PATH", "").split(os.pathsep)
additions = [str(path) for path in MACOS_EXECUTABLE_DIRECTORIES if path.is_dir()]
result["PATH"] = os.pathsep.join(dict.fromkeys([*additions, *existing]))
return result
def find_executable(
*candidates: str, platform_name: str | None = None
) -> str | None:
"""Find a command in PATH or in standard macOS package locations."""
for candidate in candidates:
executable = shutil.which(candidate)
if executable:
return executable
if (platform_name or sys.platform) == "darwin":
for directory in MACOS_EXECUTABLE_DIRECTORIES:
for candidate in candidates:
executable = directory / candidate
if executable.is_file() and os.access(executable, os.X_OK):
return str(executable)
return None
def open_path(path: str | Path) -> None:
"""Open a file with the desktop's default application."""
target = str(Path(path).expanduser().resolve())
@@ -78,17 +114,26 @@ def open_path(path: str | Path) -> None:
os.startfile(target) # type: ignore[attr-defined]
elif sys.platform.startswith("linux"):
subprocess.Popen(["xdg-open", target])
elif sys.platform == "darwin":
opener = find_executable("open") or "/usr/bin/open"
subprocess.Popen([opener, target])
else:
raise RuntimeError(f"Unsupported platform: {sys.platform}")
def launch_pdf_arranger(path: str | Path) -> None:
executable = shutil.which("pdf-arranger") or shutil.which("pdfarranger")
if not executable:
raise FileNotFoundError(
"PDF Arranger is not installed or is not available in PATH."
)
subprocess.Popen([executable, str(Path(path).expanduser().resolve())])
target = str(Path(path).expanduser().resolve())
executable = find_executable("pdf-arranger", "pdfarranger")
if executable:
subprocess.Popen([executable, target])
return
if sys.platform == "darwin":
opener = find_executable("open") or "/usr/bin/open"
subprocess.Popen([opener, "-b", "com.apple.Preview", target])
return
raise FileNotFoundError(
"PDF Arranger is not installed or is not available in PATH."
)
def replace_with_link_or_copy(
+340
View File
@@ -0,0 +1,340 @@
from pathlib import Path
import io
from . import utils
PERSPECTIVE_GUIDANCE = (
"Ce barème est indicatif, si une réponse est entièrement correcte mais utilise "
"une méthode différente, elle mérite quand même tous les points."
)
main_prompt = """Je te fournis une image contenant plusieurs réponses manuscrites à un examen.
Chaque réponse est séparée de la précédente par une ligne horizontale noire.
En dessous de cette ligne, à gauche, figure l'identifiant de la réponse,
compris entre `01` et `50`.
Attribue à chaque réponse une note de 0 à 4. Les demi-points sont autorisés,
par exemple 2.5. Même si le résultat est faux, accorde au moins la moitié
des points si le raisonnement est correct et pourrait conduire au bon résultat.
Rédige tous les commentaires destinés à l'élève en français. Indique :
- quelle partie de sa réponse est fausse ;
- pourquoi elle est fausse ;
- éventuellement, ce qu'il aurait fallu faire à la place.
Les commentaires peuvent contenir des fragments LaTeX, par exemple
`$a^2 + b^2 = c^2$`.
Si la note n'est pas 4, fournis toujours un commentaire expliquant ce qui
manque, sauf dans le cas `empty-answer` décrit ci-dessous.
Lorsqu'un commentaire concerne une erreur située dans une partie précise
de la réponse, tu peux fournir un champ `box_2d` pour la localiser.
Ses coordonnées doivent être de la forme [ymin, xmin, ymax, xmax],
normalisées entre 0 et 1000. Sinon, attribue la valeur `null` à `box_2d`.
Si la réponse est correcte, aucun commentaire n'est nécessaire. Tu n'es pas
obligé de faire des commentaires positifs ; si tu en fais, ne leur associe
pas de `box_2d`.
Par exemple, si l'élève affirme à tort qu'une fonction est continue,
localise le mot « continue ». Si un calcul est faux, localise l'étape où
l'erreur apparaît et explique cette erreur dans le commentaire.
Évite les commentaires portant sur une confusion entre les lettres `n`
et `m`, `x` et `n`, ou `h` et `k`. En cas de doute, suppose que tu as mal
lu, sauf si la distinction est très importante.
Certains cas nécessitent une valeur particulière du champ `error` :
- L'élève n'a pas répondu à la bonne question : attribue la note 0 et
indique `wrong-label`, car il peut s'agir d'une erreur de label.
- La réponse contient aussi une réponse à une autre question de
l'exercice, sur plus de quelques lignes : note la question demandée,
mais indique `additional-answer`.
- La réponse est vide, ou l'élève a seulement recopié l'énoncé : indique
`empty-answer` et ne fournis aucun commentaire.
S'il n'y a aucune de ces erreurs, attribue la chaîne vide `""` à `error`.
Réponds uniquement en JSON, sous la forme d'une liste d'objets contenant
les clés `id` et `result`. L'objet `result` contient `score`, la liste
`feedback` et `error`. Chaque commentaire contient `text` et `box_2d`.
Conserve exactement ces clés, les identifiants et les valeurs techniques
de `error` : ne les traduis pas. Le contenu de chaque champ `text` doit
être en français, même si certains documents fournis sont dans une autre langue.
Exemple :
```json
[
{
"id": "01",
"result": {
"score": 2.5,
"feedback": [
{"text": "Il manque la vérification des hypothèses du théorème.", "box_2d": null},
{"text": "Non, la fonction n'est pas forcément continue.", "box_2d": [145, 280, 340, 500]}
],
"error": ""
}
},
{
"id": "04",
"result": {"score": 4.0, "feedback": [], "error": ""}
}
]
```
Voici l'énoncé de l'exercice ou la partie pertinente du problème :
```
<<text>>
```
Voici un corrigé possible :
```
<<corr>>
```
<<persp>>
Tu dois noter uniquement la question ou l'exercice portant le label
`<<label>>`. Ne note aucune autre question et ne formule aucun commentaire
sur les autres questions."""
from .utils import get_label_text_content, get_label_sol_content, get_label_persp_content
def make_prompt(input_dir,full_label):
text = get_label_text_content(input_dir, full_label) or ""
corr = get_label_sol_content(input_dir, full_label) or ""
persp = get_label_persp_content(input_dir, full_label) or ""
# print("Debug : l/t/c/p", full_label, text, corr, persp)
if persp:
persp = (
"\n\nVoici des consignes de notation complémentaires :\n\n"
+ PERSPECTIVE_GUIDANCE
+ "\n\n```\n"
+ persp
+ "\n```\n"
)
return main_prompt.replace("<<text>>", text).replace("<<corr>>", corr).replace("<<persp>>", persp).replace("<<label>>", full_label)
from pydantic import BaseModel, Field, TypeAdapter
from typing import List, Optional, Tuple
class FeedbackItem(BaseModel):
text: str = Field(description="Commentaire destiné à l’élève, rédigé en français.")
box_2d: Optional[List[int]] = Field(None, description="Coordonnées [ymin, xmin, ymax, xmax] normalisées entre 0 et 1000, ou null.")
class ResultData(BaseModel):
score: float = Field(description="Note numérique de la réponse, sur 4 points.")
feedback: List[FeedbackItem] = Field(description="Liste des commentaires destinés à l’élève, rédigés en français.")
error: str = Field(description="Type derreur : wrong-label, additional-answer, empty-answer, ou chaîne vide.")
class EvaluationEntry(BaseModel):
id: str = Field(description="Identifiant exact de la réponse.")
result: ResultData = Field(description="Note, commentaires en français et éventuelle erreur pour cette réponse.")
# These nested definitions do not work with the batch api, unroll them
UNROLLED_SCHEMA = {
"type": "ARRAY",
"items": {
"type": "OBJECT",
"properties": {
"id": {"type": "STRING", "description": "Identifiant exact de la réponse."},
"result": {
"type": "OBJECT",
"properties": {
"score": {"type": "NUMBER", "description": "Note numérique de la réponse, sur 4 points."},
"error": {"type": "STRING", "description": "Type derreur : wrong-label, additional-answer, empty-answer, ou chaîne vide."},
"feedback": {
"type": "ARRAY",
"description": "Liste des commentaires destinés à l’élève, rédigés en français.",
"items": {
"type": "OBJECT",
"properties": {
"text": {"type": "STRING", "description": "Commentaire destiné à l’élève, rédigé en français."},
"box_2d": {
"type": "ARRAY",
"items": {"type": "INTEGER"},
"nullable": True,
"description": "Coordonnées [ymin, xmin, ymax, xmax] normalisées entre 0 et 1000, ou null."
}
},
"required": ["text"]
}
}
},
"required": ["score", "feedback", "error"]
}
},
"required": ["id", "result"]
}
}
from google.genai import types
# The root model for parsing is be: List[EvaluationEntry]
def generate_request(input_dir, file, full_label):
"""Generates request for Gemini."""
prompt = make_prompt(input_dir, full_label)
image_path = Path(file)
contents = [
types.Content(
role="user",
parts=[
types.Part.from_bytes(
data=image_path.read_bytes(),
mime_type="image/jpeg"
),
types.Part.from_text(text=prompt),
],
)
]
generate_content_config = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=1.0,
top_p=0.95,
seed=0,
max_output_tokens=65535,
response_mime_type= "application/json",
response_json_schema= TypeAdapter(List[EvaluationEntry]).json_schema()
)
return (contents, generate_content_config)
from pdf2image import convert_from_path
from PIL import Image
import json
def get_single_image_bytes(pdf_path):
"""Converts a multi-page PDF into a single stitched JPEG in memory."""
imgs = convert_from_path(pdf_path, dpi=200) # Same DPI as grouping.py
if not imgs:
raise ValueError(f"No pages in {pdf_path}")
if len(imgs) == 1:
combined = imgs[0]
else:
max_width = max(img.width for img in imgs)
total_height = sum(img.height for img in imgs)
combined = Image.new('RGB', (max_width, total_height), 'white')
y_offset = 0
for img in imgs:
combined.paste(img, (0, y_offset))
y_offset += img.height
img_byte_arr = io.BytesIO()
combined.save(img_byte_arr, format='JPEG', quality=85)
return img_byte_arr.getvalue()
def request_for_box_correction(pdf_path, original_feedbacks):
img_bytes = get_single_image_bytes(pdf_path)
localized_feedbacks = [f for f in original_feedbacks if f["box_2d"]]
prompt = f"""Voici la réponse d'un élève à une question d'examen. Le JSON
ci-dessous contient des commentaires dont les rectangles de localisation
(`box_2d`) sont incorrects. Chaque commentaire doit correspondre à la
partie de la réponse où se trouve l'erreur signalée.
Par exemple, si l'élève affirme à tort qu'une fonction est continue,
les coordonnées doivent localiser le mot « continue ». Si un calcul est
faux, elles doivent localiser l'étape où apparaît l'erreur expliquée dans
le commentaire.
Analyse l'image et renvoie le même contenu JSON en corrigeant UNIQUEMENT
les coordonnées `box_2d` pour cette image. Conserve les commentaires en
français à l'identique : ne les reformule pas et ne les traduis pas.
Conserve les noms des clés JSON.
Les coordonnées doivent être [ymin, xmin, ymax, xmax], normalisées entre
0 et 1000. Si la zone est introuvable ou le rectangle invalide, renvoie
`null` pour ce rectangle.
Commentaires d'origine :
{json.dumps(localized_feedbacks, indent=2, ensure_ascii=False)}
"""
contents = [
types.Content(
role="user",
parts=[
types.Part.from_bytes(data=img_bytes, mime_type="image/jpeg"),
types.Part.from_text(text=prompt),
],
)
]
config = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=1.0,
response_mime_type="application/json",
response_json_schema=TypeAdapter(List[FeedbackItem]).json_schema()
)
return contents,config
def request_for_wrong_label(pdf_path, label, enonce, labels_txt):
prompt = f"""Cette image représente une partie de la réponse d'un élève à un examen.
Elle porte initialement le label '{label}', mais je soupçonne une erreur
de label. L'élève a peut-être lui-même écrit le mauvais label.
Analyse l'image et identifie le label de la question à laquelle cette
réponse correspond. Ne te fie pas au label écrit par l'élève : examine
le contenu de la réponse et les notations utilisées.
Renvoie UNIQUEMENT le label exact, sans le modifier ni le traduire.
Voici l'énoncé complet de l'examen :
{enonce}
Voici les labels possibles. Ta réponse doit être l'un d'entre eux :
{labels_txt}
"""
contents = [types.Content(role="user", parts=[
types.Part.from_bytes(data=get_single_image_bytes(pdf_path), mime_type="image/jpeg"),
types.Part.from_text(text=prompt)])]
config = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=1.0,
)
return contents, config
def request_for_additional_answer(pdf_path, label, enonce, labels_txt):
prompt = f"""Cette image représente une partie de la réponse d'un élève à un examen.
Elle porte initialement le label '{label}', mais je soupçonne qu'elle
contient aussi des réponses à une ou plusieurs autres questions.
Analyse l'image et identifie les labels des questions auxquelles elle
répond. Renvoie UNIQUEMENT une liste JSON contenant les labels exacts,
sans les modifier ni les traduire.
Si le bas de l'image ne contient que la première ligne d'une réponse à
une autre question, ignore cette ligne.
Voici l'énoncé complet de l'examen :
{enonce}
Voici les labels possibles. Chaque élément de ta liste doit être l'un
d'entre eux :
{labels_txt}
"""
contents = [types.Content(role="user", parts=[
types.Part.from_bytes(data=get_single_image_bytes(pdf_path), mime_type="image/jpeg"),
types.Part.from_text(text=prompt)
])]
config = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=1.0,
response_mime_type="application/json",
)
return contents, config
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
from pathlib import Path
from PIL import Image
from copienator import atomic_write_json, configuration, read_json, utils
from copienator.filesystem import staged_directory
from copienator.platform import safe_filename
RETURN_ANSWER_OPTIONS_FILE = Path(".copienator") / "return_answers.json"
def configured_return_answer_options() -> dict[str, bool]:
return {
"context": bool(configuration.RETURN_ANSWERS_CONTEXT),
"question": bool(configuration.RETURN_ANSWERS_QUESTION),
"solution": bool(configuration.RETURN_ANSWERS_SOLUTION),
}
def save_return_answer_options(
root: Path,
*,
context: bool,
question: bool,
solution: bool,
) -> None:
path = Path(root) / RETURN_ANSWER_OPTIONS_FILE
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(
path,
{"context": context, "question": question, "solution": solution},
)
def load_return_answer_options(root: Path) -> dict[str, bool]:
options = configured_return_answer_options()
path = Path(root) / RETURN_ANSWER_OPTIONS_FILE
if not path.is_file():
return options
loaded = read_json(path)
if not isinstance(loaded, dict):
raise ValueError(f"Expected a return-answer options object in {path}")
for name in options:
if name in loaded:
if type(loaded[name]) is not bool:
raise ValueError(f"Expected a boolean for {name!r} in {path}")
options[name] = loaded[name]
return options
def publish_answer_returns(
root: Path,
source: Path,
destination: Path,
*,
answers_only: bool = False,
) -> None:
"""Publish reviewed answers, optionally without touching return metadata."""
scores = read_json(source / "score.json")
if not isinstance(scores, dict):
raise ValueError(f"Expected a score object in {source}")
info_path = source / "info.json"
if not info_path.is_file():
raise ValueError(f"Missing {info_path}; recompile annotations before giving-names")
info = read_json(info_path)
if not isinstance(info, dict) or set(info) != set(scores):
raise ValueError(f"Invalid question information in {info_path}; recompile annotations")
for label, entry in info.items():
if (
not isinstance(entry, dict)
or set(entry) != {"present", "not_empty", "touched", "score"}
or any(type(entry[key]) is not bool for key in ("present", "not_empty", "touched"))
or (entry["not_empty"] and not entry["present"])
or (entry["touched"] and not entry["not_empty"])
):
raise ValueError(f"Invalid question information in {info_path}; recompile annotations")
# Match score.json, including manual score edits awaiting recompilation.
entry["score"] = scores[label]
answers_dir = destination / "answers"
if answers_dir.is_symlink():
raise ValueError(f"Expected a real answer directory: {answers_dir}")
if configuration.RETURN_ANSWERS_ENABLED:
options = load_return_answer_options(root)
labels = [label for label, entry in info.items() if entry["present"] and entry["not_empty"]]
# Import the rendering backend only when individual images are requested.
from copienator.commands.annotating import make_base_image
from copienator.commands.reading_annotations import concatenate
all_labels = utils.read_all_labels(root)
with staged_directory(answers_dir) as staging:
for label in sorted(labels, key=utils.natural_key):
paths = []
if options["context"]:
paths.extend(utils.pdf_images_of_contexts(root, label, all_labels))
if options["question"]:
paths.append(utils.pdf_image_of_enonce(root, label))
if options["solution"]:
paths.append(utils.pdf_image_of_solution(root, label))
images = []
for path in paths:
if path:
supplement, _, _ = make_base_image(path)
if supplement is None:
raise ValueError(f"Could not render {path}")
images.append(supplement)
with Image.open(source / f"{label}.jpg") as answer:
images.append(answer.convert("RGB"))
image = concatenate(images)
output = staging / f"{safe_filename(label)}.jpg"
if output.exists():
raise ValueError(
f"Answer labels produce the same filename in {answers_dir}: {label}"
)
image.save(output)
elif answers_dir.exists():
# Replace the managed directory with an empty one to remove stale exports.
with staged_directory(answers_dir):
pass
if not answers_only:
atomic_write_json(destination / "info.json", info)
(destination / "touched.json").unlink(missing_ok=True)
+24 -9
View File
@@ -2,7 +2,7 @@ import re
from pathlib import Path
from copienator import EvaluationWorkspace
from platform_utils import validate_windows_labels
from .platform import validate_windows_labels
def natural_key(text):
return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', str(text))]
@@ -30,7 +30,7 @@ def enonce_total(base_dir):
import os
import shlex
from platform_utils import open_path
from .platform import open_path
def edit_file_and_enter(file):
editor = os.environ.get("EDITOR")
@@ -139,7 +139,7 @@ import tempfile
import shutil
import subprocess
from config import LATEX_AFTER, LATEX_BEFORE
from .configuration import LATEX_AFTER, LATEX_BEFORE
def compile_to_pdf(text, output_pdf_path):
@@ -160,23 +160,38 @@ def compile_to_pdf(text, output_pdf_path):
# env['TEXINPUTS'] = f".:{current_dir}:"
try:
subprocess.run(
result = subprocess.run(
['pdflatex', '-interaction=nonstopmode', tex_filename],
cwd=temp_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
check=False
)
if "minted" in text:
subprocess.run(
result = subprocess.run(
['pdflatex', '-interaction=nonstopmode', tex_filename],
cwd=temp_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
check=False)
if result.returncode != 0:
error_lines = [
line.strip() for line in result.stdout.splitlines()
if line.lstrip().startswith("!")
]
detail = f": {error_lines[0]}" if error_lines else ""
print(
f"Warning: LaTeX compilation failed for {output_pdf_path} "
f"(exit code {result.returncode}){detail}"
)
generated_pdf = os.path.join(temp_dir, pdf_filename)
if os.path.exists(generated_pdf):
shutil.move(generated_pdf, output_pdf_path)
else:
print(f"Warning: LaTeX compilation produced no PDF for {output_pdf_path}")
except Exception as e:
print(f"Compilation error for {output_pdf_path}: {e}")
+40 -1
View File
@@ -93,6 +93,14 @@ class EvaluationWorkspace:
def labels_file(self) -> Path:
return self.root / "labels"
@property
def label_groups_file(self) -> Path:
return self.root / "label_groups"
@property
def gemini_exam_items_file(self) -> Path:
return self.root / "Tmp" / "exam_items.txt"
@property
def correction_file(self) -> Path:
return self.root / "correction.json"
@@ -101,6 +109,18 @@ class EvaluationWorkspace:
def correction_progress_file(self) -> Path:
return self.root / "correction_progress.json"
@property
def correction_pending_responses_file(self) -> Path:
return self.root / "correction_pending_responses.json"
@property
def batch_jobs_file(self) -> Path:
return self.root / "batch_jobs.json"
@property
def batched_correction_result_file(self) -> Path:
return self.root / "batched_correction_result.jsonl"
@property
def manual_resolutions_file(self) -> Path:
return self.root / "manual_resolutions.txt"
@@ -149,7 +169,26 @@ class EvaluationWorkspace:
def return_dir(self) -> Path:
return self.root / "A Rendre"
@property
def refaire_session_id(self) -> str | None:
from .json_io import read_json
session = read_json(self.root / "refaire-session.json", default=None)
if session is None:
return None
ident = session.get("id") if isinstance(session, dict) else None
if not isinstance(ident, str) or not re.fullmatch(r"reprise-[0-9]{8}-[0-9]{6}-[a-f0-9]{8}", ident):
raise ValueError("Invalid refaire-session.json")
return ident
@property
def refaire_session_dir(self) -> Path | None:
ident = self.refaire_session_id
return self.root / "Reprises" / ident if ident else None
def annotation_dir(self, mode: str) -> Path:
if mode == "refaire" and self.refaire_session_dir is not None:
return self.refaire_session_dir / "BRnot"
directories = {
"simple": "Anot",
"checks": "Bnot",
@@ -199,7 +238,7 @@ class EvaluationWorkspace:
missing = [
relative_path
for relative_path in relative_paths
if not (self.root / relative_path).is_dir()
if not (self.annotation_dir("refaire") if relative_path == "BRnot" else self.root / relative_path).is_dir()
]
if missing:
raise WorkspaceValidationError(self.root, missing)
+7 -9
View File
@@ -8,25 +8,23 @@ from copienator_gui.app import CopienatorApp
def personal_steps_enabled() -> bool:
try:
from config import SHOW_PERSONAL_STEPS
from copienator.configuration import SHOW_PERSONAL_STEPS
except (ImportError, AttributeError):
try:
from default_config import SHOW_PERSONAL_STEPS
except (ImportError, AttributeError):
return False
return False
return bool(SHOW_PERSONAL_STEPS)
def main() -> None:
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description="Interface graphique du workflow Copienator")
parser.add_argument("evaluation", nargs="?", type=Path, help="Dossier d’évaluation à ouvrir")
args = parser.parse_args()
args = parser.parse_args(argv)
repository = Path(__file__).resolve().parent
repository = Path.cwd().resolve()
evaluation = args.evaluation.resolve() if args.evaluation else None
app = CopienatorApp(repository, personal_steps_enabled(), evaluation)
app.mainloop()
return 0
if __name__ == "__main__":
main()
raise SystemExit(main())
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1179 -69
View File
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
"""Non-blocking, cancellable batch checks while the desktop GUI is open."""
import queue
from .runner import ProcessRunner
CHECK_INTERVAL_MS = 5 * 60 * 1000
class BatchMonitor:
def __init__(self, scheduler, on_ready, on_status, on_output):
self.scheduler = scheduler
self.on_ready = on_ready
self.on_status = on_status
self.on_output = on_output
self.active = False
self.timer = None
self.runner = None
def start(self, command, cwd, environment, log_path):
if self.active:
return
self.arguments = (command, cwd, environment, log_path)
self.active = True
self._check()
def stop(self):
self.active = False
if self.timer is not None:
self.scheduler.after_cancel(self.timer)
self.timer = None
if self.runner is not None:
try:
self.runner.force_stop()
except OSError as exc:
self.on_output(f"Arrêt de la vérification : {exc}\n")
self.runner = None
self.on_status("Vérification automatique arrêtée.")
def _later(self):
self.timer = self.scheduler.after(CHECK_INTERVAL_MS, self._check)
def _check(self):
self.timer = None
if not self.active:
return
self.runner = ProcessRunner()
self.on_status("Vérification des batchs en cours…")
try:
self.runner.start(*self.arguments)
except (OSError, RuntimeError) as exc:
self.on_output(f"Vérification impossible : {exc}\n")
self.runner = None
self.on_status("Échec de la vérification. Nouvel essai dans 5 minutes.")
self._later()
return
self.timer = self.scheduler.after(100, self._poll)
def _poll(self):
self.timer = None
if not self.active or self.runner is None:
return
while True:
try:
event, payload = self.runner.events.get_nowait()
except queue.Empty:
break
if event in {"output", "runner_error"}:
self.on_output(str(payload))
elif event == "finished":
code, interrupted = payload
self.runner = None
if code == 0 and not interrupted:
self.active = False
self.on_status("Tous les résultats batch sont prêts.")
self.on_ready()
else:
self.on_status("Résultats pas encore prêts. Nouvelle vérification dans 5 minutes.")
self._later()
return
self.timer = self.scheduler.after(100, self._poll)
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
import tkinter as tk
from pathlib import Path
from tkinter import messagebox, ttk
import pymupdf
from PIL import Image, ImageTk
from copienator.pdf_cut import cut_position
PAGE_GAP = 28
SNAP_PIXELS = 12
def percentage_at_y(y: float, heights: list[float], scale: float) -> float:
"""Convert canvas y to document height, snapping across inter-page gaps."""
top = 0.0
cumulative = 0.0
total = sum(heights)
for index, height in enumerate(heights):
bottom = top + height * scale
if index + 1 < len(heights) and bottom - SNAP_PIXELS <= y <= bottom + PAGE_GAP + SNAP_PIXELS:
return (cumulative + height) / total * 100
if y <= bottom:
return max(0.0, min(100.0, (cumulative + (y - top) / scale) / total * 100))
cumulative += height
top = bottom + PAGE_GAP
return 100.0
def y_at_percentage(percent: float, heights: list[float], scale: float) -> float:
if percent <= 0:
return 0.0
if percent >= 100:
return sum(heights) * scale + (len(heights) - 1) * PAGE_GAP
index, offset = cut_position(heights, percent)
top = sum(heights[:index]) * scale + index * PAGE_GAP
return top - PAGE_GAP / 2 if index and offset == 0 else top + offset * scale
def cut_operator(percent: float, keep: int, mode: str) -> str:
value = f"{percent:.6f}".rstrip("0").rstrip(".")
return f"c{{{value}}}{keep}{mode}"
class CutHelper(tk.Toplevel):
def __init__(self, parent, path: Path, on_accept, initial=None):
# Load the source before creating a window so invalid PDFs leave no dialog.
with pymupdf.open(path) as document:
if not len(document):
raise ValueError("Le PDF est vide.")
self.heights = [page.rect.height for page in document]
width = min(850, parent.winfo_screenwidth() - 100)
self.scale = min(1.5, width / max(page.rect.width for page in document))
rendered = []
for page in document:
pix = page.get_pixmap(matrix=pymupdf.Matrix(self.scale, self.scale), alpha=False,
colorspace=pymupdf.csRGB)
rendered.append(Image.frombytes("RGB", (pix.width, pix.height), pix.samples))
super().__init__(parent)
self.title(f"Cut — {path.name}")
self.geometry(f"{width + 45}x{min(850, parent.winfo_screenheight() - 100)}")
self.transient(parent.winfo_toplevel())
self.on_accept = on_accept
self.percent = initial[0] if initial else 50.0
self.keep = tk.IntVar(value=initial[1] if initial else 1)
self.mode = tk.StringVar(value=initial[2] if initial else ">")
self.caption = tk.StringVar()
ttk.Label(self, text="Déplacez la barre rouge. Entrée : afficher la commande ; Échap : annuler.",
wraplength=width).pack(anchor="w", padx=8, pady=5)
controls = ttk.Frame(self)
controls.pack(fill="x", padx=8)
ttk.Label(controls, text="Conserver à la source :").pack(side="left")
ttk.Radiobutton(controls, text="1 — début", variable=self.keep, value=1).pack(side="left")
ttk.Radiobutton(controls, text="2 — fin", variable=self.keep, value=2).pack(side="left")
ttk.Radiobutton(controls, text="Ajouter à la cible", variable=self.mode, value=">").pack(side="left")
ttk.Radiobutton(controls, text="Remplacer", variable=self.mode, value="x").pack(side="left")
ttk.Label(self, textvariable=self.caption).pack(anchor="w", padx=8, pady=5)
viewport = ttk.Frame(self)
viewport.pack(fill="both", expand=True)
self.canvas = tk.Canvas(viewport, background="#555555", highlightthickness=0)
scrollbar = ttk.Scrollbar(viewport, command=self.canvas.yview)
self.canvas.configure(yscrollcommand=scrollbar.set)
scrollbar.pack(side="right", fill="y")
self.canvas.pack(fill="both", expand=True)
self.images = [ImageTk.PhotoImage(image, master=self) for image in rendered]
top = 0.0
self.width = width
for index, image in enumerate(self.images):
self.canvas.create_image(0, top, anchor="nw", image=image)
top += self.heights[index] * self.scale
if index + 1 < len(self.images):
top += PAGE_GAP
self.canvas.configure(scrollregion=(0, 0, width, top))
self.bar = self.canvas.create_line(0, 0, width, 0, fill="#ff3030", width=4)
self.canvas.bind("<Button-1>", self.move_bar)
self.canvas.bind("<B1-Motion>", self.move_bar)
self.canvas.bind("<Button-4>", lambda event: self.canvas.yview_scroll(-3, "units"))
self.canvas.bind("<Button-5>", lambda event: self.canvas.yview_scroll(3, "units"))
self.canvas.bind("<MouseWheel>", lambda event: self.canvas.yview_scroll(-1 if event.delta > 0 else 1, "units"))
self.bind("<Return>", self.accept)
self.bind("<Escape>", lambda event: self.destroy())
self.keep.trace_add("write", lambda *_: self.draw_bar())
self.mode.trace_add("write", lambda *_: self.draw_bar())
self.draw_bar()
self.canvas.yview_moveto(max(0, (y_at_percentage(self.percent, self.heights, self.scale) - 200) / top))
self.focus_set()
self.grab_set()
def move_bar(self, event):
self.percent = percentage_at_y(self.canvas.canvasy(event.y), self.heights, self.scale)
self.draw_bar()
def draw_bar(self):
y = y_at_percentage(self.percent, self.heights, self.scale)
self.canvas.coords(self.bar, 0, y, self.width, y)
text = cut_operator(self.percent, self.keep.get(), self.mode.get())
if 0 < self.percent < 100:
index, offset = cut_position(self.heights, self.percent)
text += f" — entre les pages {index} et {index + 1}" if offset == 0 else f" — page {index + 1}"
self.caption.set(text)
def accept(self, event=None):
operator = cut_operator(self.percent, self.keep.get(), self.mode.get())
rounded = float(operator.split("{")[1].split("}")[0])
if not 0 < rounded < 100:
messagebox.showerror("Coupe invalide", "Chaque partie doit contenir une portion du PDF.", parent=self)
return
self.destroy()
self.on_accept(operator)
+14 -6
View File
@@ -7,7 +7,7 @@ import sys
from dataclasses import dataclass
from pathlib import Path
from platform_utils import windows_filename_problems
from copienator.platform import find_executable, windows_filename_problems
@dataclass(frozen=True)
@@ -33,8 +33,10 @@ def collect_diagnostics(
checks = [
DiagnosticCheck(
"Système",
os.name == "nt" or sys.platform.startswith("linux"),
f"{sys.platform} — Linux et Windows sont pris en charge",
os.name == "nt"
or sys.platform.startswith("linux")
or sys.platform == "darwin",
f"{sys.platform} — Linux, Windows et macOS sont pris en charge",
),
DiagnosticCheck("Python", True, sys.executable),
]
@@ -49,7 +51,7 @@ def collect_diagnostics(
"pdf2image": "pdf2image",
"reportlab": "reportlab",
"img2pdf": "img2pdf",
"PyMuPDF": "fitz",
"PyMuPDF": "pymupdf",
"ftfy": "ftfy",
"ezodf": "ezodf",
"Google GenAI": "google.genai",
@@ -65,7 +67,13 @@ def collect_diagnostics(
("PDF Arranger", ("pdf-arranger", "pdfarranger"), False),
)
for label, candidates, required in programs:
executable = next((shutil.which(candidate) for candidate in candidates if shutil.which(candidate)), None)
executable = find_executable(*candidates)
if label == "PDF Arranger" and not executable and sys.platform == "darwin":
system_open = Path("/usr/bin/open")
opener = find_executable("open") or (
str(system_open) if system_open.is_file() else None
)
executable = f"{opener} (Aperçu)" if opener else None
detail = executable or "Introuvable dans PATH"
checks.append(DiagnosticCheck(label, executable is not None, detail, required))
@@ -111,7 +119,7 @@ def collect_diagnostics(
)
)
try:
from config import CURRENT_SCORE_ODS_PATH, FINAL_SCORE_ODS_PATH
from copienator.configuration import CURRENT_SCORE_ODS_PATH, FINAL_SCORE_ODS_PATH
for label, configured_path in (
("ODS courant", CURRENT_SCORE_ODS_PATH),
+154
View File
@@ -0,0 +1,154 @@
from __future__ import annotations
import tkinter as tk
from pathlib import Path
from tkinter import messagebox, ttk
from copienator import CliError
from copienator.commands.resolve_manual import get_actual_pdf, parse_instruction_text
from copienator.platform import open_path
from .cut_helper import CutHelper
HELP = """La correction propose x> pour un mauvais label et -> pour une réponse supplémentaire lorsque le PDF cible existe déjà. Vérifiez les deux PDF avant de choisir.
Format : Copie01 Label source OP Label cible|
Conservez Copie suivi du numéro et les labels exacts (sans .pdf). Les espaces dans les labels sont acceptés ; entourez lopérateur despaces.
-> : fusionner dans la cible, conserver la source.
x> : fusionner dans la cible, archiver la source.
-x : remplacer la cible par une copie de la source, conserver la source.
xx : remplacer la cible par une copie de la source, archiver la source.
ss : conserver les deux PDF sans fusion.
sx : conserver la source, archiver la cible, sans fusion.
xs : archiver la source, conserver la cible, sans fusion.
c{43}1> : couper la source à 43 %, conserver le début et ajouter la fin à la cible.
c{43}2x : couper la source à 43 %, conserver la fin et remplacer la cible par le début.
1 conserve le début, 2 conserve la fin ; > fusionne lautre partie avec la cible, x la remplace. Le pourcentage porte sur la hauteur cumulée des pages visibles. Les décimales sont acceptées. Une seule coupe par label source est autorisée.
Cut permet de placer la coupe, avec accrochage entre les pages. Entrée ferme laperçu et affiche la commande à recopier ci-dessus : aucun fichier nest modifié. Une séparation entre pages conserve les pages entières. La source complète est archivée en _old ; les deux labels modifiés sont générés en _new et ajoutés à refaire.json.
Pour les fusions : « Source x> Cible| » place la cible avant la source ; « Source x> |Cible » place la source avant la cible. Même règle avec -> et c{…}1>/c{…}2> (pour la partie transférée) ; sans |, la cible vient en premier.
Vous pouvez changer lopérateur, déplacer |, corriger les labels ou retirer une instruction. Les lignes vides et celles commençant par ### sont ignorées. Retirer/commenter une ligne ne résout pas son conflit.
Enregistrez dans l’éditeur, puis cliquez sur Recharger et enfin sur Exécuter. Seul le fichier enregistré est appliqué. Larchivage utilise le suffixe _old ; les PDF créés utilisent _new. Après succès, manual_resolutions.txt est supprimé et correction.json est mis à jour. Si des PDF sont créés, refaire.json est généré : relancez la correction avec --refaire."""
class ManualResolutionPanel(ttk.Frame):
def __init__(self, parent, get_evaluation):
super().__init__(parent)
self.get_evaluation = get_evaluation
self.pdf_buttons = []
self.cut_buttons = []
actions = ttk.Frame(self)
actions.pack(fill="x")
self.editor_button = ttk.Button(
actions, text="Ouvrir dans un éditeur de texte", command=self.open_editor
)
self.editor_button.pack(side="left")
ttk.Button(actions, text="Recharger", command=self.reload).pack(side="left", padx=6)
self.cut_result = tk.StringVar()
result = ttk.Frame(self)
result.pack(fill="x", pady=4)
ttk.Entry(result, textvariable=self.cut_result, state="readonly").pack(side="left", fill="x", expand=True)
ttk.Button(result, text="Copier la commande Cut", command=self.copy_cut_command).pack(side="left", padx=6)
self.status = ttk.Label(self, wraplength=650)
self.status.pack(fill="x", pady=4)
preview = ttk.Frame(self)
preview.pack(fill="both", expand=True)
self.text = tk.Text(preview, height=10, width=50, wrap="word", state="disabled")
scroll = ttk.Scrollbar(preview, command=self.text.yview)
self.text.configure(yscrollcommand=scroll.set)
scroll.pack(side="right", fill="y")
self.text.pack(fill="both", expand=True)
ttk.Label(self, text=HELP, wraplength=650, justify="left").pack(fill="x", pady=8)
self.reload()
def manual_path(self) -> Path | None:
evaluation = self.get_evaluation()
return evaluation / "manual_resolutions.txt" if evaluation else None
def open_editor(self):
path = self.manual_path()
if path is None or not path.is_file():
self.reload()
return
try:
# .txt is opened in the desktop's associated text editor.
open_path(path)
except (OSError, RuntimeError) as exc:
messagebox.showerror("Ouverture impossible", str(exc))
def open_pdf(self, evaluation, copy_id, label):
path = get_actual_pdf(evaluation / "Copies", copy_id, label)
try:
if not path.is_file():
raise FileNotFoundError(f"PDF introuvable : {path}")
open_path(path)
except (OSError, RuntimeError) as exc:
messagebox.showerror("Ouverture du PDF", str(exc))
def reload(self):
for button in self.pdf_buttons + self.cut_buttons:
button.destroy()
self.pdf_buttons.clear()
self.cut_buttons.clear()
self.cut_result.set("")
self.text.configure(state="normal")
self.text.delete("1.0", "end")
path = self.manual_path()
self.editor_button.configure(state="disabled")
try:
if path is None:
raise FileNotFoundError("Chargez une évaluation.")
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
self.status.configure(text=f"manual_resolutions.txt indisponible : {exc}")
else:
self.editor_button.configure(state="normal")
malformed = []
for number, raw in enumerate(content.splitlines(keepends=True), 1):
self.text.insert("end", raw.rstrip("\r\n"))
try:
instructions = parse_instruction_text(raw)
except CliError:
malformed.append(str(number))
else:
if instructions:
instruction = instructions[0]
for title, label in (("PDF source", instruction.old_label),
("PDF cible", instruction.new_label)):
button = ttk.Button(
self.text, text=title,
command=lambda label=label, copy_id=instruction.copy_id, root=path.parent: self.open_pdf(root, copy_id, label),
)
self.pdf_buttons.append(button)
self.text.window_create("end", window=button, padx=8)
button = ttk.Button(self.text, text="Cut",
command=lambda item=instruction, root=path.parent: self.open_cut(root, item))
self.cut_buttons.append(button)
self.text.window_create("end", window=button, padx=8)
if raw.endswith("\n"):
self.text.insert("end", "\n")
detail = (" — lignes invalides : " + ", ".join(malformed)) if malformed else (
f"{len(self.pdf_buttons) // 2} instruction(s)"
)
self.status.configure(text=str(path) + detail)
finally:
self.text.configure(state="disabled")
def copy_cut_command(self):
if self.cut_result.get():
self.clipboard_clear()
self.clipboard_append(self.cut_result.get())
def open_cut(self, evaluation, instruction):
source = get_actual_pdf(evaluation / "Copies", instruction.copy_id, instruction.old_label)
def accepted(operator):
target = ("|" + instruction.new_label) if instruction.pipe_first else (instruction.new_label + "|")
self.cut_result.set(f"Copie{instruction.copy_id} {instruction.old_label} {operator} {target}")
try:
initial = (*instruction.cut, instruction.operator[-1]) if instruction.cut else None
CutHelper(self, source, accepted, initial)
except (OSError, RuntimeError, ValueError) as exc:
messagebox.showerror("Ouverture du PDF", str(exc))
+37
View File
@@ -0,0 +1,37 @@
"""Launch native desktop notifications without blocking Tk."""
import base64
import json
import os
import subprocess
import sys
from copienator.platform import find_executable
def notify_desktop(title: str, message: str) -> None:
if sys.platform.startswith("linux"):
executable = find_executable("notify-send")
if not executable:
raise RuntimeError("Installez notify-send (libnotify) pour les notifications de bureau.")
command = [executable, "--app-name=Copienator", "--", title, message]
elif sys.platform == "darwin":
command = ["/usr/bin/osascript", "-e",
f"display notification {json.dumps(message, ensure_ascii=False)} with title {json.dumps(title, ensure_ascii=False)}"]
elif os.name == "nt":
# Encode the script and quote strings as literals; no shell interpolation.
quote = lambda value: "'" + value.replace("'", "''") + "'"
script = (
"Add-Type -AssemblyName System.Windows.Forms;"
"$notice = New-Object System.Windows.Forms.NotifyIcon;"
"$notice.Icon = [System.Drawing.SystemIcons]::Information;"
"$notice.Visible = $true;"
f"$notice.ShowBalloonTip(10000, {quote(title)}, {quote(message)}, "
"[System.Windows.Forms.ToolTipIcon]::Info);"
"Start-Sleep -Seconds 12; $notice.Dispose()"
)
command = ["powershell.exe", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden",
"-EncodedCommand", base64.b64encode(script.encode("utf-16-le")).decode("ascii")]
else:
raise RuntimeError("Notifications de bureau indisponibles sur ce système.")
subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+244
View File
@@ -0,0 +1,244 @@
"""Selection and command planning for the optional redo workflow."""
from __future__ import annotations
import re
import tkinter as tk
from pathlib import Path
from tkinter import ttk
from copienator import EvaluationWorkspace, read_json
from copienator.utils import natural_key
SECTION = "Refaire des copies (facultatif)"
ALL_LABELS = "Toute la copie"
ALL_COPIES = "Toutes les copies"
LAYOUTS = {
"Automatique": "auto",
"Par question (groupé)": "grouped",
"Par copie": "copies",
}
def resolve_layout(
selection: list[list], labels: list[str], choice: str = "auto"
) -> str:
if choice in {"grouped", "copies"}:
return choice
seen = set()
for _name, selected in selection:
current = set(selected or labels)
if seen & current:
return "grouped"
seen.update(current)
return "copies"
def copies_with_answer(copies: dict[str, Path], label: str) -> list[str]:
return [
name
for name, path in copies.items()
if any(
(path.with_suffix("") / f"{label}{suffix}.pdf").is_file()
for suffix in ("", "_new")
)
]
def available_copies(evaluation: Path) -> dict[str, Path]:
return {
path.stem: path
for path in sorted((evaluation / "Copies").glob("Copie*.pdf"), key=natural_key)
if re.fullmatch(r"Copie\d+", path.stem)
}
def validate_selection(
entries: object, copies: dict[str, Path], labels: list[str]
) -> list[list]:
if not isinstance(entries, list) or not entries:
raise ValueError("Ajoutez au moins une copie à refaire.")
result = {}
for entry in entries:
if not isinstance(entry, list) or len(entry) != 2:
raise ValueError("Sélection de copies invalide.")
name, selected = entry
if not isinstance(name, str) or name not in copies:
raise ValueError(f"Copie introuvable : {name}")
if not isinstance(selected, list) or any(
not isinstance(label, str) or label not in labels for label in selected
):
raise ValueError(f"Question inconnue pour {name}. Reprenez la sélection.")
if name in result:
raise ValueError(f"Copie sélectionnée plusieurs fois : {name}")
result[name] = sorted(set(selected), key=natural_key)
return [[name, result[name]] for name in sorted(result, key=natural_key)]
def load_selection(evaluation: Path) -> list[list]:
return validate_selection(
read_json(evaluation / "refaire.json"),
available_copies(evaluation),
EvaluationWorkspace(evaluation).read_labels(),
)
class RefaireSelection(ttk.Frame):
def __init__(
self,
parent,
evaluation: Path,
draft: dict,
directories: tuple[str, ...],
preferred: str,
):
super().__init__(parent)
self.columnconfigure(1, weight=1)
self.copies = available_copies(evaluation)
self.labels = (
EvaluationWorkspace(evaluation).read_labels()
if (evaluation / "labels").is_file()
else []
)
self.entries = {}
self.error = ""
try:
entries = draft.get("selection")
if entries is None:
entries = read_json(evaluation / "refaire.json", default=[])
if entries:
self.entries = dict(
validate_selection(entries, self.copies, self.labels)
)
except (OSError, ValueError, TypeError) as exc:
self.error = str(exc)
self.copy_var = tk.StringVar(value=next(iter(self.copies), ""))
self.label_var = tk.StringVar(value=ALL_LABELS)
source = draft.get("annotation_dir", preferred)
self.source_var = tk.StringVar(
value=source if source in directories else preferred
)
ttk.Label(self, text="Copie").grid(row=0, column=0, sticky="w", padx=(0, 8))
ttk.Combobox(
self,
textvariable=self.copy_var,
values=(ALL_COPIES, *self.copies),
state="readonly",
width=12,
).grid(row=0, column=1, sticky="ew")
ttk.Label(self, text="Question").grid(row=1, column=0, sticky="w", pady=5)
ttk.Combobox(
self,
textvariable=self.label_var,
values=(ALL_LABELS, *self.labels),
state="readonly",
width=12,
).grid(row=1, column=1, sticky="ew", pady=5)
ttk.Button(self, text="+ Ajouter", command=self.add).grid(
row=1, column=2, padx=(8, 0)
)
self.table = ttk.Treeview(
self, columns=("labels",), height=4, selectmode="browse"
)
self.table.heading("#0", text="Copie")
self.table.heading("labels", text="Questions à refaire")
self.table.column("#0", width=100, stretch=False)
self.table.column("labels", width=330)
self.table.grid(row=4, column=0, columnspan=3, sticky="nsew")
scrollbar = ttk.Scrollbar(self, orient="vertical", command=self.table.yview)
scrollbar.grid(row=4, column=3, sticky="ns")
self.table.configure(yscrollcommand=scrollbar.set)
ttk.Button(
self, text="Retirer la copie sélectionnée", command=self.remove
).grid(row=5, column=0, columnspan=3, sticky="w", pady=5)
ttk.Label(self, text="Passage principal").grid(
row=3, column=0, sticky="w", padx=(0, 8)
)
ttk.Combobox(
self,
textvariable=self.source_var,
values=directories,
state="readonly",
width=12,
).grid(row=3, column=1, sticky="ew")
layout = draft.get("layout", "auto")
self.layout_var = tk.StringVar(
value=next(
(name for name, code in LAYOUTS.items() if code == layout),
"Automatique",
)
)
ttk.Label(self, text="PDF à vérifier").grid(row=2, column=0, sticky="w")
ttk.Combobox(
self,
textvariable=self.layout_var,
values=tuple(LAYOUTS),
state="readonly",
width=12,
).grid(row=2, column=1, sticky="ew")
self.message_var = tk.StringVar(
value=self.error
or "Choisissez « Toutes les copies » pour refaire une question dans toute la classe."
)
help_label = ttk.Label(
self,
textvariable=self.message_var,
wraplength=500,
)
help_label.grid(row=6, column=0, columnspan=3, sticky="w", pady=5)
self.bind(
"<Configure>",
lambda event: help_label.configure(wraplength=max(200, event.width - 10)),
)
self.refresh()
def refresh(self):
self.table.delete(*self.table.get_children())
for name in sorted(self.entries, key=natural_key):
self.table.insert(
"",
"end",
iid=name,
text=name,
values=(", ".join(self.entries[name]) or ALL_LABELS,),
)
def add(self):
name, label = self.copy_var.get(), self.label_var.get()
if name not in (ALL_COPIES, *self.copies) or label not in (
ALL_LABELS,
*self.labels,
):
return
names = (
[name]
if name != ALL_COPIES
else list(self.copies)
if label == ALL_LABELS
else copies_with_answer(self.copies, label)
)
for copy_name in names:
# Adding a question must not narrow a copy already selected in full.
if label == ALL_LABELS:
self.entries[copy_name] = []
elif copy_name not in self.entries or self.entries[copy_name]:
self.entries[copy_name] = sorted(
set(self.entries.get(copy_name, [])) | {label}, key=natural_key
)
message = f"{len(names)} copie(s) ajoutée(s)."
if name == ALL_COPIES and len(names) < len(self.copies):
message += f" {len(self.copies) - len(names)} sans réponse découpée pour cette question."
self.message_var.set(message)
self.refresh()
def remove(self):
for name in self.table.selection():
self.entries.pop(name, None)
self.refresh()
def values(self):
return {
"selection": [[name, labels] for name, labels in self.entries.items()],
"annotation_dir": self.source_var.get(),
"layout": LAYOUTS[self.layout_var.get()],
}
+86
View File
@@ -0,0 +1,86 @@
"""Preserve completed redo passes and activate a fresh working directory."""
from __future__ import annotations
import copy
import shutil
import uuid
from datetime import datetime
from typing import Any
from copienator import EvaluationWorkspace, atomic_write_json, read_json
from copienator.filesystem import staged_files
RESTART_WARNING = (
"Appelez « Nouvelle reprise » seulement après avoir importé les résultats "
"de la reprise en cours et exécuté « Mettre à jour les copies finales ». "
"Cette consigne sapplique aussi à « Refaire la même sélection »."
)
def _identifier() -> str:
return f"reprise-{datetime.now().astimezone():%Y%m%d-%H%M%S}-{uuid.uuid4().hex[:8]}"
def begin_pass(
workspace: EvaluationWorkspace,
state: dict[str, Any],
*,
keep_selection: bool,
) -> tuple[dict[str, Any], str]:
"""Activate a pass atomically with its selection and reset GUI state.
Existing review files remain in place. Legacy BRnot is copied once into
the archive; failures before activation leave the old pass active.
"""
selection = read_json(workspace.refaire_file, default=[])
previous = workspace.refaire_session_dir
if previous is None and (
workspace.refaire_file.exists() or workspace.annotation_dir("refaire").exists()
):
previous = workspace.root / "Reprises" / _identifier()
previous.mkdir(parents=True)
legacy = workspace.annotation_dir("refaire")
if legacy.is_dir():
shutil.copytree(legacy, previous / "BRnot")
if previous is not None:
atomic_write_json(previous / "refaire.json", selection)
atomic_write_json(
previous / "progression.json",
{
name: entry
for name, entry in state.get("steps", {}).items()
if name.startswith("refaire_")
},
)
ident = _identifier()
directory = workspace.root / "Reprises" / ident
directory.mkdir(parents=True)
new_selection = selection if keep_selection else []
updated = copy.deepcopy(state)
values = dict(
updated.get("steps", {}).get("refaire_selection", {}).get("values", {})
)
values["selection"] = new_selection
updated["steps"] = {
name: entry
for name, entry in updated.get("steps", {}).items()
if not name.startswith("refaire_")
}
updated["steps"]["refaire_selection"] = {"values": values}
updated.setdefault("history", []).append(
{
"step": "refaire_selection",
"action": "repeat" if keep_selection else "new",
"session": ident,
"timestamp": datetime.now().astimezone().isoformat(),
}
)
atomic_write_json(directory / "refaire.json", new_selection)
atomic_write_json(directory / "session.json", {"id": ident, "values": values})
with staged_files(workspace.root) as staging:
atomic_write_json(staging / workspace.refaire_file.name, new_selection)
atomic_write_json(staging / workspace.gui_state_file.name, updated)
atomic_write_json(staging / "refaire-session.json", {"id": ident})
return updated, ident
+6 -3
View File
@@ -27,12 +27,15 @@ class ProcessRunner:
command: list[str],
cwd: Path,
environment: dict[str, str],
log_path: Path,
log_path: Path | None,
) -> None:
if self.running:
raise RuntimeError("Un processus est déjà en cours")
log_path.parent.mkdir(parents=True, exist_ok=True)
self._log_file = log_path.open("wb")
if log_path is not None:
log_path.parent.mkdir(parents=True, exist_ok=True)
self._log_file = log_path.open("wb")
else:
self._log_file = None
self._interrupted = False
kwargs: dict[str, Any] = {}
+453 -98
View File
@@ -7,6 +7,9 @@ from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
from copienator import configuration
from copienator.configuration import ALWAYS_CROP
EVALUATION = "${evaluation}"
@@ -16,6 +19,7 @@ class ArgumentSpec:
label: str
kind: str = "text" # text, int, bool, choice, path
flag: str | None = None
false_flag: str | None = None
default: object = ""
choices: tuple[str, ...] = ()
help: str = ""
@@ -32,6 +36,8 @@ class CommandVariant:
fixed_args: tuple[str, ...] = ()
fixed_args_before_positionals: bool = False
dangerous: bool = False
danger_warning: str | None = None
supports_verbose: bool = False
@dataclass(frozen=True)
@@ -46,31 +52,75 @@ class StepDefinition:
personal: bool = False
requires: tuple[str, ...] = ()
artifacts: tuple[str, ...] = ()
auto_start_first_visit: bool = False
skip_for_live_correction: bool = False
skip_without_manual_conflicts: bool = False
extra_arguments_help: str = ""
extra_arguments_variants: tuple[str, ...] = ()
@property
def is_manual(self) -> bool:
return all(variant.kind == "manual" for variant in self.variants)
def arg_target(help_text: str = "Dossier d’évaluation ou fichier à traiter") -> ArgumentSpec:
def arg_target(help_text: str = "un dossier d’évaluation ou un fichier à traiter") -> ArgumentSpec:
return ArgumentSpec(
"target",
"Cible",
kind="path",
default=EVALUATION,
positional=True,
help=help_text,
help=f"La cible peut être {help_text.rstrip('.')}.",
)
def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
python = lambda ident, label, script, **kwargs: CommandVariant(
ident, label, script, "python", **kwargs
)
def python(ident, label, script, **kwargs):
supports_verbose = kwargs.pop("supports_verbose", True)
return CommandVariant(
ident,
label,
script,
"python",
supports_verbose=supports_verbose,
**kwargs,
)
manual = lambda ident, label="Étape manuelle": CommandVariant(
ident, label, None, "manual"
)
return_answer_arguments = ()
if configuration.RETURN_ANSWERS_ENABLED:
return_answer_arguments = (
ArgumentSpec(
"return_answers_context",
"Inclure le contexte dans answers",
"bool",
"--return-answers-context",
"--no-return-answers-context",
default=configuration.RETURN_ANSWERS_CONTEXT,
help="Inclut les pages de contexte applicables avant chaque réponse individuelle publiée dans answers.",
),
ArgumentSpec(
"return_answers_question",
"Inclure l’énoncé dans answers",
"bool",
"--return-answers-question",
"--no-return-answers-question",
default=configuration.RETURN_ANSWERS_QUESTION,
help="Inclut l’énoncé actuel avant chaque réponse individuelle publiée dans answers.",
),
ArgumentSpec(
"return_answers_solution",
"Inclure la correction dans answers",
"bool",
"--return-answers-solution",
"--no-return-answers-solution",
default=configuration.RETURN_ANSWERS_SOLUTION,
help="Inclut la correction actuelle avant chaque réponse individuelle publiée dans answers.",
),
)
steps = [
StepDefinition(
"inputs",
@@ -86,27 +136,43 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Prétraitement de l’énoncé",
"Analyser l’énoncé",
"Détecte les questions, leurs groupes, le corrigé et les indications de barème.",
(
python("gemini", "Analyse avec Gemini", "gemini_for_enonce.py"),
python("personal", "Alternative enonce_info.py", "enonce_info.py"),
),
((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),)
if show_personal_steps else ())
+ (python("gemini", "Analyse avec Gemini", "statement"),),
arguments=(
arg_target("Dossier de l’évaluation"),
arg_target("le dossier de l’évaluation"),
ArgumentSpec(
"restart",
"Ignorer le cache (--restart)",
kind="bool",
flag="--restart",
help="Ignore les résultats Gemini mis en cache et recommence entièrement lanalyse de l’énoncé.",
variants=("gemini",),
),
),
requires=("enonce.pdf", "enonce.tex", "correction.tex"),
artifacts=("labels", "Text", "Sol", "Persp"),
),
StepDefinition(
"statement_groups", "Prétraitement de l’énoncé", "Regrouper les questions avec Gemini",
"Facultatif après la génération : remplace les groupes par exercice par des groupes "
"proposés par Gemini, en conservant les labels, les énoncés, les solutions et les barèmes.",
(python("default", "Groupes Gemini", "statement", fixed_args=("--groups-only",)),),
arguments=(arg_target("le dossier de l’évaluation"),),
optional=True, personal=True, requires=("labels", "Text2", "Sol2"),
),
StepDefinition(
"statement_persp", "Prétraitement de l’énoncé", "Remplacer les barèmes par Gemini",
"Facultatif : remplace Persp par des barèmes Gemini sur 4 points, pour les groupes actuels. "
"Les énoncés et les solutions personnels sont conservés.",
(python("default", "Barèmes Gemini", "statement", fixed_args=("--persp-only",)),),
arguments=(arg_target("le dossier de l’évaluation"),),
optional=True, personal=True, requires=("labels", "label_groups", "Text2", "Sol2"),
),
StepDefinition(
"review_persp",
"Prétraitement de l’énoncé",
"Relire les barèmes dans Persp",
"Relire les barèmes",
"Étape manuelle facultative : vérifier et modifier les instructions de correction.",
(manual("review"),),
optional=True,
@@ -121,13 +187,14 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
CommandVariant(
"rotate",
"Rotation",
"copies_tools.py",
"copies",
"python",
("rotate",),
fixed_args_before_positionals=True,
supports_verbose=True,
),
),
arguments=(arg_target("Dossier de l’évaluation"),),
arguments=(arg_target("le dossier de l’évaluation"),),
optional=True,
),
StepDefinition(
@@ -139,32 +206,81 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
CommandVariant(
"rename",
"Renommage",
"copies_tools.py",
"copies",
"python",
("rename",),
fixed_args_before_positionals=True,
supports_verbose=True,
),
),
arguments=(arg_target("Dossier de l’évaluation"),),
arguments=(arg_target("le dossier de l’évaluation"),),
),
StepDefinition(
"page_splitter",
"Prétraitement des copies",
"Séparer et réordonner les pages",
"Ouvre loutil interactif de découpage A3 vers A4. La cible peut être un dossier ou un PDF.",
(python("default", "Séparation des pages", "page_splitter.py"),),
arguments=(arg_target(),),
(python("default", "Séparation des pages", "page-split"),),
arguments=(
arg_target(),
ArgumentSpec(
"marked",
"Copies signalées uniquement",
"bool",
"--marked",
help="Limite lopération aux copies signalées dans linterface au lieu de traiter toutes les copies.",
),
),
artifacts=("Copies", "Copies Originales"),
),
StepDefinition(
"crop_blank_margins",
"Prétraitement des copies",
"Rogner les zones vides",
"Facultatif : détecte les zones vides en haut et en bas malgré les lignes et les perforations, "
"puis remplace les PDF dans Copies. Les versions non rognées sont sauvegardées. "
"À effectuer avant la détection des labels. Traite plusieurs copies en parallèle.",
(python("default", "Rognage des zones vides", "crop-margins"),),
arguments=(
arg_target("le dossier de l’évaluation ou un PDF du dossier Copies"),
ArgumentSpec(
"workers",
"Copies traitées en parallèle",
"int",
"--workers",
default=5,
help="Fixe le nombre maximal de copies rognées simultanément. Une valeur élevée accélère le traitement si la machine possède assez de cœurs et de mémoire.",
),
),
optional=True,
requires=("Copies",),
auto_start_first_visit=ALWAYS_CROP,
),
StepDefinition(
"cutleft",
"Prétraitement des copies",
"Découper la marge des labels",
"Produit les images de la partie gauche des copies. Une copie précise peut être ciblée.",
(python("default", "Découpe", "cutleft.py"),),
"Découper une partie à gauche pour détection des labels",
"Produit les images de la partie gauche des copies. Dans la fenêtre de découpe : "
"n décale la zone de 50 px vers la droite, N de 100 px, t de 50 px vers la gauche, "
"l l’élargit de 50 px, 1 utilise les pages entières, Entrée valide et s signale une erreur. "
"Une copie précise peut être ciblée.",
(python("default", "Découpe", "crop-labels"),),
arguments=(
arg_target(),
ArgumentSpec("fullpage", "Toujours utiliser la page entière", "bool", "--fullpage"),
ArgumentSpec(
"fullpage",
"Toujours utiliser la page entière",
"bool",
"--fullpage",
help="Désactive la découpe habituelle de la marge gauche et transmet chaque page entière à la détection des labels.",
),
ArgumentSpec(
"marked",
"Copies signalées uniquement",
"bool",
"--marked",
help="Limite lopération aux copies signalées dans linterface au lieu de traiter toutes les copies.",
),
),
requires=("Copies",),
artifacts=("Cutleft",),
@@ -174,20 +290,31 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Labels et regroupement",
"Détecter les labels avec Gemini",
"Identifie les labels dans les images produites par la découpe gauche.",
(python("default", "Détection des labels", "gemini_for_labels.py"),),
(python("default", "Détection des labels", "labels"),),
arguments=(
arg_target(),
ArgumentSpec("overwrite", "Régénérer les résultats", "bool", "--overwrite"),
ArgumentSpec(
"overwrite",
"Régénérer les résultats",
"bool",
"--overwrite",
help="Relance la détection même lorsquun fichier JSON de labels existe déjà pour la copie.",
),
),
requires=("labels", "Copies", "Cutleft"),
artifacts=("Copies/*.json",),
extra_arguments_help=(
"PDF de copie ou images Cutleft supplémentaires de cette évaluation, "
"séparés par des espaces. Mettez entre guillemets les chemins contenant des espaces."
),
),
StepDefinition(
"plotting",
"Labels et regroupement",
"Vérifier visuellement les labels",
"Ouvre la fenêtre de vérification. La cible peut être toute l’évaluation ou une copie.",
(python("default", "Vérification", "plotting.py"),),
"Ouvre la fenêtre de vérification. La cible peut être toute l’évaluation ou une copie. "
"Le raccourci p revient à limage précédente, y compris dans la copie précédente.",
(python("default", "Vérification", "review-labels"),),
arguments=(arg_target(),),
requires=("labels", "Cutleft"),
artifacts=("Copies/Copie*.json",),
@@ -197,104 +324,152 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Labels et regroupement",
"Découper les réponses par question",
"Découpe les copies à partir des coordonnées de labels vérifiées.",
(python("default", "Découpage", "splitting_int.py"),),
(python("default", "Découpage", "split-answers"),),
arguments=(arg_target(),),
requires=("Copies",),
artifacts=("Copies/Copie*/*",),
auto_start_first_visit=True,
),
StepDefinition(
"crop_exercise_bottoms",
"Labels et regroupement",
"Rogner le bas des réponses",
"Facultatif après le découpage par labels : rogne uniquement les grands espaces "
"vides au bas des réponses. Les PDF modifiés sont remplacés, les originaux sont "
"sauvegardés et plusieurs fichiers sont analysés en parallèle.",
(python("default", "Rognage du bas", "crop-answer-bottoms"),),
arguments=(
arg_target("le dossier de l’évaluation"),
ArgumentSpec(
"workers",
"PDF traités en parallèle",
"int",
"--workers",
default=5,
help="Fixe le nombre maximal de PDF de réponse analysés simultanément. Une valeur élevée accélère le traitement si la machine possède assez de cœurs et de mémoire.",
),
),
optional=True,
requires=("Copies/Copie*/*.pdf",),
auto_start_first_visit=ALWAYS_CROP,
),
StepDefinition(
"grouping",
"Labels et regroupement",
"Regrouper les réponses",
"Regroupe les réponses portant le même label pour préparer les requêtes.",
(python("default", "Regroupement", "grouping.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
(python("default", "Regroupement", "group-answers"),),
arguments=(arg_target("le dossier de l’évaluation"),),
requires=("Copies",),
artifacts=("Par label",),
),
StepDefinition(
"verify_groups",
"Labels et regroupement",
"Vérifier les groupes produits",
"Vérifie que chaque réponse PDF apparaît dans les métadonnées des groupes.",
(python("default", "Vérification des groupes", "verify_groups.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True,
requires=("Copies", "Par label"),
auto_start_first_visit=True,
),
StepDefinition(
"correction",
"Correction",
"Lancer ou intégrer la correction",
"Choisir une correction immédiate, batch, hybride, une recorrection, ou lintégration dun batch.",
"Choisir une correction immédiate, batch, hybride, une recorrection, ou lintégration dun batch. "
"En correction immédiate, la barre d’état affiche le nombre de groupes traités et le total. "
"Interrompre bloque les nouveaux appels Gemini, attend ceux déjà en cours, sauvegarde leurs résultats puis arrête la commande. "
"Au prochain lancement, les validations encore nécessaires (mauvais label ou contenu supplémentaire) reprennent sans refaire la requête principale.",
(
python("live", "Correction immédiate", "correction.py"),
python("batch", "Préparer toutes les requêtes batch", "correction.py", fixed_args=("--batch",)),
python("hybrid", "Batch à partir dun label", "correction.py"),
python("refaire", "Recorrection depuis refaire.json", "correction.py", fixed_args=("--refaire",)),
python("live", "Correction immédiate", "correct"),
python("batch", "Préparer toutes les requêtes batch", "correct", fixed_args=("--batch",)),
python("hybrid", "Batch à partir dun label", "correct"),
python("refaire", "Recorrection depuis refaire.json", "correct", fixed_args=("--refaire",)),
python(
"integrate",
"Intégrer les résultats batch",
"correction.py",
"correct",
fixed_args=("--deal-with-batched",),
),
python("reset", "Réinitialiser les corrections", "correction.py", fixed_args=("--reset",), dangerous=True),
python("reset", "Réinitialiser les corrections", "correct", fixed_args=("--reset",), dangerous=True),
),
arguments=(
arg_target("Évaluation ou image Group_X.jpg"),
ArgumentSpec("overwrite", "Écraser les corrections existantes", "bool", "--overwrite", variants=("live",)),
ArgumentSpec("limit", "Limite dappels Pro", "int", "--limit", variants=("live",)),
ArgumentSpec("batch_from", "Premier label envoyé en batch", "text", "--batch-from", variants=("hybrid",)),
arg_target("le dossier de l’évaluation ou une image Group_X.jpg"),
ArgumentSpec(
"overwrite",
"Écraser les corrections existantes",
"bool",
"--overwrite",
help="Relance les corrections demandées même lorsquun résultat existe déjà.",
variants=("live", "batch", "hybrid", "integrate"),
),
ArgumentSpec(
"limit",
"Limite dappels Pro",
"int",
"--limit",
help="Limite le nombre dappels au modèle Pro pendant cette exécution. Laissez ce champ vide pour ne pas imposer de limite.",
variants=("live", "hybrid", "refaire"),
),
ArgumentSpec(
"batch_from",
"Premier label envoyé en batch",
"text",
"--batch-from",
help="Indique le premier label traité en batch ; les labels précédents sont corrigés immédiatement.",
variants=("hybrid",),
),
),
requires=("Par label", "Persp", "labels"),
artifacts=("correction.json", "batch_requests_*.jsonl"),
extra_arguments_help=(
"Images Group_X.jpg supplémentaires de cette évaluation, séparées par des espaces. "
"Mettez entre guillemets les chemins contenant des espaces."
),
extra_arguments_variants=("live", "batch", "hybrid", "refaire"),
),
StepDefinition(
"submit_batches",
"Correction",
"Envoyer les batchs",
"Envoie à Gemini les fichiers JSONL produits par le mode batch.",
(python("default", "Envoi", "submit_batches.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
(python("default", "Envoi", "batch-submit"),),
arguments=(arg_target("le dossier de l’évaluation"),),
optional=True,
artifacts=("batch_jobs.json",),
skip_for_live_correction=True,
),
StepDefinition(
"batch_status",
"Correction",
"Consulter l’état des batchs",
"Affiche les jobs Gemini en cours. Lidentifiant de téléchargement est facultatif.",
(python("default", "État des batchs", "batch_status.py"),),
arguments=(ArgumentSpec("download", "Télécharger le job", "text", "--download"),),
"Vérifie les jobs enregistrés pour l’évaluation. Passe à la récupération uniquement lorsque tous ont réussi et que leurs résultats sont disponibles ; sinon, reste sur cette étape.",
(python("default", "État des batchs", "batch-status"),),
optional=True,
skip_for_live_correction=True,
),
StepDefinition(
"fetch_batches",
"Correction",
"Récupérer les résultats batch",
"Télécharge et rassemble les réponses des jobs terminés.",
(python("default", "Récupération", "fetch_batched_results.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
(python("default", "Récupération", "batch-fetch"),),
arguments=(arg_target("le dossier de l’évaluation"),),
optional=True,
),
StepDefinition(
"post_correction",
"Correction",
"Nettoyer la correction",
"Corrige certains problèmes dencodage et prépare le texte pour LaTeX.",
(python("default", "Post-correction", "post-correction.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
requires=("correction.json",),
skip_for_live_correction=True,
),
StepDefinition(
"manual_resolution",
"Correction",
"Résoudre les conflits manuels",
"Étape conditionnelle, uniquement si manual_resolutions.txt contient des conflits à traiter.",
(python("default", "Résolution", "resolve_manual.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
"Étape conditionnelle, uniquement si manual_resolutions.txt contient des conflits à traiter. "
"Après succès, le GUI revient à la correction avec « Recorrection depuis refaire.json » sélectionné.",
(python("default", "Résolution", "resolve-manual"),),
arguments=(arg_target("le dossier de l’évaluation"),),
optional=True,
requires=("manual_resolutions.txt", "correction.json"),
skip_without_manual_conflicts=True,
),
StepDefinition(
"post_correction",
"Correction",
"Nettoyer la correction",
"Sauvegarde correction.json dans correction_precleanup.json, puis corrige certains problèmes dencodage et prépare le texte pour LaTeX. La sauvegarde est remplacée à chaque nettoyage.",
(python("default", "Post-correction", "post-correction"),),
arguments=(arg_target("le dossier de l’évaluation"),),
requires=("correction.json",),
),
StepDefinition(
"annotation",
@@ -302,14 +477,27 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Générer les copies annotées",
"Les trois modes sont exclusifs pour un parcours donné.",
(
python("simple", "Annotations simples (Anot)", "annotating.py"),
python("checks", "Annotations avec cases (Bnot)", "annotating_with_checks.py"),
python("grouped", "Annotations groupées (BGnot)", "annotating_by_label.py"),
python("simple", "Annotations simples (Anot)", "annotate-simple"),
python("checks", "Annotations avec cases (Bnot)", "annotate-checks"),
python("grouped", "Annotations groupées (BGnot)", "annotate-grouped"),
),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec("overwrite", "Écraser les sorties", "bool", "--overwrite"),
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("checks",)),
arg_target("le dossier de l’évaluation"),
ArgumentSpec(
"overwrite",
"Écraser les sorties",
"bool",
"--overwrite",
help="Remplace les annotations déjà générées dans le dossier de sortie sélectionné.",
),
ArgumentSpec(
"refaire",
"Mode refaire",
"bool",
"--refaire",
help="Génère uniquement les copies et questions inscrites dans refaire.json, dans le dossier réservé à la reprise.",
variants=("checks", "grouped"),
),
),
requires=("correction.json",),
artifacts=("Anot", "Bnot", "BGnot"),
@@ -317,12 +505,27 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
StepDefinition(
"export",
"Génération des annotations",
"Exporter vers la tablette",
"Exporte les groupes vers le dossier EXPORT_DIR défini dans config.py.",
(python("default", "Export", "export.py"),),
"Exporter",
"Exporte les annotations vers le dossier EXPORT_DIR défini dans config.py.",
(python("default", "Export", "export"),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire"),
arg_target("le dossier de l’évaluation"),
ArgumentSpec(
"annotation_dir",
"Dossier dannotations",
"choice",
default="BGnot",
choices=("BGnot", "Bnot", "Anot"),
help="Choisissez le dossier dannotations à exporter : groupées, avec cases, ou simples.",
positional=True,
),
ArgumentSpec(
"refaire",
"Mode refaire",
"bool",
"--refaire",
help="Exporte les annotations du passage de reprise BRnot au lieu du dossier principal.",
),
),
optional=True,
),
@@ -338,10 +541,25 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Correction manuscrite",
"Importer les annotations manuscrites",
"Copie les PDF présents dans IMPORT_DIR vers l’évaluation.",
(python("default", "Import", "import.py"),),
(python("default", "Import", "import"),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire"),
arg_target("le dossier de l’évaluation"),
ArgumentSpec(
"annotation_dir",
"Dossier dannotations",
"choice",
default="BGnot",
choices=("BGnot", "Bnot", "Anot"),
help="Choisissez le dossier principal dans lequel importer les annotations manuscrites.",
positional=True,
),
ArgumentSpec(
"refaire",
"Mode refaire",
"bool",
"--refaire",
help="Importe les annotations manuscrites dans BRnot pour le passage de reprise.",
),
),
),
StepDefinition(
@@ -350,31 +568,64 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Lire les annotations manuscrites",
"Le mode doit correspondre au mode choisi lors de la génération des annotations.",
(
python("standard", "Lecture Bnot", "reading_annotations.py"),
python("grouped", "Lecture BGnot", "reading_grouped_annotations.py"),
python("standard", "Lecture Bnot", "read-annotations"),
python("grouped", "Lecture BGnot", "read-grouped"),
),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec("update_score", "Réappliquer les score.json", "bool", "--update-score"),
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("grouped",)),
),
arg_target("le dossier de l’évaluation"),
ArgumentSpec(
"update_score",
"Régénérer avec les nouveaux énoncés/corrigés et les score.json",
"bool",
"--update-score",
help="Régénère les images avec les PDF actuels d’énoncé et de correction ; pour chaque label, le score.json existant prévaut sur le score relu dans lannotation manuscrite.",
),
ArgumentSpec(
"refaire",
"Mode refaire",
"bool",
"--refaire",
help="Lit les annotations du passage de reprise BRnot et les fusionne avec les copies principales.",
variants=("grouped",),
),
ArgumentSpec(
"annotation_dir",
"Passage principal du mode refaire",
"choice",
"--annotation-dir",
default="BGnot",
choices=("BGnot", "Bnot", "Anot"),
help="Indique le dossier dannotations du passage principal dans lequel intégrer les questions refaites.",
variants=("grouped",),
),
) + return_answer_arguments,
),
StepDefinition(
"giving_names",
"Finalisation",
"Attribuer les noms et préparer A Rendre",
"Crée le dossier A Rendre à partir du dossier dannotations choisi.",
(python("default", "Attribution des noms", "giving_names.py"),),
"Crée le dossier A Rendre à partir du dossier dannotations choisi, sans passer automatiquement à l’étape suivante. "
"Après lexécution, vous pouvez renommer chaque dossier, ainsi que son fichier .jpg et son fichier .pdf, avec un autre nom ; le suffixe (id) du dossier est conservé. "
"Les outils affichés permettent didentifier et corriger les noms Unknown ou attribués à plusieurs copies. Cliquez ensuite sur « Marquer terminée ».",
(python("default", "Attribution des noms", "giving-names"),),
arguments=(
arg_target("Dossier de l’évaluation"),
arg_target("le dossier de l’évaluation"),
ArgumentSpec(
"annotation_dir",
"Dossier dannotations",
"choice",
default="BGnot",
choices=("BGnot", "Bnot", "Anot"),
help="Choisissez le dossier dannotations utilisé pour construire les fichiers nommés dans A Rendre.",
positional=True,
),
ArgumentSpec(
"update",
"Mettre à jour uniquement les images de answers",
"bool",
"--update",
help="Met à jour uniquement les images du dossier answers de chaque élève existant, en identifiant la copie par le numéro final entre parenthèses et sans modifier le nom du dossier ni les autres fichiers.",
),
),
artifacts=("A Rendre",),
),
@@ -399,10 +650,16 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Étapes personnelles",
"Mettre à jour le fichier ODS",
"Transfère les scores, avec la possibilité de n’écrire que leur somme.",
(python("default", "Mise à jour ODS", "update_ods.py"),),
(python("default", "Mise à jour ODS", "update-ods", supports_verbose=False),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec("sum", "Écrire seulement la somme", "bool", "--sum"),
arg_target("le dossier de l’évaluation"),
ArgumentSpec(
"sum",
"Écrire seulement la somme",
"bool",
"--sum",
help="Écrit uniquement la note totale de chaque élève dans le fichier ODS, sans détailler les scores par question.",
),
),
personal=True,
),
@@ -426,9 +683,11 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"final_score",
"Étapes personnelles",
"Ajouter le score final",
"Génère les fichiers de diffusion avec le score final.",
(python("default", "Score final", "add_final_score.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
"Prérequis immédiat : gestion_classe wse doit avoir été exécuté juste avant. "
"Génère ensuite les dossiers de diffusion avec le score final, puis copie "
"gestion_classe/Staging/histogramme.pdf dans le dossier Server/copies de l’évaluation.",
(python("default", "Score final", "add-final-score", supports_verbose=False),),
arguments=(arg_target("le dossier de l’évaluation"),),
personal=True,
),
StepDefinition(
@@ -448,10 +707,98 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
personal=True,
optional=True,
),
StepDefinition(
"clean",
"Archivage",
"Nettoyer les fichiers intermédiaires",
"Supprime définitivement les fichiers permettant de reprendre le parcours. "
"Conserve les PDF traités, les fichiers textuels de l’énoncé, correction.json, "
"les journaux, ainsi que les images, PDF, score.json et info.json de A Rendre.",
(
python(
"default",
"Nettoyage définitif",
"clean",
fixed_args=("--yes",),
dangerous=True,
danger_warning=(
"Le nettoyage est irréversible.\n\n"
"Toute la progression du GUI et les fichiers intermédiaires "
"seront supprimés. Il ne sera plus possible de reprendre une "
"étape sans régénérer ses données.\n\n"
"Les PDF traités, les fichiers textuels de l’énoncé, "
"correction.json, les journaux, ainsi que les images, PDF et "
"score.json et info.json de A Rendre seront conservés.\n\n"
"Continuer ?"
),
),
python(
"dry_run",
"Prévisualiser sans supprimer",
"clean",
fixed_args=("--dry-run",),
),
),
arguments=(arg_target("le dossier de l’évaluation"),),
optional=True,
requires=("Copies", "correction.json", "A Rendre"),
),
]
steps[-1:-1] = build_refaire_workflow()
return [step for step in steps if show_personal_steps or not step.personal]
def build_refaire_workflow() -> list[StepDefinition]:
from .refaire import SECTION
selection = StepDefinition(
"refaire_selection", SECTION, "Choisir les copies et les questions",
"Sélectionnez les copies et les questions à refaire, puis enregistrez la sélection. "
"Le passage principal doit être terminé ; conservez ses annotations.",
(CommandVariant("default", "Sélection", None, "manual"),),
requires=("Copies", "labels", "correction.json"),
)
definitions = [
("review", "Reprendre le découpage", "Vérifiez et ajustez les labels des copies sélectionnées. Chaque copie souvre à son tour. Fermez la fenêtre pour passer à la suivante.", "review-labels", (), True),
("split", "Redécouper les réponses", "À exécuter après une modification du découpage. Traite toutes les copies sélectionnées ; vérifiez les fichiers _new et _old en cas de résolution manuelle.", "split-answers", (), True),
("correct", "Refaire la correction", "Relance la correction des seules questions sélectionnées. Peut être ignorée pour corriger manuellement les résultats.", "correct", ("--refaire",), True),
("annotate", "Préparer les copies à vérifier", "Génère les questions sélectionnées avec des cases dans BRnot, par question ou par copie selon la sélection. Remplace le précédent passage dans BRnot.", "annotate-checks", ("--refaire", "--overwrite"), False),
("export", "Exporter vers la tablette", "Exporte BRnot vers EXPORT_DIR. Retirez les anciens fichiers dexport avant le transfert.", "export", ("--refaire",), True),
("tablet", "Vérifier sur la tablette", "Annotez les PDF exportés, puis placez les retours dans IMPORT_DIR sans changer leur nom (nom de groupe ou Copie01.pdf…). Retournez aussi les PDF sans modification. Retirez les anciens fichiers dimport.", None, (), False),
("import", "Importer les copies vérifiées", "Importe les PDF retournés dans BRnot. Vous pouvez ignorer cette étape si les fichiers Concat_annotated.pdf y sont déjà en place.", "import", ("--refaire",), True),
("merge", "Mettre à jour les copies finales", "Fusionne les questions refaites avec le reste de chaque copie dans le dossier du passage principal. Relancez ensuite la préparation de A Rendre, le calcul des notes et la diffusion.", "read-grouped", ("--refaire",), False),
]
steps = [selection]
for suffix, title, description, program, flags, optional in definitions:
arguments = (arg_target(),) if program else ()
if suffix == "merge":
arguments += (ArgumentSpec(
"annotation_dir",
"Passage principal",
"choice",
"--annotation-dir",
default="BGnot",
choices=("BGnot", "Bnot", "Anot"),
help="Indique le dossier dannotations du passage principal dans lequel intégrer les questions refaites.",
),)
requirements = ("refaire.json", "Copies", "labels", "correction.json")
if suffix in {"export", "tablet", "import", "merge"}:
requirements += ("BRnot",)
steps.append(StepDefinition(
f"refaire_{suffix}", SECTION, title, description,
(CommandVariant(
"default",
title,
program,
"python" if program else "manual",
flags,
supports_verbose=program is not None,
),),
arguments=arguments, optional=optional, requires=requirements,
))
return steps
def value_for_default(value: object, evaluation_arg: str) -> object:
return evaluation_arg if value == EVALUATION else value
@@ -463,13 +810,14 @@ def build_command(
values: dict[str, object],
evaluation_arg: str,
extra_arguments: str = "",
verbose: bool = False,
) -> list[str]:
if variant.kind == "manual" or not variant.program:
return []
program_path = repository / variant.program
if variant.kind == "python":
command = [sys.executable, "-u", str(program_path)]
command = [sys.executable, "-u", "-m", "copienator", variant.program]
elif variant.kind == "shell":
command = [str(program_path)]
else:
@@ -477,6 +825,9 @@ def build_command(
positionals: list[str] = []
options: list[str] = []
if step.id == "batch_status":
# Use the loaded evaluation without introducing another input field.
options.extend(("--evaluation", evaluation_arg))
for spec in step.arguments:
if spec.variants and variant.id not in spec.variants:
continue
@@ -484,6 +835,8 @@ def build_command(
if spec.kind == "bool":
if bool(value) and spec.flag:
options.append(spec.flag)
elif not bool(value) and spec.false_flag:
options.append(spec.false_flag)
continue
if value is None or str(value).strip() == "":
continue
@@ -499,6 +852,8 @@ def build_command(
if not variant.fixed_args_before_positionals:
command.extend(variant.fixed_args)
command.extend(options)
if verbose and variant.supports_verbose:
command.append("--verbose")
if extra_arguments.strip():
command.extend(shlex.split(extra_arguments, posix=os.name != "nt"))
return command
-366
View File
@@ -1,366 +0,0 @@
import sys
from functools import lru_cache
import os
import time
import json # Added for schema output
import argparse
import tkinter as tk
from threading import Thread
from queue import Queue, Empty
from pdf2image import convert_from_path
from PIL import Image, ImageTk
# --- Configuration ---
DELIMITER_WIDTH = 5
DELIMITER_COLOR = (0, 0, 0)
OUTPUT_SIZE = (1800, 1000)
parser = argparse.ArgumentParser(description="PDF Cropper")
parser.add_argument("path", help="Directory path or PDF file path")
parser.add_argument("--fullpage", action="store_true", help="Process all files in full page mode (1 page per output file)")
args = parser.parse_args()
path_arg = args.path
fullpage_mode = args.fullpage
files = []
INPUT_DIR = ""
COPIES_DIR = ""
if os.path.isfile(path_arg) and path_arg.lower().endswith('.pdf'):
COPIES_DIR = os.path.abspath(os.path.dirname(path_arg))
# If the file is inside a "Copies" folder, set INPUT_DIR to the parent
if os.path.basename(COPIES_DIR).lower() == 'copies':
INPUT_DIR = os.path.dirname(COPIES_DIR)
else:
INPUT_DIR = COPIES_DIR
files = [os.path.basename(path_arg)]
elif os.path.isdir(path_arg):
# Support passing either the base dir or the Copies dir directly
abs_path = os.path.abspath(path_arg)
if os.path.basename(abs_path).lower() == 'copies':
COPIES_DIR = abs_path
INPUT_DIR = os.path.dirname(abs_path)
else:
INPUT_DIR = abs_path
COPIES_DIR = os.path.join(INPUT_DIR, 'Copies')
if os.path.exists(COPIES_DIR):
files = sorted([f for f in os.listdir(COPIES_DIR) if f.lower().endswith('.pdf') and
"nonc" not in f.lower()])
else:
sys.exit(f"Error: Could not find 'Copies' directory inside {INPUT_DIR}")
else:
sys.exit("Error: Input must be a directory or a PDF file.")
OUTPUT_DIR = os.path.join(INPUT_DIR, 'Cutleft')
if not os.path.exists(OUTPUT_DIR):
os.makedirs(OUTPUT_DIR)
# --- Processing Logic ---
def distribute_pages(total_pages, max_per_file=5):
"""
Calculates how to split pages into chunks <= max_per_file,
balancing the number of columns per file.
Example: 12 pages, max 5 -> [4, 4, 4]
"""
if total_pages == 0:
return []
# Calculate minimum number of files needed
num_files = (total_pages + max_per_file - 1) // max_per_file
# Calculate base size and remainder
base_count = total_pages // num_files
remainder = total_pages % num_files
distribution = []
for i in range(num_files):
# Distribute remainder to the first few files
count = base_count + (1 if i < remainder else 0)
distribution.append(count)
return distribution
def stitch_images(image_list):
"""Helper to stitch a list of images horizontally with delimiters."""
if not image_list:
return None
num_images = len(image_list)
total_width = sum(img.width for img in image_list) + (num_images - 1) * DELIMITER_WIDTH
max_height = max(img.height for img in image_list)
combined = Image.new('RGB', (total_width, max_height), color=(255, 255, 255))
x_offset = 0
for idx, img in enumerate(image_list):
combined.paste(img, (x_offset, 0))
x_offset += img.width
if idx < num_images - 1:
delimiter = Image.new('RGB', (DELIMITER_WIDTH, max_height), color=DELIMITER_COLOR)
combined.paste(delimiter, (x_offset, 0))
x_offset += DELIMITER_WIDTH
return combined
import threading
pdf_cache_lock = threading.Lock()
@lru_cache(maxsize=3)
def _get_pdf_pages_cached(filename):
pdf_path = os.path.join(COPIES_DIR, filename)
return convert_from_path(pdf_path)
def get_pdf_pages(filename):
"""Thread-safe wrapper for the cached PDF conversion."""
with pdf_cache_lock:
return _get_pdf_pages_cached(filename)
def process_single_pdf(filename, shift_offset=0, max_per_file=5):
"""
Converts PDF to stitched images.
Returns a tuple: (preview_image_resized, list_of_split_images, schema_dict)
"""
try:
pages = get_pdf_pages(filename)
cropped_images = []
for img in pages:
width, height = img.size
if max_per_file == 1:
# If Single Page mode, take the full width (ignore shift/crop)
left = 0
right = width
else:
# Original "Cutleft" logic (approx 1/3 width)
left = 100 + shift_offset
right = (width // 3) + 100 + shift_offset
# Ensure crop box is valid
left = max(0, left)
right = min(width, right)
if right > left:
crop_box = (left, 0, right, height)
cropped = img.crop(crop_box)
cropped_images.append(cropped)
if not cropped_images:
return None
# 1. Generate Schema / Distribution
col_distribution = distribute_pages(len(cropped_images), max_per_file=max_per_file)
# 2. Generate Split Images (Full Resolution)
split_images = []
current_idx = 0
for count in col_distribution:
chunk = cropped_images[current_idx : current_idx + count]
stitched_chunk = stitch_images(chunk)
split_images.append(stitched_chunk)
current_idx += count
# 3. Generate Preview (All stitched together, Resized)
full_stitch = stitch_images(cropped_images)
preview_resized = full_stitch.resize(OUTPUT_SIZE, Image.BILINEAR)
schema = {
"original_filename": filename,
"total_pages": len(cropped_images),
"number_of_files": len(split_images),
"columns_per_file": col_distribution
}
return (preview_resized, split_images, schema)
except Exception as e:
print(f"Error processing {filename}: {e}")
return None
def save_results(result_tuple, filename):
"""
Saves the split images and the schema JSON.
"""
_, splits, schema = result_tuple
base_name = os.path.splitext(filename)[0]
# --- Cleanup: Delete existing files for this PDF ---
for f in os.listdir(OUTPUT_DIR):
file_path = os.path.join(OUTPUT_DIR, f)
if f == f"{base_name}_schema.json":
os.remove(file_path)
elif f.startswith(f"{base_name}_") and f.endswith(".jpg"):
suffix = f[len(base_name)+1:-4]
if suffix.isdigit():
os.remove(file_path)
# Save Images
for i, img in enumerate(splits):
suffix = f"_{i+1:02d}"
output_filename = f"{base_name}{suffix}.jpg"
output_path = os.path.join(OUTPUT_DIR, output_filename)
img.save(output_path, "JPEG", quality=95)
print(f"Saved: {output_filename}")
# Save Schema
json_filename = f"{base_name}_schema.json"
json_path = os.path.join(OUTPUT_DIR, json_filename)
with open(json_path, 'w') as f:
json.dump(schema, f, indent=4)
print(f"Saved schema: {json_filename}")
# --- GUI Application ---
class ImageReviewer:
def __init__(self, file_list, default_max_per_file=5):
self.files = file_list
self.index = 0
self.current_shift = 0
self.default_max_per_file = default_max_per_file
self.current_max_per_file = default_max_per_file
self.current_preview = None # Only stores the resized preview for GUI
self.is_processing = False
# Queue for manual re-processing results
self.manual_queue = Queue()
# Setup GUI
self.root = tk.Tk()
self.root.title("PDF Cropper")
self.root.geometry("+100+100")
self.label_img = tk.Label(self.root)
self.label_img.pack()
self.label_info = tk.Label(self.root, text="", font=("Arial", 12, "bold"))
self.label_info.pack(pady=5)
# Bindings
self.root.bind('<Return>', self.on_next)
self.root.bind('n', lambda e: self.on_shift(50))
self.root.bind('N', lambda e: self.on_shift(100))
self.root.bind('t', lambda e: self.on_shift(-50))
self.root.bind('1', lambda e: self.on_set_max_pages(1))
# Start background pre-fetcher
self.bg_thread = Thread(target=self.prefetch_worker, daemon=True)
self.bg_thread.start()
# Load first image
self.load_current_image()
self.root.lift()
self.root.focus_force()
self.root.mainloop()
def on_set_max_pages(self, count):
if self.is_processing:
return
self.current_max_per_file = count
print(f"Setting max pages per file: {count}")
self.trigger_processing(self.files[self.index], self.current_shift)
def prefetch_worker(self):
"""Background thread to load the NEXT file's PDF pages into RAM."""
idx_to_process = -1
while True:
target = self.index + 1
if target < len(self.files) and target != idx_to_process:
fname = self.files[target]
get_pdf_pages(fname)
idx_to_process = target
time.sleep(0.05)
def load_current_image(self, use_prefetch=False):
if self.index >= len(self.files):
print("All files processed.")
self.root.destroy()
return
filename = self.files[self.index]
self.is_processing = False
self.current_shift = 0
self.trigger_processing(filename, self.current_shift)
def trigger_processing(self, filename, shift):
"""Starts a thread to process image so GUI doesn't freeze."""
self.is_processing = True
self.label_info.configure(text=f"Processing {filename} (Shift {shift})... Please wait.", fg="red")
def worker():
res = process_single_pdf(filename, shift, self.current_max_per_file)
self.manual_queue.put(res)
Thread(target=worker, daemon=True).start()
self.check_manual_queue(filename)
def check_manual_queue(self, filename):
"""Polls the manual queue for result."""
try:
result = self.manual_queue.get_nowait()
if result:
self.handle_processing_result(result, filename)
else:
print(f"Failed to process {filename}, skipping.")
self.index += 1
self.load_current_image(use_prefetch=True)
self.is_processing = False
except Empty:
self.root.after(100, lambda: self.check_manual_queue(filename))
def handle_processing_result(self, result, filename):
"""Unpacks result, saves files, and updates display."""
preview, splits, schema = result
self.current_preview = preview
Thread(target=save_results, args=(result, filename), daemon=True).start()
self.update_display(filename, schema)
def update_display(self, filename, schema=None):
if self.current_preview:
tk_image = ImageTk.PhotoImage(self.current_preview)
self.label_img.configure(image=tk_image)
self.label_img.image = tk_image
schema_info = ""
if schema:
cols = str(schema['columns_per_file'])
schema_info = f"\nFiles: {schema['number_of_files']} | Cols: {cols}"
self.label_info.configure(
text=f"[{self.index+1}/{len(self.files)}] {filename} | Shift: {self.current_shift}px"
f"{schema_info}\n"
f"Enter: Next | n: +50 | N: +100 | t: -50 | 1: use single column",
fg="black"
)
def on_shift(self, amount):
if self.is_processing:
return
self.current_shift += amount
print(f"Applying shift: {self.current_shift}")
self.trigger_processing(self.files[self.index], self.current_shift)
def on_next(self, event):
if self.is_processing:
return
self.index += 1
self.current_shift = 0
self.current_max_per_file = self.default_max_per_file
self.load_current_image(use_prefetch=True)
# --- Entry Point ---
if __name__ == "__main__":
if not files:
print("No PDF files found.")
else:
app = ImageReviewer(files, default_max_per_file=1 if fullpage_mode else 5)
+18 -3
View File
@@ -6,26 +6,39 @@ API_KEY = os.environ.get("GEMINI_API_KEY")
EXPORT_DIR = Path("Export")
IMPORT_DIR = Path("Import")
# Fichiers à inclure dans A Rendre (les sources d'annotations sont conservées).
RETURN_JPEG_ENABLED = True
RETURN_PDF_ENABLED = True
RETURN_ANSWERS_ENABLED = False
RETURN_ANSWERS_CONTEXT = False
RETURN_ANSWERS_QUESTION = True
RETURN_ANSWERS_SOLUTION = False
# Les étapes gestion_classe, ODS et publication sont masquées par défaut.
SHOW_PERSONAL_STEPS = False
# Lance automatiquement les deux étapes facultatives de rognage dans le GUI.
ALWAYS_CROP = False
# Chemins utilisés uniquement par les étapes personnelles.
CURRENT_SCORE_ODS_PATH = Path("current_eval.ods")
FINAL_SCORE_ODS_PATH = Path("simple_eval.ods")
FINAL_SCORE_OUTPUT_DIR = Path("Server") / "copies"
FINAL_SCORE_HISTOGRAM_PATH = Path("histogramme.pdf")
FINAL_SCORE_FONT_PATH = None
# Modèle pour des choses très légères
MODEL_LITE_ID = "gemini-3.5-flash-lite"
# Modèle pour identifier visuellement les labels
MODEL_FOR_LABEL_ID = "gemini-3.5-flash-lite"
# MODEL_FOR_LABEL_ID = "gemini-3.5-flash-lite" # 3.5 flash lite marche moyennement, il insiste pour mettre les labels dans l'ordre, sans considérer ce qu'il y a écrit
MODEL_FOR_LABEL_ID = "gemini-3.8-flash"
# Modèle pour des choses normales
MODEL_FLASH_ID = "gemini-3.6-flash"
MODEL_FLASH_ID = "gemini-3.8-flash"
# Modèle pour des choses dures
MODEL_PRO_ID = "gemini-3.6-flash"
MODEL_PRO_ID = "gemini-3.8-flash"
# MODEL_PRO_ID = "gemini-3.1-pro-preview"
PAGE_SPLITTER_KB = {
@@ -41,6 +54,7 @@ PAGE_SPLITTER_KB = {
"next_page": "s",
"discard_page": "z",
"send_end": "a", # Send this page to the end
"reverse_pages": "i", # Reverse page order and restart at the new first page
"restart_file": "T",
"arranger": "A", # Call `pdf arranger` software, if available
"prev_file": "P",
@@ -69,6 +83,7 @@ LATEX_BEFORE = r"""\documentclass[varwidth=24.8cm,margin=0.4cm]{standalone}
\usepackage{minted}
\usepackage{graphicx}
\usepackage{enumitem}
\usepackage{multicol}
\begin{document}
\begin{minipage}{24.8cm}
"""
+102
View File
@@ -0,0 +1,102 @@
# Scanned PDF margin cropping
Copienator has one automatic crop detector. It finds strongly coloured or dark
ink, recovers nearby weaker strokes, and removes substantial blank areas above
and below the detected content. It is designed for scanned student work on
plain, lined, or gridded paper, including mildly skewed pages and recurring
punched-hole artifacts.
## Review utility
Run from the repository root:
```sh
python -m copienator.crop_blank_margins Interro01/Copies tmp/cropped-copies
```
The input can be a directory or one PDF. Directory processing includes only
PDFs directly inside that directory. The utility writes processed PDFs, an HTML
comparison gallery, JPEG previews, and JSON/CSV reports into the output
directory. Source PDFs are never modified.
Available options:
- `--dpi 200`: analysis resolution.
- `--padding-mm 6`: space retained around detected content.
- `--min-crop-mm 5`: minimum worthwhile removal at either edge.
The output retains filenames, page order, page count, colour, rotation, and the
embedded scan data. Cropping changes the PDF CropBox rather than rasterizing the
page. Red shading in `index.html` shows the removed part of each original page.
A `review-*` status records uncertainty; one edge can still be cropped while the
other remains unchanged.
## Detection
The detector uses colour and darkness as strong ink seeds. It recovers connected
weak strokes within a 2 mm neighbourhood using directional contrast, which
limits growth along paper lines. Two seed thresholds are compared so unstable
boundaries can be flagged for review.
For dark neutral paper, it deskews the scan and confirms repeated horizontal or
vertical ruling before suppressing paper-line pixels. It uses short directional
openings to tolerate broken or bent grid lines. Very dark fraction bars and
diagram axes remain protected. Repeated components with similar size and
alignment in the outer 15 mm are treated as punched holes only when at least
three span a substantial part of the page. Writing in the same side column still
protects its margin.
On confirmed ruled paper, faint sparse strokes in the central 80% of the page
use a moderately more sensitive component filter. The outermost 9% on each side
uses a stricter filter because punched holes, torn binding edges, and page
numbers normally appear there.
The large-blank refinement changes an edge only when it finds at least 30 mm of
additional empty paper. A 2 mm recovery neighbourhood is applied before the
normal padding. Apparently blank pages and pages without reliable ink seeds are
kept at full height.
This remains a heuristic. Extremely faint isolated pencil marks, unusually
damaged ruling, and repeated handwriting shaped like hole artifacts can be
ambiguous. Review crops before generating answer coordinates.
## Optional GUI step
After **Séparer et réordonner les pages**, the GUI offers **Rogner les zones
vides**. It can process the whole evaluation or a selected PDF. It runs at 200
dpi with 6 mm padding and uses five worker processes by default. The CLI form is:
```sh
python -m copienator crop-margins EVALUATION --workers 5
```
The batch is fully prepared before any source is replaced. Detection failures
and interruptions leave the working PDFs intact; replacement errors roll back.
Each successful run saves the untrimmed PDFs and its report under
`.copienator/runs/crop-margins-*/`. The **Archivage** step removes these backups
and reports while keeping the cropped copies and execution logs.
Cropping must run before label detection. If a selected PDF already has a
same-named JSON coordinate file, the command stops before changing any PDFs.
When `ALWAYS_CROP` is true in `config.py`, this facultative step starts
automatically when first reached; the default configuration keeps it manual.
## Performance
Separate worker processes isolate MuPDF and each worker uses one OpenCV thread.
The detector uses native channel operations, vectorized component filtering,
cached separable background filtering, and a coarser Hough voting step for skew
candidates. Report rows remain ordered by copy and page regardless of worker
completion order.
On the Ryzen 7 PRO 7840U, an end-to-end benchmark took 56.48 seconds for 48 PDFs
of 10 pages, including rendering, detection, PDF writing, backups, and
replacement. The fixture uses Interro01 and DS08VA scans and cycles pages in
shorter copies, so it does not contain 480 distinct scans. Runtime depends on the
CPU, storage, and scan content.
Run the focused checks with:
```sh
python -m unittest tests.test_crop_blank_margins tests.test_ink_detection tests.test_crop_margins_command -v
```
+42
View File
@@ -0,0 +1,42 @@
# Bottom cropping after exercise splitting
This review utility processes the exercise PDFs stored directly under
`Copies/CopieXX/`. It never changes the source files. Modified PDFs are written
to a matching tree under the chosen output directory; unchanged PDFs are not
copied.
```sh
python -m copienator.crop_exercise_bottoms Interro01 tmp/exercise-bottom-crop
```
One line is 1/36 of the uncropped full-page height recorded by the matching
`Copies/CopieXX.pdf`. Each exercise PDF page is considered independently:
1. Pages shorter than 10 lines are skipped.
2. The bottom 0.75 line is excluded from detection so a fragment of the next
label cannot keep a large blank area.
3. The existing scan detector locates the last ink above that strip and keeps
6 mm of padding.
4. The bottom CropBox changes only if the proposed removal is at least 4 lines.
The top CropBox is always retained.
The ignored 0.75-line strip is therefore not removed on its own. It is included
in the result only when the complete proposed crop passes the four-line
threshold.
The output contains `index.html`, previews with removed areas shaded red, a
plain `cropped-files.txt` list, `report.json`, and `report.csv`. The command uses
five worker processes by default; `--workers`, `--dpi`, and `--padding-mm` are
configurable.
## GUI integration
After **Découper les réponses par question**, the GUI offers the facultative
step **Rogner le bas des réponses**. It runs the same thresholds at 200 dpi and
uses five worker processes by default. Only PDFs with an accepted crop are
replaced. Their unmodified versions and the complete report are stored under
`.copienator/runs/crop-exercise-bottoms-*/`; a failure or interruption before
publication leaves every exercise PDF unchanged. The **Archivage** step removes
these retained originals and reports. When `ALWAYS_CROP` is true in `config.py`,
this facultative step starts automatically when first reached; the default
configuration keeps it manual.
+258
View File
@@ -0,0 +1,258 @@
# Final output: `A Rendre`
This documents the current implementation, as of 2026-09-12. The return folder
referred to as « À rendre » is named **`A Rendre`** on disk. JPEG files use the
extension **`.jpg`**, not `.jpeg`.
## Files and their sources
After grouped correction and review:
```sh
python -m copienator read-grouped Interro
python -m copienator giving-names Interro BGnot
```
The expected layout for a copy is:
```text
Interro/A Rendre/
└── Student Name (01)/
├── Student Name.jpg
├── Student Name.pdf
├── score.json
├── info.json
└── answers/ # when individual answer export is enabled
├── 001 - Ex 1.jpg
└── 002 - Ex 2.jpg
```
The name comes from `Copies/Copie01.json` (`name`), with filename sanitization.
The copy ID distinguishes folders even when several copies have the same name.
`giving-names` links the full JPEG, PDF and score file (or copies them when links
are unavailable). It writes `info.json` and optionally composes the individual
answer JPEGs.
| Return file | Source under `BGnot/Copie01/` | Contents |
| --- | --- | --- |
| `Student Name.jpg` | `Concat.jpg` | Full continuous image of the compiled answers and corrections. |
| `Student Name.pdf` | `Concat_F.pdf` | Filtered, paginated correction with context, questions and solutions. |
| `score.json` | `score.json` | Per-question scores, including questions omitted from the filtered PDF. |
| `info.json` | `info.json` | Answer presence, empty-answer classification, PDF membership and score. |
| `answers/*.jpg` | Final per-label JPEGs selected by `info.json` | One annotated non-empty answer, with optional supplementary material. |
## Enabling or disabling outputs
Set these independent options in `config.py` (the defaults also apply when
absent from an older personal configuration):
```python
RETURN_JPEG_ENABLED = True
RETURN_PDF_ENABLED = True
RETURN_ANSWERS_ENABLED = False
RETURN_ANSWERS_CONTEXT = False
RETURN_ANSWERS_QUESTION = True
RETURN_ANSWERS_SOLUTION = False
```
The personal `config.py` enables `RETURN_ANSWERS_ENABLED`; the distributed
default is `False`. Set a full-output option to `False`, then rerun `giving-names` to omit that file from
`A Rendre`. For each prepared copy, any existing named return file of a disabled
type is removed, including a symlink or fallback copy. Its annotation source
remains intact. These options control return publication, not intermediate
rendering or scoring. `score.json` and `info.json` are always included and have
no disabling options.
Cleanup allows the JPEG to be absent when disabled, still requires `score.json`,
and preserves return PDFs when present.
## Individual answer JPEGs
With `RETURN_ANSWERS_ENABLED = True`, `giving-names` generates an `answers/`
subdirectory inside each student's return folder. It includes **every non-empty
compiled answer**, even a perfect answer omitted from the filtered PDF. Labels
marked `empty-answer` and labels without an answer are excluded. Every JPEG
contains the final annotated student answer, including retained feedback and
extracted handwriting.
The three supplementary options independently prepend, in this order:
1. Applicable context PDFs, if `RETURN_ANSWERS_CONTEXT` is enabled.
2. The question, if `RETURN_ANSWERS_QUESTION` is enabled (the default).
3. The model solution, if `RETURN_ANSWERS_SOLUTION` is enabled.
4. The annotated student answer, always.
These use the same `Text2`/`Sol2` sources as the filtered PDF. Missing supplements
are skipped; an unreadable existing file fails the export. They are concatenated
vertically on white, without PDF pagination or its black/blue borders. The
options affect only these individual images, not the full JPEG or filtered PDF.
Disabling all supplements produces just the annotated answer.
Filenames use natural label order, a three-digit minimum sequence number, and a
sanitized label (`001 - Ex 1.jpg`). Numbering prevents filename collisions when
labels differ only by characters forbidden in filenames. JSON keys retain exact
labels. The managed `answers/` directory is replaced on successful generation,
so removed/empty answers do not leave stale images; failures preserve the previous
directory. Disabling the option clears this directory on the next `giving-names`
run. Separate storage keeps these images out of the personal final-mark stamping
step, which reads only JPEGs directly inside the student's folder.
Recompile annotations once before exporting old evaluations with this option:
the compiler now saves every final answer block and writes `info.json`
next to them. For the grouped workflow, run `read-grouped`, then `giving-names`.
This avoids reconstructing a reviewed answer from outdated correction data.
## `info.json`: per-question information
Every label in `score.json` has an object containing exactly four fields:
```json
{
"Ex 1": {"present": true, "not_empty": true, "touched": false, "score": "4"},
"Ex 2": {"present": true, "not_empty": true, "touched": true, "score": "2"},
"Empty": {"present": true, "not_empty": false, "touched": false, "score": "0"},
"Absent": {"present": false, "not_empty": false, "touched": false, "score": ""}
}
```
- `present`: an answer entry exists for this student and label in the compilation
data. A supplied answer judged empty still has `present: true`.
- `not_empty`: the answer was not marked `empty-answer` and was successfully
compiled. Absent answers have `not_empty: false`. When individual export is
enabled, a JPEG is generated if and only if both `present` and `not_empty` are
true. These fields describe the answer regardless of export settings.
- `touched`: the answer appears in the compiled filtered `Concat_F.pdf`, using
the actual selection including handwriting and selective redo preservation.
It is not a flag for human edits. Empty and absent answers have `false`.
- `score`: the same value as `score.json` (normally a numeric string, or `""`
for an unpopulated score). Editing scores requires recompilation to update the
images; return publication uses current `score.json` values for this field.
`info.json` is always exported, even when individual JPEGs or the named PDF are
disabled. `touched` describes the source filtered PDF. In normal `Anot` and
`Bnot` flows, no filtered PDF is produced and all `touched` values are false.
This file replaces `touched.json` and the internal `answer_labels.json` manifest.
Recompile old annotations, then run `giving-names`; successful regeneration and
publication remove the obsolete files from their respective folders. Missing
or malformed metadata requires recompilation rather than guessing answer presence
from scores. Cleanup preserves `info.json` alongside `score.json`.
## JPEG: the full compiled correction
The JPEG stacks the rendered answer blocks vertically in natural label order
(for example, Ex 2 precedes Ex 10). Each block contains the scanned answer, its
label and score, retained global and local feedback, and detected handwritten
review annotations. Local feedback can include red rectangles and comments in
the left margin. Review checkboxes are applied as actions rather than reproduced
as controls; internal error labels are hidden during recompilation.
The result is one RGB image of variable height, with no page breaks. It contains
all successfully compiled answer blocks, including answers scored 4 with no
remaining feedback. “Full” refers to those answer blocks, not the original scan
pages or every question in the statement. Missing/unrenderable answers cannot
be included, and the renderer normally suppresses `empty-answer` results.
The grouped compiler refuses to publish a new set when compilation is incomplete.
The JPEG does not prepend the question, context or model solution PDFs.
## PDF: a different selection and layout
**The PDF is not a PDF conversion of the JPEG.** During an ordinary full grouped
recompilation, an answer is omitted only when all three conditions hold:
- Its score is at least 4.
- Every feedback item is marked `to_delete` (also true for an empty feedback list).
- There are no significant detected handwritten annotations for that answer.
Thus, a 4/4 answer with retained feedback or handwriting still appears. Scores
for omitted answers remain in `score.json`, and their answer blocks remain in
the JPEG. Handwriting significance currently means more than 20 pixels with
alpha greater than 50 in the extracted annotation layer.
For each retained answer, the PDF stacks the following available material:
1. Applicable context PDFs from `Text2/CTXT first_label -> last_label.pdf`.
2. The question from `Text2/<label>.pdf`.
3. The model solution from `Sol2/<label>.pdf`.
4. The same compiled answer block used for the JPEG.
Missing supplementary PDFs are skipped. Contexts can repeat for successive
questions. Each question's complete group stays together on one page; groups
are packed until the next would exceed the target height. An oversized group
gets its own taller page, rather than being split. Pages are raster images saved
as PDF at 100 dpi, with variable heights, not fixed A4 sheets or searchable text.
The target height is `int(max_image_width * 1.414 * 1.25)` pixels. Small white
margins are added on the left and above/below the page. The first image of each
group receives a black border and the second a blue border. These borders are
assigned by position, so they do not consistently identify question and solution
when contexts are present or supplementary files are missing.
If nothing survives filtering, `read-grouped` removes old `Concat_F` outputs and
does not create a new PDF. During a selective `--refaire` merge, saved answer
images outside the selection are kept in the filtered output without reapplying
the perfect-answer filter; the resulting PDF can therefore retain more answers
than a full recompilation.
## JSON: per-question scores
`score.json` is a flat JSON object keyed by the exact question labels. For example
(illustrative data):
```json
{
"Ex 1 : 1)": "4",
"Ex 1 : 2)": "2.5",
"Ex 2": ""
}
```
Values are **strings**, including numeric scores. The normal question scale is
0 to 4. `""` means no score was populated for that label; it is distinct from
`"0"`. The compiler initializes all labels from the evaluation's `labels` file
to `""`, then fills processed scores. This file contains no student identity,
feedback, annotation coordinates, grading weights, or overall final mark.
Scores incorporate review checkbox changes and, when requested, existing score
overrides via `read-grouped --update-score` (or `read-annotations --update-score`
for `Bnot`). Editing the JSON alone does not update the rendered scores. Overrides
are read from the annotation source folder: a return symlink points there, but
an independent fallback copy does not. After regeneration, rerun `giving-names`
to refresh copied return files.
`update-ods` reads these return JSON files; empty values become `NT` in the normal
per-question export. Its `--sum` option sums numeric values and skips nonnumeric
ones. Weighting and the final overall mark belong to the separate grading flow.
## Availability and later steps
- `giving-names` accepts `BGnot`, `Bnot`, or `Anot`. It selects the requested
source if `score.json` and either `Concat.jpg` or `info.json` exist,
otherwise it tries `Anot/CopieXX`. This also permits returns for an entirely
empty copy. It links the PDF only if `Concat_F.pdf` exists. The normal
`Bnot` reader produces a filtered `Concat_F.jpg`, and simple annotation produces
`Concat.jpg`; these paths do not guarantee a filtered PDF.
- Preparing returns removes disabled named outputs, and removes older named
outputs whose source is now absent, including broken symlinks.
- `add-final-score` writes to `FINAL_SCORE_OUTPUT_DIR/<evaluation>/`. It stamps
the overall mark from the configured ODS in red at the JPEG's upper right,
rounded down to one decimal place. It copies PDFs unchanged and does not export
either JSON file or the `answers/` directory. It does not add that mark to the
files inside `A Rendre`.
- The `clean` command retains return images (including individual answers), PDFs,
`score.json` and `info.json`, materializing
retained symlinks before deleting their sources.
## Implementation references
- [Naming and return links](../copienator/commands/giving_names.py)
- [Individual answer publication](../copienator/return_answers.py)
- [Grouped compilation, filtering and PDF pagination](../copienator/commands/reading_grouped_annotations.py)
- [Answer rendering](../copienator/commands/annotating.py)
- [Handwriting detection and per-copy compilation](../copienator/commands/reading_annotations.py)
- [Score and feedback actions](../copienator/annotation_actions.py)
- [Context, question and solution lookup](../copienator/utils.py)
- [ODS export](../copienator/commands/update_ods.py)
- [Final-mark stamping](../copienator/commands/add_final_score.py)
- [Cleanup retention](../copienator/commands/clean.py)
-63
View File
@@ -1,63 +0,0 @@
import os
import sys
import argparse
from pathlib import Path
from google import genai
def main():
parser = argparse.ArgumentParser(description="Download and combine completed batch jobs for a directory.")
parser.add_argument("root_dir", type=str, help="Directory containing the original batches")
args = parser.parse_args()
target_dir = Path(args.root_dir)
dir_name = target_dir.name
output_path = target_dir / "batched_correction_result.jsonl"
if "GEMINI_API_KEY" not in os.environ:
sys.exit("Error: GEMINI_API_KEY environment variable not set.")
client = genai.Client()
print(f"Fetching jobs matching '{dir_name}'...")
all_jobs = client.batches.list()
matching_jobs = []
# 1. Find jobs associated with this directory
for job in all_jobs:
if hasattr(job, 'display_name') and job.display_name and dir_name in job.display_name:
matching_jobs.append(job)
if not matching_jobs:
sys.exit(f"No batch jobs found containing '{dir_name}' in their display name.")
# 2. Check that all matching jobs are complete
for job in matching_jobs:
state = job.state.name if hasattr(job.state, 'name') else job.state
print(f"Found Job: {job.display_name} | State: {state}")
if state != 'JOB_STATE_SUCCEEDED':
sys.exit(f"Error: Job '{job.display_name}' has not succeeded yet. Try again later.")
# 3. Download and concatenate
print("\nAll jobs succeeded. Downloading results...")
combined_data = b""
for job in matching_jobs:
if hasattr(job, 'dest') and job.dest and hasattr(job.dest, 'file_name') and job.dest.file_name:
print(f"Downloading output for {job.display_name}...")
file_content_bytes = client.files.download(file=job.dest.file_name)
combined_data += file_content_bytes
# Ensure proper line separation between files in JSONL
if combined_data and not combined_data.endswith(b'\n'):
combined_data += b'\n'
else:
print(f"Warning: Job {job.display_name} succeeded but has no output file.")
# 4. Save to destination
with open(output_path, "wb") as f:
f.write(combined_data)
print(f"\nSuccess! All results concatenated and saved to:\n{output_path}")
if __name__ == "__main__":
main()
-344
View File
@@ -1,344 +0,0 @@
from google import genai
from google.genai import types
import base64
from pathlib import Path
from pydantic import BaseModel, Field
from typing import List, Dict
import sys
import os
import time
import json
import argparse
import re
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
import config
MODEL_ID = config.MODEL_FOR_LABEL_ID
api_key = config.API_KEY
my_prompt = """I'm giving you an image of the left columns of a written exam.
Students answer several exercises, which can have several questions.
The image consists of several columns, separated by vertical black
lines. The image should be read top to bottom and then left to right,
meaning first column, then second column, etc.
In their sheet, students delimit exercises and questions using
delimiters such as `Ex 1`, or `Exercice 1`, and `1)` or `a)`. You need
to give me the bounding boxes of each delimiter.
When giving the bounding box of the first question of an exercise, the
box should be large enough to contain both the exercice label
(`Exercice i`) and the question label (`1)`) parts. If they are
horizontally far apart (example : if the `1)` is to the left and the
`Exercice i` is either to the right, or in the middle) then give only
the bounding box of the question label `1)` part. You should still
label it as `Exercice i : 1)` though.
You also need to give me the student name. It should appear on the top
left of the image. Disregard any mention of `MPSI 3`, it is their
class. A list of possible student names will be given below.
You will answer with a JSON object, containing a `name` field with the
name, and a `list` field, with the list of the bounding boxes and
their labels. The box_2d should be [ymin, xmin, ymax, xmax] normalized
to 0-1000.
Here is an example :
{\"name\" : \"John Doe\", \"list\" : [{\"box_2d\": (10, 20, 30, 40), \"label\" : \"Ex 1 : 1)\"}]}
Do not provide a box_2d for the name. Only for the labels. Order the
box_2d by their position in the page, column by column : first column
(top to bottom), then second column, etc.
You may find the same label present several times, as a student either
recall the current label on a new page, or adds content to its answer
later on. Give the position of each instance of each label.
For this exam you should look for the labels given below, separated by
newlines. A student need not have answered every question, so some may
be missing.
##labels##
##wrong_labels##
Here's a list of the names of the students, pick the one that matches
the best or `\"Unknown\"` if you cannot read the name
##names##"""
my_prompt2 = """I'm giving you an image of the left columns of a written exam.
Students answer several exercises, which can have several questions.
The image consists of several columns, separated by vertical black
lines. The image should be read top to bottom and then left to right,
meaning first column, then second column, etc.
In their sheet, students delimit exercises and questions using
delimiters such as `Ex 1`, or `Exercice 1`, and `1)` or `a)`. You need
to give me the bounding boxes of each delimiter.
When giving the bounding box of the first question of an exercise, the
box should be large enough to contain both the exercice label
(`Exercice i`) and the question label (`1)`) parts.
You also need to give me the student name. It should appear on the top
left of the image. Disregard any mention of `MPSI 3`, it is their
class. A list of possible student names will be given below.
You will answer with a JSON object, containing a `name` field with the
name, and a `list` field, with the list of the bounding boxes and
their labels. The box_2d should be [ymin, xmin, ymax, xmax] normalized
to 0-1000.
Here is an example :
{\"name\" : \"John Doe\", \"list\" : [{\"box_2d\": (10, 20, 30, 40), \"label\" : \"Ex 1 : 1)\"}]}
Do not provide a box_2d for the name. Only for the labels.
You may find the same label present several times, as a student either
recall the current label on a new page, or adds content to its answer
later on. Give the position of each instance of each label.
This image is one part of a sequence (e.g., part 2 of 3) for a single
student. Here is the list of labels found in the *previous* parts of
this copy:
[
##prev_context##
]
If the first column starts with a number like =3)= or =c)=, look at
the labels in the list above. If the last relevant label was =Ex 4 :
2)=, you should label the new box =Ex 4 : 3)=.
For this exam you should look for the labels given below, separated by
newlines. A student need not have answered every question, so some may
be missing.
##labels##
##wrong_labels##
Since this copy isn't the first part of a sequence, simply set the
name to `\"Continued\"`."""
class BoxItem(BaseModel):
box_2d: List[int] = Field(description="Bounding box coordinates (e.g., [ymin, xmin, ymax, xmax])")
label: str = Field(description="The label associated with the specific box")
class AnnotationData(BaseModel):
name: str = Field(description="The name identifier")
list: List[BoxItem] = Field(description="List of bounding box items")
def generate_request(file, labels, names, context_labels, wrong_labels):
"""Generates request for Gemini with context."""
image_path = Path(file)
# Format context list as a string
context_str = ", ".join([f'"{l}"' for l in context_labels]) if context_labels else "No previous context"
if context_labels == []:
text = my_prompt.replace("##labels##", labels)\
.replace("##names##", names)
else:
text = my_prompt2.replace("##labels##", labels)\
.replace("##prev_context##", context_str)
if wrong_labels:
text= text.replace("##wrong_labels##\n\n", f"On a previous request, you answered with the following wrong labels : {wrong_labels}. These are wrong, since they do not exactly match any of the labels in the previous list.")
else:
text = text.replace("##wrong_labels##\n\n", "")
contents = [
types.Content(
role="user",
parts=[
types.Part.from_bytes(
data=image_path.read_bytes(),
mime_type="image/jpeg"
),
types.Part.from_text(text=text),
],
)
]
generate_content_config = types.GenerateContentConfig(
temperature=1.0,
top_p=0.95,
seed=0,
max_output_tokens=65535,
response_mime_type= "application/json",
response_json_schema= AnnotationData.model_json_schema(),
)
return (contents, generate_content_config)
# Argument Parsing
parser = argparse.ArgumentParser(description="Process a directory or specific files using Gemini.")
parser.add_argument("input_paths", nargs='+', help="The input directory or specific files")
parser.add_argument("--overwrite", action="store_true", help="Regenerate output even if it exists")
args = parser.parse_args()
# input_arg = Path(args.input_path)
image_files = []
from utils import natural_key, read_all_labels
for path_str in args.input_paths:
input_arg = Path(path_str)
target_files = []
# 1. Determine which files to process
if input_arg.is_file():
INPUT_DIR = input_arg.parent.parent
target_files = [input_arg]
elif input_arg.is_dir():
INPUT_DIR = input_arg
COPIES_DIR = INPUT_DIR / "Copies"
target_files = list(COPIES_DIR.glob("Copie*.pdf"))
if not target_files:
print(f"Warning: No Copie*.pdf files found in {input_arg}")
else:
print(f"Error: {input_arg} is not a valid file or directory.")
continue
# 2. Run the logic for all collected files
for target_file in target_files:
# INPUT_DIR = target_file.parent
CUTLEFT_DIR = INPUT_DIR / 'Cutleft'
# Matches stem_01.jpg, stem_02.jpg, etc.
found_files = sorted(
CUTLEFT_DIR.glob(f"{target_file.stem}_*.jpg"),
key=natural_key
)
if found_files:
image_files.extend(found_files)
else:
print(f"Warning: No variants found for {target_file.stem} in {CUTLEFT_DIR}")
all_labels = read_all_labels(INPUT_DIR)
labels_txt = "\n".join(all_labels) + "\n"
valid_labels_set = set(all_labels)
names_path = (INPUT_DIR / "names")
if not os.path.exists(names_path):
names_path = Path("names")
names_txt = names_path.read_text()
valid_names_set = set(line.strip() for line in names_txt.splitlines() if line.strip())
valid_names_set.add("Unknown")
valid_names_set.add("Continued")
client = genai.Client(api_key=api_key)
# Group files by Copy ID (e.g. Copie01_01.jpg -> Copie01)
# regex: match everything before the last underscore if it ends in digits
file_groups = defaultdict(list)
for img in image_files:
stem = img.stem
# match CopieXX_YY -> Group CopieXX
match = re.match(r"(.+)_(\d+)$", stem)
if match:
group_key = match.group(1)
file_groups[group_key].append(img)
else:
# Fallback for files without underscore numbering
file_groups[stem].append(img)
# Sort files within each group to ensure sequential processing
for key in file_groups:
file_groups[key].sort(key=lambda x: x.name)
TARGET_INTERVAL = 3.5
def process_copy_group(group_key, files):
"""Processes a list of files belonging to one copy sequentially to maintain context."""
# Context accumulator for this specific copy
accumulated_labels = []
for image_file in files:
start_time = time.time()
base_name = image_file.stem
output_json = INPUT_DIR / "Copies" / f"{base_name}.json"
# Check existing
if output_json.exists() and not args.overwrite:
print(f"[{group_key}] Skipping {image_file.name}, output exists.")
# If skipping, we should try to load existing labels to keep context for next parts
try:
with open(output_json, 'r') as f:
data = json.load(f)
for item in data.get('list', []):
accumulated_labels.append(item['label'])
except:
pass # If read fails, next part has no context
continue
print(f"[{group_key}] Processing {image_file.name} with {len(accumulated_labels)} accumulated labels...")
attempt = -1
wrong_labels = []
while True:
attempt += 1
if attempt > 0:
time.sleep(10 * attempt)
try:
contents, config = generate_request(image_file, labels_txt, names_txt, accumulated_labels,
wrong_labels)
response = client.models.generate_content(
model=MODEL_ID,
contents=contents,
config=config
)
annota = AnnotationData.model_validate_json(response.text)
unknown = [item.label for item in annota.list if item.label not in valid_labels_set]
name = annota.name
if unknown:
print(f"Error: {image_file.name} contained unknown labels: {unknown}")
wrong_labels.extend(unknown)
print("Retrying request...")
continue # Retry immediately
if name not in valid_names_set:
print(f"Error: {image_file.name} returned unknown name : {name}")
if attempt == 0:
print("Retrying request...")
continue # Retry immediately
else:
name = "Unknown"
annota.name = name
# Save result
with open(output_json, "w", encoding="utf-8") as f:
json.dump(annota.model_dump(), f, indent=2)
# Update context for the next part in this group
for box in annota.list:
accumulated_labels.append(box.label)
break # exit retry loop
except Exception as e:
print(f"Error processing {image_file.name}: {e}\n\tIt will be retried.")
# Rate Limiting
elapsed = time.time() - start_time
time.sleep(max(0, TARGET_INTERVAL - elapsed))
# Run ThreadPool on GROUPS (Copies), not individual files
# Each thread handles one student's full exam copy sequentially
with ThreadPoolExecutor(max_workers=12) as executor:
# Convert dict items to arguments for map
# executor.map expects a function and an iterable.
# We use a lambda or separate function to unpack the tuple if needed,
# but here we'll just submit futures.
futures = [executor.submit(process_copy_group, k, v) for k, v in file_groups.items()]
# Wait for all to complete
for future in futures:
future.result()
-65
View File
@@ -1,65 +0,0 @@
import argparse
import shutil
import sys
from collections.abc import Sequence
from pathlib import Path
from config import IMPORT_DIR
from copienator import (
EvaluationWorkspace,
ExitCode,
evaluation_parser,
execute,
workspace_from_args,
)
def sync_annotated(
workspace: EvaluationWorkspace,
*,
refaire: bool = False,
import_dir: Path,
) -> ExitCode:
annotation_name = "BRnot" if refaire else "BGnot"
workspace.require_directories(annotation_name)
annotation_dir = workspace.root / annotation_name
annotated_dir = Path(import_dir).expanduser()
if not annotated_dir.is_dir():
print(f"Error: directory does not exist: {annotated_dir}", file=sys.stderr)
return ExitCode.INVALID_WORKSPACE
missing_targets = 0
for pdf_file in annotated_dir.glob("*.pdf"):
target_subdir = annotation_dir / pdf_file.stem
if not target_subdir.is_dir():
print(f"Warning: directory not found: {target_subdir}", file=sys.stderr)
missing_targets += 1
else:
dest_file = target_subdir / "Concat_annotated.pdf"
print(f"Copying {pdf_file} to {dest_file}")
shutil.copy2(pdf_file, dest_file)
return ExitCode.PARTIAL if missing_targets else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Import handwritten annotations from the tablet directory.")
parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json")
return parser
def run(workspace: EvaluationWorkspace, *, refaire: bool = False) -> ExitCode:
return sync_annotated(workspace, refaire=refaire, import_dir=Path(IMPORT_DIR))
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: run(workspace_from_args(args), refaire=args.refaire),
)
if __name__ == "__main__":
raise SystemExit(main())
-300
View File
@@ -1,300 +0,0 @@
from pathlib import Path
import io
import utils
main_prompt = """I'm giving you an image of several written answers to an exam.
Each answer is separated by a black horizontal line, and underneath,
to the left, is indicated the ID of the answer, from `01` to `50`.
I want you to score each answer, from 0 to 4, you may score half
points, such as 2.5. Even if a result is wrong, if the reasoning is
correct and could lead to a right answer, you should give at least
half the points.
You also need to give feedback to the student, in french :
- which part of his answer is wrong,
- why is it wrong
- possibly, what he should have done instead.
Your feedback may contain LaTeX fragments written like `$a^2 + b^2 = c^2$`.
If your score is not 4, you should always provide some feedback
explaining what's missing.
For each piece of feedback, if it is related to a specific part of the
answer that is wrong, you may provide a `box_2d`, to locate this
specific part of the answer. This `box_2d` should be in the form
[ymin, xmin, ymax, xmax] normalized to 0-1000. If you do not provide
one, set `box_2d` to `null`.
If the answer is correct, there is no need to provide feedback. You do
not have to give positive feedback, but if you do, do not provide a
`box_2d` for it.
For example, if the student says a function is continuous when it
isn't, provide the coordinates where the word «continuous» is. If a
calculation went wrong, gives the coordinates of the step where it
goes wrong, and as feedback, what went wrong.
Avoid giving feedback about confusing letters `n` with `m`, `x` with
`n` or `h` with `k`. If it looks wrong, assume you read it wrong,
unless the distinction is very important.
In some case, you may find that either
- The student didn't answer the right question. Set the score to 0.
Since it could be a labeling error, indicate it by setting `error`
to \"wrong-label\".
- You can find an answer to another question of the exercice (taking
more than a couple of lines). Score the question you are supposed
to score, but set `error` to \"additional-answer\".
- The answer to the question is empty, or the student has only
rewritten the statement of the question. In this case, set `error`
to \"empty-answer\" and do not provide any kind of feedback.
If there's no error, set `error` to `\"\"`.
You will answer using json describing a list of dictionary with a key
\"id\", and a key \"result\" that contains the \"score\", a list
\"feedback\", and possibly an \"error\". Like this example :
[{ \"id\": \"01\",
\"result\": {\"score\" : 2.5,
\"feedback\": [{text: \"Un retour générique. Il faut apprendre le cours.\", box_2d: null},
{text: \"Non, la fonction n'est pas forcément continue\", pos: [145, 280, 340, 500]}],
\"error\": \"\"}
},
{ \"id\": \"04\",
\"result\": {\"score\" : 4.,
\"feedback\" : []
\"error\": \"\" }
}
]
Here is the text of the exercice (or the relevant part of the problem)
of the exam :
```
<<text>>
```
Here is a possible correct answer :
```
<<corr>>
```
<<persp>>
You are asked to score the question or exercice labeled `<<label>>`,
do not score or give feedback to any other question."""
from utils import get_label_text_content, get_label_sol_content, get_label_persp_content
def make_prompt(input_dir,full_label):
text = get_label_text_content(input_dir, full_label) or ""
corr = get_label_sol_content(input_dir, full_label) or ""
persp = get_label_persp_content(input_dir, full_label) or ""
# print("Debug : l/t/c/p", full_label, text, corr, persp)
if persp:
persp = "\n\nHere are additional scoring instructions : \n\n```\n" + persp +"\n```\n"
return main_prompt.replace("<<text>>", text).replace("<<corr>>", corr).replace("<<persp>>", persp).replace("<<label>>", full_label)
from pydantic import BaseModel, Field, TypeAdapter
from typing import List, Optional, Tuple
class FeedbackItem(BaseModel):
text: str = Field(description="Feedback content")
box_2d: Optional[List[int]] = Field(None, description="box coordinates or null")
class ResultData(BaseModel):
score: float = Field(description="The numeric score")
feedback: List[FeedbackItem] = Field(description="List of feedback items")
error: str = Field(description="Indicates if an error occurred")
class EvaluationEntry(BaseModel):
id: str = Field(description="Entry identifier")
result: ResultData = Field(description="Result details")
# These nested definitions do not work with the batch api, unroll them
UNROLLED_SCHEMA = {
"type": "ARRAY",
"items": {
"type": "OBJECT",
"properties": {
"id": {"type": "STRING", "description": "Entry identifier"},
"result": {
"type": "OBJECT",
"properties": {
"score": {"type": "NUMBER", "description": "The numeric score"},
"error": {"type": "STRING", "description": "Indicates if an error occurred"},
"feedback": {
"type": "ARRAY",
"description": "List of feedback items",
"items": {
"type": "OBJECT",
"properties": {
"text": {"type": "STRING", "description": "Feedback content"},
"box_2d": {
"type": "ARRAY",
"items": {"type": "INTEGER"},
"nullable": True,
"description": "box coordinates or null"
}
},
"required": ["text"]
}
}
},
"required": ["score", "feedback", "error"]
}
},
"required": ["id", "result"]
}
}
from google.genai import types
# The root model for parsing is be: List[EvaluationEntry]
def generate_request(input_dir, file, full_label):
"""Generates request for Gemini."""
prompt = make_prompt(input_dir, full_label)
image_path = Path(file)
contents = [
types.Content(
role="user",
parts=[
types.Part.from_bytes(
data=image_path.read_bytes(),
mime_type="image/jpeg"
),
types.Part.from_text(text=prompt),
],
)
]
generate_content_config = types.GenerateContentConfig(
temperature=1.0,
top_p=0.95,
seed=0,
max_output_tokens=65535,
response_mime_type= "application/json",
response_json_schema= TypeAdapter(List[EvaluationEntry]).json_schema()
)
return (contents, generate_content_config)
from pdf2image import convert_from_path
from PIL import Image
import json
def get_single_image_bytes(pdf_path):
"""Converts a multi-page PDF into a single stitched JPEG in memory."""
imgs = convert_from_path(pdf_path, dpi=200) # Same DPI as grouping.py
if not imgs:
raise ValueError(f"No pages in {pdf_path}")
if len(imgs) == 1:
combined = imgs[0]
else:
max_width = max(img.width for img in imgs)
total_height = sum(img.height for img in imgs)
combined = Image.new('RGB', (max_width, total_height), 'white')
y_offset = 0
for img in imgs:
combined.paste(img, (0, y_offset))
y_offset += img.height
img_byte_arr = io.BytesIO()
combined.save(img_byte_arr, format='JPEG', quality=85)
return img_byte_arr.getvalue()
def request_for_box_correction(pdf_path, original_feedbacks):
img_bytes = get_single_image_bytes(pdf_path)
localized_feedbacks = [f for f in original_feedbacks if f["box_2d"]]
prompt = f"""
Here is a single student's submission to a question in a written exam. The following JSON contains feedback items with bounding boxes (box_2d) that are incorrect. Each piece of feedback is supposed to be related to a piece of the answer that is wrong.
For example, if the student says a function is continuous when it
isn't, the coordinates should be where the word «continuous» is. If a
calculation went wrong, the coordinates should be where the step where
it goes wrong, and the feedback is what went wrong.
Please analyze the image and return the same feedback json content, but with ONLY the box_2d coordinates corrected for this specific image.
Coordinates must be [ymin, xmin, ymax, xmax] scaled to 1000. If a box is invalid/not found, return null for it.
Original feedback:
{json.dumps(localized_feedbacks, indent=2)}
"""
contents = [
types.Content(
role="user",
parts=[
types.Part.from_bytes(data=img_bytes, mime_type="image/jpeg"),
types.Part.from_text(text=prompt),
],
)
]
config = types.GenerateContentConfig(
temperature=1.0,
response_mime_type="application/json",
response_json_schema=TypeAdapter(List[FeedbackItem]).json_schema()
)
return contents,config
def request_for_wrong_label(pdf_path, label, enonce, labels_txt):
prompt = f"""This image is a part of the answer of a student to a written exam.
It was initially labeled '{label}' but I suspect this label is wrong. Perhaps the student himself wrote the wrong label.
You need to analyse this image, and find the label of the question it answers. Do not trust the label written by the student but instead check the content of its answer and the notation he uses to identify the correct label of the question the student answered.
Return ONLY the exact label string.
Here is the full content of the exam :
{enonce}
Here is a list of all possible labels. You need to answer with one of these :
{labels_txt}
"""
contents = [types.Content(role="user", parts=[
types.Part.from_bytes(data=get_single_image_bytes(pdf_path), mime_type="image/jpeg"),
types.Part.from_text(text=prompt)])]
config = types.GenerateContentConfig(temperature=1.0)
return contents, config
def request_for_additional_answer(pdf_path, label, enonce, labels_txt):
prompt = f"""This image is a part of the answer of a student to a written exam.
It was initially labeled '{label}' but I suspect this image also contains answers to another, or several other questions.
You need to analyse this image, and find the list of the labels of the questions it answers. Return ONLY the list of the exact label strings.
If the end of the image only contains the first line of an answer to another question, ignore it.
Here is the full content of the exam :
{enonce}
Here is a list of all possible labels. You need to answer with a list one of these :
{labels_txt}
"""
contents = [types.Content(role="user", parts=[
types.Part.from_bytes(data=get_single_image_bytes(pdf_path), mime_type="image/jpeg"),
types.Part.from_text(text=prompt)
])]
config = types.GenerateContentConfig(temperature=1.0, response_mime_type="application/json")
return contents, config
+38
View File
@@ -0,0 +1,38 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "copienator"
version = "0.1.0"
description = "Workflow assistant for preparing and correcting scanned exams"
requires-python = ">=3.11"
dependencies = [
"numpy",
"opencv-python-headless>=4.8",
"pandas",
"matplotlib",
"Pillow",
"pydantic",
"pypdf",
"pdf2image",
"reportlab",
"img2pdf",
"PyMuPDF",
"ftfy",
"ezodf",
"google-genai",
]
[project.scripts]
copienator = "copienator.dispatcher:main"
copienator-gui = "copienator_gui.__main__:main"
[tool.setuptools]
py-modules = ["default_config"]
[tool.setuptools.packages.find]
include = ["copienator*", "copienator_gui*"]
[tool.setuptools.package-data]
copienator = ["data/*.txt"]
-400
View File
@@ -1,400 +0,0 @@
from __future__ import annotations
import argparse
import concurrent.futures
from collections import defaultdict
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from PIL import Image, ImageDraw
import annotating
import utils
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data
from copienator.filesystem import staged_files
from reading_annotations import (
concatenate,
detect_checks_and_notes,
has_significant_notes,
)
LabelNotes = dict[str, dict[str, Any]]
ScanResult = tuple[dict[str, list[dict[str, Any]]], dict[str, LabelNotes]]
def get_extra_pdfs_as_images(
root_dir: str | Path,
label: str,
annotating_module: Any,
all_labels: list[str],
) -> list[Image.Image]:
"""Convert the context, question and solution PDFs associated with a label."""
paths = [
*utils.pdf_images_of_contexts(root_dir, label, all_labels),
utils.pdf_image_of_enonce(root_dir, label),
utils.pdf_image_of_solution(root_dir, label),
]
images = []
for path in paths:
if path:
image, _, _ = annotating_module.make_base_image(path)
if image is not None:
images.append(image)
return images
def save_paginated_pdf(image_groups: list[list[Image.Image]], output_path: Path) -> None:
"""Paginate vertically concatenated image groups and save them as a PDF."""
non_empty = [group for group in image_groups if group]
if not non_empty:
return
max_width = max(image.width for group in non_empty for image in group)
max_page_height = int(max_width * 1.414 * 1.25)
border = int((0.2 / 2.54) * 100)
left_margin = int((0.3 / 2.54) * 100)
vertical_margin = int((0.2 / 2.54) * 100)
max_content_height = max_page_height - 2 * vertical_margin
pages: list[Image.Image] = []
page_images: list[Image.Image] = []
page_height = 0
def finish_page() -> None:
nonlocal page_images, page_height
if not page_images:
return
page = Image.new(
"RGB",
(max_width + left_margin, page_height + 2 * vertical_margin),
"white",
)
current_y = vertical_margin
for image in page_images:
page.paste(image, (left_margin, current_y))
current_y += image.height
pages.append(page)
page_images = []
page_height = 0
for group in non_empty:
processed: list[Image.Image] = []
for index, image in enumerate(group):
if index in (0, 1):
image = image.copy()
color = "black" if index == 0 else "blue"
ImageDraw.Draw(image).rectangle(
[0, 0, image.width - 1, image.height - 1],
outline=color,
width=border,
)
processed.append(image)
group_height = sum(image.height for image in processed)
if page_images and page_height + group_height > max_content_height:
finish_page()
page_images.extend(processed)
page_height += group_height
finish_page()
pages[0].save(
output_path,
"PDF",
resolution=100.0,
save_all=True,
append_images=pages[1:],
)
def _scan_annotation_directory(
directory: Path,
only_ids: set[str] | None = None,
default_student_id: str | None = None,
) -> ScanResult:
bnote_path = directory / "bnote.json"
if not bnote_path.is_file():
raise FileNotFoundError(f"Missing {bnote_path}")
bnote = read_json(bnote_path)
if not isinstance(bnote, dict):
raise TypeError(f"Expected a JSON object in {bnote_path}")
images = [item for item in bnote.get("images", []) if isinstance(item, dict)]
if only_ids and not any(
str(item.get("id", default_student_id)) in only_ids for item in images
):
return {}, {}
actions, notes_image = detect_checks_and_notes(directory)
if notes_image is None:
return {}, {}
actions_by_student: dict[str, list[dict[str, Any]]] = defaultdict(list)
notes_by_student: dict[str, LabelNotes] = defaultdict(dict)
for action in actions:
raw_student_id = action.get("student_id", default_student_id)
if raw_student_id is not None:
actions_by_student[str(raw_student_id)].append(action)
for image_info in images:
student_id = str(image_info.get("id", default_student_id or ""))
label = str(image_info.get("label", ""))
hmin = int(image_info.get("hmin", 0))
hmax = int(image_info.get("hmax", 0))
if student_id and label and hmax > hmin:
crop = notes_image.crop((0, hmin, notes_image.width, hmax))
if has_significant_notes(crop):
notes_by_student[student_id][label] = {
"img": crop,
"old_header_h": int(image_info.get("header_height", 0)),
}
return dict(actions_by_student), dict(notes_by_student)
def _merge_scan_result(
target_actions: dict[str, list[dict[str, Any]]],
target_notes: dict[str, LabelNotes],
result: ScanResult,
) -> None:
actions, notes = result
for student_id, student_actions in actions.items():
target_actions[student_id].extend(student_actions)
for student_id, student_notes in notes.items():
target_notes[student_id].update(student_notes)
def apply_actions_and_regenerate_grouped(
workspace: EvaluationWorkspace,
data: AnnotationData,
student_id: str,
actions: list[dict[str, Any]],
label_notes: LabelNotes,
all_labels: list[str],
*,
update_score: bool = False,
) -> tuple[ExitCode, str]:
"""Apply grouped annotations and atomically merge regenerated student files."""
logs = [f"\nProcessing compilation for: Copie{student_id}"]
output_dir = workspace.annotation_dir("grouped") / f"Copie{student_id}"
labels_data = data.get(student_id, {})
dirty_labels = apply_checkbox_actions(labels_data, actions, logs.append)
if update_score:
dirty_labels |= apply_score_overrides(
labels_data, output_dir / "score.json", logs.append
)
scores = dict.fromkeys(all_labels, "")
dirty_images: dict[str, Image.Image] = {}
concat_images: list[Image.Image] = []
filtered_groups: list[list[Image.Image]] = []
incomplete = False
for label, content in sorted(labels_data.items(), key=lambda item: utils.natural_key(item[0])):
result = content["result"]
scores[label] = str(result.get("score", 0))
pdf_path = Path(content["pdf_path"])
if not pdf_path.is_file():
logs.append(f" Missing answer PDF: {pdf_path}")
incomplete = True
continue
base_image, _, _ = annotating.make_base_image(pdf_path)
final_image, new_header_height = annotating.compose_label_image(
base_image,
label,
result,
content["coordinates"][0],
with_error=False,
)
if final_image is None:
incomplete = True
continue
has_notes = False
if label in label_notes:
sub_note = label_notes[label]["img"]
old_header_height = int(label_notes[label]["old_header_h"])
has_notes = has_significant_notes(sub_note)
if has_notes:
width, height = sub_note.size
if old_header_height > 0:
header = sub_note.crop((0, 0, width, min(height, old_header_height)))
final_image.paste(header, (0, 0), mask=header)
if height > old_header_height:
body = sub_note.crop((0, old_header_height, width, height))
final_image.paste(body, (0, new_header_height), mask=body)
if label in dirty_labels or has_notes:
dirty_images[label] = final_image
logs.append(f" Saved dirty image: {label}.jpg")
concat_images.append(final_image)
feedbacks = result.get("feedback", [])
perfect = float(scores[label]) >= 4.0 and all(
feedback.get("to_delete", False) for feedback in feedbacks
)
if not perfect or has_notes:
extras = get_extra_pdfs_as_images(
workspace.root, label, annotating, all_labels
)
filtered_groups.append([*extras, final_image])
concat_image = concatenate(concat_images)
with staged_files(output_dir) as staging:
for label, image in dirty_images.items():
image.save(staging / f"{label}.jpg")
atomic_write_json(staging / "score.json", scores)
if concat_image is not None:
concat_image.save(staging / "Concat.jpg")
if filtered_groups:
save_paginated_pdf(filtered_groups, staging / "Concat_F.pdf")
logs.append(f" Saved regenerated files in {output_dir}")
status = ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS
return status, "\n".join(logs)
def _read_refaire(workspace: EvaluationWorkspace) -> tuple[RefaireList, dict[str, list[str]]]:
loaded = read_json(workspace.refaire_file)
if not isinstance(loaded, list):
raise TypeError("refaire.json must contain a JSON array")
entries: RefaireList = []
by_student: dict[str, list[str]] = {}
for entry in loaded:
if not isinstance(entry, list) or len(entry) != 2 or not isinstance(entry[1], list):
raise TypeError(f"Malformed refaire entry: {entry!r}")
copy_name, labels = entry
student_id = str(copy_name).removeprefix("Copie")
normalized_labels = [str(label) for label in labels]
entries.append([str(copy_name), normalized_labels])
by_student[student_id] = normalized_labels
return entries, by_student
def run(
workspace: EvaluationWorkspace,
*,
refaire: bool = False,
update_score: bool = False,
) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label", "BGnot")
refaire_list: RefaireList | None = None
refaire_by_student: dict[str, list[str]] = {}
if refaire:
workspace.require_files("refaire.json")
workspace.require_directories("BRnot")
refaire_list, refaire_by_student = _read_refaire(workspace)
all_labels = utils.read_all_labels(workspace.root)
loaded = load_annotation_data(workspace, refaire_list=refaire_list)
for warning in loaded.warnings:
print(f"Warning: {warning}")
if not loaded.data:
print("No annotation data found.")
return ExitCode.PARTIAL
actions_by_student: dict[str, list[dict[str, Any]]] = defaultdict(list)
notes_by_student: dict[str, LabelNotes] = defaultdict(dict)
only_ids = set(refaire_by_student) or None
group_dirs = [
path
for path in workspace.annotation_dir("grouped").iterdir()
if path.is_dir() and not path.name.startswith("Copie")
]
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
futures = [
executor.submit(_scan_annotation_directory, path, only_ids)
for path in group_dirs
]
for future in concurrent.futures.as_completed(futures):
_merge_scan_result(actions_by_student, notes_by_student, future.result())
refaire_incomplete = False
if refaire:
for student_id, requested_labels in refaire_by_student.items():
selected = requested_labels or list(loaded.data.get(student_id, {}))
selected_set = set(selected)
directory = workspace.annotation_dir("refaire") / f"Copie{student_id}"
if not directory.is_dir():
print(f"Warning: missing refaire annotation directory {directory}")
refaire_incomplete = True
continue
actions_by_student[student_id] = [
action
for action in actions_by_student[student_id]
if str(action.get("label")) not in selected_set
]
for label in selected:
notes_by_student[student_id].pop(label, None)
refaire_actions, refaire_notes = _scan_annotation_directory(
directory, default_student_id=student_id
)
for action in refaire_actions.get(student_id, []):
if str(action.get("label")) in selected_set:
actions_by_student[student_id].append(action)
for label, note in refaire_notes.get(student_id, {}).items():
if label in selected_set:
notes_by_student[student_id][label] = note
status = (
ExitCode.PARTIAL
if loaded.warnings or refaire_incomplete
else ExitCode.SUCCESS
)
student_ids = list(refaire_by_student) if refaire else sorted(loaded.data, key=utils.natural_key)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
futures = {
executor.submit(
apply_actions_and_regenerate_grouped,
workspace,
loaded.data,
student_id,
actions_by_student[student_id],
notes_by_student[student_id],
all_labels,
update_score=update_score,
): student_id
for student_id in student_ids
if student_id in loaded.data
}
for future in concurrent.futures.as_completed(futures):
result, output = future.result()
print(output)
if result != ExitCode.SUCCESS:
status = ExitCode.PARTIAL
return status
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Read grouped annotations and regenerate copies")
parser.add_argument(
"--refaire",
action="store_true",
help="Use refaire.json and merge annotations from BRnot",
)
parser.add_argument(
"--update-score",
action="store_true",
help="Override generated scores with values from existing score.json files",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
return run(
workspace_from_args(args),
refaire=args.refaire,
update_score=args.update_score,
)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
-223
View File
@@ -1,223 +0,0 @@
import fitz # PyMuPDF
from pypdf import PdfWriter
from pypdf import PdfReader
import os
import sys
import json
import shutil
from pathlib import Path
from collections import defaultdict
from utils import read_all_labels
carreau = 1000 // 38
def decode_json(pdf_file):
file_path = Path(pdf_file)
with open(file_path.with_suffix(".json"), "r") as f:
json_result = json.load(f)
nb_pages = len(PdfReader(file_path).pages)
bb_list = json_result["list"]
name = json_result["name"]
column_width = 1000 // nb_pages
def page_number(b):
return ((b[1] + b[3]) // 2) // column_width
result = []
for d in bb_list:
(b, label) = d["box_2d"], d["label"]
pn = page_number(b)
result.append((label, pn, b[0] - carreau, b[2]-carreau, b[1], b[3]))
result.sort(key=lambda x: (x[1], x[2]))
return (name, result)
def split_an_interro(base_dir, input_pdf, coords_list):
doc = fitz.open(input_pdf)
output_dir = base_dir / "Copies" / input_pdf.stem
generated_files = set()
parts_by_label = defaultdict(list)
# 1. Parse labels to strip '|' and determine type: L (Left), R (Right), N (Normal)
parsed_coords = []
for item in coords_list:
label, pn, y0, y1, x0, x1 = item
if label.startswith("|"):
c_type, clean_label = "L", label[1:]
elif label.endswith("|"):
c_type, clean_label = "R", label[:-1]
else:
c_type, clean_label = "N", label
parsed_coords.append((clean_label, c_type, pn, y0, y1, x0, x1))
# 2. Filter consecutive duplicate labels based on the cleaned name
filtered_coords = []
if parsed_coords:
filtered_coords.append(parsed_coords[0])
for item in parsed_coords[1:]:
if item[0] != filtered_coords[-1][0]:
filtered_coords.append(item)
coords_list = filtered_coords
def scale_coord(y, page):
"""Scale y from 01000 range to PDF points."""
page_height = page.rect.height
return (y / 1000) * page_height
def save_cropped_page(doc, page_num, x0, y0, x1, y1, out_path):
"""Saves a cropped portion of a page as a new PDF."""
page = doc[page_num]
rotated_rect = page.rect * page.transformation_matrix
visual_crop_rect = fitz.Rect(rotated_rect.x0 + x0, y0, rotated_rect.x0 + x1, y1)
unrotated_clip_rect = visual_crop_rect * page.derotation_matrix
temp_doc = fitz.open()
temp_page = temp_doc.new_page(
width=visual_crop_rect.width,
height=visual_crop_rect.height
)
temp_page.show_pdf_page(
temp_page.rect,
doc,
page_num,
rotate=-page.rotation,
clip=unrotated_clip_rect
)
temp_doc.save(out_path)
temp_doc.close()
# Iterate through all labels
for idx, (clean_label, c_type, start_page, y_start_raw, y_end_box, x0_raw, x1_raw) in enumerate(coords_list):
if clean_label == "_":
continue
temp_parts = []
end_page = doc.page_count - 1
end_y_target_raw = 1000
# RULE 2: Determine stopping label
for next_item in coords_list[idx + 1:]:
n_clean, n_type, n_pn, n_y_start, n_y_end, _, _ = next_item
if c_type == "L":
is_stop = (n_type in ("L", "N"))
elif c_type == "R":
is_stop = (n_type in ("R", "N"))
else:
is_stop = True # Normal labels stop at anything
if is_stop:
end_page = n_pn
# end_y_target_raw = n_y_start
# On avait retiré un carreau précédemment inutilement, on le rajoute, plus un demi carreau
end_y_target_raw = min(n_y_start + int(1.5 * carreau), 1000)
break
# RULES 3 & 4: Calculate horizontal boundaries (0.0 to 1.0 fraction of local page width)
col_w = 1000 / doc.page_count
if c_type == "L": # |name
fraction_x0 = (x0_raw % col_w) / col_w
fraction_x1 = 1.0
end_y_target_raw = min(1000, end_y_target_raw + 40)
elif c_type == "R": # name|
fraction_x0 = 0.0
# Find the closest 'L' label in y-distance
L_labels = [it for it in parsed_coords if it[1] == "L"]
if L_labels:
closest_L = min(L_labels, key=lambda it: abs(it[3] - y_start_raw))
closest_L_x_center = (closest_L[5] + closest_L[6]) / 2.0
fraction_x1 = (closest_L_x_center % col_w) / col_w
if fraction_x1 <= fraction_x0: fraction_x1 = 1.0 # Fallback
else:
fraction_x1 = 1.0
else: # Normal
fraction_x0 = 0.0
fraction_x1 = 1.0
current_p = start_page
while current_p <= end_page:
page = doc[current_p]
y0 = scale_coord(y_start_raw, page) if current_p == start_page else 0
y1 = scale_coord(end_y_target_raw, page) if current_p == end_page else page.rect.height
if y1 > y0 + 1:
# Convert fractions to absolute PDF points
x0_pdf = fraction_x0 * page.rect.width
x1_pdf = fraction_x1 * page.rect.width
temp_path = f"_part_{idx}_{current_p}.pdf"
save_cropped_page(doc, current_p, x0_pdf, y0, x1_pdf, y1, temp_path)
temp_parts.append(temp_path)
current_p += 1
parts_by_label[clean_label].extend(temp_parts)
output_dir.mkdir(parents=True, exist_ok=True)
# Process aggregated parts by label
for title, parts in parts_by_label.items():
merger = PdfWriter()
for part in parts:
if os.path.exists(part):
merger.append(part)
filename = f"{title}.pdf"
merger.write(output_dir / filename)
merger.close()
generated_files.add(filename)
# Cleanup
for part in parts:
if os.path.exists(part):
os.remove(part)
doc.close()
# Move files not generated in this run to 'Missing' folder
if output_dir.exists():
missing_dir = output_dir / "Missing"
for item in output_dir.iterdir():
if item.is_file() and item.name not in generated_files:
print(f"ALERT: File '{item.name}' not generated. Moving to {missing_dir}")
missing_dir.mkdir(exist_ok=True)
item.rename(missing_dir / item.name)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python script.py <directory or pdf_file>")
sys.exit(1)
input_arg = Path(sys.argv[1])
if input_arg.is_file():
base_dir = input_arg.parent
if base_dir.name == "Copies":
base_dir = base_dir.parent
pdf_files = [input_arg]
elif input_arg.is_dir():
base_dir = input_arg
copies_dir = base_dir / "Copies"
pdf_files = sorted(copies_dir.glob("*.pdf"))
else:
print(f"Error: {input_arg} is not a valid file or directory.")
sys.exit(1)
read_all_labels(base_dir)
for pdf_path in pdf_files:
json_path = pdf_path.with_suffix(".json")
# print("Debug :", json_path)
if json_path.exists():
(name, coords) = decode_json(pdf_path)
print("Decoded name : ", name)
split_an_interro(base_dir, pdf_path, coords)
else:
print(f"Warning: No JSON found for {pdf_path.name}")
Executable
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
# Applications opened from a file manager do not inherit variables exported by
# an interactive shell (for example GEMINI_API_KEY from ~/.zshrc). Re-enter
# through the user's shell once so its startup file can provide those values.
if [[ -z "${GEMINI_API_KEY:-}" && -z "${COPIENATOR_GUI_SHELL_LOADED:-}" && -x "${SHELL:-}" ]]; then
export COPIENATOR_GUI_SHELL_LOADED=1
exec "$SHELL" -ic 'exec "$@"' copienator-gui-shell "$0" "$@"
fi
# With no arguments, choose the newest visible immediate subfolder by mtime.
if [[ $# -eq 0 ]]; then
newest=""
for candidate in "$PWD"/*/; do
[[ -d "$candidate" ]] || continue
folder="${candidate%/}"
folder="${folder##*/}"
case "$folder" in
copienator|copienator_gui|tests|OLD|__pycache__|build|dist|*.egg-info|venv|env|node_modules)
continue
;;
esac
if [[ -z "$newest" || "$candidate" -nt "$newest" ]]; then
newest="$candidate"
fi
done
if [[ -n "$newest" ]]; then
set -- "${newest%/}"
fi
fi
# Resolve the evaluation before changing to the project directory.
if [[ $# -gt 0 && "$1" != -* && "$1" != /* ]]; then
evaluation="$PWD/$1"
shift
set -- "$evaluation" "$@"
fi
repository="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
cd -- "$repository"
if [[ -x "$repository/.venv/bin/python" ]]; then
python_command="$repository/.venv/bin/python"
else
python_command="python3"
fi
exec "$python_command" -m copienator_gui "$@"
-77
View File
@@ -1,77 +0,0 @@
import os
import sys
import argparse
from pathlib import Path
from google import genai
from google.genai import types
def main():
parser = argparse.ArgumentParser(description="Upload JSONL files and create Gemini Batch jobs.")
parser.add_argument("root_dir", type=str, help="Root directory containing the batch JSONL files")
args = parser.parse_args()
root_dir = Path(args.root_dir)
if "GEMINI_API_KEY" not in os.environ:
sys.exit("Error: GEMINI_API_KEY environment variable not set.")
client = genai.Client()
# Define the batch files and their corresponding models
batches_to_create = [
{
"file_path": root_dir / "batch_requests_flash.jsonl",
"model_id": "gemini-3-flash-preview",
"display_name": f"flash-correction-{root_dir.name}"
},
{
"file_path": root_dir / "batch_requests_pro.jsonl",
"model_id": "gemini-3.1-pro-preview",
"display_name": f"pro-correction-{root_dir.name}"
}
]
for batch in batches_to_create:
file_path = batch["file_path"]
model_id = batch["model_id"]
display_name = batch["display_name"]
# Check if the file exists
if not file_path.exists():
print(f"Skipping {model_id}: {file_path.name} does not exist.")
continue
# Check if the file is empty (e.g., if all tasks went to Flash, Pro might be empty)
if file_path.stat().st_size == 0:
print(f"Skipping {model_id}: {file_path.name} is empty.")
continue
print(f"Processing {file_path.name} for model {model_id}...")
# 1. Upload the file to the File API
print(f" Uploading file...")
uploaded_file = client.files.upload(
file=str(file_path),
config=types.UploadFileConfig(
display_name=f"{display_name}-input",
mime_type='jsonl'
)
)
print(f" Uploaded successfully! File ID: {uploaded_file.name}")
# 2. Create the batch job
print(f" Starting batch job...")
batch_job = client.batches.create(
model=model_id,
src=uploaded_file.name,
config={
'display_name': display_name,
},
)
print(f" Success! Batch Job Name: {batch_job.name}\n")
print("-" * 50)
print("All batch jobs have been initiated.")
if __name__ == "__main__":
main()
+99
View File
@@ -0,0 +1,99 @@
import contextlib
import io
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import pandas as pd
from PIL import Image
from copienator.commands import add_final_score
class AddFinalScoreTests(unittest.TestCase):
def test_creates_complete_student_directory(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "A Rendre" / "Student (01)"
answers = source / "answers"
answers.mkdir(parents=True)
Image.new("RGB", (300, 200), "white").save(source / "Student.jpg")
(source / "Student.pdf").write_bytes(b"pdf")
(source / "score.json").write_bytes(b'{"Ex 1": "4"}')
(source / "info.json").write_bytes(b'{"Ex 1": {}}')
(answers / "Ex 1.jpg").write_bytes(b"answer")
output = root / "output"
output.mkdir()
(output / "Student.jpg").write_bytes(b"legacy jpeg")
(output / "Student.pdf").write_bytes(b"legacy pdf")
stale_answers = output / "Student" / "answers"
stale_answers.mkdir(parents=True)
(stale_answers / "001 - Ex 1.jpg").write_bytes(b"stale")
scores = pd.DataFrame({0: ["Student"], 1: [12.39]})
histogram = root / "histogramme.pdf"
histogram.write_bytes(b"histogram")
with patch.object(add_final_score.pd, "read_excel", return_value=scores), \
patch.object(add_final_score, "HISTOGRAM_PATH", histogram), \
contextlib.redirect_stdout(io.StringIO()):
add_final_score.process_images(root, output)
student = output / "Student"
self.assertEqual(
{path.name for path in student.iterdir()},
{"Student.jpg", "Student.pdf", "score.json", "info.json", "answers"},
)
self.assertEqual((student / "Student.pdf").read_bytes(), b"pdf")
self.assertEqual((student / "score.json").read_bytes(), b'{"Ex 1": "4"}')
self.assertEqual((student / "info.json").read_bytes(), b'{"Ex 1": {}}')
self.assertEqual(
(student / "answers" / "Ex 1.jpg").read_bytes(), b"answer"
)
self.assertFalse((output / "Student.jpg").exists())
self.assertFalse((output / "Student.pdf").exists())
self.assertFalse((student / "answers" / "001 - Ex 1.jpg").exists())
self.assertEqual((output / "histogramme.pdf").read_bytes(), b"histogram")
def test_omits_answers_directory_when_it_was_not_generated(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "A Rendre" / "Student (01)"
source.mkdir(parents=True)
Image.new("RGB", (300, 200), "white").save(source / "Student.jpg")
(source / "Student.pdf").write_bytes(b"pdf")
(source / "score.json").write_text("{}")
(source / "info.json").write_text("{}")
output = root / "output"
scores = pd.DataFrame({0: ["Student"], 1: [10]})
with patch.object(add_final_score.pd, "read_excel", return_value=scores), \
contextlib.redirect_stdout(io.StringIO()):
add_final_score.process_images(root, output)
self.assertFalse((output / "Student" / "answers").exists())
def test_missing_histogram_warns_without_discarding_student_outputs(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "A Rendre" / "Student (01)"
source.mkdir(parents=True)
Image.new("RGB", (300, 200), "white").save(source / "Student.jpg")
scores = pd.DataFrame({0: ["Student"], 1: [10]})
output = root / "output"
messages = io.StringIO()
with patch.object(
add_final_score.pd, "read_excel", return_value=scores
), patch.object(
add_final_score, "HISTOGRAM_PATH", root / "missing.pdf"
), contextlib.redirect_stdout(messages):
add_final_score.process_images(root, output)
self.assertTrue((output / "Student" / "Student.jpg").is_file())
self.assertFalse((output / "histogramme.pdf").exists())
self.assertIn("Missing histogram", messages.getvalue())
if __name__ == "__main__":
unittest.main()
+343
View File
@@ -0,0 +1,343 @@
import contextlib
import io
import itertools
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image
from copienator import EvaluationWorkspace, atomic_write_json, configuration, read_json
from copienator.commands import annotating, giving_names
from copienator.commands import reading_grouped_annotations as reader
from copienator.return_answers import (
publish_answer_returns,
save_return_answer_options,
)
class AnswerReturnTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.source = self.root / "BGnot" / "Copie01"
self.source.mkdir(parents=True)
self.destination = self.root / "A Rendre" / "Student (01)"
self.destination.mkdir(parents=True)
(self.root / "labels").write_text("Ex 1\nEx 2\nEmpty\n")
atomic_write_json(self.source / "score.json", {"Ex 1": "4", "Ex 2": "2", "Empty": "0"})
atomic_write_json(self.source / "info.json", {
"Ex 1": {"present": True, "not_empty": True, "touched": False, "score": "4"},
"Ex 2": {"present": True, "not_empty": True, "touched": True, "score": "2"},
"Empty": {"present": True, "not_empty": False, "touched": False, "score": "0"},
})
for label in ("Ex 1", "Ex 2", "Empty"):
Image.new("RGB", (100, 30), "red").save(self.source / f"{label}.jpg")
for folder in ("Text2", "Sol2"):
(self.root / folder).mkdir()
for label in ("Ex 1", "Ex 2"):
(self.root / "Text2" / f"{label}.pdf").touch()
(self.root / "Sol2" / f"{label}.pdf").touch()
(self.root / "Text2" / "CTXT Ex 1 -> Ex 2.pdf").touch()
@staticmethod
def supplement(path):
path = Path(path)
color = "blue" if path.name.startswith("CTXT") else "green" if path.parent.name == "Text2" else "yellow"
return Image.new("RGB", (100, 20), color), 0, 0
def test_all_supplement_combinations_always_include_annotated_answer(self):
for context, question, solution in itertools.product((False, True), repeat=3):
with self.subTest(context=context, question=question, solution=solution), patch.multiple(
configuration, RETURN_ANSWERS_ENABLED=True,
RETURN_ANSWERS_CONTEXT=context, RETURN_ANSWERS_QUESTION=question,
RETURN_ANSWERS_SOLUTION=solution,
), patch.object(annotating, "make_base_image", side_effect=self.supplement):
publish_answer_returns(self.root, self.source, self.destination)
files = sorted((self.destination / "answers").glob("*.jpg"))
self.assertEqual([p.name for p in files], ["Ex 1.jpg", "Ex 2.jpg"])
with Image.open(files[0]) as image:
self.assertEqual(image.size, (100, 30 + 20 * sum((context, question, solution))))
colors = []
if context:
colors.append((0, 0, 255))
if question:
colors.append((0, 128, 0))
if solution:
colors.append((255, 255, 0))
colors.append((255, 0, 0))
for index, color in enumerate(colors):
pixel = image.getpixel((50, index * 20 + 10))
self.assertTrue(all(abs(a - b) < 10 for a, b in zip(pixel, color)))
self.assertEqual(read_json(self.destination / "info.json"), read_json(self.source / "info.json"))
def test_saved_reading_options_override_configuration_for_answers(self):
save_return_answer_options(
self.root,
context=True,
question=False,
solution=True,
)
with patch.multiple(
configuration,
RETURN_ANSWERS_ENABLED=True,
RETURN_ANSWERS_CONTEXT=False,
RETURN_ANSWERS_QUESTION=True,
RETURN_ANSWERS_SOLUTION=False,
), patch.object(
annotating, "make_base_image", side_effect=self.supplement
):
publish_answer_returns(self.root, self.source, self.destination)
with Image.open(self.destination / "answers" / "Ex 1.jpg") as image:
self.assertEqual(image.size, (100, 70))
for y, color in (
(10, (0, 0, 255)),
(30, (255, 255, 0)),
(50, (255, 0, 0)),
):
self.assertTrue(
all(
abs(actual - expected) < 10
for actual, expected in zip(image.getpixel((50, y)), color)
)
)
def test_missing_supplement_is_optional_and_failure_preserves_old_answers(self):
with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True,
RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=True,
RETURN_ANSWERS_SOLUTION=False), patch.object(
annotating, "make_base_image", side_effect=self.supplement
):
(self.root / "Text2" / "Ex 1.pdf").unlink()
publish_answer_returns(self.root, self.source, self.destination)
path = self.destination / "answers" / "Ex 1.jpg"
with Image.open(path) as image:
self.assertEqual(image.size, (100, 30))
original = path.read_bytes()
(self.source / "Ex 2.jpg").unlink()
with self.assertRaises(FileNotFoundError):
publish_answer_returns(self.root, self.source, self.destination)
self.assertEqual(path.read_bytes(), original)
self.assertTrue((self.destination / "answers" / "Ex 2.jpg").exists())
def test_disabling_clears_individual_images_but_retains_info(self):
with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True,
RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=False,
RETURN_ANSWERS_SOLUTION=False):
publish_answer_returns(self.root, self.source, self.destination)
with patch.object(configuration, "RETURN_ANSWERS_ENABLED", False):
publish_answer_returns(self.root, self.source, self.destination)
self.assertEqual(list((self.destination / "answers").iterdir()), [])
self.assertTrue(read_json(self.destination / "info.json")["Ex 2"]["touched"])
def test_missing_info_requires_recompilation_instead_of_guessing(self):
(self.source / "info.json").unlink()
(self.source / "Concat_F.pdf").touch()
with self.assertRaisesRegex(ValueError, "recompile"):
publish_answer_returns(self.root, self.source, self.destination)
def test_info_replaces_touched_and_tracks_manual_score_edits(self):
atomic_write_json(self.destination / "touched.json", {"obsolete": True})
atomic_write_json(self.source / "score.json", {"Ex 1": "3.5", "Ex 2": "2", "Empty": "0"})
with patch.object(configuration, "RETURN_ANSWERS_ENABLED", False):
publish_answer_returns(self.root, self.source, self.destination)
self.assertFalse((self.destination / "touched.json").exists())
self.assertEqual(read_json(self.destination / "info.json")["Ex 1"], {
"present": True, "not_empty": True, "touched": False, "score": "3.5"
})
def test_invalid_info_preserves_previous_return(self):
atomic_write_json(self.destination / "info.json", {"previous": "keep"})
info = read_json(self.source / "info.json")
info["Empty"]["touched"] = True
atomic_write_json(self.source / "info.json", info)
with self.assertRaisesRegex(ValueError, "Invalid question information"):
publish_answer_returns(self.root, self.source, self.destination)
self.assertEqual(read_json(self.destination / "info.json"), {"previous": "keep"})
def test_publication_is_independent_of_full_jpeg_and_pdf_options(self):
workspace = EvaluationWorkspace(self.root)
workspace.copies_dir.mkdir()
atomic_write_json(workspace.copies_dir / "Copie01.json", {"name": "Student"})
(self.root / "names").write_text("Student\n")
with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True,
RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=False,
RETURN_ANSWERS_SOLUTION=False, RETURN_JPEG_ENABLED=False,
RETURN_PDF_ENABLED=False), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(giving_names.run(workspace, annotation_dir="BGnot"), 0)
self.assertEqual({p.name for p in self.destination.iterdir()}, {"answers", "score.json", "info.json"})
def test_update_uses_copy_id_from_renamed_folder_and_touches_only_answers(self):
workspace = EvaluationWorkspace(self.root)
workspace.copies_dir.mkdir()
atomic_write_json(workspace.copies_dir / "Copie01.json", {"name": "Student"})
renamed = self.destination.with_name("Nom modifié manuellement (01)")
self.destination.rename(renamed)
(renamed / "Nom personnalisé.jpg").write_bytes(b"keep-jpeg")
(renamed / "Nom personnalisé.pdf").write_bytes(b"keep-pdf")
(renamed / "score.json").write_bytes(b"keep-score")
(renamed / "info.json").write_bytes(b"keep-info")
answers = renamed / "answers"
answers.mkdir()
(answers / "obsolete.jpg").write_bytes(b"obsolete")
preserved = {
path.name: path.read_bytes()
for path in renamed.iterdir()
if path.is_file()
}
with patch.multiple(
configuration,
RETURN_ANSWERS_ENABLED=True,
RETURN_ANSWERS_CONTEXT=False,
RETURN_ANSWERS_QUESTION=False,
RETURN_ANSWERS_SOLUTION=False,
), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(
giving_names.run(workspace, annotation_dir="BGnot", update=True),
0,
)
self.assertFalse((workspace.return_dir / "Student (01)").exists())
self.assertEqual(
{
path.name: path.read_bytes()
for path in renamed.iterdir()
if path.is_file()
},
preserved,
)
self.assertEqual(
sorted(path.name for path in answers.iterdir()),
["Ex 1.jpg", "Ex 2.jpg"],
)
class CompiledMembershipTests(unittest.TestCase):
def test_update_score_preserves_file_and_manual_value_wins(self):
with tempfile.TemporaryDirectory() as directory:
workspace = EvaluationWorkspace(Path(directory))
output = workspace.root / "BGnot" / "Copie01"
output.mkdir(parents=True)
original_score = b'{\n "Ex 1": "3.5"\n}\n'
(output / "score.json").write_bytes(original_score)
answer = workspace.root / "answer.pdf"
answer.touch()
data = {
"01": {
"Ex 1": {
"result": {"score": 1, "feedback": []},
"pdf_path": answer,
"coordinates": (0, 0),
}
}
}
rendered_scores = []
def compose(base, label, result, *args, **kwargs):
rendered_scores.append(result["score"])
return Image.new("RGB", (100, 50), "white"), 0
with patch.object(
annotating, "make_base_image", return_value=(None, 0, 0)
), patch.object(
annotating, "compose_label_image", side_effect=compose
), patch.object(
reader, "get_extra_pdfs_as_images", return_value=[]
), patch.object(reader, "save_paginated_pdf"):
status, _ = reader.apply_actions_and_regenerate_grouped(
workspace,
data,
"01",
[{"label": "Ex 1", "type": "score", "value": "2"}],
{},
["Ex 1"],
update_score=True,
)
self.assertEqual(status, 0)
self.assertEqual(rendered_scores, ["3.5"])
self.assertEqual((output / "score.json").read_bytes(), original_score)
self.assertEqual(read_json(output / "info.json")["Ex 1"]["score"], "3.5")
def test_membership_matches_actual_groups_and_saves_every_nonempty_answer(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
workspace = EvaluationWorkspace(root)
output = root / "BGnot" / "Copie01"
output.mkdir(parents=True)
answer = root / "answer.pdf"
answer.touch()
results = {
"Perfect": {"score": 4, "feedback": []},
"Low": {"score": 2, "feedback": []},
"Feedback": {"score": 4, "feedback": [{"text": "keep"}]},
"Deleted": {"score": 4, "feedback": [{"text": "delete", "to_delete": True}]},
"Handwriting": {"score": 4, "feedback": []},
"Empty": {"score": 0, "error": "empty-answer"},
}
data = {"01": {label: {"result": result, "pdf_path": answer, "coordinates": (0, 0)}
for label, result in results.items()}}
all_labels = [*results, "Absent"]
rendered = []
def compose(base, label, *args, **kwargs):
rendered.append(label)
return Image.new("RGB", (100, 50), "white"), 0
notes = {"Handwriting": {"img": Image.new("RGBA", (100, 50), "red"), "old_header_h": 0}}
with patch.object(annotating, "make_base_image", return_value=(None, 0, 0)), patch.object(
annotating, "compose_label_image", side_effect=compose
), patch.object(reader, "get_extra_pdfs_as_images", return_value=[]), patch.object(
reader, "save_paginated_pdf"
) as save_pdf:
status, _ = reader.apply_actions_and_regenerate_grouped(workspace, data, "01", [], notes, all_labels)
self.assertEqual(status, 0)
info = read_json(output / "info.json")
touched = {label: entry["touched"] for label, entry in info.items()}
self.assertEqual({label for label, value in touched.items() if value}, {"Low", "Feedback", "Handwriting"})
self.assertEqual(len(save_pdf.call_args.args[0]), sum(touched.values()))
self.assertEqual({label for label, entry in info.items() if entry["present"] and entry["not_empty"]}, set(results) - {"Empty"})
self.assertEqual(info["Empty"], {"present": True, "not_empty": False, "touched": False, "score": "0"})
self.assertEqual(info["Absent"], {"present": False, "not_empty": False, "touched": False, "score": ""})
self.assertEqual(info["Perfect"], {"present": True, "not_empty": True, "touched": False, "score": "4"})
self.assertNotIn("Empty", rendered)
for label in rendered:
self.assertTrue((output / f"{label}.jpg").is_file())
# Selective redo keeps unselected saved answers in the PDF,
# including previously perfect answers; touched must follow it.
status, _ = reader.apply_actions_and_regenerate_grouped(
workspace, data, "01", [], {}, all_labels, selected_labels={"Low"}
)
self.assertEqual(status, 0)
self.assertTrue(read_json(output / "info.json")["Perfect"]["touched"])
self.assertFalse(read_json(output / "info.json")["Empty"]["not_empty"])
def test_all_empty_removes_stale_concat_and_records_false(self):
with tempfile.TemporaryDirectory() as directory:
workspace = EvaluationWorkspace(Path(directory))
output = workspace.root / "BGnot" / "Copie01"
output.mkdir(parents=True)
for name in ("Concat.jpg", "Concat_F.pdf"):
(output / name).write_bytes(b"stale")
atomic_write_json(output / "touched.json", {"Empty": True})
atomic_write_json(output / "answer_labels.json", ["Empty"])
status, _ = reader.apply_actions_and_regenerate_grouped(
workspace, {"01": {"Empty": {"result": {"score": 0, "error": "empty-answer"}}}},
"01", [], {}, ["Empty"]
)
self.assertEqual(status, 0)
self.assertEqual(read_json(output / "info.json"), {
"Empty": {"present": True, "not_empty": False, "touched": False, "score": "0"}
})
self.assertFalse((output / "Concat.jpg").exists())
self.assertFalse((output / "Concat_F.pdf").exists())
self.assertFalse((output / "touched.json").exists())
self.assertFalse((output / "answer_labels.json").exists())
if __name__ == "__main__":
unittest.main()
+69
View File
@@ -0,0 +1,69 @@
import queue
import unittest
from unittest.mock import Mock, patch
from copienator_gui.batch_monitor import BatchMonitor, CHECK_INTERVAL_MS
from copienator_gui.notifications import notify_desktop
class BatchMonitorTests(unittest.TestCase):
def setUp(self):
self.scheduler = Mock()
self.scheduler.after.side_effect = lambda delay, callback: (delay, callback)
self.ready = Mock()
self.status = Mock()
self.monitor = BatchMonitor(self.scheduler, self.ready, self.status, Mock())
self.runner = Mock()
self.runner.events = queue.Queue()
self.factory = patch("copienator_gui.batch_monitor.ProcessRunner", return_value=self.runner)
self.factory.start()
self.addCleanup(self.factory.stop)
def start(self):
self.monitor.start(["check"], "/tmp", {}, None)
def test_checks_immediately_retries_in_five_minutes_and_notifies_once(self):
self.start()
self.runner.start.assert_called_once()
self.start()
self.runner.start.assert_called_once()
self.runner.events.put(("finished", (4, False)))
self.monitor._poll()
self.assertEqual(self.monitor.timer[0], CHECK_INTERVAL_MS)
self.assertEqual(CHECK_INTERVAL_MS, 300000)
self.ready.assert_not_called()
self.monitor.timer[1]()
self.assertEqual(self.runner.start.call_count, 2)
self.runner.events.put(("finished", (0, False)))
self.monitor._poll()
self.assertFalse(self.monitor.active)
self.assertIsNone(self.monitor.timer)
self.ready.assert_called_once()
self.monitor._poll()
self.ready.assert_called_once()
def test_stop_cancels_timer_and_running_check_without_notification(self):
self.start()
timer = self.monitor.timer
self.monitor.stop()
self.scheduler.after_cancel.assert_called_once_with(timer)
self.runner.force_stop.assert_called_once()
self.runner.events.put(("finished", (0, False)))
self.monitor._poll()
self.monitor._check()
self.ready.assert_not_called()
self.runner.start.assert_called_once()
def test_start_failure_retries_without_reporting_readiness(self):
self.runner.start.side_effect = OSError("unavailable")
self.start()
self.assertEqual(self.monitor.timer[0], CHECK_INTERVAL_MS)
self.ready.assert_not_called()
def test_linux_notification_passes_text_as_arguments(self):
with patch("copienator_gui.notifications.sys.platform", "linux"), patch(
"copienator_gui.notifications.find_executable", return_value="/usr/bin/notify-send"
), patch("copienator_gui.notifications.subprocess.Popen") as launch:
notify_desktop("Copienator", "Interro02 : résultats prêts")
self.assertEqual(launch.call_args.args[0], ["/usr/bin/notify-send", "--app-name=Copienator",
"--", "Copienator", "Interro02 : résultats prêts"])
+54
View File
@@ -0,0 +1,54 @@
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, call, patch
from copienator import CliError, EvaluationWorkspace, ExitCode, atomic_write_json
from copienator.commands.batch_status import check_evaluation_jobs, main
class BatchReadinessTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.workspace = EvaluationWorkspace(Path(self.temp.name))
self.client = Mock()
def manifest(self, jobs):
atomic_write_json(self.workspace.batch_jobs_file, {"jobs": jobs})
def test_only_recorded_jobs_are_checked_and_all_must_have_results(self):
self.manifest({"flash": {"name": "batches/flash"}, "pro": {"name": "batches/pro"}})
succeeded = SimpleNamespace(state="JOB_STATE_SUCCEEDED",
dest=SimpleNamespace(file_name="files/result"))
for state in ("JOB_STATE_PENDING", "JOB_STATE_RUNNING", "JOB_STATE_FAILED",
"JOB_STATE_CANCELLED", "JOB_STATE_EXPIRED", "UNKNOWN", "JOB_STATE_SUCCEEDED"):
with self.subTest(state=state):
self.client.reset_mock()
self.client.batches.get.side_effect = [succeeded, SimpleNamespace(
state=SimpleNamespace(name=state), dest=SimpleNamespace(file_name="files/pro"))]
result = check_evaluation_jobs(self.workspace, client=self.client)
self.assertEqual(result, ExitCode.SUCCESS if state == "JOB_STATE_SUCCEEDED" else ExitCode.PARTIAL)
self.assertEqual(self.client.batches.get.call_args_list,
[call(name="batches/flash"), call(name="batches/pro")])
self.client.batches.list.assert_not_called()
self.client.files.download.assert_not_called()
self.client.batches.get.side_effect = [succeeded, SimpleNamespace(
state="JOB_STATE_SUCCEEDED", dest=None)]
self.assertEqual(check_evaluation_jobs(self.workspace, client=self.client), ExitCode.PARTIAL)
def test_missing_empty_and_invalid_manifest_cannot_report_success(self):
self.assertEqual(check_evaluation_jobs(self.workspace, client=self.client), ExitCode.PARTIAL)
self.manifest({})
self.assertEqual(check_evaluation_jobs(self.workspace, client=self.client), ExitCode.PARTIAL)
for jobs in ([], {"flash": {}}, {"flash": {"name": ""}}, {"flash": None}):
self.manifest(jobs)
with self.assertRaises(CliError):
check_evaluation_jobs(self.workspace, client=self.client)
self.client.batches.get.assert_not_called()
def test_cli_returns_readiness_code_for_selected_evaluation(self):
with patch("copienator.commands.batch_status.check_evaluation_jobs", return_value=ExitCode.PARTIAL) as check:
self.assertEqual(main(["--evaluation", str(self.workspace.root)]), ExitCode.PARTIAL)
self.assertEqual(check.call_args.args[0].root, self.workspace.root)
+30
View File
@@ -0,0 +1,30 @@
import unittest
import numpy as np
import pymupdf
from copienator.crop_blank_margins import apply_bounds
class CropBlankMarginsTests(unittest.TestCase):
def test_cropbox_coordinates_with_rotation_and_existing_crop(self):
for rotation in (0, 90, 180, 270):
with self.subTest(rotation=rotation), pymupdf.open() as doc:
page = doc.new_page(width=600, height=800)
page.set_cropbox(pymupdf.Rect(30, 40, 570, 760))
page.set_rotation(rotation)
before = page.rect
page.insert_text((80, 220), 'Visible content', fontsize=20)
pix = page.get_pixmap()
original = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, 3)
apply_bounds(page, .2, .8)
self.assertAlmostEqual(page.rect.width, before.width)
self.assertAlmostEqual(page.rect.height, before.height*.6, places=3)
self.assertEqual(page.rotation, rotation)
pix = page.get_pixmap()
cropped = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, 3)
np.testing.assert_array_equal(cropped, original[int(before.height*.2):int(before.height*.8)])
if __name__ == '__main__':
unittest.main()
+59
View File
@@ -0,0 +1,59 @@
import tempfile
import unittest
from pathlib import Path
import pymupdf
from copienator.crop_exercise_bottoms import process_exercise_pdf
class CropExerciseBottomsTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.review = self.root / "review"
def make_pdf(self, name: str, height: float, text_y: float, footer=True) -> Path:
path = self.root / name
with pymupdf.open() as document:
page = document.new_page(width=600, height=height)
page.insert_text((80, text_y), "student answer", fontsize=16)
if footer:
page.insert_text((20, height - 3), "Ex 2", fontsize=10)
document.save(path)
return path
def test_short_page_is_skipped(self):
source = self.make_pdf("short.pdf", 200, 100)
destination = self.root / "out" / source.name
records = process_exercise_pdf(
source, destination, self.review, 800, dpi=150
)
self.assertEqual(records[0]["status"], "skipped-short")
self.assertFalse(destination.exists())
def test_footer_fragment_is_ignored_for_a_large_bottom_crop(self):
source = self.make_pdf("large.pdf", 400, 100)
destination = self.root / "out" / source.name
records = process_exercise_pdf(
source, destination, self.review, 800, dpi=150
)
self.assertEqual(records[0]["status"], "cropped")
self.assertGreaterEqual(records[0]["bottom_removed_lines"], 4)
with pymupdf.open(destination) as result:
self.assertLess(result[0].rect.height, 250)
self.assertAlmostEqual(result[0].rect.width, 600)
def test_crop_smaller_than_four_lines_is_not_written(self):
source = self.make_pdf("small.pdf", 400, 330, footer=False)
destination = self.root / "out" / source.name
records = process_exercise_pdf(
source, destination, self.review, 800, dpi=150
)
self.assertEqual(records[0]["status"], "unchanged-small-crop")
self.assertFalse(destination.exists())
if __name__ == "__main__":
unittest.main()
+156
View File
@@ -0,0 +1,156 @@
import contextlib
import io
import tempfile
import unittest
from pathlib import Path
import pymupdf
from copienator.cli import CliError
from copienator.commands import crop_exercise_bottoms
from copienator.commands.clean import apply_cleanup, build_cleanup_plan
from copienator.dispatcher import main
from copienator.workspace import EvaluationWorkspace
from copienator_gui.workflow import build_workflow
class CropExerciseBottomsCommandTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.workspace = EvaluationWorkspace(Path(self.temp.name) / "Évaluation")
self.workspace.copies_dir.mkdir(parents=True)
self.copy_pdf = self.workspace.copies_dir / "Copie01.pdf"
with pymupdf.open() as document:
document.new_page(width=600, height=800)
document.save(self.copy_pdf)
self.answers = self.workspace.copies_dir / "Copie01"
self.answers.mkdir()
self.large = self.answers / "Ex 1.pdf"
self.short = self.answers / "Ex 2.pdf"
self._make_answer(self.large, 400, 100, footer=True)
self._make_answer(self.short, 200, 100, footer=False)
self.large_original = self.large.read_bytes()
self.short_original = self.short.read_bytes()
@staticmethod
def _make_answer(path: Path, height: float, text_y: float, *, footer: bool) -> None:
with pymupdf.open() as document:
page = document.new_page(width=600, height=height)
page.insert_text((80, text_y), "student answer", fontsize=16)
if footer:
page.insert_text((20, height - 3), "Ex 2", fontsize=10)
document.save(path)
def test_dispatcher_replaces_only_changed_answers_and_backs_them_up(self):
with contextlib.redirect_stdout(io.StringIO()) as log:
self.assertEqual(
main(["crop-answer-bottoms", str(self.workspace.root), "--workers", "2"]),
0,
)
self.assertIn("1 PDF remplacé", log.getvalue())
self.assertIn("1 exercice(s) rogné(s)", log.getvalue())
self.assertIn("Rognage moyen des exercices modifiés", log.getvalue())
with pymupdf.open(self.large) as cropped:
self.assertLess(cropped[0].rect.height, 250)
self.assertEqual(self.short.read_bytes(), self.short_original)
backups = list(
self.workspace.runs_dir.glob(
"crop-exercise-bottoms-*/Copies/Copie01/Ex 1.pdf"
)
)
self.assertEqual(len(backups), 1)
self.assertEqual(backups[0].read_bytes(), self.large_original)
self.assertFalse(
list(
self.workspace.runs_dir.glob(
"crop-exercise-bottoms-*/Copies/Copie01/Ex 2.pdf"
)
)
)
self.assertTrue((backups[0].parents[2] / "report.json").is_file())
def test_crop_statistics_average_percentages_by_exercise(self):
records = [
{
"file": "Copies/Copie01/Ex 1.pdf",
"height_lines": 10.0,
"bottom_removed_lines": 4.0,
"status": "cropped",
},
{
"file": "Copies/Copie01/Ex 1.pdf",
"height_lines": 10.0,
"bottom_removed_lines": 0.0,
"status": "skipped-short",
},
{
"file": "Copies/Copie01/Ex 2.pdf",
"height_lines": 10.0,
"bottom_removed_lines": 5.0,
"status": "cropped",
},
{
"file": "Copies/Copie01/Ex 3.pdf",
"height_lines": 10.0,
"bottom_removed_lines": 0.0,
"status": "unchanged-small-crop",
},
]
count, average = crop_exercise_bottoms.crop_statistics(records)
self.assertEqual(count, 2)
self.assertAlmostEqual(average, 35.0)
def test_detection_failure_does_not_publish_an_earlier_result(self):
broken = self.answers / "Ex 3.pdf"
broken.write_bytes(b"not a PDF")
with contextlib.redirect_stdout(io.StringIO()), self.assertRaises(Exception):
crop_exercise_bottoms.run(
self.workspace, self.workspace.root, workers=1
)
self.assertEqual(self.large.read_bytes(), self.large_original)
self.assertEqual(self.short.read_bytes(), self.short_original)
self.assertEqual(broken.read_bytes(), b"not a PDF")
self.assertFalse(self.workspace.runs_dir.exists())
def test_invalid_target_and_worker_count_are_rejected(self):
with self.assertRaises(CliError):
crop_exercise_bottoms.run(self.workspace, self.large, workers=1)
with self.assertRaises(CliError):
crop_exercise_bottoms.run(self.workspace, self.workspace.root, workers=0)
def test_archiving_removes_the_retained_originals_and_report(self):
with contextlib.redirect_stdout(io.StringIO()):
crop_exercise_bottoms.run(
self.workspace, self.workspace.root, workers=1
)
run_dirs = list(
self.workspace.runs_dir.glob("crop-exercise-bottoms-*")
)
self.assertEqual(len(run_dirs), 1)
self.workspace.correction_file.write_text("{}")
student = self.workspace.return_dir / "Student"
student.mkdir(parents=True)
(student / "answer.jpg").write_bytes(b"return image")
(student / "score.json").write_text("{}")
plan = build_cleanup_plan(self.workspace)
self.assertTrue(
any(path.name == "report.json" for path in plan.deleted_files)
)
with contextlib.redirect_stdout(io.StringIO()):
apply_cleanup(self.workspace, plan)
self.assertFalse(self.workspace.runs_dir.exists())
def test_optional_step_follows_splitting_and_precedes_grouping(self):
steps = build_workflow(False)
index = next(
i for i, step in enumerate(steps) if step.id == "crop_exercise_bottoms"
)
self.assertEqual(steps[index - 1].id, "splitting")
self.assertEqual(steps[index + 1].id, "grouping")
self.assertTrue(steps[index].optional)
self.assertTrue(steps[index].auto_start_first_visit)
if __name__ == "__main__":
unittest.main()
+218
View File
@@ -0,0 +1,218 @@
import contextlib
import io
import os
import signal
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import pymupdf
from copienator.cli import CliError
from copienator.commands import crop_margins
from copienator.commands.clean import build_cleanup_plan, apply_cleanup
from copienator.dispatcher import main
from copienator.workspace import EvaluationWorkspace
from copienator_gui.workflow import build_workflow
class CropMarginsCommandTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.workspace = EvaluationWorkspace(Path(self.temp.name)/"Évaluation")
self.workspace.copies_dir.mkdir(parents=True)
self.source = self.workspace.copies_dir/"Copie01.pdf"
with pymupdf.open() as doc:
page = doc.new_page(width=300, height=420)
page.insert_text((50, 180), "answer = 42", fontsize=15)
doc.new_page(width=300, height=420)
doc.save(self.source)
self.original = self.source.read_bytes()
def test_dispatcher_replaces_copies_and_keeps_recoverable_original(self):
with contextlib.redirect_stdout(io.StringIO()) as log:
self.assertEqual(main(["crop-margins", str(self.workspace.root)]), 0)
self.assertIn("Page 2/2", log.getvalue())
self.assertIn("1/2 pages rognées", log.getvalue())
self.assertIn("Rognage moyen des pages modifiées", log.getvalue())
self.assertIn("page(s) rognée(s) de plus de 30 %", log.getvalue())
with pymupdf.open(self.source) as result:
self.assertEqual(len(result), 2)
self.assertLess(result[0].rect.height, 150)
self.assertIn("answer = 42", result[0].get_text())
self.assertEqual(result[1].rect.height, 420)
for page in result:
self.assertEqual(page.rect.width, 300)
page.get_pixmap()
backups = list(self.workspace.runs_dir.glob("crop-margins-*/Copies/Copie01.pdf"))
self.assertEqual(len(backups), 1)
self.assertEqual(backups[0].read_bytes(), self.original)
self.assertTrue((backups[0].parent.parent/"report.json").is_file())
def test_crop_statistics_use_only_modified_pages(self):
records = [
{
"top_removed_mm": 10.0,
"bottom_removed_mm": 20.0,
"original_cropbox": [0, 0, 200, 300],
"rotation": 0,
},
{
"top_removed_mm": 40.0,
"bottom_removed_mm": 0.0,
"original_cropbox": [0, 0, 200, 300],
"rotation": 90,
},
{
"top_removed_mm": 0.0,
"bottom_removed_mm": 0.0,
"original_cropbox": [0, 0, 200, 300],
"rotation": 0,
},
]
count, average, over_thirty = crop_margins.crop_statistics(records)
self.assertEqual(count, 2)
self.assertAlmostEqual(average, (28.3465 + 56.6929) / 2, places=3)
self.assertEqual(over_thirty, 1)
def test_failure_or_interruption_never_publishes_partial_batch(self):
second = self.workspace.copies_dir/"Copie02.pdf"
second.write_bytes(self.original)
real = crop_margins.process_pdf
for error in (OSError("bad scan"), KeyboardInterrupt()):
with self.subTest(error=type(error).__name__):
def process(source, *args, **kwargs):
if source == second:
raise error
return real(source, *args, **kwargs)
with patch.object(crop_margins, "process_pdf", side_effect=process):
with contextlib.redirect_stdout(io.StringIO()):
with self.assertRaises(type(error)):
crop_margins.run(self.workspace, self.workspace.root, workers=1)
self.assertEqual(self.source.read_bytes(), self.original)
self.assertEqual(second.read_bytes(), self.original)
def test_existing_coordinates_are_not_silently_invalidated(self):
self.source.with_suffix(".json").write_text('{"list": []}')
with self.assertRaisesRegex(CliError, "coordonnées"):
crop_margins.run(self.workspace, self.workspace.root)
self.assertEqual(self.source.read_bytes(), self.original)
def test_archiving_removes_crop_backups_but_keeps_processed_pdf_and_logs(self):
with contextlib.redirect_stdout(io.StringIO()):
crop_margins.run(self.workspace, self.workspace.root)
processed = self.source.read_bytes()
self.workspace.correction_file.write_text('{}')
student = self.workspace.return_dir/"Student"
student.mkdir(parents=True)
(student/"answer.jpg").write_bytes(b"return image")
(student/"score.json").write_text('{}')
self.workspace.logs_dir.mkdir(parents=True)
log = self.workspace.logs_dir/"crop_blank_margins.log"
log.write_text("Completed crop")
backups = list(self.workspace.runs_dir.glob("crop-margins-*/Copies/*.pdf"))
reports = list(self.workspace.runs_dir.glob("crop-margins-*/report.json"))
self.assertTrue(backups)
self.assertTrue(reports)
plan = build_cleanup_plan(self.workspace)
for path in backups+reports:
self.assertIn(path, plan.deleted_files)
self.assertIn(self.source, plan.kept_files)
self.assertIn(log, plan.kept_files)
with contextlib.redirect_stdout(io.StringIO()):
apply_cleanup(self.workspace, plan)
self.assertFalse(self.workspace.runs_dir.exists())
self.assertEqual(self.source.read_bytes(), processed)
self.assertEqual(log.read_text(), "Completed crop")
self.assertTrue((student/"answer.jpg").exists())
self.assertTrue((student/"score.json").exists())
def test_single_copy_selection_cannot_target_original_scans(self):
self.assertEqual(crop_margins.selected_files(self.workspace, self.source), [self.source])
original = self.workspace.root/"Original.pdf"
original.write_bytes(self.original)
with self.assertRaises(CliError):
crop_margins.selected_files(self.workspace, original)
def test_parallel_workers_match_serial_results_in_copy_and_page_order(self):
second = self.workspace.copies_dir/"Copie02.pdf"
second.write_bytes(self.original)
serial, parallel = self.workspace.root/"serial", self.workspace.root/"parallel"
serial.mkdir()
parallel.mkdir()
files = [self.source, second]
with contextlib.redirect_stdout(io.StringIO()):
expected = crop_margins.process_copies(files, serial, 1)
actual = crop_margins.process_copies(files, parallel, 2)
self.assertEqual(actual, expected)
self.assertEqual([(r['file'],r['page']) for r in actual],
[(p.name,i) for p in files for i in (1,2)])
for source in files:
with pymupdf.open(serial/source.name) as a, pymupdf.open(parallel/source.name) as b:
self.assertEqual([list(p.cropbox) for p in a], [list(p.cropbox) for p in b])
def test_worker_failure_does_not_replace_any_copy(self):
broken = self.workspace.copies_dir/"Copie02.pdf"
broken.write_bytes(b"not a PDF")
with contextlib.redirect_stdout(io.StringIO()):
with self.assertRaises(Exception):
crop_margins.run(self.workspace, self.workspace.root, workers=2)
self.assertEqual(self.source.read_bytes(), self.original)
self.assertEqual(broken.read_bytes(), b"not a PDF")
self.assertFalse(list(self.workspace.root.glob(".Copies.*.files.tmp")))
def test_invalid_worker_count_is_rejected_before_processing(self):
with self.assertRaises(CliError):
crop_margins.run(self.workspace, self.workspace.root, workers=0)
self.assertEqual(self.source.read_bytes(), self.original)
@unittest.skipIf(os.name == "nt", "SIGINT subprocess check uses Unix signals")
def test_interrupt_stops_parallel_workers_before_removing_staging(self):
import select
with pymupdf.open() as doc:
for _ in range(30):
page = doc.new_page(width=595, height=842)
page.insert_text((100,400), "answer = 42", fontsize=20)
data = doc.tobytes()
self.source.write_bytes(data)
second = self.workspace.copies_dir/"Copie02.pdf"
second.write_bytes(data)
proc = subprocess.Popen(
[sys.executable,"-u","-m","copienator","crop-margins",
str(self.workspace.root),"--workers","2"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
cwd=Path(__file__).resolve().parents[1])
try:
while True:
ready, _, _ = select.select([proc.stdout], [], [], 20)
self.assertTrue(ready, "No progress from parallel crop command")
line = proc.stdout.readline()
self.assertTrue(line, "Crop command exited before processing a page")
if "Page " in line:
break
proc.send_signal(signal.SIGINT)
output, _ = proc.communicate(timeout=20)
self.assertEqual(proc.returncode, 130, output)
finally:
if proc.poll() is None:
proc.kill()
proc.communicate()
self.assertEqual(self.source.read_bytes(), data)
self.assertEqual(second.read_bytes(), data)
self.assertFalse(list(self.workspace.root.glob(".Copies.*.files.tmp")))
def test_optional_step_sits_between_page_split_and_label_crop(self):
steps = build_workflow(False)
index = next(i for i, step in enumerate(steps) if step.id == "crop_blank_margins")
self.assertEqual(steps[index-1].id, "page_splitter")
self.assertEqual(steps[index+1].id, "cutleft")
self.assertTrue(steps[index].optional)
self.assertTrue(steps[index].auto_start_first_visit)
if __name__ == "__main__":
unittest.main()
+82
View File
@@ -0,0 +1,82 @@
import copy
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from PIL import Image
from copienator import EvaluationWorkspace, atomic_write_json
from copienator.annotation_data import GroupCoordinates, _scaled_result
from copienator.annotation_actions import apply_checkbox_actions
from copienator.commands import annotating, correction
from copienator.feedback_boxes import valid_feedback_box
class FeedbackBoxTests(unittest.TestCase):
def test_checkbox_for_promoted_feedback_deletes_the_correct_comment(self):
feedback = [{"text": "Invalid local", "box_2d": [753, 680, 287, 946]},
{"text": "Existing global", "box_2d": None}]
apply_checkbox_actions({"Ex 5": {"result": {"feedback": feedback}}},
[{"label": "Ex 5", "type": "del_global", "index": 0}], lambda _: None)
self.assertTrue(feedback[0]["to_delete"])
self.assertNotIn("to_delete", feedback[1])
def test_bad_boxes_keep_their_comment_as_global_feedback_without_mutating_data(self):
for box in ([753, 680, 287, 946], [10, 50, 20, 30], [10, 20, 10, 30],
[1, 2, 3], [None, 0, 10, 20], [float("nan"), 0, 10, 20]):
with self.subTest(box=box):
result = {"score": 2, "feedback": [{"text": "Important comment", "box_2d": box}]}
original_box = result["feedback"][0]["box_2d"]
scaled = _scaled_result(result, GroupCoordinates(2415, 3297, 1655, 3297))
callback = Mock()
render = Mock(return_value=Image.new("RGBA", (200, 40), "white"))
with patch.object(annotating, "render_score_text", return_value=Image.new("RGBA", (200, 40))):
image, _ = annotating.compose_label_image(Image.new("RGBA", (800, 100)), "Ex 5", scaled,
2415, render_fn=render, draw_callback=callback)
self.assertIsNotNone(image)
render.assert_called_once_with("Important comment", unittest.mock.ANY)
self.assertFalse(any(call.args[0] == "local_rect" for call in callback.call_args_list))
self.assertIs(result["feedback"][0]["box_2d"], original_box)
def test_valid_boxes_remain_local(self):
result = {"feedback": [{"text": "Local", "box_2d": [10, 20, 30, 40]}]}
before = copy.deepcopy(result)
callback = Mock()
with patch.object(annotating, "render_score_text", return_value=Image.new("RGBA", (200, 40))):
annotating.compose_label_image(Image.new("RGBA", (800, 100)), "Ex 5", result, 0,
render_fn=Mock(return_value=Image.new("RGBA", (200, 40))),
draw_callback=callback)
self.assertTrue(any(call.args[0] == "local_rect" for call in callback.call_args_list))
self.assertEqual(result, before)
self.assertTrue(valid_feedback_box([10, 20, 30, 40]))
def test_invalid_auxiliary_response_loses_only_its_rectangle(self):
returned = [{"text": "Keep this", "box_2d": [753, 680, 287, 946]}]
with patch.object(correction.prompting, "request_for_box_correction", return_value=([], {})), patch.object(
correction, "call_gemini_with_retries", return_value=json.dumps(returned)
):
feedback = correction.correct_boxes_with_gemini("26", "Ex 5", Path("unused.pdf"), [], 0, 1000, 1, 1000)
self.assertEqual(feedback, [{"text": "Keep this", "box_2d": None}])
def test_correction_requests_repair_for_inverted_boxes_and_falls_back_without_losing_comments(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
group = root / "Par label" / "Ex 5" / "Group_1.jpg"
group.parent.mkdir(parents=True)
group.touch()
atomic_write_json(group.with_suffix(".json"), [["26", 0, 1000, 1, "Ex 5"]])
args = correction.build_parser().parse_args([str(root)])
correction.configure_runtime(EvaluationWorkspace(root), [(str(group), "Ex 5")], args, api_client=Mock())
response = [{"id": "26", "result": {"score": 2, "error": "", "feedback": [
{"text": "First", "box_2d": [753, 680, 287, 946]},
{"text": "Second", "box_2d": [100, 900, 200, 100]}]}}]
with patch.object(correction.prompting, "generate_request", return_value=([], {})), patch.object(
correction, "correct_boxes_with_gemini", side_effect=RuntimeError("repair failed")
) as repair:
correction.process_single_task((str(group), "Ex 5"), json.dumps(response))
repair.assert_called_once()
feedback = correction.results["Ex 5"][0][0]["result"]["feedback"]
self.assertEqual([f["text"] for f in feedback], ["First", "Second"])
self.assertTrue(all(f["box_2d"] is None for f in feedback))
+206
View File
@@ -0,0 +1,206 @@
from __future__ import annotations
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image, ImageDraw
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, read_json
from copienator.annotation_data import AnnotationLoadResult
from copienator.commands import annotating_by_label as grouped
from copienator.commands import annotating_with_checks as checks
from copienator.commands import export, import_annotations
from copienator.commands import reading_grouped_annotations as reader
class GroupedRedoTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name) / "Exam"
for directory in ("Copies", "Par label", "BGnot", "BRnot"):
(self.root / directory).mkdir(parents=True)
(self.root / "labels").write_text("Ex 1\nEx 2\n")
atomic_write_json(self.root / "correction.json", {})
atomic_write_json(
self.root / "refaire.json", [["Copie01", ["Ex 1"]], ["Copie02", ["Ex 1"]]]
)
(self.root / "BRnot/previous.txt").write_text("previous redo")
(self.root / "BGnot/main.txt").write_text("main run")
self.workspace = EvaluationWorkspace(self.root)
self.data = {"01": {"Ex 1": {}}, "02": {"Ex 1": {}}}
def tearDown(self):
self.temp.cleanup()
@staticmethod
def render(item):
student_id, label, _content = item
image = Image.new("RGB", (100, 100), "white")
ImageDraw.Draw(image).rectangle((10, 10, 50, 50), outline="black", width=2)
return (
student_id,
label,
image,
0,
[
{
"type": "score",
"label": label,
"value": 3,
"final_box": [10, 10, 50, 50],
}
],
)
def generate(self):
with (
patch.object(
grouped,
"load_annotation_data",
return_value=AnnotationLoadResult(self.data, []),
) as load,
patch.object(grouped, "render_item", side_effect=self.render),
patch.object(grouped, "_load_label_groups") as label_groups,
):
self.assertEqual(
grouped.run(self.workspace, refaire=True, overwrite=True),
ExitCode.SUCCESS,
)
self.assertEqual(
load.call_args.kwargs["refaire_list"],
read_json(self.root / "refaire.json"),
)
label_groups.assert_not_called()
directories = list((self.root / "BRnot").iterdir())
self.assertEqual(len(directories), 1)
self.assertTrue(directories[0].is_dir())
self.assertEqual((self.root / "BGnot/main.txt").read_text(), "main run")
return directories[0]
def test_grouped_redo_export_import_and_actual_annotation_detection(self):
directory = self.generate()
metadata = read_json(directory / "bnote.json")["images"]
self.assertEqual(
[(item["id"], item["label"]) for item in metadata],
[("01", "Ex 1"), ("02", "Ex 1")],
)
export_root = Path(self.temp.name) / "Export"
with patch.object(export, "EXPORT_DIR", export_root):
self.assertEqual(export.run(self.workspace, refaire=True), ExitCode.SUCCESS)
self.assertEqual(len(list((export_root / "Exam").glob("*.pdf"))), 1)
imported_root = Path(self.temp.name) / "Import"
imported_root.mkdir()
with Image.open(directory / "Reference.jpg") as reference:
annotated = reference.convert("RGB")
draw = ImageDraw.Draw(annotated)
draw.rectangle((17, 17, 43, 43), fill="black")
draw.rectangle((70, 170, 90, 190), fill="black")
annotated.save(imported_root / f"{directory.name}.pdf", "PDF", resolution=72)
with patch.object(import_annotations, "IMPORT_DIR", imported_root):
self.assertEqual(
import_annotations.run(self.workspace, refaire=True), ExitCode.SUCCESS
)
actions, notes, incomplete = reader._scan_redo_annotations(
self.root / "BRnot", {"01": {"Ex 1"}, "02": {"Ex 1"}}
)
self.assertFalse(incomplete)
self.assertEqual(actions["01"][0]["value"], 3)
self.assertFalse(actions.get("02"))
self.assertIn("Ex 1", notes["02"])
full_data = {student: {"Ex 1": {}, "Ex 2": {}} for student in ("01", "02")}
for mode in ("BGnot", "Bnot", "Anot"):
(self.root / mode).mkdir(exist_ok=True)
with (
self.subTest(mode=mode),
patch.object(
reader,
"load_annotation_data",
return_value=AnnotationLoadResult(full_data, []),
),
patch.object(
reader,
"apply_actions_and_regenerate_grouped",
return_value=(ExitCode.SUCCESS, ""),
) as regenerate,
):
self.assertEqual(
reader.run(self.workspace, refaire=True, annotation_dir=mode),
ExitCode.SUCCESS,
)
calls = {call.args[2]: call for call in regenerate.call_args_list}
self.assertEqual(set(calls), {"01", "02"})
self.assertEqual(set(calls["01"].args[1]["01"]), {"Ex 1", "Ex 2"})
self.assertEqual(calls["01"].args[3][0]["value"], 3)
self.assertIn("Ex 1", calls["02"].args[4])
def test_missing_group_leaves_affected_copy_incomplete(self):
directory = self.generate()
shutil.copy2(directory / "Concat.pdf", directory / "Concat_annotated.pdf")
extra = self.root / "BRnot/Ex 2 G1"
extra.mkdir()
atomic_write_json(
extra / "bnote.json", {"images": [{"id": "01", "label": "Ex 2"}]}
)
_actions, _notes, incomplete = reader._scan_redo_annotations(
self.root / "BRnot", {"01": {"Ex 1", "Ex 2"}, "02": {"Ex 1"}}
)
self.assertEqual(incomplete, {"01"})
def test_failed_generation_preserves_previous_redo_for_both_layouts(self):
for module, worker in ((grouped, "render_item"), (checks, "_render_student")):
with (
self.subTest(module=module),
patch.object(
module,
"load_annotation_data",
return_value=AnnotationLoadResult(self.data, []),
),
patch.object(
module,
worker,
return_value=None if module is grouped else "partial",
),
):
if module is grouped:
status = module.run(self.workspace, refaire=True, overwrite=True)
else:
status = module.run(
self.workspace, self.root, refaire=True, overwrite=True
)
self.assertEqual(status, ExitCode.PARTIAL)
self.assertEqual(
(self.root / "BRnot/previous.txt").read_text(), "previous redo"
)
def test_switching_to_per_copy_removes_previous_group_layout(self):
self.generate()
def render(_workspace, student_id, _labels, **kwargs):
output = kwargs["output_root"] / f"Copie{student_id}"
output.mkdir()
(output / "Concat.pdf").touch()
return "success"
with (
patch.object(
checks,
"load_annotation_data",
return_value=AnnotationLoadResult(self.data, []),
),
patch.object(checks, "_render_student", side_effect=render),
):
self.assertEqual(
checks.run(self.workspace, self.root, refaire=True, overwrite=True),
ExitCode.SUCCESS,
)
self.assertEqual(
{path.name for path in (self.root / "BRnot").iterdir()},
{"Copie01", "Copie02"},
)
if __name__ == "__main__":
unittest.main()
+371
View File
@@ -0,0 +1,371 @@
import os
import tempfile
import unittest
from pathlib import Path
from tkinter import ttk
from unittest.mock import patch
from copienator_gui.app import CopienatorApp
from copienator_gui.workflow import command_display
from copienator.commands.page_splitter import _selected_inputs
from copienator import EvaluationWorkspace
@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk requires a display")
class GuiConvenienceTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.repository = Path(self.temp.name)
self.evaluation = self.repository / "Évaluation avec espaces"
self.evaluation.mkdir()
self.app = CopienatorApp(self.repository, False, self.evaluation)
self.app.update()
def tearDown(self):
for callback in self.app.tk.splitlist(self.app.tk.call("after", "info")):
self.app.after_cancel(callback)
self.app.destroy()
self.temp.cleanup()
@staticmethod
def _label_texts(widget):
return [
text
for child in widget.winfo_children()
for text in (
[str(child.cget("text"))]
if isinstance(child, ttk.Label)
else GuiConvenienceTests._label_texts(child)
)
]
def test_reload_detects_added_and_removed_files_without_advancing(self):
for name in ("enonce.pdf", "enonce.tex", "correction.tex"):
(self.evaluation / name).touch()
self.app._reload_inputs()
self.assertIn("names", self.app.info_var.get())
(self.repository / "names").touch()
self.app._reload_inputs()
self.app.update()
self.assertEqual(self.app.state_store.step("inputs")["status"], "success")
self.assertEqual(self.app.current_step.id, "inputs")
(self.evaluation / "enonce.pdf").unlink()
self.app._reload_inputs()
self.assertEqual(self.app.state_store.step("inputs")["status"], "ready")
self.assertIn("enonce.pdf", self.app.missing_requirements_label.cget("text"))
style = ttk.Style(self.app)
self.assertEqual(
style.lookup("MissingPrerequisite.TLabel", "foreground"), "#c62828"
)
def test_compact_evaluation_input_and_open_folder_button(self):
self.assertEqual(int(self.app.evaluation_entry.cget("width")), 42)
self.assertEqual(
int(self.app.open_evaluation_button.grid_info()["column"]),
int(self.app.evaluation_entry.grid_info()["column"]) + 1,
)
with patch("copienator_gui.app.open_path") as opened:
self.app.open_evaluation_button.invoke()
opened.assert_called_once_with(self.evaluation)
def test_manual_resolution_panel_follows_command_target(self):
manual = self.evaluation / "manual_resolutions.txt"
manual.write_text("Copie01 A -> B|\n", encoding="utf-8")
self.app.tree.selection_set("manual_resolution")
self.app.update()
self.assertEqual(len(self.app.manual_panel.pdf_buttons), 2)
other = self.repository / "Other"
other.mkdir()
other_manual = other / "manual_resolutions.txt"
other_manual.write_text("Copie02 C xs D\n", encoding="utf-8")
self.app.arg_vars["target"].set(str(other))
self.app.update()
self.assertIn("Copie02", self.app.manual_panel.text.get("1.0", "end"))
with patch("copienator_gui.manual_resolution.open_path") as opened:
self.app.manual_panel.editor_button.invoke()
opened.assert_called_once_with(other_manual)
def test_successful_manual_resolution_returns_to_refaire_correction(self):
manual = self.evaluation / "manual_resolutions.txt"
manual.write_text("Copie01 A -> B|\n", encoding="utf-8")
self.app.state_store.update_step("manual_resolution", visited=True)
self.app.tree.selection_set("manual_resolution")
self.app.update()
self.app.active_step_id = "manual_resolution"
self.app._finish_process(0, False)
self.app.update()
self.assertEqual(self.app.current_step.id, "correction")
self.assertEqual(self.app.variant_var.get(), "refaire")
self.assertEqual(
self.app.state_store.step("correction")["variant"], "refaire"
)
self.assertNotIn("overwrite", self.app.arg_vars)
self.assertIn("--refaire", self.app._make_command())
def test_giving_names_waits_for_explicit_manual_completion(self):
return_dir = self.evaluation / "A Rendre" / "Student (01)"
return_dir.mkdir(parents=True)
(return_dir / "Student.jpg").write_bytes(b"jpg")
(return_dir / "Student.pdf").write_bytes(b"pdf")
self.app.tree.selection_set("giving_names")
self.app.update()
self.app.active_step_id = "giving_names"
self.app._finish_process(0, False)
self.app.update()
self.assertEqual(self.app.current_step.id, "giving_names")
self.assertEqual(
self.app.state_store.step("giving_names")["status"], "detected"
)
self.assertEqual(
self.app.complete_giving_names_button.cget("text"),
"Marquer terminée",
)
self.app.complete_giving_names_button.invoke()
self.app.update()
self.assertEqual(
self.app.state_store.step("giving_names")["status"], "success"
)
self.assertNotEqual(self.app.current_step.id, "giving_names")
def test_batch_status_waits_until_all_jobs_are_ready(self):
self.app.state_store.update_step("correction", variant="batch")
self.app.state_store.update_step("batch_status", visited=True)
self.app.tree.selection_set("batch_status")
self.app.update()
command = self.app._make_command()
self.assertIn("--evaluation", command)
self.assertFalse(self.app.arg_vars)
self.app.active_step_id = "batch_status"
self.app._finish_process(4, False)
self.app.update()
self.assertEqual(self.app.current_step.id, "batch_status")
self.app.active_step_id = "batch_status"
self.app._finish_process(0, False)
self.app.update()
self.assertEqual(self.app.current_step.id, "fetch_batches")
def test_batch_watch_buttons_use_loaded_evaluation_and_stop_on_reload(self):
self.app.state_store.update_step("correction", variant="batch")
self.app.tree.selection_set("batch_status")
self.app.update()
with patch.object(self.app.batch_monitor, "start") as start:
self.app.watch_batches_button.invoke()
self.assertEqual(start.call_args.args[0][-2:], ["--evaluation", str(self.evaluation)])
self.app.batch_monitor.active = True
self.app._update_controls()
self.assertEqual(str(self.app.stop_watch_batches_button.cget("state")), "normal")
self.app.stop_watch_batches_button.invoke()
self.assertFalse(self.app.batch_monitor.active)
self.app.batch_monitor.active = True
self.app._load_evaluation()
self.assertFalse(self.app.batch_monitor.active)
def test_background_batch_readiness_notifies_without_changing_other_step(self):
self.app.tree.selection_set("inputs")
self.app.update()
with patch("copienator_gui.app.notify_desktop") as notify:
self.app._batch_results_ready()
notify.assert_called_once()
self.assertIn(self.evaluation.name, notify.call_args.args[1])
self.assertEqual(self.app.current_step.id, "inputs")
self.assertEqual(self.app.state_store.step("batch_status")["status"], "success")
def test_optional_blank_crop_can_target_a_copy_or_be_skipped(self):
copies = self.evaluation/"Copies"
copies.mkdir()
source = copies/"Copie01.pdf"
source.touch()
self.app.state_store.update_step("crop_blank_margins", visited=True)
self.app.tree.selection_set("crop_blank_margins")
self.app.update()
self.assertEqual(self.app.current_step.id, "crop_blank_margins")
self.assertEqual(str(self.app.skip_button.cget("state")), "normal")
self.app.copy_var.set(source.name)
self.app._target_selected_copy()
command = self.app._make_command()
self.assertEqual(command[-4:], ["crop-margins", str(source), "--workers", "5"])
self.app._skip_step()
self.assertEqual(self.app.state_store.step("crop_blank_margins")["status"], "skipped")
def test_optional_answer_bottom_crop_uses_parallel_workers_and_can_be_skipped(self):
answers = self.evaluation / "Copies" / "Copie01"
answers.mkdir(parents=True)
(answers / "Ex 1.pdf").touch()
self.app.state_store.update_step("crop_exercise_bottoms", visited=True)
self.app.tree.selection_set("crop_exercise_bottoms")
self.app.update()
self.assertEqual(self.app.current_step.id, "crop_exercise_bottoms")
self.assertEqual(str(self.app.skip_button.cget("state")), "normal")
command = self.app._make_command()
self.assertEqual(
command[-4:],
["crop-answer-bottoms", self.app._evaluation_arg(), "--workers", "5"],
)
self.app._skip_step()
self.assertEqual(
self.app.state_store.step("crop_exercise_bottoms")["status"], "skipped"
)
def test_redo_targets_selected_copy_and_copies_runnable_command(self):
for folder in ("Copies", "Copies Originales"):
(self.evaluation / folder).mkdir()
for name in ("Copie01.pdf", "Copie02.pdf"):
(self.evaluation / folder / name).touch()
self.app.tree.selection_set("page_splitter")
self.app.update()
self.app.copy_var.set("Copie02.pdf")
self.app._target_selected_copy()
command = self.app._make_command()
target = Path(command[-1])
self.assertEqual(target, self.evaluation / "Copies" / "Copie02.pdf")
self.assertEqual(_selected_inputs(EvaluationWorkspace(self.evaluation), target),
[self.evaluation / "Copies Originales" / "Copie02.pdf"])
self.app._copy_command()
self.assertEqual(self.app.clipboard_get(), command_display(command))
self.app._target_all_pages()
self.assertEqual(self.app.arg_vars["target"].get(), self.app._evaluation_arg())
def test_label_detection_can_target_one_copy(self):
copies = self.evaluation / "Copies"
copies.mkdir()
for name in ("Copie01.pdf", "Copie02.pdf"):
(copies / name).touch()
self.app.tree.selection_set("labels")
self.app.update()
self.assertEqual(self.app.copy_var.get(), "Copie01.pdf")
self.app.copy_var.set("Copie02.pdf")
self.app._target_selected_copy()
command = self.app._make_command()
self.assertEqual(Path(command[-1]), copies / "Copie02.pdf")
def button_texts(widget):
return [
text
for child in widget.winfo_children()
for text in (
[child.cget("text")]
if isinstance(child, ttk.Button)
else button_texts(child)
)
]
controls = button_texts(self.app.form)
self.assertIn("Cibler la copie sélectionnée", controls)
self.assertIn("Cibler tout le dossier", controls)
def test_label_review_can_show_and_target_one_copy(self):
copies = self.evaluation / "Copies"
copies.mkdir()
for name in ("Copie01.pdf", "Copie02.pdf"):
(copies / name).touch()
self.app.tree.selection_set("plotting")
self.app.update()
self.app.copy_var.set("Copie02.pdf")
with patch("copienator_gui.app.open_path") as opened:
self.app._open_selected_copy()
opened.assert_called_once_with(copies / "Copie02.pdf")
self.app._target_selected_copy()
command = self.app._make_command()
self.assertEqual(Path(command[-1]), copies / "Copie02.pdf")
labels = self._label_texts(self.app.form)
self.assertTrue(
any("Seule la copie ciblée sera vérifiée" in text for text in labels)
)
def test_free_form_arguments_are_shown_only_when_documented(self):
self.app.tree.selection_set("labels")
self.app.update()
labels_text = self._label_texts(self.app.form)
extra_label = next(
child
for child in self.app.form.winfo_children()
if isinstance(child, ttk.Label)
and str(child.cget("text")).startswith("Arguments supplémentaires")
)
tooltip = extra_label._copienator_tooltip
self.assertIn("Cutleft", tooltip.text)
self.assertFalse(any("Cutleft" in text for text in labels_text))
tooltip.show()
self.app.update()
self.assertIsNotNone(tooltip.window)
self.assertIn("Cutleft", tooltip.window.winfo_children()[0].cget("text"))
tooltip.hide()
self.app.tree.selection_set("plotting")
self.app.update()
self.assertFalse(
any(
text.startswith("Arguments supplémentaires")
for text in self._label_texts(self.app.form)
)
)
def test_each_visible_argument_label_has_a_tooltip(self):
self.app.tree.selection_set("correction")
self.app.update()
argument_labels = [
child
for child in self.app.form.winfo_children()
if isinstance(child, ttk.Label) and str(child.cget("text")).endswith("")
]
self.assertGreaterEqual(len(argument_labels), 4)
for label in argument_labels:
with self.subTest(label=label.cget("text")):
tooltip = getattr(label, "_copienator_tooltip", None)
self.assertIsNotNone(tooltip)
self.assertTrue(tooltip.text.strip())
def test_verbose_checkbox_updates_supported_commands(self):
self.app.tree.selection_set("labels")
self.app.update()
self.assertNotIn("--verbose", self.app._make_command())
self.app.verbose_var.set(True)
self.assertIn("--verbose", self.app._make_command())
def test_console_selection_survives_output_and_is_read_only(self):
self.app._append_console("Première ligne\nDeuxième ligne\n")
self.app.console.tag_add("sel", "1.0", "1.end")
self.app._append_console("Suite\n")
self.app._copy_console_selection()
self.assertEqual(self.app.clipboard_get(), "Première ligne")
self.app._copy_console_all()
expected = "Première ligne\nDeuxième ligne\nSuite\n"
self.assertEqual(self.app.clipboard_get(), expected)
self.app.console.insert("end", "unwanted edit")
self.assertEqual(self.app.console.get("1.0", "end-1c"), expected)
self.app._select_console_all()
self.app._copy_console_selection()
self.assertEqual(self.app.clipboard_get(), expected)
def test_validate_rename_preserves_files_and_downstream_status(self):
pdf = self.evaluation / "Copie01.pdf"
pdf.write_bytes(b"unchanged PDF")
self.app.state_store.update_step("rename", status="stale")
self.app.state_store.update_step("page_splitter", status="success")
self.app.tree.selection_set("rename")
self.app.update()
with patch.object(self.app.runner, "start") as start:
self.app.validate_rename_button.invoke()
start.assert_not_called()
self.assertEqual(self.app.state_store.step("rename")["status"], "success")
self.assertEqual(self.app.state_store.step("page_splitter")["status"], "success")
self.assertEqual(pdf.read_bytes(), b"unchanged PDF")
self.assertEqual(list(self.evaluation.glob("*.pdf")), [pdf])
self.app.state_store.update_step("rename", status="stale")
self.app.active_step_id = "page_splitter"
self.app._update_controls()
self.assertIn("disabled", self.app.validate_rename_button.state())
self.app._validate_rename_step()
self.assertEqual(self.app.state_store.step("rename")["status"], "stale")
self.app.active_step_id = None

Some files were not shown because too many files have changed in this diff Show More