pixelification 1.2.5__tar.gz → 1.2.8__tar.gz

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.
Files changed (29) hide show
  1. {pixelification-1.2.5 → pixelification-1.2.8}/PKG-INFO +52 -3
  2. {pixelification-1.2.5 → pixelification-1.2.8}/README.md +51 -2
  3. {pixelification-1.2.5 → pixelification-1.2.8}/src/pixelification/main.py +366 -3
  4. {pixelification-1.2.5 → pixelification-1.2.8}/.github/workflows/publish.yml +0 -0
  5. {pixelification-1.2.5 → pixelification-1.2.8}/.gitignore +0 -0
  6. {pixelification-1.2.5 → pixelification-1.2.8}/.python-version +0 -0
  7. {pixelification-1.2.5 → pixelification-1.2.8}/Cargo.lock +0 -0
  8. {pixelification-1.2.5 → pixelification-1.2.8}/Cargo.toml +0 -0
  9. {pixelification-1.2.5 → pixelification-1.2.8}/main.py +0 -0
  10. {pixelification-1.2.5 → pixelification-1.2.8}/pyproject.toml +0 -0
  11. {pixelification-1.2.5 → pixelification-1.2.8}/requirements.txt +0 -0
  12. {pixelification-1.2.5 → pixelification-1.2.8}/src/app/mod.rs +0 -0
  13. {pixelification-1.2.5 → pixelification-1.2.8}/src/config/mod.rs +0 -0
  14. {pixelification-1.2.5 → pixelification-1.2.8}/src/drag/mod.rs +0 -0
  15. {pixelification-1.2.5 → pixelification-1.2.8}/src/events/mod.rs +0 -0
  16. {pixelification-1.2.5 → pixelification-1.2.8}/src/main.rs +0 -0
  17. {pixelification-1.2.5 → pixelification-1.2.8}/src/pixelification/__init__.py +0 -0
  18. {pixelification-1.2.5 → pixelification-1.2.8}/src/pixelification/runtime.py +0 -0
  19. {pixelification-1.2.5 → pixelification-1.2.8}/src/state/mod.rs +0 -0
  20. {pixelification-1.2.5 → pixelification-1.2.8}/src/ui/animated.rs +0 -0
  21. {pixelification-1.2.5 → pixelification-1.2.8}/src/ui/bitmap.rs +0 -0
  22. {pixelification-1.2.5 → pixelification-1.2.8}/src/ui/brush.rs +0 -0
  23. {pixelification-1.2.5 → pixelification-1.2.8}/src/ui/color.rs +0 -0
  24. {pixelification-1.2.5 → pixelification-1.2.8}/src/ui/font.rs +0 -0
  25. {pixelification-1.2.5 → pixelification-1.2.8}/src/ui/mod.rs +0 -0
  26. {pixelification-1.2.5 → pixelification-1.2.8}/src/ui/render.rs +0 -0
  27. {pixelification-1.2.5 → pixelification-1.2.8}/src/webview/mod.rs +0 -0
  28. {pixelification-1.2.5 → pixelification-1.2.8}/src/win32.rs +0 -0
  29. {pixelification-1.2.5 → pixelification-1.2.8}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pixelification
3
- Version: 1.2.5
3
+ Version: 1.2.8
4
4
  Summary: Pixel Rearrangement Tool — Keyboard-Navigated Terminal UI
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: numpy>=1.20.0
@@ -187,9 +187,15 @@ pipx run pixelification
187
187
 
188
188
  ---
189
189
 
190
- ## ◈ Usage
190
+ ## ◈ Usage — TUI
191
191
 
192
- A keyboard-navigated terminal UI opens immediately:
192
+ Launch the interactive terminal UI (no arguments needed):
193
+
194
+ ```bash
195
+ pixelification
196
+ ```
197
+
198
+ A keyboard-navigated menu opens:
193
199
 
