import initial

This commit is contained in:
Vincent Bourdon
2026-06-10 10:21:18 +02:00
commit 5a03f8a38d
59 changed files with 4777 additions and 0 deletions
@@ -0,0 +1,71 @@
# Design : affichage de l'image originale sur la page solution
**Date :** 2026-05-20
## Contexte
La page solution affiche actuellement la grille remplie (cellules noires). L'utilisateur souhaite voir également l'image d'origine dont la grille est issue, pour faciliter la vérification et enrichir le rendu visuel.
## Mise en page choisie
Page portrait A4. La page solution est divisée en **deux colonnes égales** :
- **Colonne gauche** : grille solution (indices + cellules remplies) — identique à aujourd'hui mais réduite pour tenir dans la moitié de la largeur
- **Colonne droite** : image originale redimensionnée à la même hauteur et largeur que la grille, centrée verticalement
Espacement entre les deux colonnes : gouttière fixe de 10 mm.
Si `image_bytes` est `None`, la page solution se comporte comme avant (grille seule, pleine largeur).
## Changements requis
### 1. `src/logimage/domain/entities/puzzle.py`
Ajouter un champ `image_bytes: bytes | None = None` à `NonogramPuzzle` :
```python
@dataclass(frozen=True)
class NonogramPuzzle:
grid: Grid
row_clues: tuple[Clue, ...]
col_clues: tuple[Clue, ...]
title: str
image_bytes: bytes | None = None
```
Étendre `from_grid()` pour accepter et transmettre ce paramètre :
```python
@classmethod
def from_grid(cls, grid: Grid, title: str, image_bytes: bytes | None = None) -> "NonogramPuzzle":
...
return cls(grid=grid, row_clues=row_clues, col_clues=col_clues, title=title, image_bytes=image_bytes)
```
### 2. `src/logimage/application/use_cases/generate_puzzles.py`
Passer `image_data.content` lors de la construction du puzzle :
```python
puzzle = NonogramPuzzle.from_grid(grid, image_data.title, image_data.content)
```
### 3. `src/logimage/infrastructure/pdf/reportlab_exporter.py`
Modifier `_draw_page()` pour la page solution (`filled=True`) :
- Si `puzzle.image_bytes` est `None` : comportement actuel inchangé.
- Si `puzzle.image_bytes` est présent : calculer la largeur disponible en divisant en deux colonnes (`(avail_w - gutter) / 2`). Réduire la grille à cette demi-largeur. Rendre l'image dans la colonne droite via `reportlab.lib.utils.ImageReader` avec `canvas.drawImage()`, aux mêmes dimensions que la grille (même x, y, width, height).
## Ce qui ne change pas
- La page puzzle (non solution) reste inchangée
- Le port `PdfExporter` reste inchangé (signature de `export()` inchangée)
- Le port `ImageSource` reste inchangé
- Les tests existants restent valides (le champ `image_bytes` est optionnel)
## Tests
- `NonogramPuzzle.from_grid()` avec et sans `image_bytes`
- `ReportLabPdfExporter` : vérifier que le PDF généré avec `with_solution=True` et des bytes image est plus grand (taille fichier) que sans image
- Vérifier que `with_solution=True` sans `image_bytes` se comporte comme avant (rétrocompatibilité)
@@ -0,0 +1,280 @@
# Logimage Generator — Design Spec
Date: 2026-05-20
## Résumé
Outil CLI Python qui génère des nonogrammes (logimages) en PDF prêts à imprimer. Le programme récupère automatiquement des images depuis Unsplash, les convertit en grilles noir/blanc optimisées pour la jouabilité, calcule les indices lignes/colonnes, et produit un PDF A4 avec une grille centrée par page.
---
## 1. Comportement utilisateur
### Invocation
```bash
# Mode minimal — 1 grille, thème aléatoire, difficulté medium
logimage
# Avec options
logimage \
--theme "animals" \
--difficulty medium \
--size 20x15 \
--count 5 \
--solution \
--output puzzles.pdf
```
### Paramètres CLI
| Flag | Défaut | Description |
|------|--------|-------------|
| `--theme TEXT` | aléatoire | Mot-clé pour la recherche d'images Unsplash |
| `--difficulty easy\|medium\|hard` | `medium` | 10×10 / 15×15 / 20×20 |
| `--size NxM` | — | Taille libre ; écrase `--difficulty` si fourni |
| `--count N` | `1` | Nombre de grilles dans le PDF |
| `--solution` | off | Ajoute une page solution après chaque grille |
| `--output PATH` | `puzzles.pdf` | Chemin du fichier PDF de sortie |
### Configuration
Clé API Unsplash via variable d'environnement ou fichier `.env` à la racine :
```
UNSPLASH_ACCESS_KEY=xxx
```
### Format PDF
- Format A4 portrait, une grille par page
- Mise en page : titre en haut, grille centrée, thème en bas
- Grille vierge (cases à colorier), indices lignes à gauche, indices colonnes en haut
- Si `--solution` : page solution insérée après chaque grille (grille pré-remplie)
---
## 2. Architecture (Clean Architecture)
La règle de dépendance est stricte : les couches internes ne connaissent jamais les couches externes.
```
domain ← application ← infrastructure ← cli
```
### Structure des fichiers
```
src/logimage/
├── domain/
│ ├── entities/
│ │ └── puzzle.py # NonogramPuzzle
│ ├── value_objects/
│ │ ├── grid.py # Grid (immuable)
│ │ └── clue.py # Clue (immuable)
│ └── ports/
│ ├── image_source.py # ImageSource (ABC)
│ ├── image_converter.py # ImageConverter (ABC)
│ └── pdf_exporter.py # PdfExporter (ABC)
├── application/
│ └── use_cases/
│ └── generate_puzzles.py # GeneratePuzzlesUseCase
├── infrastructure/
│ ├── image/
│ │ ├── unsplash_source.py # UnsplashImageSource
│ │ └── pillow_converter.py # PillowImageConverter
│ └── pdf/
│ └── reportlab_exporter.py
└── cli/
└── main.py # Composition root + argparse
tests/
├── domain/
├── application/
├── infrastructure/ # @pytest.mark.integration
└── fakes/ # FakeImageSource, FakeImageConverter, FakePdfExporter
```
---
## 3. Modèle du domaine
### Value objects
**`Grid`**
- Données : `tuple[tuple[bool]]` (immuable)
- Contraintes : largeur et hauteur entre 5 et 50
- Lève `ValueError` si les contraintes ne sont pas respectées
**`Clue`**
- Séquence immuable d'entiers positifs représentant les blocs d'une ligne ou colonne
- Ligne vide → `Clue([])`
- Exemple : `[T,T,F,T,F,F,T,T,T]``Clue([2, 1, 3])`
### Entité
**`NonogramPuzzle`**
- `grid: Grid` — la solution
- `row_clues: tuple[Clue]` — calculées à la construction
- `col_clues: tuple[Clue]` — calculées à la construction
- `title: str` — thème ou nom de l'image source
- Fabriqué via `NonogramPuzzle.from_grid(grid, title)` qui calcule les clues
### Ports (interfaces)
```python
class ImageSource(ABC):
def fetch(self, theme: str | None = None) -> bytes: ...
class ImageConverter(ABC):
def to_grid(self, image_bytes: bytes, width: int, height: int) -> Grid: ...
class PdfExporter(ABC):
def export(
self,
puzzles: list[NonogramPuzzle],
path: Path,
with_solution: bool = False,
) -> None: ...
```
---
## 4. Use case
**`GeneratePuzzlesUseCase`**
Dépendances injectées : `ImageSource`, `ImageConverter`, `PdfExporter`
Séquence d'exécution :
1. Valider les paramètres (taille, count)
2. Pour chaque grille à générer :
a. `image_source.fetch(theme)``bytes`
b. `image_converter.to_grid(bytes, width, height)``Grid`
c. `NonogramPuzzle.from_grid(grid, title)``NonogramPuzzle`
3. `pdf_exporter.export(puzzles, path, with_solution)`
---
## 5. Infrastructure
### `UnsplashImageSource`
- Utilise `httpx` pour appeler l'API Unsplash (`/photos/random?query=<theme>`)
- Clé API lue depuis `UNSPLASH_ACCESS_KEY`
- Télécharge l'image en JPEG (taille raisonnable, ~800px)
### `PillowImageConverter`
- Redimensionne l'image à `width × height` pixels
- Conversion en niveaux de gris
- Détection de contours avec OpenCV (Canny) pour favoriser les formes reconnaissables
- Seuillage adaptatif pour maximiser la jouabilité (pas trop de cases noires, pas trop peu)
- Retourne un `Grid`
### `ReportLabPdfExporter`
- Format A4 portrait
- Grille rendue avec `reportlab` (rectangles, texte pour les indices)
- Indices colonnes en haut (empilés verticalement si plusieurs chiffres), indices lignes à gauche
- Marquage de groupes de 5 cases (bords plus épais) pour faciliter le comptage
- Page solution : même layout, cases noires pré-remplies
---
## 6. Stratégie de test (TDD)
### Tests domain (purs, aucun mock)
```
test_clue_from_consecutive_cells()
test_clue_empty_row()
test_clue_single_cell()
test_puzzle_computes_row_and_col_clues()
test_grid_rejects_width_below_minimum()
test_grid_rejects_height_above_maximum()
test_grid_is_immutable()
```
### Tests application (mocks des ports via fakes)
```
test_generate_single_puzzle_calls_fetch_once()
test_generate_count_5_calls_fetch_5_times()
test_solution_flag_forwarded_to_exporter()
test_invalid_size_raises_value_error()
test_size_from_difficulty_easy_is_10x10()
test_size_from_difficulty_hard_is_20x20()
```
### Tests d'intégration (`@pytest.mark.integration`, réseau réel)
```
test_unsplash_returns_valid_jpeg_bytes()
test_pillow_converter_output_matches_requested_size()
test_reportlab_creates_nonempty_pdf_file()
```
### Exécution
```bash
pytest tests/domain tests/application # rapide, CI toujours
pytest -m integration # ponctuel, nécessite UNSPLASH_ACCESS_KEY
pytest --cov=logimage --cov-report=term-missing
```
Couverture cible : ≥ 90% sur `domain/` et `application/`.
---
## 7. Dépendances
| Rôle | Bibliothèque |
|------|-------------|
| Traitement image | `Pillow` |
| Détection contours | `opencv-python-headless` |
| Génération PDF | `reportlab` |
| HTTP | `httpx` |
| Variables d'env | `python-dotenv` |
| Tests | `pytest`, `pytest-cov` |
| Lint + format | `ruff` |
| Typage statique | `mypy` |
Python ≥ 3.11 requis.
### `pyproject.toml` (structure)
```toml
[project]
name = "logimage-generator"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"Pillow", "opencv-python-headless", "reportlab",
"httpx", "python-dotenv",
]
[project.optional-dependencies]
dev = ["pytest", "pytest-cov", "ruff", "mypy"]
[project.scripts]
logimage = "logimage.cli.main:main"
[tool.pytest.ini_options]
markers = ["integration: tests requiring network and API keys"]
[tool.ruff]
line-length = 88
[tool.mypy]
strict = true
```
---
## 8. Ce qui est hors scope
- Interface graphique ou web
- Éditeur interactif de grille
- Validation de l'unicité de la solution (problème NP-difficile pour les grandes grilles)
- Cache local des images téléchargées (peut être ajouté plus tard)
- Support des nonogrammes en couleur
@@ -0,0 +1,45 @@
# Design : intégration Pexels et aiguillage multi-provider
**Date :** 2026-05-20
## Contexte
Le projet dispose déjà d'un port `ImageSource` (ABC) et d'une implémentation `UnsplashImageSource`. La clé API Unsplash n'est pas encore disponible ; une clé Pexels est disponible maintenant. L'objectif est d'ajouter le support Pexels et de router automatiquement vers le bon provider selon les variables d'environnement.
## Périmètre
- Ajouter `PexelsImageSource` dans l'infrastructure
- Modifier `main.py` pour router selon les clés d'API présentes
- Pas de changement au domain, au port, ni au use case
## Nouveau fichier : `PexelsImageSource`
**Chemin :** `src/logimage/infrastructure/image/pexels_source.py`
Implémente `ImageSource`. Deux endpoints selon la présence d'un thème :
- Avec thème : `GET https://api.pexels.com/v1/search?query=<theme>&per_page=1`
- Sans thème : `GET https://api.pexels.com/v1/curated?per_page=1`
Authentification : header `Authorization: <api_key>` (pas de préfixe).
Extraction des données :
- URL image : `photo["src"]["large"]`
- Titre : `photo["alt"]` ou `theme` ou `"logimage"` en fallback
## Modification : routing dans `main.py`
Remplacer la logique actuelle (Unsplash uniquement) par :
1. Lire `PEXELS_API_KEY` et `UNSPLASH_ACCESS_KEY` depuis l'environnement
2. Si `PEXELS_API_KEY` est présente → instancier `PexelsImageSource`
3. Sinon si `UNSPLASH_ACCESS_KEY` est présente → instancier `UnsplashImageSource`
4. Sinon → afficher une erreur claire et quitter :
`"Error: set PEXELS_API_KEY or UNSPLASH_ACCESS_KEY in .env"`
Pexels est prioritaire car c'est la clé disponible aujourd'hui.
## Ce qui ne change pas
- Le port `ImageSource` reste inchangé
- Le use case `GeneratePuzzlesUseCase` reste inchangé
- `UnsplashImageSource` reste inchangé (prêt pour quand la clé sera disponible)
@@ -0,0 +1,54 @@
# Amélioration du pipeline de conversion image → nonogram
## Objectif
Produire des grilles de nonogram à la fois jouables (≤ 6 blocs par ligne/colonne) et reconnaissables (ressemblance avec l'image source), à partir de photos Pexels/Unsplash ou d'images locales.
## Problème actuel
Le pipeline actuel (edge detection Canny + blend + Otsu) génère trop de pixels isolés sur les photos, produisant des clues illisibles (ex: 15 blocs sur une ligne de 32 cellules) et une grille méconnaissable.
## Pipeline cible
```
Image bytes
→ Grayscale + resize (inchangé)
→ Bilateral filter (d=9, sigmaColor=75, sigmaSpace=75)
→ Posterize 2 niveaux (seuillage médian sur histogramme)
→ Binarisation Otsu
→ Fermeture morphologique kernel 3×3 (combler micro-trous)
→ Suppression composantes connexes < min_component_size (défaut: 2px)
→ [Boucle] Vérification complexité clues
si max_blocks_per_line > max_clue_blocks :
fermeture morphologique kernel += 1
max 3 itérations
→ Grid
```
## Paramètres configurables
| Paramètre | Défaut | Description |
|---|---|---|
| `max_clue_blocks` | 6 | Max blocs acceptables par ligne/colonne |
| `min_component_size` | 2 | Taille min composante connexe (px) |
| `max_cleanup_iterations` | 3 | Nb max d'itérations de nettoyage supplémentaire |
## Nouvelle source locale
`LocalFileImageSource` — implémente `ImageSource`, lit un fichier depuis le disque.
CLI : nouvelle option `--local-image <path>` mutuellement exclusive avec `--theme`.
## Tests
- **Unitaires** : bilateral filter, cleanup morpho, boucle complexité sur grilles synthétiques
- **Intégration** : image simple → vérifie max blocs/ligne ≤ 6
- **Fixture** : `tests/fixtures/simple_icon.png` généré programmatiquement (forme simple)
## Fichiers modifiés
- `src/logimage/infrastructure/image/pillow_converter.py` — pipeline complet
- `src/logimage/infrastructure/image/local_file_source.py` — nouvelle source
- `src/logimage/cli/main.py` — option `--local-image`
- `tests/infrastructure/test_pillow_converter.py` — nouveaux tests
- `tests/fixtures/simple_icon.png` — image de test