pixelification 0.1.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.
|
File without changes
|
pixelification/main.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pixel Rearrangement Tool — Keyboard-Navigated Terminal UI
|
|
3
|
+
|
|
4
|
+
Select images via a native file dialog, then watch the pixel rearrangement
|
|
5
|
+
animate row-by-row in an OpenCV window. No new pixels are created; only
|
|
6
|
+
existing pixels from the source image are reordered to match the target.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python rearrange_pixels_tui.py
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import cv2
|
|
14
|
+
import numpy as np
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from prompt_toolkit import Application
|
|
24
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
25
|
+
from prompt_toolkit.layout import Layout, Window, FormattedTextControl
|
|
26
|
+
from prompt_toolkit.styles import Style
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── Styling ──────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
STYLE = Style([
|
|
32
|
+
("title", "bold #00d787"),
|
|
33
|
+
("path-label", "bold #5f87ff"),
|
|
34
|
+
("path-value", "#87afff"),
|
|
35
|
+
("path-empty", "#585858 italic"),
|
|
36
|
+
("divider", "#3a3a3a"),
|
|
37
|
+
("menu-item", "bold #ffffff"),
|
|
38
|
+
("menu-desc", "#6c6c6c"),
|
|
39
|
+
("menu-cursor", "bold #000000 bg:#00d787"),
|
|
40
|
+
("status", "#5faf5f"),
|
|
41
|
+
("status-info", "#878787 italic"),
|
|
42
|
+
("status-error", "#ff5f5f"),
|
|
43
|
+
("status-warn", "#ffaf5f"),
|
|
44
|
+
("help", "#585858 italic"),
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── Native File Dialog ───────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
def _powershell_open_file(title: str) -> str | None:
|
|
51
|
+
"""Open the native Windows file dialog via a temp PowerShell script.
|
|
52
|
+
Avoids command-line quoting issues with -Command."""
|
|
53
|
+
script = """Add-Type -AssemblyName System.Windows.Forms
|
|
54
|
+
$f = New-Object System.Windows.Forms.OpenFileDialog
|
|
55
|
+
$f.Filter = "Image Files (*.png;*.jpg;*.jpeg;*.bmp;*.tiff;*.gif;*.webp)|*.png;*.jpg;*.jpeg;*.bmp;*.tiff;*.gif;*.webp|All Files (*.*)|*.*"
|
|
56
|
+
$f.FilterIndex = 1
|
|
57
|
+
$f.RestoreDirectory = $true
|
|
58
|
+
if ($f.ShowDialog() -eq "OK") { $f.FileName }
|
|
59
|
+
"""
|
|
60
|
+
tmp = None
|
|
61
|
+
try:
|
|
62
|
+
with tempfile.NamedTemporaryFile(
|
|
63
|
+
mode="w", suffix=".ps1", delete=False, encoding="utf-8",
|
|
64
|
+
) as f:
|
|
65
|
+
f.write(script)
|
|
66
|
+
tmp = f.name
|
|
67
|
+
r = subprocess.run(
|
|
68
|
+
["powershell", "-NoProfile", "-File", tmp],
|
|
69
|
+
capture_output=True, text=True, timeout=60,
|
|
70
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
|
|
71
|
+
)
|
|
72
|
+
out = r.stdout.strip()
|
|
73
|
+
return out if out else None
|
|
74
|
+
except Exception:
|
|
75
|
+
return None
|
|
76
|
+
finally:
|
|
77
|
+
if tmp and os.path.exists(tmp):
|
|
78
|
+
try:
|
|
79
|
+
os.unlink(tmp)
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _tkinter_open_file(title: str) -> str | None:
|
|
85
|
+
"""Fallback: open file dialog via tkinter."""
|
|
86
|
+
try:
|
|
87
|
+
import tkinter as tk
|
|
88
|
+
from tkinter import filedialog
|
|
89
|
+
root = tk.Tk()
|
|
90
|
+
root.withdraw()
|
|
91
|
+
root.attributes("-topmost", True)
|
|
92
|
+
path = filedialog.askopenfilename(
|
|
93
|
+
parent=root, title=title,
|
|
94
|
+
filetypes=[
|
|
95
|
+
("Image files", "*.png *.jpg *.jpeg *.bmp *.tiff *.gif *.webp"),
|
|
96
|
+
("All files", "*.*"),
|
|
97
|
+
],
|
|
98
|
+
)
|
|
99
|
+
root.destroy()
|
|
100
|
+
return path if path else None
|
|
101
|
+
except Exception:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def select_file(title: str = "Select Image") -> str | None:
|
|
106
|
+
"""Open a native file-selection dialog. Uses PowerShell on Windows,
|
|
107
|
+
tkinter elsewhere. Returns the selected path or *None* if cancelled."""
|
|
108
|
+
if os.name == "nt":
|
|
109
|
+
path = _powershell_open_file(title)
|
|
110
|
+
if path:
|
|
111
|
+
return path
|
|
112
|
+
path = _tkinter_open_file(title)
|
|
113
|
+
return path if path else None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── Application State ────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class State:
|
|
120
|
+
source: str = ""
|
|
121
|
+
target: str = ""
|
|
122
|
+
status: str = "Ready"
|
|
123
|
+
status_style: str = "status"
|
|
124
|
+
info: str = ""
|
|
125
|
+
cursor: int = 0 # 0-3 → source / target / run / quit
|
|
126
|
+
running: bool = False
|
|
127
|
+
done: bool = False
|
|
128
|
+
|
|
129
|
+
MENU = [
|
|
130
|
+
("Select Source Image", "choose the image whose pixels will be rearranged"),
|
|
131
|
+
("Select Target Image", "choose the image whose layout will be approximated"),
|
|
132
|
+
("Run Rearrangement", "execute the sort-based pixel-matching algorithm"),
|
|
133
|
+
("Quit", "exit the application"),
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ── Rearrangement Engine ─────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def compute_sort_keys(img):
|
|
140
|
+
h, w = img.shape[:2]
|
|
141
|
+
flat = img.reshape(-1, 3).astype(np.float32)
|
|
142
|
+
lum = 0.299 * flat[:, 2] + 0.587 * flat[:, 1] + 0.114 * flat[:, 0]
|
|
143
|
+
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV_FULL).reshape(-1, 3).astype(np.float32)
|
|
144
|
+
return lum, hsv[:, 0], hsv[:, 1]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_screen_resolution():
|
|
148
|
+
"""Attempt to get screen resolution across platforms."""
|
|
149
|
+
try:
|
|
150
|
+
import ctypes
|
|
151
|
+
sw = ctypes.windll.user32.GetSystemMetrics(0)
|
|
152
|
+
sh = ctypes.windll.user32.GetSystemMetrics(1)
|
|
153
|
+
if sw > 0 and sh > 0:
|
|
154
|
+
return sw, sh
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
import tkinter as tk
|
|
160
|
+
root = tk.Tk()
|
|
161
|
+
root.withdraw()
|
|
162
|
+
sw = root.winfo_screenwidth()
|
|
163
|
+
sh = root.winfo_screenheight()
|
|
164
|
+
root.destroy()
|
|
165
|
+
if sw > 0 and sh > 0:
|
|
166
|
+
return sw, sh
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
return 1920, 1080
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def rearrange(source_path: str, target_path: str, state: State) -> None:
|
|
174
|
+
"""Run the pixel-rearrangement algorithm and show the OpenCV window.
|
|
175
|
+
Called from a background thread."""
|
|
176
|
+
try:
|
|
177
|
+
img_src = cv2.imread(source_path, cv2.IMREAD_COLOR)
|
|
178
|
+
img_tgt = cv2.imread(target_path, cv2.IMREAD_COLOR)
|
|
179
|
+
|
|
180
|
+
if img_src is None:
|
|
181
|
+
state.status = f"Can't read source: {Path(source_path).name}"
|
|
182
|
+
state.status_style = "status-error"; return
|
|
183
|
+
if img_tgt is None:
|
|
184
|
+
state.status = f"Can't read target: {Path(target_path).name}"
|
|
185
|
+
state.status_style = "status-error"; return
|
|
186
|
+
|
|
187
|
+
h, w = img_src.shape[:2]
|
|
188
|
+
img_tgt = cv2.resize(img_tgt, (w, h))
|
|
189
|
+
|
|
190
|
+
# ── optimal transport via colour sorting ──
|
|
191
|
+
s_l, s_h, s_s = compute_sort_keys(img_src)
|
|
192
|
+
t_l, t_h, t_s = compute_sort_keys(img_tgt)
|
|
193
|
+
s_order = np.lexsort((s_s, s_h, s_l))
|
|
194
|
+
t_order = np.lexsort((t_s, t_h, t_l))
|
|
195
|
+
|
|
196
|
+
out = np.empty_like(img_src.reshape(-1, 3), dtype=np.uint8)
|
|
197
|
+
out[t_order] = img_src.reshape(-1, 3)[s_order]
|
|
198
|
+
out_img = out.reshape(h, w, 3)
|
|
199
|
+
|
|
200
|
+
# ── Display setup (screen-resolution canvas) ──
|
|
201
|
+
sw, sh = get_screen_resolution()
|
|
202
|
+
canvas = np.full((sh, sw, 3), 32, dtype=np.uint8)
|
|
203
|
+
|
|
204
|
+
label_h = 22
|
|
205
|
+
pw = sw // 3 # panel width (equal thirds)
|
|
206
|
+
ph = sh - label_h # panel height
|
|
207
|
+
sc = min(pw / w, ph / h) # as large as possible, no crop
|
|
208
|
+
iw, ih = max(int(w * sc), 1), max(int(h * sc), 1)
|
|
209
|
+
|
|
210
|
+
src_s = cv2.resize(img_src, (iw, ih), interpolation=cv2.INTER_LANCZOS4)
|
|
211
|
+
tgt_s = cv2.resize(img_tgt, (iw, ih), interpolation=cv2.INTER_LANCZOS4)
|
|
212
|
+
|
|
213
|
+
cx = (pw - iw) // 2
|
|
214
|
+
cy = label_h + (ph - ih) // 2
|
|
215
|
+
canvas[cy:cy+ih, cx:cx+iw] = src_s
|
|
216
|
+
canvas[cy:cy+ih, pw+cx:pw+cx+iw] = tgt_s
|
|
217
|
+
|
|
218
|
+
rec_x = 2 * pw + cx
|
|
219
|
+
rec_region = canvas[cy:cy+ih, rec_x:rec_x+iw]
|
|
220
|
+
|
|
221
|
+
font = cv2.FONT_HERSHEY_SIMPLEX
|
|
222
|
+
for label, xo in [("Source", 0), ("Target", pw), ("Reconstruction", 2 * pw)]:
|
|
223
|
+
cv2.rectangle(canvas, (xo, 0), (xo + pw, label_h), (0, 0, 0), -1)
|
|
224
|
+
cv2.putText(canvas, label, (xo + 6, 16), font, 0.45, (200, 200, 200), 1)
|
|
225
|
+
|
|
226
|
+
wn = "Pixel Rearrangement (ESC/q anytime to quit)"
|
|
227
|
+
cv2.namedWindow(wn, cv2.WINDOW_NORMAL)
|
|
228
|
+
cv2.resizeWindow(wn, sw, sh)
|
|
229
|
+
cv2.imshow(wn, canvas)
|
|
230
|
+
cv2.waitKey(300)
|
|
231
|
+
|
|
232
|
+
# ── True pixel-sliding animation ──
|
|
233
|
+
total = h * w
|
|
234
|
+
forward = np.empty(total, dtype=np.int32)
|
|
235
|
+
forward[s_order] = t_order
|
|
236
|
+
|
|
237
|
+
scx = iw / w
|
|
238
|
+
scy = ih / h
|
|
239
|
+
|
|
240
|
+
s_idx_x = (np.arange(iw, dtype=np.float32) * w / iw).clip(0, w - 1).round().astype(np.int32)
|
|
241
|
+
s_idx_y = (np.arange(ih, dtype=np.float32) * h / ih).clip(0, h - 1).round().astype(np.int32)
|
|
242
|
+
gx, gy = np.meshgrid(s_idx_x, s_idx_y)
|
|
243
|
+
src_lin = gy * w + gx
|
|
244
|
+
|
|
245
|
+
tgt_lin = forward[src_lin]
|
|
246
|
+
tgt_dx = (tgt_lin % w).astype(np.float32) * scx
|
|
247
|
+
tgt_dy = (tgt_lin // w).astype(np.float32) * scy
|
|
248
|
+
|
|
249
|
+
src_dx = gx.astype(np.float32) * scx
|
|
250
|
+
src_dy = gy.astype(np.float32) * scy
|
|
251
|
+
|
|
252
|
+
colors = src_s.reshape(-1, 3).astype(np.float32)
|
|
253
|
+
|
|
254
|
+
num_frames = 60
|
|
255
|
+
for fi in range(num_frames):
|
|
256
|
+
t = (fi + 1) / num_frames
|
|
257
|
+
|
|
258
|
+
curr_x = np.clip((1 - t) * src_dx.ravel() + t * tgt_dx.ravel(), 0, iw - 1)
|
|
259
|
+
curr_y = np.clip((1 - t) * src_dy.ravel() + t * tgt_dy.ravel(), 0, ih - 1)
|
|
260
|
+
rx = np.round(curr_x).astype(np.int32)
|
|
261
|
+
ry = np.round(curr_y).astype(np.int32)
|
|
262
|
+
|
|
263
|
+
accum = np.zeros((ih, iw, 3), dtype=np.float32)
|
|
264
|
+
cnt = np.zeros((ih, iw), dtype=np.float32)
|
|
265
|
+
np.add.at(accum, (ry, rx), colors)
|
|
266
|
+
np.add.at(cnt, (ry, rx), 1.0)
|
|
267
|
+
mask = cnt > 0
|
|
268
|
+
accum[mask] /= cnt[mask, None]
|
|
269
|
+
|
|
270
|
+
rec_region[:] = accum.astype(np.uint8)
|
|
271
|
+
cv2.imshow(wn, canvas)
|
|
272
|
+
if cv2.waitKey(25) & 0xFF in (27, ord("q")):
|
|
273
|
+
break
|
|
274
|
+
else:
|
|
275
|
+
rec_region[:] = cv2.resize(
|
|
276
|
+
img_src.reshape(-1, 3)[s_order][t_order.argsort()].reshape(h, w, 3),
|
|
277
|
+
(iw, ih),
|
|
278
|
+
)
|
|
279
|
+
cv2.imshow(wn, canvas)
|
|
280
|
+
|
|
281
|
+
cv2.waitKey(0)
|
|
282
|
+
cv2.destroyAllWindows()
|
|
283
|
+
|
|
284
|
+
state.status = "Rearrangement finished!"
|
|
285
|
+
state.status_style = "status"
|
|
286
|
+
|
|
287
|
+
except Exception as e:
|
|
288
|
+
state.status = f"Error: {e}"
|
|
289
|
+
state.status_style = "status-error"
|
|
290
|
+
finally:
|
|
291
|
+
state.running = False
|
|
292
|
+
state.done = True
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ── TUI Application ──────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
class PixelTUI:
|
|
298
|
+
def __init__(self):
|
|
299
|
+
self.state = State()
|
|
300
|
+
|
|
301
|
+
self.kb = KeyBindings()
|
|
302
|
+
self._register_bindings()
|
|
303
|
+
|
|
304
|
+
self._app: Application | None = None
|
|
305
|
+
|
|
306
|
+
# ── Key Bindings ─────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
def _register_bindings(self):
|
|
309
|
+
kb = self.kb
|
|
310
|
+
|
|
311
|
+
@kb.add("up")
|
|
312
|
+
def _(event):
|
|
313
|
+
if not self.state.running:
|
|
314
|
+
self.state.cursor = (self.state.cursor - 1) % 4
|
|
315
|
+
self._invalidate()
|
|
316
|
+
|
|
317
|
+
@kb.add("down")
|
|
318
|
+
def _(event):
|
|
319
|
+
if not self.state.running:
|
|
320
|
+
self.state.cursor = (self.state.cursor + 1) % 4
|
|
321
|
+
self._invalidate()
|
|
322
|
+
|
|
323
|
+
@kb.add("enter")
|
|
324
|
+
def _(event):
|
|
325
|
+
if not self.state.running:
|
|
326
|
+
self._dispatch(self.state.cursor)
|
|
327
|
+
|
|
328
|
+
for key, idx in [("1", 0), ("2", 1), ("3", 2), ("4", 3)]:
|
|
329
|
+
@kb.add(key)
|
|
330
|
+
def _(event, idx=idx):
|
|
331
|
+
if not self.state.running:
|
|
332
|
+
self.state.cursor = idx
|
|
333
|
+
self._dispatch(idx)
|
|
334
|
+
|
|
335
|
+
@kb.add("escape")
|
|
336
|
+
@kb.add("q")
|
|
337
|
+
def _(event):
|
|
338
|
+
if not self.state.running:
|
|
339
|
+
self._quit()
|
|
340
|
+
|
|
341
|
+
@kb.add("c-c")
|
|
342
|
+
def _(event):
|
|
343
|
+
self._quit()
|
|
344
|
+
|
|
345
|
+
# ── Dispatch ─────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
def _dispatch(self, idx: int):
|
|
348
|
+
{0: self._select_source,
|
|
349
|
+
1: self._select_target,
|
|
350
|
+
2: self._run,
|
|
351
|
+
3: self._quit}[idx]()
|
|
352
|
+
|
|
353
|
+
# ── Actions ──────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
def _select_source(self):
|
|
356
|
+
path = select_file("Select Source Image (pixels to rearrange)")
|
|
357
|
+
if path:
|
|
358
|
+
self.state.source = path
|
|
359
|
+
self._refresh_info()
|
|
360
|
+
self.state.status = f"Source: {Path(path).name}"
|
|
361
|
+
self.state.status_style = "status"
|
|
362
|
+
else:
|
|
363
|
+
self.state.status = "Selection cancelled"
|
|
364
|
+
self.state.status_style = "status-info"
|
|
365
|
+
self._invalidate()
|
|
366
|
+
|
|
367
|
+
def _select_target(self):
|
|
368
|
+
path = select_file("Select Target Image (layout to approximate)")
|
|
369
|
+
if path:
|
|
370
|
+
self.state.target = path
|
|
371
|
+
self._refresh_info()
|
|
372
|
+
self.state.status = f"Target: {Path(path).name}"
|
|
373
|
+
self.state.status_style = "status"
|
|
374
|
+
else:
|
|
375
|
+
self.state.status = "Selection cancelled"
|
|
376
|
+
self.state.status_style = "status-info"
|
|
377
|
+
self._invalidate()
|
|
378
|
+
|
|
379
|
+
def _run(self):
|
|
380
|
+
if not self.state.source:
|
|
381
|
+
self.state.status = "Select a source image first!"
|
|
382
|
+
self.state.status_style = "status-error"; self._invalidate(); return
|
|
383
|
+
if not self.state.target:
|
|
384
|
+
self.state.status = "Select a target image first!"
|
|
385
|
+
self.state.status_style = "status-error"; self._invalidate(); return
|
|
386
|
+
|
|
387
|
+
self.state.running = True
|
|
388
|
+
self.state.done = False
|
|
389
|
+
self.state.status = "Rearrangement running in OpenCV window…"
|
|
390
|
+
self.state.status_style = "status-warn"
|
|
391
|
+
self._invalidate()
|
|
392
|
+
|
|
393
|
+
t = threading.Thread(
|
|
394
|
+
target=rearrange,
|
|
395
|
+
args=(self.state.source, self.state.target, self.state),
|
|
396
|
+
daemon=True,
|
|
397
|
+
)
|
|
398
|
+
t.start()
|
|
399
|
+
|
|
400
|
+
# Poll thread completion in the background
|
|
401
|
+
async def waiter():
|
|
402
|
+
while t.is_alive():
|
|
403
|
+
await asyncio.sleep(0.5)
|
|
404
|
+
self._invalidate()
|
|
405
|
+
self._invalidate()
|
|
406
|
+
|
|
407
|
+
if self._app:
|
|
408
|
+
asyncio.create_task(waiter())
|
|
409
|
+
|
|
410
|
+
def _refresh_info(self):
|
|
411
|
+
parts = []
|
|
412
|
+
for path in (self.state.source, self.state.target):
|
|
413
|
+
if not path:
|
|
414
|
+
continue
|
|
415
|
+
try:
|
|
416
|
+
img = cv2.imread(path, cv2.IMREAD_COLOR)
|
|
417
|
+
if img is not None:
|
|
418
|
+
h, w = img.shape[:2]
|
|
419
|
+
kb = Path(path).stat().st_size / 1024
|
|
420
|
+
parts.append(f"{Path(path).name} {w}×{h} ({kb:.0f} KB)")
|
|
421
|
+
except Exception:
|
|
422
|
+
parts.append(Path(path).name)
|
|
423
|
+
if self.state.source and self.state.target:
|
|
424
|
+
parts.append("Ready!")
|
|
425
|
+
self.state.info = " | ".join(parts) if parts else ""
|
|
426
|
+
|
|
427
|
+
def _quit(self):
|
|
428
|
+
cv2.destroyAllWindows()
|
|
429
|
+
if self._app:
|
|
430
|
+
self._app.exit()
|
|
431
|
+
sys.exit(0)
|
|
432
|
+
|
|
433
|
+
# ── UI Rendering ─────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
def _build_text(self):
|
|
436
|
+
s = self.state
|
|
437
|
+
F: list[tuple[str, str]] = []
|
|
438
|
+
|
|
439
|
+
def push(style, text):
|
|
440
|
+
if text:
|
|
441
|
+
F.append((style, text))
|
|
442
|
+
|
|
443
|
+
# Header
|
|
444
|
+
push("bold #00d787", " ■ Pixel Rearrangement Tool")
|
|
445
|
+
push("", "\n")
|
|
446
|
+
push("#3a3a3a", " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
|
447
|
+
push("", "\n")
|
|
448
|
+
|
|
449
|
+
# Source / Target paths
|
|
450
|
+
push("bold #5f87ff", "\n Source: ")
|
|
451
|
+
push("#87afff" if s.source else "#585858 italic",
|
|
452
|
+
s.source if s.source else "— not selected —")
|
|
453
|
+
|
|
454
|
+
push("bold #5f87ff", "\n Target: ")
|
|
455
|
+
push("#87afff" if s.target else "#585858 italic",
|
|
456
|
+
s.target if s.target else "— not selected —")
|
|
457
|
+
|
|
458
|
+
# Info line
|
|
459
|
+
if s.info:
|
|
460
|
+
push("", "\n ")
|
|
461
|
+
push("#878787 italic", s.info)
|
|
462
|
+
|
|
463
|
+
# Menu divider
|
|
464
|
+
push("", "\n\n")
|
|
465
|
+
push("#3a3a3a", " ───────────────────────────────────────────────────────────")
|
|
466
|
+
push("", "\n")
|
|
467
|
+
|
|
468
|
+
# Menu items
|
|
469
|
+
for i, (label, desc) in enumerate(State.MENU):
|
|
470
|
+
cursor = "●" if i == s.cursor else "○"
|
|
471
|
+
sel = i == s.cursor
|
|
472
|
+
push("bold #000000 bg:#00d787" if sel else "bold #ffffff",
|
|
473
|
+
f" {cursor} {label} ")
|
|
474
|
+
push("", " ")
|
|
475
|
+
push("#6c6c6c", f"{desc}\n")
|
|
476
|
+
|
|
477
|
+
# Status divider
|
|
478
|
+
push("", "\n")
|
|
479
|
+
push("#3a3a3a", " ───────────────────────────────────────────────────────────")
|
|
480
|
+
push("", "\n ")
|
|
481
|
+
|
|
482
|
+
# Status line
|
|
483
|
+
c = {"status": "#5faf5f", "status-error": "#ff5f5f",
|
|
484
|
+
"status-warn": "#ffaf5f", "status-info": "#878787 italic"}
|
|
485
|
+
push(c.get(s.status_style, "#878787 italic"), s.status)
|
|
486
|
+
push("", "\n")
|
|
487
|
+
|
|
488
|
+
# Help
|
|
489
|
+
push("#585858 italic", "↑↓ navigate • Enter select • 1-4 shortcut • q quit")
|
|
490
|
+
push("", "\n")
|
|
491
|
+
|
|
492
|
+
return F
|
|
493
|
+
|
|
494
|
+
def _build_layout(self):
|
|
495
|
+
control = FormattedTextControl(
|
|
496
|
+
text=self._build_text,
|
|
497
|
+
show_cursor=False,
|
|
498
|
+
)
|
|
499
|
+
return Layout(Window(content=control, dont_extend_height=False))
|
|
500
|
+
|
|
501
|
+
def _invalidate(self):
|
|
502
|
+
try:
|
|
503
|
+
if self._app:
|
|
504
|
+
self._app.invalidate()
|
|
505
|
+
except Exception:
|
|
506
|
+
pass
|
|
507
|
+
|
|
508
|
+
# ── Run ──────────────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
def run(self):
|
|
511
|
+
self._app = Application(
|
|
512
|
+
layout=self._build_layout(),
|
|
513
|
+
key_bindings=self.kb,
|
|
514
|
+
style=STYLE,
|
|
515
|
+
mouse_support=False,
|
|
516
|
+
full_screen=True,
|
|
517
|
+
)
|
|
518
|
+
try:
|
|
519
|
+
self._app.run()
|
|
520
|
+
except KeyboardInterrupt:
|
|
521
|
+
self._quit()
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def main():
|
|
525
|
+
PixelTUI().run()
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# ── Entry Point ──────────────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
if __name__ == "__main__":
|
|
531
|
+
main()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pixelification
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pixel Rearrangement Tool — Keyboard-Navigated Terminal UI
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: numpy>=1.20.0
|
|
7
|
+
Requires-Dist: opencv-python>=4.0.0
|
|
8
|
+
Requires-Dist: prompt-toolkit>=3.0.0
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# Pixelification
|
|
12
|
+
|
|
13
|
+
A terminal tool that rearranges pixels from a source image to approximate a target image using optimal transport via color sorting — then animates each pixel physically sliding to its new position.
|
|
14
|
+
|
|
15
|
+
No new pixels are created. Every pixel in the output comes from the source image, just rearranged.
|
|
16
|
+
|
|
17
|
+
## How it works
|
|
18
|
+
|
|
19
|
+
```mermaid
|
|
20
|
+
flowchart LR
|
|
21
|
+
A[Source Image] --> C[Color sort<br/>lexsort by<br/>lum→hue→sat]
|
|
22
|
+
B[Target Image] --> D[Color sort<br/>lexsort by<br/>lum→hue→sat]
|
|
23
|
+
C --> E["s_order[i]"]
|
|
24
|
+
D --> F["t_order[i]"]
|
|
25
|
+
E --> G["forward[s_order] = t_order"]
|
|
26
|
+
F --> G
|
|
27
|
+
G --> H[Per-pixel<br/>position lerp]
|
|
28
|
+
H --> I[Scatter render<br/>np.add.at]
|
|
29
|
+
I --> J[60 frames<br/>slide animation]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
1. **Sort by colour** — every pixel in both images is sorted by luminance, then hue, then saturation. The darkest source pixel gets rank 0, the lightest gets rank *N*−1. Same for the target.
|
|
33
|
+
|
|
34
|
+
2. **Map by rank** — a source pixel with rank *i* maps to the target position with rank *i*. This is the optimal transport: the *i*th darkest pixel in the source ends up where the *i*th darkest pixel was in the target.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
Source pixels (sorted) Target positions (sorted)
|
|
38
|
+
┌─────────────────────┐ ┌─────────────────────┐
|
|
39
|
+
│ rank 0 (darkest) │──────→│ rank 0 │
|
|
40
|
+
│ rank 1 │──────→│ rank 1 │
|
|
41
|
+
│ rank 2 │──────→│ rank 2 │
|
|
42
|
+
│ ... │ │ ... │
|
|
43
|
+
│ rank N−1 (lightest)│──────→│ rank N−1 │
|
|
44
|
+
└─────────────────────┘ └─────────────────────┘
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
3. **Animate** — each pixel slides from its original position to its mapped position over 60 frames using linear interpolation. All pixels move simultaneously. When multiple pixels land on the same display cell, their colours are averaged.
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
Frame 0 Frame 30 Frame 60
|
|
51
|
+
┌────────┐ ┌────────┐ ┌────────┐
|
|
52
|
+
│• • │ │ •• │ │ •• │
|
|
53
|
+
│ •• │ ───────→ │ • •• │ ───────→ │ •• │
|
|
54
|
+
│ • • │ lerp │ • • │ lerp │ • • │
|
|
55
|
+
└────────┘ └────────┘ └────────┘
|
|
56
|
+
source positions midway target positions
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
python rearrange_pixels_tui.py
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A keyboard-navigated terminal interface opens. Use arrow keys to select images, press Enter to run.
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
↑↓ navigate • Enter select • 1-4 shortcut • q quit
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
An OpenCV window opens with three panels:
|
|
72
|
+
|
|
73
|
+
| Source | Target | Reconstruction |
|
|
74
|
+
|--------|--------|----------------|
|
|
75
|
+
| Your image | Layout to approximate | Pixels sliding into place |
|
|
76
|
+
|
|
77
|
+
Press `ESC` or `q` during the animation to quit.
|
|
78
|
+
|
|
79
|
+
## Requirements
|
|
80
|
+
|
|
81
|
+
- Python 3.10+
|
|
82
|
+
- OpenCV (`cv2`)
|
|
83
|
+
- NumPy
|
|
84
|
+
- `prompt_toolkit`
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pip install opencv-python numpy prompt_toolkit
|
|
88
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
pixelification/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
pixelification/main.py,sha256=qsTp-vgKzQSLnwL1m8uYBrpL_3oWHZjhmEWHSNw4zZc,18748
|
|
3
|
+
pixelification-0.1.0.dist-info/METADATA,sha256=iLbwh5dDEEs0Xd_V4lxtpJ0qSrF8_Q7TIp7IcgIf_hk,3701
|
|
4
|
+
pixelification-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
5
|
+
pixelification-0.1.0.dist-info/entry_points.txt,sha256=ZHzodclL9j9F7sSnTZilwYGQLJCpAeHu-8_ZOABJ-cA,60
|
|
6
|
+
pixelification-0.1.0.dist-info/RECORD,,
|