194
200
  ```
195
201
  ■ Pixel Rearrangement Tool
@@ -237,6 +243,49 @@ A keyboard-navigated terminal UI opens immediately:
237
243
 
238
244
  ---
239
245
 
246
+ ## ◈ Usage — CLI
247
+
248
+ For scripting, batch processing, or headless environments, use the subcommand interface:
249
+
250
+ ```bash
251
+ pixelification img2img <source> <target> [options]
252
+ pixelification vid2vid <source> <target> [options]
253
+ pixelification img2ascii <image> [options]
254
+ ```
255
+
256
+ Output paths default to auto-named files in the current directory (e.g. `reconstructed_src_from_tgt.png`).
257
+
258
+ ### Commands
259
+
260
+ ```
261
+ pixelification img2img <source> <target>
262
+ Rearrange pixels from a source image to approximate a target image.
263
+
264
+ -o, --output FILE output image path (default: auto-named)
265
+ --show open an OpenCV window with the result
266
+ --cpu force CPU-only mode
267
+
268
+ pixelification vid2vid <source> <target>
269
+ Rearrange every frame of a source (video or still image) to match
270
+ a target video. Prints a progress bar to stderr.
271
+
272
+ -o, --output FILE output video path (default: auto-named)
273
+ --show play the result in an OpenCV window
274
+ --cpu force CPU-only mode
275
+
276
+ pixelification img2ascii <image>
277
+ Convert an image to ASCII art. Prints to stdout and saves to file.
278
+
279
+ -o, --output FILE output text path (default: auto-named)
280
+ -w, --width CHARS output width in characters (default: 120)
281
+ --no-dither disable Floyd-Steinberg dithering
282
+
283
+ pixelification --version / -v
284
+ Print the installed version.
285
+ ```
286
+
287
+ ---
288
+
240
289
  ## ◈ Requirements
241
290
 
242
291
  ```
@@ -175,9 +175,15 @@ pipx run pixelification
175
175
 
176
176
  ---
177
177
 
178
- ## ◈ Usage
178
+ ## ◈ Usage — TUI
179
179
 
180
- A keyboard-navigated terminal UI opens immediately:
180
+ Launch the interactive terminal UI (no arguments needed):
181
+
182
+ ```bash
183
+ pixelification
184
+ ```
185
+
186
+ A keyboard-navigated menu opens:
181
187
 
182
188
  ```
183
189
  ■ Pixel Rearrangement Tool
@@ -225,6 +231,49 @@ A keyboard-navigated terminal UI opens immediately:
225
231
 
226
232
  ---
227
233
 
234
+ ## ◈ Usage — CLI
235
+
236
+ For scripting, batch processing, or headless environments, use the subcommand interface:
237
+
238
+ ```bash
239
+ pixelification img2img <source> <target> [options]
240
+ pixelification vid2vid <source> <target> [options]
241
+ pixelification img2ascii <image> [options]
242
+ ```
243
+
244
+ Output paths default to auto-named files in the current directory (e.g. `reconstructed_src_from_tgt.png`).
245
+
246
+ ### Commands
247
+
248
+ ```
249
+ pixelification img2img <source> <target>
250
+ Rearrange pixels from a source image to approximate a target image.
251
+
252
+ -o, --output FILE output image path (default: auto-named)
253
+ --show open an OpenCV window with the result
254
+ --cpu force CPU-only mode
255
+
256
+ pixelification vid2vid <source> <target>
257
+ Rearrange every frame of a source (video or still image) to match
258
+ a target video. Prints a progress bar to stderr.
259
+
260
+ -o, --output FILE output video path (default: auto-named)
261
+ --show play the result in an OpenCV window
262
+ --cpu force CPU-only mode
263
+
264
+ pixelification img2ascii <image>
265
+ Convert an image to ASCII art. Prints to stdout and saves to file.
266
+
267
+ -o, --output FILE output text path (default: auto-named)
268
+ -w, --width CHARS output width in characters (default: 120)
269
+ --no-dither disable Floyd-Steinberg dithering
270
+
271
+ pixelification --version / -v
272
+ Print the installed version.
273
+ ```
274
+
275
+ ---
276
+
228
277
  ## ◈ Requirements
229
278
 
230
279
  ```
@@ -5,11 +5,13 @@ Rearrange image pixels or video frames via colour-sort optimal transport.
5
5
  OpenCV window shows the result (animation for images, playback for video).
