D1 – Damier, escaliers & pixel art¶
Python numérique – devoir n° 1 – à rendre pour le dimanche 13 septembre 2026, 23 h 59 (la séance 2 est le mardi 15)
Bienvenue dans votre premier devoir du cours ! Nous allons y reprendre ensemble les notions vues en séance 1 : création de tableaux numpy, dtype, shape, reshape, indexation et slicing, vues et copies, et les images vues comme des tableaux (H, W, 3) d'entiers uint8.
Durée indicative : 1 h 30.
{admonition}
:class: important
* Ce notebook reste dans le dossier `D1/` de votre dépôt `pe-homework` (celui que le zip du sujet a créé en se dézippant), avec ses figures à côté, puis il doit être **commité et poussé** (`git add`, `git commit`, `git push`) avant la deadline. Attention, c'est la **date du commit** qui fait foi, pas celle du push ; le nom du fichier, lui, n'a pas d'importance (`sujet.ipynb` convient très bien).
* Juste avant de rendre, **pensez à ré-exécuter toutes les cellules** via *Kernel → Restart Kernel and Run All Cells* : toutes doivent s'exécuter dans l'ordre et sans erreur. En effet, un notebook qui plante à mi-chemin sera corrigé jusqu'à l'erreur…
* Les images demandées (`pixel_art.png`, …) sont à enregistrer dans le dossier du notebook, et doivent aussi apparaître dans le notebook (`plt.imshow`).
* Les cellules `# votre code` sont à compléter ; vous pouvez ajouter autant de cellules que vous le souhaitez. Les cellules de test (`assert`) sont là pour vous aider : si elles passent sans erreur, c'est bon signe. Ne les modifiez pas !
* Répondez aux questions "en français" dans une cellule *Markdown*, en deux ou trois phrases.
{admonition}
:class: note
* La documentation de `numpy` / `matplotlib`, les notebooks du cours, vos notes.
* Les assistants IA sont tolérés, avec une règle simple : **vous devez être capable d'expliquer chaque ligne que vous rendez**, et je pourrai vous demander de le faire à l'oral en séance. Le devoir est individuel.
* En revanche, les boucles `for` / `while` sur les éléments d'un tableau sont interdites, sauf mention contraire : tout se fait avec des opérations `numpy` "globales" (slicing, opérations entre tableaux, `np.repeat`, …). C'est tout l'objet du devoir !
Barème¶
| Exercice | Contenu | Points |
|---|---|---|
| 1 | Table de multiplication | 3 |
| 2 | Damier paramétré | 4 |
| 3 | Escaliers et pyramide | 4 |
| 4 | Pixel art : construction, agrandissement, gris, seuillage, sauvegarde | 7 |
| – | Notebook propre : exécuté sans erreur, réponses rédigées, figures titrées | 2 |
| Total | 20 | |
| Bonus | Négatif, miroir, vues | +2 |
Inspiré des exercices de https://numerique-exos.info-mines.paris.
import numpy as np
import matplotlib.pyplot as plt
Exercice 1 – Table de multiplication (3 pts)¶
On veut la table de multiplication de 1 à 10 sous forme d'un tableau table de forme (10, 10) tel que table[i, j] == (i + 1) * (j + 1).
{admonition}
:class: note
Quand on combine deux tableaux de formes différentes, `numpy` "étire" automatiquement celui dont une dimension vaut 1 : par ex. `np.arange(3).reshape(-1, 1) * np.arange(4)` combine une colonne `(3, 1)` et une ligne `(4,)` et produit une grille `(3, 4)`. On appelle cela le *broadcasting*, et nous l'étudierons en détail en séance 2.
1.a Construisez table sans écrire de boucle Python.
{admonition}
:class: tip
`np.outer(a, b)` calcule le tableau des produits `a[i] * b[j]`.
# SOLUTION
colonne = np.arange(1, 11).reshape(-1, 1)
ligne = np.arange(1, 11)
table = colonne * ligne # équivalent : np.outer(ligne, ligne)
table
array([[ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
[ 2, 4, 6, 8, 10, 12, 14, 16, 18, 20],
[ 3, 6, 9, 12, 15, 18, 21, 24, 27, 30],
[ 4, 8, 12, 16, 20, 24, 28, 32, 36, 40],
[ 5, 10, 15, 20, 25, 30, 35, 40, 45, 50],
[ 6, 12, 18, 24, 30, 36, 42, 48, 54, 60],
[ 7, 14, 21, 28, 35, 42, 49, 56, 63, 70],
[ 8, 16, 24, 32, 40, 48, 56, 64, 72, 80],
[ 9, 18, 27, 36, 45, 54, 63, 72, 81, 90],
[ 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]])
assert table.shape == (10, 10)
assert table[0, 0] == 1
assert table[2, 4] == 15
assert table[9, 9] == 100
assert table.sum() == 3025
print("ok")
ok
1.b Quel est le dtype de votre tableau et combien d'octets occupe-t-il en mémoire (nbytes) ? Refaites la même table avec le plus petit type entier non signé qui suffit à stocker toutes les valeurs, et comparez la taille mémoire.
# SOLUTION
print(table.dtype, table.nbytes, "octets")
table_u8 = (np.arange(1, 11, dtype=np.uint8).reshape(-1, 1)
* np.arange(1, 11, dtype=np.uint8))
print(table_u8.dtype, table_u8.nbytes, "octets")
int64 800 octets uint8 100 octets
assert table_u8.dtype == np.uint8
assert np.array_equal(table_u8, table)
assert table_u8.nbytes == 100
print("ok")
ok
{admonition}
Sur une machine 64 bits, `np.arange` produit des `int64` : 800 octets, contre 100 en `uint8` (les valeurs vont de 1 à 100, ce qui tient largement dans 0..255). On acceptera `astype(np.uint8)` après coup.
Exercice 2 – Damier (4 pts)¶
2.a Écrivez une fonction damier(n, coin=0) qui renvoie un tableau d'entiers de forme (n, n) rempli de 0 et de 1 en alternance, comme un damier, où la case [0, 0] vaut coin (0 par défaut).
# SOLUTION
def damier(n, coin=0):
d = np.zeros((n, n), dtype=int)
d[::2, 1::2] = 1
d[1::2, ::2] = 1
if coin == 1:
d = 1 - d
return d
damier(8)
array([[0, 1, 0, 1, 0, 1, 0, 1],
[1, 0, 1, 0, 1, 0, 1, 0],
[0, 1, 0, 1, 0, 1, 0, 1],
[1, 0, 1, 0, 1, 0, 1, 0],
[0, 1, 0, 1, 0, 1, 0, 1],
[1, 0, 1, 0, 1, 0, 1, 0],
[0, 1, 0, 1, 0, 1, 0, 1],
[1, 0, 1, 0, 1, 0, 1, 0]])
d8 = damier(8)
assert d8.shape == (8, 8)
assert d8.dtype.kind in "iu" # un type entier
assert d8.sum() == 32
assert d8[0, 0] == 0 and d8[0, 1] == 1 and d8[1, 0] == 1 and d8[7, 7] == 0
assert damier(5).sum() == 12
assert damier(5, coin=1).sum() == 13
assert damier(5, coin=1)[0, 0] == 1
assert damier(3, coin=1)[1, 1] == 1
print("ok")
ok
{admonition}
:class: note
Après un `plt.imshow(...)`, l'appel `plt.title("mon titre")` écrit un titre au-dessus de la figure en cours, et `plt.colorbar()` lui ajoute une échelle de couleurs. Ces fonctions d'habillage s'utilisent avant `plt.show()`, qui clôt la figure. On reviendra sur `matplotlib` en séance 5.
2.b Affichez damier(8) avec plt.imshow(..., cmap="gray") et donnez un titre à la figure.
# SOLUTION
plt.imshow(damier(8), cmap="gray")
plt.title("damier(8)")
plt.show()
2.c Version booléenne : écrivez damier_bool(n) qui renvoie le même damier (coin 0 = False) mais avec un dtype bool. Comparez damier(64).nbytes et damier_bool(64).nbytes et expliquez la différence dans une cellule Markdown.
# SOLUTION
def damier_bool(n):
return damier(n).astype(bool)
print(damier(64).nbytes, damier_bool(64).nbytes)
32768 4096
db = damier_bool(8)
assert db.dtype == bool
assert db.shape == (8, 8)
assert db.sum() == 32
assert db[0, 0] == False and db[0, 1] == True
assert damier_bool(64).nbytes == 64 * 64
print("ok")
ok
{admonition}
Un booléen `numpy` occupe 1 octet, un `int64` en occupe 8 : 4096 octets contre 32768. Réponse attendue : « le `dtype` par défaut des entiers est `int64` (8 octets par case), un `bool` tient sur 1 octet ».
Exercice 3 – Escaliers et pyramide (4 pts)¶
{admonition}
:class: note
`np.indices((n, m))` renvoie deux tableaux de forme `(n, m)` : le premier contient le numéro de ligne de chaque case, le second son numéro de colonne. Ainsi `I, J = np.indices((2, 3))` donne `I = [[0, 0, 0], [1, 1, 1]]` et `J = [[0, 1, 2], [0, 1, 2]]`. C'est la façon la plus directe de fabriquer un tableau dont la valeur dépend de la position de la case.
3.a Écrivez une fonction escalier(n) qui renvoie le tableau M de forme (n, n) tel que M[i, j] == i + j, toujours sans boucle.
# SOLUTION
def escalier(n):
I, J = np.indices((n, n))
return I + J
escalier(5)
array([[0, 1, 2, 3, 4],
[1, 2, 3, 4, 5],
[2, 3, 4, 5, 6],
[3, 4, 5, 6, 7],
[4, 5, 6, 7, 8]])
E = escalier(6)
assert E.shape == (6, 6)
assert E[0, 0] == 0 and E[5, 5] == 10 and E[2, 3] == 5
assert E.sum() == 6 * 6 * 5
assert np.array_equal(E, E.T)
print("ok")
ok
3.b Écrivez pyramide(n) qui renvoie le tableau P tel que P[i, j] == |i - j| (la distance à la diagonale). Sa diagonale est nulle et les valeurs croissent quand on s'en éloigne.
# SOLUTION
def pyramide(n):
I, J = np.indices((n, n))
return np.abs(I - J)
pyramide(5)
array([[0, 1, 2, 3, 4],
[1, 0, 1, 2, 3],
[2, 1, 0, 1, 2],
[3, 2, 1, 0, 1],
[4, 3, 2, 1, 0]])
P = pyramide(7)
assert P.shape == (7, 7)
assert np.all(np.diag(P) == 0)
assert P[0, 6] == 6 and P[6, 0] == 6 and P[2, 5] == 3
assert np.array_equal(P, P.T)
assert P.sum() == 112
print("ok")
ok
3.c Affichez escalier(10) et pyramide(10) avec plt.imshow (une figure chacun, avec un titre, et une barre de couleurs avec plt.colorbar()).
Puis, à l'aide d'un seul slicing, extrayez de pyramide(10) la sous-matrice (5, 5) formée des lignes paires et des colonnes paires, et vérifiez qu'elle vaut 2 * pyramide(5).
# SOLUTION
for nom, M in [("escalier(10)", escalier(10)), ("pyramide(10)", pyramide(10))]:
plt.imshow(M)
plt.colorbar()
plt.title(nom)
plt.show()
sous = pyramide(10)[::2, ::2]
sous
array([[0, 2, 4, 6, 8],
[2, 0, 2, 4, 6],
[4, 2, 0, 2, 4],
[6, 4, 2, 0, 2],
[8, 6, 4, 2, 0]])
assert sous.shape == (5, 5)
assert np.array_equal(sous, 2 * pyramide(5))
print("ok")
ok
Exercice 4 – Pixel art (7 pts)¶
Une image couleur est un tableau de forme (hauteur, largeur, 3) d'entiers uint8 : pour chaque pixel, trois valeurs entre 0 et 255 (rouge, vert, bleu). Quelques couleurs utiles :
| couleur | (R, G, B) |
|---|---|
| noir | (0, 0, 0) |
| blanc | (255, 255, 255) |
| rouge | (220, 30, 30) |
| jaune | (250, 210, 40) |
| bleu ciel | (120, 180, 240) |
| vert | (40, 170, 70) |
4.a Construisez une image img de forme (16, 16, 3) et de type uint8 représentant un motif en pixel art de votre choix : cœur, smiley, drapeau, lettre, fantôme de Pac-Man, … Contraintes :
- le fond n'est pas noir ;
- le motif utilise au moins trois couleurs différentes (fond compris) ;
- tout se fait par slicing et affectation (
img[2:5, 6:10] = (220, 30, 30)) : pas de boucle sur les pixels ! Une affectation par "trait" horizontal ou vertical est parfaitement acceptable, et pensez aussi aux pas (img[::2, ...]) pour les motifs répétitifs.
Affichez-la avec plt.imshow(img).
{admonition}
:class: tip
Dessinez sur papier quadrillé 16×16 avant de coder : les lignes sont le premier indice (de haut en bas), les colonnes le second (de gauche à droite).
# SOLUTION
img = np.zeros((16, 16, 3), dtype=np.uint8)
img[:, :] = (120, 180, 240) # fond bleu ciel
rouge = (220, 30, 30)
# un cœur : deux bosses puis un triangle qui se resserre
img[3, 4:7] = rouge; img[3, 9:12] = rouge
img[4, 3:8] = rouge; img[4, 8:13] = rouge
img[5:8, 2:14] = rouge
img[8, 3:13] = rouge
img[9, 4:12] = rouge
img[10, 5:11] = rouge
img[11, 6:10] = rouge
img[12, 7:9] = rouge
# un reflet blanc
img[5:7, 4] = (255, 255, 255)
# une bordure damier en bas
img[14:16, ::2] = (250, 210, 40)
img[15, 1::2] = (250, 210, 40)
img[14, 1::2] = (40, 170, 70)
plt.imshow(img)
plt.title("pixel art 16×16")
plt.show()
assert img.shape == (16, 16, 3)
assert img.dtype == np.uint8
couleurs = np.unique(img.reshape(-1, 3), axis=0)
assert len(couleurs) >= 3, "il faut au moins 3 couleurs"
assert not np.array_equal(img[0, 0], (0, 0, 0)), "le fond ne doit pas être noir"
print("ok :", len(couleurs), "couleurs")
ok : 5 couleurs
{admonition}
:class: note
`np.repeat(tab, k, axis=...)` duplique `k` fois de suite chaque élément le long de l'axe indiqué : `np.repeat(np.array([1, 2]), 3)` vaut `[1, 1, 1, 2, 2, 2]`. Sur un tableau à plusieurs dimensions, `axis=0` répète les lignes et `axis=1` les colonnes ; la taille de l'axe concerné est multipliée par `k`.
4.b L'image est minuscule. Agrandissez-la d'un facteur 20 dans chaque direction avec np.repeat (une fois le long de l'axe 0, une fois le long de l'axe 1) : chaque pixel devient un carré de 20×20 pixels. Le résultat grande a la forme (320, 320, 3). Affichez-le.
# SOLUTION
grande = np.repeat(np.repeat(img, 20, axis=0), 20, axis=1)
plt.imshow(grande)
plt.show()
assert grande.shape == (320, 320, 3)
assert grande.dtype == np.uint8
assert np.array_equal(grande[::20, ::20], img)
assert np.array_equal(grande[0:20, 0:20], np.broadcast_to(img[0, 0], (20, 20, 3)))
print("ok")
ok
{admonition}
:class: note
`tab.sum()`, `tab.mean()`, `tab.max()`… travaillent par défaut sur tout le tableau, mais l'argument `axis=` permet de ne réduire qu'une dimension : pour un tableau de forme `(4, 5, 3)`, `tab.mean(axis=2)` moyenne les 3 valeurs de la dernière dimension et renvoie un tableau `(4, 5)`. Retenez bien que l'axe qu'on donne est celui qui disparaît ! Nous verrons les agrégations en séance 2.
4.c Niveaux de gris. Construisez gris, tableau (320, 320) de type uint8 où chaque pixel vaut la moyenne de ses trois canaux.
{admonition}
:class: warning
Testez d'abord la cellule ci-dessous. Que vaut `np.uint8(200) + np.uint8(100)` ? Pouvez-vous deviner pourquoi ?
En conséquence, `(grande[:, :, 0] + grande[:, :, 1] + grande[:, :, 2]) / 3` est **faux** ! Il faut convertir en flottants *avant* d'additionner (par ex. avec `grande.astype(float)`), ou bien utiliser `grande.mean(axis=2)` qui calcule la moyenne le long du troisième axe (les canaux) et renvoie des flottants. On revient ensuite en `uint8` avec `astype(np.uint8)`.
a = np.array([200], dtype=np.uint8)
b = np.array([100], dtype=np.uint8)
print(a + b, (a + b).dtype)
[44] uint8
Expliquez le résultat ci-dessus en une phrase (cellule Markdown), puis calculez gris et affichez-le avec plt.imshow(gris, cmap="gray").
{admonition}
200 + 100 = 300 ne tient pas sur 8 bits (max 255) : le calcul reste en `uint8` et « reboucle » modulo 256, d'où 44. `numpy` ne lève pas d'erreur (il émet au plus un avertissement pour les scalaires).
# SOLUTION
gris = grande.mean(axis=2).astype(np.uint8)
plt.imshow(gris, cmap="gray")
plt.title("niveaux de gris")
plt.show()
assert gris.shape == (320, 320)
assert gris.dtype == np.uint8
attendu = (grande[:, :, 0].astype(float) + grande[:, :, 1] + grande[:, :, 2]) / 3
assert np.all(np.abs(gris.astype(float) - attendu) <= 1)
print("ok")
ok
{admonition}
:class: note
Une comparaison s'applique case par case et renvoie un tableau de booléens de même forme : `np.array([1, 5, 9]) > 4` vaut `[False, True, True]`. On appelle cela un *masque* ; `astype(np.uint8)` le convertit ensuite en 0 et 1. On travaillera les masques en séance 2.
4.d Seuillage. Construisez noir_blanc, image (320, 320) de type uint8 qui vaut 255 (blanc) là où gris est strictement supérieur à 127 et 0 (noir) ailleurs. Affichez-la.
# SOLUTION
noir_blanc = (gris > 127).astype(np.uint8) * 255
plt.imshow(noir_blanc, cmap="gray")
plt.title("seuillage à 127")
plt.show()
assert noir_blanc.shape == (320, 320)
assert noir_blanc.dtype == np.uint8
assert set(np.unique(noir_blanc)) <= {0, 255}
assert np.all((noir_blanc == 255) == (gris > 127))
print("ok")
ok
4.e Sauvegarde. Enregistrez grande dans pixel_art.png et gris dans pixel_art_gris.png avec plt.imsave (pour l'image en gris, précisez cmap="gray"). Relisez ensuite pixel_art.png avec plt.imread dans une variable relue : quelle est sa forme ? son dtype ? Ses valeurs sont-elles entre 0 et 255 ? Commentez en une ou deux phrases.
# SOLUTION
plt.imsave("pixel_art.png", grande)
plt.imsave("pixel_art_gris.png", gris, cmap="gray")
relue = plt.imread("pixel_art.png")
print(relue.shape, relue.dtype, relue.min(), relue.max())
(320, 320, 4) float32 0.11764706 1.0
import os
assert os.path.exists("pixel_art.png") and os.path.exists("pixel_art_gris.png")
assert relue.shape[:2] == (320, 320)
print("ok")
ok
{admonition}
`plt.imread` d'un PNG renvoie des `float32` entre 0 et 1, avec 4 canaux (RGB + alpha) : forme `(320, 320, 4)`. On attend que l'étudiant remarque le changement de type / d'échelle et le canal supplémentaire (vu en TP images 1/2).
Bonus (+2) – Négatif, miroir, vues¶
B.1 Le négatif d'une image s'obtient en remplaçant chaque valeur v par 255 - v. Calculez le négatif de grande et affichez-le. Quel est son dtype ?
B.2 Affichez l'image miroir (gauche-droite) de grande, puis l'image retournée (haut-bas), avec du slicing à pas négatif.
B.3 Vues et copies. Exécutez les lignes suivantes, observez et expliquez en deux phrases ce qui se passe sur grande ; puis proposez la modification qui évite de toucher à grande.
coin = grande[:40, :40]
coin[:, :] = (0, 0, 0)
plt.imshow(grande)
Vérifiez votre explication avec np.shares_memory(coin, grande).
Bon courage à tous ! Pour toute question sur le sujet, vous pouvez me contacter par email : raphael@imagine-app.fr.
Au plaisir de vous lire,
votre responsable de cours, Raphael Prasquier (sujet conçu par Aurélien Noce)
# SOLUTION
negatif = 255 - grande
print(negatif.dtype)
plt.imshow(negatif); plt.title("négatif"); plt.show()
plt.imshow(grande[:, ::-1]); plt.title("miroir"); plt.show()
plt.imshow(grande[::-1]); plt.title("retournée"); plt.show()
coin = grande[:40, :40] # une VUE : mêmes octets que grande
coin[:, :] = (0, 0, 0) # ... donc grande est modifiée
plt.imshow(grande); plt.title("le coin a été noirci dans grande"); plt.show()
print(np.shares_memory(coin, grande))
# pour ne pas toucher à grande : travailler sur une copie
coin2 = grande[40:80, 40:80].copy()
coin2[:, :] = (0, 0, 0)
print(np.shares_memory(coin2, grande))
uint8
True False
{admonition}
Un slicing renvoie une vue : `coin` partage sa mémoire avec `grande`, écrire dans `coin` écrit dans `grande`. Solution : `.copy()`. `255 - grande` reste en `uint8` (soustraction de scalaire entier sans dépassement).