Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a453403cf | ||
|
|
5b8215e7f5 | ||
|
|
9b22a8a137 | ||
|
|
0a86403ca6 | ||
|
|
5080274e8f | ||
|
|
d60d5479d6 | ||
|
|
e8b8a11c5b | ||
|
|
060859ddef | ||
|
|
dfce79f240 | ||
|
|
f7b3689f23 | ||
|
|
0a167072ed | ||
|
|
3969580e01 | ||
|
|
359c62004c | ||
|
|
13a08f6cbd | ||
|
|
bf05272797 | ||
|
|
db4ed2ef31 | ||
|
|
06d7bad04e | ||
|
|
2313d51a62 | ||
|
|
2ff1a9b7b9 | ||
|
|
a9897606ac | ||
|
|
15d1319374 | ||
|
|
576e3c9452 | ||
|
|
2921d5ec8e | ||
|
|
b9e9d00e7d | ||
|
|
dc25b5fefa | ||
|
|
5a7ddd407f | ||
|
|
92a9f9883e | ||
|
|
9af05f2c86 | ||
|
|
ce9f385a0a | ||
|
|
81dc658640 | ||
|
|
3a8d0fe3ff | ||
|
|
b19d3b0db6 | ||
|
|
18d1e5e2bb | ||
|
|
aa40e58dd1 | ||
|
|
644e287586 | ||
|
|
bcba5facc8 | ||
|
|
8b087bb3e4 | ||
|
|
63f690b353 | ||
|
|
0a95afacdd | ||
|
|
352d36e38c | ||
|
|
2f1cd00e32 | ||
|
|
ac4ab782b2 | ||
|
|
7c366a9ca4 | ||
|
|
f7067d8585 | ||
|
|
c535febbc4 | ||
|
|
f345f8bb4f | ||
|
|
137a9a7868 | ||
|
|
9d7a4f37b6 | ||
|
|
09befe1d55 | ||
|
|
fd35675e4c | ||
|
|
121bd0714a | ||
|
|
27c0dae20e | ||
|
|
a80187ba80 | ||
|
|
80d06e4693 | ||
|
|
9e8b9ac191 | ||
|
|
c2e915226e |
@@ -0,0 +1,13 @@
|
|||||||
|
OLD/
|
||||||
|
/Interro*/
|
||||||
|
/DS*/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
config.py
|
||||||
|
.copienator-gui.json
|
||||||
|
.copienator/
|
||||||
|
tmp/
|
||||||
@@ -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.
|
||||||
|
Before Width: | Height: | Size: 4.9 MiB |
|
Before Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 526 KiB |
|
Before Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 404 KiB |
|
Before Width: | Height: | Size: 574 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 341 KiB |
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"Ex 1": "4.0",
|
|
||||||
"Ex 2 : 1)": "2.5",
|
|
||||||
"Ex 2 : 2)": "1.5",
|
|
||||||
"Ex 3 : 1)": "3.5",
|
|
||||||
"Ex 3 : 2)": "",
|
|
||||||
"Ex 4": "2.0",
|
|
||||||
"Ex 5 : 1)": "2.5",
|
|
||||||
"Ex 5 : 2)": "1.0",
|
|
||||||
"Ex 6 : 1)": "1.5",
|
|
||||||
"Ex 6 : 2)": "1.5",
|
|
||||||
"Ex 6 : 3)": "2.5",
|
|
||||||
"Ex 7 : 1)": "0.0",
|
|
||||||
"Ex 7 : 2)": "0.0",
|
|
||||||
"Ex 7 : 3)": "0.0",
|
|
||||||
"Ex 8 : 1)": "3.0",
|
|
||||||
"Ex 8 : 2)": "",
|
|
||||||
"Ex 9 : 1)": "2.0",
|
|
||||||
"Ex 9 : 2)": "",
|
|
||||||
"Ex 10 : 1)": "0",
|
|
||||||
"Ex 10 : 2)": "",
|
|
||||||
"Ex 11": ""
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 7.8 MiB |
@@ -1,132 +0,0 @@
|
|||||||
{
|
|
||||||
"width": 1955,
|
|
||||||
"height": 22256,
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"id": "01",
|
|
||||||
"label": "Ex 2 : 1)",
|
|
||||||
"header_height": 80,
|
|
||||||
"hmin": 0,
|
|
||||||
"hmax": 1542
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "01",
|
|
||||||
"label": "Ex 2 : 2)",
|
|
||||||
"header_height": 80,
|
|
||||||
"hmin": 1542,
|
|
||||||
"hmax": 3191
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "02",
|
|
||||||
"label": "Ex 2 : 1)",
|
|
||||||
"header_height": 80,
|
|
||||||
"hmin": 3191,
|
|
||||||
"hmax": 3889
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "02",
|
|
||||||
"label": "Ex 2 : 2)",
|
|
||||||
"header_height": 119,
|
|
||||||
"hmin": 3889,
|
|
||||||
"hmax": 6096
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "03",
|
|
||||||
"label": "Ex 2 : 1)",
|
|
||||||
"header_height": 80,
|
|
||||||
"hmin": 6096,
|
|
||||||
"hmax": 7435
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "03",
|
|
||||||
"label": "Ex 2 : 2)",
|
|
||||||
"header_height": 80,
|
|
||||||
"hmin": 7435,
|
|
||||||
"hmax": 9335
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "04",
|
|
||||||
"label": "Ex 2 : 1)",
|
|
||||||
"header_height": 80,
|
|
||||||
"hmin": 9335,
|
|
||||||
"hmax": 10232
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "04",
|
|
||||||
"label": "Ex 2 : 2)",
|
|
||||||
"header_height": 80,
|
|
||||||
"hmin": 10232,
|
|
||||||
"hmax": 11406
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "05",
|
|
||||||
"label": "Ex 2 : 1)",
|
|
||||||
"header_height": 80,
|
|
||||||
"hmin": 11406,
|
|
||||||
"hmax": 12022
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "05",
|
|
||||||
"label": "Ex 2 : 2)",
|
|
||||||
"header_height": 150,
|
|
||||||
"hmin": 12022,
|
|
||||||
"hmax": 14145
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "06",
|
|
||||||
"label": "Ex 2 : 1)",
|
|
||||||
"header_height": 80,
|
|
||||||
"hmin": 14145,
|
|
||||||
"hmax": 14616
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "06",
|
|
||||||
"label": "Ex 2 : 2)",
|
|
||||||
"header_height": 122,
|
|
||||||
"hmin": 14616,
|
|
||||||
"hmax": 15251
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "07",
|
|
||||||
"label": "Ex 2 : 1)",
|
|
||||||
"header_height": 80,
|
|
||||||
"hmin": 15251,
|
|
||||||
"hmax": 15823
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "07",
|
|
||||||
"label": "Ex 2 : 2)",
|
|
||||||
"header_height": 122,
|
|
||||||
"hmin": 15823,
|
|
||||||
"hmax": 18154
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "08",
|
|
||||||
"label": "Ex 2 : 1)",
|
|
||||||
"header_height": 80,
|
|
||||||
"hmin": 18154,
|
|
||||||
"hmax": 18922
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "08",
|
|
||||||
"label": "Ex 2 : 2)",
|
|
||||||
"header_height": 80,
|
|
||||||
"hmin": 18922,
|
|
||||||
"hmax": 20133
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "09",
|
|
||||||
"label": "Ex 2 : 1)",
|
|
||||||
"header_height": 80,
|
|
||||||
"hmin": 20133,
|
|
||||||
"hmax": 20887
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "09",
|
|
||||||
"label": "Ex 2 : 2)",
|
|
||||||
"header_height": 119,
|
|
||||||
"hmin": 20887,
|
|
||||||
"hmax": 22256
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
$$\tr (AB) = \sum_{i=1}^n (AB)_{ii} = \sum_{i=1}^n \sum_{k=1}^n a_{ik} b_{ki} = \sum_{k=1}^n \sum_{i=1}^n b_{ki} a_{ik} = \tr (BA).$$
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
1) Puisque $G$ est un groupe, l'application $\varphi_k : A \mapsto A_k A$ est une bijection de $G$ dans lui-même (translation à gauche). On en déduit :
|
|
||||||
$A_k \sum_{i=1}^p A_i = \sum_{i=1}^p A_k A_i = \sum_{j=1}^p A_j = M$.
|
|
||||||
|
|
||||||
En sommant la relation précédente pour $k$ allant de $1$ à $p$, on obtient :
|
|
||||||
$$M^2 = (\sum_{k=1}^p A_k) M = \sum_{k=1}^p (A_k M) = \sum_{k=1}^p M = pM$$
|
|
||||||
Soit $u = \frac{1}{p} f$. On a alors $u^2 = \frac{1}{p^2} f^2 = \frac{1}{p^2} (pf) = \frac{1}{p} f = u$. L'endomorphisme $u$ est donc un projecteur et $f = pu$ (soit $\lambda = p$).
|
|
||||||
2) Dans une base adaptée à la décomposition $\R^n = \op{Im } u \oplus \op{Ker } u$, la matrice de $u$ est une matrice diagonale avec $\op{rg } u$ fois la valeur $1$ et le reste de zéros. Ainsi, $\op{tr } u = \op{rg } u$ et $\sum_{i=1}^p \op{tr}(A_i) = \op{tr}(M) = \op{tr}(p u) = p \op{tr}(u) = p \op{rg}(u)$, qui est bien un multiple de $p$.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
La condition est $\dim \Ker u \geq 2 + \dim \Ker u \cap \Im u$.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
1) On évalue $f$ sur les vecteurs de la base canonique $(1, X, X^2)$ :
|
|
||||||
|
|
||||||
+ $f(1) = 1 + 1 - 2 = 0$
|
|
||||||
+ $f(X) = (X+2) + X - 2(X+1) = 0$
|
|
||||||
+ $f(X^2) = (X+2)^2 + X^2 - 2(X+1)^2 = 2X^2+4X+4 - 2X^2-4X-2 = 2$
|
|
||||||
La matrice de $f$ dans la base canonique est donc :
|
|
||||||
$$A = \begin{pmatrix} 0 & 0 & 2 \\ 0 & 0 & 0 \\ 0 & 0 & 0 \end{pmatrix}$$
|
|
||||||
2) On cherche une base $(P_1, P_2, P_3)$ telle que $f(P_1)=0$, $f(P_2)=P_3$ et $f(P_3)=0$.
|
|
||||||
D'après les calculs précédents, on a $f(X^2) = 2$. On peut donc choisir $P_2 = X^2$, ce qui impose $P_3 = 2$.
|
|
||||||
On a alors bien $f(P_3) = f(2) = 0$. Il suffit de compléter avec un vecteur $P_1 \in \ker(f)$ indépendant de $P_3$, par exemple $P_1 = X$.
|
|
||||||
La famille $(X, X^2, 2)$ est une famille de polynômes de degrés échelonnés, c'est donc une base de $\R_2[X]$ dans laquelle la matrice de $f$ a la forme voulue.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
1) $A$ est $B$ sont équivalentes s'il existe $P,Q\in GL_n(\R)$ tels que $A = PBQ$. Par ailleurs, on sait que deux matrices (de même tailles) sont équivalentes si et seulement si elles ont le même rang.
|
|
||||||
2) En notant $r = \rg A$, on sait que $A$ est équivalente à $J_r$, donc qu'il existe $P,Q\in GL_n(\R)$ telles que $A = PJ_r Q$.
|
|
||||||
|
|
||||||
Alors $UAUA = U PJ_r Q U P J_r Q$, et il suffit de prendre $U = Q^{-1} P^{-1}$ (et d'utiliser le fait que $J_rJ_r = J_r$) pour avoir $UAUA = UA$.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
Soit $(e_1,\dots, e_p)$ une base de $V$, que l'on complète en une base $(e_1,\dots, e_n)$ de $E$. Un endomorphisme $u\in\mc L(E)$ vérifie $V\subset \Ker u$ si et seulement si $e_1,\dots, e_p\in \Ker u$, donc si et seulement si la matrice de $u$ dans la base $\mc B$ a ses $p$-premières colonnes nulles.
|
|
||||||
|
|
||||||
L'ensemble $\mc M$ des matrices qui vérifient cette condition est de dimension $(n-p) n$.
|
|
||||||
|
|
||||||
Comme l'application $\Phi$ qui à $u$ associe $\op{Mat}_{\mc B}(u)$ est un isomorphisme, l'ensemble $\mc V = \Phi^{-1}(\mc M)$ est de dimension $(n-p)n$.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
1) Les colonnes de $X Y^T$ sont les $y_i X$, donc sont toutes colinéaires à $X$, donc $\rg X Y^T\leq 1$ et $\Im (XY^T)\subset \vect X$. Le rang est nul si et seulement si $X = \vec 0$ ou $Y = \vec 0$, auquel cas l'image est $\{\vec 0\}$.
|
|
||||||
2) Si $M$ est de rang $1$ et $m\colon X\mapsto MX$, $\dim \Ker M = n-1$, donc en complétant n'importe quelle base $(e_2,\dots, e_n)$ de $\Ker M$ en une base de l'espace $\mc B = (e_1,\dots, e_n)$, on aura $\op{Mat}_{\mc B}(m)$ qui sera de la forme voulue. Comme $M = \op{Mat}_{\mc B_{can}}(m)$, $M$ est semblable à la matrice voulue.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
1) Notons $C_1, C_2, C_3, C_4$ les colonnes de $A$. On remarque que $C_2 = -C_1$. De plus, la famille $(C_1, C_3, C_4)$ est libre car :
|
|
||||||
$$\alpha C_1 + \beta C_3 + \gamma C_4 = 0 \iff \begin{cases} \alpha + 2\beta - 2\gamma = 0 \\ \beta - \gamma = 0 \\ \alpha + \beta = 0 \\ \alpha + \beta = 0 \end{cases} \iff \beta = \gamma, \alpha = -\beta, -\beta + 2\beta - 2\beta = 0 \iff \alpha=\beta=\gamma=0$$
|
|
||||||
On en déduit :
|
|
||||||
|
|
||||||
+ $\op{rg}(f) = 3$ ;
|
|
||||||
+ $\op{Im } f = \op{Vect}((1, 0, 1, 1), (2, 1, 1, 1), (-2, -1, 0, 0))$ ;
|
|
||||||
+ Par le théorème du rang, $\dim \ker f = 1$. Comme $C_1+C_2=0$, $\ker f = \op{Vect}((1, 1, 0, 0))$.
|
|
||||||
|
|
||||||
2) On cherche $e'_1, e'_2$ dans $\ker(f^2)$ et $e'_3, e'_4$ dans $\ker(f-\op{id})^2$ tels que $f(e'_1)=0, f(e'_2)=e'_1, f(e'_3)=e'_3, f(e'_4)=e'_3+e'_4$.
|
|
||||||
|
|
||||||
On peut vérifier que $A e_2 = e_1$, donc on va prendre $e_1' = e_1$ et $e_2' = e_2$.
|
|
||||||
|
|
||||||
On peut aussi vérifier que $A e_3 = e_3 + e_4$, donc $e_3' = e_4$ et $e_4' = e_3$ conviennent.
|
|
||||||
|
|
||||||
La famille $(e_1', e_2', e_3', e_4')$ est bien une base.
|
|
||||||
3) La matrice de passage est $P = \begin{pmatrix} 1 & 0 & 0 & 1 \\ 1 & 1 & 0 & 0 \\ 0 & 1 & 1 & 0 \\ 0 & 0 & 1 & 0 \end{pmatrix}$. Par inversion du système $Y=PX$, on trouve $P^{-1} = \begin{pmatrix} 0 & 1 & -1 & 1 \\ 0 & 0 & 1 & -1 \\ 0 & 0 & 0 & 1 \\ 1 & -1 & 1 & -1 \end{pmatrix}$.
|
|
||||||
|
|
||||||
Pour $n \ge 2$, $T^n = \begin{pmatrix} 0 & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 \\ 0 & 0 & 1 & n \\ 0 & 0 & 0 & 1 \end{pmatrix}$. On en déduit $A^n = P T^n P^{-1}$.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
1) On a $\Im (AB) \subset \Im A$, d'où $\rg AB \leq \rg A$. De plus, $\Im (AB) = A(\Im B)$, donc $\dim \Im (AB) \leq \dim \Im B$, ce qui donne $\rg AB \leq \rg B$.
|
|
||||||
2) On a $\Im (A+B) \subset \Im A + \Im B$, et $\dim (\Im A + \Im B) \leq \dim \Im A + \dim \Im B$, d'où $\rg (A+B) \leq \dim (\Im A) + \dim (\Im B) = \rg A + \rg B$.
|
|
||||||
|
|
||||||
En écrivant $A = (A+B) - B$, la première inégalité donne $\rg A \leq \rg (A+B) + \rg (-B) = \rg (A+B) + \rg B$, soit $\rg (A+B) \geq \rg A - \rg B$. Par symétrie des rôles de $A$ et $B$, on a également $\rg (A+B) \geq \rg B - \rg A$, d'où l'inégalité en valeur absolue.
|
|
||||||
3) On a $A(B-I_n) = B$, donc $\rg B\leq \rg A$. De même, $(A-I_n)B = AB - B = A$, ce qui implique par la question 1 que $\rg A \leq \rg B$. Finalement, $\rg A = \rg B$.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
1) On a
|
|
||||||
$$V_n = \vvvv{v_0}{v_1}{\vdots}{v_n} = \vvvv{u_0}{u_0 + u_1}{\vdots}{\sum_{k=0}^n u_k {n\choose k}} = M U_n = \begin{pmatrix}1 & & & \\ 1 & 1 & & \\ \vdots & \ddots & \ddots & \\ 1 & \dots & \dots & 1 \end{pmatrix} \vvvv{u_0}{u_1}{\vdots}{u_n},$$
|
|
||||||
où $M\in\M_{n+1}(\R)$ est la matrice $M= \big({i+1\choose j+1}\big)_{i,j\leq n+1}$, triangulaire inférieure.
|
|
||||||
2) On sait que la matrice $M$ est inversible (triangulaire inférieure, avec une diagonale de $1$).
|
|
||||||
|
|
||||||
On sait par ailleurs que la transposée de $M$ est la matrice de l'application $u\in \mc L(\R_n(X))\colon P\mapsto P(X+1)$, donc que $M^{-T}$ est la matrice de $u^{-1}\colon P\mapsto P(X-1)$, c'est-à-dire $M^{-T} = \big({j+1\choose i+1} (-1)^{i+j}\big)_{i,j\leq n+1}$, et $M^{-1} = \big({i+1\choose j+1} (-1)^{i+j}\big)_{i,j\leq n+1}$.
|
|
||||||
|
|
||||||
On a alors $U_n = M^{-1} V_n$, c'est-à-dire $u_n = \sum_{k=0}^{n} {n \choose k} (-1)^{n+k} v_k = \sum_{k=0}^{n} {n \choose k} (-1)^{n-k}v_k$.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
1) Avec la représentation du cours d'une file par une liste [3, *,
|
|
||||||
*, A, B, C] où l[0] est l'indice du premier élément de la file,
|
|
||||||
les opérations enfile et défile ont des complexités en $O(1)$. La
|
|
||||||
fonction peek consiste à renvoyer l[l[0]] si l[0] > 0, et None sinon.
|
|
||||||
2)
|
|
||||||
#+begin_src python
|
|
||||||
def hamming(n):
|
|
||||||
f2, f3, f5 = nouvelle_file(), nouvelle_file(), nouvelle_file()
|
|
||||||
l = [1]
|
|
||||||
enfile(f2, 2);enfile(f3, 3);enfile(f5, 5)
|
|
||||||
while True:
|
|
||||||
k2, k3, k5 = peek(f2), peek(f3), peek(f5)
|
|
||||||
m = min(k2, k3, k5)
|
|
||||||
if k2 == m: defile(f2)
|
|
||||||
if k3 == m: defile(f3)
|
|
||||||
if k5 == m: defile(f5)
|
|
||||||
enfile(f2, 2*m); enfile(f3, 3*m); enfile(f5, 5*m)
|
|
||||||
if m<n:
|
|
||||||
l)append(m)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
return l
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Soient $A\in\M_{n,p}(\K)$, $B\in\M_{p,n}(\K)$. Montrer que $\tr(AB) = \tr (BA)$.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
Soit $G$ un sous-groupe fini de $(GL_n(\R), \times)$, on note $G = \{A_1, \dots, A_p\}$.
|
|
||||||
L'objectif est de montrer que $\sum_{i=1}^p \text{tr}(A_i)$ est un multiple entier de $p$.
|
|
||||||
1) Soit $M = \sum_{i=1}^p A_i$, montrer que $M^2 = pM$.
|
|
||||||
On note $f$ l'endomorphisme de $\R^n$ canoniquement associé à $M$.
|
|
||||||
Montrer que l'on peut écrire $f = \lambda u$, où $u$ est un projecteur et $\lambda$ une constante à déterminer.
|
|
||||||
2) Conclure.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
[X 2022]
|
|
||||||
Soit $n\geq 3$. caractériser les endomorphismes de $\K^n$ pour lesquels il existe une base dans laquelle $u$ est représenté par une matrice de la forme $\begin{pmatrix}0 & 0 & 0 \\ 0 & M & 0 \\ 0 & 0 & 0 \end{pmatrix}$, où $M\in\M_{n-2}(\K)$.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
Soit $f\in\mc L(\R_2[X])$ définie par $f(P) = P(X+2) + P(X) - 2P(X+1)$.
|
|
||||||
1) Donner la matrice de $f$ dans la base canonique de $\R_2[X]$.
|
|
||||||
2) Déterminer une base de $\R_2[X]$ dans laquelle la matrice de $f$ est $\begin{pmatrix}0 & 0 & 0 \\ 0 & 0 & 0 \\ 0 & 1 & 0 \end{pmatrix}$.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
Soient $A,B\in\M_n(\R)$.
|
|
||||||
1) À quelle condition $A$ et $B$ sont-elles équivalentes ? Rappeler la définition, et une caractérisation simple.
|
|
||||||
2) Montrer qu'il existe $U\in GL_n(\R)$ tel que $UA$ soit une matrice de projection.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Soit $E$ un espace de dimension $n$ et $V$ un sous-espace vectoriel de $E$ de dimension $p$. On note $\mc V = \{u\in \mc L(E)\mid V\subset \Ker u\}$. Déterminer la dimension de $\mc V$.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
1) Pour $X,Y\in\K^n$, dessiner la matrice $M = X Y^T$, préciser son rang et son image.
|
|
||||||
2) Montrer que toute matrice $M$ de rang $1$ est semblable à une matrice de la forme $\scalemath{0.5}{\begin{pmatrix}* & 0 & \dots & 0 \\ \vdots & \vdots & \dots & \vdots \\ \vdots & \vdots & \dots & \vdots \\ * & 0 & \dots & 0 \end{pmatrix}}$.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
Soit $A = \scalemath{0.6}{\begin{pmatrix} 1 & -1 & 2 & -2 \\ 0 & 0 & 1 & -1 \\ 1 & -1 & 1 & 0 \\ 1 & -1 & 1 & 0 \end{pmatrix}}$ et $f$ l'endomorphisme de $\R^4$ canoniquement associé à la matrice $A$.
|
|
||||||
1) Déterminer $\op{rg}(f)$, $\ker f$ et $\op{Im } f$.
|
|
||||||
|
|
||||||
On admet que les espaces $\ker(f^2)$ et $\ker(f - \op{id})^2$ sont supplémentaires dans $\R^4$ et que $\big(e_1 = (1, 1, 0, 0), e_2 = (0, 1, 1, 0)\big)$ et $\big(e_3 = (1, 0, 0, 0), e_4 = (0, 0, 1, 1)\big)$ forment des bases de ces deux espaces.
|
|
||||||
|
|
||||||
2) Montrer qu'il existe une base $\mathcal{B}' = (e'_1, e'_2, e'_3, e'_4)$ de $\R^4$ dans laquelle la matrice de $f$ est égale à $T = \scalemath{0.6}{\begin{pmatrix} 0 & 1 & 0 & 0 \\ 0 & 0 & 0 & 0 \\ 0 & 0 & 1 & 1 \\ 0 & 0 & 0 & 1 \end{pmatrix}}$.
|
|
||||||
3) Calculer $T^n$ pour tout $n$ de $\N^*$. Expliquer comment en déduire $A^n$.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
Soient $A,B\in\M_n(\K)$.
|
|
||||||
1) Montrer que $\rg AB\leq \min (\rg A, \rg B)$.
|
|
||||||
2) Montrer que $\rg (A+B)\leq \rg A + \rg B$, puis en déduire que $\rg (A+B)\geq |\rg A - \rg B|$.
|
|
||||||
3) Montrer que si $AB = A+B$ alors $\rg A= \rg B$.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
On considère une suite réelle $(u_n)_{n\in\N}$, et la suite $(v_n)$ définie par $\displaystyle\forall n\in\N,\, v_n = \sum_{k=0}^n {n\choose k} u_k$.
|
|
||||||
1) On note $V_n = (v_0,\dots, v_n)$ et $U_n = (u_0,\dots,u_n)$, interprétés comme des vecteurs colonnes. Expliciter une matrice $M$ tel que $V_n = M U_n$.
|
|
||||||
2) Déterminer une expression explicite de $u_n$ en fonction des $v_k$, pour $0\leq k\leq n$.
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
1) Proposer une représentation naïve d'une file. Pour cette
|
|
||||||
représentation, donner les complexité des opérations \texttt{enfile} et
|
|
||||||
\texttt{defile}. Implémenter une fonction \texttt{peek} qui prend en argument la
|
|
||||||
file et renvoie le premier élément de la file, ou \texttt{None} si elle
|
|
||||||
est vide, sans le retirer de la file.
|
|
||||||
2) Les nombres de Hamming sont les entiers de la forme $2^a 3^b 5^c$.
|
|
||||||
On veut écrire une fonction \texttt{hamming} qui prend un entier $n$ en
|
|
||||||
argument et renvoie la liste des entiers de Hamming $\leq n$, dans
|
|
||||||
un ordre croissant, avec une complexité linéaire en la longueur de la liste renvoyée.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
Ex 1
|
|
||||||
Ex 2 : 1),Ex 2 : 2)
|
|
||||||
Ex 3 : 1),Ex 3 : 2)
|
|
||||||
Ex 4
|
|
||||||
Ex 5 : 1),Ex 5 : 2)
|
|
||||||
Ex 6 : 1),Ex 6 : 2),Ex 6 : 3)
|
|
||||||
Ex 7 : 1),Ex 7 : 2),Ex 7 : 3)
|
|
||||||
Ex 8 : 1),Ex 8 : 2)
|
|
||||||
Ex 9 : 1),Ex 9 : 2)
|
|
||||||
Ex 10 : 1),Ex 10 : 2)
|
|
||||||
Ex 11
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
Ex 1
|
|
||||||
Ex 2 : 1)
|
|
||||||
Ex 2 : 2)
|
|
||||||
Ex 3 : 1)
|
|
||||||
Ex 3 : 2)
|
|
||||||
Ex 4
|
|
||||||
Ex 5 : 1)
|
|
||||||
Ex 5 : 2)
|
|
||||||
Ex 6 : 1)
|
|
||||||
Ex 6 : 2)
|
|
||||||
Ex 6 : 3)
|
|
||||||
Ex 7 : 1)
|
|
||||||
Ex 7 : 2)
|
|
||||||
Ex 7 : 3)
|
|
||||||
Ex 8 : 1)
|
|
||||||
Ex 8 : 2)
|
|
||||||
Ex 9 : 1)
|
|
||||||
Ex 9 : 2)
|
|
||||||
Ex 10 : 1)
|
|
||||||
Ex 10 : 2)
|
|
||||||
Ex 11
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
#+title: Script
|
#+title: Copienator
|
||||||
#+author: Sébastien Miquel
|
#+author: Sébastien Miquel
|
||||||
#+date: 14-03-2026
|
#+date: 14-03-2026
|
||||||
# Time-stamp: <17-05-26 10:51>
|
# Time-stamp: <22-08-26 12:22>
|
||||||
#+OPTIONS:
|
#+OPTIONS:
|
||||||
|
|
||||||
* Méta
|
* Présentation
|
||||||
** Quézaco
|
** Quézaco
|
||||||
|
|
||||||
Ce dépôt contient un certain nombre de script Python que j'utilise
|
Ce dépôt contient un certain nombre de script Python que j'utilise
|
||||||
@@ -21,264 +21,247 @@ pour faire corriger des copies par Gemini.
|
|||||||
4. Ces annotations manuscrites sont lues et recompilées en une
|
4. Ces annotations manuscrites sont lues et recompilées en une
|
||||||
version de la copie pour l'élève.
|
version de la copie pour l'élève.
|
||||||
|
|
||||||
** Disclaimer
|
** Limitations
|
||||||
|
|
||||||
J'utilise régulièrement cet outil et j'en suis satisfait, mais j'ai
|
Pour l'instant, la correction est faite question par question : le LLM
|
||||||
fait peu d'efforts pour le rendre universel et simple à l'emploi.
|
n'a accès qu'à la partie de la copie correspondant à une question
|
||||||
Plusieurs parties de mon workflow sont spécifiques à mes
|
fixée. Ça ne gère donc pas les cas où un argument a été donné dans une
|
||||||
représentations internes des sujets d'examens et/ou à mon environment.
|
question précédente ou autre.
|
||||||
|
|
||||||
L'utilisation de ce système nécessite une familiarité avec python et
|
*** Noms de labels sous Windows
|
||||||
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
|
Les labels de questions sont utilisés comme noms de fichiers. Les
|
||||||
de ce système n'est pas une priorité.
|
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
|
#+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
|
#+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
|
Créer et activer un environnement virtuel est recommandé :
|
||||||
+ Windows : Download from: https://github.com/oschwartz10612/poppler-windows
|
|
||||||
and add it to your PATH
|
#+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
|
*** Accès à Gemini
|
||||||
|
|
||||||
Il faut créer une clef API pour Gemini (pas facile).
|
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 :
|
Puis ajouter =GEMINI_API_KEY= à l'environnement avec :
|
||||||
|
|
||||||
#+BEGIN_SRC bash
|
#+BEGIN_SRC bash
|
||||||
export GEMINI_API_KEY=…
|
export GEMINI_API_KEY=…
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
ou éventuellement, la renseigner directement dans le fichier
|
||||||
|
`config.py`.
|
||||||
|
|
||||||
|
** Configuration
|
||||||
|
|
||||||
|
Copier `default_config.py` en `config.py`. Éventuellement le modifier.
|
||||||
|
|
||||||
** Correction d'un paquet de copies
|
** Correction d'un paquet de copies
|
||||||
|
|
||||||
1. Créer un fichier =names= dans le dossier courant, avec les
|
1. Créer un fichier =names= dans le dossier courant, avec les
|
||||||
noms/prénoms des élèves, un par ligne
|
noms/prénoms des élèves, un par ligne
|
||||||
2. Créer un dossier correspondant à l'évaluation, comme =Interro=
|
2. Créer un dossier correspondant à l'évaluation (=Interro= dans la
|
||||||
3. Mettre les fichiers pdfs scannés dans =Interro=.
|
suite)
|
||||||
4. Dans le dossier =Interro= créer un fichier =labels= avec les labels
|
3. Suivre les instructions suivantes
|
||||||
des questions, comme =Ex 1 : 1)a)=, un par ligne.
|
|
||||||
5. Il faudra créer des dossiers =Text=, =Sol= et =Persp=, et dans ces
|
|
||||||
dossiers créer, pour chaque label (ou groupe de labels : par
|
|
||||||
exemple un seul fichier =Ex 1= peut être utilisé pour toutes les
|
|
||||||
questions de l'exercice 1) un fichier texte qui contient
|
|
||||||
respectivement l'énoncé, un corrigé, et des indications de comment
|
|
||||||
corriger (Gemini met une note sur 4, on peut demander 2 points
|
|
||||||
pour tel truc, etc)
|
|
||||||
6. Suivre les étapes plus bas.
|
|
||||||
|
|
||||||
* Étapes et Script
|
** Interface graphique
|
||||||
** Prétraitement
|
|
||||||
|
|
||||||
1. =./rotate_all.sh Interro=
|
Lancer l'assistant avec :
|
||||||
(facultatif)
|
|
||||||
Retourne toutes les pdf de 180°
|
|
||||||
2. =./rename_to_copie.sh 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.
|
#+BEGIN_SRC bash
|
||||||
+ s pour garder les deux pages
|
python -m copienator gui
|
||||||
+ t/n pour garder celle de gauche/de droite
|
#+END_SRC
|
||||||
+ r pour jeter les deux pages
|
|
||||||
|
|
||||||
Les key bindings ne sont pas adaptés à un clavier azerty… À changer…
|
Sous Linux ou macOS, le lanceur exécutable =./start-gui.sh= démarre aussi
|
||||||
|
l’interface, depuis n’importe quel répertoire. Il utilise le Python de
|
||||||
|
=.venv= s’il 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=. S’il n’y a aucun dossier admissible, l’interface démarre
|
||||||
|
sans évaluation. Un chemin explicite reste utilisable même s’il est exclu
|
||||||
|
de la sélection automatique.
|
||||||
|
|
||||||
Fix issues with =python page_splitter.py Interro14/Copies/Copie01.pdf=
|
=Recharger — vérifier à nouveau= relit les fichiers d’entrée du dossier.
|
||||||
4. =python cutleft.py Interro=
|
Pour reprendre le découpage d’une 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 l’original conservé.
|
||||||
|
Dans la fenêtre de découpage, =i= inverse l’ordre 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).
|
||||||
|
|
||||||
Découpe la partie gauche des copies, là où il devrait y avoir les
|
Pendant =Découper une partie à gauche pour détection des labels=, =n= décale
|
||||||
labels des exercices/questions.
|
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.
|
||||||
|
|
||||||
Rerun on a single file with =python cutleft.py Interro/Copies/Copie01.pdf=
|
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 d’exercices 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=.
|
||||||
|
|
||||||
** Génération d'information sur l'énoncé
|
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.
|
||||||
|
|
||||||
1. =python enonce_info.py Interro= (gestion perso)
|
On peut aussi ouvrir directement une évaluation avec =python -m copienator gui
|
||||||
OU
|
Interro=. L'interface conserve l'état et l'historique des étapes dans
|
||||||
2. =python gemini_for_enonce.py Interro=
|
=Interro/.copienator-gui.json=, et les sorties complètes dans
|
||||||
+ Nécessite =enonce.tex/org= et `correction.tex/org`
|
=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.
|
||||||
|
|
||||||
** Labelisation et regroupement
|
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.
|
||||||
|
|
||||||
Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
|
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.
|
||||||
|
|
||||||
1. =python gemini_for_labels.py Interro=, avec éventuellement =--overwrite=
|
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=.
|
||||||
|
|
||||||
Fait des requêtes à Gemini pour identifier les labels des
|
* Documentation complémentaire
|
||||||
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.
|
- [[file:docs/final_output.md][Fichiers finaux dans A Rendre]] : contenu du JPEG, sélection et
|
||||||
+ Sous linux, on peut faire =e= pour ouvrir le fichier .json et
|
pagination du PDF, JPEG par réponse, =score.json=, =info.json= et diffusion.
|
||||||
l'éditer a la main.
|
- [[file:Script.org][Référence des étapes et des scripts]] : commandes, arguments,
|
||||||
+ Quand un label est manquant, il est possible de cliquer sur
|
prérequis, fichiers produits et parcours alternatifs.
|
||||||
l'image, ce qui copie les coordonnées dans le presse papier
|
- [[file:Architecture.org][Architecture et conventions de développement]] : API commune,
|
||||||
(sous linux…), puis on peut l'ajouter à la main.
|
état partagé, écritures sûres et règles de maintenance.
|
||||||
+ 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=
|
|
||||||
1. 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_dir_batching.py Interro/Copie{id}= ?? À
|
|
||||||
vérifier, pas sûr que ça marche.
|
|
||||||
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.
|
|
||||||
|
|
||||||
** Correction et annotation
|
|
||||||
|
|
||||||
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=
|
|
||||||
=python correction.py Interro --pro-by-label= (needs `labels_for_pro`)
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
** 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=.
|
|
||||||
|
|
||||||
2. =python annotating_with_checks.py Interro=
|
|
||||||
|
|
||||||
Ajoute les annotations Gemini, et des checkboxes à cocher.
|
|
||||||
Enregistrées dans le dossier =Bnot=,
|
|
||||||
=--overwrite=
|
|
||||||
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.
|
|
||||||
|
|
||||||
|
|
||||||
3. =python to_tablette.py Interro= (gestion perso)
|
|
||||||
Cela déplace les groupes dans =SyncCopies/À Annoter=.
|
|
||||||
- Les mettre dans le dossier racine de la tablette, et renommer en =aaa=.
|
|
||||||
- Vider =Syncthing/Annotées= sur la tablette et localement.
|
|
||||||
À automatiser, aussi c'est lent…
|
|
||||||
|
|
||||||
** Lecture de la correction manuscrite
|
|
||||||
|
|
||||||
1. =python from_tablette.py Interro= (gestion perso)
|
|
||||||
|
|
||||||
_Before_ : delete =~/SyncCopies/Annotées=, copy or sync from the
|
|
||||||
tablette to here.
|
|
||||||
|
|
||||||
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=.
|
|
||||||
|
|
||||||
(À faire : universaliser =to_tablette.py= et =from_tablette.py=)
|
|
||||||
|
|
||||||
2. =python reading_annotations.py Interro=
|
|
||||||
|
|
||||||
Lit les =Concat_annotated= dans =Bnot=, regénère les copies avec
|
|
||||||
les modifications.
|
|
||||||
OU
|
|
||||||
2. =python reading_grouped_annotations.py Interro=
|
|
||||||
|
|
||||||
Idem, mais pour =BGnot=.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
On peut faire des changements manuels aux =score.json= ici.
|
|
||||||
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.
|
|
||||||
|
|
||||||
** 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
|
|
||||||
: [["Copie01", []],
|
|
||||||
: ["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= avec --refaire.
|
|
||||||
6. =python to_tablette.py --refaire Interro24=
|
|
||||||
6. =python from_tablette.py --refaire Interro24=
|
|
||||||
7. =python reading_grouped_annotations.py --refaire Interro24=
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import math
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
from pathlib import Path
|
|
||||||
import pandas as pd
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
# Configuration constants
|
|
||||||
ODS_PATH = "/home/sebastien/Rust/gestion_classe/Staging/simple_eval.ods"
|
|
||||||
OUTPUT_DIR = Path("/home/sebastien/Rust/Server/copies")
|
|
||||||
FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" # Standard Linux font path
|
|
||||||
|
|
||||||
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):
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
try:
|
|
||||||
font = ImageFont.truetype(FONT_PATH, font_size)
|
|
||||||
except IOError:
|
|
||||||
# Fallback if specific font not found
|
|
||||||
font = ImageFont.load_default()
|
|
||||||
print(f"Warning: System font not found, using default for {student_name}")
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
OUTPUT_DIR = OUTPUT_DIR / args.dir
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
process_images(args.dir)
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
import sys
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import shutil
|
|
||||||
import argparse
|
|
||||||
import concurrent.futures
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
from reportlab.pdfgen import canvas
|
|
||||||
|
|
||||||
import annotating
|
|
||||||
import annotating_with_checks
|
|
||||||
|
|
||||||
from utils import natural_key
|
|
||||||
|
|
||||||
MAX_HEIGHT_PX = 25000 # Can be increased by 10%.
|
|
||||||
|
|
||||||
def render_item(item):
|
|
||||||
student_id, label, content = item
|
|
||||||
pdf_path = content['pdf_path']
|
|
||||||
if not os.path.exists(pdf_path):
|
|
||||||
print("no pdf path for ", pdf_path)
|
|
||||||
return None
|
|
||||||
|
|
||||||
base_img, _, _ = annotating.make_base_image(pdf_path)
|
|
||||||
cb_renderer = annotating_with_checks.CheckboxRenderer(label)
|
|
||||||
|
|
||||||
final_img, header_h = annotating.compose_label_image(
|
|
||||||
base_img, label, content['result'], content['coordinates'][0],
|
|
||||||
draw_callback=cb_renderer.callback,
|
|
||||||
more_right=True,
|
|
||||||
with_id=student_id
|
|
||||||
)
|
|
||||||
if final_img is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return (student_id, label, final_img, header_h, cb_renderer.checkboxes)
|
|
||||||
|
|
||||||
def save_batch(batch, prefix, group_id, root_dir, overwrite):
|
|
||||||
output_dir = os.path.join(root_dir, "BGnot", f"{prefix} G{group_id}")
|
|
||||||
|
|
||||||
if os.path.exists(output_dir):
|
|
||||||
if not overwrite:
|
|
||||||
print(f"Skipping {output_dir}: Output already exists.")
|
|
||||||
return
|
|
||||||
shutil.rmtree(output_dir)
|
|
||||||
|
|
||||||
print(f"Generating Group PDF: {prefix} G{group_id} ({len(batch)} elements)")
|
|
||||||
os.makedirs(output_dir)
|
|
||||||
|
|
||||||
max_w = max(item[2].width for item in batch)
|
|
||||||
total_h = sum(item[2].height for item in batch)
|
|
||||||
concat_img = Image.new("RGB", (max_w, total_h), "white")
|
|
||||||
draw = ImageDraw.Draw(concat_img)
|
|
||||||
|
|
||||||
final_json_map = []
|
|
||||||
bnote_entries = []
|
|
||||||
current_y = 0
|
|
||||||
last_sid = None
|
|
||||||
|
|
||||||
for sid, label, img, header_h, boxes in batch:
|
|
||||||
concat_img.paste(img, (0, current_y))
|
|
||||||
|
|
||||||
if sid != last_sid:
|
|
||||||
draw.rectangle([0, current_y, max_w, current_y + 4], fill="purple")
|
|
||||||
last_sid = sid
|
|
||||||
|
|
||||||
bnote_entries.append({
|
|
||||||
"id": sid,
|
|
||||||
"label": label,
|
|
||||||
"header_height": header_h,
|
|
||||||
"hmin": current_y,
|
|
||||||
"hmax": current_y + img.height
|
|
||||||
})
|
|
||||||
|
|
||||||
for item in boxes:
|
|
||||||
b = item.get('final_box') or item.get('rel_box')
|
|
||||||
item['global_box'] = [b[0], b[1] + current_y, b[2], b[3] + current_y]
|
|
||||||
item['student_id'] = sid # Required to map checkbox to the correct student
|
|
||||||
final_json_map.append(item)
|
|
||||||
|
|
||||||
current_y += img.height
|
|
||||||
|
|
||||||
with open(os.path.join(output_dir, "bnote.json"), "w") as f:
|
|
||||||
json.dump({"width": max_w, "height": total_h, "images": bnote_entries}, f, indent=2)
|
|
||||||
|
|
||||||
with open(os.path.join(output_dir, "checkboxes.json"), "w") as f:
|
|
||||||
json.dump(final_json_map, f, indent=2)
|
|
||||||
|
|
||||||
temp_img_path = os.path.join(output_dir, "Reference.jpg")
|
|
||||||
concat_img.save(temp_img_path, quality=90)
|
|
||||||
|
|
||||||
pdf_path = os.path.join(output_dir, "Concat.pdf")
|
|
||||||
w, h = concat_img.size
|
|
||||||
c = canvas.Canvas(pdf_path, pagesize=(w, h))
|
|
||||||
c.drawImage(temp_img_path, 0, 0, width=w, height=h)
|
|
||||||
c.save()
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="Generate annotated PDFs grouped by labels.")
|
|
||||||
parser.add_argument("input_path", help="Directory containing Bnot structure")
|
|
||||||
parser.add_argument("--overwrite", action="store_true", help="Overwrite existing output files")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
root_dir = args.input_path
|
|
||||||
results = annotating.make_dictionary(root_dir)
|
|
||||||
label_groups = os.path.join(root_dir, "label_groups")
|
|
||||||
all_labels = os.path.join(root_dir, "labels")
|
|
||||||
|
|
||||||
if not os.path.exists(label_groups):
|
|
||||||
print(f"Warning: Labels file '{label_groups}' not found, making it out of '{all_labels}'")
|
|
||||||
if not os.path.exists(all_labels):
|
|
||||||
print(f"Error: {all_labels} not found.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
with open(all_labels, 'r') as f:
|
|
||||||
lines = [l.strip() for l in f if l.strip()]
|
|
||||||
|
|
||||||
groups = {}
|
|
||||||
for line in lines:
|
|
||||||
# Key is the part before the colon, or the whole line if no colon
|
|
||||||
key = line.split(' : ')[0] if ' : ' in line else line
|
|
||||||
groups.setdefault(key, []).append(line)
|
|
||||||
|
|
||||||
with open(label_groups, 'w') as f:
|
|
||||||
for items in groups.values():
|
|
||||||
f.write(",".join(items) + "\n")
|
|
||||||
|
|
||||||
with open(label_groups, "r") as f:
|
|
||||||
lines = [line.strip() for line in f if line.strip()]
|
|
||||||
|
|
||||||
bgnot_dir = os.path.join(root_dir, "BGnot")
|
|
||||||
if args.overwrite and os.path.exists(bgnot_dir):
|
|
||||||
shutil.rmtree(bgnot_dir)
|
|
||||||
os.makedirs(bgnot_dir, exist_ok=True)
|
|
||||||
|
|
||||||
used_prefixes = set()
|
|
||||||
|
|
||||||
previous_prefix = None
|
|
||||||
for line in lines:
|
|
||||||
labels = [l.strip() for l in line.split(',') if l.strip()]
|
|
||||||
safe_labels = [l.replace(":", "").strip() for l in line.split(',') if l.strip()]
|
|
||||||
if not labels:
|
|
||||||
continue
|
|
||||||
|
|
||||||
base_prefix = os.path.commonprefix(safe_labels).strip()
|
|
||||||
if not base_prefix:
|
|
||||||
base_prefix = "Group"
|
|
||||||
|
|
||||||
unique_prefix = base_prefix
|
|
||||||
if unique_prefix[-1] == "i":
|
|
||||||
unique_prefix = unique_prefix[:-1]
|
|
||||||
counter = 2
|
|
||||||
while unique_prefix in used_prefixes:
|
|
||||||
unique_prefix = f"{base_prefix}-{counter}"
|
|
||||||
counter += 1
|
|
||||||
if counter == 2 and previous_prefix and previous_prefix in unique_prefix:
|
|
||||||
unique_prefix = f"{previous_prefix}-{counter}"
|
|
||||||
elif counter == 2:
|
|
||||||
previous_prefx = unique_prefix
|
|
||||||
|
|
||||||
used_prefixes.add(unique_prefix)
|
|
||||||
|
|
||||||
existing_items = set()
|
|
||||||
max_existing_group = 0
|
|
||||||
|
|
||||||
|
|
||||||
if not args.overwrite and os.path.exists(bgnot_dir):
|
|
||||||
for d in os.listdir(bgnot_dir):
|
|
||||||
if d.startswith(f"{unique_prefix} G"):
|
|
||||||
try:
|
|
||||||
g_id = int(d.split(' G')[-1])
|
|
||||||
max_existing_group = max(max_existing_group, g_id)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
bnote_path = os.path.join(bgnot_dir, d, "bnote.json")
|
|
||||||
if os.path.exists(bnote_path):
|
|
||||||
with open(bnote_path, "r") as bf:
|
|
||||||
bdata = json.load(bf)
|
|
||||||
for img in bdata.get("images", []):
|
|
||||||
existing_items.add((img["id"], img["label"]))
|
|
||||||
|
|
||||||
items_to_render = []
|
|
||||||
for sid, lbls in results.items():
|
|
||||||
for lbl in labels:
|
|
||||||
if lbl in lbls:
|
|
||||||
# Only add if it hasn't been generated yet
|
|
||||||
if (sid, lbl) not in existing_items:
|
|
||||||
items_to_render.append((sid, lbl, lbls[lbl]))
|
|
||||||
if not items_to_render:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Sort structurally: by student id and label
|
|
||||||
items_to_render.sort(key=lambda x: (natural_key(x[0]), natural_key(x[1])))
|
|
||||||
# Render images in parallel using the pre-existing lock & render function
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
|
||||||
rendered = list(executor.map(render_item, items_to_render))
|
|
||||||
|
|
||||||
rendered = [r for r in rendered if r is not None]
|
|
||||||
if not rendered:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Split into constrained height batches
|
|
||||||
batches = []
|
|
||||||
current_batch = []
|
|
||||||
current_h = 0
|
|
||||||
for r in rendered:
|
|
||||||
sid = r[0]
|
|
||||||
img_h = r[2].height
|
|
||||||
# Split if we exceed max height AND we are on a new student
|
|
||||||
if current_batch and current_h + img_h > MAX_HEIGHT_PX and sid != last_sid:
|
|
||||||
batches.append(current_batch)
|
|
||||||
current_batch = []
|
|
||||||
current_h = 0
|
|
||||||
current_batch.append(r)
|
|
||||||
current_h += img_h
|
|
||||||
last_sid = sid
|
|
||||||
if current_batch:
|
|
||||||
batches.append(current_batch)
|
|
||||||
|
|
||||||
batches2 = []
|
|
||||||
current_batch2 = []
|
|
||||||
current_h2 = 0
|
|
||||||
last_sid2 = None
|
|
||||||
for r in rendered:
|
|
||||||
sid = r[0]
|
|
||||||
img_h = r[2].height
|
|
||||||
# Split if we exceed max height AND we are on a new student
|
|
||||||
if current_batch2 and current_h2 + img_h > 1.1 *MAX_HEIGHT_PX \
|
|
||||||
and sid != last_sid2:
|
|
||||||
batches2.append(current_batch2)
|
|
||||||
current_batch2 = []
|
|
||||||
current_h2 = 0
|
|
||||||
current_batch2.append(r)
|
|
||||||
current_h2 += img_h
|
|
||||||
last_sid2 = sid
|
|
||||||
if current_batch2:
|
|
||||||
batches2.append(current_batch2)
|
|
||||||
|
|
||||||
if len(batches2) < len(batches):
|
|
||||||
batches = batches2
|
|
||||||
|
|
||||||
for i, batch in enumerate(batches, 1):
|
|
||||||
save_batch(batch, unique_prefix, max_existing_group + i, root_dir, args.overwrite)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
import sys
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import shutil
|
|
||||||
import concurrent.futures
|
|
||||||
import threading
|
|
||||||
import img2pdf
|
|
||||||
from reportlab.pdfgen import canvas
|
|
||||||
|
|
||||||
# Fix for Matplotlib in threads: Set backend to non-interactive 'Agg'
|
|
||||||
import matplotlib
|
|
||||||
matplotlib.use('Agg')
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
import annotating
|
|
||||||
from annotating import MARGIN_LEFT, ANNOT_WIDTH
|
|
||||||
|
|
||||||
# Global lock for Matplotlib/Latex rendering to prevent race conditions
|
|
||||||
LATEX_LOCK = threading.Lock()
|
|
||||||
DPI = 100
|
|
||||||
BOX_SIZE = 30
|
|
||||||
SCORE_BOX_SIZE = 40
|
|
||||||
SCORES = [x * 0.5 for x in range(10)] # 0.0 to 4.5
|
|
||||||
|
|
||||||
try:
|
|
||||||
CHECKBOX_FONT = ImageFont.truetype("DejaVuSans.ttf", 20)
|
|
||||||
except IOError:
|
|
||||||
try:
|
|
||||||
CHECKBOX_FONT = ImageFont.truetype("arial.ttf", 20)
|
|
||||||
except IOError:
|
|
||||||
CHECKBOX_FONT = ImageFont.load_default()
|
|
||||||
|
|
||||||
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.rectangle([x, y, x + size, y + size], fill=fill, outline="black", width=2)
|
|
||||||
|
|
||||||
return [x, y, x + size, y + size]
|
|
||||||
|
|
||||||
def safe_render_latex(*args, **kwargs):
|
|
||||||
"""Thread-safe wrapper for latex rendering."""
|
|
||||||
# with LATEX_LOCK:
|
|
||||||
# return annotating.render_latex_text(*args, **kwargs)
|
|
||||||
return annotating.render_real_latex_text(*args, **kwargs)
|
|
||||||
|
|
||||||
class CheckboxRenderer:
|
|
||||||
def __init__(self, label_name):
|
|
||||||
self.label = label_name
|
|
||||||
self.checkboxes = [] # List of {type, box, etc.}
|
|
||||||
|
|
||||||
def callback(self, kind, draw, pos, meta):
|
|
||||||
"""
|
|
||||||
Called by compose_label_image during rendering.
|
|
||||||
pos contains {x, y, w, h} or {box}.
|
|
||||||
meta contains {data, index, etc.}
|
|
||||||
"""
|
|
||||||
if kind == "header_item":
|
|
||||||
# meta['data'] is either result object (for score) or feedback object
|
|
||||||
if meta.get("type") == "score":
|
|
||||||
# Draw score boxes
|
|
||||||
start_x = pos['w'] + 20
|
|
||||||
for val in SCORES:
|
|
||||||
box = draw_checkbox(draw, start_x, pos['y'] + 25,
|
|
||||||
SCORE_BOX_SIZE, str(val))
|
|
||||||
self.checkboxes.append({
|
|
||||||
"type": "score", "label": self.label, "value": val,
|
|
||||||
"rel_box": box # Will be adjusted for global Y later
|
|
||||||
})
|
|
||||||
start_x += SCORE_BOX_SIZE + 45
|
|
||||||
|
|
||||||
start_x += SCORE_BOX_SIZE + 60
|
|
||||||
box = draw_checkbox(draw, start_x, pos['y'] + 25, SCORE_BOX_SIZE, "clr")
|
|
||||||
self.checkboxes.append({
|
|
||||||
"type": "clear_all", "label": self.label,
|
|
||||||
"rel_box": box
|
|
||||||
})
|
|
||||||
|
|
||||||
elif meta.get("type") == "global_fb":
|
|
||||||
# Draw delete box for global feedback
|
|
||||||
bx = pos['w'] - BOX_SIZE - 5
|
|
||||||
by = pos['y'] + 5
|
|
||||||
box = draw_checkbox(draw, bx, by, BOX_SIZE)
|
|
||||||
self.checkboxes.append({
|
|
||||||
"type": "del_global", "label": self.label, "index": meta["index"],
|
|
||||||
"rel_box": box, "text_preview": meta["data"]["text"][:20]
|
|
||||||
})
|
|
||||||
|
|
||||||
elif kind == "local_rect":
|
|
||||||
# Delete rect checkbox
|
|
||||||
b = pos['box'] # [xmin, ymin, xmax, ymax]
|
|
||||||
box = draw_checkbox(draw, b[2] - BOX_SIZE, b[1], BOX_SIZE)
|
|
||||||
self.checkboxes.append({
|
|
||||||
"type": "del_local_rect", "label": self.label, "index": meta["index"],
|
|
||||||
"final_box": box, "text_preview": meta["data"]["text"][:20]
|
|
||||||
})
|
|
||||||
|
|
||||||
elif kind == "local_text":
|
|
||||||
# Delete whole local feedback checkbox
|
|
||||||
bx = pos['x'] + pos['w'] - BOX_SIZE
|
|
||||||
by = pos['y']
|
|
||||||
box = draw_checkbox(draw, bx, by, BOX_SIZE)
|
|
||||||
self.checkboxes.append({
|
|
||||||
"type": "del_local", "label": self.label, "index": meta["index"],
|
|
||||||
"final_box": box, "text_preview": meta["data"]["text"][:20]
|
|
||||||
})
|
|
||||||
|
|
||||||
from utils import natural_key
|
|
||||||
|
|
||||||
def process_student(args):
|
|
||||||
"""Thread worker: Processes one student."""
|
|
||||||
root_dir, student_id, labels, overwrite, sub_folder = args
|
|
||||||
|
|
||||||
output_dir = os.path.join(root_dir, sub_folder, f"Copie{student_id}")
|
|
||||||
|
|
||||||
if os.path.exists(output_dir):
|
|
||||||
if not overwrite:
|
|
||||||
print(f"Skipping {student_id}: Output already exists.")
|
|
||||||
return
|
|
||||||
shutil.rmtree(output_dir)
|
|
||||||
|
|
||||||
print(f"Generating Checkable PDF for: {student_id}")
|
|
||||||
os.makedirs(output_dir)
|
|
||||||
|
|
||||||
label_images = []
|
|
||||||
# ... (rest of the function remains exactly the same)
|
|
||||||
all_checkboxes = []
|
|
||||||
bnote_entries = [] # For bnote.json
|
|
||||||
|
|
||||||
sorted_labels = sorted(labels.items(), key=lambda x: natural_key(x[0]))
|
|
||||||
|
|
||||||
for label, content in sorted_labels:
|
|
||||||
pdf_path = content['pdf_path']
|
|
||||||
if not os.path.exists(pdf_path): continue
|
|
||||||
|
|
||||||
base_img, _, _ = annotating.make_base_image(pdf_path)
|
|
||||||
|
|
||||||
# Initialize the hook
|
|
||||||
cb_renderer = CheckboxRenderer(label)
|
|
||||||
|
|
||||||
# Render using the shared engine
|
|
||||||
final_img, header_h = annotating.compose_label_image(
|
|
||||||
base_img, label, content['result'], content['coordinates'][0],
|
|
||||||
draw_callback=cb_renderer.callback
|
|
||||||
)
|
|
||||||
if final_img == None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
label_images.append(final_img)
|
|
||||||
all_checkboxes.append(cb_renderer.checkboxes)
|
|
||||||
bnote_entries.append({
|
|
||||||
"id": student_id,
|
|
||||||
"label": label,
|
|
||||||
"header_height": header_h,
|
|
||||||
# hmin/hmax will be filled during concatenation
|
|
||||||
"img_h": final_img.height
|
|
||||||
})
|
|
||||||
|
|
||||||
if not label_images: return
|
|
||||||
|
|
||||||
# Concatenate
|
|
||||||
max_w = max(i.width for i in label_images)
|
|
||||||
total_h = sum(i.height for i in label_images)
|
|
||||||
concat_img = Image.new("RGB", (max_w, total_h), "white")
|
|
||||||
|
|
||||||
final_json_map = []
|
|
||||||
current_y = 0
|
|
||||||
|
|
||||||
for idx, (img, boxes) in enumerate(zip(label_images, all_checkboxes)):
|
|
||||||
concat_img.paste(img, (0, current_y))
|
|
||||||
|
|
||||||
bnote_entries[idx]["hmin"] = current_y
|
|
||||||
bnote_entries[idx]["hmax"] = current_y + img.height
|
|
||||||
del bnote_entries[idx]["img_h"] # Clean up temp data
|
|
||||||
|
|
||||||
# Adjust coordinates for concatenated image
|
|
||||||
for item in boxes:
|
|
||||||
# item might have 'rel_box' (header) or 'final_box' (local)
|
|
||||||
# Both were relative to the label image. We just add current_y.
|
|
||||||
b = item.get('final_box') or item.get('rel_box')
|
|
||||||
item['global_box'] = [b[0], b[1] + current_y, b[2], b[3] + current_y]
|
|
||||||
final_json_map.append(item)
|
|
||||||
|
|
||||||
current_y += img.height
|
|
||||||
|
|
||||||
bnote_data = {
|
|
||||||
"width": max_w,
|
|
||||||
"height": total_h,
|
|
||||||
"images": bnote_entries
|
|
||||||
}
|
|
||||||
with open(os.path.join(output_dir, "bnote.json"), "w") as f:
|
|
||||||
json.dump(bnote_data, f, indent=2)
|
|
||||||
|
|
||||||
with open(os.path.join(output_dir, "checkboxes.json"), "w") as f:
|
|
||||||
json.dump(final_json_map, f, indent=2)
|
|
||||||
|
|
||||||
temp_img_path = os.path.join(output_dir, "Reference.jpg") # Can't use png here
|
|
||||||
concat_img.save(temp_img_path, quality=90)
|
|
||||||
|
|
||||||
pdf_path = os.path.join(output_dir, "Concat.pdf")
|
|
||||||
w, h = concat_img.size
|
|
||||||
c = canvas.Canvas(pdf_path, pagesize=(w, h))
|
|
||||||
c.drawImage(temp_img_path, 0, 0, width=w, height=h)
|
|
||||||
c.save()
|
|
||||||
|
|
||||||
import argparse # Added
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(description="Generate annotated PDFs.")
|
|
||||||
parser.add_argument("input_path", help="Directory or specific file path")
|
|
||||||
parser.add_argument("--overwrite", action="store_true", help="Overwrite existing output files")
|
|
||||||
parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json") # ADD THIS LINE
|
|
||||||
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
input_path = args.input_path
|
|
||||||
overwrite = args.overwrite # Capture flag
|
|
||||||
target_id = None
|
|
||||||
|
|
||||||
# Detect if input is a specific file
|
|
||||||
if os.path.isfile(input_path):
|
|
||||||
root_dir = os.path.dirname(input_path) or "."
|
|
||||||
# Extract ID from filename (e.g., Copie40.pdf -> 40)
|
|
||||||
match = re.search(r'Copie(\d+)', os.path.basename(input_path))
|
|
||||||
if match:
|
|
||||||
target_id = match.group(1)
|
|
||||||
else:
|
|
||||||
print("Error: Could not extract student ID from filename.")
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
root_dir = input_path
|
|
||||||
|
|
||||||
if not args.refaire:
|
|
||||||
results = annotating.make_dictionary(root_dir)
|
|
||||||
|
|
||||||
if args.refaire:
|
|
||||||
refaire_path = os.path.join(root_dir, "refaire.json")
|
|
||||||
if os.path.exists(refaire_path):
|
|
||||||
with open(refaire_path, "r", encoding="utf-8") as f:
|
|
||||||
refaire_list = json.load(f)
|
|
||||||
results = annotating.make_dictionary(root_dir,
|
|
||||||
refaire=True,refaire_list=refaire_list)
|
|
||||||
|
|
||||||
filtered_results = {}
|
|
||||||
for copie_name, labels_to_redo in refaire_list:
|
|
||||||
sid = copie_name.replace("Copie", "") # Extract "01" from "Copie01"
|
|
||||||
if sid in results:
|
|
||||||
if not labels_to_redo:
|
|
||||||
# Empty list: keep all labels for this Copie
|
|
||||||
filtered_results[sid] = results[sid]
|
|
||||||
else:
|
|
||||||
# Keep only the requested labels
|
|
||||||
filtered_results[sid] = {
|
|
||||||
lbl: data for lbl, data in results[sid].items()
|
|
||||||
if lbl in labels_to_redo
|
|
||||||
}
|
|
||||||
results = filtered_results
|
|
||||||
else:
|
|
||||||
print(f"Warning: --refaire flag used, but {refaire_path} not found.")
|
|
||||||
elif target_id:
|
|
||||||
if target_id in results:
|
|
||||||
results = {target_id: results[target_id]}
|
|
||||||
else:
|
|
||||||
print(f"Student ID {target_id} not found in directory scan.")
|
|
||||||
results = {}
|
|
||||||
|
|
||||||
sub_folder = "BRnot" if args.refaire else "Bnot"
|
|
||||||
|
|
||||||
tasks = sorted([(root_dir, sid, lbls, overwrite, sub_folder)
|
|
||||||
for sid, lbls in results.items()])
|
|
||||||
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
|
||||||
results = executor.map(process_student, tasks)
|
|
||||||
try:
|
|
||||||
for _ in results:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
@@ -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()
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Core building blocks shared by Copienator scripts and interfaces."""
|
||||||
|
|
||||||
|
from .cli import (
|
||||||
|
CliError,
|
||||||
|
ExitCode,
|
||||||
|
evaluation_parser,
|
||||||
|
evaluation_workspace,
|
||||||
|
execute,
|
||||||
|
standard_parser,
|
||||||
|
target_parser,
|
||||||
|
workspace_from_args,
|
||||||
|
workspace_from_target,
|
||||||
|
)
|
||||||
|
from .json_io import (
|
||||||
|
JsonLockTimeout,
|
||||||
|
atomic_update_json,
|
||||||
|
atomic_write_bytes,
|
||||||
|
atomic_write_json,
|
||||||
|
atomic_write_text,
|
||||||
|
read_json,
|
||||||
|
)
|
||||||
|
from .workspace import (
|
||||||
|
EvaluationWorkspace,
|
||||||
|
WorkspaceNotFoundError,
|
||||||
|
WorkspaceValidationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CliError",
|
||||||
|
"EvaluationWorkspace",
|
||||||
|
"ExitCode",
|
||||||
|
"JsonLockTimeout",
|
||||||
|
"WorkspaceNotFoundError",
|
||||||
|
"WorkspaceValidationError",
|
||||||
|
"atomic_update_json",
|
||||||
|
"atomic_write_bytes",
|
||||||
|
"atomic_write_json",
|
||||||
|
"atomic_write_text",
|
||||||
|
"evaluation_parser",
|
||||||
|
"evaluation_workspace",
|
||||||
|
"execute",
|
||||||
|
"read_json",
|
||||||
|
"standard_parser",
|
||||||
|
"target_parser",
|
||||||
|
"workspace_from_args",
|
||||||
|
"workspace_from_target",
|
||||||
|
]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .dispatcher import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Callable
|
||||||
|
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]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_checkbox_actions(
|
||||||
|
labels_data: dict[str, dict[str, Any]],
|
||||||
|
actions: list[dict[str, Any]],
|
||||||
|
log: Log,
|
||||||
|
) -> set[str]:
|
||||||
|
actions_by_label: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||||
|
for action in actions:
|
||||||
|
actions_by_label[str(action.get("label", ""))].append(action)
|
||||||
|
|
||||||
|
dirty_labels: set[str] = set()
|
||||||
|
for label, label_actions in actions_by_label.items():
|
||||||
|
if label not in labels_data:
|
||||||
|
continue
|
||||||
|
result = labels_data[label]["result"]
|
||||||
|
feedbacks = result.get("feedback", [])
|
||||||
|
# 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:
|
||||||
|
action_type = action.get("type")
|
||||||
|
if action_type == "score":
|
||||||
|
result["score"] = action.get("value")
|
||||||
|
dirty_labels.add(label)
|
||||||
|
log(f" > Updated score for {label} to {action.get('value')}")
|
||||||
|
elif action_type == "clear_all":
|
||||||
|
for feedback in feedbacks:
|
||||||
|
feedback["to_delete"] = True
|
||||||
|
if feedback.get("box_2d"):
|
||||||
|
feedback["norectangle"] = True
|
||||||
|
dirty_labels.add(label)
|
||||||
|
log(f" > Cleared all feedbacks in {label}")
|
||||||
|
elif action_type == "del_global":
|
||||||
|
index = int(action.get("index", -1))
|
||||||
|
if 0 <= index < len(global_feedbacks):
|
||||||
|
global_feedbacks[index]["to_delete"] = True
|
||||||
|
dirty_labels.add(label)
|
||||||
|
log(f" > Deleted global feedback in {label}")
|
||||||
|
elif action_type in {"del_local", "del_local_rect"}:
|
||||||
|
index = int(action.get("index", -1))
|
||||||
|
if 0 <= index < len(local_feedbacks):
|
||||||
|
target = local_feedbacks[index]
|
||||||
|
if action_type == "del_local":
|
||||||
|
target["to_delete"] = True
|
||||||
|
log(f" > Deleted local feedback in {label}")
|
||||||
|
else:
|
||||||
|
target["norectangle"] = True
|
||||||
|
log(f" > Deleted rectangle in {label}")
|
||||||
|
dirty_labels.add(label)
|
||||||
|
return dirty_labels
|
||||||
|
|
||||||
|
|
||||||
|
def apply_score_overrides(
|
||||||
|
labels_data: dict[str, dict[str, Any]],
|
||||||
|
score_path: Path,
|
||||||
|
log: Log,
|
||||||
|
) -> set[str]:
|
||||||
|
if not score_path.exists():
|
||||||
|
return set()
|
||||||
|
loaded = read_json(score_path)
|
||||||
|
if not isinstance(loaded, dict):
|
||||||
|
raise TypeError(f"Expected a JSON object in {score_path}")
|
||||||
|
dirty: set[str] = set()
|
||||||
|
for label, score in loaded.items():
|
||||||
|
if label not in labels_data:
|
||||||
|
continue
|
||||||
|
current = str(labels_data[label]["result"].get("score", 0))
|
||||||
|
if current != str(score):
|
||||||
|
labels_data[label]["result"]["score"] = score
|
||||||
|
dirty.add(label)
|
||||||
|
log(f" > Overrode score for {label} to {score} from score.json")
|
||||||
|
return dirty
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
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]]]
|
||||||
|
RefaireList = list[list[Any]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class GroupCoordinates:
|
||||||
|
minimum: int
|
||||||
|
maximum: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AnnotationLoadResult:
|
||||||
|
data: AnnotationData
|
||||||
|
warnings: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _coordinate_index(
|
||||||
|
workspace: EvaluationWorkspace,
|
||||||
|
) -> tuple[dict[tuple[str, str], GroupCoordinates], list[str]]:
|
||||||
|
index: dict[tuple[str, str], GroupCoordinates] = {}
|
||||||
|
warnings: list[str] = []
|
||||||
|
if not workspace.groups_dir.is_dir():
|
||||||
|
return index, [f"Group directory not found: {workspace.groups_dir}"]
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
raise TypeError("expected a JSON array")
|
||||||
|
with Image.open(image_path) as image:
|
||||||
|
width, height = image.size
|
||||||
|
for entry in entries:
|
||||||
|
copy_id = str(entry[0])
|
||||||
|
minimum = int(entry[1])
|
||||||
|
maximum = int(entry[2])
|
||||||
|
label = str(entry[4])
|
||||||
|
index.setdefault(
|
||||||
|
(label, copy_id),
|
||||||
|
GroupCoordinates(minimum, maximum, width, height),
|
||||||
|
)
|
||||||
|
except (IndexError, OSError, TypeError, ValueError) as exc:
|
||||||
|
warnings.append(f"Could not read group metadata {metadata_path}: {exc}")
|
||||||
|
return index, warnings
|
||||||
|
|
||||||
|
|
||||||
|
def _scaled_result(result: dict[str, Any], coordinates: GroupCoordinates | None):
|
||||||
|
scaled = copy.deepcopy(result)
|
||||||
|
if coordinates is None:
|
||||||
|
return scaled
|
||||||
|
for feedback in scaled.get("feedback", []):
|
||||||
|
box = feedback.get("box_2d")
|
||||||
|
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
|
||||||
|
box[1] = int(box[1] * coordinates.width) // 1000
|
||||||
|
box[3] = int(box[3] * coordinates.width) // 1000
|
||||||
|
return scaled
|
||||||
|
|
||||||
|
|
||||||
|
def _answer_pdf(
|
||||||
|
workspace: EvaluationWorkspace,
|
||||||
|
copy_id: str,
|
||||||
|
label: str,
|
||||||
|
suffix: str = "",
|
||||||
|
) -> Path:
|
||||||
|
copy_dir = workspace.copies_dir / f"Copie{copy_id}"
|
||||||
|
preferred = copy_dir / f"{label}{suffix}.pdf"
|
||||||
|
if preferred.exists():
|
||||||
|
return preferred
|
||||||
|
for candidate in (
|
||||||
|
copy_dir / f"{label}.pdf",
|
||||||
|
copy_dir / f"{label}_new.pdf",
|
||||||
|
):
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
return preferred
|
||||||
|
|
||||||
|
|
||||||
|
def _dummy_entry(
|
||||||
|
workspace: EvaluationWorkspace,
|
||||||
|
copy_id: str,
|
||||||
|
label: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"pdf_path": _answer_pdf(workspace, copy_id, label),
|
||||||
|
"result": {
|
||||||
|
"score": 0.0,
|
||||||
|
"feedback": [],
|
||||||
|
"error": "non traité",
|
||||||
|
},
|
||||||
|
"coordinates": (0, 0),
|
||||||
|
"issues": ["No correction result was available for this answer."],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_refaire_filter(
|
||||||
|
workspace: EvaluationWorkspace,
|
||||||
|
data: AnnotationData,
|
||||||
|
refaire_list: RefaireList,
|
||||||
|
warnings: list[str],
|
||||||
|
) -> AnnotationData:
|
||||||
|
filtered: AnnotationData = {}
|
||||||
|
for raw_entry in refaire_list:
|
||||||
|
if not isinstance(raw_entry, list) or len(raw_entry) != 2:
|
||||||
|
warnings.append(f"Ignoring malformed refaire entry: {raw_entry!r}")
|
||||||
|
continue
|
||||||
|
copy_name, requested_labels = raw_entry
|
||||||
|
copy_id = str(copy_name).removeprefix("Copie")
|
||||||
|
available = data.get(copy_id, {})
|
||||||
|
if not requested_labels:
|
||||||
|
filtered[copy_id] = dict(available)
|
||||||
|
continue
|
||||||
|
selected: dict[str, dict[str, Any]] = {}
|
||||||
|
for raw_label in requested_labels:
|
||||||
|
label = str(raw_label)
|
||||||
|
selected[label] = (
|
||||||
|
available[label]
|
||||||
|
if label in available
|
||||||
|
else _dummy_entry(workspace, copy_id, label)
|
||||||
|
)
|
||||||
|
filtered[copy_id] = selected
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def load_annotation_data(
|
||||||
|
workspace: EvaluationWorkspace,
|
||||||
|
*,
|
||||||
|
refaire_list: RefaireList | None = None,
|
||||||
|
copy_id: str | None = None,
|
||||||
|
) -> AnnotationLoadResult:
|
||||||
|
workspace.require_files("correction.json")
|
||||||
|
corrections = read_json(workspace.correction_file)
|
||||||
|
if not isinstance(corrections, dict):
|
||||||
|
raise TypeError("correction.json must contain a JSON object")
|
||||||
|
|
||||||
|
coordinate_index, warnings = _coordinate_index(workspace)
|
||||||
|
data: AnnotationData = {}
|
||||||
|
for label, raw_batches in corrections.items():
|
||||||
|
if not isinstance(raw_batches, list):
|
||||||
|
warnings.append(f"Ignoring malformed correction batches for {label!r}")
|
||||||
|
continue
|
||||||
|
for raw_batch in raw_batches:
|
||||||
|
if not isinstance(raw_batch, list):
|
||||||
|
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
|
||||||
|
):
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
result = item["result"]
|
||||||
|
suffix = str(result.get("suffix", ""))
|
||||||
|
if suffix == "_old":
|
||||||
|
continue
|
||||||
|
coordinates = coordinate_index.get((str(label), student_id))
|
||||||
|
issues: list[str] = []
|
||||||
|
if coordinates is None:
|
||||||
|
issues.append("Group coordinates were not found.")
|
||||||
|
pdf_path = _answer_pdf(workspace, student_id, str(label), suffix)
|
||||||
|
if not pdf_path.exists():
|
||||||
|
issues.append(f"Answer PDF not found: {pdf_path}")
|
||||||
|
data.setdefault(student_id, {})[str(label)] = {
|
||||||
|
"pdf_path": pdf_path,
|
||||||
|
"result": _scaled_result(result, coordinates),
|
||||||
|
"coordinates": (
|
||||||
|
(coordinates.minimum, coordinates.maximum)
|
||||||
|
if coordinates is not None
|
||||||
|
else (0, 0)
|
||||||
|
),
|
||||||
|
"issues": issues,
|
||||||
|
}
|
||||||
|
warnings.extend(
|
||||||
|
f"Copie{student_id} {label}: {issue}" for issue in issues
|
||||||
|
)
|
||||||
|
|
||||||
|
if refaire_list is not None:
|
||||||
|
data = _apply_refaire_filter(workspace, data, refaire_list, warnings)
|
||||||
|
if copy_id is not None:
|
||||||
|
data = {copy_id: data[copy_id]} if copy_id in data else {}
|
||||||
|
if not data:
|
||||||
|
warnings.append(f"Copy id {copy_id} was not found in correction.json")
|
||||||
|
return AnnotationLoadResult(data, warnings)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
|
from enum import IntEnum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .workspace import (
|
||||||
|
EvaluationWorkspace,
|
||||||
|
WorkspaceNotFoundError,
|
||||||
|
WorkspaceValidationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExitCode(IntEnum):
|
||||||
|
SUCCESS = 0
|
||||||
|
FAILURE = 1
|
||||||
|
INVALID_ARGUMENTS = 2
|
||||||
|
INVALID_WORKSPACE = 3
|
||||||
|
PARTIAL = 4
|
||||||
|
INTERRUPTED = 130
|
||||||
|
|
||||||
|
|
||||||
|
class CliError(Exception):
|
||||||
|
def __init__(self, message: str, exit_code: ExitCode = ExitCode.FAILURE) -> None:
|
||||||
|
self.exit_code = exit_code
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
def standard_parser(description: str) -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description=description)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="Show a traceback when an unexpected error occurs",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def evaluation_parser(description: str) -> argparse.ArgumentParser:
|
||||||
|
parser = standard_parser(description)
|
||||||
|
parser.add_argument(
|
||||||
|
"evaluation",
|
||||||
|
type=Path,
|
||||||
|
help="Evaluation directory",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def target_parser(description: str) -> argparse.ArgumentParser:
|
||||||
|
parser = standard_parser(description)
|
||||||
|
parser.add_argument(
|
||||||
|
"target",
|
||||||
|
type=Path,
|
||||||
|
help="Evaluation directory or nested file to process",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def evaluation_workspace(
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
repository: str | Path | None = None,
|
||||||
|
) -> EvaluationWorkspace:
|
||||||
|
root = Path(path).expanduser().resolve()
|
||||||
|
if not root.exists():
|
||||||
|
raise CliError(
|
||||||
|
f"Evaluation directory does not exist: {root}",
|
||||||
|
ExitCode.INVALID_WORKSPACE,
|
||||||
|
)
|
||||||
|
if not root.is_dir():
|
||||||
|
raise CliError(
|
||||||
|
f"Evaluation path is not a directory: {root}",
|
||||||
|
ExitCode.INVALID_WORKSPACE,
|
||||||
|
)
|
||||||
|
return EvaluationWorkspace(root, Path(repository) if repository is not None else None)
|
||||||
|
|
||||||
|
|
||||||
|
def workspace_from_args(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
*,
|
||||||
|
repository: str | Path | None = None,
|
||||||
|
) -> EvaluationWorkspace:
|
||||||
|
return evaluation_workspace(args.evaluation, repository=repository)
|
||||||
|
|
||||||
|
|
||||||
|
def workspace_from_target(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
*,
|
||||||
|
repository: str | Path | None = None,
|
||||||
|
) -> tuple[EvaluationWorkspace, Path]:
|
||||||
|
target = Path(args.target).expanduser().resolve()
|
||||||
|
if not target.exists():
|
||||||
|
raise CliError(f"Target does not exist: {target}", ExitCode.INVALID_WORKSPACE)
|
||||||
|
repository_path = Path(repository) if repository is not None else None
|
||||||
|
if target.is_dir() and EvaluationWorkspace.looks_like_evaluation(target):
|
||||||
|
return EvaluationWorkspace(target, repository_path), target
|
||||||
|
workspace = EvaluationWorkspace.discover(target, repository=repository_path)
|
||||||
|
return workspace, target
|
||||||
|
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
parser: argparse.ArgumentParser,
|
||||||
|
argv: Sequence[str] | None,
|
||||||
|
handler: Callable[[argparse.Namespace], int | ExitCode | None],
|
||||||
|
) -> int:
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
result = handler(args)
|
||||||
|
except CliError as exc:
|
||||||
|
print(f"Error: {exc}", file=sys.stderr)
|
||||||
|
return int(exc.exit_code)
|
||||||
|
except (WorkspaceNotFoundError, WorkspaceValidationError) as exc:
|
||||||
|
print(f"Error: {exc}", file=sys.stderr)
|
||||||
|
return int(ExitCode.INVALID_WORKSPACE)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("Interrupted by user.", file=sys.stderr)
|
||||||
|
return int(ExitCode.INTERRUPTED)
|
||||||
|
except Exception as exc: # noqa: BLE001 - executable boundary
|
||||||
|
if getattr(args, "verbose", False):
|
||||||
|
traceback.print_exc()
|
||||||
|
else:
|
||||||
|
print(f"Error: {exc}", file=sys.stderr)
|
||||||
|
return int(ExitCode.FAILURE)
|
||||||
|
return int(ExitCode.SUCCESS if result is None else result)
|
||||||
|
|
||||||
|
|
||||||
|
def stderr(message: Any) -> None:
|
||||||
|
print(message, file=sys.stderr)
|
||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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())
|
||||||
@@ -1,127 +1,54 @@
|
|||||||
import sys
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import json
|
import re
|
||||||
import glob
|
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from PIL import Image
|
import tempfile
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
import matplotlib.colors as mcolors
|
||||||
|
import PIL.ImageOps
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
|
||||||
|
from matplotlib import pyplot as plt
|
||||||
|
from pdf2image import convert_from_path
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from copienator import utils
|
||||||
|
from copienator.configuration import LATEX_ANOT_AFTER, LATEX_ANOT_BEFORE
|
||||||
|
from copienator import (
|
||||||
|
EvaluationWorkspace,
|
||||||
|
ExitCode,
|
||||||
|
atomic_write_json,
|
||||||
|
evaluation_parser,
|
||||||
|
execute,
|
||||||
|
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 copienator.feedback_boxes import valid_feedback_box
|
||||||
|
from copienator.utils import natural_key
|
||||||
|
|
||||||
MARGIN_LEFT = 300
|
MARGIN_LEFT = 300
|
||||||
ANNOT_WIDTH = 600
|
ANNOT_WIDTH = 600
|
||||||
|
|
||||||
# Results is : Copie id -> label -> {pdf_path, gemini_result, coordinates}
|
|
||||||
# Coordinates are the real coordinates (hmin, hmax) of the image in the Group
|
|
||||||
# The gemini_result coordinates should be un-normalized !
|
|
||||||
def make_dictionary(root_dir, refaire=False, refaire_list=[]):
|
|
||||||
correction_path = os.path.join(root_dir, "correction.json")
|
|
||||||
|
|
||||||
# Load correction data
|
|
||||||
try:
|
|
||||||
with open(correction_path, 'r', encoding='utf-8') as f:
|
|
||||||
corrections = json.load(f)
|
|
||||||
except FileNotFoundError:
|
|
||||||
print(f"Error: {correction_path} not found.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Dictionary: keys are IDs
|
def make_dictionary(root_dir, refaire=False, refaire_list=None):
|
||||||
result_data = {}
|
"""Compatibility wrapper used by the annotation-reading scripts."""
|
||||||
|
workspace = EvaluationWorkspace(Path(root_dir))
|
||||||
|
loaded = load_annotation_data(
|
||||||
|
workspace,
|
||||||
|
refaire_list=(refaire_list or []) if refaire else None,
|
||||||
|
)
|
||||||
|
return loaded.data
|
||||||
|
|
||||||
# Iterate through labels and items in correction.json
|
|
||||||
for label, items in corrections.items():
|
|
||||||
items = sum(items, []) # Flatten
|
|
||||||
for item in items:
|
|
||||||
# print(item)
|
|
||||||
student_id = item['id']
|
|
||||||
result_obj = item['result']
|
|
||||||
|
|
||||||
# Find coordinates
|
|
||||||
coordinates = None
|
|
||||||
height,width= None, None
|
|
||||||
label_dir = Path(root_dir) / "Par label" / label
|
|
||||||
|
|
||||||
# Search all json files in Dir/label
|
|
||||||
json_files = glob.glob(os.path.join(label_dir, "*.json"))
|
|
||||||
for jf in json_files:
|
|
||||||
try:
|
|
||||||
with open(jf, 'r', encoding='utf-8') as f:
|
|
||||||
coord_list = json.load(f)
|
|
||||||
# Format: [["id", x, y, width_r, "label"], ...]
|
|
||||||
for entry in coord_list:
|
|
||||||
if entry[0] == student_id:
|
|
||||||
coordinates = (entry[1], entry[2])
|
|
||||||
img_path = os.path.splitext(jf)[0] + ".jpg"
|
|
||||||
with Image.open(img_path) as img:
|
|
||||||
width, height = img.size
|
|
||||||
break
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
if coordinates:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Construct PDF path: Dir/Copie{id}/{label}.pdf
|
|
||||||
pdf_path = Path(root_dir) / "Copies" / f"Copie{student_id}" / f"{label}.pdf"
|
|
||||||
|
|
||||||
# Initialize dictionary structure for this ID if missing
|
|
||||||
if student_id not in result_data:
|
|
||||||
result_data[student_id] = {}
|
|
||||||
|
|
||||||
fb = result_obj.get("feedback", [])
|
|
||||||
for i in range(len(fb)):
|
|
||||||
el = fb[i]
|
|
||||||
if "box_2d" in el and el["box_2d"]:
|
|
||||||
el["box_2d"][0] = (el["box_2d"][0] * height)//1000
|
|
||||||
el["box_2d"][2] = (el["box_2d"][2] * height)//1000
|
|
||||||
el["box_2d"][1] = (el["box_2d"][1] * width)//1000
|
|
||||||
el["box_2d"][3] = (el["box_2d"][3] * width)//1000
|
|
||||||
|
|
||||||
# Populate the object
|
|
||||||
result_data[student_id][label] = {
|
|
||||||
"pdf_path": pdf_path,
|
|
||||||
"result": result_obj,
|
|
||||||
"coordinates": coordinates
|
|
||||||
}
|
|
||||||
|
|
||||||
if refaire:
|
|
||||||
for copie_name, labels_to_redo in refaire_list:
|
|
||||||
sid = copie_name.replace("Copie", "") # Extract "01" from "Copie01"
|
|
||||||
if sid in result_data:
|
|
||||||
# Si des labels à refaire ne sont pas présent dans la correction
|
|
||||||
# On ajoute des dummies
|
|
||||||
if labels_to_redo: # Si la liste est non vide
|
|
||||||
for lbl in labels_to_redo:
|
|
||||||
pdf_path = Path(root_dir) / "Copies" / f"Copie{sid}" / f"{lbl}.pdf"
|
|
||||||
if not Path(pdf_path).exists():
|
|
||||||
print("Debug : asked to refaire", sid, lbl, "but pdf absent")
|
|
||||||
continue
|
|
||||||
result_data[sid][lbl] = {
|
|
||||||
"pdf_path": pdf_path,
|
|
||||||
"result": {
|
|
||||||
"score": 0.0,
|
|
||||||
"confidence": 1.0,
|
|
||||||
"feedback": [],
|
|
||||||
"error": "non traité"
|
|
||||||
},
|
|
||||||
"coordinates": (0,0)
|
|
||||||
}
|
|
||||||
else: # Ce student id n'a jamais été corrigé
|
|
||||||
result_data[sid] = {}
|
|
||||||
for lbl in labels_to_redo:
|
|
||||||
pdf_path = Path(root_dir) / "Copies" / f"Copie{sid}" / f"{lbl}.pdf"
|
|
||||||
if not pdf_path.exists():
|
|
||||||
print("Debug : asked to refaire", sid, lbl, "but pdf absent")
|
|
||||||
continue
|
|
||||||
result_data[sid][lbl] = {
|
|
||||||
"pdf_path": pdf_path,
|
|
||||||
"result": {
|
|
||||||
"score": 0.0,
|
|
||||||
"confidence": 1.0,
|
|
||||||
"feedback": [],
|
|
||||||
"error": "non traité"
|
|
||||||
},
|
|
||||||
"coordinates": (0,0)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result_data
|
|
||||||
|
|
||||||
def make_base_image(pdf_path):
|
def make_base_image(pdf_path):
|
||||||
pages = convert_from_path(pdf_path)
|
pages = convert_from_path(pdf_path)
|
||||||
@@ -139,20 +66,6 @@ def make_base_image(pdf_path):
|
|||||||
current_y += page.height
|
current_y += page.height
|
||||||
return (base_img, total_h, max_w)
|
return (base_img, total_h, max_w)
|
||||||
|
|
||||||
import io
|
|
||||||
import shutil
|
|
||||||
from pdf2image import convert_from_path
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
import matplotlib
|
|
||||||
matplotlib.use('Agg') # Force headless rendering
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
|
|
||||||
# plt.rcParams.update({ "text.usetex": True,
|
|
||||||
# "text.latex.preamble": r"\usepackage{bbold}"})
|
|
||||||
|
|
||||||
import re
|
|
||||||
import textwrap
|
|
||||||
|
|
||||||
def normalize_mathtext(text):
|
def normalize_mathtext(text):
|
||||||
"""
|
"""
|
||||||
Replaces LaTeX shortcuts not supported by Matplotlib's mathtext parser.
|
Replaces LaTeX shortcuts not supported by Matplotlib's mathtext parser.
|
||||||
@@ -261,30 +174,16 @@ def render_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=N
|
|||||||
final_img.alpha_composite(img)
|
final_img.alpha_composite(img)
|
||||||
return final_img
|
return final_img
|
||||||
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
import subprocess
|
|
||||||
import PIL.ImageOps
|
|
||||||
|
|
||||||
|
|
||||||
def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=None, fontsize=19):
|
def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=None, fontsize=19):
|
||||||
dpi = 100
|
dpi = 100
|
||||||
width_in = width_px / dpi
|
width_in = width_px / dpi
|
||||||
line_spacing = int(fontsize * 1.2)
|
line_spacing = int(fontsize * 1.2)
|
||||||
|
|
||||||
# Use the 'standalone' class with 'varwidth' to auto-crop height while restricting width
|
# Use the 'standalone' class with 'varwidth' to auto-crop height while restricting width
|
||||||
latex_template = f"""\\documentclass[varwidth={width_in}in,margin=0.2cm]{{standalone}}
|
header = LATEX_ANOT_BEFORE.substitute(
|
||||||
\\usepackage[utf8]{{inputenc}}
|
width_in=width_in, fontsize=fontsize, line_spacing=line_spacing
|
||||||
\\usepackage[T1]{{fontenc}}
|
)
|
||||||
\\usepackage{{lmodern}} % Enables arbitrary font scaling
|
latex_template = f"{header}{text}{LATEX_ANOT_AFTER}"
|
||||||
\\usepackage{{amsmath, amssymb}}
|
|
||||||
\\usepackage{{commands}}
|
|
||||||
%\\usepackage{{anyfontsize}} % replaced by lmodern
|
|
||||||
\\begin{{document}}
|
|
||||||
\\fontsize{{{fontsize}}}{{{line_spacing}}}\\selectfont
|
|
||||||
{text}
|
|
||||||
\\end{{document}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
tex_path = os.path.join(temp_dir, 'text.tex')
|
tex_path = os.path.join(temp_dir, 'text.tex')
|
||||||
@@ -294,11 +193,12 @@ def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_li
|
|||||||
f.write(latex_template)
|
f.write(latex_template)
|
||||||
|
|
||||||
# Compile to PDF
|
# Compile to PDF
|
||||||
result = subprocess.run(
|
subprocess.run(
|
||||||
['pdflatex', '-interaction=nonstopmode', 'text.tex'],
|
['pdflatex', '-interaction=nonstopmode', 'text.tex'],
|
||||||
cwd=temp_dir,
|
cwd=temp_dir,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not os.path.exists(pdf_path):
|
if not os.path.exists(pdf_path):
|
||||||
@@ -327,12 +227,6 @@ def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_li
|
|||||||
|
|
||||||
return final_img
|
return final_img
|
||||||
|
|
||||||
import io
|
|
||||||
from PIL import Image
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
import matplotlib.colors as mcolors
|
|
||||||
from highlight_text import ax_text
|
|
||||||
|
|
||||||
def color(score):
|
def color(score):
|
||||||
t = max(0.0, min(1.0, float(score) / 4.0))
|
t = max(0.0, min(1.0, float(score) / 4.0))
|
||||||
t = t*1.5 - 0.25
|
t = t*1.5 - 0.25
|
||||||
@@ -341,8 +235,6 @@ def color(score):
|
|||||||
green = 150 * t
|
green = 150 * t
|
||||||
return mcolors.to_hex((red/255, green/255, 0))
|
return mcolors.to_hex((red/255, green/255, 0))
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
def render_score_text(label, score, error, width_px, fontsize=30,
|
def render_score_text(label, score, error, width_px, fontsize=30,
|
||||||
bg_color=(255, 255, 255, 255),
|
bg_color=(255, 255, 255, 255),
|
||||||
with_error=True, id=None):
|
with_error=True, id=None):
|
||||||
@@ -371,7 +263,7 @@ def render_score_text(label, score, error, width_px, fontsize=30,
|
|||||||
try:
|
try:
|
||||||
font_regular = ImageFont.truetype("DejaVuSans.ttf", fontsize)
|
font_regular = ImageFont.truetype("DejaVuSans.ttf", fontsize)
|
||||||
font_bold = ImageFont.truetype("DejaVuSans-Bold.ttf", fontsize)
|
font_bold = ImageFont.truetype("DejaVuSans-Bold.ttf", fontsize)
|
||||||
except IOError:
|
except OSError:
|
||||||
# Fallback for systems without specific TTFs readily available
|
# Fallback for systems without specific TTFs readily available
|
||||||
print("here")
|
print("here")
|
||||||
try:
|
try:
|
||||||
@@ -420,7 +312,6 @@ def compose_label_image(base_img, label, result, hmin,
|
|||||||
if base_img.width < TARGET_MIN_WIDTH:
|
if base_img.width < TARGET_MIN_WIDTH:
|
||||||
total_missing = TARGET_MIN_WIDTH - base_img.width
|
total_missing = TARGET_MIN_WIDTH - base_img.width
|
||||||
left_pad = min(total_missing, MARGIN_LEFT)
|
left_pad = min(total_missing, MARGIN_LEFT)
|
||||||
right_pad = total_missing - left_pad
|
|
||||||
|
|
||||||
new_base = Image.new("RGB", (TARGET_MIN_WIDTH, base_img.height), "white")
|
new_base = Image.new("RGB", (TARGET_MIN_WIDTH, base_img.height), "white")
|
||||||
new_base.paste(base_img, (left_pad, 0))
|
new_base.paste(base_img, (left_pad, 0))
|
||||||
@@ -436,6 +327,16 @@ def compose_label_image(base_img, label, result, hmin,
|
|||||||
|
|
||||||
# Filter deleted items (used by reading_annotations.py)
|
# Filter deleted items (used by reading_annotations.py)
|
||||||
feedbacks = [f for f in feedbacks if "to_delete" not in f]
|
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')]
|
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')]
|
local_fb = [f for f in feedbacks if f.get('box_2d')]
|
||||||
@@ -540,112 +441,123 @@ def compose_label_image(base_img, label, result, hmin,
|
|||||||
|
|
||||||
return final_img, header_height
|
return final_img, header_height
|
||||||
|
|
||||||
from utils import natural_key
|
|
||||||
import concurrent.futures
|
|
||||||
|
|
||||||
def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
|
def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
|
||||||
"""Helper function to process a single student."""
|
"""Helper function to process a single student."""
|
||||||
|
|
||||||
# Prepare output directory: Dir/Anot_CopieID
|
# Prepare output directory: Dir/Anot_CopieID
|
||||||
output_dir = os.path.join(root_dir, "Anot", f"Copie{student_id}")
|
output_dir = Path(root_dir) / "Anot" / f"Copie{student_id}"
|
||||||
|
|
||||||
# Check if already processed (Concat.jpg exists)
|
# Check if already processed (Concat.jpg exists)
|
||||||
concat_path = os.path.join(output_dir, "Concat.jpg")
|
concat_path = output_dir / "Concat.jpg"
|
||||||
if os.path.exists(concat_path) and not overwrite:
|
if concat_path.exists() and not overwrite:
|
||||||
print(f"Skipping Copie {student_id} (Concat.jpg exists)")
|
print(f"Skipping Copie {student_id} (Concat.jpg exists)")
|
||||||
return
|
return "skipped"
|
||||||
|
|
||||||
print("Processing :", student_id)
|
print("Processing :", student_id)
|
||||||
|
problems = False
|
||||||
# Clean folder if re-processing
|
with staged_directory(output_dir) as staging:
|
||||||
if os.path.exists(output_dir):
|
|
||||||
shutil.rmtree(output_dir)
|
|
||||||
os.makedirs(output_dir)
|
|
||||||
|
|
||||||
d_notes = dict.fromkeys(all_labels, "")
|
d_notes = dict.fromkeys(all_labels, "")
|
||||||
label_images = []
|
label_images = []
|
||||||
|
answer_labels = []
|
||||||
# !! Trier par l'ordre des labels plutôt
|
sorted_labels = sorted(labels_data.items(), key=lambda item: natural_key(item[0]))
|
||||||
sorted_labels = sorted(list(labels_data.items()), key=natural_key)
|
|
||||||
|
|
||||||
for label, content in sorted_labels:
|
for label, content in sorted_labels:
|
||||||
# 1. Find PDF path
|
pdf_full_path = content.get('pdf_path')
|
||||||
copie_folder = f"Copie{student_id}"
|
if not pdf_full_path or not Path(pdf_full_path).exists():
|
||||||
pdf_full_path = Path(root_dir) / "Copies" / copie_folder / f"{label}.pdf"
|
|
||||||
|
|
||||||
if not os.path.exists(pdf_full_path):
|
|
||||||
print(f"File not found: {pdf_full_path}")
|
print(f"File not found: {pdf_full_path}")
|
||||||
|
problems = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 2. Convert PDF to Image
|
|
||||||
try:
|
try:
|
||||||
(base_img, _, _) = make_base_image(pdf_full_path)
|
base_img, _, _ = make_base_image(pdf_full_path)
|
||||||
except Exception as e:
|
except Exception as exc: # noqa: BLE001 - PDF/LaTeX backends vary
|
||||||
print(f"Error converting {pdf_full_path}: {e}")
|
print(f"Error converting {pdf_full_path}: {exc}")
|
||||||
|
problems = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
result = content.get('result', {})
|
result = content.get('result', {})
|
||||||
coordinates = content.get('coordinates', (0, 0)) # (hmin, hmax)
|
coordinates = content.get('coordinates', (0, 0))
|
||||||
score = result.get('score', 0)
|
d_notes[label] = str(result.get('score', 0))
|
||||||
d_notes[label] = str(score)
|
final_img, _ = compose_label_image(
|
||||||
|
base_img,
|
||||||
final_img, _ = compose_label_image(base_img, label, result, coordinates[0],
|
label,
|
||||||
|
result,
|
||||||
|
coordinates[0],
|
||||||
with_empty=True,
|
with_empty=True,
|
||||||
render_fn=render_real_latex_text)
|
render_fn=render_real_latex_text,
|
||||||
# 7. Save Image
|
)
|
||||||
save_path = os.path.join(output_dir, f"{label}.jpg")
|
final_img.save(staging / f"{label}.jpg")
|
||||||
final_img.save(save_path)
|
|
||||||
if result.get('error', "") != "empty-answer":
|
if result.get('error', "") != "empty-answer":
|
||||||
label_images.append(final_img)
|
label_images.append(final_img)
|
||||||
|
answer_labels.append(label)
|
||||||
|
|
||||||
# Save scores
|
atomic_write_json(staging / "score.json", d_notes)
|
||||||
with open(os.path.join(output_dir, "score.json"), "w") as f:
|
atomic_write_json(staging / "info.json", build_answer_info(
|
||||||
json.dump(d_notes, f, indent=4)
|
d_notes, labels_data, answer_labels
|
||||||
|
))
|
||||||
# Concatenate
|
|
||||||
if label_images:
|
if label_images:
|
||||||
max_w = max(i.width for i in label_images)
|
max_w = max(image.width for image in label_images)
|
||||||
total_h = sum(i.height for i in label_images)
|
total_h = sum(image.height for image in label_images)
|
||||||
canvas = Image.new('RGB', (max_w, total_h))
|
canvas = Image.new('RGB', (max_w, total_h))
|
||||||
cy = 0
|
current_y = 0
|
||||||
for img in label_images:
|
for image in label_images:
|
||||||
canvas.paste(img, (0, cy))
|
canvas.paste(image, (0, current_y))
|
||||||
cy += img.height
|
current_y += image.height
|
||||||
canvas.save(concat_path)
|
canvas.save(staging / "Concat.jpg")
|
||||||
|
elif labels_data:
|
||||||
|
problems = True
|
||||||
|
return "partial" if problems else "success"
|
||||||
|
|
||||||
|
|
||||||
def process_correction(root_dir, data, all_labels, overwrite=False):
|
def process_correction(root_dir, data, all_labels, overwrite=False):
|
||||||
|
|
||||||
# with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
|
||||||
# # Create a list of futures
|
|
||||||
# futures = []
|
|
||||||
# for student_id, labels in sorted(data.items()):
|
|
||||||
# futures.append(
|
|
||||||
# executor.submit(process_student, student_id, labels, root_dir, all_labels, overwrite)
|
|
||||||
# )
|
|
||||||
|
|
||||||
# # Wait for all threads to complete
|
|
||||||
# concurrent.futures.wait(futures)
|
|
||||||
|
|
||||||
# Ne pas thread cette application
|
# Ne pas thread cette application
|
||||||
# 1. Il faut protéger les appels à matplotlib
|
# 1. Il faut protéger les appels à matplotlib
|
||||||
# 2. tu vas perdre les erreurs
|
# 2. tu vas perdre les erreurs
|
||||||
for student_id, labels in sorted(data.items()):
|
statuses = [
|
||||||
process_student(student_id, labels, root_dir, all_labels, overwrite)
|
process_student(student_id, labels, root_dir, all_labels, overwrite)
|
||||||
|
for student_id, labels in sorted(data.items())
|
||||||
|
]
|
||||||
|
return ExitCode.PARTIAL if "partial" in statuses else ExitCode.SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = evaluation_parser("Generate simple annotated copies.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--overwrite",
|
||||||
|
action="store_true",
|
||||||
|
help="Replace existing student output directories",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
|
||||||
|
workspace.require_files("labels", "correction.json")
|
||||||
|
workspace.require_directories("Copies", "Par label")
|
||||||
|
labels = utils.read_all_labels(workspace.root)
|
||||||
|
loaded = load_annotation_data(workspace)
|
||||||
|
for warning in loaded.warnings:
|
||||||
|
print(f"Warning: {warning}")
|
||||||
|
if not loaded.data:
|
||||||
|
print("Warning: no annotation data was found.")
|
||||||
|
return ExitCode.PARTIAL
|
||||||
|
result = process_correction(
|
||||||
|
workspace.root,
|
||||||
|
loaded.data,
|
||||||
|
labels,
|
||||||
|
overwrite=overwrite,
|
||||||
|
)
|
||||||
|
if loaded.warnings and result == ExitCode.SUCCESS:
|
||||||
|
return ExitCode.PARTIAL
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
return execute(
|
||||||
|
parser,
|
||||||
|
argv,
|
||||||
|
lambda args: run(workspace_from_args(args), overwrite=args.overwrite),
|
||||||
|
)
|
||||||
|
|
||||||
import argparse
|
|
||||||
import utils
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(description="Annotate copies")
|
raise SystemExit(main())
|
||||||
parser.add_argument("root_dir", help="Directory containing the copies")
|
|
||||||
parser.add_argument("--overwrite", action="store_true", help="Reprocess even if Concat.jpg exists")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
root_dir = args.root_dir
|
|
||||||
labels = utils.read_all_labels(root_dir)
|
|
||||||
results = make_dictionary(root_dir)
|
|
||||||
# Results is : Copie id -> label -> {pdf_path, gemini_result, coordinates}
|
|
||||||
# Coordinates are the real coordinates (hmin, hmax) of the image in the Group
|
|
||||||
# print(results,"\n\n\n")
|
|
||||||
process_correction(root_dir, results, labels,overwrite=args.overwrite)
|
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import concurrent.futures
|
||||||
|
import os
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
|
||||||
|
from copienator import (
|
||||||
|
CliError,
|
||||||
|
EvaluationWorkspace,
|
||||||
|
ExitCode,
|
||||||
|
atomic_write_json,
|
||||||
|
atomic_write_text,
|
||||||
|
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 copienator.utils import natural_key
|
||||||
|
|
||||||
|
MAX_HEIGHT_PX = 25000
|
||||||
|
|
||||||
|
|
||||||
|
def render_item(item):
|
||||||
|
student_id, label, content = item
|
||||||
|
pdf_path = Path(content["pdf_path"])
|
||||||
|
if not pdf_path.exists():
|
||||||
|
print(f"Warning: answer PDF not found: {pdf_path}")
|
||||||
|
return None
|
||||||
|
base_image, _, _ = annotating.make_base_image(pdf_path)
|
||||||
|
checkbox_renderer = annotating_with_checks.CheckboxRenderer(label)
|
||||||
|
final_image, header_height = annotating.compose_label_image(
|
||||||
|
base_image,
|
||||||
|
label,
|
||||||
|
content["result"],
|
||||||
|
content["coordinates"][0],
|
||||||
|
draw_callback=checkbox_renderer.callback,
|
||||||
|
more_right=True,
|
||||||
|
with_id=student_id,
|
||||||
|
)
|
||||||
|
if final_image is None:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
student_id,
|
||||||
|
label,
|
||||||
|
final_image,
|
||||||
|
header_height,
|
||||||
|
checkbox_renderer.checkboxes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_batch(batch, prefix, group_id, output_root: Path) -> None:
|
||||||
|
output_dir = output_root / f"{prefix} G{group_id}"
|
||||||
|
print(f"Generating group PDF: {prefix} G{group_id} ({len(batch)} elements)")
|
||||||
|
max_width = max(item[2].width for item in batch)
|
||||||
|
total_height = sum(item[2].height for item in batch)
|
||||||
|
concatenated = Image.new("RGB", (max_width, total_height), "white")
|
||||||
|
draw = ImageDraw.Draw(concatenated)
|
||||||
|
checkbox_map: list[dict[str, Any]] = []
|
||||||
|
bnote_entries: list[dict[str, Any]] = []
|
||||||
|
current_y = 0
|
||||||
|
previous_student = None
|
||||||
|
|
||||||
|
for student_id, label, image, header_height, checkboxes in batch:
|
||||||
|
concatenated.paste(image, (0, current_y))
|
||||||
|
if student_id != previous_student:
|
||||||
|
draw.rectangle([0, current_y, max_width, current_y + 4], fill="purple")
|
||||||
|
previous_student = student_id
|
||||||
|
bnote_entries.append(
|
||||||
|
{
|
||||||
|
"id": student_id,
|
||||||
|
"label": label,
|
||||||
|
"header_height": header_height,
|
||||||
|
"hmin": current_y,
|
||||||
|
"hmax": current_y + image.height,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for item in checkboxes:
|
||||||
|
box = item.get("final_box") or item.get("rel_box")
|
||||||
|
item["global_box"] = [
|
||||||
|
box[0],
|
||||||
|
box[1] + current_y,
|
||||||
|
box[2],
|
||||||
|
box[3] + current_y,
|
||||||
|
]
|
||||||
|
item["student_id"] = student_id
|
||||||
|
checkbox_map.append(item)
|
||||||
|
current_y += image.height
|
||||||
|
|
||||||
|
with staged_directory(output_dir) as staging:
|
||||||
|
atomic_write_json(
|
||||||
|
staging / "bnote.json",
|
||||||
|
{"width": max_width, "height": total_height, "images": bnote_entries},
|
||||||
|
)
|
||||||
|
atomic_write_json(staging / "checkboxes.json", checkbox_map)
|
||||||
|
reference = staging / "Reference.jpg"
|
||||||
|
concatenated.save(reference, quality=90)
|
||||||
|
pdf_path = staging / "Concat.pdf"
|
||||||
|
pdf_canvas = canvas.Canvas(str(pdf_path), pagesize=(max_width, total_height))
|
||||||
|
pdf_canvas.drawImage(
|
||||||
|
str(reference),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
width=max_width,
|
||||||
|
height=total_height,
|
||||||
|
)
|
||||||
|
pdf_canvas.save()
|
||||||
|
|
||||||
|
|
||||||
|
def _initial_label_groups(labels: list[str]) -> str:
|
||||||
|
groups: dict[str, list[str]] = {}
|
||||||
|
for label in labels:
|
||||||
|
key = label.split(" : ")[0] if " : " in label else label
|
||||||
|
groups.setdefault(key, []).append(label)
|
||||||
|
return "".join(",".join(items) + "\n" for items in groups.values())
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
||||||
|
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]] = []
|
||||||
|
unknown: set[str] = set()
|
||||||
|
for line in label_groups.read_text(encoding="utf-8").splitlines():
|
||||||
|
group = [label.strip() for label in line.split(",") if label.strip()]
|
||||||
|
unknown.update(label for label in group if label not in known_labels)
|
||||||
|
if group:
|
||||||
|
groups.append(group)
|
||||||
|
if unknown:
|
||||||
|
raise CliError(
|
||||||
|
"label_groups contains unknown labels: " + ", ".join(sorted(unknown))
|
||||||
|
)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_prefixes(groups: list[list[str]]) -> list[tuple[str, list[str]]]:
|
||||||
|
used: set[str] = set()
|
||||||
|
result: list[tuple[str, list[str]]] = []
|
||||||
|
previous: str | None = None
|
||||||
|
for labels in groups:
|
||||||
|
safe_labels = [label.replace(":", "").strip() for label in labels]
|
||||||
|
base = os.path.commonprefix(safe_labels).strip() or "Group"
|
||||||
|
if base and previous is not None and natural_key(base) < natural_key(previous):
|
||||||
|
candidate = f"{safe_labels[0]}+"
|
||||||
|
if natural_key(candidate) > natural_key(previous):
|
||||||
|
base = candidate
|
||||||
|
prefix = base.removesuffix("i")
|
||||||
|
counter = 2
|
||||||
|
while prefix in used:
|
||||||
|
prefix = f"{base}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
if counter == 2 and previous and previous in prefix:
|
||||||
|
prefix = f"{previous}-{counter}"
|
||||||
|
elif counter == 2:
|
||||||
|
previous = prefix
|
||||||
|
used.add(prefix)
|
||||||
|
result.append((prefix, labels))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_group_state(
|
||||||
|
output_root: Path,
|
||||||
|
prefix: str,
|
||||||
|
) -> tuple[set[tuple[str, str]], int]:
|
||||||
|
existing_items: set[tuple[str, str]] = set()
|
||||||
|
maximum_group = 0
|
||||||
|
if not output_root.is_dir():
|
||||||
|
return existing_items, maximum_group
|
||||||
|
for directory in output_root.iterdir():
|
||||||
|
if not directory.is_dir() or not directory.name.startswith(f"{prefix} G"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
maximum_group = max(maximum_group, int(directory.name.split(" G")[-1]))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
metadata = directory / "bnote.json"
|
||||||
|
if not metadata.exists():
|
||||||
|
continue
|
||||||
|
loaded = read_json(metadata)
|
||||||
|
if not isinstance(loaded, dict):
|
||||||
|
raise TypeError(f"Expected a JSON object in {metadata}")
|
||||||
|
for image in loaded.get("images", []):
|
||||||
|
existing_items.add((str(image["id"]), str(image["label"])))
|
||||||
|
return existing_items, maximum_group
|
||||||
|
|
||||||
|
|
||||||
|
def split_batches(rendered):
|
||||||
|
def split(maximum_height: float):
|
||||||
|
batches = []
|
||||||
|
current = []
|
||||||
|
current_height = 0
|
||||||
|
previous_student = None
|
||||||
|
for item in rendered:
|
||||||
|
student_id = item[0]
|
||||||
|
image_height = item[2].height
|
||||||
|
if (
|
||||||
|
current
|
||||||
|
and current_height + image_height > maximum_height
|
||||||
|
and student_id != previous_student
|
||||||
|
):
|
||||||
|
batches.append(current)
|
||||||
|
current = []
|
||||||
|
current_height = 0
|
||||||
|
current.append(item)
|
||||||
|
current_height += image_height
|
||||||
|
previous_student = student_id
|
||||||
|
if current:
|
||||||
|
batches.append(current)
|
||||||
|
return batches
|
||||||
|
|
||||||
|
strict = split(MAX_HEIGHT_PX)
|
||||||
|
relaxed = split(1.1 * MAX_HEIGHT_PX)
|
||||||
|
return relaxed if len(relaxed) < len(strict) else strict
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_groups(
|
||||||
|
output_root: Path,
|
||||||
|
data,
|
||||||
|
groups: list[list[str]],
|
||||||
|
*,
|
||||||
|
resume: bool,
|
||||||
|
) -> tuple[int, bool]:
|
||||||
|
generated = 0
|
||||||
|
problems = False
|
||||||
|
for prefix, labels in _unique_prefixes(groups):
|
||||||
|
existing_items, maximum_group = (
|
||||||
|
_existing_group_state(output_root, prefix) if resume else (set(), 0)
|
||||||
|
)
|
||||||
|
items = [
|
||||||
|
(student_id, label, student_labels[label])
|
||||||
|
for student_id, student_labels in data.items()
|
||||||
|
for label in labels
|
||||||
|
if label in student_labels and (student_id, label) not in existing_items
|
||||||
|
]
|
||||||
|
if not items:
|
||||||
|
continue
|
||||||
|
items.sort(key=lambda item: (natural_key(item[0]), natural_key(item[1])))
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
rendered = list(executor.map(render_item, items))
|
||||||
|
if any(item is None for item in rendered):
|
||||||
|
problems = True
|
||||||
|
successful = [item for item in rendered if item is not None]
|
||||||
|
for index, batch in enumerate(split_batches(successful), start=1):
|
||||||
|
save_batch(batch, prefix, maximum_group + index, output_root)
|
||||||
|
generated += 1
|
||||||
|
return generated, problems
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
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("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
|
||||||
|
|
||||||
|
try:
|
||||||
|
with staged_directory(output_root) as staging:
|
||||||
|
generated, problems = _generate_groups(
|
||||||
|
staging,
|
||||||
|
loaded.data,
|
||||||
|
groups,
|
||||||
|
resume=False,
|
||||||
|
)
|
||||||
|
if generated == 0 or problems or loaded.warnings:
|
||||||
|
raise IncompleteGroupedOutput
|
||||||
|
except IncompleteGroupedOutput:
|
||||||
|
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)
|
||||||
|
generated, problems = _generate_groups(
|
||||||
|
output_root,
|
||||||
|
loaded.data,
|
||||||
|
groups,
|
||||||
|
resume=True,
|
||||||
|
)
|
||||||
|
if problems:
|
||||||
|
return ExitCode.PARTIAL
|
||||||
|
if generated == 0:
|
||||||
|
print("No new grouped annotations were required.")
|
||||||
|
return ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = evaluation_parser("Generate annotated PDFs grouped by labels.")
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
return execute(
|
||||||
|
parser,
|
||||||
|
argv,
|
||||||
|
lambda args: run(
|
||||||
|
workspace_from_args(args), overwrite=args.overwrite, refaire=args.refaire
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import concurrent.futures
|
||||||
|
import re
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
|
||||||
|
from PIL import Image, ImageFont
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
|
||||||
|
from copienator import (
|
||||||
|
CliError,
|
||||||
|
EvaluationWorkspace,
|
||||||
|
ExitCode,
|
||||||
|
atomic_write_json,
|
||||||
|
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 copienator.utils import natural_key
|
||||||
|
|
||||||
|
BOX_SIZE = 30
|
||||||
|
SCORE_BOX_SIZE = 40
|
||||||
|
SCORES = [value * 0.5 for value in range(10)]
|
||||||
|
EXPECTED_OUTPUTS = ("bnote.json", "checkboxes.json", "Reference.jpg", "Concat.pdf")
|
||||||
|
|
||||||
|
try:
|
||||||
|
CHECKBOX_FONT = ImageFont.truetype("DejaVuSans.ttf", 20)
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
CHECKBOX_FONT = ImageFont.truetype("arial.ttf", 20)
|
||||||
|
except OSError:
|
||||||
|
CHECKBOX_FONT = ImageFont.load_default()
|
||||||
|
|
||||||
|
|
||||||
|
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.rectangle([x, y, x + size, y + size], fill=fill, outline="black", width=2)
|
||||||
|
return [x, y, x + size, y + size]
|
||||||
|
|
||||||
|
|
||||||
|
class CheckboxRenderer:
|
||||||
|
def __init__(self, label_name):
|
||||||
|
self.label = label_name
|
||||||
|
self.checkboxes = []
|
||||||
|
|
||||||
|
def callback(self, kind, draw, pos, meta):
|
||||||
|
if kind == "header_item":
|
||||||
|
if meta.get("type") == "score":
|
||||||
|
start_x = pos["w"] + 20
|
||||||
|
for value in SCORES:
|
||||||
|
box = draw_checkbox(
|
||||||
|
draw,
|
||||||
|
start_x,
|
||||||
|
pos["y"] + 25,
|
||||||
|
SCORE_BOX_SIZE,
|
||||||
|
str(value),
|
||||||
|
)
|
||||||
|
self.checkboxes.append(
|
||||||
|
{
|
||||||
|
"type": "score",
|
||||||
|
"label": self.label,
|
||||||
|
"value": value,
|
||||||
|
"rel_box": box,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
start_x += SCORE_BOX_SIZE + 45
|
||||||
|
start_x += SCORE_BOX_SIZE + 60
|
||||||
|
box = draw_checkbox(
|
||||||
|
draw,
|
||||||
|
start_x,
|
||||||
|
pos["y"] + 25,
|
||||||
|
SCORE_BOX_SIZE,
|
||||||
|
"clr",
|
||||||
|
)
|
||||||
|
self.checkboxes.append(
|
||||||
|
{"type": "clear_all", "label": self.label, "rel_box": box}
|
||||||
|
)
|
||||||
|
elif meta.get("type") == "global_fb":
|
||||||
|
box = draw_checkbox(
|
||||||
|
draw,
|
||||||
|
pos["w"] - BOX_SIZE - 5,
|
||||||
|
pos["y"] + 5,
|
||||||
|
BOX_SIZE,
|
||||||
|
)
|
||||||
|
self.checkboxes.append(
|
||||||
|
{
|
||||||
|
"type": "del_global",
|
||||||
|
"label": self.label,
|
||||||
|
"index": meta["index"],
|
||||||
|
"rel_box": box,
|
||||||
|
"text_preview": meta["data"]["text"][:20],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif kind == "local_rect":
|
||||||
|
rectangle = pos["box"]
|
||||||
|
box = draw_checkbox(
|
||||||
|
draw,
|
||||||
|
rectangle[2] - BOX_SIZE,
|
||||||
|
rectangle[1],
|
||||||
|
BOX_SIZE,
|
||||||
|
)
|
||||||
|
self.checkboxes.append(
|
||||||
|
{
|
||||||
|
"type": "del_local_rect",
|
||||||
|
"label": self.label,
|
||||||
|
"index": meta["index"],
|
||||||
|
"final_box": box,
|
||||||
|
"text_preview": meta["data"]["text"][:20],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif kind == "local_text":
|
||||||
|
box = draw_checkbox(
|
||||||
|
draw,
|
||||||
|
pos["x"] + pos["w"] - BOX_SIZE,
|
||||||
|
pos["y"],
|
||||||
|
BOX_SIZE,
|
||||||
|
)
|
||||||
|
self.checkboxes.append(
|
||||||
|
{
|
||||||
|
"type": "del_local",
|
||||||
|
"label": self.label,
|
||||||
|
"index": meta["index"],
|
||||||
|
"final_box": box,
|
||||||
|
"text_preview": meta["data"]["text"][:20],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _output_complete(output_dir: Path) -> bool:
|
||||||
|
return all((output_dir / name).is_file() for name in EXPECTED_OUTPUTS)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_student(
|
||||||
|
workspace: EvaluationWorkspace,
|
||||||
|
student_id: str,
|
||||||
|
labels: dict[str, dict[str, Any]],
|
||||||
|
*,
|
||||||
|
overwrite: bool,
|
||||||
|
output_mode: str,
|
||||||
|
output_root: Path | None = None,
|
||||||
|
) -> str:
|
||||||
|
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"
|
||||||
|
|
||||||
|
print(f"Generating checkable PDF for: {student_id}")
|
||||||
|
label_images: list[Image.Image] = []
|
||||||
|
checkbox_groups: list[list[dict[str, Any]]] = []
|
||||||
|
bnote_entries: list[dict[str, Any]] = []
|
||||||
|
problems = False
|
||||||
|
|
||||||
|
for label, content in sorted(labels.items(), key=lambda item: natural_key(item[0])):
|
||||||
|
pdf_path = Path(content["pdf_path"])
|
||||||
|
if not pdf_path.exists():
|
||||||
|
print(f"Warning: answer PDF not found: {pdf_path}")
|
||||||
|
problems = True
|
||||||
|
continue
|
||||||
|
base_image, _, _ = annotating.make_base_image(pdf_path)
|
||||||
|
checkbox_renderer = CheckboxRenderer(label)
|
||||||
|
final_image, header_height = annotating.compose_label_image(
|
||||||
|
base_image,
|
||||||
|
label,
|
||||||
|
content["result"],
|
||||||
|
content["coordinates"][0],
|
||||||
|
draw_callback=checkbox_renderer.callback,
|
||||||
|
)
|
||||||
|
if final_image is None:
|
||||||
|
problems = True
|
||||||
|
continue
|
||||||
|
label_images.append(final_image)
|
||||||
|
checkbox_groups.append(checkbox_renderer.checkboxes)
|
||||||
|
bnote_entries.append(
|
||||||
|
{
|
||||||
|
"id": student_id,
|
||||||
|
"label": label,
|
||||||
|
"header_height": header_height,
|
||||||
|
"img_h": final_image.height,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not label_images:
|
||||||
|
print(f"Warning: no annotations could be rendered for Copie{student_id}")
|
||||||
|
return "partial"
|
||||||
|
|
||||||
|
max_width = max(image.width for image in label_images)
|
||||||
|
total_height = sum(image.height for image in label_images)
|
||||||
|
concatenated = Image.new("RGB", (max_width, total_height), "white")
|
||||||
|
checkbox_map: list[dict[str, Any]] = []
|
||||||
|
current_y = 0
|
||||||
|
for index, (image, checkboxes) in enumerate(
|
||||||
|
zip(label_images, checkbox_groups, strict=True)
|
||||||
|
):
|
||||||
|
concatenated.paste(image, (0, current_y))
|
||||||
|
bnote_entries[index]["hmin"] = current_y
|
||||||
|
bnote_entries[index]["hmax"] = current_y + image.height
|
||||||
|
del bnote_entries[index]["img_h"]
|
||||||
|
for item in checkboxes:
|
||||||
|
box = item.get("final_box") or item.get("rel_box")
|
||||||
|
item["global_box"] = [
|
||||||
|
box[0],
|
||||||
|
box[1] + current_y,
|
||||||
|
box[2],
|
||||||
|
box[3] + current_y,
|
||||||
|
]
|
||||||
|
checkbox_map.append(item)
|
||||||
|
current_y += image.height
|
||||||
|
|
||||||
|
with staged_directory(output_dir) as staging:
|
||||||
|
atomic_write_json(
|
||||||
|
staging / "bnote.json",
|
||||||
|
{"width": max_width, "height": total_height, "images": bnote_entries},
|
||||||
|
)
|
||||||
|
atomic_write_json(staging / "checkboxes.json", checkbox_map)
|
||||||
|
reference = staging / "Reference.jpg"
|
||||||
|
concatenated.save(reference, quality=90)
|
||||||
|
pdf_path = staging / "Concat.pdf"
|
||||||
|
pdf_canvas = canvas.Canvas(str(pdf_path), pagesize=(max_width, total_height))
|
||||||
|
pdf_canvas.drawImage(
|
||||||
|
str(reference),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
width=max_width,
|
||||||
|
height=total_height,
|
||||||
|
)
|
||||||
|
pdf_canvas.save()
|
||||||
|
return "partial" if problems else "success"
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_id_from_target(workspace: EvaluationWorkspace, target: Path) -> str | None:
|
||||||
|
if target == workspace.root:
|
||||||
|
return None
|
||||||
|
match = re.search(r"Copie(\d+)", target.name)
|
||||||
|
if match is None:
|
||||||
|
raise CliError(f"Could not extract a copy id from target: {target}")
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_refaire(workspace: EvaluationWorkspace):
|
||||||
|
workspace.require_files("refaire.json")
|
||||||
|
loaded = read_json(workspace.refaire_file)
|
||||||
|
if not isinstance(loaded, list):
|
||||||
|
raise CliError("refaire.json must contain a JSON array")
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
def run(
|
||||||
|
workspace: EvaluationWorkspace,
|
||||||
|
target: Path,
|
||||||
|
*,
|
||||||
|
overwrite: bool = False,
|
||||||
|
refaire: bool = False,
|
||||||
|
) -> ExitCode:
|
||||||
|
workspace.require_files("labels", "correction.json")
|
||||||
|
workspace.require_directories("Copies", "Par label")
|
||||||
|
utils.read_all_labels(workspace.root)
|
||||||
|
copy_id = _copy_id_from_target(workspace, target)
|
||||||
|
refaire_list = _load_refaire(workspace) if refaire else None
|
||||||
|
loaded = load_annotation_data(
|
||||||
|
workspace,
|
||||||
|
refaire_list=refaire_list,
|
||||||
|
copy_id=None if refaire else copy_id,
|
||||||
|
)
|
||||||
|
for warning in loaded.warnings:
|
||||||
|
print(f"Warning: {warning}")
|
||||||
|
if not loaded.data:
|
||||||
|
print("Warning: no annotation data was found.")
|
||||||
|
return ExitCode.PARTIAL
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
executor.submit(
|
||||||
|
_render_student,
|
||||||
|
workspace,
|
||||||
|
student_id,
|
||||||
|
labels,
|
||||||
|
overwrite=overwrite,
|
||||||
|
output_mode=output_mode,
|
||||||
|
)
|
||||||
|
for student_id, labels in tasks
|
||||||
|
]
|
||||||
|
for future in futures:
|
||||||
|
statuses.append(future.result())
|
||||||
|
if loaded.warnings or "partial" in statuses:
|
||||||
|
return ExitCode.PARTIAL
|
||||||
|
return ExitCode.SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
"--refaire",
|
||||||
|
action="store_true",
|
||||||
|
help="Process only entries from refaire.json",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
|
||||||
|
def handler(args: argparse.Namespace) -> ExitCode:
|
||||||
|
workspace, target = workspace_from_target(args)
|
||||||
|
return run(
|
||||||
|
workspace,
|
||||||
|
target,
|
||||||
|
overwrite=args.overwrite,
|
||||||
|
refaire=args.refaire,
|
||||||
|
)
|
||||||
|
|
||||||
|
return execute(parser, argv, handler)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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 n’est 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())
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pypdf import PdfReader, PdfWriter
|
||||||
|
|
||||||
|
from copienator import (
|
||||||
|
EvaluationWorkspace,
|
||||||
|
ExitCode,
|
||||||
|
execute,
|
||||||
|
standard_parser,
|
||||||
|
workspace_from_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_pdfs(directory: Path) -> list[Path]:
|
||||||
|
directory = directory.expanduser().resolve()
|
||||||
|
if not directory.is_dir():
|
||||||
|
raise NotADirectoryError(f"Dossier introuvable : {directory}")
|
||||||
|
return sorted(
|
||||||
|
(
|
||||||
|
path
|
||||||
|
for path in directory.glob("*.pdf")
|
||||||
|
if path.name.casefold() not in {"enonce.pdf", "énoncé.pdf"}
|
||||||
|
),
|
||||||
|
key=lambda path: path.name.casefold(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_pdf(path: Path) -> None:
|
||||||
|
temporary = path.with_name(f".{path.stem}.rotate-{uuid.uuid4().hex}.pdf")
|
||||||
|
try:
|
||||||
|
with path.open("rb") as source, temporary.open("wb") as destination:
|
||||||
|
reader = PdfReader(source)
|
||||||
|
writer = PdfWriter()
|
||||||
|
for page in reader.pages:
|
||||||
|
writer.add_page(page.rotate(180))
|
||||||
|
if reader.metadata:
|
||||||
|
metadata = {
|
||||||
|
str(key): str(value)
|
||||||
|
for key, value in reader.metadata.items()
|
||||||
|
if value is not None
|
||||||
|
}
|
||||||
|
writer.add_metadata(metadata)
|
||||||
|
writer.write(destination)
|
||||||
|
temporary.replace(path)
|
||||||
|
finally:
|
||||||
|
if temporary.exists():
|
||||||
|
temporary.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_all(directory: Path) -> int:
|
||||||
|
files = copy_pdfs(directory)
|
||||||
|
for path in files:
|
||||||
|
rotate_pdf(path)
|
||||||
|
print(f"Rotated: {path.name}")
|
||||||
|
if not files:
|
||||||
|
print("No PDF copies found.")
|
||||||
|
return len(files)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_all(directory: Path) -> list[tuple[Path, Path]]:
|
||||||
|
directory = directory.expanduser().resolve()
|
||||||
|
files = copy_pdfs(directory)
|
||||||
|
width = max(2, len(str(len(files))))
|
||||||
|
plan = [
|
||||||
|
(source, directory / f"Copie{index:0{width}d}.pdf")
|
||||||
|
for index, source in enumerate(files, start=1)
|
||||||
|
]
|
||||||
|
staged: list[tuple[Path, Path, Path]] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for source, destination in plan:
|
||||||
|
temporary = directory / f".copienator-rename-{uuid.uuid4().hex}.pdf"
|
||||||
|
source.replace(temporary)
|
||||||
|
staged.append((source, temporary, destination))
|
||||||
|
|
||||||
|
completed: list[tuple[Path, Path, Path]] = []
|
||||||
|
try:
|
||||||
|
for source, temporary, destination in staged:
|
||||||
|
temporary.replace(destination)
|
||||||
|
completed.append((source, temporary, destination))
|
||||||
|
print(f"Renamed: {source.name} -> {destination.name}")
|
||||||
|
except OSError:
|
||||||
|
for _source, temporary, destination in completed:
|
||||||
|
if destination.exists():
|
||||||
|
destination.replace(temporary)
|
||||||
|
raise
|
||||||
|
except OSError:
|
||||||
|
for source, temporary, _destination in staged:
|
||||||
|
if temporary.exists():
|
||||||
|
temporary.replace(source)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not plan:
|
||||||
|
print("No PDF copies found.")
|
||||||
|
return [(source, destination) for source, _temporary, destination in staged]
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = standard_parser("Prepare scanned PDF copies.")
|
||||||
|
subparsers = parser.add_subparsers(dest="operation", required=True)
|
||||||
|
for operation in ("rotate", "rename"):
|
||||||
|
subparser = subparsers.add_parser(operation)
|
||||||
|
subparser.add_argument("evaluation", type=Path, help="Evaluation directory")
|
||||||
|
subparser.add_argument(
|
||||||
|
"--verbose",
|
||||||
|
action="store_true",
|
||||||
|
default=argparse.SUPPRESS,
|
||||||
|
help="Show a traceback when an unexpected error occurs",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def run(workspace: EvaluationWorkspace, *, operation: str) -> ExitCode:
|
||||||
|
if operation == "rotate":
|
||||||
|
rotate_all(workspace.root)
|
||||||
|
else:
|
||||||
|
rename_all(workspace.root)
|
||||||
|
return ExitCode.SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
return execute(
|
||||||
|
parser,
|
||||||
|
argv,
|
||||||
|
lambda args: run(workspace_from_args(args), operation=args.operation),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
||||||
@@ -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 l’analyse. 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())
|
||||||
@@ -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 n’a é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 l’analyse. 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())
|
||||||
@@ -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 l’enregistrement : {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,56 +1,26 @@
|
|||||||
import sys
|
from __future__ import annotations
|
||||||
import os
|
|
||||||
|
import argparse
|
||||||
import glob
|
import glob
|
||||||
import json
|
import json
|
||||||
import urllib.request
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import urllib.request
|
||||||
import tempfile
|
from collections.abc import Sequence
|
||||||
import shutil
|
from uuid import uuid4
|
||||||
|
|
||||||
def compile_to_pdf(text, output_pdf_path): # 21 cm + 3.8 (dimension de la marge de gauche)
|
from copienator import (
|
||||||
"""Wraps text in a standalone template and compiles it to PDF."""
|
CliError,
|
||||||
latex_template = f"""\\documentclass[varwidth=24.8cm,margin=0.4cm]{{standalone}}
|
EvaluationWorkspace,
|
||||||
\\usepackage[utf8]{{inputenc}}
|
ExitCode,
|
||||||
\\usepackage[T1]{{fontenc}}
|
atomic_write_text,
|
||||||
\\usepackage{{lmodern}}
|
evaluation_parser,
|
||||||
\\usepackage{{amsmath, amssymb}}
|
execute,
|
||||||
\\usepackage{{commands}}
|
workspace_from_args,
|
||||||
\\usepackage{{graphicx}}
|
)
|
||||||
\\usepackage{{enumitem}}
|
from copienator.platform import WindowsLabelError, validate_windows_labels
|
||||||
\\begin{{document}}
|
from copienator.utils import compile_to_pdf
|
||||||
\\begin{{minipage}}{{24.8cm}}
|
|
||||||
{text}
|
|
||||||
\\end{{minipage}}
|
|
||||||
\\end{{document}}
|
|
||||||
"""
|
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
|
||||||
tex_filename = 'text.tex'
|
|
||||||
pdf_filename = 'text.pdf'
|
|
||||||
tex_path = os.path.join(temp_dir, tex_filename)
|
|
||||||
|
|
||||||
with open(tex_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write(latex_template)
|
|
||||||
|
|
||||||
# Set TEXINPUTS so pdflatex can find commands.sty if it's in the current dir
|
|
||||||
# env = os.environ.copy()
|
|
||||||
# current_dir = os.getcwd()
|
|
||||||
# env['TEXINPUTS'] = f".:{current_dir}:"
|
|
||||||
|
|
||||||
try:
|
|
||||||
subprocess.run(
|
|
||||||
['pdflatex', '-interaction=nonstopmode', tex_filename],
|
|
||||||
cwd=temp_dir,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False
|
|
||||||
)
|
|
||||||
|
|
||||||
generated_pdf = os.path.join(temp_dir, pdf_filename)
|
|
||||||
if os.path.exists(generated_pdf):
|
|
||||||
shutil.move(generated_pdf, output_pdf_path)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Compilation error for {output_pdf_path}: {e}")
|
|
||||||
|
|
||||||
def fetch_and_save_sub_text(ex_id, indices, label, text_path):
|
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"""
|
"""Fetches text for a specific sub-question and saves it to Text/{label}.tex"""
|
||||||
@@ -70,6 +40,7 @@ def fetch_and_save_sub_text(ex_id, indices, label, text_path):
|
|||||||
compile_to_pdf(content, pdf_file)
|
compile_to_pdf(content, pdf_file)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error fetching sub-text from {url}: {e}")
|
print(f"Error fetching sub-text from {url}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
def fetch_and_save_sub_sol(ex_id, indices, label, sol_path):
|
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"""
|
"""Fetches text for a specific sub-question and saves it to Text/{label}.tex"""
|
||||||
@@ -89,6 +60,7 @@ def fetch_and_save_sub_sol(ex_id, indices, label, sol_path):
|
|||||||
compile_to_pdf(content, pdf_file)
|
compile_to_pdf(content, pdf_file)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error fetching sub-text from {url}: {e}")
|
print(f"Error fetching sub-text from {url}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
ROMANS_CAP = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"]
|
ROMANS_CAP = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"]
|
||||||
@@ -168,32 +140,41 @@ def save_split_content(text, path, base_fname, problem):
|
|||||||
f.write(chunk)
|
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
|
# 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:
|
if not tex_files:
|
||||||
print(f"No .tex file found in {directory}. Looking in /Staging/Interro/")
|
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")
|
tex_path = os.path.join(os.path.expanduser("~"), "Prépa/Staging/Interro", f"{int_name}.tex")
|
||||||
if os.path.exists(tex_path):
|
if os.path.exists(tex_path):
|
||||||
tex_file = tex_path
|
tex_file = tex_path
|
||||||
else:
|
else:
|
||||||
print("Not found in ", tex_path)
|
raise CliError(
|
||||||
return
|
f"No .tex input found in {workspace.root}",
|
||||||
|
ExitCode.INVALID_WORKSPACE,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
tex_file = tex_files[0]
|
tex_file = tex_files[0]
|
||||||
|
|
||||||
# Prepare output directories
|
# Prepare output directories
|
||||||
paths = {
|
paths = {
|
||||||
'Text': os.path.join(directory, "Text"),
|
'Text': os.path.join(directory, "Text"),
|
||||||
|
'Text2': os.path.join(directory, "Text2"),
|
||||||
'Sol': os.path.join(directory, "Sol"),
|
'Sol': os.path.join(directory, "Sol"),
|
||||||
|
'Sol2': os.path.join(directory, "Sol2"),
|
||||||
'Persp': os.path.join(directory, "Persp")
|
'Persp': os.path.join(directory, "Persp")
|
||||||
}
|
}
|
||||||
for p in paths.values():
|
for p in paths.values():
|
||||||
os.makedirs(p, exist_ok=True)
|
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
|
current_ex_num = 1
|
||||||
|
had_errors = False
|
||||||
|
exercise_groups = []
|
||||||
|
|
||||||
# Read entirely to allow chunking
|
# Read entirely to allow chunking
|
||||||
with open(tex_file, 'r', encoding='utf-8') as f_in:
|
with open(tex_file, 'r', encoding='utf-8') as f_in:
|
||||||
@@ -201,8 +182,11 @@ def process_directory(directory):
|
|||||||
|
|
||||||
# Split by the specific SHEETINFO tag
|
# Split by the specific SHEETINFO tag
|
||||||
blocks = content.split("%%SHEETINFO :")
|
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)
|
# Skip blocks[0] (content before first SHEETINFO)
|
||||||
for block in blocks[1:]:
|
for block in blocks[1:]:
|
||||||
parts_line = block.split("\n", 1)
|
parts_line = block.split("\n", 1)
|
||||||
@@ -216,6 +200,7 @@ def process_directory(directory):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(json_str)
|
data = json.loads(json_str)
|
||||||
|
block_labels = []
|
||||||
# Construct 'ids' parameter
|
# Construct 'ids' parameter
|
||||||
ex_id = str(data['id'])
|
ex_id = str(data['id'])
|
||||||
selection = data.get('select')
|
selection = data.get('select')
|
||||||
@@ -231,16 +216,18 @@ def process_directory(directory):
|
|||||||
indexes = data.get('indexes', [])
|
indexes = data.get('indexes', [])
|
||||||
if not indexes:
|
if not indexes:
|
||||||
label = f"Ex {current_ex_num}"
|
label = f"Ex {current_ex_num}"
|
||||||
f_labels.write(f"{label}\n")
|
validate_windows_labels([label])
|
||||||
fetch_and_save_sub_text(ids, [], label, paths['Text'])
|
block_labels.append(label)
|
||||||
fetch_and_save_sub_sol(ids, [], label, paths['Sol'])
|
fetch_and_save_sub_text(ids, [], label, paths['Text2'])
|
||||||
|
fetch_and_save_sub_sol(ids, [], label, paths['Sol2'])
|
||||||
else:
|
else:
|
||||||
for item in indexes:
|
for item in indexes:
|
||||||
suffix = format_indices(item['indices'], problem)
|
suffix = format_indices(item['indices'], problem)
|
||||||
label = f"Ex {current_ex_num}" + (f" : {suffix}" if suffix else "")
|
label = f"Ex {current_ex_num}" + (f" : {suffix}" if suffix else "")
|
||||||
f_labels.write(f"{label}\n")
|
validate_windows_labels([label])
|
||||||
fetch_and_save_sub_text(ids, item['indices'], label, paths['Text'])
|
block_labels.append(label)
|
||||||
fetch_and_save_sub_sol(ids, item['indices'], label, paths['Sol'])
|
fetch_and_save_sub_text(ids, item['indices'], label, paths['Text2'])
|
||||||
|
fetch_and_save_sub_sol(ids, item['indices'], label, paths['Sol2'])
|
||||||
|
|
||||||
|
|
||||||
# Construct URL (append pb=true if \Roman matched)
|
# Construct URL (append pb=true if \Roman matched)
|
||||||
@@ -281,16 +268,34 @@ def process_directory(directory):
|
|||||||
save_split_content(s_text, paths['Sol'], base_filename, problem)
|
save_split_content(s_text, paths['Sol'], base_filename, problem)
|
||||||
save_split_content(p_text, paths['Persp'], 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
|
current_ex_num += 1
|
||||||
|
|
||||||
|
except WindowsLabelError:
|
||||||
|
labels_staging.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
print(f"Error decoding JSON in block: {json_str}")
|
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}")
|
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 __name__ == "__main__":
|
||||||
if len(sys.argv) < 2:
|
raise SystemExit(main())
|
||||||
print("Usage: python script.py <Dir>")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
process_directory(sys.argv[1])
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import argparse
|
||||||
|
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 EXPORT_DIR
|
||||||
|
from copienator.platform import replace_with_link_or_copy
|
||||||
|
|
||||||
|
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
|
||||||
|
|
||||||
|
|
||||||
|
def export_directory(
|
||||||
|
workspace: EvaluationWorkspace,
|
||||||
|
source_dir_name: str,
|
||||||
|
) -> ExitCode:
|
||||||
|
workspace.require_directories(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()]
|
||||||
|
if source_dir_name == "BGnot" and subdirs:
|
||||||
|
all_start_with_copie = all(directory.name.startswith("Copie") for directory in subdirs)
|
||||||
|
if not all_start_with_copie:
|
||||||
|
subdirs = [directory for directory in subdirs if not directory.name.startswith("Copie")]
|
||||||
|
|
||||||
|
missing_outputs = 0
|
||||||
|
for subdir in subdirs:
|
||||||
|
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"{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
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
*,
|
||||||
|
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:
|
||||||
|
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())
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,954 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
from collections.abc import 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 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
|
||||||
|
while i < len(s1) and i < len(s2) and s1[i] == s2[i]:
|
||||||
|
i += 1
|
||||||
|
lcp = s1[:i]
|
||||||
|
if ')' in s1 or ')' in s2:
|
||||||
|
last_paren = lcp.rfind(')')
|
||||||
|
if last_paren != -1:
|
||||||
|
return lcp[:last_paren + 1]
|
||||||
|
return lcp
|
||||||
|
|
||||||
|
|
||||||
|
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="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]
|
||||||
|
|
||||||
|
# --- Modèles pour la Requête 2 ---
|
||||||
|
class SolutionOnlyItem(BaseModel):
|
||||||
|
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]
|
||||||
|
|
||||||
|
# --- Modèles pour la Requête 3 ---
|
||||||
|
class ExtractedContext(BaseModel):
|
||||||
|
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]
|
||||||
|
|
||||||
|
# --- Modèles pour la Requête 4 (Barèmes) ---
|
||||||
|
class RubricItem(BaseModel):
|
||||||
|
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]
|
||||||
|
|
||||||
|
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é 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 point si les hypothèses d'un théorème ou d'une question précédente ne sont pas vérifiées.
|
||||||
|
|
||||||
|
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) ---
|
||||||
|
class QuestionItem(BaseModel):
|
||||||
|
label: str
|
||||||
|
question_content: str
|
||||||
|
solution_content: str
|
||||||
|
|
||||||
|
class ContextItem(BaseModel):
|
||||||
|
target_question_label: str
|
||||||
|
last_question_label: str
|
||||||
|
content: str # Juste une string encapsulée pour le différencier facilement
|
||||||
|
|
||||||
|
class ExamExtraction(BaseModel):
|
||||||
|
items: list[QuestionItem | ContextItem] # Liste mixte
|
||||||
|
|
||||||
|
class GroupedExamExtraction(BaseModel):
|
||||||
|
groups: list[list[QuestionItem | ContextItem]]
|
||||||
|
|
||||||
|
PROMPT_1 = """Je te fournis :
|
||||||
|
1. Le PDF d'un examen (`enonce.pdf`).
|
||||||
|
2. Le code source de ses questions (fichier `enonce`).
|
||||||
|
|
||||||
|
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 = """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`).
|
||||||
|
|
||||||
|
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 = """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`).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Par exemple, dans ce code LaTeX :
|
||||||
|
|
||||||
|
\\item Soient N et M deux matrices qui commutent.
|
||||||
|
\\begin{itemize}
|
||||||
|
\\item Montrer que N et M ont un vecteur propre commun.
|
||||||
|
\\item Montrer que N et M sont simultanément trigonalisables.
|
||||||
|
\\end{itemize}
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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 | None:
|
||||||
|
for ext in [".org", ".tex"]:
|
||||||
|
path = folder / f"{base_name}{ext}"
|
||||||
|
if path.is_file():
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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 d’examen en groupes cohérents pour la correction "
|
||||||
|
"et l’annotation, selon leurs dépendances et leur contexte commun. "
|
||||||
|
"Ne mélange pas des exercices différents. Conserve l’ordre 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 l’examen, 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"
|
||||||
|
cache_dir.mkdir(exist_ok=True)
|
||||||
|
tmp_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
cache_q_file = cache_dir / "gemini_questions.json"
|
||||||
|
cache_s_file = cache_dir / "gemini_solutions.json"
|
||||||
|
cache_c_file = cache_dir / "gemini_context.json"
|
||||||
|
|
||||||
|
# 1. Resolve files
|
||||||
|
pdf_path = folder / "enonce.pdf"
|
||||||
|
enonce_path = find_file(folder, "enonce")
|
||||||
|
correction_path = find_file(folder, "correction")
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
if not pdf_path.is_file(): missing.append("enonce.pdf")
|
||||||
|
if not enonce_path: missing.append("enonce.org or enonce.tex")
|
||||||
|
if not correction_path: missing.append("correction.org or correction.tex")
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
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")
|
||||||
|
|
||||||
|
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
|
||||||
|
# ==========================================
|
||||||
|
contents_1 = [
|
||||||
|
types.Content(
|
||||||
|
role="user",
|
||||||
|
parts=[
|
||||||
|
types.Part.from_text(text=PROMPT_1),
|
||||||
|
types.Part.from_bytes(data=pdf_bytes, mime_type="application/pdf"),
|
||||||
|
types.Part.from_text(text=f"--- ENONCE SOURCE ({enonce_path.name}) ---\n{enonce_text}"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if cache_q_file.is_file() and not restart:
|
||||||
|
print("Loading cached questions from Cache/gemini_questions.json...")
|
||||||
|
response_q_text = cache_q_file.read_text(encoding="utf-8")
|
||||||
|
else:
|
||||||
|
print("Sending request 1 (Questions) to Gemini...")
|
||||||
|
response_q = client.models.generate_content(
|
||||||
|
model=MODEL_ID,
|
||||||
|
contents=contents_1,
|
||||||
|
config=config_1
|
||||||
|
)
|
||||||
|
response_q_text = response_q.text
|
||||||
|
print("Saving questions to cache...")
|
||||||
|
atomic_write_text(cache_q_file, response_q_text)
|
||||||
|
|
||||||
|
questions_data = ExamQuestions.model_validate_json(response_q_text)
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# REQUÊTE 2 : Extraction des Corrections
|
||||||
|
# ==========================================
|
||||||
|
extracted_questions_json = questions_data.model_dump_json(indent=2)
|
||||||
|
|
||||||
|
contents_2 = [
|
||||||
|
types.Content(
|
||||||
|
role="user",
|
||||||
|
parts=[
|
||||||
|
types.Part.from_text(text=PROMPT_2),
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if cache_s_file.is_file() and not restart:
|
||||||
|
print("Loading cached solutions from Cache/gemini_solutions.json...")
|
||||||
|
response_s_text = cache_s_file.read_text(encoding="utf-8")
|
||||||
|
else:
|
||||||
|
print("Sending request 2 (Solutions) to Gemini...")
|
||||||
|
response_s = client.models.generate_content(
|
||||||
|
model=MODEL_ID,
|
||||||
|
contents=contents_2,
|
||||||
|
config=config_2
|
||||||
|
)
|
||||||
|
response_s_text = response_s.text
|
||||||
|
print("Saving solutions to cache...")
|
||||||
|
atomic_write_text(cache_s_file, response_s_text)
|
||||||
|
|
||||||
|
solutions_data = ExamSolutions.model_validate_json(response_s_text)
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# REQUÊTE 3 : Extraction du Contexte (Notations, etc.)
|
||||||
|
# ==========================================
|
||||||
|
contents_3 = [
|
||||||
|
types.Content(
|
||||||
|
role="user",
|
||||||
|
parts=[
|
||||||
|
types.Part.from_text(text=PROMPT_3),
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if cache_c_file.is_file() and not restart:
|
||||||
|
print("Loading cached context from Cache/gemini_context.json...")
|
||||||
|
response_c_text = cache_c_file.read_text(encoding="utf-8")
|
||||||
|
else:
|
||||||
|
print("Sending request 3 (Context) to Gemini...")
|
||||||
|
response_c = client.models.generate_content(
|
||||||
|
model=MODEL_ID,
|
||||||
|
contents=contents_3,
|
||||||
|
config=config_3
|
||||||
|
)
|
||||||
|
response_c_text = response_c.text
|
||||||
|
print("Saving context to cache...")
|
||||||
|
atomic_write_text(cache_c_file, response_c_text)
|
||||||
|
|
||||||
|
context_data = ExamContext.model_validate_json(response_c_text)
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# FUSION des trois résultats
|
||||||
|
# ==========================================
|
||||||
|
sol_map = {s.label: s.solution_content for s in solutions_data.solutions}
|
||||||
|
|
||||||
|
|
||||||
|
# Map labels to their index to validate ordering
|
||||||
|
label_to_idx = {q.label: i for i, q in enumerate(questions_data.questions)}
|
||||||
|
|
||||||
|
for c in context_data.contexts:
|
||||||
|
first_idx = label_to_idx.get(c.target_question_label, -1)
|
||||||
|
last_idx = label_to_idx.get(c.last_question_label, -1)
|
||||||
|
|
||||||
|
# Enforce LAST is after (or equal to) FIRST
|
||||||
|
if first_idx != -1 and last_idx != -1 and last_idx < first_idx:
|
||||||
|
print(f"Warning: LAST question ({c.last_question_label}) is before FIRST ({c.target_question_label}). Fixing.")
|
||||||
|
c.last_question_label = c.target_question_label
|
||||||
|
elif last_idx == -1: # Fallback if invalid
|
||||||
|
c.last_question_label = c.target_question_label
|
||||||
|
|
||||||
|
# Grouper les contextes par label cible
|
||||||
|
ctx_map = {}
|
||||||
|
for c in context_data.contexts:
|
||||||
|
if c.target_question_label in ctx_map:
|
||||||
|
ctx_map[c.target_question_label]['content'] += "\n\n" + c.context_content
|
||||||
|
# Keep the furthest LAST question label
|
||||||
|
curr_last = ctx_map[c.target_question_label]['last']
|
||||||
|
if label_to_idx.get(c.last_question_label, -1) > label_to_idx.get(curr_last, -1):
|
||||||
|
ctx_map[c.target_question_label]['last'] = c.last_question_label
|
||||||
|
else:
|
||||||
|
ctx_map[c.target_question_label] = {
|
||||||
|
'content': c.context_content,
|
||||||
|
'last': c.last_question_label
|
||||||
|
}
|
||||||
|
|
||||||
|
merged_items = []
|
||||||
|
for q in questions_data.questions:
|
||||||
|
if q.label in ctx_map:
|
||||||
|
merged_items.append(ContextItem(
|
||||||
|
target_question_label=q.label,
|
||||||
|
last_question_label=ctx_map[q.label]['last'],
|
||||||
|
content=ctx_map[q.label]['content']
|
||||||
|
))
|
||||||
|
|
||||||
|
sol_content = sol_map.get(q.label, "")
|
||||||
|
merged_items.append(QuestionItem(
|
||||||
|
label=q.label,
|
||||||
|
question_content=q.question_content,
|
||||||
|
solution_content=sol_content
|
||||||
|
))
|
||||||
|
|
||||||
|
extracted_data = ExamExtraction(items=merged_items)
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# INITIAL GROUPING COMPUTATION
|
||||||
|
# ==========================================
|
||||||
|
items_file = tmp_dir / "exam_items.txt"
|
||||||
|
full_items_file = tmp_dir / "exam_items_full.txt"
|
||||||
|
trunc_map = {}
|
||||||
|
|
||||||
|
# --- INITIAL GROUPING COMPUTATION ---
|
||||||
|
# 1. Normalize labels first
|
||||||
|
for item in extracted_data.items:
|
||||||
|
if isinstance(item, QuestionItem):
|
||||||
|
item.label = item.label.replace("Exercice", "Ex").replace(".", ")")
|
||||||
|
|
||||||
|
# 2. Extract questions and compute grouping indices
|
||||||
|
questions_only = [item for item in extracted_data.items if isinstance(item, QuestionItem)]
|
||||||
|
# questions_only = [item for item in extracted_data.items if isinstance(item, QuestionItem)]
|
||||||
|
|
||||||
|
q_group_indices = []
|
||||||
|
if questions_only:
|
||||||
|
n = len(questions_only)
|
||||||
|
if n == 1:
|
||||||
|
q_group_indices = [[0]]
|
||||||
|
else:
|
||||||
|
adj_lcp = [get_lcp(questions_only[i].label, questions_only[i+1].label) for i in range(n - 1)]
|
||||||
|
|
||||||
|
current_g = [0]
|
||||||
|
for i in range(n - 1):
|
||||||
|
p = adj_lcp[i]
|
||||||
|
prev_p = adj_lcp[i - 1] if i > 0 else ""
|
||||||
|
next_p = adj_lcp[i + 1] if i < n - 2 else ""
|
||||||
|
|
||||||
|
# Group i and i+1 together if p is non-empty and at least as specific as adjacent LCPs
|
||||||
|
if p and len(p) >= len(prev_p) and len(p) >= len(next_p):
|
||||||
|
current_g.append(i + 1)
|
||||||
|
else:
|
||||||
|
q_group_indices.append(current_g)
|
||||||
|
current_g = [i + 1]
|
||||||
|
q_group_indices.append(current_g)
|
||||||
|
|
||||||
|
|
||||||
|
# Build list of unique ContextItems from extracted data
|
||||||
|
all_contexts = [item for item in extracted_data.items if isinstance(item, ContextItem)]
|
||||||
|
|
||||||
|
initial_groups = []
|
||||||
|
for g_indices in q_group_indices:
|
||||||
|
group_items = []
|
||||||
|
first_q_idx = g_indices[0]
|
||||||
|
|
||||||
|
for q_idx in g_indices:
|
||||||
|
q_item = questions_only[q_idx]
|
||||||
|
|
||||||
|
# 1. Collect contexts targeting this specific question
|
||||||
|
# 2. Or contexts carried over from an earlier group (only added at the start of the group)
|
||||||
|
for ctx in all_contexts:
|
||||||
|
target_idx = label_to_idx.get(ctx.target_question_label, -1)
|
||||||
|
last_idx = label_to_idx.get(ctx.last_question_label, -1)
|
||||||
|
|
||||||
|
if target_idx != -1 and last_idx != -1:
|
||||||
|
is_exact_target = (target_idx == q_idx)
|
||||||
|
is_carried_over = (q_idx == first_q_idx and target_idx < first_q_idx and last_idx >= first_q_idx)
|
||||||
|
|
||||||
|
if is_exact_target or is_carried_over:
|
||||||
|
group_items.append(ContextItem(
|
||||||
|
target_question_label=ctx.target_question_label,
|
||||||
|
last_question_label=ctx.last_question_label,
|
||||||
|
content=ctx.content
|
||||||
|
))
|
||||||
|
|
||||||
|
group_items.append(q_item)
|
||||||
|
|
||||||
|
initial_groups.append(group_items)
|
||||||
|
|
||||||
|
# ---- Transform labels, and check uniqueness
|
||||||
|
|
||||||
|
seen_labels = set()
|
||||||
|
label_updates = {}
|
||||||
|
for group in initial_groups:
|
||||||
|
for item in group:
|
||||||
|
if isinstance(item, QuestionItem):
|
||||||
|
orig_label = item.label
|
||||||
|
|
||||||
|
# 2. Ensure uniqueness (prefix with XX)
|
||||||
|
while item.label in seen_labels:
|
||||||
|
item.label = f"XX{item.label}"
|
||||||
|
seen_labels.add(item.label)
|
||||||
|
label_updates[orig_label] = item.label
|
||||||
|
|
||||||
|
# Sync the modified labels to ContextItem
|
||||||
|
for group in initial_groups:
|
||||||
|
for item in group:
|
||||||
|
if isinstance(item, ContextItem):
|
||||||
|
item.target_question_label = label_updates.get(item.target_question_label, item.target_question_label)
|
||||||
|
item.last_question_label = label_updates.get(item.last_question_label, item.last_question_label)
|
||||||
|
|
||||||
|
# --- WRITE TEXT FILES ---
|
||||||
|
print(f"Writing items files to {items_file.name} and {full_items_file.name}...")
|
||||||
|
|
||||||
|
with open(items_file, "w", encoding="utf-8") as f, \
|
||||||
|
open(full_items_file, "w", encoding="utf-8") as f_full:
|
||||||
|
|
||||||
|
header = "# Edit labels. Modify groups (---). Ensure label uniqueness (XX). Duplicate CONTEXT.\n\n"
|
||||||
|
f.write(header)
|
||||||
|
f_full.write(header)
|
||||||
|
|
||||||
|
for g_idx, group in enumerate(initial_groups):
|
||||||
|
if g_idx > 0:
|
||||||
|
f.write("\n---\n\n")
|
||||||
|
f_full.write("\n---\n\n")
|
||||||
|
|
||||||
|
for item in group:
|
||||||
|
if isinstance(item, QuestionItem):
|
||||||
|
safe_content = item.question_content.replace('\n', ' \\n ')
|
||||||
|
f_full.write(f"{item.label} ### {safe_content}\n")
|
||||||
|
|
||||||
|
if len(safe_content) > 65:
|
||||||
|
trunc_content = safe_content[:64] + "…"
|
||||||
|
trunc_map[trunc_content] = safe_content
|
||||||
|
else:
|
||||||
|
trunc_content = safe_content
|
||||||
|
|
||||||
|
f.write(f"{item.label} ### {trunc_content}\n")
|
||||||
|
|
||||||
|
elif isinstance(item, ContextItem):
|
||||||
|
safe_content = item.content.replace('\n', ' \\n ')
|
||||||
|
f.write(f"CONTEXT ### {safe_content}\n")
|
||||||
|
f_full.write(f"CONTEXT ### {safe_content}\n")
|
||||||
|
|
||||||
|
# --- OPEN EDITOR AND PARSE ---
|
||||||
|
while True:
|
||||||
|
print("Opening items file for editing...")
|
||||||
|
utils.edit_file_and_enter(items_file)
|
||||||
|
|
||||||
|
print(f"Parsing edited items from {items_file.name}...")
|
||||||
|
with open(items_file, "r", encoding="utf-8") as f:
|
||||||
|
edited_lines = [line.strip() for line in f if line.strip() and not line.startswith("#")]
|
||||||
|
|
||||||
|
# 1. Validation for XX labels
|
||||||
|
has_xx = False
|
||||||
|
for line in edited_lines:
|
||||||
|
if " ### " in line:
|
||||||
|
lbl = line.split(" ### ", 1)[0].strip()
|
||||||
|
if lbl.startswith("XX"):
|
||||||
|
has_xx = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if has_xx:
|
||||||
|
print("\n!!! ERROR: Some labels still start with 'XX'. Please remove the 'XX' prefixes to ensure unique, valid labels.")
|
||||||
|
input("Press ENTER to return to the editor...")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Map original contexts by normalized content
|
||||||
|
orig_contexts = {c.context_content.strip(): c for c in context_data.contexts}
|
||||||
|
|
||||||
|
# 2. Actual Parsing
|
||||||
|
grouped_items = []
|
||||||
|
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 = lbl.strip()
|
||||||
|
if lbl != "CONTEXT":
|
||||||
|
all_new_q_labels.append(lbl)
|
||||||
|
|
||||||
|
# Mapping from original question index to new label
|
||||||
|
idx_to_new_label = {i: all_new_q_labels[i] for i in range(min(len(questions_only), len(all_new_q_labels)))}
|
||||||
|
|
||||||
|
orig_q_idx = 0
|
||||||
|
current_group = []
|
||||||
|
|
||||||
|
for line in edited_lines:
|
||||||
|
if line == "---":
|
||||||
|
if current_group:
|
||||||
|
grouped_items.append(current_group)
|
||||||
|
current_group = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
if " ### " not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_label, edited_content_raw = line.split(" ### ", 1)
|
||||||
|
new_label = new_label.strip()
|
||||||
|
|
||||||
|
if "…" in edited_content_raw and edited_content_raw in trunc_map:
|
||||||
|
edited_content_raw = trunc_map[edited_content_raw]
|
||||||
|
|
||||||
|
edited_content = edited_content_raw.replace(' \\n ', '\n')
|
||||||
|
|
||||||
|
if new_label == "CONTEXT":
|
||||||
|
current_group.append(('CONTEXT', edited_content))
|
||||||
|
else:
|
||||||
|
sol_content = questions_only[orig_q_idx].solution_content if orig_q_idx < len(questions_only) else ""
|
||||||
|
current_group.append(QuestionItem(
|
||||||
|
label=new_label,
|
||||||
|
question_content=edited_content,
|
||||||
|
solution_content=sol_content
|
||||||
|
))
|
||||||
|
orig_q_idx += 1
|
||||||
|
|
||||||
|
if current_group:
|
||||||
|
grouped_items.append(current_group)
|
||||||
|
|
||||||
|
# Pass 2: Resolve ContextItem target/last labels per group
|
||||||
|
final_grouped_items = []
|
||||||
|
for group in grouped_items:
|
||||||
|
final_group = []
|
||||||
|
q_in_group = [item for item in group if isinstance(item, QuestionItem)]
|
||||||
|
g_first_label = q_in_group[0].label if q_in_group else ""
|
||||||
|
g_last_label = q_in_group[-1].label if q_in_group else ""
|
||||||
|
|
||||||
|
for i, item in enumerate(group):
|
||||||
|
if isinstance(item, tuple) and item[0] == 'CONTEXT':
|
||||||
|
c_text = item[1]
|
||||||
|
norm_text = c_text.strip()
|
||||||
|
|
||||||
|
# Find next question label in group following this context
|
||||||
|
next_q_label = g_first_label
|
||||||
|
for successor in group[i+1:]:
|
||||||
|
if isinstance(successor, QuestionItem):
|
||||||
|
next_q_label = successor.label
|
||||||
|
break
|
||||||
|
|
||||||
|
if norm_text in orig_contexts:
|
||||||
|
orig_c = orig_contexts[norm_text]
|
||||||
|
orig_target_idx = label_to_idx.get(orig_c.target_question_label, -1)
|
||||||
|
orig_last_idx = label_to_idx.get(orig_c.last_question_label, -1)
|
||||||
|
|
||||||
|
mapped_target = idx_to_new_label.get(orig_target_idx, next_q_label)
|
||||||
|
mapped_last = idx_to_new_label.get(orig_last_idx, g_last_label)
|
||||||
|
|
||||||
|
# Check if context's last question is BEFORE the first question of this group
|
||||||
|
first_q_idx_in_exam = all_new_q_labels.index(g_first_label) if g_first_label in all_new_q_labels else -1
|
||||||
|
last_q_idx_in_exam = all_new_q_labels.index(mapped_last) if mapped_last in all_new_q_labels else -1
|
||||||
|
|
||||||
|
if last_q_idx_in_exam != -1 and first_q_idx_in_exam != -1 and last_q_idx_in_exam < first_q_idx_in_exam:
|
||||||
|
mapped_last = g_last_label
|
||||||
|
|
||||||
|
final_group.append(ContextItem(
|
||||||
|
target_question_label=mapped_target,
|
||||||
|
last_question_label=mapped_last,
|
||||||
|
content=c_text
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# New context created by user
|
||||||
|
final_group.append(ContextItem(
|
||||||
|
target_question_label=next_q_label,
|
||||||
|
last_question_label=g_last_label,
|
||||||
|
content=c_text
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
final_group.append(item)
|
||||||
|
|
||||||
|
final_grouped_items.append(final_group)
|
||||||
|
|
||||||
|
grouped_items = final_grouped_items
|
||||||
|
break
|
||||||
|
|
||||||
|
labels_list = [item.label for group in grouped_items for item in group if isinstance(item, QuestionItem)]
|
||||||
|
validate_windows_labels(labels_list)
|
||||||
|
|
||||||
|
# Save labels and proceed
|
||||||
|
grouped_extraction = GroupedExamExtraction(groups=grouped_items)
|
||||||
|
|
||||||
|
# 2. Setup output directories
|
||||||
|
text_dir = folder / "Text"
|
||||||
|
sol_dir = folder / "Sol"
|
||||||
|
text2_dir = folder / "Text2"
|
||||||
|
sol2_dir = folder / "Sol2"
|
||||||
|
persp_dir = folder / "Persp"
|
||||||
|
dirs = [text_dir, sol_dir, text2_dir, sol2_dir, persp_dir]
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
# Ask only if any directory already exists
|
||||||
|
if any(d.exists() for d in dirs):
|
||||||
|
answer = input(
|
||||||
|
"Output directories already exist. Delete their contents? [y/N] "
|
||||||
|
).strip().lower()
|
||||||
|
|
||||||
|
if answer not in ("y", "yes"):
|
||||||
|
raise CliError("Output replacement aborted", ExitCode.INVALID_ARGUMENTS)
|
||||||
|
# Empty each directory
|
||||||
|
for d in dirs:
|
||||||
|
if d.exists():
|
||||||
|
shutil.rmtree(d)
|
||||||
|
d.mkdir(parents=True)
|
||||||
|
else:
|
||||||
|
# Create them if they don't exist
|
||||||
|
for d in dirs:
|
||||||
|
d.mkdir(parents=True)
|
||||||
|
|
||||||
|
text_dir.mkdir(exist_ok=True)
|
||||||
|
sol_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
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)]
|
||||||
|
labels = [q.label for q in q_items]
|
||||||
|
|
||||||
|
if not labels:
|
||||||
|
continue # Skip if a group has no questions (only contexts)
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# REQUÊTE 4 : Génération du Barème pour le groupe
|
||||||
|
# ==========================================
|
||||||
|
group_text_parts = []
|
||||||
|
for item in group:
|
||||||
|
if isinstance(item, QuestionItem):
|
||||||
|
group_text_parts.append(f"Question [{item.label}]:\n{item.question_content}\nCorrection [{item.label}]:\n{item.solution_content}")
|
||||||
|
elif isinstance(item, ContextItem):
|
||||||
|
group_text_parts.append(f"Contexte (Cible: {item.target_question_label}):\n{item.content}")
|
||||||
|
|
||||||
|
group_context_text = "\n\n---\n\n".join(group_text_parts)
|
||||||
|
|
||||||
|
print(f"Generating rubric (Persp) for group: {', '.join(labels)}...")
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
prefix = labels[0]
|
||||||
|
for lbl in labels[1:]:
|
||||||
|
prefix = get_lcp(prefix, lbl)
|
||||||
|
|
||||||
|
# 2. Format the Text filename: prefix [label1, label2]
|
||||||
|
labels_str = ",".join([label[len(prefix):] for label in labels])
|
||||||
|
group_filename = f"{prefix}[{labels_str}]"
|
||||||
|
safe_group_filename = group_filename.replace("/", "_")
|
||||||
|
|
||||||
|
text_content_lines = []
|
||||||
|
|
||||||
|
# 3. Process each item in the group
|
||||||
|
for item in group:
|
||||||
|
if isinstance(item, QuestionItem):
|
||||||
|
# 1. Prepare tabulated content:
|
||||||
|
# Start with a tab, then replace every newline+whitespace with newline+tab
|
||||||
|
raw_content = item.question_content.strip()
|
||||||
|
tabulated = "\t" + re.sub(r'\n\s*', '\n\t', raw_content)
|
||||||
|
|
||||||
|
# 2. Build Text entry
|
||||||
|
text_content_lines.append(f"{item.label} :")
|
||||||
|
text_content_lines.append(tabulated)
|
||||||
|
|
||||||
|
# Write individual Sol file (remains unchanged)
|
||||||
|
safe_label = item.label.replace("/", "_")
|
||||||
|
with open(sol_dir / safe_label, "w", encoding="utf-8") as f_sol:
|
||||||
|
f_sol.write(f"{item.label}\n{item.solution_content}")
|
||||||
|
|
||||||
|
with open(text2_dir / f"{safe_label}.tex", "w", encoding="utf-8") as f_t2:
|
||||||
|
f_t2.write(f"\\textbf{{{item.label}}} {item.question_content}")
|
||||||
|
|
||||||
|
with open(sol2_dir / f"{safe_label}.tex", "w", encoding="utf-8") as f_s2:
|
||||||
|
f_s2.write(f"\\textbf{{{item.label}}} {item.solution_content}")
|
||||||
|
|
||||||
|
# --- Écriture du Barème (Persp) ---
|
||||||
|
rubric_text = rubrics_map.get(item.label, "")
|
||||||
|
with open(persp_dir / safe_label, "w", encoding="utf-8") as f_persp:
|
||||||
|
f_persp.write(f"{item.label}\n{rubric_text}")
|
||||||
|
|
||||||
|
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("CONTEXT :")
|
||||||
|
text_content_lines.append(tabulated_ctx)
|
||||||
|
|
||||||
|
# --- Save context to Text2 (Concatenating if exists) ---
|
||||||
|
safe_first = item.target_question_label.replace("/", "_")
|
||||||
|
safe_last = item.last_question_label.replace("/", "_")
|
||||||
|
ctxt_filename = f"CTXT {safe_first} -> {safe_last}.tex"
|
||||||
|
ctxt_path = text2_dir / ctxt_filename
|
||||||
|
|
||||||
|
# If file exists, prepend some spacing before appending
|
||||||
|
prefix = "\n\n" if ctxt_path.exists() else ""
|
||||||
|
|
||||||
|
with open(ctxt_path, "a", encoding="utf-8") as f_c2:
|
||||||
|
f_c2.write(prefix + item.content)
|
||||||
|
|
||||||
|
# 4. Write the grouped Text file
|
||||||
|
with open(text_dir / safe_group_filename, "w", encoding="utf-8") as f_text:
|
||||||
|
f_text.write("\n".join(text_content_lines))
|
||||||
|
|
||||||
|
print(f"Success! Processed {len(grouped_extraction.groups)} groups.")
|
||||||
|
# ==========================================
|
||||||
|
# PDF COMPILATION (4 Threads)
|
||||||
|
# ==========================================
|
||||||
|
all_tex_files = list(text2_dir.glob("*.tex")) + list(sol2_dir.glob("*.tex"))
|
||||||
|
|
||||||
|
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: # 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:
|
||||||
|
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__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from copienator import (
|
||||||
|
EvaluationWorkspace,
|
||||||
|
ExitCode,
|
||||||
|
configuration,
|
||||||
|
evaluation_parser,
|
||||||
|
execute,
|
||||||
|
read_json,
|
||||||
|
workspace_from_args,
|
||||||
|
)
|
||||||
|
from copienator.platform import replace_with_link_or_copy, safe_filename
|
||||||
|
from copienator.return_answers import publish_answer_returns
|
||||||
|
|
||||||
|
ANNOTATION_CHOICES = ("BGnot", "Bnot", "Anot")
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = evaluation_parser("Assign student names and prepare the return directory.")
|
||||||
|
parser.add_argument(
|
||||||
|
"annotation_dir",
|
||||||
|
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():
|
||||||
|
print(
|
||||||
|
f"Warning: names file not found in {workspace.root} or the current directory.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return set()
|
||||||
|
return {
|
||||||
|
line.strip()
|
||||||
|
for line in names_path.read_text(encoding="utf-8").splitlines()
|
||||||
|
if line.strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
) -> ExitCode:
|
||||||
|
workspace.require_directories("Copies", annotation_dir_name)
|
||||||
|
workspace.return_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
expected_names = _read_expected_names(workspace)
|
||||||
|
copies_map: defaultdict[str, list[str]] = defaultdict(list)
|
||||||
|
pattern = re.compile(r"^Copie(\d+)\.json$")
|
||||||
|
had_errors = False
|
||||||
|
|
||||||
|
for json_path in workspace.copies_dir.iterdir():
|
||||||
|
match = pattern.match(json_path.name)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = read_json(json_path)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise TypeError("expected a JSON object")
|
||||||
|
name = str(data.get("name", "Unknown")).strip()
|
||||||
|
copies_map[name].append(match.group(1))
|
||||||
|
except (OSError, TypeError, ValueError) as exc:
|
||||||
|
print(f"Error processing {json_path}: {exc}", file=sys.stderr)
|
||||||
|
had_errors = True
|
||||||
|
|
||||||
|
assigned_names: set[str] = set()
|
||||||
|
for name, copy_ids in copies_map.items():
|
||||||
|
if name == "Unknown":
|
||||||
|
print(
|
||||||
|
f"Warning: unknown name for copies: {', '.join(copy_ids)}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
elif len(copy_ids) > 1:
|
||||||
|
print(
|
||||||
|
f"Warning: name {name!r} is assigned to multiple copies: "
|
||||||
|
f"{', '.join(copy_ids)}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
safe_name = safe_filename(name)
|
||||||
|
for copy_id in copy_ids:
|
||||||
|
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", 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, enabled in links:
|
||||||
|
source = source_folder / source_name
|
||||||
|
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,
|
||||||
|
target,
|
||||||
|
prefer="symlink",
|
||||||
|
)
|
||||||
|
if method == "copy":
|
||||||
|
print(
|
||||||
|
f"Copied {source_name} for {destination.name} "
|
||||||
|
"(links unavailable)"
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
print(
|
||||||
|
f"Error linking {source} for {destination.name}: {exc}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
had_errors = True
|
||||||
|
|
||||||
|
unassigned = expected_names - assigned_names
|
||||||
|
if unassigned:
|
||||||
|
print("Names from the list that were not assigned:", file=sys.stderr)
|
||||||
|
for name in sorted(unassigned):
|
||||||
|
print(f" - {name}", file=sys.stderr)
|
||||||
|
|
||||||
|
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
return execute(
|
||||||
|
parser,
|
||||||
|
argv,
|
||||||
|
lambda args: run(
|
||||||
|
workspace_from_args(args, repository=Path.cwd()),
|
||||||
|
annotation_dir=args.annotation_dir,
|
||||||
|
update=args.update,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
|
import argparse
|
||||||
import os
|
import os
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
import sys
|
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from collections.abc import Sequence
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
from pdf2image import convert_from_path, pdfinfo_from_path
|
from pdf2image import convert_from_path, pdfinfo_from_path
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from copienator import (
|
||||||
|
EvaluationWorkspace,
|
||||||
|
ExitCode,
|
||||||
|
atomic_write_json,
|
||||||
|
evaluation_parser,
|
||||||
|
execute,
|
||||||
|
workspace_from_args,
|
||||||
|
)
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
DPI = 200 # Good balance for readability and size
|
DPI = 200 # Good balance for readability and size
|
||||||
@@ -34,7 +43,7 @@ def get_pdf_height(path):
|
|||||||
|
|
||||||
# Return total height
|
# Return total height
|
||||||
return single_page_px * num_pages
|
return single_page_px * num_pages
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001 - pdfinfo may raise backend-specific errors
|
||||||
print(f"Error reading {path}: {e}")
|
print(f"Error reading {path}: {e}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -78,7 +87,7 @@ def group_files(file_list):
|
|||||||
groups = []
|
groups = []
|
||||||
|
|
||||||
for item in sorted_files:
|
for item in sorted_files:
|
||||||
dd, path, height = item
|
_, _, height = item
|
||||||
placed = False
|
placed = False
|
||||||
|
|
||||||
# 2. Try to fit item into an existing group (First Fit)
|
# 2. Try to fit item into an existing group (First Fit)
|
||||||
@@ -140,7 +149,7 @@ def create_jpg(identifier, group_index, group, root_dir):
|
|||||||
combined_img = stitch_pdf_pages(imgs)
|
combined_img = stitch_pdf_pages(imgs)
|
||||||
if combined_img:
|
if combined_img:
|
||||||
images.append((dd, combined_img))
|
images.append((dd, combined_img))
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001 - PDF/image backends vary by platform
|
||||||
print(f"Failed to convert {path}: {e}")
|
print(f"Failed to convert {path}: {e}")
|
||||||
|
|
||||||
if not images:
|
if not images:
|
||||||
@@ -159,7 +168,7 @@ def create_jpg(identifier, group_index, group, root_dir):
|
|||||||
# Try loading a font, fallback to default
|
# Try loading a font, fallback to default
|
||||||
try:
|
try:
|
||||||
font = ImageFont.truetype("DejaVuSans.ttf", 40)
|
font = ImageFont.truetype("DejaVuSans.ttf", 40)
|
||||||
except IOError:
|
except OSError:
|
||||||
print("font not found")
|
print("font not found")
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
@@ -193,8 +202,7 @@ def create_jpg(identifier, group_index, group, root_dir):
|
|||||||
# Save JSON metadata
|
# Save JSON metadata
|
||||||
json_filename = f"Group_{group_index+1}.json"
|
json_filename = f"Group_{group_index+1}.json"
|
||||||
json_path = os.path.join(target_folder, json_filename)
|
json_path = os.path.join(target_folder, json_filename)
|
||||||
with open(json_path, 'w') as f:
|
atomic_write_json(json_path, metadata, indent=None)
|
||||||
json.dump(metadata, f)
|
|
||||||
|
|
||||||
# Save with size constraints
|
# Save with size constraints
|
||||||
output_filename = f"Group_{group_index+1}.jpg"
|
output_filename = f"Group_{group_index+1}.jpg"
|
||||||
@@ -211,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)")
|
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):
|
def process_identifier(identifier, files_info, output_dir):
|
||||||
@@ -227,18 +235,16 @@ def process_identifier(identifier, files_info, output_dir):
|
|||||||
for idx, group in enumerate(file_groups):
|
for idx, group in enumerate(file_groups):
|
||||||
create_jpg(identifier, idx, group, output_dir)
|
create_jpg(identifier, idx, group, output_dir)
|
||||||
|
|
||||||
def main():
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
if len(sys.argv) < 2:
|
return evaluation_parser("Group copy extracts by question label.")
|
||||||
print("Usage: python app.py <Path_to_Dir>")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
root_dir = Path(sys.argv[1])
|
|
||||||
|
|
||||||
copies_dir = root_dir / "Copies"
|
def run(workspace: EvaluationWorkspace) -> ExitCode:
|
||||||
par_label_dir = root_dir / "Par label"
|
workspace.require_directories("Copies")
|
||||||
|
workspace.groups_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
print("Scanning files...")
|
print("Scanning files...")
|
||||||
data = collect_files(copies_dir)
|
data = collect_files(workspace.copies_dir)
|
||||||
|
|
||||||
print(f"Found {len(data)} identifiers. Processing...")
|
print(f"Found {len(data)} identifiers. Processing...")
|
||||||
|
|
||||||
@@ -247,11 +253,26 @@ def main():
|
|||||||
|
|
||||||
# Process using 8 threads
|
# Process using 8 threads
|
||||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||||
for identifier in sorted_identifiers:
|
futures = [
|
||||||
executor.submit(process_identifier, identifier, data[identifier],
|
executor.submit(
|
||||||
par_label_dir)
|
process_identifier,
|
||||||
|
identifier,
|
||||||
|
data[identifier],
|
||||||
|
workspace.groups_dir,
|
||||||
|
)
|
||||||
|
for identifier in sorted_identifiers
|
||||||
|
]
|
||||||
|
for future in futures:
|
||||||
|
future.result()
|
||||||
|
|
||||||
print("Done.")
|
print("Done.")
|
||||||
|
return ExitCode.SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
raise SystemExit(main())
|
||||||
|
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,679 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import tkinter as tk
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import messagebox
|
||||||
|
|
||||||
|
import pymupdf # PyMuPDF
|
||||||
|
from PIL import Image, ImageDraw, ImageTk
|
||||||
|
from pypdf import PdfReader, PdfWriter
|
||||||
|
|
||||||
|
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: 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:
|
||||||
|
|
||||||
|
def setup_next_file(self):
|
||||||
|
self.num += 1
|
||||||
|
if len(self.inputs) == 0:
|
||||||
|
return False
|
||||||
|
self.pdf_path = self.inputs.pop()
|
||||||
|
self.file_rotation = 0
|
||||||
|
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 = 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 - {self.pdf_path.name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
master: tk.Tk,
|
||||||
|
workspace: EvaluationWorkspace,
|
||||||
|
inputs: list[Path],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Initializes the application.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
master (tk.Tk): The root Tkinter window.
|
||||||
|
pdf_path (str): The path to the input PDF file.
|
||||||
|
"""
|
||||||
|
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"No PDF files found in {workspace.root}")
|
||||||
|
master.destroy()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._resize_job = None # For debouncing resize events
|
||||||
|
|
||||||
|
self._initialize_current_page_settings()
|
||||||
|
|
||||||
|
# --- UI Setup ---
|
||||||
|
# Set a reasonable initial size for the window
|
||||||
|
self.master.geometry("800x1000")
|
||||||
|
|
||||||
|
def fmt(action):
|
||||||
|
k = PAGE_SPLITTER_KB.get(action, "")
|
||||||
|
return k
|
||||||
|
|
||||||
|
# Dynamic instructions text
|
||||||
|
instructions = (
|
||||||
|
f"{fmt('move_left')} / {fmt('move_right')} : Move line 1cm left/right\n"
|
||||||
|
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)
|
||||||
|
self.info_label.pack(pady=5, side=tk.TOP)
|
||||||
|
|
||||||
|
self.page_label = tk.Label(master, text="", font=("Helvetica", 12))
|
||||||
|
self.page_label.pack(pady=5, side=tk.TOP)
|
||||||
|
|
||||||
|
# Canvas for PDF page preview
|
||||||
|
self.canvas = tk.Canvas(master, bg="gray")
|
||||||
|
self.canvas.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
# --- Bindings ---
|
||||||
|
action_map = {
|
||||||
|
"move_left": self.move_line_left,
|
||||||
|
"move_right": self.move_line_right,
|
||||||
|
"confirm_next": self.confirm_and_next_page,
|
||||||
|
"rotate_page": self.rotate_page,
|
||||||
|
"rotate_all_pages": self.rotate_all_pages,
|
||||||
|
"rotate_all_files": self.rotate_all_files,
|
||||||
|
"keep_left": self.keep_left,
|
||||||
|
"keep_right": self.keep_right,
|
||||||
|
"keep_as_is": self.keep_as_is,
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
for action, key in PAGE_SPLITTER_KB.items():
|
||||||
|
if action in action_map:
|
||||||
|
self.master.bind(key, action_map[action])
|
||||||
|
|
||||||
|
# self.master.bind("<Left>", self.move_line_left)
|
||||||
|
# self.master.bind("<Right>", self.move_line_right)
|
||||||
|
# self.master.bind("<Return>", self.confirm_and_next_page)
|
||||||
|
# self.master.bind("c", self.rotate_page)
|
||||||
|
# self.master.bind("C", self.rotate_all_pages)
|
||||||
|
# self.master.bind(",", self.rotate_all_files)
|
||||||
|
# self.master.bind("t", self.keep_left)
|
||||||
|
# self.master.bind("n", self.keep_right)
|
||||||
|
# self.master.bind("m", self.keep_as_is)
|
||||||
|
# self.master.bind("s", self.confirm_and_next_page)
|
||||||
|
# self.master.bind("r", self.discard_page)
|
||||||
|
# self.master.bind("z", self.send_page_end)
|
||||||
|
# self.master.bind("R", self.restart_current_file)
|
||||||
|
# self.master.bind("A", self.start_arranger)
|
||||||
|
# self.master.bind("P", self.go_to_previous_file)
|
||||||
|
|
||||||
|
|
||||||
|
# Bind the resize event on the canvas
|
||||||
|
self.canvas.bind("<Configure>", self.on_resize)
|
||||||
|
|
||||||
|
self.current_zoom = 1.0
|
||||||
|
|
||||||
|
def start_arranger(self):
|
||||||
|
try:
|
||||||
|
launch_pdf_arranger(self.pdf_path)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
messagebox.showerror("PDF Arranger", str(exc))
|
||||||
|
|
||||||
|
def on_resize(self, event):
|
||||||
|
"""
|
||||||
|
Handles window resize events by reloading the page.
|
||||||
|
Uses a "debounce" mechanism to avoid excessive redrawing.
|
||||||
|
"""
|
||||||
|
if self._resize_job:
|
||||||
|
self.master.after_cancel(self._resize_job)
|
||||||
|
self._resize_job = self.master.after(250, self.load_page) # Redraw after 250ms of no resizing
|
||||||
|
|
||||||
|
def _initialize_current_page_settings(self):
|
||||||
|
"""Initializes or resets the settings for the current page."""
|
||||||
|
if self.current_page_index < len(self.doc):
|
||||||
|
page = self.doc.load_page(self.current_page_index)
|
||||||
|
self.current_line_x = page.rect.width / 2
|
||||||
|
self.current_rotation = 0
|
||||||
|
|
||||||
|
def load_page(self):
|
||||||
|
"""Loads and displays the current page on the canvas, scaled to fit."""
|
||||||
|
if self.current_page_index >= len(self.doc):
|
||||||
|
if not self.processing:
|
||||||
|
self.processing = True
|
||||||
|
self.finish_and_process()
|
||||||
|
return
|
||||||
|
|
||||||
|
page = self.doc.load_page(self.current_page_index)
|
||||||
|
self.page_label.config(text=f"Page {self.current_page_index + 1} of {len(self.doc)}")
|
||||||
|
|
||||||
|
# --- Calculate Scaling ---
|
||||||
|
canvas_width = self.canvas.winfo_width()
|
||||||
|
canvas_height = self.canvas.winfo_height()
|
||||||
|
|
||||||
|
# Don't try to render if the canvas has no size yet.
|
||||||
|
if canvas_width <= 1 or canvas_height <= 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
page_rect = page.rect
|
||||||
|
zoom_x = canvas_width / page_rect.width
|
||||||
|
zoom_y = canvas_height / page_rect.height
|
||||||
|
# Use 98% of the smallest zoom factor to leave a small margin
|
||||||
|
self.current_zoom = min(zoom_x, zoom_y) * 0.98
|
||||||
|
|
||||||
|
# --- Render Page ---
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# Apply rotation if needed *after* drawing the line
|
||||||
|
if (self.current_rotation + self.file_rotation + self.global_rotation) % 360 != 0:
|
||||||
|
img = img.rotate(180, expand=True)
|
||||||
|
|
||||||
|
# --- Draw Line and Rotate ---
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
# The line position is scaled by the same zoom factor
|
||||||
|
line_x_scaled = self.current_line_x * self.current_zoom
|
||||||
|
draw.line([(line_x_scaled, 0), (line_x_scaled, pix.height)], fill="red", width=3)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Display on Canvas ---
|
||||||
|
self.photo_img = ImageTk.PhotoImage(img)
|
||||||
|
self.canvas.delete("all")
|
||||||
|
# Center the image on the canvas
|
||||||
|
self.canvas.create_image(canvas_width / 2, canvas_height / 2, anchor="center",
|
||||||
|
image=self.photo_img)
|
||||||
|
|
||||||
|
def restart_current_file(self, event=None):
|
||||||
|
"""Restarts the processing of the current file."""
|
||||||
|
# Close the modified in-memory document
|
||||||
|
if hasattr(self, 'doc'):
|
||||||
|
self.doc.close()
|
||||||
|
|
||||||
|
# Re-open the file from disk to reset changes (like moved pages)
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
|
||||||
|
# Reset state variables for the current file
|
||||||
|
self.file_rotation = 0
|
||||||
|
self.current_page_index = 0
|
||||||
|
self.page_settings = []
|
||||||
|
self.processing = False
|
||||||
|
|
||||||
|
# Reload UI
|
||||||
|
self._initialize_current_page_settings()
|
||||||
|
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)
|
||||||
|
self.load_page()
|
||||||
|
|
||||||
|
def move_line_right(self, event=None):
|
||||||
|
"""Moves the split line to the right."""
|
||||||
|
page = self.doc.load_page(self.current_page_index)
|
||||||
|
self.current_line_x = min(page.rect.width, self.current_line_x + CM_TO_POINTS / 2)
|
||||||
|
self.load_page()
|
||||||
|
|
||||||
|
def rotate_page(self, event=None):
|
||||||
|
"""Toggles the page rotation between 0 and 180 degrees."""
|
||||||
|
self.current_rotation = 180 if self.current_rotation == 0 else 0
|
||||||
|
self.load_page()
|
||||||
|
|
||||||
|
def rotate_all_pages(self, event=None):
|
||||||
|
"""Toggles the page rotation between 0 and 180 degrees."""
|
||||||
|
self.file_rotation = 180 if self.file_rotation == 0 else 0
|
||||||
|
self.load_page()
|
||||||
|
|
||||||
|
def rotate_all_files(self, event=None):
|
||||||
|
"""Toggles the page rotation between 0 and 180 degrees."""
|
||||||
|
self.global_rotation = 180 if self.global_rotation == 0 else 0
|
||||||
|
self.load_page()
|
||||||
|
|
||||||
|
def keep_left(self, event=None):
|
||||||
|
self.confirm_and_next_page(keep="left")
|
||||||
|
def keep_right(self, event=None):
|
||||||
|
self.confirm_and_next_page(keep="right")
|
||||||
|
def discard_page(self, event=None):
|
||||||
|
self.confirm_and_next_page(keep="none")
|
||||||
|
def keep_as_is(self, event=None):
|
||||||
|
self.confirm_and_next_page(keep="as_is")
|
||||||
|
def send_page_end(self, event=None):
|
||||||
|
# Do nothing if we are already at or past the last page
|
||||||
|
if self.current_page_index >= len(self.doc) - 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Move the current page to the end of the document
|
||||||
|
# -1 as the destination puts it after the last page
|
||||||
|
self.doc.move_page(self.current_page_index, -1)
|
||||||
|
|
||||||
|
# Initialize settings for the page that shifted into the current slot
|
||||||
|
self._initialize_current_page_settings()
|
||||||
|
|
||||||
|
# Reload the canvas to show the new page
|
||||||
|
self.load_page()
|
||||||
|
|
||||||
|
def confirm_and_next_page(self, event=None, keep="both"):
|
||||||
|
"""Saves the settings for the current page and moves to the next."""
|
||||||
|
self.page_settings.append({
|
||||||
|
"line_x": self.current_line_x,
|
||||||
|
"rotation": self.current_rotation,
|
||||||
|
"keep": keep
|
||||||
|
})
|
||||||
|
|
||||||
|
self.current_page_index += 1
|
||||||
|
|
||||||
|
if self.current_page_index < len(self.doc):
|
||||||
|
self._initialize_current_page_settings()
|
||||||
|
self.load_page()
|
||||||
|
else:
|
||||||
|
if not self.finish_and_process():
|
||||||
|
return
|
||||||
|
self.history.append(self.pdf_path)
|
||||||
|
if self.setup_next_file():
|
||||||
|
self._initialize_current_page_settings()
|
||||||
|
self.load_page()
|
||||||
|
else:
|
||||||
|
self.master.destroy()
|
||||||
|
|
||||||
|
def finish_and_process(self) -> bool:
|
||||||
|
"""Render and transactionally install the processed PDF."""
|
||||||
|
try:
|
||||||
|
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."""
|
||||||
|
if not self.history:
|
||||||
|
return # Nowhere to go back to
|
||||||
|
|
||||||
|
# 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. Reprocess the previous file from its preserved original backup
|
||||||
|
prev_file = self.history.pop()
|
||||||
|
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()
|
||||||
|
self._initialize_current_page_settings()
|
||||||
|
self.load_page()
|
||||||
|
|
||||||
|
def split_filename_left(self, i):
|
||||||
|
return os.path.join(self.split_dir, f"{self.base_name}_{i+1}l.pdf")
|
||||||
|
def split_filename_right(self, i):
|
||||||
|
return os.path.join(self.split_dir, f"{self.base_name}_{i+1}r.pdf")
|
||||||
|
def reorder_filename(self, i):
|
||||||
|
return os.path.join(self.reorder_dir, f"{self.base_name}_{i+1}.pdf")
|
||||||
|
|
||||||
|
def clean_up_dir(self, dir, make=True):
|
||||||
|
if make:
|
||||||
|
os.makedirs(dir, exist_ok=True)
|
||||||
|
pdf_files = glob.glob(os.path.join(dir, "*.pdf"))
|
||||||
|
for pdf in pdf_files:
|
||||||
|
try:
|
||||||
|
os.remove(pdf)
|
||||||
|
except OSError as e:
|
||||||
|
print(f"Error deleting {pdf}: {e}")
|
||||||
|
|
||||||
|
def split_pdf(self):
|
||||||
|
"""Splits each page of the PDF according to the saved settings."""
|
||||||
|
print("Starting PDF processing...")
|
||||||
|
self.clean_up_dir(self.split_dir)
|
||||||
|
|
||||||
|
for i, settings in enumerate(self.page_settings):
|
||||||
|
page = self.doc.load_page(i)
|
||||||
|
line_x = settings['line_x']
|
||||||
|
rotation_settings = settings['rotation']
|
||||||
|
keep = settings['keep']
|
||||||
|
rotation = (page.rotation + rotation_settings +
|
||||||
|
self.file_rotation + self.global_rotation) % 360
|
||||||
|
|
||||||
|
if keep == "as_is":
|
||||||
|
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)
|
||||||
|
|
||||||
|
output_path_full = self.split_filename_left(i)
|
||||||
|
doc_full.save(output_path_full)
|
||||||
|
doc_full.close()
|
||||||
|
continue # Skip left/right generation
|
||||||
|
|
||||||
|
# --- Create Left Part ---
|
||||||
|
if rotation == 0:
|
||||||
|
rect_left = pymupdf.Rect(0, 0, line_x, page.rect.height)
|
||||||
|
else:
|
||||||
|
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 = 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)
|
||||||
|
|
||||||
|
output_path_left = self.split_filename_left(i)
|
||||||
|
doc_left.save(output_path_left)
|
||||||
|
doc_left.close()
|
||||||
|
|
||||||
|
# --- Create Right Part ---
|
||||||
|
if rotation == 0:
|
||||||
|
rect_right = pymupdf.Rect(line_x, 0, page.rect.width, page.rect.height)
|
||||||
|
else:
|
||||||
|
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 = 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)
|
||||||
|
output_path_right = self.split_filename_right(i)
|
||||||
|
doc_right.save(output_path_right)
|
||||||
|
doc_right.close()
|
||||||
|
|
||||||
|
self.doc.close()
|
||||||
|
print(f"\nProcessing complete. Files are in '{self.split_dir}' directory.")
|
||||||
|
|
||||||
|
def reorder_pdfs(self):
|
||||||
|
"""Reordonne les pages, si ce sont des copies doubles."""
|
||||||
|
self.clean_up_dir(self.reorder_dir)
|
||||||
|
ps = self.page_settings
|
||||||
|
ri = 0
|
||||||
|
i = 0
|
||||||
|
while i < len(ps):
|
||||||
|
psk = ps[i]['keep']
|
||||||
|
|
||||||
|
# Si c'est une copie double (on s'assure qu'on a bien 2 pages consécutives modifiables)
|
||||||
|
if psk in ["both", "right", "left", "none"] and i < len(ps)-1 and ps[i+1]['keep'] in ["both", "right", "left", "none"]:
|
||||||
|
|
||||||
|
# 1. Page de garde (Extérieur Droit)
|
||||||
|
if ps[i]['keep'] in ["both", "right"]:
|
||||||
|
shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
|
||||||
|
ri += 1
|
||||||
|
|
||||||
|
# 2. Intérieur Gauche
|
||||||
|
if ps[i+1]['keep'] in ["both", "left"]:
|
||||||
|
shutil.copy2(self.split_filename_left(i+1), self.reorder_filename(ri))
|
||||||
|
ri += 1
|
||||||
|
|
||||||
|
# 3. Intérieur Droit
|
||||||
|
if ps[i+1]['keep'] in ["both", "right"]:
|
||||||
|
shutil.copy2(self.split_filename_right(i+1), self.reorder_filename(ri))
|
||||||
|
ri += 1
|
||||||
|
|
||||||
|
# 4. Dos de la copie (Extérieur Gauche)
|
||||||
|
if ps[i]['keep'] in ["both", "left"]:
|
||||||
|
shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri))
|
||||||
|
ri += 1
|
||||||
|
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
# Si c'est une page simple (ou as_is)
|
||||||
|
if psk in ["left", "both", "as_is"]:
|
||||||
|
shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri))
|
||||||
|
ri += 1
|
||||||
|
if psk in ["right", "both"]:
|
||||||
|
shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
|
||||||
|
ri += 1
|
||||||
|
i += 1
|
||||||
|
# def reorder_pdfs(self):
|
||||||
|
# """Reordonne les pages, si ce sont des copies doubles."""
|
||||||
|
# self.clean_up_dir(self.reorder_dir)
|
||||||
|
# ps = self.page_settings
|
||||||
|
# ri = 0
|
||||||
|
# i = 0
|
||||||
|
# while i < len(ps):
|
||||||
|
# # Si c'est une copie double
|
||||||
|
# if (ps[i]['keep'] == "both" or ps[i]['keep'] == "right") \
|
||||||
|
# and i < len(ps)-1 and (ps[i+1]['keep'] != "right"):
|
||||||
|
# shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
|
||||||
|
# ri += 1
|
||||||
|
# if ps[i+1]['keep'] != "none":
|
||||||
|
# shutil.copy2(self.split_filename_left(i+1), self.reorder_filename(ri))
|
||||||
|
# ri += 1
|
||||||
|
# if ps[i+1]['keep'] != "left":
|
||||||
|
# shutil.copy2(self.split_filename_right(i+1), self.reorder_filename(ri))
|
||||||
|
# ri += 1
|
||||||
|
# if ps[i]['keep'] == "both":
|
||||||
|
# shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri))
|
||||||
|
# ri += 1
|
||||||
|
# i += 2
|
||||||
|
# else:
|
||||||
|
# psk = ps[i]['keep']
|
||||||
|
# if psk == "left" or psk == "both" or psk == "as_is":
|
||||||
|
# shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri))
|
||||||
|
# ri += 1
|
||||||
|
# if psk == "right" or psk == "both":
|
||||||
|
# shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
|
||||||
|
# ri += 1
|
||||||
|
# i += 1
|
||||||
|
|
||||||
|
def concate_files(self):
|
||||||
|
writer = PdfWriter()
|
||||||
|
|
||||||
|
def natural_key(text):
|
||||||
|
return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', text)]
|
||||||
|
|
||||||
|
pdf_files = sorted(
|
||||||
|
glob.glob(os.path.join(self.reorder_dir, "*.pdf")),
|
||||||
|
key=natural_key
|
||||||
|
)
|
||||||
|
|
||||||
|
for pdf in pdf_files:
|
||||||
|
reader = PdfReader(pdf)
|
||||||
|
for page in reader.pages:
|
||||||
|
writer.add_page(page)
|
||||||
|
|
||||||
|
if self.output_dir != None:
|
||||||
|
os.makedirs(os.path.dirname(self.final_file), exist_ok=True)
|
||||||
|
with open(self.final_file, "wb") as f:
|
||||||
|
writer.write(f)
|
||||||
|
print(f"Created merged PDF: {self.final_file}")
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
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()
|
||||||
|
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())
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import copy
|
||||||
|
import queue
|
||||||
|
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 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
|
||||||
|
MISSING_LABEL_COLOR = "orange"
|
||||||
|
COMMON_MISSING_LABEL_COLOR = "#403a00"
|
||||||
|
COMMON_MISSING_THRESHOLD = 0.66
|
||||||
|
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype("DejaVuSans.ttf", size=30)
|
||||||
|
except OSError:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
# --- Helper Functions (Shared) ---
|
||||||
|
|
||||||
|
def page_number(b, nb_pages):
|
||||||
|
column_width = 1000 // 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\
|
||||||
|
+ (1000 // tot_dest) * (npn - 1)
|
||||||
|
l[3] = (l[3] - (1000 // tot_ori) * (pn_ori-1)) * tot_ori // tot_dest\
|
||||||
|
+ (1000 // tot_dest) * (npn - 1)
|
||||||
|
return l
|
||||||
|
|
||||||
|
def convert_list(l, group_id, json_schema):
|
||||||
|
ll = []
|
||||||
|
nb_pages = json_schema["columns_per_file"][group_id-1]
|
||||||
|
nb_previous_pages = sum([json_schema["columns_per_file"][i] for i in range(group_id-1)])
|
||||||
|
nb_tot_pages = sum([e for e in json_schema["columns_per_file"]])
|
||||||
|
for e in l:
|
||||||
|
ee = e.copy()
|
||||||
|
pn = page_number(e["box_2d"], nb_pages)
|
||||||
|
npn = pn + nb_previous_pages
|
||||||
|
ee["box_2d"] = convert_box2d(ee["box_2d"], pn, npn, nb_pages, nb_tot_pages)
|
||||||
|
ee["part"] = group_id
|
||||||
|
ee["pn"] = npn
|
||||||
|
ll.append(ee)
|
||||||
|
return ll
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
common_missing = common_missing or set()
|
||||||
|
|
||||||
|
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)
|
||||||
|
raw_x_max = int(bbox["box_2d"][3] * width / 1000)
|
||||||
|
abs_y_min = max(0, raw_y_min - 10)
|
||||||
|
abs_x_min = max(0, raw_x_min - 10)
|
||||||
|
abs_y_max = min(height, raw_y_max + 10)
|
||||||
|
abs_x_max = min(width, raw_x_max + 10)
|
||||||
|
|
||||||
|
label = bbox.get("label")
|
||||||
|
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:
|
||||||
|
if abs_y_min > 80:
|
||||||
|
draw.text((abs_x_min + 8, abs_y_min - 30), label, fill=color, font=font)
|
||||||
|
else:
|
||||||
|
draw.text((abs_x_min + 8, abs_y_max + 6), label, fill=color, font=font)
|
||||||
|
return (new_im, last_label_index)
|
||||||
|
|
||||||
|
# --- Processing Logic (Worker Thread) ---
|
||||||
|
|
||||||
|
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:])
|
||||||
|
copie = img_path.stem[:-3]
|
||||||
|
if copie != previous_copie:
|
||||||
|
last_label_index = -1
|
||||||
|
previous_copie = copie
|
||||||
|
json_schema_path = base_dir / 'Cutleft' / f"{copie}_schema.json"
|
||||||
|
|
||||||
|
try:
|
||||||
|
json_schema = read_json(json_schema_path)
|
||||||
|
except (OSError, TypeError, ValueError):
|
||||||
|
print("No json_schema : ", json_schema_path)
|
||||||
|
continue
|
||||||
|
|
||||||
|
nb_pages = json_schema["columns_per_file"][copie_part-1]
|
||||||
|
|
||||||
|
if json_path.exists():
|
||||||
|
# Read strictly for visualization purposes
|
||||||
|
bb_list = []
|
||||||
|
json_name = ""
|
||||||
|
try:
|
||||||
|
json_result = read_json(json_path)
|
||||||
|
bb_list = json_result.get("list", [])
|
||||||
|
json_name = json_result.get("name", "")
|
||||||
|
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,
|
||||||
|
common_missing,
|
||||||
|
)
|
||||||
|
error_msg = None
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"copie": copie,
|
||||||
|
"part": copie_part,
|
||||||
|
"schema": json_schema,
|
||||||
|
"name": json_name,
|
||||||
|
"error": error_msg
|
||||||
|
}
|
||||||
|
|
||||||
|
output_queue.put((pil_image, json_path, metadata))
|
||||||
|
|
||||||
|
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, workspace, valid_labels, input_queue):
|
||||||
|
self.root = root
|
||||||
|
self.root.resizable(False, False) # If you resize, coordinates will be wrong
|
||||||
|
|
||||||
|
screen_w = root.winfo_screenwidth()
|
||||||
|
screen_h = root.winfo_screenheight()
|
||||||
|
|
||||||
|
x = int(screen_w * 0.1)
|
||||||
|
y = int(screen_h * 0.05)
|
||||||
|
|
||||||
|
root.geometry(f"+{x}+{y}")
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Display State
|
||||||
|
self.current_image = None
|
||||||
|
self.current_json_path = None
|
||||||
|
self.current_meta = None # Stores schema/copie info
|
||||||
|
self.is_viewing = False
|
||||||
|
self.scale_factor = 1.0
|
||||||
|
self.orig_size = (1, 1)
|
||||||
|
|
||||||
|
# Data Aggregation State
|
||||||
|
self.active_copie_name = None
|
||||||
|
self.accumulated_results = None # Dict with "name" and "list"
|
||||||
|
|
||||||
|
# To go back
|
||||||
|
self.history = []
|
||||||
|
self.forward_stack = []
|
||||||
|
self.current_pil_image = None
|
||||||
|
self.failed = False
|
||||||
|
|
||||||
|
from config import PLOTTING_KB
|
||||||
|
|
||||||
|
# Bindings
|
||||||
|
self.root.bind(PLOTTING_KB["OK"], self.on_enter)
|
||||||
|
self.root.bind(PLOTTING_KB["previous"], self.on_previous)
|
||||||
|
self.root.bind(PLOTTING_KB["edit"], self.on_edit)
|
||||||
|
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 _event: self.close())
|
||||||
|
self.root.protocol("WM_DELETE_WINDOW", self.close)
|
||||||
|
self.label.bind('<Button-1>', self.on_click)
|
||||||
|
|
||||||
|
self.poll_queue()
|
||||||
|
|
||||||
|
def poll_queue(self):
|
||||||
|
if not self.is_viewing:
|
||||||
|
try:
|
||||||
|
# pil_image, json_path, metadata = image_queue.get_nowait()
|
||||||
|
if self.forward_stack:
|
||||||
|
pil_image, json_path, metadata = self.forward_stack.pop()
|
||||||
|
else:
|
||||||
|
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()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if we switched to a new "Copie" group
|
||||||
|
if self.active_copie_name != metadata["copie"]:
|
||||||
|
self.save_current_batch() # Write previous group to disk
|
||||||
|
# Start new batch
|
||||||
|
self.active_copie_name = metadata["copie"]
|
||||||
|
self.accumulated_results = {"name": metadata["name"], "list": []}
|
||||||
|
|
||||||
|
self.display_image(pil_image, json_path, metadata)
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
self.root.after(100, self.poll_queue)
|
||||||
|
|
||||||
|
def save_current_batch(self):
|
||||||
|
"""Writes the accumulated data to the main JSON file."""
|
||||||
|
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}")
|
||||||
|
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,
|
||||||
|
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):
|
||||||
|
self.current_pil_image = pil_image # ADD THIS LINE
|
||||||
|
self.orig_size = pil_image.size
|
||||||
|
self.scale_factor = 1.0
|
||||||
|
screen_h = self.root.winfo_screenheight() - 100
|
||||||
|
if pil_image.height > screen_h:
|
||||||
|
self.scale_factor = screen_h / pil_image.height
|
||||||
|
pil_image = pil_image.resize((int(pil_image.width * self.scale_factor),
|
||||||
|
int(pil_image.height * self.scale_factor)))
|
||||||
|
|
||||||
|
self.tk_image = ImageTk.PhotoImage(pil_image)
|
||||||
|
self.label.config(image=self.tk_image, text=f"Processing: {json_path.name}")
|
||||||
|
self.current_json_path = json_path
|
||||||
|
self.current_meta = metadata
|
||||||
|
self.is_viewing = True
|
||||||
|
self.root.lift()
|
||||||
|
|
||||||
|
if metadata.get("error"):
|
||||||
|
msg = f"Error generating boxes for {json_path.name}:\n\n{metadata['error']}\n\nPlease press 'e' to fix the JSON file, then press Enter to retry."
|
||||||
|
messagebox.showerror("Processing Error", msg)
|
||||||
|
|
||||||
|
def on_enter(self, event):
|
||||||
|
if self.is_viewing:
|
||||||
|
print(f"Committing data for {self.current_json_path.name}...")
|
||||||
|
previous_copie_name = self.active_copie_name
|
||||||
|
previous_results = copy.deepcopy(self.accumulated_results)
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_data = read_json(self.current_json_path)
|
||||||
|
items = current_data["list"]
|
||||||
|
|
||||||
|
# Perform the conversion now, post-edit
|
||||||
|
converted_items = convert_list(
|
||||||
|
items,
|
||||||
|
self.current_meta["part"],
|
||||||
|
self.current_meta["schema"]
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
# Add to accumulator
|
||||||
|
if self.accumulated_results:
|
||||||
|
self.accumulated_results["list"].extend(converted_items)
|
||||||
|
# Update name just in case (though usually consistent per group)
|
||||||
|
if "name" in current_data and current_data["name"] != "Continued":
|
||||||
|
self.accumulated_results["name"] = current_data["name"]
|
||||||
|
|
||||||
|
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,
|
||||||
|
previous_copie_name,
|
||||||
|
previous_results,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Advance UI
|
||||||
|
self.is_viewing = False
|
||||||
|
self.label.config(image="", text="Loading next...")
|
||||||
|
|
||||||
|
def on_open_pdf(self, event):
|
||||||
|
if self.is_viewing and self.current_json_path:
|
||||||
|
a = self.current_json_path.stem.split('_')[0] + ".pdf"
|
||||||
|
pdf_path = self.current_json_path.with_name(a)
|
||||||
|
print(f"Opening {pdf_path}")
|
||||||
|
open_path(pdf_path)
|
||||||
|
|
||||||
|
def on_open_interro(self, event):
|
||||||
|
if self.is_viewing and self.current_json_path:
|
||||||
|
# Check local directory first
|
||||||
|
local_accent = self.base_dir / "énoncé.pdf"
|
||||||
|
local_plain = self.base_dir / "enonce.pdf"
|
||||||
|
|
||||||
|
if local_accent.exists():
|
||||||
|
pdf_path = str(local_accent)
|
||||||
|
elif local_plain.exists():
|
||||||
|
pdf_path = local_plain
|
||||||
|
else:
|
||||||
|
messagebox.showerror(
|
||||||
|
"PDF not found",
|
||||||
|
f"Neither {local_accent.name} nor {local_plain.name} exists in {self.base_dir}.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Opening {pdf_path}")
|
||||||
|
open_path(pdf_path)
|
||||||
|
|
||||||
|
def on_open_ori_pdf(self, event):
|
||||||
|
if self.is_viewing and self.current_json_path:
|
||||||
|
new_filename = self.current_json_path.stem.split('_')[0] + ".pdf"
|
||||||
|
pdf_path = self.base_dir / "Copies Originales" / new_filename
|
||||||
|
print(f"Opening {pdf_path}")
|
||||||
|
open_path(pdf_path)
|
||||||
|
|
||||||
|
def on_edit(self, event):
|
||||||
|
if self.is_viewing and self.current_json_path:
|
||||||
|
print(f"Opening {self.current_json_path}")
|
||||||
|
open_path(self.current_json_path)
|
||||||
|
|
||||||
|
def on_click(self, event):
|
||||||
|
if not self.is_viewing: return
|
||||||
|
x = int(event.x / self.scale_factor)
|
||||||
|
y = int(event.y / self.scale_factor)
|
||||||
|
w, h = self.orig_size
|
||||||
|
box = [
|
||||||
|
int(max(0, y - 5) / h * 1000),
|
||||||
|
int(max(0, x - 5) / (w- padding) * 1000),
|
||||||
|
int(min(h, y + 5) / h * 1000),
|
||||||
|
int(min(w, x + 5) / (w - padding) * 1000),
|
||||||
|
]
|
||||||
|
box_str = "{ \"box_2d\": " + str(box) + ", \"label\": \"\" },"
|
||||||
|
print(f"Copied box at ({x},{y}): {box_str}")
|
||||||
|
self.root.clipboard_clear()
|
||||||
|
self.root.clipboard_append(box_str)
|
||||||
|
|
||||||
|
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__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from copienator import (
|
||||||
|
EvaluationWorkspace,
|
||||||
|
ExitCode,
|
||||||
|
atomic_write_bytes,
|
||||||
|
atomic_write_json,
|
||||||
|
evaluation_parser,
|
||||||
|
execute,
|
||||||
|
read_json,
|
||||||
|
workspace_from_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
return evaluation_parser("Clean encoding and LaTeX issues in correction.json.")
|
||||||
|
|
||||||
|
|
||||||
|
def escape_latex_underscores(text: str) -> str:
|
||||||
|
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):
|
||||||
|
start, end = match.span()
|
||||||
|
parts.append(escape_plain(text[last_end:start]))
|
||||||
|
parts.append(match.group(0))
|
||||||
|
last_end = end
|
||||||
|
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] = {}
|
||||||
|
for word in words:
|
||||||
|
broken_key = ACCENT_PATTERN.sub("\x00", word)
|
||||||
|
if "\x00" in broken_key:
|
||||||
|
lookup[broken_key.lower()] = word
|
||||||
|
return lookup
|
||||||
|
|
||||||
|
|
||||||
|
def fast_fix(text: str, lookup: dict[str, str]) -> str:
|
||||||
|
def replacer(match: re.Match[str]) -> str:
|
||||||
|
broken_word = match.group(0)
|
||||||
|
return lookup.get(broken_word.lower(), broken_word)
|
||||||
|
|
||||||
|
return re.sub(r"[a-zA-Z\x00]+", replacer, text)
|
||||||
|
|
||||||
|
|
||||||
|
def fix_hex_corruption_safe(text: str) -> str:
|
||||||
|
return re.sub(
|
||||||
|
r"\x00([eEfF][0-9a-fA-F])",
|
||||||
|
lambda match: chr(int(match.group(1), 16)),
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
text = fix_hex_corruption_safe(text)
|
||||||
|
text = text.replace("\x19", "\x00")
|
||||||
|
text = text.replace("\x18", "\x00")
|
||||||
|
text = text.replace("\x00\x00", "\x00")
|
||||||
|
text = re.sub(r" \x00{1,2} ", " à ", text)
|
||||||
|
if "\x00" in text:
|
||||||
|
text = fast_fix(text, lookup).replace("\x00", "")
|
||||||
|
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:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return clean_string(value, lookup)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [clean_obj(item, lookup) for item in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
key: item if key == "suffix" else clean_obj(item, lookup)
|
||||||
|
for key, item in value.items()
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def run(
|
||||||
|
workspace: EvaluationWorkspace,
|
||||||
|
*,
|
||||||
|
word_list_path: Path = WORD_LIST_FILE,
|
||||||
|
) -> ExitCode:
|
||||||
|
workspace.require_files("correction.json")
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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())
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from pdf2image import convert_from_path
|
||||||
|
from PIL import Image, ImageChops, ImageDraw, ImageFilter
|
||||||
|
|
||||||
|
from copienator.commands import annotating
|
||||||
|
from copienator import configuration, 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.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
|
||||||
|
|
||||||
|
|
||||||
|
def detect_checks_and_notes(
|
||||||
|
output_dir: str | Path,
|
||||||
|
) -> tuple[list[dict[str, Any]], Image.Image | None]:
|
||||||
|
"""Detect checked boxes and extract handwritten notes from an annotated PDF."""
|
||||||
|
directory = Path(output_dir)
|
||||||
|
pdf_path = directory / "Concat_annotated.pdf"
|
||||||
|
reference_path = directory / "Reference.jpg"
|
||||||
|
boxes_path = directory / "checkboxes.json"
|
||||||
|
missing = [
|
||||||
|
path.name
|
||||||
|
for path in (pdf_path, reference_path, boxes_path)
|
||||||
|
if not path.is_file()
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
print(f"\tMissing annotation input in {directory}: {', '.join(missing)}")
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
boxes = read_json(boxes_path)
|
||||||
|
if not isinstance(boxes, list):
|
||||||
|
raise TypeError(f"Expected a JSON array in {boxes_path}")
|
||||||
|
with Image.open(reference_path) as opened_reference:
|
||||||
|
reference = opened_reference.convert("RGB").copy()
|
||||||
|
|
||||||
|
try:
|
||||||
|
pages = convert_from_path(pdf_path, dpi=72)
|
||||||
|
except Exception as exc: # noqa: BLE001 - PDF backends expose many errors
|
||||||
|
print(f"Error reading PDF {pdf_path}: {exc}")
|
||||||
|
return [], None
|
||||||
|
if not pages:
|
||||||
|
print(f"Error reading PDF {pdf_path}: no page found")
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
user_image = Image.new("RGB", (pages[0].width, sum(page.height for page in pages)))
|
||||||
|
current_y = 0
|
||||||
|
for page in pages:
|
||||||
|
user_image.paste(page.convert("RGB"), (0, current_y))
|
||||||
|
current_y += page.height
|
||||||
|
if user_image.size != reference.size:
|
||||||
|
print(f" Resizing annotated PDF from {user_image.size} to {reference.size}")
|
||||||
|
user_image = user_image.resize(reference.size, Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# 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]] = []
|
||||||
|
|
||||||
|
for raw_box in boxes:
|
||||||
|
if not isinstance(raw_box, dict) or "global_box" not in raw_box:
|
||||||
|
continue
|
||||||
|
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[y1 + 5 : y2 - 5, x1 + 5 : x2 - 5]
|
||||||
|
if region.size == 0:
|
||||||
|
continue
|
||||||
|
# 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)
|
||||||
|
else:
|
||||||
|
mask_draw.rectangle([x1 - 2, y1 - 2, x2 + 2, y2 + 2], fill=0)
|
||||||
|
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")
|
||||||
|
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(alpha))
|
||||||
|
return actions, notes
|
||||||
|
|
||||||
|
|
||||||
|
def has_significant_notes(note_img: Image.Image | None, threshold: int = 20) -> bool:
|
||||||
|
"""Return whether an RGBA note layer contains enough visible pixels."""
|
||||||
|
if note_img is None or note_img.mode != "RGBA":
|
||||||
|
return False
|
||||||
|
alpha = np.array(note_img)[:, :, 3]
|
||||||
|
return bool(np.sum(alpha > 50) > threshold)
|
||||||
|
|
||||||
|
|
||||||
|
def concatenate(images: list[Image.Image]) -> Image.Image | None:
|
||||||
|
if not images:
|
||||||
|
return None
|
||||||
|
result = Image.new(
|
||||||
|
"RGB",
|
||||||
|
(max(image.width for image in images), sum(image.height for image in images)),
|
||||||
|
"white",
|
||||||
|
)
|
||||||
|
current_y = 0
|
||||||
|
for image in images:
|
||||||
|
result.paste(image, (0, current_y))
|
||||||
|
current_y += image.height
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def apply_actions_and_regenerate(
|
||||||
|
workspace: EvaluationWorkspace,
|
||||||
|
data: AnnotationData,
|
||||||
|
student_id: str,
|
||||||
|
actions: list[dict[str, Any]],
|
||||||
|
notes_layer: Image.Image | None,
|
||||||
|
all_labels: list[str],
|
||||||
|
*,
|
||||||
|
update_score: bool = False,
|
||||||
|
) -> ExitCode:
|
||||||
|
"""Apply annotations and atomically merge the regenerated student files."""
|
||||||
|
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
|
||||||
|
bnote_path = output_dir / "bnote.json"
|
||||||
|
if not bnote_path.is_file():
|
||||||
|
print(f" Missing {bnote_path}")
|
||||||
|
return ExitCode.PARTIAL
|
||||||
|
bnote_data = read_json(bnote_path)
|
||||||
|
if not isinstance(bnote_data, dict):
|
||||||
|
raise TypeError(f"Expected a JSON object in {bnote_path}")
|
||||||
|
|
||||||
|
labels_data = data[student_id]
|
||||||
|
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:
|
||||||
|
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] = []
|
||||||
|
incomplete = False
|
||||||
|
|
||||||
|
for image_info in bnote_data.get("images", []):
|
||||||
|
if not isinstance(image_info, dict):
|
||||||
|
incomplete = True
|
||||||
|
continue
|
||||||
|
label = str(image_info.get("label", ""))
|
||||||
|
if label not in labels_data:
|
||||||
|
incomplete = True
|
||||||
|
continue
|
||||||
|
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:
|
||||||
|
hmin = int(image_info.get("hmin", 0))
|
||||||
|
hmax = int(image_info.get("hmax", 0))
|
||||||
|
sub_note = notes_layer.crop((0, hmin, notes_layer.width, hmax))
|
||||||
|
has_notes = has_significant_notes(sub_note)
|
||||||
|
|
||||||
|
pdf_path = Path(content["pdf_path"])
|
||||||
|
if not pdf_path.is_file():
|
||||||
|
print(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
|
||||||
|
|
||||||
|
if has_notes and sub_note is not None:
|
||||||
|
old_header_height = int(image_info.get("header_height", 0))
|
||||||
|
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)
|
||||||
|
|
||||||
|
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, 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")
|
||||||
|
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,
|
||||||
|
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:
|
||||||
|
print(f"Warning: {warning}")
|
||||||
|
if not loaded.data:
|
||||||
|
print("No annotation data found.")
|
||||||
|
return ExitCode.PARTIAL
|
||||||
|
|
||||||
|
status = ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
|
||||||
|
for student_id in sorted(loaded.data, key=utils.natural_key):
|
||||||
|
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
|
||||||
|
if not output_dir.is_dir():
|
||||||
|
print(f"Warning: missing annotation directory {output_dir}")
|
||||||
|
status = ExitCode.PARTIAL
|
||||||
|
continue
|
||||||
|
print(f"Processing annotations for: {student_id}")
|
||||||
|
actions, notes = detect_checks_and_notes(output_dir)
|
||||||
|
if notes is None and not actions and not update_score:
|
||||||
|
print(" No readable annotation input found.")
|
||||||
|
status = ExitCode.PARTIAL
|
||||||
|
continue
|
||||||
|
result = apply_actions_and_regenerate(
|
||||||
|
workspace,
|
||||||
|
loaded.data,
|
||||||
|
student_id,
|
||||||
|
actions,
|
||||||
|
notes,
|
||||||
|
all_labels,
|
||||||
|
update_score=update_score,
|
||||||
|
)
|
||||||
|
if result != ExitCode.SUCCESS:
|
||||||
|
status = ExitCode.PARTIAL
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = evaluation_parser("Read checked annotations and regenerate copies")
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pypdf import PdfWriter
|
||||||
|
from copienator.pdf_cut import split_pdf
|
||||||
|
|
||||||
|
from copienator import (
|
||||||
|
CliError,
|
||||||
|
EvaluationWorkspace,
|
||||||
|
ExitCode,
|
||||||
|
atomic_write_json,
|
||||||
|
evaluation_parser,
|
||||||
|
execute,
|
||||||
|
read_json,
|
||||||
|
workspace_from_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
OPERATORS = ("-x", "->", "x>", "ss", "sx", "xx", "xs")
|
||||||
|
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+(.+)")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ManualInstruction:
|
||||||
|
copy_id: str
|
||||||
|
old_label: str
|
||||||
|
operator: str
|
||||||
|
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(">")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def should_copy(self) -> bool:
|
||||||
|
return not self.should_merge and "s" not in self.operator
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
return evaluation_parser("Apply the instructions from manual_resolutions.txt.")
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
text.splitlines(), start=1
|
||||||
|
):
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("###"):
|
||||||
|
continue
|
||||||
|
operator_match = OPERATOR_PATTERN.search(line)
|
||||||
|
if operator_match is None:
|
||||||
|
malformed.append(line_number)
|
||||||
|
continue
|
||||||
|
left = line[: operator_match.start()].strip()
|
||||||
|
right = line[operator_match.end() :].strip()
|
||||||
|
copy_match = COPY_PATTERN.fullmatch(left)
|
||||||
|
new_label = right.strip("|").strip()
|
||||||
|
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(
|
||||||
|
ManualInstruction(
|
||||||
|
copy_id=copy_match.group(1),
|
||||||
|
old_label=copy_match.group(2).strip(),
|
||||||
|
operator=operator_match.group(1),
|
||||||
|
new_label=new_label,
|
||||||
|
pipe_first=right.startswith("|"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if malformed:
|
||||||
|
lines = ", ".join(str(number) for number in malformed)
|
||||||
|
raise CliError(f"Malformed manual resolution instruction at line(s): {lines}")
|
||||||
|
return instructions
|
||||||
|
|
||||||
|
|
||||||
|
def set_suffix_and_clean_error(
|
||||||
|
results: dict[str, Any],
|
||||||
|
copy_id: str,
|
||||||
|
label: str,
|
||||||
|
suffix: str | None,
|
||||||
|
new_label_target: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
for batch in results.get(label, []):
|
||||||
|
for item in batch:
|
||||||
|
if item["id"] != copy_id:
|
||||||
|
continue
|
||||||
|
if suffix:
|
||||||
|
item["result"]["suffix"] = suffix
|
||||||
|
error = item["result"].get("error", "")
|
||||||
|
if 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:
|
||||||
|
base = copies_dir / f"Copie{copy_id}" / f"{label}.pdf"
|
||||||
|
for candidate in (
|
||||||
|
base,
|
||||||
|
base.with_name(f"{label}_new.pdf"),
|
||||||
|
base.with_name(f"{label}_old.pdf"),
|
||||||
|
):
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def safe_strip_suffix(stem: str) -> str:
|
||||||
|
if stem.endswith(("_new", "_old")):
|
||||||
|
return stem[:-4]
|
||||||
|
return stem
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_pdf_inputs(
|
||||||
|
instructions: list[ManualInstruction],
|
||||||
|
initial_paths: dict[tuple[str, str], Path],
|
||||||
|
) -> None:
|
||||||
|
missing: set[Path] = set()
|
||||||
|
for instruction in instructions:
|
||||||
|
source = initial_paths[(instruction.copy_id, instruction.old_label)]
|
||||||
|
destination = initial_paths[(instruction.copy_id, instruction.new_label)]
|
||||||
|
if instruction.should_merge:
|
||||||
|
if not source.exists():
|
||||||
|
missing.add(source)
|
||||||
|
if not destination.exists():
|
||||||
|
missing.add(destination)
|
||||||
|
elif instruction.should_copy and not source.exists():
|
||||||
|
missing.add(source)
|
||||||
|
if missing:
|
||||||
|
rendered = ", ".join(str(path) for path in sorted(missing))
|
||||||
|
raise CliError(f"PDF input(s) required by manual resolutions not found: {rendered}")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
|
||||||
|
workspace.require_files("manual_resolutions.txt", "correction.json")
|
||||||
|
workspace.require_directories("Copies")
|
||||||
|
loaded = read_json(workspace.correction_file)
|
||||||
|
if not isinstance(loaded, dict):
|
||||||
|
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] = {}
|
||||||
|
for instruction in instructions:
|
||||||
|
for label in (instruction.old_label, instruction.new_label):
|
||||||
|
key = (instruction.copy_id, label)
|
||||||
|
if key not in initial_paths:
|
||||||
|
path = get_actual_pdf(workspace.copies_dir, *key)
|
||||||
|
initial_paths[key] = path
|
||||||
|
current_paths[key] = path
|
||||||
|
_validate_pdf_inputs(instructions, initial_paths)
|
||||||
|
|
||||||
|
files_to_old: set[Path] = set()
|
||||||
|
temp_files: list[Path] = []
|
||||||
|
try:
|
||||||
|
for instruction in instructions:
|
||||||
|
key_old = (instruction.copy_id, instruction.old_label)
|
||||||
|
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}"
|
||||||
|
/ f"temp_{len(temp_files)}.pdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
if instruction.operator.startswith("x"):
|
||||||
|
files_to_old.add(initial_paths[key_old])
|
||||||
|
if instruction.operator.endswith("x"):
|
||||||
|
files_to_old.add(initial_paths[key_new])
|
||||||
|
|
||||||
|
if instruction.should_merge:
|
||||||
|
writer = PdfWriter()
|
||||||
|
try:
|
||||||
|
if instruction.pipe_first:
|
||||||
|
writer.append(source)
|
||||||
|
writer.append(destination)
|
||||||
|
else:
|
||||||
|
writer.append(destination)
|
||||||
|
writer.append(source)
|
||||||
|
writer.write(temp_output)
|
||||||
|
except Exception:
|
||||||
|
temp_output.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
current_paths[key_new] = temp_output
|
||||||
|
temp_files.append(temp_output)
|
||||||
|
files_to_old.add(initial_paths[key_new])
|
||||||
|
elif instruction.should_copy:
|
||||||
|
shutil.copy(source, temp_output)
|
||||||
|
current_paths[key_new] = temp_output
|
||||||
|
temp_files.append(temp_output)
|
||||||
|
except Exception:
|
||||||
|
for temporary in temp_files:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
for pdf in files_to_old:
|
||||||
|
if not pdf.exists():
|
||||||
|
continue
|
||||||
|
copy_id = pdf.parent.name.removeprefix("Copie")
|
||||||
|
label = safe_strip_suffix(pdf.stem)
|
||||||
|
old_name = pdf.with_name(f"{label}_old.pdf")
|
||||||
|
if pdf != old_name:
|
||||||
|
old_name.unlink(missing_ok=True)
|
||||||
|
shutil.move(str(pdf), str(old_name))
|
||||||
|
set_suffix_and_clean_error(results, copy_id, label, "_old")
|
||||||
|
|
||||||
|
for instruction in instructions:
|
||||||
|
set_suffix_and_clean_error(
|
||||||
|
results,
|
||||||
|
instruction.copy_id,
|
||||||
|
instruction.old_label,
|
||||||
|
None,
|
||||||
|
instruction.new_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
refaire_by_copy: dict[str, list[str]] = {}
|
||||||
|
for (copy_id, label), current_path in current_paths.items():
|
||||||
|
if not current_path.name.startswith("temp_"):
|
||||||
|
continue
|
||||||
|
final_name = workspace.copies_dir / f"Copie{copy_id}" / f"{label}_new.pdf"
|
||||||
|
final_name.unlink(missing_ok=True)
|
||||||
|
shutil.move(str(current_path), str(final_name))
|
||||||
|
set_suffix_and_clean_error(results, copy_id, label, "_new")
|
||||||
|
labels = refaire_by_copy.setdefault(f"Copie{copy_id}", [])
|
||||||
|
if label not in labels:
|
||||||
|
labels.append(label)
|
||||||
|
|
||||||
|
used_temps = set(current_paths.values())
|
||||||
|
for temporary in temp_files:
|
||||||
|
if temporary not in used_temps:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
atomic_write_json(workspace.correction_file, results)
|
||||||
|
refaire_tasks = [[copy_name, labels] for copy_name, labels in refaire_by_copy.items()]
|
||||||
|
if refaire_tasks:
|
||||||
|
atomic_write_json(workspace.refaire_file, refaire_tasks)
|
||||||
|
workspace.manual_resolutions_file.unlink()
|
||||||
|
|
||||||
|
print("Manual resolutions successfully applied.")
|
||||||
|
if refaire_tasks:
|
||||||
|
print(
|
||||||
|
f"File {workspace.refaire_file.name} generated. Run "
|
||||||
|
f'`python -m copienator correct "{workspace.command_argument()}" --refaire` '
|
||||||
|
"to process updates."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("No new corrections required.")
|
||||||
|
return ExitCode.SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
def run(workspace: EvaluationWorkspace) -> ExitCode:
|
||||||
|
return resolve_manual(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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
import argparse
|
import argparse
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
|
||||||
import ezodf
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from utils import natural_key, read_all_labels
|
import ezodf
|
||||||
|
|
||||||
|
from copienator.configuration import CURRENT_SCORE_ODS_PATH
|
||||||
|
from copienator.utils import read_all_labels
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
ODS_PATH = "/home/sebastien/Rust/gestion_classe/Staging/current_eval.ods"
|
ODS_PATH = Path(CURRENT_SCORE_ODS_PATH).expanduser()
|
||||||
TARGET_DIR_NAME = "A Rendre"
|
TARGET_DIR_NAME = "A Rendre"
|
||||||
|
|
||||||
def main():
|
def main(argv=None):
|
||||||
parser = argparse.ArgumentParser(description="Update ODS with student scores.")
|
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("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")
|
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)
|
work_dir = os.path.abspath(args.work_dir)
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ def main():
|
|||||||
|
|
||||||
print(f"Opening ODS file: {ODS_PATH}...")
|
print(f"Opening ODS file: {ODS_PATH}...")
|
||||||
try:
|
try:
|
||||||
doc = ezodf.opendoc(ODS_PATH)
|
doc = ezodf.opendoc(str(ODS_PATH))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to open ODS: {e}")
|
print(f"Failed to open ODS: {e}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -168,4 +169,4 @@ def main():
|
|||||||
print("Done.")
|
print("Done.")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
raise SystemExit(main())
|
||||||
@@ -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())
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||