pixelcut 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pixelcut
|
|
3
|
+
Version: 1.3.0
|
|
4
|
+
Summary: K&K pixelcut - cut a picture into parts without changing a single pixel
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: numpy<3,>=1.23
|
|
10
|
+
Requires-Dist: opencv-python-headless<5,>=4.7
|
|
11
|
+
Requires-Dist: Pillow<13,>=9.5
|
|
12
|
+
Requires-Dist: scikit-image<1,>=0.20
|
|
13
|
+
Requires-Dist: vtracer<1,>=0.6
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest; extra == "test"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# K&K pixelcut
|
|
19
|
+
|
|
20
|
+
   
|
|
21
|
+
|
|
22
|
+
[](https://ko-fi.com/kkwonderland)
|
|
23
|
+
|
|
24
|
+
**English in short:** a small command-line tool that cuts an image into wedge-shaped parts (free angles, straight or **zig-zag** edges) **without altering a single pixel**, turns a white background into real transparency (exact colour-to-alpha), lifts a word or element out and re-places it, and writes **hit-area polygons** for touch menus. Parts put back together over white give the original back (max deviation <= 1/255, measured with `--verify` and `compare`). It does *not* generate or redraw images and does not upscale without inventing pixels. Works on drawings and photos. Details below (German).
|
|
25
|
+
|
|
26
|
+
**Ein Bild in Teile zerlegen, ohne ein einziges Pixel zu verändern.**
|
|
27
|
+
*Cut a picture into parts without changing a single pixel.*
|
|
28
|
+
|
|
29
|
+
**Auf einen Blick**
|
|
30
|
+
|
|
31
|
+
- **Exakt:** Teile über Weiß zusammengesetzt = das Original (höchstens 1/255 Abweichung, bei Fotos 0).
|
|
32
|
+
- **Zacken statt Linie:** Keile um eine Mitte, freie Winkel, gerade Kante oder Zickzack.
|
|
33
|
+
- **Echte Transparenz:** weißer Hintergrund wird sauber freigestellt, auch weiche Kanten.
|
|
34
|
+
- **Trefferflächen:** `parts.json` mit Polygon pro Teil, fertig für Touch-Menüs.
|
|
35
|
+
- **Messbar:** `compare` zeigt SSIM und Pixelabweichung, auch für Ergebnisse anderer Werkzeuge.
|
|
36
|
+
|
|
37
|
+
Gebaut von K&K AI. Entstanden bei einer Handy-App: Ein Comic-Bild sollte zum Menü werden (Blase in vier Teile, Zacken statt Linie, das Wort in der Mitte als Knopf), und es durfte **nicht anders aussehen als das Original**.
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Die Idee in drei Sätzen
|
|
44
|
+
|
|
45
|
+
Die meisten "Bild nachbauen"-Werkzeuge zeichnen neu, tracen oder skalieren hoch. Das Ergebnis sieht dann nur *ähnlich* aus.
|
|
46
|
+
pixelcut macht das Gegenteil: Es behält die Original-Pixel und entscheidet nur, **zu welchem Teil jedes Pixel gehört**.
|
|
47
|
+
Legt man die Teile wieder zusammen, kommt das Original zurück, Pixel für Pixel, und das Werkzeug misst das selbst nach (`--verify`, `compare`).
|
|
48
|
+
|
|
49
|
+
**Bedingung:** Das gilt für 8-Bit-Bilder und mit `--gap 0 --edge 0`. Die Standardwerte (`--gap 2.6 --edge 3.2`) schneiden einen sichtbaren Spalt mit Tintenkante, damit man die Teile sieht. Dann weicht das Ergebnis gewollt ab; `--verify` zeigt beide Zahlen. Transparente Bilder werden über Weiß gelegt und 16-Bit-Bilder auf 8 Bit gebracht, beides mit Hinweis.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Download (ohne Python)
|
|
54
|
+
|
|
55
|
+
Fertige Programme für Windows, Linux (x64 und ARM) und Mac (Apple Silicon und Intel) gibt es unter [Releases](https://github.com/CalyieX/pixelcut/releases/latest). Jedes Programm wurde vor dem Hochladen auf seinem eigenen System mit einem Rundlauftest geprüft.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# Linux / Mac: einmal ausführbar machen, dann starten
|
|
59
|
+
chmod +x pixelcut-linux-x64 # bzw. pixelcut-macos-arm64
|
|
60
|
+
./pixelcut-linux-x64 --help
|
|
61
|
+
|
|
62
|
+
# Windows (PowerShell)
|
|
63
|
+
.\pixelcut-windows-x64.exe --help
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Mac: Beim ersten Start meldet macOS evtl. „nicht verifizierter Entwickler“. Dann in den Systemeinstellungen unter „Datenschutz & Sicherheit“ auf „Trotzdem öffnen“ klicken, oder `xattr -d com.apple.quarantine pixelcut-macos-arm64` ausführen. Die Programme sind nicht signiert.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Im Browser (iPhone, iPad, Android, jeder Rechner)
|
|
71
|
+
|
|
72
|
+
**[calyiex.github.io/pixelcut](https://calyiex.github.io/pixelcut/)** – dieselbe Engine läuft direkt im Browser (Pyodide/WebAssembly), nichts wird hochgeladen, nichts muss installiert werden. Bild auswählen, Winkel eintragen, Teile als ZIP speichern; zum Zusammensetzen alle Teile samt `parts.json` wieder auswählen. Auf iPhone/iPad in Safari ab iOS 16; beim ersten Öffnen werden ca. 40 MB geladen, danach liegt es im Cache. Nicht dabei: `trace`, `upscale`, `compare`, `key` (nur `split`, `join`, `alpha`).
|
|
73
|
+
|
|
74
|
+
Bei jedem Commit prüft das CI die Seite in **WebKit (die Engine von Safari/iPhone), Chromium und Firefox**: Bild teilen, wieder zusammensetzen, Abweichung muss 0 sein. Das ist die Safari-Engine auf einem Mac-Rechner, kein echtes iPhone.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Installation
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
git clone https://github.com/CalyieX/pixelcut
|
|
82
|
+
cd pixelcut
|
|
83
|
+
pip install -r requirements.txt # numpy, opencv, Pillow, scikit-image, vtracer
|
|
84
|
+
python pixelcut.py -h
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Alles ist in `requirements.txt` enthalten, auch `trace` (vtracer) und `compare` (SSIM). Für `upscale` lädt das Tool beim ersten Aufruf von selbst die offizielle Real-ESRGAN-Version (ca. 50 MB, einmalig nach `~/.cache/pixelcut`). Du musst nichts weiter einrichten. Läuft Real-ESRGAN auf dem Rechner nicht (ARM-Linux oder keine Vulkan-Grafikkarte), skaliert das Tool stattdessen mit Lanczos und sagt es dazu.
|
|
88
|
+
|
|
89
|
+
Getestet bei jedem Commit auf GitHub Actions: Windows, Linux (x64 und ARM) und macOS (Apple Silicon und Intel), jeweils mit Python 3.9, 3.12 und 3.13. Das Badge oben zeigt den aktuellen Stand. Nur `upscale` mit Real-ESRGAN braucht eine Vulkan-fähige Grafikkarte und x86-64; sonst skaliert das Tool mit Lanczos und sagt das dazu.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Was pixelcut kann
|
|
94
|
+
|
|
95
|
+
### 1. `alpha`: weißen Hintergrund transparent machen, exakt
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
python pixelcut.py alpha bild.png bild_alpha.png --verify
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Standard-Verfahren *Color-to-Alpha* gegen Weiß: Jeder Farbwert wird so in Farbe + Deckkraft zerlegt, dass das Bild **über Weiß wieder genau das Original** ergibt (gemessen: höchstens 0,5 von 255 Abweichung). Auch weiche Kanten und halbtransparente Ränder bleiben richtig. Kein Ausschneiden mit Zauberstab, keine Kantenreste.
|
|
102
|
+
|
|
103
|
+
### 2. `split`: in Keile schneiden, mit Zacken und Trefferflächen
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
python pixelcut.py split bild.png out --angles 32,148,214,327 --tooth 18 --depth 9 --only-inside --verify
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- **Keile um eine Mitte.** Die Mitte ist frei wählbar (`--centre x,y`) oder wird automatisch bestimmt (Schwerpunkt der nicht weißen Fläche).
|
|
110
|
+
- **Freie Winkel.** `--angles` gibt die Trennlinien an (0 = rechts, 90 = unten). Sie dürfen **ungleich und schräg** sein, also keine Viertel, sondern zum Beispiel ein X, dessen Arme nicht gleich verteilt sind.
|
|
111
|
+
- **Gerade oder Zacken.** `--depth 0` = gerade Linie. Mit `--depth` und `--tooth` wird aus der Linie ein Zickzack mit scharfen Zähnen.
|
|
112
|
+
- **Spalt und Tintenkante.** `--gap` lässt einen schmalen Spalt zwischen den Teilen, `--edge` zeichnet eine dünne dunkle Kante an die Schnittkante (Comic-Look). Beides lässt sich auf 0 stellen.
|
|
113
|
+
- **Nur die Hauptform schneiden.** `--only-inside` schneidet nur den größten zusammenhängenden Bereich. Wolken, Sterne und Punkte drumherum bleiben ganz und gehören jeweils einem einzigen Teil (dem, in dem die meiste Fläche liegt).
|
|
114
|
+
- **Trefferflächen.** `parts.json` enthält pro Teil `polygon` (größte Außenkontur), `polygons` (alle getrennten Flächen mit ihren Löchern) und den dicksten Punkt (guter Platz für ein Symbol). Damit baut man Touch-Menüs, Klickflächen und Hotspots. Im Original-Projekt: Android `Path` + `Region`.
|
|
115
|
+
- **Fotos.** `--opaque` lässt jedes Pixel deckend, damit helle Bildstellen nicht angetastet werden.
|
|
116
|
+
|
|
117
|
+
Ausgabe: `part_0.png … part_N.png` (RGBA, gleiche Größe wie das Original, nur die Pixel des Teils sind sichtbar) und `parts.json`.
|
|
118
|
+
|
|
119
|
+
### 3. `join`: Teile wieder zusammensetzen
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
python pixelcut.py join out nachbau.png
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Legt alle `part_*.png` über Weiß aufeinander. Das ist der exakte "Nachbau". Mit `--gap 0 --edge 0` geschnitten, ergibt das Ergebnis das Original.
|
|
126
|
+
|
|
127
|
+
### 4. `compare`: messen statt glauben
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
python pixelcut.py compare original.png nachbau.png
|
|
131
|
+
# SSIM 1.0000 mean |diff| 0.000 max |diff| 0
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Ähnlichkeit (SSIM, 1,0 = identisch), mittlere und größte Pixelabweichung. Nutzbar für jedes Bildpaar, auch für Ergebnisse anderer Werkzeuge.
|
|
135
|
+
|
|
136
|
+
### 5. `key`: ein Element herausheben, verkleinern, in die Mitte setzen
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
python pixelcut.py key bild.png out --box 172,170,428,310 --scale 0.55
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Hebt ein Element (zum Beispiel ein Wort) aus einer Fläche heraus, **mit den Original-Pixeln**, verkleinert es und setzt es in die Mitte. Die Lücke wird mit der Flächenfarbe gefüllt. Ausgabe: `base.png` (Bild ohne Element), `element.png` (Element auf transparentem Grund), `preview.png`.
|
|
143
|
+
Voraussetzung: Der Rand der Box (`--box`) liegt auf **einfarbiger Fläche**, sonst stimmt die Füllfarbe nicht.
|
|
144
|
+
|
|
145
|
+
### 6. `trace` und `upscale` (nur zum Vergleichen)
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
python pixelcut.py trace bild.png bild.svg --render bild_trace.png # vtracer
|
|
149
|
+
python pixelcut.py upscale bild.png gross.png # holt Real-ESRGAN beim ersten Mal selbst
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Zwei Fremdverfahren, damit man selbst messen kann, wie weit sie vom Original abweichen. Beide **ändern Pixel** und sind nicht mehr exakt.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Was pixelcut NICHT kann
|
|
157
|
+
|
|
158
|
+
Bitte vorher lesen, das spart Zeit.
|
|
159
|
+
|
|
160
|
+
1. **Es erzeugt kein Bild aus dem Nichts.** Es braucht das Original. Es gibt keine Text-zu-Bild-Funktion, kein "male mir so etwas".
|
|
161
|
+
2. **Es bildet ein Bild nicht ohne Vorlage nach.** Wer ein Bild "neu aufbauen" will, ohne dass es abweicht, braucht das Original als Quelle. Ein Bild, das nicht vom Original zu unterscheiden sein soll, **ist** das Original. Alles, was neu zeichnet (Hand, KI, Tracer), weicht ab. Die Zahlen unten zeigen, wie viel.
|
|
162
|
+
3. **Es macht Bilder nicht größer, ohne Pixel zu erfinden.** Mehr Auflösung geht nur durch Rechnen. `upscale` nutzt dafür Real-ESRGAN, das neue Pixel erfindet. Das Ergebnis ist schärfer, aber nicht mehr exakt.
|
|
163
|
+
4. **Keine Form-Erkennung, keine Objekte.** Es schneidet nach Geometrie (Winkel um eine Mitte), nicht nach Inhalt. Es erkennt keine Personen, Gegenstände oder Texte.
|
|
164
|
+
5. **Keine freien Polygone oder Raster.** `split` schneidet Keile um **eine** Mitte. Für Raster gibt es gute fertige Werkzeuge (zum Beispiel `image-slicer`), für freie Formen Grafikprogramme.
|
|
165
|
+
6. **`key` ist einfach gebaut.** Es erkennt das Element als "alles, was von der Hintergrundfarbe abweicht". Bei Verlauf, Muster oder Foto als Hintergrund liegt es daneben. Das Füllen der Lücke ist eine glatte Fläche (keine Textur, kein Inpainting).
|
|
166
|
+
7. **`alpha` ist nur für weiße Hintergründe.** Andere Hintergrundfarben oder Fotos mit weißem Himmel werden nicht sauber freigestellt (bei Fotos `--opaque` benutzen).
|
|
167
|
+
8. **Keine Animation, keine Videos, keine Vektorgrafik.** Nur Rastergrafik (PNG, JPEG usw.). Ausgabe ist PNG mit Alphakanal.
|
|
168
|
+
9. **Bei Spalt und Tintenkante ist das Ergebnis nicht mehr bit-gleich.** Wer `--gap` und `--edge` benutzt, schneidet bewusst Pixel weg und malt eine Kante. Das ist ein Gestaltungswerkzeug. Exakt zusammensetzen geht mit `--gap 0 --edge 0`.
|
|
169
|
+
10. **Kein Farbmanagement.** Es rechnet in sRGB-Werten, ICC-Profile werden ignoriert.
|
|
170
|
+
11. **Keine Schnittstelle für Programme.** Nur die Kommandozeile (und die Python-Datei zum Einbinden), keine grafische Oberfläche.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Messungen
|
|
175
|
+
|
|
176
|
+
### Foto-Test
|
|
177
|
+
|
|
178
|
+
Foto (Katze, CC0, 451 × 300): jeweils nachgebaut und mit `compare` gegen das Original gemessen.
|
|
179
|
+
|
|
180
|
+
| Weg | SSIM | Mittlere Abweichung | Maximale Abweichung |
|
|
181
|
+
|---|---|---|---|
|
|
182
|
+
| **pixelcut** (`split` + `join`) | **1.0000** | **0.000** | **0** |
|
|
183
|
+
| Bicubic 4x hoch und wieder runter | 0.9956 | 0.661 | 15 |
|
|
184
|
+
| Real-ESRGAN 4x hoch und wieder runter | 0.9513 | 3.438 | 93 |
|
|
185
|
+
| vtracer (als Vektor neu gezeichnet) | 0.8686 | 5.263 | 151 |
|
|
186
|
+
|
|
187
|
+

|
|
188
|
+
|
|
189
|
+
Ausschnitt 3x vergrößert. Nur pixelcut ist vom Original nicht zu unterscheiden.
|
|
190
|
+
|
|
191
|
+
### Zeichnung (Comic-Bild)
|
|
192
|
+
|
|
193
|
+
| Verfahren | SSIM |
|
|
194
|
+
|---|---|
|
|
195
|
+
| [vtracer](https://github.com/visioncortex/vtracer) (Nachzeichnen, Standardwerte des Tools) | 0,96 |
|
|
196
|
+
| Farbquantisierung + Konturen ([image-to-svg](https://github.com/oaustegard/claude-skills)) | 0,78 |
|
|
197
|
+
| **pixelcut** (Original-Pixel) | ≈ 1,00 mit `--gap 0 --edge 0` (höchstens 0,5 von 255 bei der Zeichnung, 0,00 beim Foto) |
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Beispiele
|
|
202
|
+
|
|
203
|
+
| Zeichnung (Demo) | Zerlegt, Teile über Weiß | Foto (CC0, scikit-image), zerlegt |
|
|
204
|
+
|---|---|---|
|
|
205
|
+
|  |  |  |
|
|
206
|
+
|
|
207
|
+
`key` auf der Demo (Wort verkleinert und zentriert, Lücke gefüllt):
|
|
208
|
+
|
|
209
|
+

|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Tests
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
pip install -e ".[test]"
|
|
217
|
+
pytest -q
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
145 Tests. Sie prüfen die gespeicherten Dateien: Rundlauf split + join ist exakt (Zeichnung, Foto, gerade und Zickzack), die Teile überlappen nicht, Polygone, Winkel, Mitte, Größe und dickster Punkt stimmen mit den Teilen überein, `compare`, `key` und `alpha` rechnen richtig, `join` verweigert fehlende oder falsch große Teile, `split` fasst fremde Dateien nicht an, und schlechte Eingaben ergeben eine klare Fehlerzeile. Eingebaute Fehler (Mutationen) werden gefangen.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Wie es im Detail funktioniert
|
|
225
|
+
|
|
226
|
+
- **Color-to-Alpha:** `alpha = 1 − min(R, G, B)`, Farbe = `(Pixel − (1 − alpha)) / alpha`. Über Weiß zusammengesetzt ergibt das wieder den Pixelwert.
|
|
227
|
+
- **Keile:** Für jedes Pixel wird der Winkel zur Mitte berechnet. Der Keil ergibt sich aus den sortierten Trennwinkeln.
|
|
228
|
+
- **Zacken:** Pro Trennlinie wird die Entfernung des Pixels von der Linie mit einer Dreieckswelle verglichen. Wer links der verschobenen Linie liegt, gehört zum einen Keil, wer rechts liegt, zum anderen. So haben beide Teile dieselbe Zackenkante ohne Lücke (außer dem gewollten Spalt).
|
|
229
|
+
- **Trefferflächen:** Aus der Maske jedes Teils wird die Außenkontur gezogen und vereinfacht. Der dickste Punkt kommt aus der Distanztransformation.
|
|
230
|
+
- **Key:** Hintergrundfarbe = Median des Box-Rands. Alles, was weiter als `--threshold` davon entfernt ist, gehört zum Element. Löcher darin werden gefüllt, die Lücke danach weich mit der Flächenfarbe überblendet.
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Lizenz
|
|
235
|
+
|
|
236
|
+
MIT. Das Foto stammt aus `skimage.data` (CC0). Die Demo-Grafik ist selbst erzeugt (`make_demo.py`).
|
|
237
|
+
|
|
238
|
+
Mit Claude Code von K&K AI gebaut.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
Gefällt dir pixelcut? Ein Kaffee hilft, es weiterzubauen: [ko-fi.com/kkwonderland](https://ko-fi.com/kkwonderland)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
pixelcut.py,sha256=rOXHQNp1bVjhKHH_QXBxDOB2anwgc4-t1aa8PWgmhIg,45148
|
|
2
|
+
pixelcut-1.3.0.dist-info/licenses/LICENSE,sha256=Vvu7JrldqGO9y5oqWdhmnl86v4OyGkod1CgOxZfTjJ8,1063
|
|
3
|
+
pixelcut-1.3.0.dist-info/METADATA,sha256=xbVCvmGyee7vamsz-3IAAtjuzD-e_zSsOWP9SFpROtY,14590
|
|
4
|
+
pixelcut-1.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
5
|
+
pixelcut-1.3.0.dist-info/entry_points.txt,sha256=E1Nxk3OsDP8JjVjIOEmdXn2dlvtxsIAzzooSa2-afLs,43
|
|
6
|
+
pixelcut-1.3.0.dist-info/top_level.txt,sha256=ILT3ZqlnF4DHcTTUq-r8eL8hxS2bpqWIo91BCoGBNSI,9
|
|
7
|
+
pixelcut-1.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 K&K AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pixelcut
|
pixelcut.py
ADDED
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""K&K pixelcut - cut a picture into parts without changing a single pixel.
|
|
3
|
+
|
|
4
|
+
Most "recreate this image" tools redraw, trace or upscale, so the result is only similar. pixelcut does the opposite: it keeps
|
|
5
|
+
the original pixels and only decides which part each pixel belongs to. Put the parts back together over white and you get the
|
|
6
|
+
original back (max. deviation <= 1/255, checked by `--verify`).
|
|
7
|
+
|
|
8
|
+
Commands
|
|
9
|
+
alpha white background -> transparent (colour-to-alpha), exact over white
|
|
10
|
+
split cut into wedges around a centre, dividing lines at free angles, straight or zig-zag, with hit-area polygons
|
|
11
|
+
join put the parts back together over white (exact)
|
|
12
|
+
compare SSIM + pixel difference of two images
|
|
13
|
+
trace (vtracer) redraw as SVG - approximate
|
|
14
|
+
upscale (Real-ESRGAN, auto-download) AI upscale - invents pixels
|
|
15
|
+
key lift a foreground element (e.g. a word) out of a flat area, shrink it, put it in the middle, heal the hole
|
|
16
|
+
|
|
17
|
+
Run `python pixelcut.py COMMAND -h` for the options.
|
|
18
|
+
"""
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
import stat
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import cv2
|
|
29
|
+
import numpy as np
|
|
30
|
+
from PIL import Image, ImageOps
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
PART_NAME = re.compile(r"part_\d+\.png")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def manifest_files(m):
|
|
37
|
+
"""Validate a pixelcut manifest and return its part file names. Raises ValueError for anything that is not one.
|
|
38
|
+
Manifests from v1.0/1.1 have no 'files' list: the names are then part_0..part_N-1."""
|
|
39
|
+
if not isinstance(m, dict) or not isinstance(m.get("parts"), list) or not m["parts"]:
|
|
40
|
+
raise ValueError("no parts")
|
|
41
|
+
size = m.get("size")
|
|
42
|
+
if not (isinstance(size, list) and len(size) == 2 and all(isinstance(v, int) and not isinstance(v, bool) and v > 0 for v in size)):
|
|
43
|
+
raise ValueError("bad size")
|
|
44
|
+
c = m.get("centre")
|
|
45
|
+
if not (isinstance(c, list) and len(c) == 2 and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in c)):
|
|
46
|
+
raise ValueError("bad centre")
|
|
47
|
+
if not all(isinstance(x, dict) and isinstance(x.get("part"), int) and isinstance(x.get("starts_at_angle"), (int, float)) for x in m["parts"]):
|
|
48
|
+
raise ValueError("bad part entries")
|
|
49
|
+
names = m.get("files") or ["part_%d.png" % k for k in range(len(m["parts"]))]
|
|
50
|
+
if not (isinstance(names, list) and len(names) == len(m["parts"]) == len(set(names))
|
|
51
|
+
and all(isinstance(x, str) and PART_NAME.fullmatch(x) for x in names)):
|
|
52
|
+
raise ValueError("bad file list")
|
|
53
|
+
return names
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
JOURNAL = ".journal.json"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def link_like(path):
|
|
60
|
+
"""Symlink or Windows junction/reparse point: it points somewhere else, so whatever we do 'in' it happens elsewhere."""
|
|
61
|
+
st = os.lstat(path)
|
|
62
|
+
return stat.S_ISLNK(st.st_mode) or bool(getattr(st, "st_file_attributes", 0) & 0x400)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def real_folder(folder, what):
|
|
66
|
+
if folder.exists() and (link_like(folder) or not folder.is_dir()):
|
|
67
|
+
sys.exit("%s is a link or not a folder - pixelcut will not work inside it (%s)" % (folder, what))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def scratch_beside(path):
|
|
71
|
+
"""A fresh private folder next to the target (same disk, so the final rename is atomic). Returns (folder, file inside it
|
|
72
|
+
with the target's name). Nothing that already exists can be hit: the folder name is unique and created by us."""
|
|
73
|
+
import tempfile
|
|
74
|
+
path = Path(path).resolve()
|
|
75
|
+
d = Path(tempfile.mkdtemp(prefix=".pixelcut_", dir=path.parent))
|
|
76
|
+
return d, d / path.name
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def publish(pairs):
|
|
80
|
+
"""Replace several finished files at once: [(finished_file, destination), ...]. Existing destinations are moved aside first and
|
|
81
|
+
put back if anything goes wrong, so either all new files are in place or all old ones are. The aside folder is the truth about
|
|
82
|
+
what has to be restored (no bookkeeping that an abort could skip) and it is deleted only when it is empty again."""
|
|
83
|
+
import tempfile
|
|
84
|
+
for _, dest in pairs:
|
|
85
|
+
if os.path.lexists(dest) and (link_like(dest) or not Path(dest).is_file()):
|
|
86
|
+
sys.exit("error: %s exists and is not a plain file (folder or link) - nothing was changed" % dest)
|
|
87
|
+
if Path(dest).exists() and not os.access(dest, os.W_OK):
|
|
88
|
+
sys.exit("error: %s is read-only - nothing was changed" % dest)
|
|
89
|
+
aside = Path(tempfile.mkdtemp(prefix=".pixelcut_", dir=Path(pairs[0][1]).resolve().parent))
|
|
90
|
+
installed = []
|
|
91
|
+
try:
|
|
92
|
+
for i, (_, dest) in enumerate(pairs):
|
|
93
|
+
if Path(dest).exists():
|
|
94
|
+
os.replace(dest, aside / str(i))
|
|
95
|
+
for src, dest in pairs:
|
|
96
|
+
installed.append(dest) # before the move: an abort in between must still clean up
|
|
97
|
+
os.replace(src, dest)
|
|
98
|
+
except BaseException:
|
|
99
|
+
stuck = []
|
|
100
|
+
for dest in installed:
|
|
101
|
+
try:
|
|
102
|
+
Path(dest).unlink(missing_ok=True)
|
|
103
|
+
except OSError as e:
|
|
104
|
+
stuck.append("%s (%s)" % (dest, e))
|
|
105
|
+
for f in list(aside.iterdir()):
|
|
106
|
+
try:
|
|
107
|
+
os.replace(f, pairs[int(f.name)][1])
|
|
108
|
+
except OSError as e:
|
|
109
|
+
stuck.append("%s (%s)" % (pairs[int(f.name)][1], e))
|
|
110
|
+
if stuck: # never destroy the only copy: say where it is
|
|
111
|
+
print("error: could not restore everything - your previous files are kept in %s\n problem with: %s" % (aside, "; ".join(stuck)), file=sys.stderr)
|
|
112
|
+
else:
|
|
113
|
+
shutil.rmtree(aside, ignore_errors=True)
|
|
114
|
+
raise
|
|
115
|
+
shutil.rmtree(aside, ignore_errors=True)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def refuse_inputs(inputs, outputs):
|
|
119
|
+
"""An output must never be one of the input files - writing it would destroy what is being read."""
|
|
120
|
+
ins = {Path(x).resolve() for x in inputs}
|
|
121
|
+
for o in outputs:
|
|
122
|
+
if Path(o).resolve() in ins:
|
|
123
|
+
sys.exit("error: %s is also an input - choose a different output file" % o)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def need_folder_of(path):
|
|
127
|
+
if not Path(path).resolve().parent.is_dir():
|
|
128
|
+
sys.exit("error: the folder for %s does not exist" % path)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def save_atomic(img, path):
|
|
132
|
+
"""Write beside the target, replace only when complete: an abort never leaves half a picture in place of a good one."""
|
|
133
|
+
need_folder_of(path)
|
|
134
|
+
d, tmp = scratch_beside(path)
|
|
135
|
+
try:
|
|
136
|
+
img.save(tmp)
|
|
137
|
+
os.replace(tmp, Path(path).resolve())
|
|
138
|
+
finally:
|
|
139
|
+
shutil.rmtree(d, ignore_errors=True)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def foreign_entries(folder, extra=()):
|
|
143
|
+
"""Names in a pixelcut scratch/backup folder that pixelcut did not put there. Only plain files with pixelcut's own
|
|
144
|
+
names count as ours - a directory or a link called part_0.png is foreign, whatever it is named."""
|
|
145
|
+
bad = []
|
|
146
|
+
for f in folder.iterdir():
|
|
147
|
+
own = (PART_NAME.fullmatch(f.name) or f.name in ("parts.json", JOURNAL, JOURNAL + ".tmp") or f.name in extra)
|
|
148
|
+
if not own or link_like(f) or not f.is_file():
|
|
149
|
+
bad.append(f.name)
|
|
150
|
+
return bad
|
|
151
|
+
DONE = ".pixelcut_done"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def write_journal(folder, names):
|
|
155
|
+
"""Atomic: a crash leaves either the old or the new journal, never half a file."""
|
|
156
|
+
tmp = folder / (JOURNAL + ".tmp")
|
|
157
|
+
tmp.write_text(json.dumps(names))
|
|
158
|
+
os.replace(tmp, folder / JOURNAL)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ----------------------------------------------------------------------------- helpers
|
|
162
|
+
def open_upright(path):
|
|
163
|
+
"""Image.open plus the camera's rotation: a phone photo is stored sideways with an 'Orientation' tag, viewers turn it - so do we
|
|
164
|
+
(with a notice), otherwise the result would not look like what the person sees."""
|
|
165
|
+
im = Image.open(path)
|
|
166
|
+
try:
|
|
167
|
+
orient = im.getexif().get(274, 1)
|
|
168
|
+
except Exception:
|
|
169
|
+
orient = 1
|
|
170
|
+
if orient not in (1, None, 0):
|
|
171
|
+
print("note: %s is rotated by its EXIF orientation tag (%s) - turned upright like a viewer shows it" % (path, orient), file=sys.stderr)
|
|
172
|
+
im = ImageOps.exif_transpose(im)
|
|
173
|
+
return im
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def load_rgb(path):
|
|
177
|
+
"""8-bit RGB. Transparency is composited over white and 16-bit data is scaled to 8 bit - both with a notice, never silently."""
|
|
178
|
+
im = open_upright(path)
|
|
179
|
+
if im.width * im.height > 150_000_000:
|
|
180
|
+
sys.exit("image too large (%d x %d = %d MP, limit 150 MP)" % (im.width, im.height, im.width * im.height // 1_000_000))
|
|
181
|
+
if getattr(im, "n_frames", 1) > 1:
|
|
182
|
+
print("note: %s has %d frames - only the first one is used" % (path, im.n_frames), file=sys.stderr)
|
|
183
|
+
deep = None
|
|
184
|
+
if Path(path).suffix.lower() in (".png", ".tif", ".tiff"): # Pillow cuts 48/64-bit PNG silently
|
|
185
|
+
deep = cv2.imdecode(np.fromfile(str(path), np.uint8), cv2.IMREAD_UNCHANGED)
|
|
186
|
+
if deep is not None and deep.dtype == np.uint16 and deep.ndim == 3:
|
|
187
|
+
print("note: %s is 16 bit per channel - scaled to 8 bit, so the result is not exact for the full range" % path, file=sys.stderr)
|
|
188
|
+
rgb16 = deep[:, :, :3][:, :, ::-1].astype(np.float64) if deep.shape[2] >= 3 else np.dstack([deep[:, :, 0]] * 3).astype(np.float64)
|
|
189
|
+
if deep.shape[2] == 4: # transparency over white, as for 8 bit
|
|
190
|
+
al = deep[:, :, 3:4].astype(np.float64) / 65535.0
|
|
191
|
+
rgb16 = rgb16 * al + 65535.0 * (1 - al)
|
|
192
|
+
return np.clip(rgb16 / 257.0 + 0.5, 0, 255).astype(np.uint8)
|
|
193
|
+
if im.mode == "CMYK":
|
|
194
|
+
print("note: %s is CMYK - converted to RGB, colours are not exact" % path, file=sys.stderr)
|
|
195
|
+
if im.mode in ("I;16", "I;16L", "I;16B", "I", "F"):
|
|
196
|
+
print("note: %s is %s (more than 8 bit) - scaled to 8 bit, so the result is not exact for the full range" % (path, im.mode), file=sys.stderr)
|
|
197
|
+
arr = np.array(im).astype(np.float64)
|
|
198
|
+
top = 65535.0 if im.mode.startswith("I;16") else max(arr.max(), 1.0)
|
|
199
|
+
return np.dstack([np.clip(arr / top * 255 + 0.5, 0, 255).astype(np.uint8)] * 3)
|
|
200
|
+
if im.mode in ("RGBA", "LA", "PA") or "transparency" in im.info: # includes L/RGB with a PNG colour key
|
|
201
|
+
print("note: %s has transparency - composited over white" % path, file=sys.stderr)
|
|
202
|
+
im = im.convert("RGBA")
|
|
203
|
+
bg = Image.new("RGBA", im.size, (255, 255, 255, 255))
|
|
204
|
+
im = Image.alpha_composite(bg, im)
|
|
205
|
+
return np.array(im.convert("RGB"))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def finite(values, what):
|
|
209
|
+
if not all(np.isfinite(values)):
|
|
210
|
+
sys.exit("%s must be finite numbers" % what)
|
|
211
|
+
return values
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def color_to_alpha(rgb):
|
|
215
|
+
"""RGBA such that compositing over white gives `rgb` back (standard colour-to-alpha against white)."""
|
|
216
|
+
f = rgb.astype(np.float32) / 255.0
|
|
217
|
+
a = np.clip(1.0 - f.min(2), 0, 1)
|
|
218
|
+
col = np.clip((f - (1 - a[:, :, None])) / np.maximum(a, 1e-4)[:, :, None], 0, 1)
|
|
219
|
+
col[a < 1e-3] = 0
|
|
220
|
+
return np.dstack([col * 255, a * 255]).round().astype(np.uint8)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def over_white(rgba_list):
|
|
224
|
+
h, w = rgba_list[0].shape[:2]
|
|
225
|
+
acc = np.full((h, w, 3), 255.0, np.float32)
|
|
226
|
+
for im in rgba_list:
|
|
227
|
+
al = im[:, :, 3:4].astype(np.float32) / 255.0
|
|
228
|
+
acc = acc * (1 - al) + im[:, :, :3].astype(np.float32) * al
|
|
229
|
+
return acc
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def tri(t):
|
|
233
|
+
"""Triangle wave -1..1 with sharp teeth, period 1."""
|
|
234
|
+
t = np.mod(t, 1.0)
|
|
235
|
+
return np.where(t < .5, 4 * t - 1, 3 - 4 * t)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def contour_polygon(mask, eps):
|
|
239
|
+
cs, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
|
|
240
|
+
if not cs:
|
|
241
|
+
return []
|
|
242
|
+
c = max(cs, key=cv2.contourArea)
|
|
243
|
+
ap = cv2.approxPolyDP(c, eps, True)[:, 0, :]
|
|
244
|
+
return [[int(x), int(y)] for x, y in ap]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def contour_polygons(mask, eps, min_area=4):
|
|
248
|
+
"""Every connected area as {outer, holes}: rings and islands are kept, unlike contour_polygon (largest outline only)."""
|
|
249
|
+
cs, hier = cv2.findContours(mask.astype(np.uint8), cv2.RETR_CCOMP, cv2.CHAIN_APPROX_NONE)
|
|
250
|
+
out = []
|
|
251
|
+
if hier is None:
|
|
252
|
+
return out
|
|
253
|
+
hier = hier[0]
|
|
254
|
+
|
|
255
|
+
def ring(c):
|
|
256
|
+
return [[int(x), int(y)] for x, y in cv2.approxPolyDP(c, eps, True)[:, 0, :]]
|
|
257
|
+
for i, c in enumerate(cs):
|
|
258
|
+
if hier[i][3] == -1 and cv2.contourArea(c) >= min_area: # outer ring; holes are its children
|
|
259
|
+
holes = [ring(cs[j]) for j in range(len(cs)) if hier[j][3] == i and cv2.contourArea(cs[j]) >= min_area]
|
|
260
|
+
out.append({"outer": ring(c), "holes": holes})
|
|
261
|
+
return out
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def fattest_point(mask):
|
|
265
|
+
if not mask.any():
|
|
266
|
+
return None # nothing solid in this part (e.g. only faint pixels): no honest answer
|
|
267
|
+
d = cv2.distanceTransform(mask.astype(np.uint8), cv2.DIST_L2, 5)
|
|
268
|
+
y, x = np.unravel_index(np.argmax(d), d.shape)
|
|
269
|
+
return [int(x), int(y)]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ----------------------------------------------------------------------------- alpha
|
|
273
|
+
def cmd_alpha(a):
|
|
274
|
+
refuse_inputs([a.input], [a.output])
|
|
275
|
+
rgb = load_rgb(a.input)
|
|
276
|
+
rgba = color_to_alpha(rgb)
|
|
277
|
+
save_atomic(Image.fromarray(rgba, "RGBA"), a.output)
|
|
278
|
+
if a.verify:
|
|
279
|
+
err = np.abs(over_white([rgba]) - rgb.astype(np.float32)).max()
|
|
280
|
+
print("max deviation over white: %.2f / 255" % err)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ----------------------------------------------------------------------------- split
|
|
284
|
+
def split_sectors(shape, centre, angles, tooth, depth, gap, edge, region=None):
|
|
285
|
+
"""Sector id per pixel (0..n-1, sector i starts at angles[i]), slit mask and ink-edge mask.
|
|
286
|
+
|
|
287
|
+
The dividing lines run from `centre` outwards at `angles` (degrees, screen angle: 0 = right, 90 = down). With depth > 0 each
|
|
288
|
+
line is a zig-zag (teeth of period `tooth`, amplitude `depth`) instead of straight. Lines may be uneven (any angles).
|
|
289
|
+
"""
|
|
290
|
+
h, w = shape
|
|
291
|
+
cx, cy = centre
|
|
292
|
+
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
|
|
293
|
+
dx, dy = xx - cx, yy - cy
|
|
294
|
+
r = np.hypot(dx, dy)
|
|
295
|
+
ang = (np.degrees(np.arctan2(dy, dx)) + 360) % 360
|
|
296
|
+
order = sorted(range(len(angles)), key=lambda i: angles[i])
|
|
297
|
+
ang_sorted = [angles[i] for i in order]
|
|
298
|
+
n = len(ang_sorted)
|
|
299
|
+
sector = np.zeros((h, w), np.int16) # sector k = from ang_sorted[k] to ang_sorted[k+1]
|
|
300
|
+
for k in range(n):
|
|
301
|
+
lo, hi = ang_sorted[k], ang_sorted[(k + 1) % n]
|
|
302
|
+
m = (ang >= lo) & (ang < hi) if hi > lo else (ang >= lo) | (ang < hi)
|
|
303
|
+
sector[m] = k
|
|
304
|
+
slit_dist = np.full((h, w), 1e9, np.float32)
|
|
305
|
+
for k in range(n): # line k separates sector k-1 and k
|
|
306
|
+
bang = ang_sorted[k]
|
|
307
|
+
d = np.radians(((ang - bang + 180) % 360) - 180)
|
|
308
|
+
along, perp = r * np.cos(d), r * np.sin(d)
|
|
309
|
+
zig = depth * tri(along / tooth + .25 * k) if depth > 0 else 0.0
|
|
310
|
+
near = (np.abs(perp) <= depth + gap / 2 + edge + 2) & (along > 0) # a narrow band along the line, never a wide wedge
|
|
311
|
+
if depth > 0: # straight lines: membership by angle is already exact
|
|
312
|
+
sector = np.where(near, np.where(perp > zig, k, (k - 1) % n), sector)
|
|
313
|
+
slit_dist = np.where(near, np.minimum(slit_dist, np.abs(perp - zig)), slit_dist)
|
|
314
|
+
limit = region if region is not None else np.ones((h, w), bool)
|
|
315
|
+
slit = (slit_dist < gap / 2) & limit
|
|
316
|
+
edge_zone = (slit_dist < gap / 2 + edge) & ~slit & limit
|
|
317
|
+
return sector, slit, edge_zone, order
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def cmd_split(a):
|
|
321
|
+
refuse_inputs([a.input], [Path(a.outdir) / n for n in ["parts.json"] + ["part_%d.png" % k for k in range(64)]])
|
|
322
|
+
rgb = load_rgb(a.input)
|
|
323
|
+
h, w = rgb.shape[:2]
|
|
324
|
+
rgba = np.dstack([rgb, np.full(rgb.shape[:2], 255, np.uint8)]) if a.opaque else color_to_alpha(rgb)
|
|
325
|
+
if rgba[:, :, 3].max() == 0: # no pixel has any alpha: there is literally nothing to cut
|
|
326
|
+
sys.exit("the image is completely white - nothing to cut (use --opaque to cut it anyway)")
|
|
327
|
+
if a.centre == "auto": # centroid of everything that is not white
|
|
328
|
+
m = (rgb.min(2) < 250).astype(np.uint8)
|
|
329
|
+
M = cv2.moments(m, True)
|
|
330
|
+
centre = (M["m10"] / M["m00"], M["m01"] / M["m00"]) if M["m00"] > 0 else (w / 2.0, h / 2.0)
|
|
331
|
+
else:
|
|
332
|
+
try:
|
|
333
|
+
centre = tuple(float(v) for v in a.centre.split(","))
|
|
334
|
+
assert len(centre) == 2
|
|
335
|
+
except (ValueError, AssertionError):
|
|
336
|
+
sys.exit("--centre must look like 300,240 (x,y) or 'auto'")
|
|
337
|
+
finite(list(centre), "--centre")
|
|
338
|
+
if a.simplify < 0 or not np.isfinite(a.simplify):
|
|
339
|
+
sys.exit("--simplify must be 0 or greater")
|
|
340
|
+
try:
|
|
341
|
+
angles = [float(v) for v in a.angles.split(",")]
|
|
342
|
+
finite(angles, "--angles")
|
|
343
|
+
angles = [v % 360.0 for v in angles] # 360 means 0, -90 means 270
|
|
344
|
+
except ValueError:
|
|
345
|
+
sys.exit("--angles must be numbers separated by commas, e.g. 32,148,214,327")
|
|
346
|
+
if len(angles) < 2 or len({round(v % 360, 6) for v in angles}) != len(angles):
|
|
347
|
+
sys.exit("--angles needs at least two different angles")
|
|
348
|
+
for flag, value in (("--gap", a.gap), ("--edge", a.edge), ("--depth", a.depth), ("--tooth", a.tooth)):
|
|
349
|
+
if not np.isfinite(value) or value < 0:
|
|
350
|
+
sys.exit("%s must be a finite number, 0 or greater" % flag)
|
|
351
|
+
if a.gap > 100 or a.edge > 100 or a.depth > 1000:
|
|
352
|
+
sys.exit("--gap/--edge/--depth are in pixels; these values are far too large")
|
|
353
|
+
if a.depth > 0 and a.tooth <= 0:
|
|
354
|
+
sys.exit("--tooth must be greater than 0 (or use --depth 0 for straight lines)")
|
|
355
|
+
region = None
|
|
356
|
+
if a.only_inside: # cut only inside the largest non-white blob, leave satellites whole
|
|
357
|
+
m = (rgb.min(2) < 250).astype(np.uint8)
|
|
358
|
+
m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8))
|
|
359
|
+
n, lab, st, _ = cv2.connectedComponentsWithStats(m)
|
|
360
|
+
if n > 1: # an all-background image has no blob: cut the whole picture
|
|
361
|
+
big = 1 + int(np.argmax(st[1:, cv2.CC_STAT_AREA]))
|
|
362
|
+
region = cv2.dilate((lab == big).astype(np.uint8), np.ones((5, 5), np.uint8)) > 0
|
|
363
|
+
sector, slit, edge_zone, order = split_sectors((h, w), centre, angles, a.tooth, a.depth, a.gap, a.edge, region)
|
|
364
|
+
if region is not None: # satellites stay whole: one sector per connected area outside the main blob
|
|
365
|
+
content = cv2.morphologyEx((rgb.min(2) < 250).astype(np.uint8), cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8))
|
|
366
|
+
nl, lab, _, _ = cv2.connectedComponentsWithStats(np.where(region, 0, content).astype(np.uint8))
|
|
367
|
+
for c in range(1, nl):
|
|
368
|
+
cc = lab == c
|
|
369
|
+
sector[cc] = np.bincount(sector[cc].ravel()).argmax()
|
|
370
|
+
out = Path(a.outdir)
|
|
371
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
372
|
+
for scratch in (DONE, ".pixelcut_old", ".pixelcut_new"):
|
|
373
|
+
real_folder(out / scratch, "scratch folder")
|
|
374
|
+
if (out / DONE).is_dir(): # the swap had finished, only the clean-up was interrupted
|
|
375
|
+
if foreign_entries(out / DONE):
|
|
376
|
+
sys.exit("%s holds files pixelcut did not put there - check and remove it first" % (out / DONE))
|
|
377
|
+
shutil.rmtree(out / DONE)
|
|
378
|
+
leftover = out / ".pixelcut_old" # an earlier run was interrupted while swapping: its old set is the only full copy
|
|
379
|
+
if leftover.is_dir():
|
|
380
|
+
strange = foreign_entries(leftover)
|
|
381
|
+
if strange:
|
|
382
|
+
sys.exit("%s holds files pixelcut did not put there (%s) - check and remove it first" % (leftover, ", ".join(strange)))
|
|
383
|
+
journal = leftover / JOURNAL
|
|
384
|
+
if journal.is_file(): # files the interrupted run had already put in place: take them out again
|
|
385
|
+
try:
|
|
386
|
+
for name in json.loads(journal.read_text()):
|
|
387
|
+
if isinstance(name, str) and (PART_NAME.fullmatch(name) or name == "parts.json") and (out / name).is_file():
|
|
388
|
+
(out / name).unlink()
|
|
389
|
+
except ValueError:
|
|
390
|
+
pass
|
|
391
|
+
journal.unlink()
|
|
392
|
+
if (leftover / (JOURNAL + ".tmp")).is_file():
|
|
393
|
+
(leftover / (JOURNAL + ".tmp")).unlink()
|
|
394
|
+
for f in leftover.iterdir(): # only pixelcut's own names are ever restored
|
|
395
|
+
os.replace(f, out / f.name)
|
|
396
|
+
shutil.rmtree(leftover)
|
|
397
|
+
print("note: an interrupted earlier run was rolled back first (previous parts restored)", file=sys.stderr)
|
|
398
|
+
old_manifest = out / "parts.json" # only files that an earlier pixelcut run listed may be replaced
|
|
399
|
+
ours = set()
|
|
400
|
+
if old_manifest.exists():
|
|
401
|
+
try:
|
|
402
|
+
ours = set(manifest_files(json.loads(old_manifest.read_text())))
|
|
403
|
+
except Exception: # a parts.json that is not ours is never touched
|
|
404
|
+
sys.exit("%s exists but is not a pixelcut manifest - use an empty folder" % old_manifest)
|
|
405
|
+
clash = [("part_%d.png" % k) for k in range(len(angles)) if (out / ("part_%d.png" % k)).exists() and ("part_%d.png" % k) not in ours]
|
|
406
|
+
if clash:
|
|
407
|
+
sys.exit("%s already exists in %s and was not written by pixelcut - use an empty folder" % (", ".join(clash), out))
|
|
408
|
+
staging = out / ".pixelcut_new" # everything is written here first, replacing happens only when all parts worked
|
|
409
|
+
if staging.exists():
|
|
410
|
+
if foreign_entries(staging):
|
|
411
|
+
sys.exit("%s exists and does not look like pixelcut's own scratch folder - remove it first" % staging)
|
|
412
|
+
shutil.rmtree(staging)
|
|
413
|
+
staging.mkdir()
|
|
414
|
+
ink = np.array([20, 16, 14], np.uint8)
|
|
415
|
+
parts, info = [], []
|
|
416
|
+
for k in range(len(angles)):
|
|
417
|
+
img = rgba.copy()
|
|
418
|
+
img[(sector != k) | slit] = 0
|
|
419
|
+
if a.edge > 0:
|
|
420
|
+
e = edge_zone & (sector == k) & (img[:, :, 3] > 0)
|
|
421
|
+
img[e, :3] = ink
|
|
422
|
+
img[e, 3] = 255
|
|
423
|
+
parts.append(img)
|
|
424
|
+
Image.fromarray(img, "RGBA").save(staging / ("part_%d.png" % k))
|
|
425
|
+
solid = ((sector == k) & ~slit & (rgba[:, :, 3] > 8)).astype(np.uint8)
|
|
426
|
+
info.append({"part": k, "starts_at_angle": sorted(angles)[k],
|
|
427
|
+
"polygon": contour_polygon(solid, a.simplify), "polygons": contour_polygons(solid, a.simplify),
|
|
428
|
+
"fattest_point": fattest_point(solid)})
|
|
429
|
+
files = ["part_%d.png" % k for k in range(len(parts))]
|
|
430
|
+
(staging / "parts.json").write_text(json.dumps({"size": [w, h], "centre": list(centre), "files": files, "parts": info}, indent=1))
|
|
431
|
+
backup = out / ".pixelcut_old" # old files are moved aside, not deleted - an abort rolls back
|
|
432
|
+
backup.mkdir() # (a leftover one was restored above, so this is always new)
|
|
433
|
+
write_journal(backup, []) # names this run is installing, written BEFORE each install (all old files are aside by then)
|
|
434
|
+
moved, installed = [], []
|
|
435
|
+
try:
|
|
436
|
+
for name in sorted(ours | {"parts.json"}):
|
|
437
|
+
if (out / name).is_file():
|
|
438
|
+
os.replace(out / name, backup / name)
|
|
439
|
+
moved.append(name)
|
|
440
|
+
for name in files + ["parts.json"]:
|
|
441
|
+
installed.append(name)
|
|
442
|
+
write_journal(backup, installed)
|
|
443
|
+
os.replace(staging / name, out / name)
|
|
444
|
+
except BaseException:
|
|
445
|
+
for name in installed: # 1. take back exactly what this run put in place (journal stays: an interrupted
|
|
446
|
+
if (out / name).is_file(): # roll-back is finished by the next run from the same list)
|
|
447
|
+
(out / name).unlink()
|
|
448
|
+
(backup / JOURNAL).unlink(missing_ok=True) # 2. only now may the journal go - the files restored next must not be taken out again
|
|
449
|
+
for f in list(backup.iterdir()): # 3. restore whatever is really in the backup (an abort between the move and the
|
|
450
|
+
if f.name != JOURNAL + ".tmp": # bookkeeping must not hide a file), and never delete a backup that is not empty
|
|
451
|
+
os.replace(f, out / f.name)
|
|
452
|
+
(backup / (JOURNAL + ".tmp")).unlink(missing_ok=True)
|
|
453
|
+
backup.rmdir()
|
|
454
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
455
|
+
raise
|
|
456
|
+
os.replace(backup, out / DONE) # commit point: from here on the new set is the truth
|
|
457
|
+
shutil.rmtree(out / DONE)
|
|
458
|
+
shutil.rmtree(staging)
|
|
459
|
+
print("wrote %d parts + parts.json to %s" % (len(parts), out))
|
|
460
|
+
if a.verify:
|
|
461
|
+
# without slit and edge the parts must add up to the original over white
|
|
462
|
+
flat = [rgba.copy() for _ in parts]
|
|
463
|
+
for k, im in enumerate(flat):
|
|
464
|
+
im[sector != k] = 0
|
|
465
|
+
err = np.abs(over_white(flat) - rgb.astype(np.float32)).max()
|
|
466
|
+
print("max deviation over white without slit: %.2f / 255" % err)
|
|
467
|
+
real = over_white([np.array(Image.open(out / ("part_%d.png" % k)).convert("RGBA")) for k in range(len(parts))])
|
|
468
|
+
real_err = np.abs(real - rgb.astype(np.float32)).max()
|
|
469
|
+
print("saved files joined over white: %.2f / 255 (with --gap %.1f --edge %.1f the slit is visible on purpose; 0 slit = exact)" % (real_err, a.gap, a.edge))
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ----------------------------------------------------------------------------- key
|
|
473
|
+
def cmd_key(a):
|
|
474
|
+
refuse_inputs([a.input], [Path(a.outdir) / n for n in ("base.png", "element.png", "preview.png")])
|
|
475
|
+
rgb = load_rgb(a.input)
|
|
476
|
+
h, w = rgb.shape[:2]
|
|
477
|
+
try:
|
|
478
|
+
x0, y0, x1, y1 = (int(v) for v in a.box.split(","))
|
|
479
|
+
except ValueError:
|
|
480
|
+
sys.exit("--box must look like x0,y0,x1,y1 (four whole numbers)")
|
|
481
|
+
if not (0 <= x0 < x1 <= w and 0 <= y0 < y1 <= h):
|
|
482
|
+
sys.exit("--box %s does not lie inside the image (%dx%d) or is empty/reversed" % (a.box, w, h))
|
|
483
|
+
if not (np.isfinite(a.scale) and 0 < a.scale <= 10):
|
|
484
|
+
sys.exit("--scale must be greater than 0 and at most 10")
|
|
485
|
+
if a.close < 1 or a.close > 99 or not np.isfinite(a.threshold) or a.threshold < 0:
|
|
486
|
+
sys.exit("--close must be 1..99 and --threshold 0 or greater")
|
|
487
|
+
roi = rgb[y0:y1, x0:x1].astype(np.float32)
|
|
488
|
+
ring = np.concatenate([roi[:3].reshape(-1, 3), roi[-3:].reshape(-1, 3), roi[:, :3].reshape(-1, 3), roi[:, -3:].reshape(-1, 3)])
|
|
489
|
+
fill = np.median(ring, 0) # the flat colour the element sits on
|
|
490
|
+
diff = np.abs(roi - fill).max(2)
|
|
491
|
+
fg = (diff > a.threshold).astype(np.uint8)
|
|
492
|
+
fg = cv2.morphologyEx(fg, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (a.close, a.close)))
|
|
493
|
+
cs, _ = cv2.findContours(fg, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
|
|
494
|
+
fg = np.zeros_like(fg)
|
|
495
|
+
cv2.drawContours(fg, cs, -1, 1, -1) # holes (counters, highlights) filled
|
|
496
|
+
ys, xs = np.nonzero(fg)
|
|
497
|
+
if len(xs) == 0:
|
|
498
|
+
sys.exit("nothing stands out from the background inside --box (try a lower --threshold)")
|
|
499
|
+
bx0, bx1, by0, by1 = xs.min(), xs.max(), ys.min(), ys.max()
|
|
500
|
+
# heal the hole with the flat colour, softly blended
|
|
501
|
+
soft = cv2.GaussianBlur(cv2.dilate(fg, np.ones((5, 5), np.uint8)).astype(np.float32), (0, 0), 1.2)[:, :, None]
|
|
502
|
+
healed = rgb.astype(np.float32).copy()
|
|
503
|
+
healed[y0:y1, x0:x1] = roi * (1 - soft) + fill * soft
|
|
504
|
+
# the element: exact pixels, shrunk, centred on (cx, cy)
|
|
505
|
+
try:
|
|
506
|
+
cx, cy = (float(v) for v in a.to.split(",")) if a.to else ((x0 + x1) / 2, (y0 + y1) / 2)
|
|
507
|
+
except ValueError:
|
|
508
|
+
sys.exit("--to must look like x,y")
|
|
509
|
+
finite([cx, cy], "--to")
|
|
510
|
+
el = rgb[y0:y1, x0:x1][by0:by1 + 1, bx0:bx1 + 1]
|
|
511
|
+
al = cv2.GaussianBlur(fg[by0:by1 + 1, bx0:bx1 + 1].astype(np.float32), (0, 0), .8)
|
|
512
|
+
kw, kh = max(1, int(round(el.shape[1] * a.scale))), max(1, int(round(el.shape[0] * a.scale)))
|
|
513
|
+
el = cv2.resize(el, (kw, kh), interpolation=cv2.INTER_AREA)
|
|
514
|
+
al = cv2.resize(al, (kw, kh), interpolation=cv2.INTER_AREA)
|
|
515
|
+
layer = np.zeros((h, w, 4), np.uint8)
|
|
516
|
+
px, py = int(round(cx - kw / 2)), int(round(cy - kh / 2))
|
|
517
|
+
if px < 0 or py < 0 or px + kw > w or py + kh > h:
|
|
518
|
+
sys.exit("the element would leave the image at --to %s (needs %dx%d px around that point)" % (a.to, kw, kh))
|
|
519
|
+
layer[py:py + kh, px:px + kw, :3] = el
|
|
520
|
+
layer[py:py + kh, px:px + kw, 3] = np.clip(al * 255, 0, 255).astype(np.uint8)
|
|
521
|
+
out = Path(a.outdir)
|
|
522
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
523
|
+
comp = healed.copy()
|
|
524
|
+
al3 = layer[:, :, 3:4].astype(np.float32) / 255
|
|
525
|
+
comp = comp * (1 - al3) + layer[:, :, :3].astype(np.float32) * al3
|
|
526
|
+
work, _ = scratch_beside(out / "base.png") # all three are finished first, then published together
|
|
527
|
+
try:
|
|
528
|
+
Image.fromarray(np.clip(healed, 0, 255).round().astype(np.uint8)).save(work / "base.png")
|
|
529
|
+
Image.fromarray(layer, "RGBA").save(work / "element.png")
|
|
530
|
+
Image.fromarray(np.clip(comp, 0, 255).round().astype(np.uint8)).save(work / "preview.png")
|
|
531
|
+
publish([(work / n, out / n) for n in ("base.png", "element.png", "preview.png")])
|
|
532
|
+
finally:
|
|
533
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
534
|
+
print("wrote base.png (hole healed), element.png (exact pixels, scale %.2f), preview.png" % a.scale)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# ----------------------------------------------------------------------------- join / compare
|
|
539
|
+
def cmd_join(a):
|
|
540
|
+
"""Put parts back together (over white or a colour) - the way to 'rebuild' a picture from its exact pieces."""
|
|
541
|
+
manifest = Path(a.parts) / "parts.json"
|
|
542
|
+
size = None
|
|
543
|
+
if manifest.exists(): # the manifest says which parts belong together - all of them are needed
|
|
544
|
+
try:
|
|
545
|
+
m = json.loads(manifest.read_text())
|
|
546
|
+
n = len(m["parts"])
|
|
547
|
+
size = tuple(m["size"])
|
|
548
|
+
assert len(size) == 2 and all(isinstance(v, int) and v > 0 for v in size) and n > 0
|
|
549
|
+
names = m.get("files") or ["part_%d.png" % k for k in range(n)]
|
|
550
|
+
assert len(names) == n == len(set(names)) and all(isinstance(x, str) and PART_NAME.fullmatch(x) for x in names)
|
|
551
|
+
except (ValueError, KeyError, TypeError, AssertionError, AttributeError):
|
|
552
|
+
sys.exit("parts.json is damaged (needs 'size' [w,h], 'parts' and matching 'files')")
|
|
553
|
+
files = [Path(a.parts) / x for x in names]
|
|
554
|
+
missing = [f.name for f in files if not f.exists()]
|
|
555
|
+
if missing:
|
|
556
|
+
sys.exit("parts.json lists %d parts but these are missing: %s" % (n, ", ".join(missing)))
|
|
557
|
+
else:
|
|
558
|
+
files = sorted(f for f in Path(a.parts).glob("part_*.png") if PART_NAME.fullmatch(f.name))
|
|
559
|
+
numbers = sorted(int(f.stem.split("_")[1]) for f in files)
|
|
560
|
+
gaps = [k for k in range(numbers[-1] + 1) if k not in numbers] if numbers else []
|
|
561
|
+
if gaps:
|
|
562
|
+
sys.exit("part numbering has a gap (missing part_%d.png) - without parts.json every part is needed" % gaps[0])
|
|
563
|
+
if not files:
|
|
564
|
+
sys.exit("no part_*.png in %s" % a.parts)
|
|
565
|
+
ims = [np.array(Image.open(f).convert("RGBA")) for f in files]
|
|
566
|
+
shapes = {im.shape[:2] for im in ims}
|
|
567
|
+
if len(shapes) != 1 or (size and (size[1], size[0]) not in shapes):
|
|
568
|
+
sys.exit("the parts do not all have the same size%s" % (" as parts.json says" if size else ""))
|
|
569
|
+
refuse_inputs(files + [Path(a.parts) / "parts.json"], [a.output])
|
|
570
|
+
out = over_white(ims)
|
|
571
|
+
save_atomic(Image.fromarray(np.clip(out, 0, 255).round().astype(np.uint8)), a.output)
|
|
572
|
+
print("joined %d parts -> %s" % (len(ims), a.output))
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def measure(x, y):
|
|
576
|
+
"""(SSIM on grey, mean abs diff, max abs diff) of two same-size RGB arrays."""
|
|
577
|
+
from skimage.metrics import structural_similarity
|
|
578
|
+
gx = cv2.cvtColor(x, cv2.COLOR_RGB2GRAY)
|
|
579
|
+
gy = cv2.cvtColor(y, cv2.COLOR_RGB2GRAY)
|
|
580
|
+
d = np.abs(x.astype(np.float32) - y.astype(np.float32))
|
|
581
|
+
win = min(7, min(gx.shape) // 2 * 2 - 1 if min(gx.shape) % 2 == 0 else min(gx.shape))
|
|
582
|
+
ss = structural_similarity(gx, gy, win_size=win) if win >= 3 else float("nan") # tiny images: no SSIM window
|
|
583
|
+
return ss, float(d.mean()), float(d.max())
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def cmd_compare(a):
|
|
587
|
+
x, y = load_rgb(a.first), load_rgb(a.second)
|
|
588
|
+
if x.shape != y.shape:
|
|
589
|
+
if not a.resize:
|
|
590
|
+
sys.exit("the images have different sizes (%dx%d vs %dx%d) - use --resize to scale the second one" % (x.shape[1], x.shape[0], y.shape[1], y.shape[0]))
|
|
591
|
+
y = cv2.resize(y, (x.shape[1], x.shape[0]), interpolation=cv2.INTER_AREA)
|
|
592
|
+
ss, mean, mx = measure(x, y)
|
|
593
|
+
print("SSIM %.4f mean |diff| %.3f max |diff| %d" % (ss, mean, mx))
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
# ----------------------------------------------------------------------------- trace / upscale (optional external engines)
|
|
597
|
+
def find_chrome():
|
|
598
|
+
import shutil
|
|
599
|
+
for c in ("chrome", "google-chrome", "chromium", "msedge", "brave"):
|
|
600
|
+
w = shutil.which(c)
|
|
601
|
+
if w:
|
|
602
|
+
return w
|
|
603
|
+
for c in (r"C:\Program Files\Google\Chrome\Application\chrome.exe", r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
|
604
|
+
"/usr/bin/google-chrome", "/usr/bin/chromium", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"):
|
|
605
|
+
if Path(c).exists():
|
|
606
|
+
return c
|
|
607
|
+
return None
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def cmd_trace(a):
|
|
611
|
+
"""Redraw the picture as vector shapes (vtracer). Approximate by nature: use `compare` to see how far it is from the original."""
|
|
612
|
+
import vtracer
|
|
613
|
+
if not Path(a.input).is_file():
|
|
614
|
+
sys.exit("error: %s does not exist" % a.input)
|
|
615
|
+
if not (0 <= a.speckle <= 128 and 1 <= a.colors <= 8 and 0 <= a.layers <= 255):
|
|
616
|
+
sys.exit("--speckle must be 0..128, --colors 1..8, --layers 0..255")
|
|
617
|
+
if not Path(a.output).resolve().parent.is_dir():
|
|
618
|
+
sys.exit("error: the folder for %s does not exist" % a.output)
|
|
619
|
+
here = Path(a.input).resolve()
|
|
620
|
+
if Path(a.output).resolve() == here:
|
|
621
|
+
sys.exit("error: the SVG must be a different file than the input")
|
|
622
|
+
if a.render and Path(a.render).resolve() in (here, Path(a.output).resolve()):
|
|
623
|
+
sys.exit("error: --render must be a different file than the input and the SVG")
|
|
624
|
+
work, tmp_svg = scratch_beside(a.output)
|
|
625
|
+
try:
|
|
626
|
+
vtracer.convert_image_to_svg_py(a.input, str(tmp_svg), colormode="color", hierarchical="stacked", mode="spline",
|
|
627
|
+
filter_speckle=a.speckle, color_precision=a.colors, layer_difference=a.layers,
|
|
628
|
+
corner_threshold=60, length_threshold=3.5, splice_threshold=45, path_precision=3)
|
|
629
|
+
os.replace(tmp_svg, Path(a.output).resolve())
|
|
630
|
+
finally:
|
|
631
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
632
|
+
print("wrote", a.output)
|
|
633
|
+
if a.render:
|
|
634
|
+
import subprocess
|
|
635
|
+
import tempfile
|
|
636
|
+
render_target = Path(a.render).resolve()
|
|
637
|
+
if render_target in (Path(a.input).resolve(), Path(a.output).resolve()):
|
|
638
|
+
sys.exit("error: --render must be a different file than the input and the SVG")
|
|
639
|
+
chrome = find_chrome()
|
|
640
|
+
if not chrome:
|
|
641
|
+
sys.exit("--render needs Chrome/Edge/Brave")
|
|
642
|
+
w, h = Image.open(a.input).size
|
|
643
|
+
tmp = tempfile.TemporaryDirectory()
|
|
644
|
+
t = Path(tmp.name)
|
|
645
|
+
(t / "p.html").write_text('<html><body style="margin:0;background:#fff"><img src="%s" width=%d height=%d></body></html>'
|
|
646
|
+
% (Path(a.output).resolve().as_uri(), w, h))
|
|
647
|
+
shot = t / "shot.png" # render aside, replace the target only when it worked
|
|
648
|
+
try:
|
|
649
|
+
subprocess.run([chrome, "--headless=new", "--disable-gpu", "--hide-scrollbars", "--allow-file-access-from-files",
|
|
650
|
+
"--user-data-dir=" + str(t / "prof"), "--window-size=%d,%d" % (w, h), "--screenshot=" + str(shot),
|
|
651
|
+
(t / "p.html").as_uri()], capture_output=True, timeout=120)
|
|
652
|
+
if not shot.exists():
|
|
653
|
+
sys.exit("error: Chrome did not produce a picture - %s was left as it was" % a.render)
|
|
654
|
+
wd, staged = scratch_beside(render_target) # copy beside the target first (may be another disk), then one atomic rename
|
|
655
|
+
try:
|
|
656
|
+
shutil.copyfile(shot, staged)
|
|
657
|
+
os.replace(staged, render_target)
|
|
658
|
+
finally:
|
|
659
|
+
shutil.rmtree(wd, ignore_errors=True)
|
|
660
|
+
finally:
|
|
661
|
+
shutil.rmtree(tmp.name, ignore_errors=True) # browser profile included
|
|
662
|
+
print("rendered", a.render)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
ESRGAN_SHA256 = {"windows": "abc02804e17982a3be33675e4d471e91ea374e65b70167abc09e31acb412802d",
|
|
666
|
+
"ubuntu": "e5aa6eb131234b87c0c51f82b89390f5e3e642b7b70f2b9bbe95b6a285a40c96",
|
|
667
|
+
"macos": "e0ad05580abfeb25f8d8fb55aaf7bedf552c375b5b4d9bd3c8d59764d2cc333a"}
|
|
668
|
+
ESRGAN_URL = "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesrgan-ncnn-vulkan-20220424-%s.zip"
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def ensure_esrgan():
|
|
672
|
+
"""Find realesrgan-ncnn-vulkan; if it is missing, download the official release once into ~/.cache/pixelcut.
|
|
673
|
+
Returns None when this machine cannot run it (e.g. ARM Linux)."""
|
|
674
|
+
import platform
|
|
675
|
+
import shutil
|
|
676
|
+
import urllib.request
|
|
677
|
+
import zipfile
|
|
678
|
+
found = shutil.which("realesrgan-ncnn-vulkan")
|
|
679
|
+
if found:
|
|
680
|
+
return found
|
|
681
|
+
osname = {"win32": "windows", "darwin": "macos"}.get(sys.platform, "ubuntu")
|
|
682
|
+
if platform.machine().lower() not in ("amd64", "x86_64") and osname != "macos":
|
|
683
|
+
return None
|
|
684
|
+
exename = "realesrgan-ncnn-vulkan" + (".exe" if osname == "windows" else "")
|
|
685
|
+
cache = Path.home() / ".cache" / "pixelcut" / "realesrgan"
|
|
686
|
+
exe = next(cache.rglob(exename), None) if cache.exists() else None
|
|
687
|
+
if exe:
|
|
688
|
+
return str(exe)
|
|
689
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
690
|
+
zpath = cache / "release.zip"
|
|
691
|
+
print("realesrgan-ncnn-vulkan not found - downloading the official release (~50 MB, once) ...")
|
|
692
|
+
try:
|
|
693
|
+
import hashlib
|
|
694
|
+
import socket
|
|
695
|
+
socket.setdefaulttimeout(60) # a dead connection must end in the Lanczos fallback, not hang
|
|
696
|
+
urllib.request.urlretrieve(ESRGAN_URL % osname, zpath)
|
|
697
|
+
if hashlib.sha256(zpath.read_bytes()).hexdigest() != ESRGAN_SHA256[osname]:
|
|
698
|
+
zpath.unlink()
|
|
699
|
+
print("download rejected: checksum does not match the known Real-ESRGAN release")
|
|
700
|
+
return None
|
|
701
|
+
with zipfile.ZipFile(zpath) as z:
|
|
702
|
+
for member in z.namelist(): # no path may leave the cache folder
|
|
703
|
+
if not (cache / member).resolve().is_relative_to(cache.resolve()):
|
|
704
|
+
raise ValueError("unsafe path in archive: %s" % member)
|
|
705
|
+
z.extractall(cache)
|
|
706
|
+
zpath.unlink()
|
|
707
|
+
except Exception as e:
|
|
708
|
+
print("download failed (%s)" % e)
|
|
709
|
+
return None
|
|
710
|
+
exe = next(cache.rglob(exename), None)
|
|
711
|
+
if exe and osname != "windows":
|
|
712
|
+
exe.chmod(0o755)
|
|
713
|
+
return str(exe) if exe else None
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def lanczos_upscale(a, why):
|
|
717
|
+
"""Fallback without AI: plain Lanczos resize. Smooth, but no invented detail."""
|
|
718
|
+
print("Real-ESRGAN not usable here (%s) - using plain Lanczos resize instead (no AI detail)." % why)
|
|
719
|
+
im = open_upright(a.input)
|
|
720
|
+
im = im.convert("RGBA" if (im.mode in ("RGBA", "LA", "PA") or "transparency" in im.info) else "RGB") # keep transparency
|
|
721
|
+
save_atomic(im.resize((im.width * a.scale, im.height * a.scale), Image.LANCZOS), a.output)
|
|
722
|
+
print("wrote", a.output)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def cmd_upscale(a):
|
|
726
|
+
"""AI upscale with Real-ESRGAN (realesrgan-ncnn-vulkan). New pixels are invented: sharper, but not exact any more.
|
|
727
|
+
Falls back to Lanczos where the engine cannot run (ARM, no Vulkan GPU)."""
|
|
728
|
+
import subprocess
|
|
729
|
+
if not 1 <= a.scale <= 8:
|
|
730
|
+
sys.exit("--scale must be a whole number from 1 to 8")
|
|
731
|
+
refuse_inputs([a.input], [a.output])
|
|
732
|
+
need_folder_of(a.output)
|
|
733
|
+
if a.no_ai:
|
|
734
|
+
return lanczos_upscale(a, "--no-ai")
|
|
735
|
+
exe = a.bin or ensure_esrgan()
|
|
736
|
+
if not exe:
|
|
737
|
+
return lanczos_upscale(a, "no build for this machine")
|
|
738
|
+
target = Path(a.output).resolve()
|
|
739
|
+
work, tmp = scratch_beside(target) # the engine writes into a private folder beside the target; only a finished file replaces it
|
|
740
|
+
try:
|
|
741
|
+
subprocess.run([exe, "-i", str(Path(a.input).resolve()), "-o", str(tmp), "-n", a.model, "-s", str(a.scale)],
|
|
742
|
+
check=True, cwd=str(Path(exe).parent), capture_output=True, timeout=1800)
|
|
743
|
+
if not tmp.is_file() or tmp.stat().st_size == 0:
|
|
744
|
+
raise OSError("the engine produced no picture")
|
|
745
|
+
os.replace(tmp, target)
|
|
746
|
+
except subprocess.TimeoutExpired:
|
|
747
|
+
return lanczos_upscale(a, "the engine did not finish in 30 minutes")
|
|
748
|
+
except OSError as e:
|
|
749
|
+
return lanczos_upscale(a, "cannot start: %s" % e)
|
|
750
|
+
except subprocess.CalledProcessError:
|
|
751
|
+
return lanczos_upscale(a, "needs a Vulkan GPU/driver")
|
|
752
|
+
finally:
|
|
753
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
754
|
+
print("wrote", a.output)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
# ----------------------------------------------------------------------------- cli
|
|
758
|
+
def main(argv=None):
|
|
759
|
+
for stream in (sys.stdout, sys.stderr): # a path with characters the console cannot show must not turn a finished job into an error
|
|
760
|
+
try:
|
|
761
|
+
stream.reconfigure(errors="backslashreplace")
|
|
762
|
+
except (AttributeError, ValueError):
|
|
763
|
+
pass
|
|
764
|
+
p = argparse.ArgumentParser(prog="pixelcut", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
765
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
766
|
+
|
|
767
|
+
s = sub.add_parser("alpha", help="white background -> transparent, exact over white")
|
|
768
|
+
s.add_argument("input"); s.add_argument("output")
|
|
769
|
+
s.add_argument("--verify", action="store_true", help="print the max deviation over white")
|
|
770
|
+
s.set_defaults(fn=cmd_alpha)
|
|
771
|
+
|
|
772
|
+
s = sub.add_parser("split", help="cut into wedges with straight or zig-zag dividing lines")
|
|
773
|
+
s.add_argument("input"); s.add_argument("outdir")
|
|
774
|
+
s.add_argument("--centre", default="auto", help="x,y of the centre or 'auto' (centroid of the non-white area)")
|
|
775
|
+
s.add_argument("--angles", default="45,135,225,315", help="angles of the dividing lines in degrees (0 = right, 90 = down); free, e.g. 32,148,214,327")
|
|
776
|
+
s.add_argument("--tooth", type=float, default=18.0, help="zig-zag period in px")
|
|
777
|
+
s.add_argument("--depth", type=float, default=9.0, help="zig-zag amplitude in px, 0 = straight lines")
|
|
778
|
+
s.add_argument("--gap", type=float, default=2.6, help="width of the slit in px")
|
|
779
|
+
s.add_argument("--edge", type=float, default=3.2, help="thin ink edge along the slit in px, 0 = none")
|
|
780
|
+
s.add_argument("--only-inside", action="store_true", help="cut only the biggest blob, keep satellites (clouds, dots) whole")
|
|
781
|
+
s.add_argument("--simplify", type=float, default=2.0, help="polygon simplification in px (for hit areas)")
|
|
782
|
+
s.add_argument("--opaque", action="store_true", help="photos: keep every pixel opaque, no white-to-alpha")
|
|
783
|
+
s.add_argument("--verify", action="store_true")
|
|
784
|
+
s.set_defaults(fn=cmd_split)
|
|
785
|
+
|
|
786
|
+
s = sub.add_parser("key", help="lift a foreground element out of a flat area, shrink and centre it, heal the hole")
|
|
787
|
+
s.add_argument("input"); s.add_argument("outdir")
|
|
788
|
+
s.add_argument("--box", required=True, help="x0,y0,x1,y1 box around the element; its border must be flat background")
|
|
789
|
+
s.add_argument("--scale", type=float, default=.55)
|
|
790
|
+
s.add_argument("--to", help="x,y where the element is centred (default: centre of the box)")
|
|
791
|
+
s.add_argument("--threshold", type=float, default=40, help="colour distance from the flat background that counts as element")
|
|
792
|
+
s.add_argument("--close", type=int, default=9, help="closing kernel px, joins letters and fills gaps")
|
|
793
|
+
s.set_defaults(fn=cmd_key)
|
|
794
|
+
|
|
795
|
+
s = sub.add_parser("join", help="put parts_dir/part_*.png back together over white")
|
|
796
|
+
s.add_argument("parts"); s.add_argument("output")
|
|
797
|
+
s.set_defaults(fn=cmd_join)
|
|
798
|
+
|
|
799
|
+
s = sub.add_parser("compare", help="SSIM and pixel difference of two images")
|
|
800
|
+
s.add_argument("first"); s.add_argument("second")
|
|
801
|
+
s.add_argument("--resize", action="store_true", help="scale the second image to the size of the first (otherwise different sizes are an error)")
|
|
802
|
+
s.set_defaults(fn=cmd_compare)
|
|
803
|
+
|
|
804
|
+
s = sub.add_parser("trace", help="redraw as SVG (vtracer): approximate")
|
|
805
|
+
s.add_argument("input"); s.add_argument("output")
|
|
806
|
+
s.add_argument("--render", help="also render the SVG to this PNG (needs Chrome/Edge/Brave)")
|
|
807
|
+
s.add_argument("--speckle", type=int, default=2); s.add_argument("--colors", type=int, default=7); s.add_argument("--layers", type=int, default=8)
|
|
808
|
+
s.set_defaults(fn=cmd_trace)
|
|
809
|
+
|
|
810
|
+
s = sub.add_parser("upscale", help="AI upscale (Real-ESRGAN, downloaded automatically on first use): invents pixels")
|
|
811
|
+
s.add_argument("input"); s.add_argument("output")
|
|
812
|
+
s.add_argument("--scale", type=int, default=4); s.add_argument("--model", default="realesrgan-x4plus-anime")
|
|
813
|
+
s.add_argument("--bin", help="path to realesrgan-ncnn-vulkan (default: automatic)")
|
|
814
|
+
s.add_argument("--no-ai", action="store_true", help="plain Lanczos resize: no download, no engine, works offline")
|
|
815
|
+
s.set_defaults(fn=cmd_upscale)
|
|
816
|
+
|
|
817
|
+
a = p.parse_args(argv)
|
|
818
|
+
try:
|
|
819
|
+
a.fn(a)
|
|
820
|
+
except (SystemExit, KeyboardInterrupt):
|
|
821
|
+
raise
|
|
822
|
+
except BaseException as e: # includes pyo3 PanicException, which is not an Exception
|
|
823
|
+
if os.environ.get("PIXELCUT_DEBUG"):
|
|
824
|
+
raise
|
|
825
|
+
sys.exit("error: %s (set PIXELCUT_DEBUG=1 for the full trace)" % (str(e).strip().splitlines() or [type(e).__name__])[0])
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
if __name__ == "__main__":
|
|
829
|
+
sys.exit(main())
|