6
6
  """
7
7
 
8
+ import argparse
8
9
  import asyncio
9
10
  import cv2
10
11
  import numpy as np
11
12
  import os
12
13
  import shutil
14
+ import signal
13
15
  import subprocess
14
16
  import sys
15
17
  import tempfile
@@ -27,6 +29,11 @@ from pixelification.runtime import RuntimeConfig, load_or_create_runtime_config
27
29
  # ── GPU & Accelerator Support ────────────────────────────────────────
28
30
 
29
31
  FORCE_CPU = False
32
+ _RUNNING = True
33
+
34
+ def _sigint_handler(signum, frame):
35
+ global _RUNNING
36
+ _RUNNING = False
30
37
 
31
38
  try:
32
39
  import cupy as cp
@@ -120,6 +127,7 @@ ASCII_ART = [
120
127
  ]
121
128
 
122
129
  _IMAGE_EXTS = frozenset({'.png', '.jpg', '.jpeg', '.bmp', '.tiff', '.gif', '.webp'})
130
+ _VIDEO_EXTS = frozenset({'.mp4', '.avi', '.mov', '.mkv', '.webm'})
123
131
 
124
132
 
125
133
  # ── Native File Dialog ───────────────────────────────────────────────
@@ -279,6 +287,31 @@ def compute_sort_keys(img, xp=np):
279
287
  return lum, hsv[:, 0], hsv[:, 1]
280
288
 
281
289
 
290
+ def _compute_rearrangement(source_path: str, target_path: str) -> np.ndarray:
291
+ img_src = cv2.imread(source_path, cv2.IMREAD_COLOR)
292
+ img_tgt = cv2.imread(target_path, cv2.IMREAD_COLOR)
293
+ if img_src is None:
294
+ raise FileNotFoundError(f"Cannot read source: {source_path}")
295
+ if img_tgt is None:
296
+ raise FileNotFoundError(f"Cannot read target: {target_path}")
297
+
298
+ h, w = img_src.shape[:2]
299
+ img_tgt = cv2.resize(img_tgt, (w, h))
300
+
301
+ xp = get_xp()
302
+ s_l, s_h, s_s = compute_sort_keys(img_src, xp)
303
+ t_l, t_h, t_s = compute_sort_keys(img_tgt, xp)
304
+ s_order = xp_lexsort((s_s, s_h, s_l), xp)
305
+ t_order = xp_lexsort((t_s, t_h, t_l), xp)
306
+
307
+ out_flat = xp.empty_like(xp.array(img_src.reshape(-1, 3)), dtype=xp.uint8)
308
+ out_flat[t_order] = xp.array(img_src.reshape(-1, 3))[s_order]
309
+ out_img = out_flat.reshape(h, w, 3)
310
+ out_img = to_np(out_img)
311
+
312
+ return out_img
313
+
314
+
282
315
  def get_screen_resolution():
283
316
  try:
284
317
  import ctypes
@@ -461,6 +494,120 @@ def letterbox_pad(img, target_w, target_h):
461
494
  return canvas
462
495
 
463
496
 
497
+ def _compute_video_rearrangement(
498
+ source_path: str,
499
+ target_path: str,
500
+ progress_callback=None,
501
+ ) -> str:
502
+ src_is_img = Path(source_path).suffix.lower() in _IMAGE_EXTS
503
+
504
+ if src_is_img:
505
+ img_src = cv2.imread(source_path, cv2.IMREAD_COLOR)
506
+ if img_src is None:
507
+ raise FileNotFoundError(f"Cannot read source image: {source_path}")
508
+ h_src, w_src = img_src.shape[:2]
509
+ cap_tgt = cv2.VideoCapture(target_path)
510
+ if not cap_tgt.isOpened():
511
+ raise FileNotFoundError(f"Cannot open target video: {target_path}")
512
+ total_tgt = int(cap_tgt.get(cv2.CAP_PROP_FRAME_COUNT))
513
+ total = total_tgt
514
+ if total == 0:
515
+ raise ValueError("Target video has no frames")
516
+ fps = cap_tgt.get(cv2.CAP_PROP_FPS)
517
+ else:
518
+ cap_src = cv2.VideoCapture(source_path)
519
+ cap_tgt = cv2.VideoCapture(target_path)
520
+ if not cap_src.isOpened():
521
+ raise FileNotFoundError(f"Cannot open source video: {source_path}")
522
+ if not cap_tgt.isOpened():
523
+ raise FileNotFoundError(f"Cannot open target video: {target_path}")
524
+ total_src = int(cap_src.get(cv2.CAP_PROP_FRAME_COUNT))
525
+ total_tgt = int(cap_tgt.get(cv2.CAP_PROP_FRAME_COUNT))
526
+ total = min(total_src, total_tgt)
527
+ if total == 0:
528
+ raise ValueError("One or both videos have no frames")
529
+ fps = cap_src.get(cv2.CAP_PROP_FPS)
530
+ h_src = int(cap_src.get(cv2.CAP_PROP_FRAME_WIDTH))
531
+ w_src = int(cap_src.get(cv2.CAP_PROP_FRAME_HEIGHT))
532
+
533
+ w_tgt = int(cap_tgt.get(cv2.CAP_PROP_FRAME_WIDTH))
534
+ h_tgt = int(cap_tgt.get(cv2.CAP_PROP_FRAME_HEIGHT))
535
+
536
+ if w_src == 0 or h_src == 0:
537
+ raise ValueError(f"source is not a valid video file (0x0 dimensions)")
538
+ if w_tgt == 0 or h_tgt == 0:
539
+ raise ValueError(f"target is not a valid video file (0x0 dimensions)")
540
+
541
+ ar_src = w_src / h_src
542
+ ar_tgt = w_tgt / h_tgt
543
+ ar_diff = abs(ar_src - ar_tgt) > 0.01
544
+
545
+ if ar_diff:
546
+ if ar_src >= ar_tgt:
547
+ out_w, out_h = w_src, h_src
548
+ pad_src, pad_tgt = False, True
549
+ else:
550
+ out_w, out_h = w_tgt, h_tgt
551
+ pad_src, pad_tgt = True, False
552
+ else:
553
+ out_w, out_h = w_src, h_src
554
+ pad_src, pad_tgt = False, False
555
+
556
+ fd, tmp_path = tempfile.mkstemp(suffix=".mp4")
557
+ os.close(fd)
558
+
559
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
560
+ writer = cv2.VideoWriter(tmp_path, fourcc, fps, (out_w, out_h))
561
+
562
+ xp = get_xp()
563
+
564
+ for i in range(total):
565
+ if not _RUNNING:
566
+ raise KeyboardInterrupt()
567
+
568
+ if src_is_img:
569
+ src_frame = img_src.copy()
570
+ else:
571
+ ret_src, src_frame = cap_src.read()
572
+ if not ret_src:
573
+ break
574
+
575
+ ret_tgt, tgt_frame = cap_tgt.read()
576
+ if not ret_tgt:
577
+ break
578
+
579
+ if pad_src:
580
+ src_frame = letterbox_pad(src_frame, out_w, out_h)
581
+ if pad_tgt:
582
+ tgt_frame = letterbox_pad(tgt_frame, out_w, out_h)
583
+ if not pad_src and not pad_tgt and tgt_frame.shape[:2] != src_frame.shape[:2]:
584
+ tgt_frame = cv2.resize(tgt_frame, (out_w, out_h))
585
+
586
+ s_l, s_h, s_s = compute_sort_keys(src_frame, xp)
587
+ t_l, t_h, t_s = compute_sort_keys(tgt_frame, xp)
588
+ s_order = xp_lexsort((s_s, s_h, s_l), xp)
589
+ t_order = xp_lexsort((t_s, t_h, t_l), xp)
590
+
591
+ src_flat = xp.array(src_frame.reshape(-1, 3))
592
+ out_flat = xp.empty_like(src_flat, dtype=xp.uint8)
593
+ out_flat[t_order] = src_flat[s_order]
594
+ out_frame = out_flat.reshape(out_h, out_w, 3)
595
+
596
+ out_frame = to_np(out_frame)
597
+
598
+ writer.write(out_frame)
599
+
600
+ if progress_callback:
601
+ progress_callback(i + 1, total)
602
+
603
+ if not src_is_img:
604
+ cap_src.release()
605
+ cap_tgt.release()
606
+ writer.release()
607
+
608
+ return tmp_path
609
+
610
+
464
611
  def rearrange_video(source_path: str, target_path: str, state: State) -> None:
465
612
  try:
466
613
  src_is_img = Path(source_path).suffix.lower() in _IMAGE_EXTS
@@ -692,6 +839,157 @@ def image_to_ascii(path: str, width: int = 120, dither: bool = True) -> str:
692
839
  return "\n".join(lines)
693
840
 
694
841
 
842
+ # ── CLI Handlers ─────────────────────────────────────────────────────
843
+
844
+ def _progress_bar(current, total, bar_len=20):
845
+ filled = int(bar_len * current / total)
846
+ bar = "\u2588" * filled + "\u2591" * (bar_len - filled)
847
+ print(f"\r [{bar}] {current}/{total} ({100*current/total:.0f}%)", end="", file=sys.stderr)
848
+ if current == total:
849
+ print(file=sys.stderr)
850
+
851
+
852
+ def _cli_img2img(args):
853
+ try:
854
+ if args.cpu:
855
+ global FORCE_CPU; FORCE_CPU = True
856
+
857
+ src_ext = Path(args.source).suffix.lower()
858
+ tgt_ext = Path(args.target).suffix.lower()
859
+ if src_ext not in _IMAGE_EXTS:
860
+ print(f"Error: source must be an image ({', '.join(sorted(_IMAGE_EXTS))})", file=sys.stderr)
861
+ sys.exit(1)
862
+ if tgt_ext not in _IMAGE_EXTS:
863
+ print(f"Error: target must be an image ({', '.join(sorted(_IMAGE_EXTS))})", file=sys.stderr)
864
+ sys.exit(1)
865
+
866
+ out_path = args.output
867
+ if not out_path:
868
+ src_stem = Path(args.source).stem
869
+ tgt_stem = Path(args.target).stem
870
+ out_path = f"reconstructed_{src_stem}_from_{tgt_stem}.png"
871
+
872
+ result = _compute_rearrangement(args.source, args.target)
873
+ cv2.imwrite(out_path, result)
874
+ print(out_path)
875
+
876
+ if args.show:
877
+ img_src = cv2.imread(args.source, cv2.IMREAD_COLOR)
878
+ img_tgt = cv2.imread(args.target, cv2.IMREAD_COLOR)
879
+ h, w = img_src.shape[:2]
880
+ img_tgt = cv2.resize(img_tgt, (w, h))
881
+ sw, sh = get_screen_resolution()
882
+ canvas = np.full((sh, sw, 3), 32, dtype=np.uint8)
883
+ label_h = 22
884
+ pw = sw // 3
885
+ ph = sh - label_h
886
+ sc = min(pw / w, ph / h)
887
+ iw, ih = max(int(w * sc), 1), max(int(h * sc), 1)
888
+ src_s = cv2.resize(img_src, (iw, ih), interpolation=cv2.INTER_LANCZOS4)
889
+ tgt_s = cv2.resize(img_tgt, (iw, ih), interpolation=cv2.INTER_LANCZOS4)
890
+ cx = (pw - iw) // 2
891
+ cy = label_h + (ph - ih) // 2
892
+ canvas[cy:cy+ih, cx:cx+iw] = src_s
893
+ canvas[cy:cy+ih, pw+cx:pw+cx+iw] = tgt_s
894
+ rec_x = 2 * pw + cx
895
+ rec_region = canvas[cy:cy+ih, rec_x:rec_x+iw]
896
+ font = cv2.FONT_HERSHEY_SIMPLEX
897
+ for label, xo in [("Source", 0), ("Target", pw), ("Reconstruction", 2 * pw)]:
898
+ cv2.rectangle(canvas, (xo, 0), (xo + pw, label_h), (0, 0, 0), -1)
899
+ cv2.putText(canvas, label, (xo + 6, 16), font, 0.45, (200, 200, 200), 1)
900
+ wn = "Pixel Rearrangement (ESC/q anytime to quit)"
901
+ cv2.namedWindow(wn, cv2.WINDOW_NORMAL)
902
+ cv2.resizeWindow(wn, sw, sh)
903
+ rec_region[:] = cv2.resize(result, (iw, ih))
904
+ cv2.imshow(wn, canvas)
905
+ while True:
906
+ key = cv2.waitKey(100) & 0xFF
907
+ if key in (27, ord("q")):
908
+ break
909
+ cv2.destroyAllWindows()
910
+ except Exception as e:
911
+ print(f"Error: {e}", file=sys.stderr)
912
+ sys.exit(1)
913
+
914
+
915
+ def _cli_vid2vid(args):
916
+ try:
917
+ if args.cpu:
918
+ global FORCE_CPU; FORCE_CPU = True
919
+
920
+ src_ext = Path(args.source).suffix.lower()
921
+ tgt_ext = Path(args.target).suffix.lower()
922
+ if src_ext not in _IMAGE_EXTS and src_ext not in _VIDEO_EXTS:
923
+ print(f"Error: source must be an image or video ({', '.join(sorted(_IMAGE_EXTS | _VIDEO_EXTS))})", file=sys.stderr)
924
+ sys.exit(1)
925
+ if tgt_ext not in _VIDEO_EXTS:
926
+ print(f"Error: target must be a video ({', '.join(sorted(_VIDEO_EXTS))})", file=sys.stderr)
927
+ sys.exit(1)
928
+
929
+ def progress(cur, total):
930
+ _progress_bar(cur, total)
931
+
932
+ result_path = _compute_video_rearrangement(args.source, args.target, progress)
933
+
934
+ out_path = args.output
935
+ if not out_path:
936
+ src_stem = Path(args.source).stem
937
+ tgt_stem = Path(args.target).stem
938
+ out_path = f"rearranged_{src_stem}_from_{tgt_stem}.mp4"
939
+
940
+ shutil.copy2(result_path, out_path)
941
+ print(out_path)
942
+
943
+ try:
944
+ os.unlink(result_path)
945
+ except Exception:
946
+ pass
947
+
948
+ if args.show:
949
+ cap = cv2.VideoCapture(out_path)
950
+ wn = "Video Rearrangement Result (ESC/q anytime to quit)"
951
+ cv2.namedWindow(wn, cv2.WINDOW_NORMAL)
952
+ cv2.resizeWindow(wn, 1280, 720)
953
+ while cv2.getWindowProperty(wn, cv2.WND_PROP_VISIBLE) >= 1:
954
+ ret, frame = cap.read()
955
+ if not ret:
956
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
957
+ continue
958
+ cv2.imshow(wn, frame)
959
+ key = cv2.waitKey(30) & 0xFF
960
+ if key in (27, ord("q")):
961
+ break
962
+ cap.release()
963
+ cv2.destroyAllWindows()
964
+ except Exception as e:
965
+ print(f"Error: {e}", file=sys.stderr)
966
+ sys.exit(1)
967
+
968
+
969
+ def _cli_img2ascii(args):
970
+ try:
971
+ ext = Path(args.image).suffix.lower()
972
+ if ext not in _IMAGE_EXTS:
973
+ print(f"Error: input must be an image ({', '.join(sorted(_IMAGE_EXTS))})", file=sys.stderr)
974
+ sys.exit(1)
975
+
976
+ ascii_str = image_to_ascii(args.image, args.width, not args.no_dither)
977
+ if not ascii_str:
978
+ print("Error: could not read image", file=sys.stderr)
979
+ sys.exit(1)
980
+
981
+ print(ascii_str)
982
+
983
+ out_path = args.output
984
+ if not out_path:
985
+ stem = Path(args.image).stem
986
+ out_path = f"{stem}_ascii.txt"
987
+ Path(out_path).write_text(ascii_str, encoding="utf-8")
988
+ except Exception as e:
989
+ print(f"Error: {e}", file=sys.stderr)
990
+ sys.exit(1)
991
+
992
+
695
993
  # ── TUI Application ──────────────────────────────────────────────────
696
994
 
697
995
  class PixelTUI:
@@ -1312,14 +1610,79 @@ class PixelTUI:
1312
1610
 
1313
1611
 
1314
1612
  def main():
1315
- if "--version" in sys.argv or "-v" in sys.argv:
1613
+ parser = argparse.ArgumentParser(
1614
+ prog="pixelification",
1615
+ description="Rearrange image pixels via colour-sort optimal transport.",
1616
+ )
1617
+ parser.add_argument("--version", "-v", action="store_true", help="print version")
1618
+
1619
+ _orig_error = parser.error
1620
+ def _parser_error(msg):
1621
+ if "invalid choice" in msg:
1622
+ _orig_error(
1623
+ "missing subcommand.\n\n"
1624
+ " Usage: pixelification <command> [arguments]\n\n"
1625
+ " Commands:\n"
1626
+ " img2img <source> <target> Rearrange pixels between two images\n"
1627
+ " vid2vid <source> <target> Rearrange frames between two videos\n"
1628
+ " img2ascii <image> Convert an image to ASCII art\n"
1629
+ " help Show this help message"
1630
+ )
1631
+ _orig_error(msg)
1632
+ parser.error = _parser_error
1633
+
1634
+ sub = parser.add_subparsers(dest="command", metavar="")
1635
+
1636
+ p_img2img = sub.add_parser("img2img", help="rearrange pixels between two images")
1637
+ p_img2img.add_argument("source", help="source image path")
1638
+ p_img2img.add_argument("target", help="target image path")
1639
+ p_img2img.add_argument("-o", "--output", help="output image path (default: auto-named)")
1640
+ p_img2img.add_argument("--show", action="store_true", help="show the result in an OpenCV window")
1641
+ p_img2img.add_argument("--cpu", action="store_true", help="force CPU mode")
1642
+
1643
+ p_vid2vid = sub.add_parser("vid2vid", help="rearrange frames between two videos (or image->video)")
1644
+ p_vid2vid.add_argument("source", help="source video or image path")
1645
+ p_vid2vid.add_argument("target", help="target video path")
1646
+ p_vid2vid.add_argument("-o", "--output", help="output video path (default: auto-named)")
1647
+ p_vid2vid.add_argument("--show", action="store_true", help="play the result in an OpenCV window")
1648
+ p_vid2vid.add_argument("--cpu", action="store_true", help="force CPU mode")
1649
+
1650
+ p_ascii = sub.add_parser("img2ascii", help="convert an image to ASCII art")
1651
+ p_ascii.add_argument("image", help="source image path")
1652
+ p_ascii.add_argument("-o", "--output", help="output text path (default: auto-named)")
1653
+ p_ascii.add_argument("-w", "--width", type=int, default=120, help="ASCII output width in characters (default: 120)")
1654
+ p_ascii.add_argument("--no-dither", action="store_true", help="disable Floyd-Steinberg dithering")
1655
+
1656
+ p_help = sub.add_parser("help", help="show this help message and exit")
1657
+
1658
+ args = parser.parse_args()
1659
+
1660
+ if args.version:
1316
1661
  try:
1317
1662
  print(f"pixelification {version('pixelification')}")
1318
1663
  except PackageNotFoundError:
1319
1664
  print("pixelification (local development)")
1320
1665
  return
1321
- runtime_config = load_or_create_runtime_config(HAS_CUPY)
1322
- PixelTUI(runtime_config).run()
1666
+
1667
+ if not args.command:
1668
+ runtime_config = load_or_create_runtime_config(HAS_CUPY)
1669
+ PixelTUI(runtime_config).run()
1670
+ return
1671
+
1672
+ handlers = {
1673
+ "img2img": _cli_img2img,
1674
+ "vid2vid": _cli_vid2vid,
1675
+ "img2ascii": _cli_img2ascii,
1676
+ "help": lambda _: parser.print_help(),
1677
+ }
1678
+ signal.signal(signal.SIGINT, _sigint_handler)
1679
+
1680
+ try:
1681
+ handlers[args.command](args)
1682
+ except KeyboardInterrupt:
1683
+ print(file=sys.stderr)
1684
+ print("Interrupted.", file=sys.stderr)
1685
+ os._exit(1)
1323
1686
 
1324
1687
 
1325
1688
  # ── Entry Point ──────────────────────────────────────────────────────
File without changes
File without changes