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 |
@@ -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
|
||||
#+date: 14-03-2026
|
||||
# Time-stamp: <08-08-26 12:28>
|
||||
# Time-stamp: <22-08-26 12:22>
|
||||
#+OPTIONS:
|
||||
|
||||
* Méta
|
||||
* Présentation
|
||||
** Quézaco
|
||||
|
||||
Ce dépôt contient un certain nombre de script Python que j'utilise
|
||||
@@ -21,46 +21,129 @@ pour faire corriger des copies par Gemini.
|
||||
4. Ces annotations manuscrites sont lues et recompilées en une
|
||||
version de la copie pour l'élève.
|
||||
|
||||
** Disclaimer
|
||||
** Limitations
|
||||
|
||||
J'utilise régulièrement cet outil et j'en suis satisfait, mais j'ai
|
||||
fait peu d'efforts pour le rendre universel et simple à l'emploi.
|
||||
Plusieurs parties de mon workflow sont spécifiques à mes
|
||||
représentations internes des sujets d'examens et/ou à mon environment.
|
||||
Pour l'instant, la correction est faite question par question : le LLM
|
||||
n'a accès qu'à la partie de la copie correspondant à une question
|
||||
fixée. Ça ne gère donc pas les cas où un argument a été donné dans une
|
||||
question précédente ou autre.
|
||||
|
||||
L'utilisation de ce système nécessite une familiarité avec python et
|
||||
la ligne de commande unix ; cette familiarité n'est probablement pas
|
||||
suffisante en l'état, mais en théorie il devrait être possible de le
|
||||
faire tourner sur le dossier =Example= qui contient une copie
|
||||
initiale, les fichiers correspondant au sujet de l'interro, et des
|
||||
examples du rendu final (dans le sous dossier =BGnot=).
|
||||
*** Noms de labels sous Windows
|
||||
|
||||
Cette situation s'améliorera peut-être, mais faciliter l'utilisation
|
||||
de ce système n'est pas une priorité.
|
||||
Les labels de questions sont utilisés comme noms de fichiers. Les
|
||||
labels contenant notamment =:= ne sont pas acceptés par Windows.
|
||||
|
||||
** Requirements
|
||||
** Prérequis et installation
|
||||
|
||||
*** Python
|
||||
*** Python 3.11 ou plus récent
|
||||
|
||||
Libraries :
|
||||
Sous macOS avec Homebrew, installer Python et la version correspondante
|
||||
de Tkinter avant de créer l'environnement virtuel. Par exemple :
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
pip install numpy pandas matplotlib pillow pydantic pypdf pdf2image reportlab img2pdf pymupdf ftfy ezodf google
|
||||
brew install python@3.13 python-tk@3.13
|
||||
#+END_SRC
|
||||
|
||||
*** Poppler (for pdf2image)
|
||||
Utiliser alors =python3.13= à la place de =python= dans les commandes
|
||||
de création de l'environnement si la commande non versionnée n'est pas
|
||||
disponible.
|
||||
|
||||
+ Linux : install poppler-utils
|
||||
+ Windows : Download from: https://github.com/oschwartz10612/poppler-windows
|
||||
and add it to your PATH
|
||||
Créer et activer un environnement virtuel est recommandé :
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
python -m venv .venv
|
||||
#+END_SRC
|
||||
|
||||
Sous Linux et macOS :
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
source .venv/bin/activate
|
||||
#+END_SRC
|
||||
|
||||
Sous Windows (PowerShell) :
|
||||
|
||||
#+BEGIN_SRC powershell
|
||||
.venv\Scripts\Activate.ps1
|
||||
#+END_SRC
|
||||
|
||||
Installer ensuite Copienator et ses dépendances Python depuis la
|
||||
racine du dépôt :
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e .
|
||||
#+END_SRC
|
||||
|
||||
Cette commande installe les modules contrôlés par le diagnostic du GUI
|
||||
(NumPy, pandas, Matplotlib, Pillow, pydantic, pypdf, pdf2image,
|
||||
ReportLab, img2pdf, PyMuPDF, ftfy, ezodf et Google Gen AI), ainsi que
|
||||
les exécutables =copienator= et =copienator-gui=. Le paquet fournissant
|
||||
=google.genai= est =google-genai=, et non =google=.
|
||||
|
||||
Les commandes restent également accessibles avec
|
||||
=python -m copienator= depuis la racine du dépôt.
|
||||
|
||||
*** Programmes externes obligatoires
|
||||
|
||||
Le diagnostic vérifie que Poppler et LaTeX sont accessibles depuis
|
||||
=PATH=.
|
||||
|
||||
**** Linux (Debian et Ubuntu)
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
sudo apt install poppler-utils python3-tk texlive-latex-extra texlive-fonts-extra lmodern python3-pygments
|
||||
#+END_SRC
|
||||
|
||||
Cette sélection couvre les modèles LaTeX de la configuration par
|
||||
défaut, notamment =standalone=, =lmodern=, =mathabx= et =minted=. Selon
|
||||
les commandes LaTeX utilisées dans vos propres énoncés, d'autres
|
||||
paquets TeX peuvent être nécessaires. =python3-tk= fournit Tkinter sur
|
||||
les installations Python qui ne l'incluent pas d'origine.
|
||||
|
||||
**** Windows
|
||||
|
||||
1. Télécharger [[https://github.com/oschwartz10612/poppler-windows][Poppler pour Windows]] et ajouter son dossier =bin= à
|
||||
=PATH=.
|
||||
2. Installer une distribution LaTeX, par exemple MiKTeX ou TeX Live,
|
||||
et vérifier que =pdflatex= est accessible depuis =PATH=.
|
||||
|
||||
Fermer puis rouvrir le terminal et le GUI après une modification de
|
||||
=PATH=.
|
||||
|
||||
**** macOS
|
||||
|
||||
Avec Homebrew, installer Poppler et MacTeX :
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
brew install poppler
|
||||
brew install --cask mactex
|
||||
#+END_SRC
|
||||
|
||||
MacTeX fournit la distribution TeX Live complète utilisée par les
|
||||
modèles de Copienator. Après son installation, rouvrir le terminal ou
|
||||
exécuter :
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
eval "$(/usr/libexec/path_helper)"
|
||||
#+END_SRC
|
||||
|
||||
Copienator recherche aussi les exécutables dans =/opt/homebrew/bin=,
|
||||
=/usr/local/bin= et =/Library/TeX/texbin=, notamment lorsque le GUI ne
|
||||
récupère pas le =PATH= du terminal. Ces instructions conviennent aux
|
||||
Mac Intel et Apple Silicon sous macOS 11 ou plus récent.
|
||||
|
||||
*** Programme externe facultatif
|
||||
|
||||
PDF Arranger permet d'ouvrir et de réorganiser plus facilement les
|
||||
PDF. Son absence est signalée comme facultative dans le diagnostic. Il
|
||||
doit fournir la commande =pdf-arranger= ou =pdfarranger= dans =PATH=.
|
||||
Sous macOS, Copienator utilise automatiquement Aperçu comme solution de
|
||||
repli.
|
||||
|
||||
*** Accès à Gemini
|
||||
|
||||
Il faut créer une clef API pour Gemini (pas facile).
|
||||
|
||||
NB : Lors de la création, google offre (offrait ?) 300€ d'utilisation,
|
||||
mais seulement pendant les trois mois à venir.
|
||||
|
||||
Puis ajouter =GEMINI_API_KEY= à l'environnement avec :
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
@@ -80,267 +163,105 @@ Copier `default_config.py` en `config.py`. Éventuellement le modifier.
|
||||
noms/prénoms des élèves, un par ligne
|
||||
2. Créer un dossier correspondant à l'évaluation (=Interro= dans la
|
||||
suite)
|
||||
3. Mettre l'énoncé, au format pdf, et l'énoncé et le corrigé au
|
||||
format .tex dans le dossier.
|
||||
|
||||
* Étapes et Script
|
||||
** Prétraitement de l'énoncé
|
||||
|
||||
Dans le dossier de l'évaluation, mettre : `enonce.pdf`, `enonce.tex`,
|
||||
`correction.tex`.
|
||||
|
||||
- `python gemini_for_enonce.py Interro` or
|
||||
`python gemini_for_enonce.py Interro --restart`
|
||||
|
||||
À partir des trois fichiers précédents, se charge de détecter les
|
||||
labels des questions et leur contenu.
|
||||
|
||||
Les questions vont également être regroupées. Par la suite, quand
|
||||
des requêtes de corrections seront effectuées sur une question,
|
||||
seulement les énoncés des questions du groupe seront envoyés (et le
|
||||
corrigé de la question). Il faut donc que chaque groupe contienne
|
||||
si possible le contexte nécessaire pour comprendre la question.
|
||||
|
||||
Une fenêtre s'ouvre pour permettre d'éditer le résultat. Ne pas
|
||||
hésiter à faire des groupes plus gros que les groupes par défaut.
|
||||
|
||||
Après relecture le script génère :
|
||||
|
||||
+ un fichier `labels` avec les labels des questions
|
||||
+ Un dossier `Text` avec le contenu textuel des questions,
|
||||
regroupées.
|
||||
+ Un dossier `Sol` avec le contenu textuel du corrigé, question par
|
||||
question.
|
||||
+ Un dossier `Text2`, qui compile un fichier `.tex` pour chaque
|
||||
question (utilisé pour compiler un rendu pdf du corrigé pour
|
||||
chaque question)
|
||||
+ Un dossier `Sol2`, qui compile un fichier `.tex` pour chaque
|
||||
correction de chaque question.
|
||||
+ Un dossier `Persp` avec des instruction de barème pour chaque
|
||||
question.
|
||||
|
||||
Éventuellement : vérifier et modifier les barèmes dans `Persp`.
|
||||
|
||||
- Alternative personnelle : `python enonce_info.py Interro`
|
||||
|
||||
|
||||
** Prétraitement des copies
|
||||
|
||||
Mettre les copies scannées au format pdf dans =Interro=.
|
||||
|
||||
1. =./rotate_all.sh Interro= (facultatif)
|
||||
|
||||
Retourne tous les pdf de 180°, si la photocopie a été faite à
|
||||
l'envers.
|
||||
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.
|
||||
+ s pour garder les deux pages
|
||||
+ t/n pour garder celle de gauche/de droite
|
||||
+ r pour jeter les deux pages
|
||||
|
||||
Les key bindings ne sont pas adaptés à un clavier azerty… À changer…
|
||||
|
||||
Fix issues with =python page_splitter.py Interro14/Copies/Copie01.pdf=
|
||||
4. =python cutleft.py Interro=
|
||||
|
||||
Découpe la partie gauche des copies, là où il devrait y avoir les
|
||||
labels des exercices/questions.
|
||||
|
||||
Rerun on a single file with =python cutleft.py Interro/Copies/Copie01.pdf=
|
||||
|
||||
** Labelisation et regroupement
|
||||
|
||||
Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
|
||||
|
||||
1. =python gemini_for_labels.py Interro=, avec éventuellement =--overwrite=
|
||||
|
||||
Fait des requêtes à Gemini pour identifier les labels des
|
||||
questions dans images générées à partir des parties gauches des copies.
|
||||
2. =python plotting.py Interro=
|
||||
|
||||
Permet de vérifier visuellement les labels trouvés.
|
||||
+ Sous linux, on peut faire =e= pour ouvrir le fichier .json et
|
||||
l'éditer a la main.
|
||||
+ Quand un label est manquant, il est possible de cliquer sur
|
||||
l'image, ce qui copie les coordonnées dans le presse papier
|
||||
(sous linux…), puis on peut l'ajouter à la main.
|
||||
+ Utilisation de `_`, `|…` et `…|` :
|
||||
+ `|…` n'est pas arrêté verticalement par son type opposé.
|
||||
+ `…|` est stoppé horizontalement par le `|…` le plus proche.
|
||||
Pour modifier une seule copie :
|
||||
=python plotting.py Interro/Copies/Copie01.pdf=
|
||||
|
||||
It also generates les =Copie01.json=, à partir des =Copie01_01.json=
|
||||
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.
|
||||
4. Résolution manuel de conflits
|
||||
|
||||
Edit `manual_resolutions.txt`. Use :
|
||||
+ `->` or `x>` : Here set a pipe `|` before or after the new_label name
|
||||
+ `-x` : replace the goal
|
||||
+ `ss` : do nothing
|
||||
+ `sx` : stay, and remove goal.
|
||||
+ `xx` : move to goal.
|
||||
+ `xs` : remove old, keep goal.
|
||||
|
||||
Then call `python resolve_manual.py Interro`
|
||||
5. Call `python correction.py Interro --refaire`.
|
||||
|
||||
|
||||
** Génération des copies annotées
|
||||
|
||||
1. =python annotating.py Interro= (facultatif)
|
||||
|
||||
Ajoute les annotations Gemini aux copies, enregistrées dans le dossier =Anot=.
|
||||
On peut passer l'argument =--overwrite=.
|
||||
|
||||
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.
|
||||
|
||||
4. On peut faire des changements manuels aux =score.json= ici, puis
|
||||
- `python reading_annotations.py --update-score Interro`
|
||||
- `python reading_grouped_annotations.py --update-score Interro`
|
||||
pour mettre à jour les scores dans les images.
|
||||
4. (gestion perso)
|
||||
+ =gestion_classe ne= pour créer l'interro puis
|
||||
+ =gestion_classe we= (set barème here)
|
||||
+ =python update_ods.py Interro=
|
||||
ou =python update_ods.py Interro --sum= (en l'absence de barème)
|
||||
+ =gestion_classe re=
|
||||
+ =gestion_classe wsent=
|
||||
+ =python add_final_score.py Interro21=
|
||||
(this makes files in =Server/copies=)
|
||||
5. (gestion perso)
|
||||
+ Deploy =miqmacs-copies-assets=, and
|
||||
+ update the copies from =miqmacs.fr/admin=.
|
||||
6. (gestion perso) Impression d'une copie. Via Evince » print to pdf.
|
||||
|
||||
** Recorrection d'une seule copie (peu testé)
|
||||
|
||||
!! Attention, refaire ne marchera pas si tu fais une annotation non
|
||||
groupée into refaire !!
|
||||
|
||||
1. Redécoupage
|
||||
+ =python plotting.py InterroTest/Copie01.pdf=
|
||||
+ =python splitting_int.py InterroTest/Copie20.pdf=
|
||||
2. Créer =refaire.json=, avec un contenu comme
|
||||
: [["Copie02", []],
|
||||
: ["Copie01", ["Ex 1 : 1)"]]]
|
||||
3. Appeler =correction= avec --refaire. Il doit créer des groupes
|
||||
individuels, faire des requêtes, et remplacer les corrections
|
||||
précédentes (à sauver ailleurs).
|
||||
|
||||
Ou non, si tu veux le faire à la main.
|
||||
4. ?? Si je fais refaire, avant d'avoir créer les annotating with
|
||||
checks, que se passe-t-il ???
|
||||
5. Appeler =annotating_with_checks.py --refaire --overwrite=
|
||||
6. =python to_tablette.py --refaire Interro24=
|
||||
6. =python from_tablette.py --refaire Interro24=
|
||||
7. =python reading_grouped_annotations.py --refaire Interro24=
|
||||
|
||||
** Exemple de replotting, refaire d'une copie
|
||||
|
||||
1. replot it.
|
||||
2. `python splitting_int.py DS09VA/Copies/Copie25.pdf`
|
||||
this will get rid of old/new.
|
||||
!! Attention, et si ça dégage un new : bad bad bad.
|
||||
3. Make `refaire.json`, avec la copie, et les labels à refaire.
|
||||
4. `python correction.py DS09VA --refaire`
|
||||
5. `python annotating_with_checks.py DS09VA --refaire`
|
||||
6. `python from_tablette.py Interro24 --refaire`
|
||||
3. Suivre les instructions suivantes
|
||||
|
||||
** Interface graphique
|
||||
|
||||
Lancer l'assistant avec :
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
python -m copienator gui
|
||||
#+END_SRC
|
||||
|
||||
Sous Linux ou macOS, le lanceur exécutable =./start-gui.sh= démarre aussi
|
||||
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.
|
||||
|
||||
=Recharger — vérifier à nouveau= relit les fichiers d’entrée du dossier.
|
||||
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).
|
||||
|
||||
Pendant =Découper une partie à gauche pour détection des labels=, =n= décale
|
||||
la zone de 50 px vers la droite, =N= de 100 px, =t= de 50 px vers la
|
||||
gauche et =l= l’élargit de 50 px. =1= utilise les pages entières. =s=
|
||||
signale la copie en erreur et passe à la suivante ; =Entrée= valide et
|
||||
enregistre la découpe affichée.
|
||||
Une copie ignorée conserve ses anciennes découpes. Les signalements sont
|
||||
conservés dans =.copienator/copy_errors.json=, même après fermeture.
|
||||
Dans =Séparer et réordonner les pages=, =Traiter les copies signalées=
|
||||
reprend ces copies à partir des originaux conservés. Le même bouton dans
|
||||
=Découper une partie à gauche pour détection des labels= reprend leur
|
||||
découpage ; chaque signalement
|
||||
est effacé seulement après validation et enregistrement avec =Entrée=.
|
||||
Fermer la fenêtre, appuyer à nouveau sur =s= ou rencontrer une erreur
|
||||
conserve le signalement. Les commandes =page-split= et =crop-labels=
|
||||
acceptent aussi =--marked= pour traiter les copies signalées de l’évaluation.
|
||||
|
||||
Avec =SHOW_PERSONAL_STEPS = True=, =Analyser l’énoncé= propose le choix
|
||||
entre Gemini et =Énoncés et solutions personnels (SHEETINFO)=. Ce dernier
|
||||
lance =python -m copienator statement-personal Interro= : il lit
|
||||
=enonce.tex=, utilise le service 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=.
|
||||
|
||||
Deux boutons ouvrent ensuite des actions facultatives avec aperçu de
|
||||
commande et bouton =Exécuter= : =Regrouper avec Gemini…= remplace seulement
|
||||
=label_groups= (=statement Interro --groups-only=) ; =Remplacer Persp avec
|
||||
Gemini…= régénère seulement les barèmes des groupes actuels
|
||||
(=statement Interro --persp-only=), avec le même prompt que le parcours
|
||||
Gemini complet. Une réponse incomplète ou un échec laisse les anciens
|
||||
barèmes en place. On peut ignorer ces étapes et conserver les résultats
|
||||
personnels.
|
||||
|
||||
On peut aussi ouvrir directement une évaluation avec =python -m copienator gui
|
||||
Interro=. L'interface conserve l'état et l'historique des étapes dans
|
||||
=Interro/.copienator-gui.json=, et les sorties complètes dans
|
||||
=Interro/.copienator/logs/=. Une relance d'une étape antérieure ne
|
||||
supprime aucun résultat ; les étapes suivantes sont seulement marquées
|
||||
comme étant à revalider.
|
||||
|
||||
Après la réussite d'un script, l'interface sélectionne automatiquement
|
||||
l'étape suivante. =Découper les réponses par question= et =Regrouper
|
||||
les réponses= démarrent automatiquement lors de leur première visite.
|
||||
Avec le mode =Correction immédiate=, les trois étapes batch sont alors
|
||||
marquées =Ignorée= à leur première visite. L'étape de résolution
|
||||
manuelle est traitée de la même façon si =manual_resolutions.txt= est
|
||||
absent, vide ou ne contient que des commentaires. Ces automatismes ne
|
||||
se répètent pas lors d'un retour en arrière.
|
||||
|
||||
Le bouton =Diagnostic…= vérifie les modules Python, Poppler, LaTeX,
|
||||
PDF Arranger (ou Aperçu sous macOS) et la configuration Gemini. Sous
|
||||
Windows, les exécutables externes doivent être accessibles depuis
|
||||
=PATH=. Sous macOS, les emplacements standards de Homebrew et MacTeX
|
||||
sont également inspectés. Quand la création de liens symboliques ou
|
||||
physiques n'est pas autorisée, l'export et la préparation de =A Rendre=
|
||||
utilisent automatiquement une copie normale.
|
||||
|
||||
Les chemins des étapes personnelles peuvent être adaptés avec
|
||||
=CURRENT_SCORE_ODS_PATH=, =FINAL_SCORE_ODS_PATH=,
|
||||
=FINAL_SCORE_OUTPUT_DIR= et =FINAL_SCORE_FONT_PATH= dans =config.py=.
|
||||
|
||||
* Documentation complémentaire
|
||||
|
||||
- [[file:docs/final_output.md][Fichiers finaux dans A Rendre]] : contenu du JPEG, sélection et
|
||||
pagination du PDF, JPEG par réponse, =score.json=, =info.json= et diffusion.
|
||||
- [[file:Script.org][Référence des étapes et des scripts]] : commandes, arguments,
|
||||
prérequis, fichiers produits et parcours alternatifs.
|
||||
- [[file:Architecture.org][Architecture et conventions de développement]] : API commune,
|
||||
état partagé, écritures sûres et règles de maintenance.
|
||||
|
||||
@@ -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,254 +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 base_prefix and previous_prefix is not None:
|
||||
if natural_key(base_prefix) < natural_key(previous_prefix):
|
||||
base_prefix_maybe = f"{safe_labels[0]}+"
|
||||
if natural_key(base_prefix_maybe) > natural_key(previous_prefix):
|
||||
base_prefix = base_prefix_maybe
|
||||
|
||||
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_prefix = 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,140 +1,54 @@
|
||||
import sys
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import os
|
||||
import json
|
||||
import glob
|
||||
from pathlib import Path
|
||||
import re
|
||||
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
|
||||
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
|
||||
result_data = {}
|
||||
def make_dictionary(root_dir, refaire=False, refaire_list=None):
|
||||
"""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']
|
||||
|
||||
if result_obj.get("suffix") == "_old":
|
||||
continue
|
||||
|
||||
# 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
|
||||
|
||||
suffix = result_obj.get("suffix", "")
|
||||
if suffix == "_new":
|
||||
pdf_path = Path(root_dir) / "Copies" / f"Copie{student_id}" / f"{label}_new.pdf"
|
||||
else:
|
||||
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 height == None or width == None:
|
||||
print("?? height or width is None, for ", student_id, label)
|
||||
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():
|
||||
pdf_path_new = Path(root_dir) / "Copies" / f"Copie{sid}" / f"{lbl}_new.pdf"
|
||||
if pdf_path_new.exists():
|
||||
pdf_path = pdf_path_new
|
||||
else:
|
||||
print("Debug : asked to refaire", sid, lbl, "but pdf absent")
|
||||
continue
|
||||
# result_data[sid][lbl] = {
|
||||
# "pdf_path": pdf_path,
|
||||
# "result": {
|
||||
# "score": 0.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():
|
||||
pdf_path_new = Path(root_dir) / "Copies" / f"Copie{sid}" / f"{lbl}_new.pdf"
|
||||
if pdf_path_new.exists():
|
||||
pdf_path = pdf_path_new
|
||||
else:
|
||||
print("Debug : asked to refaire", sid, lbl, "but pdf absent")
|
||||
continue
|
||||
result_data[sid][lbl] = {
|
||||
"pdf_path": pdf_path,
|
||||
"result": {
|
||||
"score": 0.0,
|
||||
"feedback": [],
|
||||
"error": "non traité"
|
||||
},
|
||||
"coordinates": (0,0)
|
||||
}
|
||||
|
||||
return result_data
|
||||
|
||||
def make_base_image(pdf_path):
|
||||
pages = convert_from_path(pdf_path)
|
||||
@@ -152,20 +66,6 @@ def make_base_image(pdf_path):
|
||||
current_y += page.height
|
||||
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):
|
||||
"""
|
||||
Replaces LaTeX shortcuts not supported by Matplotlib's mathtext parser.
|
||||
@@ -274,22 +174,13 @@ def render_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=N
|
||||
final_img.alpha_composite(img)
|
||||
return final_img
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import subprocess
|
||||
import PIL.ImageOps
|
||||
|
||||
|
||||
from config import LATEX_ANOT_AFTER, LATEX_ANOT_BEFORE
|
||||
|
||||
|
||||
def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=None, fontsize=19):
|
||||
dpi = 100
|
||||
width_in = width_px / dpi
|
||||
line_spacing = int(fontsize * 1.2)
|
||||
|
||||
# Use the 'standalone' class with 'varwidth' to auto-crop height while restricting width
|
||||
header = LATEX_ANOT_BEFORE.format(
|
||||
header = LATEX_ANOT_BEFORE.substitute(
|
||||
width_in=width_in, fontsize=fontsize, line_spacing=line_spacing
|
||||
)
|
||||
latex_template = f"{header}{text}{LATEX_ANOT_AFTER}"
|
||||
@@ -302,11 +193,12 @@ def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_li
|
||||
f.write(latex_template)
|
||||
|
||||
# Compile to PDF
|
||||
result = subprocess.run(
|
||||
subprocess.run(
|
||||
['pdflatex', '-interaction=nonstopmode', 'text.tex'],
|
||||
cwd=temp_dir,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
|
||||
if not os.path.exists(pdf_path):
|
||||
@@ -335,12 +227,6 @@ def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_li
|
||||
|
||||
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):
|
||||
t = max(0.0, min(1.0, float(score) / 4.0))
|
||||
t = t*1.5 - 0.25
|
||||
@@ -349,8 +235,6 @@ def color(score):
|
||||
green = 150 * t
|
||||
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,
|
||||
bg_color=(255, 255, 255, 255),
|
||||
with_error=True, id=None):
|
||||
@@ -379,7 +263,7 @@ def render_score_text(label, score, error, width_px, fontsize=30,
|
||||
try:
|
||||
font_regular = ImageFont.truetype("DejaVuSans.ttf", fontsize)
|
||||
font_bold = ImageFont.truetype("DejaVuSans-Bold.ttf", fontsize)
|
||||
except IOError:
|
||||
except OSError:
|
||||
# Fallback for systems without specific TTFs readily available
|
||||
print("here")
|
||||
try:
|
||||
@@ -428,7 +312,6 @@ def compose_label_image(base_img, label, result, hmin,
|
||||
if base_img.width < TARGET_MIN_WIDTH:
|
||||
total_missing = TARGET_MIN_WIDTH - base_img.width
|
||||
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.paste(base_img, (left_pad, 0))
|
||||
@@ -444,6 +327,16 @@ def compose_label_image(base_img, label, result, hmin,
|
||||
|
||||
# Filter deleted items (used by reading_annotations.py)
|
||||
feedbacks = [f for f in feedbacks if "to_delete" not in f]
|
||||
# Never guess where an invalid rectangle belongs, or lose its comment.
|
||||
# Use a copy so rendering cannot mutate saved correction data.
|
||||
normalized = []
|
||||
for feedback in feedbacks:
|
||||
box = feedback.get("box_2d")
|
||||
if box is not None and not valid_feedback_box(box):
|
||||
print(f"Warning: Copie{with_id or ''} {label}: invalid feedback box {box!r}; displaying the comment without a rectangle.")
|
||||
feedback = {**feedback, "box_2d": None}
|
||||
normalized.append(feedback)
|
||||
feedbacks = normalized
|
||||
|
||||
global_fb = [f for f in feedbacks if not f.get('box_2d')]
|
||||
local_fb = [f for f in feedbacks if f.get('box_2d')]
|
||||
@@ -548,100 +441,123 @@ def compose_label_image(base_img, label, result, hmin,
|
||||
|
||||
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):
|
||||
"""Helper function to process a single student."""
|
||||
|
||||
# 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)
|
||||
concat_path = os.path.join(output_dir, "Concat.jpg")
|
||||
if os.path.exists(concat_path) and not overwrite:
|
||||
concat_path = output_dir / "Concat.jpg"
|
||||
if concat_path.exists() and not overwrite:
|
||||
print(f"Skipping Copie {student_id} (Concat.jpg exists)")
|
||||
return
|
||||
return "skipped"
|
||||
|
||||
print("Processing :", student_id)
|
||||
problems = False
|
||||
with staged_directory(output_dir) as staging:
|
||||
d_notes = dict.fromkeys(all_labels, "")
|
||||
label_images = []
|
||||
answer_labels = []
|
||||
sorted_labels = sorted(labels_data.items(), key=lambda item: natural_key(item[0]))
|
||||
|
||||
# Clean folder if re-processing
|
||||
if os.path.exists(output_dir):
|
||||
shutil.rmtree(output_dir)
|
||||
os.makedirs(output_dir)
|
||||
for label, content in sorted_labels:
|
||||
pdf_full_path = content.get('pdf_path')
|
||||
if not pdf_full_path or not Path(pdf_full_path).exists():
|
||||
print(f"File not found: {pdf_full_path}")
|
||||
problems = True
|
||||
continue
|
||||
try:
|
||||
base_img, _, _ = make_base_image(pdf_full_path)
|
||||
except Exception as exc: # noqa: BLE001 - PDF/LaTeX backends vary
|
||||
print(f"Error converting {pdf_full_path}: {exc}")
|
||||
problems = True
|
||||
continue
|
||||
|
||||
d_notes = dict.fromkeys(all_labels, "")
|
||||
label_images = []
|
||||
result = content.get('result', {})
|
||||
coordinates = content.get('coordinates', (0, 0))
|
||||
d_notes[label] = str(result.get('score', 0))
|
||||
final_img, _ = compose_label_image(
|
||||
base_img,
|
||||
label,
|
||||
result,
|
||||
coordinates[0],
|
||||
with_empty=True,
|
||||
render_fn=render_real_latex_text,
|
||||
)
|
||||
final_img.save(staging / f"{label}.jpg")
|
||||
if result.get('error', "") != "empty-answer":
|
||||
label_images.append(final_img)
|
||||
answer_labels.append(label)
|
||||
|
||||
# !! Trier par l'ordre des labels plutôt
|
||||
sorted_labels = sorted(list(labels_data.items()), key=natural_key)
|
||||
|
||||
for label, content in sorted_labels:
|
||||
# 1. Find PDF path
|
||||
copie_folder = f"Copie{student_id}"
|
||||
pdf_full_path = content.get('pdf_path')
|
||||
|
||||
if not pdf_full_path or not os.path.exists(pdf_full_path):
|
||||
print(f"File not found: {pdf_full_path}")
|
||||
continue
|
||||
|
||||
# 2. Convert PDF to Image
|
||||
try:
|
||||
(base_img, _, _) = make_base_image(pdf_full_path)
|
||||
except Exception as e:
|
||||
print(f"Error converting {pdf_full_path}: {e}")
|
||||
continue
|
||||
|
||||
result = content.get('result', {})
|
||||
coordinates = content.get('coordinates', (0, 0)) # (hmin, hmax)
|
||||
score = result.get('score', 0)
|
||||
d_notes[label] = str(score)
|
||||
|
||||
final_img, _ = compose_label_image(base_img, label, result, coordinates[0],
|
||||
with_empty=True,
|
||||
render_fn=render_real_latex_text)
|
||||
# 7. Save Image
|
||||
save_path = os.path.join(output_dir, f"{label}.jpg")
|
||||
final_img.save(save_path)
|
||||
if result.get('error', "") != "empty-answer":
|
||||
label_images.append(final_img)
|
||||
|
||||
# Save scores
|
||||
with open(os.path.join(output_dir, "score.json"), "w") as f:
|
||||
json.dump(d_notes, f, indent=4)
|
||||
|
||||
# Concatenate
|
||||
if label_images:
|
||||
max_w = max(i.width for i in label_images)
|
||||
total_h = sum(i.height for i in label_images)
|
||||
canvas = Image.new('RGB', (max_w, total_h))
|
||||
cy = 0
|
||||
for img in label_images:
|
||||
canvas.paste(img, (0, cy))
|
||||
cy += img.height
|
||||
canvas.save(concat_path)
|
||||
atomic_write_json(staging / "score.json", d_notes)
|
||||
atomic_write_json(staging / "info.json", build_answer_info(
|
||||
d_notes, labels_data, answer_labels
|
||||
))
|
||||
if label_images:
|
||||
max_w = max(image.width for image in label_images)
|
||||
total_h = sum(image.height for image in label_images)
|
||||
canvas = Image.new('RGB', (max_w, total_h))
|
||||
current_y = 0
|
||||
for image in label_images:
|
||||
canvas.paste(image, (0, current_y))
|
||||
current_y += image.height
|
||||
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):
|
||||
# Ne pas thread cette application
|
||||
# 1. Il faut protéger les appels à matplotlib
|
||||
# 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)
|
||||
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__":
|
||||
parser = argparse.ArgumentParser(description="Annotate copies")
|
||||
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)
|
||||
raise SystemExit(main())
|
||||
@@ -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,14 +1,26 @@
|
||||
import sys
|
||||
import os
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import urllib.request
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import shutil
|
||||
import urllib.request
|
||||
from collections.abc import Sequence
|
||||
from uuid import uuid4
|
||||
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_text,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.platform import WindowsLabelError, validate_windows_labels
|
||||
from copienator.utils import compile_to_pdf
|
||||
|
||||
from utils import compile_to_pdf
|
||||
|
||||
def fetch_and_save_sub_text(ex_id, indices, label, text_path):
|
||||
"""Fetches text for a specific sub-question and saves it to Text/{label}.tex"""
|
||||
@@ -28,6 +40,7 @@ def fetch_and_save_sub_text(ex_id, indices, label, text_path):
|
||||
compile_to_pdf(content, pdf_file)
|
||||
except Exception as e:
|
||||
print(f"Error fetching sub-text from {url}: {e}")
|
||||
raise
|
||||
|
||||
def fetch_and_save_sub_sol(ex_id, indices, label, sol_path):
|
||||
"""Fetches text for a specific sub-question and saves it to Text/{label}.tex"""
|
||||
@@ -47,6 +60,7 @@ def fetch_and_save_sub_sol(ex_id, indices, label, sol_path):
|
||||
compile_to_pdf(content, pdf_file)
|
||||
except Exception as e:
|
||||
print(f"Error fetching sub-text from {url}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
ROMANS_CAP = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"]
|
||||
@@ -126,18 +140,22 @@ def save_split_content(text, path, base_fname, problem):
|
||||
f.write(chunk)
|
||||
|
||||
|
||||
def process_directory(directory):
|
||||
def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
|
||||
directory = str(workspace.root)
|
||||
# Find the first .tex file in the directory
|
||||
tex_files = glob.glob(os.path.join(directory, "*.tex"))
|
||||
enonce = workspace.root / "enonce.tex"
|
||||
tex_files = [str(enonce)] if enonce.is_file() else sorted(glob.glob(os.path.join(directory, "*.tex")))
|
||||
if not tex_files:
|
||||
print(f"No .tex file found in {directory}. Looking in /Staging/Interro/")
|
||||
int_name = directory[:-1] if directory.endswith("/") else directory
|
||||
int_name = workspace.root.name
|
||||
tex_path = os.path.join(os.path.expanduser("~"), "Prépa/Staging/Interro", f"{int_name}.tex")
|
||||
if os.path.exists(tex_path):
|
||||
tex_file = tex_path
|
||||
else:
|
||||
print("Not found in ", tex_path)
|
||||
return
|
||||
raise CliError(
|
||||
f"No .tex input found in {workspace.root}",
|
||||
ExitCode.INVALID_WORKSPACE,
|
||||
)
|
||||
else:
|
||||
tex_file = tex_files[0]
|
||||
|
||||
@@ -152,8 +170,11 @@ def process_directory(directory):
|
||||
for p in paths.values():
|
||||
os.makedirs(p, exist_ok=True)
|
||||
|
||||
labels_file = os.path.join(directory, "labels")
|
||||
labels_file = workspace.labels_file
|
||||
labels_staging = labels_file.with_name(f".{labels_file.name}.{uuid4().hex}.tmp")
|
||||
current_ex_num = 1
|
||||
had_errors = False
|
||||
exercise_groups = []
|
||||
|
||||
# Read entirely to allow chunking
|
||||
with open(tex_file, 'r', encoding='utf-8') as f_in:
|
||||
@@ -161,8 +182,11 @@ def process_directory(directory):
|
||||
|
||||
# Split by the specific SHEETINFO tag
|
||||
blocks = content.split("%%SHEETINFO :")
|
||||
if len(blocks) == 1:
|
||||
print(f"No SHEETINFO blocks found in {tex_file}")
|
||||
return ExitCode.PARTIAL
|
||||
|
||||
with open(labels_file, 'w', encoding='utf-8') as f_labels:
|
||||
with open(labels_staging, 'w', encoding='utf-8') as f_labels:
|
||||
# Skip blocks[0] (content before first SHEETINFO)
|
||||
for block in blocks[1:]:
|
||||
parts_line = block.split("\n", 1)
|
||||
@@ -176,6 +200,7 @@ def process_directory(directory):
|
||||
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
block_labels = []
|
||||
# Construct 'ids' parameter
|
||||
ex_id = str(data['id'])
|
||||
selection = data.get('select')
|
||||
@@ -191,14 +216,16 @@ def process_directory(directory):
|
||||
indexes = data.get('indexes', [])
|
||||
if not indexes:
|
||||
label = f"Ex {current_ex_num}"
|
||||
f_labels.write(f"{label}\n")
|
||||
validate_windows_labels([label])
|
||||
block_labels.append(label)
|
||||
fetch_and_save_sub_text(ids, [], label, paths['Text2'])
|
||||
fetch_and_save_sub_sol(ids, [], label, paths['Sol2'])
|
||||
else:
|
||||
for item in indexes:
|
||||
suffix = format_indices(item['indices'], problem)
|
||||
label = f"Ex {current_ex_num}" + (f" : {suffix}" if suffix else "")
|
||||
f_labels.write(f"{label}\n")
|
||||
validate_windows_labels([label])
|
||||
block_labels.append(label)
|
||||
fetch_and_save_sub_text(ids, item['indices'], label, paths['Text2'])
|
||||
fetch_and_save_sub_sol(ids, item['indices'], label, paths['Sol2'])
|
||||
|
||||
@@ -241,16 +268,34 @@ def process_directory(directory):
|
||||
save_split_content(s_text, paths['Sol'], base_filename, problem)
|
||||
save_split_content(p_text, paths['Persp'], base_filename, problem)
|
||||
|
||||
for label in block_labels:
|
||||
f_labels.write(f"{label}\n")
|
||||
exercise_groups.append(block_labels)
|
||||
current_ex_num += 1
|
||||
|
||||
except WindowsLabelError:
|
||||
labels_staging.unlink(missing_ok=True)
|
||||
raise
|
||||
except json.JSONDecodeError:
|
||||
print(f"Error decoding JSON in block: {json_str}")
|
||||
except Exception as e:
|
||||
had_errors = True
|
||||
except Exception as e: # noqa: BLE001 - one malformed exercise is partial
|
||||
print(f"Error processing block {ex_id if 'ex_id' in locals() else 'unknown'}: {e}")
|
||||
had_errors = True
|
||||
|
||||
labels_staging.replace(labels_file)
|
||||
atomic_write_text(workspace.label_groups_file,
|
||||
"".join(", ".join(group) + "\n" for group in exercise_groups))
|
||||
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
return evaluation_parser("Generate statement metadata from SHEETINFO blocks")
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
return execute(parser, argv, lambda args: process_directory(workspace_from_args(args)))
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python script.py <Dir>")
|
||||
sys.exit(1)
|
||||
|
||||
process_directory(sys.argv[1])
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
@@ -1,16 +1,30 @@
|
||||
import shlex
|
||||
import re
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Union
|
||||
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from utils import compile_to_pdf
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from copienator import configuration as config
|
||||
from copienator import utils
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_text,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.platform import validate_windows_labels
|
||||
from copienator.filesystem import staged_directory
|
||||
from copienator.utils import compile_to_pdf
|
||||
|
||||
|
||||
def get_lcp(s1: str, s2: str) -> str:
|
||||
i = 0
|
||||
@@ -24,54 +38,67 @@ def get_lcp(s1: str, s2: str) -> str:
|
||||
return lcp
|
||||
|
||||
|
||||
import config
|
||||
|
||||
MODEL_ID = config.MODEL_LITE_ID
|
||||
api_key = config.API_KEY
|
||||
|
||||
# --- Modèles pour la Requête 1 ---
|
||||
class QuestionOnlyItem(BaseModel):
|
||||
label: str = Field(description="The unique label of the question (e.g., '1.a', 'Exercice 1')")
|
||||
question_content: str = Field(description="The source text of the question, strictly extracted from the enonce file, EXCLUDING the label itself.")
|
||||
label: str = Field(description="Label unique de la question (par exemple '1.a' ou 'Exercice 1').")
|
||||
question_content: str = Field(description="Texte source de la question, extrait exactement du fichier d’énoncé, SANS le label lui-même.")
|
||||
|
||||
class ExamQuestions(BaseModel):
|
||||
questions: List[QuestionOnlyItem]
|
||||
questions: list[QuestionOnlyItem]
|
||||
|
||||
# --- Modèles pour la Requête 2 ---
|
||||
class SolutionOnlyItem(BaseModel):
|
||||
label: str = Field(description="The exact unique label of the question provided in the input.")
|
||||
solution_content: str = Field(description="The source text of the solution, strictly extracted from the correction file.")
|
||||
label: str = Field(description="Label exact de la question fourni en entrée, à conserver sans traduction.")
|
||||
solution_content: str = Field(description="Texte source de la solution, extrait exactement du fichier de correction.")
|
||||
|
||||
class ExamSolutions(BaseModel):
|
||||
solutions: List[SolutionOnlyItem]
|
||||
solutions: list[SolutionOnlyItem]
|
||||
|
||||
# --- Modèles pour la Requête 3 ---
|
||||
class ExtractedContext(BaseModel):
|
||||
target_question_label: str = Field(description="The exact label of the FIRST question that comes immediately AFTER this information in the exam.")
|
||||
last_question_label: str = Field(description="The exact label of the LAST question that uses or relies on this information.")
|
||||
context_content: str = Field(description="The source text of the definitions, notations, or hypotheses, extracted from the enonce.")
|
||||
target_question_label: str = Field(description="Label exact de la PREMIÈRE question située immédiatement APRÈS cette information dans l’énoncé.")
|
||||
last_question_label: str = Field(description="Label exact de la DERNIÈRE question qui utilise cette information.")
|
||||
context_content: str = Field(description="Texte source des définitions, notations ou hypothèses, extrait de l’énoncé.")
|
||||
|
||||
class ExamContext(BaseModel):
|
||||
contexts: List[ExtractedContext]
|
||||
contexts: list[ExtractedContext]
|
||||
|
||||
# --- Modèles pour la Requête 4 (Barèmes) ---
|
||||
class RubricItem(BaseModel):
|
||||
label: str = Field(description="The exact label of the question.")
|
||||
rubric_content: str = Field(description="Le barème détaillé en français.")
|
||||
label: str = Field(description="Label exact de la question, à conserver sans traduction.")
|
||||
rubric_content: str = Field(description="Barème détaillé sur 4 points : toutes les consignes, explications et justifications doivent être rédigées en français.")
|
||||
|
||||
class GroupRubrics(BaseModel):
|
||||
rubrics: List[RubricItem]
|
||||
rubrics: list[RubricItem]
|
||||
|
||||
class LabelGroups(BaseModel):
|
||||
groups: list[list[str]]
|
||||
|
||||
PROMPT_4 = """Je te fournis les questions, le contexte éventuel, et les corrections pour un groupe de questions d'un examen.
|
||||
Ta tâche :
|
||||
Établir un barème de correction détaillé en français pour CHAQUE question.
|
||||
Établir un barème de correction détaillé pour CHAQUE question.
|
||||
Rédige intégralement en français le contenu de chaque champ `rubric_content`,
|
||||
y compris les consignes de notation, les explications et les justifications,
|
||||
même si certains textes fournis sont dans une autre langue.
|
||||
Conserve les formules mathématiques, les labels exacts des questions et les
|
||||
clés JSON `rubrics`, `label` et `rubric_content` sans les traduire.
|
||||
Chaque question DOIT être notée sur exactement 4 points. Propose une répartition logique de ces points.
|
||||
Il est inutile d'indiquer dans `rubric_content` que le barème totalise 4 points :
|
||||
ce total est toujours implicite.
|
||||
N'utilise pas de caractères mathématiques Unicode dans `rubric_content`.
|
||||
Écris les expressions mathématiques en LaTeX, par exemple
|
||||
`$\\lfloor \\sqrt{k} \\rfloor$` plutôt qu'avec des symboles Unicode.
|
||||
Par exemple :
|
||||
- Au moins 2 points si le résultat est correct.
|
||||
- Mettre la moitié des points si le raisonnement est correct mais pas le résultat.
|
||||
- Retirer 1.5 points si les hypothèses d'un théorème ou d'une question précédente ne sont pas vérifiées.
|
||||
- Retirer 1,5 point si les hypothèses d'un théorème ou d'une question précédente ne sont pas vérifiées.
|
||||
|
||||
Renvoie le résultat sous forme de liste JSON correspondant aux labels des questions fournies.
|
||||
Renvoie uniquement un objet JSON contenant une liste `rubrics`. Pour chaque
|
||||
question fournie, cette liste contient un objet avec son `label` exact et
|
||||
son barème en français dans `rubric_content`.
|
||||
"""
|
||||
|
||||
# --- Modèle fusionné (pour le reste du script) ---
|
||||
@@ -86,69 +113,190 @@ class ContextItem(BaseModel):
|
||||
content: str # Juste une string encapsulée pour le différencier facilement
|
||||
|
||||
class ExamExtraction(BaseModel):
|
||||
items: List[Union[QuestionItem, ContextItem]] # Liste mixte
|
||||
items: list[QuestionItem | ContextItem] # Liste mixte
|
||||
|
||||
class GroupedExamExtraction(BaseModel):
|
||||
groups: List[List[Union[QuestionItem, ContextItem]]]
|
||||
groups: list[list[QuestionItem | ContextItem]]
|
||||
|
||||
PROMPT_1 = """I am providing:
|
||||
1. A PDF of an exam (`enonce.pdf`)
|
||||
2. The source code of the exam questions (`enonce` file)
|
||||
PROMPT_1 = """Je te fournis :
|
||||
1. Le PDF d'un examen (`enonce.pdf`).
|
||||
2. Le code source de ses questions (fichier `enonce`).
|
||||
|
||||
Your task:
|
||||
1. Identify all distinct question labels using the PDF document.
|
||||
These labels should be unique : use `Ex 1 : 1)a)` or `I)1)b)`.
|
||||
2. For each label, extract its exact corresponding question text
|
||||
from the `enonce` source file. Do not include the label itself
|
||||
in this extracted text (nor LaTeX like `item` nor org-mode list
|
||||
labelling like `2.`).
|
||||
Return the result as a JSON list in the exact reading order of the document.
|
||||
Ta tâche :
|
||||
1. Identifie tous les labels distincts des questions à l'aide du PDF.
|
||||
Ils doivent être uniques : utilise par exemple `Ex 1 : 1)a)` ou `I)1)b)`.
|
||||
2. Pour chaque label, extrais exactement le texte de la question
|
||||
correspondante dans le fichier source `enonce`. N'inclus ni le label
|
||||
lui-même, ni les commandes de liste LaTeX comme `item`, ni les marques
|
||||
de liste org-mode comme `2.`.
|
||||
Ne reformule pas et ne traduis pas le texte extrait ; conserve le LaTeX.
|
||||
Renvoie les questions dans l'ordre exact de lecture du document, dans la
|
||||
liste `questions` de l'objet JSON attendu. Conserve les clés `label` et
|
||||
`question_content`.
|
||||
"""
|
||||
|
||||
PROMPT_2 = """I am providing:
|
||||
1. A JSON list of question labels and their texts extracted from an exam.
|
||||
2. The source code of the exam solutions (`correction` file).
|
||||
PROMPT_2 = """Je te fournis :
|
||||
1. Une liste JSON des labels des questions d'un examen et de leurs textes.
|
||||
2. Le code source du corrigé de l'examen (fichier `correction`).
|
||||
|
||||
Your task:
|
||||
For each question label provided in the JSON, extract its exact corresponding solution textual
|
||||
content from the `correction` source file. Return the result as a JSON list in the exact same order.
|
||||
Pour chaque label fourni, extrais exactement le texte de la solution
|
||||
correspondante dans le fichier source `correction`. Ne reformule pas et
|
||||
ne traduis pas le texte extrait ; conserve le LaTeX.
|
||||
Renvoie les solutions dans le même ordre que les questions, dans la liste
|
||||
`solutions` de l'objet JSON attendu. Conserve les clés `label` et
|
||||
`solution_content` ainsi que les labels exacts des questions.
|
||||
"""
|
||||
|
||||
PROMPT_3 = """I am providing:
|
||||
1. A JSON list of question labels and their texts extracted from an exam.
|
||||
2. The source code of the exam questions (`enonce` file).
|
||||
PROMPT_3 = """Je te fournis :
|
||||
1. Une liste JSON des labels des questions d'un examen et de leurs textes.
|
||||
2. Le code source des questions de l'examen (fichier `enonce`).
|
||||
|
||||
Your task:
|
||||
Extract important information necessary to understand the questions (e.g., definitions of objects, global notations, hypotheses, context) that are NOT part of the question texts themselves. Often, this information can be in a previous \\item that is not itself a question, but contains the question items.
|
||||
Extrais les informations importantes nécessaires à la compréhension des
|
||||
questions, mais qui ne font PAS partie des textes des questions :
|
||||
définitions des objets, notations générales, hypothèses ou contexte.
|
||||
Ces informations figurent souvent dans un \\item précédent qui ne constitue
|
||||
pas lui-même une question, mais contient une liste de questions.
|
||||
|
||||
For example, given LaTeX code like
|
||||
Par exemple, dans ce code LaTeX :
|
||||
|
||||
\\item Let N, M be two commutating matrices
|
||||
\\item Soient N et M deux matrices qui commutent.
|
||||
\\begin{itemize}
|
||||
\\item Prove that N, M have a common eigenvector
|
||||
\\item Prove that N, M are co-trigonalizable.
|
||||
\\item Montrer que N et M ont un vecteur propre commun.
|
||||
\\item Montrer que N et M sont simultanément trigonalisables.
|
||||
\\end{itemize}
|
||||
|
||||
the `Let N, M be two commutating matrices` part is not a question itself, and is important information to understand the next two questions.
|
||||
La phrase « Soient N et M deux matrices qui commutent » n'est pas une
|
||||
question ; elle est nécessaire pour comprendre les deux questions suivantes.
|
||||
|
||||
For each extracted piece of information, identify:
|
||||
1. The label of the FIRST question that comes immediately AFTER this information in the exam.
|
||||
2. The label of the LAST question that uses or relies on this information.
|
||||
Return the result as a JSON list.
|
||||
Pour chaque information extraite, identifie :
|
||||
1. Le label de la PREMIÈRE question située immédiatement APRÈS cette
|
||||
information dans l'énoncé (`target_question_label`).
|
||||
2. Le label de la DERNIÈRE question qui utilise cette information
|
||||
(`last_question_label`).
|
||||
Conserve le texte source dans `context_content`, sans le reformuler ni le
|
||||
traduire, et conserve le LaTeX ainsi que les labels exacts.
|
||||
Renvoie le résultat dans la liste `contexts` de l'objet JSON attendu.
|
||||
"""
|
||||
|
||||
def find_file(folder: Path, base_name: str) -> Path:
|
||||
def find_file(folder: Path, base_name: str) -> Path | None:
|
||||
for ext in [".org", ".tex"]:
|
||||
path = folder / f"{base_name}{ext}"
|
||||
if path.is_file():
|
||||
return path
|
||||
return None
|
||||
|
||||
def process_exam(folder_path: str, restart: bool = False):
|
||||
folder = Path(folder_path)
|
||||
|
||||
def generate_rubrics(client, group_context_text: str) -> dict[str, str]:
|
||||
"""Use the same rubric request for full and selective statement generation."""
|
||||
response = client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
contents=[types.Content(role="user", parts=[
|
||||
types.Part.from_text(text=PROMPT_4),
|
||||
types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"),
|
||||
])],
|
||||
config=types.GenerateContentConfig(
|
||||
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
|
||||
system_instruction="Rédige tous les barèmes et consignes de notation en français. Conserve les clés JSON, les labels et les formules mathématiques.",
|
||||
temperature=0.2,
|
||||
response_mime_type="application/json",
|
||||
response_json_schema=GroupRubrics.model_json_schema(),
|
||||
),
|
||||
)
|
||||
rubrics = GroupRubrics.model_validate_json(response.text).rubrics
|
||||
if len({item.label for item in rubrics}) != len(rubrics):
|
||||
raise ValueError("Gemini returned duplicate rubric labels")
|
||||
return {item.label: item.rubric_content for item in rubrics}
|
||||
|
||||
|
||||
def validate_groups(groups: list[list[str]], labels: list[str]) -> None:
|
||||
flattened = [label for group in groups for label in group]
|
||||
if (not groups or any(not group for group in groups)
|
||||
or len(flattened) != len(set(flattened)) or set(flattened) != set(labels)):
|
||||
raise CliError("Groups must contain every existing label exactly once")
|
||||
|
||||
|
||||
def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=None) -> ExitCode:
|
||||
"""Regroup or replace rubrics without regenerating statements or solutions."""
|
||||
labels = workspace.read_labels()
|
||||
if not labels or len(labels) != len(set(labels)):
|
||||
raise CliError("Generate unique question labels before refining the statement")
|
||||
validate_windows_labels(labels)
|
||||
questions = {}
|
||||
for label in labels:
|
||||
safe_label = label.replace("/", "_")
|
||||
parts = []
|
||||
for directory, title in (("Text2", "Question"), ("Sol2", "Correction")):
|
||||
path = workspace.root / directory / f"{safe_label}.tex"
|
||||
if not path.is_file():
|
||||
raise CliError(f"Missing {path}; generate statements and solutions first")
|
||||
parts.append(f"{title} [{label}]:\n{path.read_text(encoding='utf-8')}")
|
||||
questions[label] = "\n".join(parts)
|
||||
context = utils.enonce_total(workspace.root)
|
||||
if api_client is None:
|
||||
if not api_key:
|
||||
raise CliError("GEMINI_API_KEY is not configured")
|
||||
api_client = genai.Client(api_key=api_key)
|
||||
|
||||
if mode == "groups":
|
||||
response = api_client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
contents=[types.Content(role="user", parts=[types.Part.from_text(text=(
|
||||
"Regroupe ces questions 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"
|
||||
@@ -165,15 +313,21 @@ def process_exam(folder_path: str, restart: bool = False):
|
||||
if not correction_path: missing.append("correction.org or correction.tex")
|
||||
|
||||
if missing:
|
||||
print(f"Error: Missing files in {folder}: {', '.join(missing)}")
|
||||
sys.exit(1)
|
||||
raise CliError(
|
||||
f"Missing files in {folder}: {', '.join(missing)}",
|
||||
ExitCode.INVALID_WORKSPACE,
|
||||
)
|
||||
|
||||
print("Reading files...")
|
||||
pdf_bytes = pdf_path.read_bytes()
|
||||
enonce_text = enonce_path.read_text(encoding="utf-8")
|
||||
correction_text = correction_path.read_text(encoding="utf-8")
|
||||
|
||||
client = genai.Client(api_key=api_key)
|
||||
if api_client is None:
|
||||
if not api_key:
|
||||
raise CliError("GEMINI_API_KEY is not configured")
|
||||
api_client = genai.Client(api_key=api_key)
|
||||
client = api_client
|
||||
|
||||
# ==========================================
|
||||
# REQUÊTE 1 : Extraction des Énoncés
|
||||
@@ -190,6 +344,7 @@ def process_exam(folder_path: str, restart: bool = False):
|
||||
]
|
||||
|
||||
config_1 = types.GenerateContentConfig(
|
||||
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
|
||||
temperature=0.1,
|
||||
response_mime_type="application/json",
|
||||
response_json_schema=ExamQuestions.model_json_schema(),
|
||||
@@ -207,7 +362,7 @@ def process_exam(folder_path: str, restart: bool = False):
|
||||
)
|
||||
response_q_text = response_q.text
|
||||
print("Saving questions to cache...")
|
||||
cache_q_file.write_text(response_q_text, encoding="utf-8")
|
||||
atomic_write_text(cache_q_file, response_q_text)
|
||||
|
||||
questions_data = ExamQuestions.model_validate_json(response_q_text)
|
||||
|
||||
@@ -221,13 +376,14 @@ def process_exam(folder_path: str, restart: bool = False):
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_text(text=PROMPT_2),
|
||||
types.Part.from_text(text=f"--- EXTRACTED QUESTIONS ---\n{extracted_questions_json}"),
|
||||
types.Part.from_text(text=f"--- QUESTIONS EXTRAITES ---\n{extracted_questions_json}"),
|
||||
types.Part.from_text(text=f"--- CORRECTION SOURCE ({correction_path.name}) ---\n{correction_text}"),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
config_2 = types.GenerateContentConfig(
|
||||
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
|
||||
temperature=0.1,
|
||||
response_mime_type="application/json",
|
||||
response_json_schema=ExamSolutions.model_json_schema(),
|
||||
@@ -245,7 +401,7 @@ def process_exam(folder_path: str, restart: bool = False):
|
||||
)
|
||||
response_s_text = response_s.text
|
||||
print("Saving solutions to cache...")
|
||||
cache_s_file.write_text(response_s_text, encoding="utf-8")
|
||||
atomic_write_text(cache_s_file, response_s_text)
|
||||
|
||||
solutions_data = ExamSolutions.model_validate_json(response_s_text)
|
||||
|
||||
@@ -257,13 +413,14 @@ def process_exam(folder_path: str, restart: bool = False):
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_text(text=PROMPT_3),
|
||||
types.Part.from_text(text=f"--- EXTRACTED QUESTIONS ---\n{extracted_questions_json}"),
|
||||
types.Part.from_text(text=f"--- QUESTIONS EXTRAITES ---\n{extracted_questions_json}"),
|
||||
types.Part.from_text(text=f"--- ENONCE SOURCE ({enonce_path.name}) ---\n{enonce_text}"),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
config_3 = types.GenerateContentConfig(
|
||||
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
|
||||
temperature=0.1,
|
||||
response_mime_type="application/json",
|
||||
response_json_schema=ExamContext.model_json_schema(),
|
||||
@@ -281,7 +438,7 @@ def process_exam(folder_path: str, restart: bool = False):
|
||||
)
|
||||
response_c_text = response_c.text
|
||||
print("Saving context to cache...")
|
||||
cache_c_file.write_text(response_c_text, encoding="utf-8")
|
||||
atomic_write_text(cache_c_file, response_c_text)
|
||||
|
||||
context_data = ExamContext.model_validate_json(response_c_text)
|
||||
|
||||
@@ -341,8 +498,8 @@ def process_exam(folder_path: str, restart: bool = False):
|
||||
# ==========================================
|
||||
# INITIAL GROUPING COMPUTATION
|
||||
# ==========================================
|
||||
items_file = folder / "exam_items.txt"
|
||||
full_items_file = folder / "exam_items_full.txt"
|
||||
items_file = tmp_dir / "exam_items.txt"
|
||||
full_items_file = tmp_dir / "exam_items_full.txt"
|
||||
trunc_map = {}
|
||||
|
||||
# --- INITIAL GROUPING COMPUTATION ---
|
||||
@@ -385,7 +542,6 @@ def process_exam(folder_path: str, restart: bool = False):
|
||||
for g_indices in q_group_indices:
|
||||
group_items = []
|
||||
first_q_idx = g_indices[0]
|
||||
last_q_idx = g_indices[-1]
|
||||
|
||||
for q_idx in g_indices:
|
||||
q_item = questions_only[q_idx]
|
||||
@@ -469,20 +625,7 @@ def process_exam(folder_path: str, restart: bool = False):
|
||||
# --- OPEN EDITOR AND PARSE ---
|
||||
while True:
|
||||
print("Opening items file for editing...")
|
||||
editor = os.environ.get("EDITOR")
|
||||
try:
|
||||
if editor:
|
||||
subprocess.run(shlex.split(editor) + [str(items_file)])
|
||||
else:
|
||||
if sys.platform.startswith("linux"):
|
||||
subprocess.run(["xdg-open", str(items_file)])
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.run(["open", str(items_file)])
|
||||
else:
|
||||
os.startfile(str(items_file))
|
||||
input("Press ENTER here once you have saved and closed the text file...")
|
||||
except Exception as e:
|
||||
print(f"Error running editor: {e}")
|
||||
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:
|
||||
@@ -507,14 +650,13 @@ def process_exam(folder_path: str, restart: bool = False):
|
||||
|
||||
# 2. Actual Parsing
|
||||
grouped_items = []
|
||||
current_raw_group = [] # Stores (is_context, label_or_flag, content)
|
||||
all_new_q_labels = []
|
||||
|
||||
# Pass 1: Read all edited lines and collect question labels in sequence
|
||||
for line in edited_lines:
|
||||
if line == "---" or " ### " not in line:
|
||||
continue
|
||||
lbl, content_raw = line.split(" ### ", 1)
|
||||
lbl, _content_raw = line.split(" ### ", 1)
|
||||
lbl = lbl.strip()
|
||||
if lbl != "CONTEXT":
|
||||
all_new_q_labels.append(lbl)
|
||||
@@ -613,12 +755,9 @@ def process_exam(folder_path: str, restart: bool = False):
|
||||
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
|
||||
with open(folder / "labels", 'w', encoding='utf-8') as f_labels:
|
||||
for label in labels_list:
|
||||
f_labels.write(f"{label}\n")
|
||||
|
||||
grouped_extraction = GroupedExamExtraction(groups=grouped_items)
|
||||
|
||||
# 2. Setup output directories
|
||||
@@ -637,8 +776,7 @@ def process_exam(folder_path: str, restart: bool = False):
|
||||
).strip().lower()
|
||||
|
||||
if answer not in ("y", "yes"):
|
||||
print("Aborted.")
|
||||
sys.exit(1)
|
||||
raise CliError("Output replacement aborted", ExitCode.INVALID_ARGUMENTS)
|
||||
# Empty each directory
|
||||
for d in dirs:
|
||||
if d.exists():
|
||||
@@ -654,6 +792,7 @@ def process_exam(folder_path: str, restart: bool = False):
|
||||
|
||||
|
||||
print("Writing grouped question and solution files...")
|
||||
processing_errors = []
|
||||
|
||||
for group in grouped_extraction.groups:
|
||||
q_items = [item for item in group if isinstance(item, QuestionItem)]
|
||||
@@ -674,33 +813,12 @@ def process_exam(folder_path: str, restart: bool = False):
|
||||
|
||||
group_context_text = "\n\n---\n\n".join(group_text_parts)
|
||||
|
||||
contents_4 = [
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_text(text=PROMPT_4),
|
||||
types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
config_4 = types.GenerateContentConfig(
|
||||
temperature=0.2,
|
||||
response_mime_type="application/json",
|
||||
response_json_schema=GroupRubrics.model_json_schema(),
|
||||
)
|
||||
|
||||
print(f"Generating rubric (Persp) for group: {', '.join(labels)}...")
|
||||
try:
|
||||
response_r = client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
contents=contents_4,
|
||||
config=config_4
|
||||
)
|
||||
rubrics_data = GroupRubrics.model_validate_json(response_r.text)
|
||||
rubrics_map = {r.label: r.rubric_content for r in rubrics_data.rubrics}
|
||||
except Exception as e:
|
||||
rubrics_map = generate_rubrics(client, group_context_text)
|
||||
except Exception as e: # noqa: BLE001 - remote API boundary
|
||||
print(f"Error generating rubric for group {labels[0]}: {e}")
|
||||
processing_errors.append(str(e))
|
||||
rubrics_map = {}
|
||||
|
||||
# 1. Compute the common prefix for the group
|
||||
@@ -746,7 +864,7 @@ def process_exam(folder_path: str, restart: bool = False):
|
||||
elif isinstance(item, ContextItem):
|
||||
raw_ctx = item.content.strip()
|
||||
tabulated_ctx = "\t" + re.sub(r'\n\s*', '\n\t', raw_ctx)
|
||||
text_content_lines.append(f"CONTEXT :")
|
||||
text_content_lines.append("CONTEXT :")
|
||||
text_content_lines.append(tabulated_ctx)
|
||||
|
||||
# --- Save context to Text2 (Concatenating if exists) ---
|
||||
@@ -771,29 +889,66 @@ def process_exam(folder_path: str, restart: bool = False):
|
||||
# ==========================================
|
||||
all_tex_files = list(text2_dir.glob("*.tex")) + list(sol2_dir.glob("*.tex"))
|
||||
|
||||
def compile_worker(tex_file: Path):
|
||||
def compile_worker(tex_file: Path) -> str | None:
|
||||
"""Helper to read content and call the utility function."""
|
||||
try:
|
||||
content = tex_file.read_text(encoding="utf-8")
|
||||
pdf_path = tex_file.with_suffix(".pdf")
|
||||
compile_to_pdf(content, pdf_path)
|
||||
except Exception as e:
|
||||
print(f"Error compiling {tex_file.name}: {e}")
|
||||
except Exception as e: # noqa: BLE001 - compiler worker boundary
|
||||
return f"Error compiling {tex_file.name}: {e}"
|
||||
return None
|
||||
|
||||
print(f"Compiling {len(all_tex_files)} files to PDF using 4 threads...")
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
executor.map(compile_worker, all_tex_files)
|
||||
compile_errors = [
|
||||
error for error in executor.map(compile_worker, all_tex_files) if error
|
||||
]
|
||||
for error in compile_errors:
|
||||
print(error)
|
||||
processing_errors.extend(compile_errors)
|
||||
atomic_write_text(
|
||||
workspace.labels_file,
|
||||
"".join(f"{label}\n" for label in labels_list),
|
||||
)
|
||||
atomic_write_text(
|
||||
workspace.label_groups_file,
|
||||
"".join(", ".join(item.label for item in group if isinstance(item, QuestionItem)) + "\n"
|
||||
for group in grouped_extraction.groups
|
||||
if any(isinstance(item, QuestionItem) for item in group)),
|
||||
)
|
||||
|
||||
return ExitCode.PARTIAL if processing_errors else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = evaluation_parser("Extract exam and solution code via Gemini")
|
||||
actions = parser.add_mutually_exclusive_group()
|
||||
actions.add_argument("--groups-only", action="store_true",
|
||||
help="Regroup existing questions with Gemini; update only label_groups")
|
||||
actions.add_argument("--persp-only", action="store_true",
|
||||
help="Replace only Persp with Gemini rubrics for existing groups")
|
||||
parser.add_argument(
|
||||
"--restart",
|
||||
action="store_true",
|
||||
help="Ignore cached Gemini extraction responses",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
return execute(
|
||||
parser,
|
||||
argv,
|
||||
lambda args: refine_existing(workspace_from_args(args),
|
||||
"groups" if args.groups_only else "persp")
|
||||
if args.groups_only or args.persp_only else process_exam(
|
||||
workspace_from_args(args),
|
||||
restart=args.restart,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not api_key:
|
||||
print("Error: GEMINI_API_KEY environment variable is not set.")
|
||||
sys.exit(1)
|
||||
|
||||
parser = argparse.ArgumentParser(description="Extract exam and solution code via Gemini.")
|
||||
parser.add_argument("folder", help="Directory containing the exam files")
|
||||
parser.add_argument("--restart", action="store_true", help="Ignore cache files and re-run extraction requests.")
|
||||
|
||||
|
||||
args = parser.parse_args()
|
||||
process_exam(args.folder, restart=args.restart)
|
||||
raise SystemExit(main())
|
||||
@@ -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 json
|
||||
import re
|
||||
import sys
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
from collections.abc import Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
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
|
||||
DPI = 200 # Good balance for readability and size
|
||||
@@ -34,7 +43,7 @@ def get_pdf_height(path):
|
||||
|
||||
# Return total height
|
||||
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}")
|
||||
return 0
|
||||
|
||||
@@ -78,7 +87,7 @@ def group_files(file_list):
|
||||
groups = []
|
||||
|
||||
for item in sorted_files:
|
||||
dd, path, height = item
|
||||
_, _, height = item
|
||||
placed = False
|
||||
|
||||
# 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)
|
||||
if 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}")
|
||||
|
||||
if not images:
|
||||
@@ -159,7 +168,7 @@ def create_jpg(identifier, group_index, group, root_dir):
|
||||
# Try loading a font, fallback to default
|
||||
try:
|
||||
font = ImageFont.truetype("DejaVuSans.ttf", 40)
|
||||
except IOError:
|
||||
except OSError:
|
||||
print("font not found")
|
||||
font = ImageFont.load_default()
|
||||
|
||||
@@ -193,8 +202,7 @@ def create_jpg(identifier, group_index, group, root_dir):
|
||||
# Save JSON metadata
|
||||
json_filename = f"Group_{group_index+1}.json"
|
||||
json_path = os.path.join(target_folder, json_filename)
|
||||
with open(json_path, 'w') as f:
|
||||
json.dump(metadata, f)
|
||||
atomic_write_json(json_path, metadata, indent=None)
|
||||
|
||||
# Save with size constraints
|
||||
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)")
|
||||
|
||||
from utils import natural_key
|
||||
from copienator.utils import natural_key
|
||||
|
||||
|
||||
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):
|
||||
create_jpg(identifier, idx, group, output_dir)
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python app.py <Path_to_Dir>")
|
||||
sys.exit(1)
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
return evaluation_parser("Group copy extracts by question label.")
|
||||
|
||||
root_dir = Path(sys.argv[1])
|
||||
|
||||
copies_dir = root_dir / "Copies"
|
||||
par_label_dir = root_dir / "Par label"
|
||||
def run(workspace: EvaluationWorkspace) -> ExitCode:
|
||||
workspace.require_directories("Copies")
|
||||
workspace.groups_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print("Scanning files...")
|
||||
data = collect_files(copies_dir)
|
||||
data = collect_files(workspace.copies_dir)
|
||||
|
||||
print(f"Found {len(data)} identifiers. Processing...")
|
||||
|
||||
@@ -247,11 +253,26 @@ def main():
|
||||
|
||||
# Process using 8 threads
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
for identifier in sorted_identifiers:
|
||||
executor.submit(process_identifier, identifier, data[identifier],
|
||||
par_label_dir)
|
||||
futures = [
|
||||
executor.submit(
|
||||
process_identifier,
|
||||
identifier,
|
||||
data[identifier],
|
||||
workspace.groups_dir,
|
||||
)
|
||||
for identifier in sorted_identifiers
|
||||
]
|
||||
for future in futures:
|
||||
future.result()
|
||||
|
||||
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__":
|
||||
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())
|
||||
@@ -1,25 +1,98 @@
|
||||
import fitz # PyMuPDF
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
from PIL import Image, ImageTk, ImageDraw
|
||||
import sys
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import glob
|
||||
import shutil
|
||||
import subprocess
|
||||
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 config import PAGE_SPLITTER_KB
|
||||
from copienator.configuration import PAGE_SPLITTER_KB
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
execute,
|
||||
target_parser,
|
||||
workspace_from_target,
|
||||
)
|
||||
from copienator.platform import launch_pdf_arranger
|
||||
from copienator.copy_errors import marked_copy_paths
|
||||
|
||||
# Keep the new shortcut available with older personal configuration files.
|
||||
PAGE_SPLITTER_KB = {"reverse_pages": "i", **PAGE_SPLITTER_KB}
|
||||
|
||||
# --- Constants ---
|
||||
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
|
||||
CM_TO_POINTS = (1 / 2.54) * 72
|
||||
|
||||
def list_pdf_files(directory):
|
||||
l = list(reversed(sorted(glob.glob(os.path.join(directory, "*.pdf")))))
|
||||
return [u for u in l if "enonce" not in u]
|
||||
def list_pdf_files(directory: str | Path) -> list[Path]:
|
||||
paths = sorted(Path(directory).glob("*.pdf"), key=lambda path: path.name.casefold())
|
||||
return [path for path in paths if "enonce" not in path.name.casefold()]
|
||||
|
||||
|
||||
def _temporary_sibling(path: Path, purpose: str) -> Path:
|
||||
return path.with_name(f".{path.name}.{purpose}.{uuid.uuid4().hex}.tmp")
|
||||
|
||||
|
||||
def commit_processed_pdf(
|
||||
workspace: EvaluationWorkspace,
|
||||
original_path: Path,
|
||||
generated_path: Path,
|
||||
) -> Path:
|
||||
"""Commit a processed copy and its original backup with rollback."""
|
||||
backup_path = workspace.original_copies_dir / original_path.name
|
||||
output_path = workspace.copies_dir / original_path.name
|
||||
workspace.original_copies_dir.mkdir(parents=True, exist_ok=True)
|
||||
workspace.copies_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
staged_backup = None
|
||||
if original_path.resolve() != backup_path.resolve():
|
||||
staged_backup = _temporary_sibling(backup_path, "new-original")
|
||||
shutil.copy2(original_path, staged_backup)
|
||||
saved_backup = _temporary_sibling(backup_path, "old-original")
|
||||
saved_output = _temporary_sibling(output_path, "old-output")
|
||||
backup_replaced = False
|
||||
output_replaced = False
|
||||
try:
|
||||
if staged_backup is not None:
|
||||
if backup_path.exists():
|
||||
backup_path.replace(saved_backup)
|
||||
staged_backup.replace(backup_path)
|
||||
backup_replaced = True
|
||||
if output_path.exists():
|
||||
output_path.replace(saved_output)
|
||||
generated_path.replace(output_path)
|
||||
output_replaced = True
|
||||
if original_path.resolve() not in {
|
||||
backup_path.resolve(),
|
||||
output_path.resolve(),
|
||||
}:
|
||||
original_path.unlink()
|
||||
except Exception:
|
||||
if output_replaced and output_path.exists():
|
||||
output_path.unlink()
|
||||
if saved_output.exists():
|
||||
saved_output.replace(output_path)
|
||||
if backup_replaced and backup_path.exists():
|
||||
backup_path.unlink()
|
||||
if saved_backup.exists():
|
||||
saved_backup.replace(backup_path)
|
||||
raise
|
||||
finally:
|
||||
for temporary in (staged_backup, saved_backup, saved_output):
|
||||
if temporary is not None and temporary.exists():
|
||||
temporary.unlink()
|
||||
return output_path
|
||||
|
||||
class PDFPreviewer:
|
||||
|
||||
@@ -29,26 +102,36 @@ class PDFPreviewer:
|
||||
return False
|
||||
self.pdf_path = self.inputs.pop()
|
||||
self.file_rotation = 0
|
||||
self.base_name = os.path.splitext(os.path.basename(self.pdf_path))[0]
|
||||
self.split_dir = f"{self.base_name}_split"
|
||||
self.reorder_dir = f"{self.base_name}_reorder"
|
||||
|
||||
# Create a temporary output file
|
||||
self.final_file = f"{self.base_name}_temp.pdf"
|
||||
self.base_name = self.pdf_path.stem
|
||||
self._temporary_directory = tempfile.TemporaryDirectory(
|
||||
prefix=f".{self.base_name}.page-splitter.",
|
||||
dir=self.workspace.root,
|
||||
)
|
||||
working_dir = Path(self._temporary_directory.name)
|
||||
self.split_dir = working_dir / "split"
|
||||
self.reorder_dir = working_dir / "reorder"
|
||||
self.final_file = working_dir / f"{self.base_name}.pdf"
|
||||
|
||||
self.current_page_index = 0
|
||||
self.page_settings = []
|
||||
self.processing = False # Flag to prevent multiple finish calls
|
||||
try:
|
||||
self.doc = fitz.open(self.pdf_path)
|
||||
except Exception as e:
|
||||
self.doc = pymupdf.open(self.pdf_path)
|
||||
except (OSError, RuntimeError, ValueError) as e:
|
||||
self.failed = True
|
||||
self._temporary_directory.cleanup()
|
||||
messagebox.showerror("Error", f"Failed to open PDF file: {e}")
|
||||
self.master.destroy()
|
||||
return
|
||||
self.master.title(f"PDF Splitter - {os.path.basename(self.pdf_path)}")
|
||||
self.master.title(f"PDF Splitter - {self.pdf_path.name}")
|
||||
return True
|
||||
|
||||
def __init__(self, master, path):
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Tk,
|
||||
workspace: EvaluationWorkspace,
|
||||
inputs: list[Path],
|
||||
) -> None:
|
||||
"""
|
||||
Initializes the application.
|
||||
|
||||
@@ -56,40 +139,16 @@ class PDFPreviewer:
|
||||
master (tk.Tk): The root Tkinter window.
|
||||
pdf_path (str): The path to the input PDF file.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
messagebox.showerror("Error", f"File not found: {path}")
|
||||
master.destroy()
|
||||
return
|
||||
|
||||
if os.path.isdir(path):
|
||||
self.inputs = list_pdf_files(path)
|
||||
else:
|
||||
# Check for existing original in backup and restore if found
|
||||
dir_name = os.path.dirname(os.path.abspath(path))
|
||||
file_name = os.path.basename(path)
|
||||
if os.path.basename(dir_name) == "Copies":
|
||||
dir_name = os.path.dirname(dir_name)
|
||||
path = os.path.join(dir_name, file_name)
|
||||
backup_path = os.path.join(dir_name, "Copies Originales", file_name)
|
||||
|
||||
if os.path.exists(backup_path):
|
||||
try:
|
||||
shutil.move(backup_path, path)
|
||||
print(f"Restored original file from: {backup_path}")
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to restore original file: {e}")
|
||||
master.destroy()
|
||||
return
|
||||
|
||||
self.inputs = [path]
|
||||
|
||||
self.workspace = workspace
|
||||
self.inputs = inputs
|
||||
self.output_dir = None
|
||||
self.master = master
|
||||
self.num = 0
|
||||
self.global_rotation = 0 # Rotation appliquée à tous les fichiers
|
||||
self.history = []
|
||||
self.failed = False
|
||||
if not self.setup_next_file():
|
||||
print(f"Aucun fichier PDF valide trouvé dans : {path}")
|
||||
print(f"No PDF files found in {workspace.root}")
|
||||
master.destroy()
|
||||
return
|
||||
|
||||
@@ -111,6 +170,7 @@ class PDFPreviewer:
|
||||
f"'{fmt('rotate_page')}': Rotate page 180°, '{fmt('rotate_all_pages')}' : rotate all pages, '{fmt('rotate_all_files')}' : rotate all files\n"
|
||||
f"{fmt('keep_left')} {fmt('next_page')} {fmt('discard_page')} {fmt('keep_right')} {fmt('keep_as_is')}: keep left, next page, keep none, keep right, keep as is\n"
|
||||
f"{fmt('send_end')}: send page to end, '{fmt('arranger')}': pdf arranger, '{fmt('restart_file')}': restart file, '{fmt('prev_file')}': previous file\n"
|
||||
f"'{fmt('reverse_pages')}': reverse page order and restart from the new first page\n"
|
||||
)
|
||||
|
||||
self.info_label = tk.Label(master, text=instructions, justify=tk.LEFT)
|
||||
@@ -137,6 +197,7 @@ class PDFPreviewer:
|
||||
"next_page": self.confirm_and_next_page,
|
||||
"discard_page": self.discard_page,
|
||||
"send_end": self.send_page_end,
|
||||
"reverse_pages": self.reverse_pages,
|
||||
"restart_file": self.restart_current_file,
|
||||
"arranger": self.start_arranger,
|
||||
"prev_file": self.go_to_previous_file,
|
||||
@@ -169,7 +230,10 @@ class PDFPreviewer:
|
||||
self.current_zoom = 1.0
|
||||
|
||||
def start_arranger(self):
|
||||
subprocess.Popen(["pdf-arranger", self.pdf_path])
|
||||
try:
|
||||
launch_pdf_arranger(self.pdf_path)
|
||||
except FileNotFoundError as exc:
|
||||
messagebox.showerror("PDF Arranger", str(exc))
|
||||
|
||||
def on_resize(self, event):
|
||||
"""
|
||||
@@ -213,7 +277,7 @@ class PDFPreviewer:
|
||||
self.current_zoom = min(zoom_x, zoom_y) * 0.98
|
||||
|
||||
# --- Render Page ---
|
||||
mat = fitz.Matrix(self.current_zoom, self.current_zoom)
|
||||
mat = pymupdf.Matrix(self.current_zoom, self.current_zoom)
|
||||
pix = page.get_pixmap(matrix=mat, alpha=False)
|
||||
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
||||
|
||||
@@ -244,8 +308,8 @@ class PDFPreviewer:
|
||||
|
||||
# Re-open the file from disk to reset changes (like moved pages)
|
||||
try:
|
||||
self.doc = fitz.open(self.pdf_path)
|
||||
except Exception as e:
|
||||
self.doc = pymupdf.open(self.pdf_path)
|
||||
except (OSError, RuntimeError, ValueError) as e:
|
||||
messagebox.showerror("Error", f"Failed to reopen PDF file: {e}")
|
||||
self.master.destroy()
|
||||
return
|
||||
@@ -261,6 +325,16 @@ class PDFPreviewer:
|
||||
self.load_page()
|
||||
|
||||
|
||||
def reverse_pages(self, event=None):
|
||||
"""Reverse the current document and discard earlier page decisions."""
|
||||
if self.processing or not len(self.doc):
|
||||
return
|
||||
self.doc.select(list(reversed(range(len(self.doc)))))
|
||||
self.current_page_index = 0
|
||||
self.page_settings = []
|
||||
self._initialize_current_page_settings()
|
||||
self.load_page()
|
||||
|
||||
def move_line_left(self, event=None):
|
||||
"""Moves the split line to the left."""
|
||||
self.current_line_x = max(0, self.current_line_x - CM_TO_POINTS / 2)
|
||||
@@ -324,7 +398,8 @@ class PDFPreviewer:
|
||||
self._initialize_current_page_settings()
|
||||
self.load_page()
|
||||
else:
|
||||
self.finish_and_process()
|
||||
if not self.finish_and_process():
|
||||
return
|
||||
self.history.append(self.pdf_path)
|
||||
if self.setup_next_file():
|
||||
self._initialize_current_page_settings()
|
||||
@@ -332,59 +407,24 @@ class PDFPreviewer:
|
||||
else:
|
||||
self.master.destroy()
|
||||
|
||||
def finish_and_process(self):
|
||||
"""Starts the PDF splitting process and moves files."""
|
||||
self.split_pdf()
|
||||
# print("Debug : ", self.page_settings)
|
||||
# input("Splitting done. Continue ?")
|
||||
self.reorder_pdfs()
|
||||
# input("Reorder done. Continue ?")
|
||||
self.concate_files()
|
||||
|
||||
# Logic to move original to backup and replace with new file
|
||||
def finish_and_process(self) -> bool:
|
||||
"""Render and transactionally install the processed PDF."""
|
||||
try:
|
||||
abs_path = os.path.abspath(self.pdf_path)
|
||||
dir_name = os.path.dirname(abs_path)
|
||||
file_name = os.path.basename(abs_path)
|
||||
|
||||
backup_dir = os.path.join(dir_name, "Copies Originales")
|
||||
copies_dir = os.path.join(dir_name, "Copies")
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
os.makedirs(copies_dir, exist_ok=True)
|
||||
|
||||
backup_path = os.path.join(backup_dir, file_name)
|
||||
copies_path = os.path.join(copies_dir, file_name)
|
||||
|
||||
# Remove backup if it already exists (overwrite)
|
||||
if os.path.exists(backup_path):
|
||||
os.remove(backup_path)
|
||||
|
||||
# Move the original file to "Copies Originales"
|
||||
shutil.move(self.pdf_path, backup_path)
|
||||
|
||||
# Move the temp output file to replace the original
|
||||
shutil.move(self.final_file, copies_path)
|
||||
|
||||
# print(f"Original moved to {backup_path}, new file saved at {self.pdf_path}")
|
||||
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to move/replace files: {e}")
|
||||
|
||||
self.remove_dirs()
|
||||
|
||||
def _restore_original(self, path):
|
||||
"""Restores the original file from the 'Copies Originales' backup."""
|
||||
dir_name = os.path.dirname(os.path.abspath(path))
|
||||
file_name = os.path.basename(path)
|
||||
backup_path = os.path.join(dir_name, "Copies Originales", file_name)
|
||||
|
||||
if os.path.exists(backup_path):
|
||||
try:
|
||||
# Moving overwrites the generated PDF with the original backup
|
||||
shutil.move(backup_path, path)
|
||||
print(f"Restored original file from: {backup_path}")
|
||||
except Exception as e:
|
||||
print(f"Failed to restore original file: {e}")
|
||||
self.split_pdf()
|
||||
self.reorder_pdfs()
|
||||
self.concate_files()
|
||||
commit_processed_pdf(self.workspace, self.pdf_path, self.final_file)
|
||||
except Exception as exc: # noqa: BLE001 - interactive boundary
|
||||
self.failed = True
|
||||
self.processing = False
|
||||
print(f"Failed to process {self.pdf_path}: {exc}")
|
||||
messagebox.showerror("Error", f"Failed to process PDF: {exc}")
|
||||
self._temporary_directory.cleanup()
|
||||
self.master.destroy()
|
||||
return False
|
||||
else:
|
||||
self._temporary_directory.cleanup()
|
||||
return True
|
||||
|
||||
def go_to_previous_file(self, event=None):
|
||||
"""Goes back to the beginning of the previously completed file."""
|
||||
@@ -394,14 +434,16 @@ class PDFPreviewer:
|
||||
# Close the currently open document to avoid lock issues
|
||||
if hasattr(self, 'doc'):
|
||||
self.doc.close()
|
||||
if hasattr(self, "_temporary_directory"):
|
||||
self._temporary_directory.cleanup()
|
||||
|
||||
# 1. Push current file back onto the stack so it processes next
|
||||
self.inputs.append(self.pdf_path)
|
||||
|
||||
# 2. Get the previous file, restore its original state, and push to stack
|
||||
# 2. Reprocess the previous file from its preserved original backup
|
||||
prev_file = self.history.pop()
|
||||
self._restore_original(prev_file)
|
||||
self.inputs.append(prev_file)
|
||||
backup = self.workspace.original_copies_dir / Path(prev_file).name
|
||||
self.inputs.append(backup if backup.is_file() else Path(prev_file))
|
||||
|
||||
# 3. Reload environment (setup_next_file will pop prev_file back off the stack)
|
||||
self.setup_next_file()
|
||||
@@ -422,13 +464,9 @@ class PDFPreviewer:
|
||||
for pdf in pdf_files:
|
||||
try:
|
||||
os.remove(pdf)
|
||||
except Exception as e:
|
||||
except OSError as e:
|
||||
print(f"Error deleting {pdf}: {e}")
|
||||
|
||||
def remove_dirs(self):
|
||||
shutil.rmtree(self.split_dir)
|
||||
shutil.rmtree(self.reorder_dir)
|
||||
|
||||
def split_pdf(self):
|
||||
"""Splits each page of the PDF according to the saved settings."""
|
||||
print("Starting PDF processing...")
|
||||
@@ -443,7 +481,7 @@ class PDFPreviewer:
|
||||
self.file_rotation + self.global_rotation) % 360
|
||||
|
||||
if keep == "as_is":
|
||||
doc_full = fitz.open()
|
||||
doc_full = pymupdf.open()
|
||||
page_full = doc_full.new_page(width=page.rect.width, height=page.rect.height)
|
||||
page_full.show_pdf_page(page_full.rect, self.doc, i)
|
||||
page_full.set_rotation(rotation)
|
||||
@@ -455,13 +493,13 @@ class PDFPreviewer:
|
||||
|
||||
# --- Create Left Part ---
|
||||
if rotation == 0:
|
||||
rect_left = fitz.Rect(0, 0, line_x, page.rect.height)
|
||||
rect_left = pymupdf.Rect(0, 0, line_x, page.rect.height)
|
||||
else:
|
||||
rect_left = fitz.Rect(page.rect.width-line_x, 0, page.rect.width, page.rect.height)
|
||||
rect_left = pymupdf.Rect(page.rect.width-line_x, 0, page.rect.width, page.rect.height)
|
||||
|
||||
if (keep == "both" or keep == "left") and line_x > 0:
|
||||
|
||||
doc_left = fitz.open()
|
||||
doc_left = pymupdf.open()
|
||||
page_left = doc_left.new_page(width=rect_left.width, height=rect_left.height)
|
||||
page_left.show_pdf_page(page_left.rect, self.doc, i, clip=rect_left)
|
||||
page_left.set_rotation(rotation)
|
||||
@@ -472,11 +510,11 @@ class PDFPreviewer:
|
||||
|
||||
# --- Create Right Part ---
|
||||
if rotation == 0:
|
||||
rect_right = fitz.Rect(line_x, 0, page.rect.width, page.rect.height)
|
||||
rect_right = pymupdf.Rect(line_x, 0, page.rect.width, page.rect.height)
|
||||
else:
|
||||
rect_right = fitz.Rect(0, 0, page.rect.width-line_x, page.rect.height)
|
||||
rect_right = pymupdf.Rect(0, 0, page.rect.width-line_x, page.rect.height)
|
||||
if (keep == "both" or keep == "right") and line_x < page.rect.width:
|
||||
doc_right = fitz.open()
|
||||
doc_right = pymupdf.open()
|
||||
page_right = doc_right.new_page(width=rect_right.width, height=rect_right.height)
|
||||
page_right.show_pdf_page(page_right.rect, self.doc, i, clip=rect_right)
|
||||
page_right.set_rotation(rotation)
|
||||
@@ -584,13 +622,58 @@ class PDFPreviewer:
|
||||
print(f"Created merged PDF: {self.final_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python script_name.py <path_to_pdf_file>")
|
||||
sys.exit(1)
|
||||
def _selected_inputs(
|
||||
workspace: EvaluationWorkspace,
|
||||
target: Path,
|
||||
) -> list[Path]:
|
||||
if target.is_file():
|
||||
if target.suffix.casefold() != ".pdf":
|
||||
raise CliError(f"Target is not a PDF: {target}", ExitCode.INVALID_ARGUMENTS)
|
||||
backup = workspace.original_copies_dir / target.name
|
||||
return [backup if backup.is_file() else target]
|
||||
|
||||
pdf_file_path = sys.argv[1]
|
||||
directory = target
|
||||
if target == workspace.copies_dir:
|
||||
candidates = list_pdf_files(workspace.copies_dir)
|
||||
candidates = [
|
||||
(
|
||||
workspace.original_copies_dir / path.name
|
||||
if (workspace.original_copies_dir / path.name).is_file()
|
||||
else path
|
||||
)
|
||||
for path in candidates
|
||||
]
|
||||
else:
|
||||
candidates = list_pdf_files(directory)
|
||||
return list(reversed(candidates))
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, target: Path, *, marked: bool = False) -> ExitCode:
|
||||
inputs = list(reversed(marked_copy_paths(workspace, originals=True))) if marked else _selected_inputs(workspace, target)
|
||||
if not inputs:
|
||||
print(f"No PDF files found in {target}")
|
||||
return ExitCode.SUCCESS
|
||||
root = tk.Tk()
|
||||
app = PDFPreviewer(root, pdf_file_path)
|
||||
application = PDFPreviewer(root, workspace, inputs)
|
||||
root.mainloop()
|
||||
return ExitCode.FAILURE if application.failed else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = target_parser("Interactively split and reorder scanned PDF pages")
|
||||
parser.add_argument("--marked", action="store_true", help="Process only copies flagged during margin review")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
|
||||
def handle(args: argparse.Namespace) -> ExitCode:
|
||||
workspace, target = workspace_from_target(args)
|
||||
return run(workspace, target, marked=args.marked)
|
||||
|
||||
return execute(parser, argv, handle)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,23 +1,34 @@
|
||||
import sys
|
||||
import json
|
||||
import threading
|
||||
import re
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import queue
|
||||
import subprocess
|
||||
import re
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageTk
|
||||
|
||||
print("o to open pdf, O original pdf, e to emacs part, p to go back, i to interro, click for coordinates")
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
execute,
|
||||
read_json,
|
||||
target_parser,
|
||||
workspace_from_target,
|
||||
)
|
||||
from copienator.platform import open_path
|
||||
from copienator.utils import natural_key, read_all_labels
|
||||
|
||||
# --- Configuration & Globals ---
|
||||
padding = 60
|
||||
valid_labels_set = None
|
||||
|
||||
# Queue payload: (pil_image, json_path, metadata)
|
||||
# metadata is a dict: {'copie': str, 'part': int, 'schema': dict}
|
||||
image_queue = queue.Queue(maxsize=5)
|
||||
MISSING_LABEL_COLOR = "orange"
|
||||
COMMON_MISSING_LABEL_COLOR = "#403a00"
|
||||
COMMON_MISSING_THRESHOLD = 0.66
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype("DejaVuSans.ttf", size=30)
|
||||
@@ -31,6 +42,19 @@ def page_number(b, nb_pages):
|
||||
center_x = (b[1] + b[3]) // 2
|
||||
return center_x // column_width
|
||||
|
||||
|
||||
def sort_bounding_boxes(bounding_boxes, nb_pages):
|
||||
"""Return label boxes in reading order: columns first, then top to bottom."""
|
||||
return sorted(
|
||||
bounding_boxes,
|
||||
key=lambda entry: (
|
||||
page_number(entry["box_2d"], nb_pages),
|
||||
entry["box_2d"][0],
|
||||
entry["box_2d"][1],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def convert_box2d(b, pn_ori, npn, tot_ori, tot_dest):
|
||||
l = b.copy()
|
||||
l[1] = (l[1] - (1000 // tot_ori) * (pn_ori-1)) * tot_ori // tot_dest\
|
||||
@@ -54,16 +78,91 @@ def convert_list(l, group_id, json_schema):
|
||||
ll.append(ee)
|
||||
return ll
|
||||
|
||||
def prepare_image(image_path: str, bounding_boxes, all_labels, nb_pages, last_label_index):
|
||||
|
||||
def normalized_labels(entries):
|
||||
return [
|
||||
str(value["label"]).removeprefix("|").removesuffix("|")
|
||||
for value in entries
|
||||
if str(value["label"]) != "_"
|
||||
]
|
||||
|
||||
|
||||
def frequently_missing_labels(
|
||||
copies_dir: Path,
|
||||
all_labels: list[str],
|
||||
threshold: float = COMMON_MISSING_THRESHOLD,
|
||||
) -> set[str]:
|
||||
"""Return labels absent from at least ``threshold`` of detected copies."""
|
||||
labels_by_copy: dict[str, set[str]] = {}
|
||||
for json_path in copies_dir.glob("*.json"):
|
||||
match = re.fullmatch(r"(.+)_\d+", json_path.stem)
|
||||
if match is None:
|
||||
continue
|
||||
try:
|
||||
data = read_json(json_path)
|
||||
entries = data["list"]
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
present = set(normalized_labels(entries))
|
||||
except (OSError, KeyError, TypeError, ValueError):
|
||||
continue
|
||||
labels_by_copy.setdefault(match.group(1), set()).update(present)
|
||||
|
||||
copy_count = len(labels_by_copy)
|
||||
if copy_count == 0:
|
||||
return set()
|
||||
return {
|
||||
label
|
||||
for label in all_labels
|
||||
if sum(label not in present for present in labels_by_copy.values())
|
||||
>= threshold * copy_count
|
||||
}
|
||||
|
||||
|
||||
def label_color(
|
||||
label: str | None,
|
||||
all_labels: list[str],
|
||||
last_label_index: int,
|
||||
common_missing: set[str],
|
||||
) -> tuple[str, int]:
|
||||
"""Choose a label color and return the updated chronological index."""
|
||||
color = "black"
|
||||
if not label or label not in all_labels:
|
||||
return color, last_label_index
|
||||
|
||||
current_index = all_labels.index(label)
|
||||
if current_index < last_label_index or (
|
||||
last_label_index == -1 and current_index != 0
|
||||
):
|
||||
color = "red"
|
||||
elif current_index > last_label_index + 1:
|
||||
only_previous_is_missing = current_index == last_label_index + 2
|
||||
previous_label = all_labels[current_index - 1]
|
||||
color = (
|
||||
COMMON_MISSING_LABEL_COLOR
|
||||
if only_previous_is_missing and previous_label in common_missing
|
||||
else MISSING_LABEL_COLOR
|
||||
)
|
||||
return color, current_index
|
||||
|
||||
|
||||
def prepare_image(
|
||||
image_path: str,
|
||||
bounding_boxes,
|
||||
all_labels,
|
||||
nb_pages,
|
||||
last_label_index,
|
||||
common_missing: set[str] | None = None,
|
||||
):
|
||||
im = Image.open(image_path)
|
||||
im.load()
|
||||
width, height = im.size
|
||||
new_im = Image.new(im.mode, (width + padding, height), "white")
|
||||
new_im.paste(im, (0, 0))
|
||||
draw = ImageDraw.Draw(new_im)
|
||||
bounding_boxes.sort(key=lambda b: (page_number(b["box_2d"], nb_pages), b["box_2d"][0]))
|
||||
common_missing = common_missing or set()
|
||||
|
||||
for bbox in bounding_boxes:
|
||||
for bbox in sort_bounding_boxes(bounding_boxes, nb_pages):
|
||||
raw_y_min = int(bbox["box_2d"][0] * height / 1000)
|
||||
raw_x_min = int(bbox["box_2d"][1] * width / 1000)
|
||||
raw_y_max = int(bbox["box_2d"][2] * height / 1000)
|
||||
@@ -73,15 +172,13 @@ def prepare_image(image_path: str, bounding_boxes, all_labels, nb_pages, last_la
|
||||
abs_y_max = min(height, raw_y_max + 10)
|
||||
abs_x_max = min(width, raw_x_max + 10)
|
||||
|
||||
color = "black"
|
||||
label = bbox.get("label")
|
||||
if label and label in all_labels:
|
||||
current_index = all_labels.index(label)
|
||||
if current_index < last_label_index or (last_label_index == -1 and current_index != 0):
|
||||
color = "red"
|
||||
elif current_index > last_label_index + 1:
|
||||
color = "orange"
|
||||
last_label_index = current_index
|
||||
color, last_label_index = label_color(
|
||||
label,
|
||||
all_labels,
|
||||
last_label_index,
|
||||
common_missing,
|
||||
)
|
||||
|
||||
draw.rectangle(((abs_x_min, abs_y_min), (abs_x_max, abs_y_max)), outline=color, width=4)
|
||||
if label:
|
||||
@@ -93,13 +190,14 @@ def prepare_image(image_path: str, bounding_boxes, all_labels, nb_pages, last_la
|
||||
|
||||
# --- Processing Logic (Worker Thread) ---
|
||||
|
||||
def worker_thread(base_dir, files_to_process, all_labels):
|
||||
def _worker_items(base_dir, files_to_process, all_labels, output_queue):
|
||||
"""
|
||||
Iterates through files, prepares VISUALS only, and puts metadata in queue.
|
||||
Does NOT write final JSON files anymore.
|
||||
"""
|
||||
previous_copie = None
|
||||
last_label_index = None
|
||||
common_missing = frequently_missing_labels(base_dir / "Copies", all_labels)
|
||||
for img_path in files_to_process:
|
||||
json_path = base_dir / "Copies" / f"{img_path.stem}.json"
|
||||
copie_part = int(img_path.stem[-2:])
|
||||
@@ -110,9 +208,8 @@ def worker_thread(base_dir, files_to_process, all_labels):
|
||||
json_schema_path = base_dir / 'Cutleft' / f"{copie}_schema.json"
|
||||
|
||||
try:
|
||||
with open(json_schema_path, 'r') as f:
|
||||
json_schema = json.load(f)
|
||||
except:
|
||||
json_schema = read_json(json_schema_path)
|
||||
except (OSError, TypeError, ValueError):
|
||||
print("No json_schema : ", json_schema_path)
|
||||
continue
|
||||
|
||||
@@ -123,22 +220,27 @@ def worker_thread(base_dir, files_to_process, all_labels):
|
||||
bb_list = []
|
||||
json_name = ""
|
||||
try:
|
||||
with open(json_path, 'r') as f:
|
||||
json_result = json.load(f)
|
||||
json_result = read_json(json_path)
|
||||
bb_list = json_result.get("list", [])
|
||||
json_name = json_result.get("name", "")
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001 - malformed user-editable JSON
|
||||
print(f"Warning: {json_path.name} is malformed! Loading blank. {e}")
|
||||
# We do NOT skip; we continue so the user can fix it in the GUI
|
||||
|
||||
try:
|
||||
print(f"Buffering {img_path.name}...")
|
||||
(pil_image, last_label_index) = \
|
||||
prepare_image(str(img_path), bb_list, all_labels, nb_pages, last_label_index)
|
||||
prepare_image(
|
||||
str(img_path),
|
||||
bb_list,
|
||||
all_labels,
|
||||
nb_pages,
|
||||
last_label_index,
|
||||
common_missing,
|
||||
)
|
||||
error_msg = None
|
||||
|
||||
image_queue.put((pil_image, json_path, metadata))
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001 - keep the item editable in the GUI
|
||||
print(f"Error processing {img_path.name}: {e}")
|
||||
pil_image = Image.open(str(img_path))
|
||||
error_msg = str(e)
|
||||
@@ -151,15 +253,24 @@ def worker_thread(base_dir, files_to_process, all_labels):
|
||||
"error": error_msg
|
||||
}
|
||||
|
||||
image_queue.put((pil_image, json_path, metadata))
|
||||
output_queue.put((pil_image, json_path, metadata))
|
||||
|
||||
# Sentinel to indicate finished
|
||||
image_queue.put((None, None, None))
|
||||
def worker_thread(base_dir, files_to_process, all_labels, output_queue):
|
||||
"""Prepare queue items and always terminate the GUI stream."""
|
||||
failure = None
|
||||
try:
|
||||
_worker_items(base_dir, files_to_process, all_labels, output_queue)
|
||||
except Exception as exc: # noqa: BLE001 - worker boundary
|
||||
failure = str(exc)
|
||||
print(f"Plotting worker failed: {exc}")
|
||||
finally:
|
||||
metadata = {"worker_error": failure} if failure else None
|
||||
output_queue.put((None, None, metadata))
|
||||
|
||||
# --- GUI Logic (Main Thread) ---
|
||||
|
||||
class ImageViewer:
|
||||
def __init__(self, root, base_dir):
|
||||
def __init__(self, root, workspace, valid_labels, input_queue):
|
||||
self.root = root
|
||||
self.root.resizable(False, False) # If you resize, coordinates will be wrong
|
||||
|
||||
@@ -171,7 +282,10 @@ class ImageViewer:
|
||||
|
||||
root.geometry(f"+{x}+{y}")
|
||||
|
||||
self.base_dir = base_dir
|
||||
self.workspace = workspace
|
||||
self.base_dir = workspace.root
|
||||
self.valid_labels = valid_labels
|
||||
self.image_queue = input_queue
|
||||
self.root.title("Bounding Box Viewer")
|
||||
self.label = tk.Label(root, text="Waiting for images...")
|
||||
self.label.pack(expand=True, fill="both")
|
||||
@@ -192,6 +306,7 @@ class ImageViewer:
|
||||
self.history = []
|
||||
self.forward_stack = []
|
||||
self.current_pil_image = None
|
||||
self.failed = False
|
||||
|
||||
from config import PLOTTING_KB
|
||||
|
||||
@@ -200,9 +315,10 @@ class ImageViewer:
|
||||
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 originial pdf"], self.on_open_ori_pdf)
|
||||
self.root.bind(PLOTTING_KB["open original pdf"], self.on_open_ori_pdf)
|
||||
self.root.bind(PLOTTING_KB["open eval"], self.on_open_interro)
|
||||
self.root.bind('<Escape>', lambda e: self.root.quit())
|
||||
self.root.bind('<Escape>', lambda _event: self.close())
|
||||
self.root.protocol("WM_DELETE_WINDOW", self.close)
|
||||
self.label.bind('<Button-1>', self.on_click)
|
||||
|
||||
self.poll_queue()
|
||||
@@ -214,10 +330,15 @@ class ImageViewer:
|
||||
if self.forward_stack:
|
||||
pil_image, json_path, metadata = self.forward_stack.pop()
|
||||
else:
|
||||
pil_image, json_path, metadata = image_queue.get_nowait()
|
||||
pil_image, json_path, metadata = self.image_queue.get_nowait()
|
||||
|
||||
# Handle End of Stream
|
||||
if pil_image is None:
|
||||
if metadata and metadata.get("worker_error"):
|
||||
self.failed = True
|
||||
messagebox.showerror(
|
||||
"Processing Error", metadata["worker_error"]
|
||||
)
|
||||
self.save_current_batch() # Save any remaining data
|
||||
print("All images processed.")
|
||||
self.root.quit()
|
||||
@@ -229,7 +350,6 @@ class ImageViewer:
|
||||
# Start new batch
|
||||
self.active_copie_name = metadata["copie"]
|
||||
self.accumulated_results = {"name": metadata["name"], "list": []}
|
||||
self.history.clear()
|
||||
|
||||
self.display_image(pil_image, json_path, metadata)
|
||||
except queue.Empty:
|
||||
@@ -241,24 +361,33 @@ class ImageViewer:
|
||||
if self.active_copie_name and self.accumulated_results:
|
||||
main_json_path = self.base_dir / "Copies" / f"{self.active_copie_name}.json"
|
||||
print(f"Writing aggregated result to {main_json_path}")
|
||||
with open(main_json_path, 'w') as f:
|
||||
json.dump(self.accumulated_results, f)
|
||||
atomic_write_json(main_json_path, self.accumulated_results)
|
||||
self.accumulated_results = None
|
||||
|
||||
def close(self):
|
||||
self.root.quit()
|
||||
|
||||
|
||||
def on_previous(self, event):
|
||||
if self.is_viewing and self.history:
|
||||
print("Going back to previous image...")
|
||||
prev_pil, prev_json, prev_meta, num_added = self.history.pop()
|
||||
|
||||
# Undo the accumulation to prevent duplicates when we hit Enter again
|
||||
if self.accumulated_results and num_added > 0:
|
||||
self.accumulated_results["list"] = self.accumulated_results["list"][:-num_added]
|
||||
(
|
||||
prev_pil,
|
||||
prev_json,
|
||||
prev_meta,
|
||||
previous_copie_name,
|
||||
previous_results,
|
||||
) = self.history.pop()
|
||||
|
||||
# Push current image to the forward stack so we don't lose it
|
||||
self.forward_stack.append((self.current_pil_image,
|
||||
self.current_json_path, self.current_meta))
|
||||
|
||||
# Restore the aggregation exactly as it was before validating the
|
||||
# previous image. This also makes navigation across copies safe.
|
||||
self.active_copie_name = previous_copie_name
|
||||
self.accumulated_results = previous_results
|
||||
|
||||
# Display the previous image immediately
|
||||
self.display_image(prev_pil, prev_json, prev_meta)
|
||||
def display_image(self, pil_image, json_path, metadata):
|
||||
@@ -285,32 +414,30 @@ class ImageViewer:
|
||||
def on_enter(self, event):
|
||||
if self.is_viewing:
|
||||
print(f"Committing data for {self.current_json_path.name}...")
|
||||
num_added = 0 # ADD THIS LINE
|
||||
previous_copie_name = self.active_copie_name
|
||||
previous_results = copy.deepcopy(self.accumulated_results)
|
||||
|
||||
try:
|
||||
with open(self.current_json_path, 'r') as f:
|
||||
current_data = json.load(f)
|
||||
current_data = read_json(self.current_json_path)
|
||||
items = current_data["list"]
|
||||
|
||||
# Perform the conversion now, post-edit
|
||||
converted_items = convert_list(
|
||||
current_data["list"],
|
||||
items,
|
||||
self.current_meta["part"],
|
||||
self.current_meta["schema"]
|
||||
)
|
||||
|
||||
labels = [v["label"] for v in current_data["list"]]
|
||||
labels = [label for label in labels if label != "_"]
|
||||
labels = [label[1:] for label in labels if label[0] == "|"]
|
||||
labels = [label[:-1] for label in labels if label[-1] == "|"]
|
||||
false_labels = [label for label in labels if label not in valid_labels_set]
|
||||
labels = normalized_labels(items)
|
||||
false_labels = [
|
||||
label for label in labels if label not in self.valid_labels
|
||||
]
|
||||
|
||||
if false_labels:
|
||||
msg = f"Wrong label in {self.current_json_path.name}: {false_labels}\n\n\tPlease press 'e' to fix it, then press Enter again."
|
||||
print(msg)
|
||||
messagebox.showerror("Label Error", msg)
|
||||
return
|
||||
num_added = len(converted_items)
|
||||
|
||||
# Add to accumulator
|
||||
if self.accumulated_results:
|
||||
self.accumulated_results["list"].extend(converted_items)
|
||||
@@ -318,15 +445,22 @@ class ImageViewer:
|
||||
if "name" in current_data and current_data["name"] != "Continued":
|
||||
self.accumulated_results["name"] = current_data["name"]
|
||||
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001 - interactive validation boundary
|
||||
# Warn user and STOP (do not advance to next image)
|
||||
msg = f"Error reading {self.current_json_path.name}:\n\n{e}\n\nPlease press 'e' to fix it, then press Enter again."
|
||||
print(msg)
|
||||
messagebox.showerror("JSON Error", msg)
|
||||
return # Abort advancement
|
||||
|
||||
self.history.append((self.current_pil_image, self.current_json_path,
|
||||
self.current_meta, num_added))
|
||||
self.history.append(
|
||||
(
|
||||
self.current_pil_image,
|
||||
self.current_json_path,
|
||||
self.current_meta,
|
||||
previous_copie_name,
|
||||
previous_results,
|
||||
)
|
||||
)
|
||||
|
||||
# Advance UI
|
||||
self.is_viewing = False
|
||||
@@ -337,7 +471,7 @@ class ImageViewer:
|
||||
a = self.current_json_path.stem.split('_')[0] + ".pdf"
|
||||
pdf_path = self.current_json_path.with_name(a)
|
||||
print(f"Opening {pdf_path}")
|
||||
subprocess.Popen(['xdg-open', str(pdf_path.absolute())])
|
||||
open_path(pdf_path)
|
||||
|
||||
def on_open_interro(self, event):
|
||||
if self.is_viewing and self.current_json_path:
|
||||
@@ -348,25 +482,28 @@ class ImageViewer:
|
||||
if local_accent.exists():
|
||||
pdf_path = str(local_accent)
|
||||
elif local_plain.exists():
|
||||
pdf_path = str(local_plain)
|
||||
pdf_path = local_plain
|
||||
else:
|
||||
# Fallback to the Interro staging directory
|
||||
pdf_path = f"/home/sebastien/Prépa/Staging/Interro/{self.base_dir.name}.pdf"
|
||||
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}")
|
||||
subprocess.Popen(['xdg-open', 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}")
|
||||
subprocess.Popen(['xdg-open', str(pdf_path.absolute())])
|
||||
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}")
|
||||
subprocess.Popen(['xdg-open', str(self.current_json_path.absolute())])
|
||||
open_path(self.current_json_path)
|
||||
|
||||
def on_click(self, event):
|
||||
if not self.is_viewing: return
|
||||
@@ -384,54 +521,58 @@ class ImageViewer:
|
||||
self.root.clipboard_clear()
|
||||
self.root.clipboard_append(box_str)
|
||||
|
||||
from utils import natural_key, read_all_labels
|
||||
def _selected_images(workspace: EvaluationWorkspace, target: Path) -> list[Path]:
|
||||
workspace.require_directories("Cutleft", "Copies")
|
||||
if target.is_file():
|
||||
stem = target.stem
|
||||
exact = workspace.cutleft_dir / f"{stem}.jpg"
|
||||
if exact.is_file():
|
||||
return [exact]
|
||||
return sorted(
|
||||
workspace.cutleft_dir.glob(f"{stem}_*.jpg"),
|
||||
key=natural_key,
|
||||
)
|
||||
return sorted(workspace.cutleft_dir.glob("*.jpg"), key=natural_key)
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
|
||||
workspace.require_files("labels")
|
||||
all_labels = read_all_labels(workspace.root)
|
||||
files_to_process = _selected_images(workspace, target)
|
||||
if not files_to_process:
|
||||
print(f"No Cutleft images found for {target}")
|
||||
return ExitCode.PARTIAL
|
||||
|
||||
print(
|
||||
"o to open pdf, O original pdf, e to edit part, p to go back, "
|
||||
"i to open the statement, click for coordinates"
|
||||
)
|
||||
input_queue = queue.Queue(maxsize=5)
|
||||
worker = threading.Thread(
|
||||
target=worker_thread,
|
||||
args=(workspace.root, files_to_process, all_labels, input_queue),
|
||||
daemon=True,
|
||||
)
|
||||
worker.start()
|
||||
root = tk.Tk()
|
||||
application = ImageViewer(root, workspace, set(all_labels), input_queue)
|
||||
root.mainloop()
|
||||
return ExitCode.PARTIAL if application.failed else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
return target_parser("Interactively verify detected label coordinates")
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
|
||||
def handle(args: argparse.Namespace) -> ExitCode:
|
||||
workspace, target = workspace_from_target(args)
|
||||
return run(workspace, target)
|
||||
|
||||
return execute(parser, argv, handle)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python plotting.py <directory_or_file>")
|
||||
sys.exit(1)
|
||||
|
||||
input_path = Path(sys.argv[1])
|
||||
files_to_process = []
|
||||
|
||||
|
||||
if input_path.is_file():
|
||||
# Correctly identify base_dir if we are in 'Copies' or 'Cutleft'
|
||||
if input_path.parent.name in ["Copies", "Cutleft"]:
|
||||
base_dir = input_path.parent.parent
|
||||
else:
|
||||
base_dir = input_path.parent
|
||||
|
||||
stem = input_path.stem
|
||||
cutleft_dir = base_dir / "Cutleft"
|
||||
img_path = cutleft_dir / f"{stem}.jpg"
|
||||
|
||||
if img_path.exists():
|
||||
files_to_process = [img_path]
|
||||
else:
|
||||
# We're given something like Copie01.pdf, look for its split image parts
|
||||
files_to_process = sorted(list(cutleft_dir.glob(f"{stem}_*.jpg")), key=natural_key)
|
||||
else:
|
||||
base_dir = input_path
|
||||
cutleft_dir = base_dir / "Cutleft"
|
||||
if not cutleft_dir.exists():
|
||||
print(f"Error: {cutleft_dir} does not exist.")
|
||||
sys.exit(1)
|
||||
files_to_process = sorted(cutleft_dir.glob("*.jpg"))
|
||||
|
||||
labels_txt = (base_dir / "labels").read_text()
|
||||
valid_labels_set = set(line.strip() for line in labels_txt.splitlines() if line.strip())
|
||||
|
||||
|
||||
try:
|
||||
all_labels = read_all_labels(base_dir)
|
||||
except FileNotFoundError:
|
||||
all_labels = []
|
||||
|
||||
t = threading.Thread(target=worker_thread, args=(base_dir, files_to_process, all_labels))
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
root = tk.Tk()
|
||||
app = ImageViewer(root, base_dir)
|
||||
root.mainloop()
|
||||
raise SystemExit(main())
|
||||
@@ -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 json
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import ezodf
|
||||
import re
|
||||
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
|
||||
ODS_PATH = "/home/sebastien/Rust/gestion_classe/Staging/current_eval.ods"
|
||||
ODS_PATH = Path(CURRENT_SCORE_ODS_PATH).expanduser()
|
||||
TARGET_DIR_NAME = "A Rendre"
|
||||
|
||||
def main():
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description="Update ODS with student scores.")
|
||||
parser.add_argument("work_dir", nargs="?", default=os.getcwd(), help="Directory to process")
|
||||
parser.add_argument("--sum", action="store_true", help="Write only the total sum per student")
|
||||
args = parser.parse_args()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
work_dir = os.path.abspath(args.work_dir)
|
||||
|
||||
@@ -34,7 +35,7 @@ def main():
|
||||
|
||||
print(f"Opening ODS file: {ODS_PATH}...")
|
||||
try:
|
||||
doc = ezodf.opendoc(ODS_PATH)
|
||||
doc = ezodf.opendoc(str(ODS_PATH))
|
||||
except Exception as e:
|
||||
print(f"Failed to open ODS: {e}")
|
||||
sys.exit(1)
|
||||
@@ -168,4 +169,4 @@ def main():
|
||||
print("Done.")
|
||||
|
||||
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
|
||||