apftool 1.1.2__tar.gz → 1.2.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apftool
3
- Version: 1.1.2
3
+ Version: 1.2.0
4
4
  Summary: Encoder and decoder for Aperture Science Image Format (apf), Aperture Science Image Format 2 (apf2), Wireless Bitmap (wbmp), Over-The-Air Bitmap (otb), and FaceDev's .bruh format
5
5
  Author: Mari (kbity)
6
6
  Requires-Python: >=3.10
@@ -40,7 +40,9 @@ bruh is an intentionally terrible format made by facedev. info can be found [her
40
40
 
41
41
  `apf.encode(img: bytes | Image.Image, lineskip: int = 1, findbestlineskip: bool = False, returnbytes: bool = False)` takes image bytes or pil image object and outputs apf string. lineskip is interleave value, findbestlineskip brute-forces different interleave values to the possible max of 199 and uses the smallest one. returnbytes makes it return bytes instead of a string.
42
42
 
43
- `apf2.encode(img: bytes | Image.Image, lineskip: int = 1, findbestlineskip: bool = False, legacy: bool = False, trans = False, pal: int = 95, desc: str = "", prepalette: str = None, dodithering: bool = False, returnbytes: bool = False, compress: bool = True, topside_first: bool = False, emit_redundant_flag: bool = False, mode: int = None, combine: bool = False, avoid_run_breaks: bool = False)` takes image bytes or pil image object and outputs apf2 as a string or ascii bytes. lineskip is interleave value, findbestlineskip brute-forces different interleave values to the provided lineskip and uses the smallest one. legacy uses apf1-style 2 color data instead of a 95 color palette. trans sets transparency mode, with mode 0 being off, mode 1 being index 0 transparency (GIF-like, it overrides a color), and mode 2 being indexed alpha. pal allows you to manually force a smaller or larger palette anything over 95 will use APF2-1994's Dual Indexed Mode. Prepalette is an already encoded APF2 palette you input into the encoder to make the colors deterministic. dodithering enables or disables dithering. returnbytes makes it return bytes instead of a string. These options use the APF2-2000 mode: compress - disable RLE compression, allows for smaller file sizes where RLE is not beneficial. Topside_first makes the image scan top-to-bottom, like most formats. emit_redundant_flag - adds the implicit i flag explicitally to baseline 95 color images. mode - selects apf2 coding mode [0: 2 colors, 1: 95 colors, 2: 9025 colors, 3: near-truecolor, 4: near-truecolor with alpha, 5: truecolor, 6: truecolor with alpha, 7: quantized grayscale (based on 95 color mode), 8: 8-bit grayscale (based on 9025 color mode)], combine attempts to do cross-frame delta coding. avoid_run_breaks reduces the amount of transparency pixels emitted by combine, typically harms efficiency
43
+ `apf2.encode(img: bytes | Image.Image, lineskip: int = 1, findbestlineskip: bool = False, legacy: bool = False, trans = False, pal: int = 95, desc: str = "", prepalette: str = None, dodithering: bool = False, returnbytes: bool = False, compress: bool = True, topside_first: bool = False, emit_redundant_flag: bool = False, mode: int = None, combine: bool = False, avoid_run_breaks: bool = False, width: int = None, height: int = None)`
44
+
45
+ takes image bytes or pil image object and outputs apf2 as a string or ascii bytes. lineskip is interleave value, findbestlineskip brute-forces different interleave values to the provided lineskip and uses the smallest one. legacy uses apf1-style 2 color data instead of a 95 color palette. trans sets transparency mode, with mode 0 being off, mode 1 being index 0 transparency (GIF-like, it overrides a color), and mode 2 being indexed alpha. pal allows you to manually force a smaller or larger palette anything over 95 will use APF2-1994's Dual Indexed Mode. Prepalette is an already encoded APF2 palette you input into the encoder to make the colors deterministic. dodithering enables or disables dithering. returnbytes makes it return bytes instead of a string. These options use the APF2-2000 mode: compress - disable RLE compression, allows for smaller file sizes where RLE is not beneficial. Topside_first makes the image scan top-to-bottom, like most formats. emit_redundant_flag - adds the implicit i flag explicitally to baseline 95 color images. mode - selects apf2 coding mode [0: 2 colors, 1: 95 colors, 2: 9025 colors, 3: near-truecolor, 4: near-truecolor with alpha, 5: truecolor, 6: truecolor with alpha, 7: quantized grayscale (based on 95 color mode), 8: 8-bit grayscale (based on 9025 color mode)], combine attempts to do cross-frame delta coding. avoid_run_breaks reduces the amount of transparency pixels emitted by combine, typically harms efficiency. width and height force the output resolution of the image. if both are specified, the output is streched to the specified resolution. if only 1 is present, the other is determined based on aspect ratio. If unspecfied, no scaling happens.
44
46
 
45
47
  `wbmp.encode(img: Image)` takes pil image object and outputs wbmp bytes
46
48
 
@@ -88,14 +90,20 @@ apftool provides many extensions tuples:
88
90
 
89
91
  `extensions_all` - all supported apftool extensions, useful for universal decoder
90
92
 
93
+ `version` - reports apftool version
94
+
91
95
  ## dependancies
92
96
 
93
97
  `PIL (pillow)` is required
94
98
 
95
- `numpy` and `scikit-learn` are needed for high-speed APF2-1994 257+ Color Encoding, if missing the code will use a very slow fallback.
99
+ `numpy` and `scipy` are needed for high-quality, high-speed APF2 encoding, if missing the code will use a very slow and low quality fallback.
96
100
 
97
101
  ## changelog:
98
102
 
103
+ 1.2.0 - increase apfcli verbosity, add lossy encoding options. Allow apfcli to take its own formats as inputs and outputs, also adds a much better quantization algorithm and removes the limitations on indexed animations.
104
+
105
+ 1.1.3 - add version field, add width and height options to apf2 encoding
106
+
99
107
  1.1.2 - fix apftool
100
108
 
101
109
  1.1.1 - fix A2K's q and n modes (~ should map to 255 in those modes)
@@ -29,7 +29,9 @@ bruh is an intentionally terrible format made by facedev. info can be found [her
29
29
 
30
30
  `apf.encode(img: bytes | Image.Image, lineskip: int = 1, findbestlineskip: bool = False, returnbytes: bool = False)` takes image bytes or pil image object and outputs apf string. lineskip is interleave value, findbestlineskip brute-forces different interleave values to the possible max of 199 and uses the smallest one. returnbytes makes it return bytes instead of a string.
31
31
 
32
- `apf2.encode(img: bytes | Image.Image, lineskip: int = 1, findbestlineskip: bool = False, legacy: bool = False, trans = False, pal: int = 95, desc: str = "", prepalette: str = None, dodithering: bool = False, returnbytes: bool = False, compress: bool = True, topside_first: bool = False, emit_redundant_flag: bool = False, mode: int = None, combine: bool = False, avoid_run_breaks: bool = False)` takes image bytes or pil image object and outputs apf2 as a string or ascii bytes. lineskip is interleave value, findbestlineskip brute-forces different interleave values to the provided lineskip and uses the smallest one. legacy uses apf1-style 2 color data instead of a 95 color palette. trans sets transparency mode, with mode 0 being off, mode 1 being index 0 transparency (GIF-like, it overrides a color), and mode 2 being indexed alpha. pal allows you to manually force a smaller or larger palette anything over 95 will use APF2-1994's Dual Indexed Mode. Prepalette is an already encoded APF2 palette you input into the encoder to make the colors deterministic. dodithering enables or disables dithering. returnbytes makes it return bytes instead of a string. These options use the APF2-2000 mode: compress - disable RLE compression, allows for smaller file sizes where RLE is not beneficial. Topside_first makes the image scan top-to-bottom, like most formats. emit_redundant_flag - adds the implicit i flag explicitally to baseline 95 color images. mode - selects apf2 coding mode [0: 2 colors, 1: 95 colors, 2: 9025 colors, 3: near-truecolor, 4: near-truecolor with alpha, 5: truecolor, 6: truecolor with alpha, 7: quantized grayscale (based on 95 color mode), 8: 8-bit grayscale (based on 9025 color mode)], combine attempts to do cross-frame delta coding. avoid_run_breaks reduces the amount of transparency pixels emitted by combine, typically harms efficiency
32
+ `apf2.encode(img: bytes | Image.Image, lineskip: int = 1, findbestlineskip: bool = False, legacy: bool = False, trans = False, pal: int = 95, desc: str = "", prepalette: str = None, dodithering: bool = False, returnbytes: bool = False, compress: bool = True, topside_first: bool = False, emit_redundant_flag: bool = False, mode: int = None, combine: bool = False, avoid_run_breaks: bool = False, width: int = None, height: int = None)`
33
+
34
+ takes image bytes or pil image object and outputs apf2 as a string or ascii bytes. lineskip is interleave value, findbestlineskip brute-forces different interleave values to the provided lineskip and uses the smallest one. legacy uses apf1-style 2 color data instead of a 95 color palette. trans sets transparency mode, with mode 0 being off, mode 1 being index 0 transparency (GIF-like, it overrides a color), and mode 2 being indexed alpha. pal allows you to manually force a smaller or larger palette anything over 95 will use APF2-1994's Dual Indexed Mode. Prepalette is an already encoded APF2 palette you input into the encoder to make the colors deterministic. dodithering enables or disables dithering. returnbytes makes it return bytes instead of a string. These options use the APF2-2000 mode: compress - disable RLE compression, allows for smaller file sizes where RLE is not beneficial. Topside_first makes the image scan top-to-bottom, like most formats. emit_redundant_flag - adds the implicit i flag explicitally to baseline 95 color images. mode - selects apf2 coding mode [0: 2 colors, 1: 95 colors, 2: 9025 colors, 3: near-truecolor, 4: near-truecolor with alpha, 5: truecolor, 6: truecolor with alpha, 7: quantized grayscale (based on 95 color mode), 8: 8-bit grayscale (based on 9025 color mode)], combine attempts to do cross-frame delta coding. avoid_run_breaks reduces the amount of transparency pixels emitted by combine, typically harms efficiency. width and height force the output resolution of the image. if both are specified, the output is streched to the specified resolution. if only 1 is present, the other is determined based on aspect ratio. If unspecfied, no scaling happens.
33
35
 
34
36
  `wbmp.encode(img: Image)` takes pil image object and outputs wbmp bytes
35
37
 
@@ -77,14 +79,20 @@ apftool provides many extensions tuples:
77
79
 
78
80
  `extensions_all` - all supported apftool extensions, useful for universal decoder
79
81
 
82
+ `version` - reports apftool version
83
+
80
84
  ## dependancies
81
85
 
82
86
  `PIL (pillow)` is required
83
87
 
84
- `numpy` and `scikit-learn` are needed for high-speed APF2-1994 257+ Color Encoding, if missing the code will use a very slow fallback.
88
+ `numpy` and `scipy` are needed for high-quality, high-speed APF2 encoding, if missing the code will use a very slow and low quality fallback.
85
89
 
86
90
  ## changelog:
87
91
 
92
+ 1.2.0 - increase apfcli verbosity, add lossy encoding options. Allow apfcli to take its own formats as inputs and outputs, also adds a much better quantization algorithm and removes the limitations on indexed animations.
93
+
94
+ 1.1.3 - add version field, add width and height options to apf2 encoding
95
+
88
96
  1.1.2 - fix apftool
89
97
 
90
98
  1.1.1 - fix A2K's q and n modes (~ should map to 255 in those modes)
@@ -13,6 +13,8 @@ extensions_mqif = (".mqif", ".mqi")
13
13
 
14
14
  extensions_txt = (".txt", ".text") # txt is seen as a generic container for apf/apf2 because they're just txt files
15
15
 
16
+ version = "1.2.0"
17
+
16
18
  extensions_all = []
17
19
  extensions_all.extend(extensions_apf+extensions_apf2+extensions_wbmp+extensions_otb+extensions_bruh+extensions_mqif)
18
20
 
@@ -1,17 +1,16 @@
1
1
  from PIL import Image, ImageSequence
2
2
  from collections import Counter
3
- import io, textwrap
3
+ import io, textwrap, math
4
4
 
5
5
  qmf = "basic"
6
6
 
7
7
  # try numpy and sklearn
8
8
  try:
9
9
  import numpy as np
10
- from sklearn.cluster import MiniBatchKMeans
11
- from sklearn.metrics import pairwise_distances_argmin
10
+ from .apf2quant import quantize_perceptual
12
11
  qmf = "sk"
13
12
  except Exception as e:
14
- print(f"Importing sklearn or numpy failed, high-color images and images with alpha may encode slowly or not at all, and dithering will be unavailable for them!\n{e}")
13
+ print(f"Importing scipy or numpy failed, high-color images and images with alpha may encode slowly or not at all, dithering will be unavailable for them, and quality will be worse!\n{e}")
15
14
 
16
15
  # width and height are 320x200 for standard apf files
17
16
  apf2headertext = "APERTURE IMAGE FORMAT (c) 1993" # apf2 header
@@ -27,73 +26,8 @@ mapping = []
27
26
  for i in range(256):
28
27
  mapping.append(chr(quant_to_base95(i)+32))
29
28
 
30
- # fast sklearn version
31
- def quantize_most_frequent_sk(img: Image.Image, n_colors: int, palette: list = None, dither: bool = False):
32
- img = img.convert("RGBA")
33
- arr = np.array(img, dtype=np.uint8)
34
- flat = arr.reshape(-1, 4).astype(np.float32)
35
-
36
- if palette is not None:
37
- # ---- FIXED PALETTE MODE ----
38
- palette_arr = np.array(palette, dtype=np.uint8)
39
- else:
40
- # ---- KMEANS MODE ----
41
- kmeans = MiniBatchKMeans(
42
- n_clusters=n_colors,
43
- batch_size=10_000,
44
- n_init="auto",
45
- random_state=0
46
- )
47
- kmeans.fit(flat)
48
- palette_arr = kmeans.cluster_centers_.astype(np.uint8)
49
-
50
- if dither:
51
- # Floyd-Steinberg dithering — operate on a float copy to accumulate error
52
- h, w = arr.shape[:2]
53
- buf = arr.astype(np.float32) # (H, W, 4)
54
-
55
- out_indices = np.empty((h, w), dtype=np.int32)
56
-
57
- for y in range(h):
58
- # Serpentine scan (alternating direction) reduces directional bias
59
- xs = range(w) if y % 2 == 0 else range(w - 1, -1, -1)
60
- for x in xs:
61
- old = buf[y, x]
62
- idx = pairwise_distances_argmin(old[np.newaxis], palette_arr)[0]
63
- out_indices[y, x] = idx
64
- new = palette_arr[idx].astype(np.float32)
65
- err = old - new
66
-
67
- # Distribute error to 4 neighbours (Floyd-Steinberg weights)
68
- if y % 2 == 0: # left-to-right
69
- if x + 1 < w:
70
- buf[y, x + 1] += err * (7 / 16)
71
- if y + 1 < h:
72
- if x - 1 >= 0:
73
- buf[y + 1, x - 1] += err * (3 / 16)
74
- buf[y + 1, x ] += err * (5 / 16)
75
- if x + 1 < w:
76
- buf[y + 1, x + 1] += err * (1 / 16)
77
- else: # right-to-left
78
- if x - 1 >= 0:
79
- buf[y, x - 1] += err * (7 / 16)
80
- if y + 1 < h:
81
- if x + 1 < w:
82
- buf[y + 1, x + 1] += err * (3 / 16)
83
- buf[y + 1, x ] += err * (5 / 16)
84
- if x - 1 >= 0:
85
- buf[y + 1, x - 1] += err * (1 / 16)
86
-
87
- quantized = palette_arr[out_indices].reshape(arr.shape)
88
- else:
89
- labels = pairwise_distances_argmin(flat, palette_arr)
90
- quantized = palette_arr[labels].reshape(arr.shape)
91
-
92
- palette_out = [tuple(map(int, x)) for x in palette_arr]
93
- return Image.fromarray(quantized, "RGBA"), palette_out
94
-
95
29
  # slow pure python version
96
- def quantize_most_frequent_basic(img: Image.Image, n_colors: int, palette: list = None, dither: bool = False): # for use with A and P>256 images, dithering unsupported for basic mode
30
+ def quantize_most_frequent_basic(img: Image.Image, n_colors: int, palette: list = None, dither: bool = False, dither_mode: str = None): # for use with A and P>256 images, dithering unsupported for basic mode
97
31
  img = img.convert("RGBA") # ensures consistent format
98
32
  pixels = list(img.getdata())
99
33
 
@@ -126,7 +60,9 @@ def quantize_most_frequent_basic(img: Image.Image, n_colors: int, palette: list
126
60
  return out, palette
127
61
 
128
62
  if qmf == "sk":
129
- quantize_most_frequent = quantize_most_frequent_sk
63
+ quantize_most_frequent = quantize_perceptual
64
+ #if qmf == "sk": replaced by claude's perceptual quantizer. don't feel sad, the old one was also vibecoded.
65
+ # quantize_most_frequent = quantize_most_frequent_sk
130
66
  #elif qmf == "numpy": removed the numpy backend because it was having problems
131
67
  # quantize_most_frequent = quantize_most_frequent_numpy
132
68
  else:
@@ -207,6 +143,9 @@ def flexTypeToRGB(inpt: str, mode: int, trans: bool = False):
207
143
  # 4 - near-truecolor with alpha
208
144
  # 5 - truecolor (can be paired with t to store 3 levels of alpha)
209
145
  # 6 - truecolor with alpha
146
+
147
+ # 7 - 95-level Grayscale
148
+ # 8 - 8-bit Grayscale
210
149
  if mode == 3:
211
150
  r = mapping.index(inpt[0])
212
151
  g = mapping.index(inpt[1])
@@ -258,6 +197,15 @@ def flexTypeToRGB(inpt: str, mode: int, trans: bool = False):
258
197
  ac = (c//27)%3
259
198
 
260
199
  return r+rc, g+gc, b+bc, a+ac
200
+
201
+ elif mode == 7:
202
+ r = mapping.index(inpt[0])
203
+ return [r, r, r]
204
+
205
+ elif mode == 8:
206
+ ind = (ord(inpt[0])-32)*95
207
+ ind += (ord(inpt[1])-32)
208
+ return [ind, ind, ind]
261
209
  else:
262
210
  raise ValueError("Invalid/Unsupported Mode")
263
211
 
@@ -660,73 +608,60 @@ def slice_frames(img: Image, animated, trans):
660
608
  else:
661
609
  return img, None
662
610
 
663
- def reduce_to_apf2_quality(img: Image, num_colors: int = 95, animated: bool = False, trans = 0, prepalette: list = None, dodithering: bool = False):
611
+ def reduce_to_apf2_quality(img: Image, num_colors: int = 95, animated: bool = False, trans = 0, prepalette: list = None, dodithering: bool = False, useadvancedquant: bool = False, dither_mode: str = "fs", verbose: bool = False):
664
612
  trans = int(trans)
665
613
  if animated:
666
- # avoid expensive repeated hi-color quantize computing of every frame
667
- if trans == 2:
668
- trans = 1
669
- if num_colors > 256:
670
- num_colors = 256
671
- if trans:
672
- num_colors = 255
673
- print("Animation Detected, capping colors to 255/256")
674
- if dodithering:
675
- print("Animated APF2s do not get dithered, sorry.")
676
-
677
- ifuckinghatequantize = (255, 0, 255)
678
-
679
614
  frames = []
680
615
  frametiming = 0
681
- if trans:
682
- for frame in ImageSequence.Iterator(img):
683
- if not frametiming:
684
- frametiming = frame.info.get("duration", img.info.get("duration", 0))
685
-
686
- frame = frame.convert("RGBA")
687
- background = Image.new("RGBA", frame.size, ifuckinghatequantize + (255,))
688
- composited = Image.alpha_composite(background, frame)
689
- frames.append(composited.convert("RGB"))
690
- else:
691
- for frame in ImageSequence.Iterator(img):
692
- if not frametiming:
693
- frametiming = frame.info.get("duration", img.info.get("duration", 0))
694
- frames.append(frame.copy().convert("RGB"))
616
+
617
+ for frame in ImageSequence.Iterator(img):
618
+ if not frametiming:
619
+ frametiming = int(frame.info.get("duration", img.info.get("duration", 0)))
620
+ frames.append(frame.copy())
695
621
 
696
622
  widths, heights = zip(*(f.size for f in frames))
697
623
  total_width = max(widths)
698
624
  total_height = sum(heights)
699
- combined = Image.new("RGB", (total_width, total_height))
625
+ if trans == 2:
626
+ combined = Image.new("RGBA", (total_width, total_height))
627
+ else:
628
+ combined = Image.new("RGB", (total_width, total_height))
700
629
  y_offset = 0
701
630
 
702
631
  for frame in frames:
703
632
  combined.paste(frame, (0, y_offset))
704
633
  y_offset += frame.height
705
634
 
706
- if trans:
707
- combined_p = combined.convert("P", palette=Image.ADAPTIVE, colors=num_colors-1, dither=Image.NONE)
708
-
709
- pal = combined_p.getpalette()
710
- pal = [255, 0, 255] + pal
711
- combined_p.putpalette(pal)
635
+ if trans == 2:
636
+ _, prepalette = quantize_most_frequent(combined, num_colors-1, prepalette)
637
+ prepalette = [(0, 0, 0, 0)] + prepalette # hack to make cross-frame trans = 2 not break
638
+ elif trans == 1:
639
+ _, prepalette = quantize_most_frequent(combined, num_colors-1, prepalette)
640
+ prepalette = [(255, 0, 255, 0)] + prepalette
712
641
  else:
713
- combined_p = combined.convert("P", palette=Image.ADAPTIVE, colors=num_colors-1, dither=Image.NONE)
642
+ _, prepalette = quantize_most_frequent(combined, num_colors, prepalette)
643
+ if verbose:
644
+ print("Generated Animation Palette!")
714
645
 
715
646
  frames_p = []
716
- for frame in frames:
717
- f_p = frame.quantize(palette=combined_p, dither=Image.NONE)
718
- if trans:
719
- f_p.info["transparency"] = 0
647
+ for i, frame in enumerate(frames):
648
+ f_p, _ = quantize_most_frequent(frame, num_colors, prepalette, dodithering, dither_mode = dither_mode)
720
649
  frames_p.append(f_p)
650
+ if verbose:
651
+ print(f"Quantized frame {i+1}/{len(frames)}")
721
652
 
722
- raw_palette = combined_p.getpalette()[:num_colors*3]
723
653
  seen = set()
724
- palette = [tuple(raw_palette[i:i+3]) for i in range(0, len(raw_palette), 3) if not (tuple(raw_palette[i:i+3]) in seen or seen.add(tuple(raw_palette[i:i+3])))]
725
654
 
726
- return frames_p, palette, frametiming, trans
655
+ npp = []
656
+ if not trans == 2: # remove whatever
657
+ for ind in prepalette:
658
+ npp.append((ind[0], ind[1], ind[2]))
659
+ prepalette = npp
660
+
661
+ return frames_p, prepalette, frametiming, trans
727
662
  else:
728
- if trans == 2 or num_colors > 256 or prepalette:
729
- img, palette = quantize_most_frequent(img, num_colors, prepalette, dodithering)
663
+ if trans == 2 or num_colors > 256 or prepalette or useadvancedquant:
664
+ img, palette = quantize_most_frequent(img, num_colors, prepalette, dodithering, dither_mode = dither_mode)
730
665
  if trans != 2:
731
666
  palette_na = []
732
667
  for col in palette:
@@ -855,7 +790,26 @@ def order_pixels(bitmap, h, lineskip, topside_first):
855
790
 
856
791
  return revmap
857
792
 
858
- def generate_runs_apf2_f(bitmap: list, lineskip: int, w: int, h: int, trans, compress: bool, topside_first: bool, mode: int, combine: bool = False, prevframe: list = None, avoid_run_breaks: bool = False, transmag: bool = False):
793
+ def calc_error(currentrun, pixel, maxerror, foofcontrol = False):
794
+ if foofcontrol and pixel == (255, 0, 255):
795
+ if (pixel == currentrun):
796
+ return True
797
+ else:
798
+ return False
799
+
800
+ if currentrun is None:
801
+ return False
802
+
803
+ if len(currentrun) == 4 and len(pixel) == 4:
804
+ if currentrun[3] != pixel[3]:
805
+ return False
806
+
807
+ re = abs(currentrun[0]-pixel[0])
808
+ ge = abs(currentrun[1]-pixel[1])
809
+ be = abs(currentrun[2]-pixel[2])
810
+ return (( re * 2 + ge * 4 + be ) / 5) <= maxerror
811
+
812
+ def generate_runs_apf2_f(bitmap: list, lineskip: int, w: int, h: int, trans, compress: bool, topside_first: bool, mode: int, combine: bool = False, prevframe: list = None, avoid_run_breaks: bool = False, transmag: bool = False, maxmotionerror: float = 0, maxrunerror: float = 0):
859
813
  if mode <3: # heart
860
814
  raise ValueError("Modes 0, 1, and 2 should not be flex encoded!")
861
815
 
@@ -881,6 +835,7 @@ def generate_runs_apf2_f(bitmap: list, lineskip: int, w: int, h: int, trans, com
881
835
 
882
836
  runcounter = 0
883
837
  currentrun = None
838
+ currentrun_pixel = None # for error thing
884
839
  runlens = []
885
840
 
886
841
  revmap = order_pixels(bitmap, h, lineskip, topside_first)
@@ -909,7 +864,13 @@ def generate_runs_apf2_f(bitmap: list, lineskip: int, w: int, h: int, trans, com
909
864
  pixel = revmap[y][x]
910
865
  if prevrev:
911
866
  if not avoid_run_breaks or (not (runcounter>1 and not currentrun in transtuples)):
912
- if pixel == prevrev[y][x]:
867
+ # lossy motion encoding mode
868
+ if maxmotionerror:
869
+ cont = calc_error(prevrev[y][x], pixel, maxmotionerror)
870
+ else:
871
+ cont = (pixel == prevrev[y][x])
872
+
873
+ if cont:
913
874
  pixel = (255, 0, 255) if transmag else (0,0,0,0)
914
875
  isTransPix = True
915
876
 
@@ -957,7 +918,13 @@ def generate_runs_apf2_f(bitmap: list, lineskip: int, w: int, h: int, trans, com
957
918
  if not isTransPix and (transmag and curpixval == "~ ~"):
958
919
  curpixval = "~!~"
959
920
 
960
- if currentrun == curpixval:
921
+ # lossy RLE encoding mode
922
+ if maxrunerror and not (runcounter+1 > 94):
923
+ cont = calc_error(currentrun_pixel, pixel, maxrunerror, transmag)
924
+ else:
925
+ cont = (currentrun == curpixval)
926
+
927
+ if cont:
961
928
  if (runcounter+1 > 94) or (not compress): # dont compress
962
929
  if currentrun is not None:
963
930
  runlens.append([curpixval, runcounter])
@@ -971,6 +938,9 @@ def generate_runs_apf2_f(bitmap: list, lineskip: int, w: int, h: int, trans, com
971
938
  runlens.append([currentrun, runcounter])
972
939
  runcounter = 1
973
940
  currentrun = curpixval
941
+ currentrun_pixel = pixel
942
+ if not isTransPix and (transmag and pixel == (255, 0, 255)):
943
+ currentrun_pixel = (255, 2, 255)
974
944
 
975
945
  if runcounter > 0:
976
946
  if trans and currentrun[3] == 0:
@@ -983,7 +953,7 @@ def generate_runs_apf2_f(bitmap: list, lineskip: int, w: int, h: int, trans, com
983
953
  total = sum(rldb)
984
954
  return runlens
985
955
 
986
- def generate_runs_apf2(bitmap: list, palette: list, lineskip: int, w: int, h: int, trans = 0, dim: bool = False, prepalette: str = None, compress: bool = True, topside_first: bool = False, palette_only: bool = False, combine: bool = False, prevframe: list = None, avoid_run_breaks: bool = False):
956
+ def generate_runs_apf2(bitmap: list, palette: list, lineskip: int, w: int, h: int, trans = 0, dim: bool = False, prepalette: str = None, compress: bool = True, topside_first: bool = False, palette_only: bool = False, combine: bool = False, prevframe: list = None, avoid_run_breaks: bool = False, maxmotionerror: float = 0, maxrunerror: float = 0, useadvancedquant: bool = False):
987
957
  trans = int(trans)
988
958
  colpal = {}
989
959
  colpalbnr = {}
@@ -1066,10 +1036,21 @@ def generate_runs_apf2(bitmap: list, palette: list, lineskip: int, w: int, h: in
1066
1036
  pixel = revmap[y][x]
1067
1037
  if prevrev:
1068
1038
  if not avoid_run_breaks or (not (runcounter>1 and not currentrun == (0,0,0,0))):
1069
- if pixel == prevrev[y][x]:
1039
+ # lossy motion encoding mode
1040
+ if maxmotionerror:
1041
+ cont = calc_error(prevrev[y][x], pixel, maxmotionerror)
1042
+ else:
1043
+ cont = (pixel == prevrev[y][x])
1044
+ if cont:
1070
1045
  pixel = (0,0,0,0)
1071
1046
 
1072
- if currentrun == pixel:
1047
+ # lossy RLE encoding mode
1048
+ if maxrunerror and not (runcounter+1 > 94):
1049
+ cont = calc_error(currentrun, pixel, maxrunerror)
1050
+ else:
1051
+ cont = (currentrun == pixel)
1052
+
1053
+ if cont:
1073
1054
  if (runcounter+1 > 94) or (not compress): # dont compress
1074
1055
  if currentrun is not None:
1075
1056
  if not currentrun in colpalbnr:
@@ -1101,12 +1082,17 @@ def generate_runs_apf2(bitmap: list, palette: list, lineskip: int, w: int, h: in
1101
1082
  return runlens, apf2pal
1102
1083
 
1103
1084
  # wrapper function to encode the data
1104
- def encode_wrapper(legacy, bitmaps, gray, truecolor, lineskip, w, h, topside_first, bg, trans, avoid_run_breaks, transmag, mode, combine, palette, dim, prepalette, compress):
1085
+ def encode_wrapper(legacy, bitmaps, gray, truecolor, lineskip, w, h, topside_first, bg, trans, avoid_run_breaks, transmag, mode, combine, palette, dim, prepalette, compress, verbose, maxmotionerror, maxrunerror):
1105
1086
  apf2pal = None
1106
1087
 
1107
1088
  if legacy:
1108
1089
  outputs = []
1090
+ animated = (len(bitmaps) > 1)
1091
+ inc = 0
1109
1092
  for bitmap in bitmaps:
1093
+ if verbose and animated:
1094
+ print(f"Encoding frame {inc+1}/{len(bitmaps)}...")
1095
+ inc+=1
1110
1096
  temp = []
1111
1097
  runlens = generate_runs_apf2_l(bitmap, lineskip, w, h, topside_first, bg, trans)
1112
1098
  for num in runlens:
@@ -1118,13 +1104,17 @@ def encode_wrapper(legacy, bitmaps, gray, truecolor, lineskip, w, h, topside_fir
1118
1104
 
1119
1105
  elif gray or truecolor:
1120
1106
  outputs = []
1107
+ animated = (len(bitmaps) > 1)
1108
+
1121
1109
  for bm in range(len(bitmaps)):
1110
+ if verbose and animated:
1111
+ print(f"Encoding frame {bm+1}/{len(bitmaps)}...")
1122
1112
  temp = []
1123
1113
  prevframe = None
1124
1114
  if bm != 0 and combine:
1125
1115
  prevframe = bitmaps[bm-1]
1126
1116
 
1127
- runlens = generate_runs_apf2_f(bitmaps[bm], lineskip, w, h, trans, compress, topside_first, mode, combine, prevframe, avoid_run_breaks, transmag)
1117
+ runlens = generate_runs_apf2_f(bitmaps[bm], lineskip, w, h, trans, compress, topside_first, mode, combine, prevframe, avoid_run_breaks, transmag, maxmotionerror, maxrunerror)
1128
1118
  for num in runlens:
1129
1119
  temp.append(num[0])
1130
1120
  if compress:
@@ -1139,14 +1129,17 @@ def encode_wrapper(legacy, bitmaps, gray, truecolor, lineskip, w, h, topside_fir
1139
1129
  else:
1140
1130
  outputs = []
1141
1131
  _, apf2pal = generate_runs_apf2(bitmaps[0], palette, lineskip, w, h, trans, dim, prepalette, compress, topside_first, True)
1132
+ animated = (len(bitmaps) > 1)
1142
1133
 
1143
1134
  for bm in range(len(bitmaps)):
1135
+ if verbose and animated:
1136
+ print(f"Encoding frame {bm+1}/{len(bitmaps)}...")
1144
1137
  temp = []
1145
1138
  prevframe = None
1146
1139
  if bm != 0 and combine:
1147
1140
  prevframe = bitmaps[bm-1]
1148
1141
 
1149
- runlens, _ = generate_runs_apf2(bitmaps[bm], palette, lineskip, w, h, trans, dim, prepalette, compress, topside_first, False, combine, prevframe, avoid_run_breaks)
1142
+ runlens, _ = generate_runs_apf2(bitmaps[bm], palette, lineskip, w, h, trans, dim, prepalette, compress, topside_first, False, combine, prevframe, avoid_run_breaks, maxmotionerror, maxrunerror)
1150
1143
  for num in runlens:
1151
1144
  temp.append(num[0])
1152
1145
  if compress:
@@ -1166,7 +1159,7 @@ def encode_wrapper(legacy, bitmaps, gray, truecolor, lineskip, w, h, topside_fir
1166
1159
 
1167
1160
  return output, apf2pal
1168
1161
 
1169
- def encode(img: bytes | Image.Image, lineskip: int = None, findbestlineskip: bool = False, legacy: bool = False, trans = False, pal: int = 95, desc: str = "", prepalette: str = None, dodithering: bool = False, returnbytes: bool = False, compress: bool = True, topside_first: bool = False, emit_redundant_flag: bool = False, mode: int = None, combine: bool = False, avoid_run_breaks: bool = False):
1162
+ def encode(img: bytes | Image.Image, lineskip: int = None, findbestlineskip: bool = False, legacy: bool = False, trans = False, pal: int = 95, desc: str = "", prepalette: str = None, dodithering: bool = False, returnbytes: bool = False, compress: bool = True, topside_first: bool = False, emit_redundant_flag: bool = False, mode: int = None, combine: bool = False, avoid_run_breaks: bool = False, width: int = None, height: int = None, maxrunerror: float = 0, maxmotionerror: float = 0, verbose: bool = False, useadvancedquant: bool = True, dither_mode: str = "fs"):
1170
1163
  frametiming = None
1171
1164
  bakedpal = None
1172
1165
  dim = False
@@ -1260,11 +1253,23 @@ def encode(img: bytes | Image.Image, lineskip: int = None, findbestlineskip: boo
1260
1253
 
1261
1254
  w, h = img.size
1262
1255
 
1256
+ # preserve aspect ratio modes
1257
+ if width is not None and height is None:
1258
+ aspct = h/w
1259
+ height = round(width*aspct)
1260
+ if width is None and height is not None:
1261
+ aspct = w/h
1262
+ width = round(height*aspct)
1263
+
1264
+ if width is not None and height is not None:
1265
+ img = img.resize((width, height))
1266
+ w, h = img.size
1267
+
1263
1268
  animated = getattr(img, "is_animated", False)
1264
1269
  if legacy and not find_bgfg:
1265
1270
  img, frametiming = reduce_to_apf_in_apf2_quality(img, animated)
1266
1271
  elif not truecolor and not gray:
1267
- img, palette, frametiming, trans = reduce_to_apf2_quality(img, pal, animated, trans, bakedpal, dodithering)
1272
+ img, palette, frametiming, trans = reduce_to_apf2_quality(img, pal, animated, trans, bakedpal, dodithering, useadvancedquant, dither_mode, verbose)
1268
1273
  else:
1269
1274
  img, frametiming = slice_frames(img, animated, trans)
1270
1275
 
@@ -1415,16 +1420,20 @@ def encode(img: bytes | Image.Image, lineskip: int = None, findbestlineskip: boo
1415
1420
 
1416
1421
  for lsk in range(lineskip):
1417
1422
  lsk+=1
1418
- output, apf2pal = encode_wrapper(legacy, bitmaps, gray, truecolor, lsk, w, h, topside_first, bg, trans, avoid_run_breaks, transmag, mode, combine, palette, dim, prepalette, compress)
1423
+ if verbose:
1424
+ print(f"trying lineskip = {lsk}")
1425
+ output, apf2pal = encode_wrapper(legacy, bitmaps, gray, truecolor, lsk, w, h, topside_first, bg, trans, avoid_run_breaks, transmag, mode, combine, palette, dim, prepalette, compress, verbose, maxmotionerror, maxrunerror)
1419
1426
  all_outputs[str(lsk)] = output
1420
1427
  for lsk in all_outputs:
1421
1428
  if outsize > len(all_outputs[lsk])+len(lsk):
1422
1429
  output = all_outputs[lsk]
1423
1430
  outsize = len(all_outputs[lsk])+len(lsk)
1424
1431
  lineskip = int(lsk)
1432
+ if verbose:
1433
+ print(f"Smallest: {lineskip}")
1425
1434
  metadata.append(str(lineskip))
1426
1435
  else:
1427
- output, apf2pal = encode_wrapper(legacy, bitmaps, gray, truecolor, lineskip, w, h, topside_first, bg, trans, avoid_run_breaks, transmag, mode, combine, palette, dim, prepalette, compress)
1436
+ output, apf2pal = encode_wrapper(legacy, bitmaps, gray, truecolor, lineskip, w, h, topside_first, bg, trans, avoid_run_breaks, transmag, mode, combine, palette, dim, prepalette, compress, verbose, maxmotionerror, maxrunerror)
1428
1437
 
1429
1438
 
1430
1439
  if desc or frametiming:
@@ -1456,6 +1465,17 @@ def encode(img: bytes | Image.Image, lineskip: int = None, findbestlineskip: boo
1456
1465
 
1457
1466
  apflist.append(output)
1458
1467
  apftext = "\n".join(apflist)
1468
+
1469
+ if verbose:
1470
+ if uses2000extensions:
1471
+ version = 2000
1472
+ elif uses1994extensions:
1473
+ version = 1994
1474
+ else:
1475
+ version = 1993
1476
+ print(f"APF2 Version: {version}")
1477
+ print(f"APF2 Header Line: {apflist[1]}")
1478
+
1459
1479
  if returnbytes:
1460
1480
  return apftext.encode()
1461
1481
  else:
@@ -0,0 +1,236 @@
1
+ # This code was generated by Claude
2
+ """
3
+ Perceptual palette quantizer: drop-in upgrade for quantize_most_frequent_sk.
4
+
5
+ Pipeline
6
+ 1. Collapse the image to its unique RGBA colors + pixel counts (fast, and makes
7
+ everything frequency-weighted for free).
8
+ 2. Convert to Oklab so distances approximate perceived difference.
9
+ 3. Seed a palette with weighted median-cut (splits the box with the largest
10
+ weighted squared error), then refine with a few weighted Lloyd iterations.
11
+ If the image has <= n_colors unique colors the palette is exact (lossless).
12
+ 4. Optionally order the palette for index locality (helps RLE / delta coding).
13
+ 5. Map pixels with a KD-tree in Oklab, with optional dithering:
14
+ - "ordered": Bayer matrix, fully vectorised, run-friendly
15
+ - "fs": serpentine Floyd-Steinberg in Oklab with error clamping
16
+ """
17
+ import heapq
18
+
19
+ import numpy as np
20
+ from PIL import Image
21
+ from scipy.spatial import cKDTree
22
+
23
+ # ---------------------------------------------------------------- Oklab ----
24
+ _M1 = np.array([[0.4122214708, 0.5363325363, 0.0514459929],
25
+ [0.2119034982, 0.6806995451, 0.1073969566],
26
+ [0.0883024619, 0.2817188376, 0.6299787005]])
27
+ _M2 = np.array([[0.2104542553, 0.7936177850, -0.0040720468],
28
+ [1.9779984951, -2.4285922050, 0.4505937099],
29
+ [0.0259040371, 0.7827717662, -0.8086757660]])
30
+ _M1_INV = np.linalg.inv(_M1)
31
+ _M2_INV = np.linalg.inv(_M2)
32
+
33
+
34
+ def _srgb_to_oklab(rgb_u8):
35
+ c = rgb_u8.astype(np.float64) / 255.0
36
+ lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
37
+ lms = np.cbrt(lin @ _M1.T)
38
+ return lms @ _M2.T
39
+
40
+
41
+ def _oklab_to_srgb(lab):
42
+ lms = (lab @ _M2_INV.T) ** 3
43
+ lin = np.clip(lms @ _M1_INV.T, 0.0, 1.0)
44
+ c = np.where(lin <= 0.0031308, lin * 12.92, 1.055 * lin ** (1 / 2.4) - 0.055)
45
+ return np.clip(np.rint(c * 255.0), 0, 255).astype(np.uint8)
46
+
47
+
48
+ def _to_features(rgba, wvec):
49
+ """RGBA uint8 (N,4) -> weighted [L, a, b, alpha] float64 (N,4)."""
50
+ lab = _srgb_to_oklab(rgba[:, :3])
51
+ lab[rgba[:, 3] == 0] = 0.0 # colour of fully transparent pixels is meaningless
52
+ feat = np.empty((len(rgba), 4), dtype=np.float64)
53
+ feat[:, :3] = lab
54
+ feat[:, 3] = rgba[:, 3] / 255.0
55
+ return feat * wvec
56
+
57
+
58
+ def _from_features(feat, wvec):
59
+ f = feat / wvec
60
+ rgba = np.empty((len(f), 4), dtype=np.uint8)
61
+ rgba[:, :3] = _oklab_to_srgb(f[:, :3])
62
+ rgba[:, 3] = np.clip(np.rint(f[:, 3] * 255.0), 0, 255).astype(np.uint8)
63
+ return rgba
64
+
65
+
66
+ # ------------------------------------------------------ palette building ----
67
+ def _median_cut(pts, w, k):
68
+ """Weighted median cut; always splits the box with the largest SSE."""
69
+ def stats(idx):
70
+ bw, bp = w[idx], pts[idx]
71
+ mean = (bp * bw[:, None]).sum(0) / bw.sum()
72
+ return (((bp - mean) ** 2) * bw[:, None]).sum(0)
73
+
74
+ heap, done, n = [], [], 0
75
+ root = np.arange(len(pts))
76
+ var = stats(root)
77
+ heapq.heappush(heap, (-var.sum(), n, root, var))
78
+ while heap and len(heap) + len(done) < k:
79
+ _, _, idx, var = heapq.heappop(heap)
80
+ if len(idx) < 2:
81
+ done.append(idx)
82
+ continue
83
+ axis = int(np.argmax(var))
84
+ order = idx[np.argsort(pts[idx, axis], kind="stable")]
85
+ cw = np.cumsum(w[order])
86
+ s = int(np.searchsorted(cw, cw[-1] / 2)) + 1
87
+ s = min(max(s, 1), len(order) - 1)
88
+ for part in (order[:s], order[s:]):
89
+ n += 1
90
+ v = stats(part)
91
+ heapq.heappush(heap, (-v.sum(), n, part, v))
92
+ boxes = [h[2] for h in heap] + done
93
+ return np.stack([(pts[b] * w[b, None]).sum(0) / w[b].sum() for b in boxes])
94
+
95
+
96
+ def _lloyd(pts, w, centers, iters):
97
+ k = len(centers)
98
+ for _ in range(iters):
99
+ _, lab = cKDTree(centers).query(pts, workers=-1)
100
+ ws = np.bincount(lab, weights=w, minlength=k)
101
+ new = np.stack([np.bincount(lab, weights=w * pts[:, d], minlength=k)
102
+ for d in range(pts.shape[1])], axis=1)
103
+ m = ws > 0
104
+ centers[m] = new[m] / ws[m, None]
105
+ return centers
106
+
107
+
108
+ def _locality_order(feat):
109
+ """Order palette so neighbouring indices are perceptually close:
110
+ lightness bands, then hue sweeps alternating direction per band."""
111
+ n = len(feat)
112
+ bands = max(1, int(np.sqrt(n) / 2))
113
+ rank = np.empty(n, dtype=np.int64)
114
+ rank[np.argsort(feat[:, 0], kind="stable")] = np.arange(n)
115
+ band = rank * bands // n
116
+ hue = np.arctan2(feat[:, 2], feat[:, 1])
117
+ hue = np.where(band % 2 == 0, hue, -hue)
118
+ return np.lexsort((hue, band))
119
+
120
+
121
+ # --------------------------------------------------------------- dithering --
122
+ def _bayer(n):
123
+ m = np.array([[0]])
124
+ while m.shape[0] < n:
125
+ m = np.block([[4 * m, 4 * m + 2], [4 * m + 3, 4 * m + 1]])
126
+ return (m + 0.5) / (n * n) - 0.5 # centred on 0, range (-0.5, 0.5)
127
+
128
+
129
+ def _palette_spacing(pal_feat):
130
+ """Median nearest-neighbour distance between palette entries (colour axes)."""
131
+ if len(pal_feat) < 2:
132
+ return 0.0
133
+ d, _ = cKDTree(pal_feat[:, :3]).query(pal_feat[:, :3], k=2)
134
+ return float(np.median(d[:, 1]))
135
+
136
+
137
+ # --------------------------------------------------------------- public API --
138
+ def quantize_perceptual(img: Image.Image, n_colors: int, palette: list = None,
139
+ dither: bool = False, *, dither_mode: str = "ordered",
140
+ dither_strength: float = 0.8, kmeans_iters: int = 6,
141
+ lightness_weight: float = 1.0, alpha_weight: float = 1.0,
142
+ sort_palette: bool = True):
143
+ """Returns (RGBA PIL image, palette as list of (r,g,b,a) tuples).
144
+
145
+ palette fixed palette; skips palette generation (and sorting)
146
+ dither_mode "ordered" (Bayer, run-friendly) or "fs" (Floyd-Steinberg)
147
+ dither_strength 0..1+, scales the dither amplitude / diffused error
148
+ lightness_weight >1 spends more palette on luminance detail
149
+ alpha_weight how much alpha differences count versus colour
150
+ """
151
+ img = img.convert("RGBA")
152
+ arr = np.ascontiguousarray(np.array(img, dtype=np.uint8))
153
+ h, w = arr.shape[:2]
154
+ flat = arr.reshape(-1, 4)
155
+ wvec = np.array([lightness_weight, 1.0, 1.0, alpha_weight])
156
+
157
+ # unique colours + counts
158
+ packed = flat.view(np.uint32).ravel()
159
+ uniq, inv, counts = np.unique(packed, return_inverse=True, return_counts=True)
160
+ inv = inv.ravel()
161
+ uniq_rgba = np.ascontiguousarray(uniq).view(np.uint8).reshape(-1, 4)
162
+ uniq_feat = _to_features(uniq_rgba, wvec)
163
+
164
+ # palette
165
+ if palette is not None:
166
+ pal_rgba = np.array(palette, dtype=np.uint8)
167
+ if pal_rgba.shape[1] == 3:
168
+ pal_rgba = np.concatenate(
169
+ [pal_rgba, np.full((len(pal_rgba), 1), 255, np.uint8)], axis=1)
170
+ elif len(uniq_rgba) <= n_colors:
171
+ pal_rgba = uniq_rgba.copy()
172
+ else:
173
+ cnt = counts.astype(np.float64)
174
+ centers = _median_cut(uniq_feat, cnt, n_colors)
175
+ centers = _lloyd(uniq_feat, cnt, centers, kmeans_iters)
176
+ pal_rgba = _from_features(centers, wvec)
177
+ pal_feat = _to_features(pal_rgba, wvec) # use the *actual* 8-bit palette
178
+ if palette is None and sort_palette:
179
+ order = _locality_order(pal_feat)
180
+ pal_rgba, pal_feat = pal_rgba[order], pal_feat[order]
181
+
182
+ tree = cKDTree(pal_feat)
183
+ _, base_u = tree.query(uniq_feat, workers=-1)
184
+ base_idx = base_u[inv] # plain nearest-colour index per pixel
185
+
186
+ if not dither or len(pal_rgba) < 2:
187
+ idx = base_idx
188
+ else:
189
+ spacing = _palette_spacing(pal_feat)
190
+ feat_all = uniq_feat[inv] # (N,4)
191
+ opaque = flat[:, 3] > 0
192
+
193
+ if dither_mode == "ordered":
194
+ tile = _bayer(8)
195
+ thr = np.tile(tile, (h // 8 + 1, w // 8 + 1))[:h, :w].reshape(-1)
196
+ shifted = feat_all.copy()
197
+ # thr spans (-0.5, 0.5); nearest-neighbour spacing underestimates the
198
+ # real cell width in a sparse 3D palette, so scale by 2 (+/- 1 step at 1.0)
199
+ shifted[:, :3] += (thr * 2.0 * spacing * dither_strength)[:, None]
200
+ _, idx = tree.query(shifted, workers=-1)
201
+ idx = np.where(opaque, idx, base_idx)
202
+
203
+ elif dither_mode == "fs":
204
+ buf = feat_all.reshape(h, w, 4).copy()
205
+ opq = opaque.reshape(h, w)
206
+ base2d = base_idx.reshape(h, w)
207
+ out = np.empty((h, w), dtype=np.int64)
208
+ lim = spacing # clamp so error cannot snowball past one palette step
209
+ for y in range(h):
210
+ ltr = (y % 2 == 0)
211
+ xs = range(w) if ltr else range(w - 1, -1, -1)
212
+ dx = 1 if ltr else -1
213
+ for x in xs:
214
+ if not opq[y, x]:
215
+ out[y, x] = base2d[y, x]
216
+ continue
217
+ old = buf[y, x]
218
+ _, i = tree.query(old)
219
+ out[y, x] = i
220
+ err = np.clip(old[:3] - pal_feat[i, :3], -lim, lim) * dither_strength
221
+ xn = x + dx
222
+ if 0 <= xn < w:
223
+ buf[y, xn, :3] += err * (7 / 16)
224
+ if y + 1 < h:
225
+ xb = x - dx
226
+ if 0 <= xb < w:
227
+ buf[y + 1, xb, :3] += err * (3 / 16)
228
+ buf[y + 1, x, :3] += err * (5 / 16)
229
+ if 0 <= xn < w:
230
+ buf[y + 1, xn, :3] += err * (1 / 16)
231
+ idx = out.reshape(-1)
232
+ else:
233
+ raise ValueError("dither_mode must be 'ordered' or 'fs'")
234
+
235
+ quantized = pal_rgba[idx].reshape(h, w, 4)
236
+ return Image.fromarray(quantized, "RGBA"), [tuple(int(v) for v in c) for c in pal_rgba]
@@ -8,6 +8,8 @@ exts = Image.registered_extensions()
8
8
  supported_extensions = {ex for ex, f in exts.items() if f in Image.OPEN}
9
9
 
10
10
  def main():
11
+ print(f"apftool cli [v{apftool.version}] (c) 2026 Maristocratic Communications")
12
+
11
13
  if len(sys.argv) < 3:
12
14
  print("""Usage: apfcli <input_file> <output file>
13
15
  arguents:
@@ -19,17 +21,22 @@ encoding:
19
21
  --lineskip=INT - (APF/APF2) how many lines are skipped in progressive scanning. (default is 1)
20
22
  --findbestlineskip - (APF/APF2) finds the lineskip value with the smallest file size. (APF2) search is limited to supplied lineskip argument (default is 10)
21
23
 
22
- --desc=STR - (APF2) file description
24
+ --desc=STR - (APF2) file description [Alias: -D=STR]
23
25
  --legacy - (APF2) use bi-level runs
24
26
  --transparent - (APF2) alias for --transmode=1
25
- --transmode=INT - (APF2) sets transparency mode. 0 is off, 1 is index 0 is 00000000, 2 is 8-bit alpha in palette
27
+ --transmode=INT - (APF2) sets transparency mode. 0 is off, 1 is index 0 is 00000000, 2 is 8-bit alpha in palette [Alias: -T=INT]
26
28
  --palette=INT - (APF2) sets max colors. 2 will use legacy + bg.fg, >95 will use dual-indexed mode. 9025 is max.
27
- --dither - (APF2) enables dithering (off by default outside of legacy)
29
+ --dither - (APF2) enables dithering (off by default outside of legacy) [Alias: -D]
28
30
  --topdown - (APF2) reverses scan order
29
31
  --uncompressed - (APF2) disables RLE (not applicable for legacy mode)
30
- --combine - (APF2) apply cross-frame compression. Implicitly sets transmode to 1, except for mode 3 where it uses FE00FE as transparency. Unsupported on legacy.
31
- --avoidrunbreaks - (APF2) avoid breaking runs on combine mode. May improve or harm efficiency.
32
- --mode=INT - (APF2) color mode (specified in A2K) for APF2.
32
+ --combine - (APF2) apply cross-frame compression. Implicitly sets transmode to 1, except for mode 3 where it uses FE00FE as transparency. Unsupported on legacy. [Alias: -C]
33
+ --avoidrunbreaks - (APF2) avoid breaking runs on combine mode. May improve or harm efficiency (usually harm, as it is not tuned).
34
+ --run-quality=INT - (APF2) (0 to 100) allows for lossy RLE compression
35
+ --motion-quality=INT - (APF2) (0 to 100) allows for lossy motion compression (for --combine)
36
+ --quality=INT - (APF2) (0 to 100) sets both --run-quality and --motion-quality [Alias: -Q=INT]
37
+ --pillow - (APF2) use pillow's quantizer [Alias: -P]
38
+ --bayer - (APF2) use faster bayer/ordered dithering for perceptual mode instead of the slower, but prettier floyd-steinberg
39
+ --mode=INT - (APF2) color mode (specified in A2K) for APF2. [Alias: -M=INT]
33
40
  0 - 2 colors
34
41
  1 - 95 colors
35
42
  2 - 9025 colors
@@ -40,8 +47,8 @@ encoding:
40
47
  7 - Mode 1-based Grayscale
41
48
  8 - Mode 2-based Grayscale
42
49
 
43
- --width - (OTB) sets width (default is 255)
44
- --height - (OTB) sets height (default is 255)
50
+ --width - (OTB/APF2) sets width (default is 255 for OTB, no scaling for APF2) [Alias: -W=INT]
51
+ --height - (OTB/APF2) sets height (default is 255 for OTB, no scaling for APF2) [Alias: -H=INT]
45
52
 
46
53
  --transcolor - (MQIF) sets color to be made transparent (default is None)
47
54
 
@@ -68,14 +75,22 @@ TODO: MQIF encoding
68
75
  lineskip = None
69
76
  dither = False
70
77
  description = ""
71
- wid = 255
72
- hei = 255
78
+ if opext in apftool.extensions_apf2:
79
+ wid = None
80
+ hei = None
81
+ else:
82
+ wid = 255
83
+ hei = 255
73
84
  transcolor = None
74
85
  flip = False
75
86
  compress = True
76
87
  mode = None
77
88
  combine = False
78
89
  avoid_run_breaks = False
90
+ maxmotionerror = 0
91
+ maxrunerror = 0
92
+ useadvancedquant = True
93
+ dither_mode = "fs"
79
94
  if args:
80
95
  if "--findbestlineskip" in args:
81
96
  fbls = True
@@ -83,35 +98,57 @@ TODO: MQIF encoding
83
98
  legacy = True
84
99
  if "--transparent" in args:
85
100
  trans = 1
86
- if "--dither" in args:
101
+ if "--dither" in args or "-D" in args:
87
102
  dither = True
88
103
  if "--topdown" in args:
89
104
  flip = True
90
105
  if "--uncompressed" in args:
91
106
  compress = False
92
- if "--combine" in args:
107
+ if "--combine" in args or "-C" in args:
93
108
  combine = True
94
109
  if "--magenta" in args:
95
110
  magenta = True
96
111
  if "--avoidrunbreaks" in args:
97
112
  avoid_run_breaks = True
113
+ if "--pillow" in args or "-P" in args:
114
+ useadvancedquant = False
115
+ if "--bayer" in args:
116
+ dither_mode = "ordered"
98
117
  for arg in args:
99
- if arg.startswith("--desc="):
100
- description = arg.replace("--desc=", "")
118
+ if arg.startswith("--desc=") or arg.startswith("-D="):
119
+ description = arg.replace("--desc=", "").replace("-D=", "")
101
120
  if arg.startswith("--format="):
102
121
  forma = arg.replace("--format=", "")
103
- if arg.startswith("--transmode="):
104
- trans = int(arg.replace("--transmode=", ""))
122
+ if arg.startswith("--transmode=") or arg.startswith("-T="):
123
+ trans = int(arg.replace("--transmode=", "").replace("-T=", ""))
105
124
  if arg.startswith("--palette="):
106
125
  maxpalette = int(arg.replace("--palette=", ""))
107
126
  if arg.startswith("--lineskip="):
108
127
  lineskip = int(arg.replace("--lineskip=", ""))
109
- if arg.startswith("--width="):
110
- wid = int(arg.replace("--width=", ""))
111
- if arg.startswith("--height="):
112
- hei = int(arg.replace("--height=", ""))
128
+
129
+ if arg.startswith("--width=") or arg.startswith("-W="):
130
+ wid = int(arg.replace("--width=", "").replace("-W=", ""))
131
+ if arg.startswith("--height=") or arg.startswith("-H="):
132
+ hei = int(arg.replace("--height=", "").replace("-H=", ""))
133
+
113
134
  if arg.startswith("--mode="):
114
135
  mode = int(arg.replace("--mode=", ""))
136
+ if arg.startswith("-M="):
137
+ mode = int(arg.replace("-M=", ""))
138
+
139
+ if arg.startswith("--quality=") or arg.startswith("-Q="):
140
+ qual = int(arg.replace("--quality=", "").replace("-Q=", ""))/2
141
+ maxmotionerror = 50-qual
142
+ maxrunerror = maxmotionerror
143
+
144
+ if arg.startswith("--run-quality="):
145
+ qual = int(arg.replace("--run-quality=", ""))/2
146
+ maxrunerror = 50-qual
147
+
148
+ if arg.startswith("--motion-quality="):
149
+ qual = int(arg.replace("--motion-quality=", ""))/2
150
+ maxmotionerror = 50-qual
151
+
115
152
  if arg.startswith("--transcolor="):
116
153
  transcolor_hex = arg.replace("--transcolor=", "").replace("#", "")
117
154
  tcr = int(transcolor_hex[0:2], 16)
@@ -127,9 +164,29 @@ TODO: MQIF encoding
127
164
  maxpalette = 95
128
165
  mode = 6 if trans == 2 else 5
129
166
 
167
+ assert maxrunerror >= 0, f"Quality cannot be above 100 (got {maxrunerror})"
168
+ assert maxmotionerror >= 0, f"Quality cannot be above 100 (got {maxrunerror})"
169
+
170
+ res = f"Converting {input_path} to {output_path}..."
171
+ if opext in apftool.extensions_apf2:
172
+ md = mode
173
+ if not md:
174
+ if legacy:
175
+ md = 0
176
+ elif maxpalette > 95:
177
+ md = 2
178
+ else:
179
+ md = 1
180
+ res+= f" [Max Run Error: {maxrunerror}, Max Motion Error: {maxmotionerror}, Mode {md}]"
181
+ print(res)
182
+
130
183
  # PNG to APF/APF2
131
- if ext in supported_extensions:
132
- img_bytes = Image.open(input_path)
184
+ if opext in apftool.extensions_all:
185
+ if ext in supported_extensions:
186
+ img_bytes = Image.open(input_path)
187
+ else:
188
+ with open(input_path, "rb") as f:
189
+ img_bytes = apftool.decode(f.read(), forma)
133
190
 
134
191
  if opext in apftool.extensions_apf:
135
192
  encoded = apftool.apf.encode(img_bytes, lineskip=lineskip, findbestlineskip=fbls)
@@ -147,7 +204,7 @@ TODO: MQIF encoding
147
204
  encoded = apftool.mqif.encode(img_bytes, transcolor)
148
205
 
149
206
  elif opext in apftool.extensions_apf2:
150
- encoded = apftool.apf2.encode(img_bytes, lineskip=lineskip, findbestlineskip=fbls, legacy=legacy, trans=trans, pal=maxpalette, desc=description, prepalette= None, dodithering=dither, returnbytes=True, compress=compress, topside_first=flip, mode=mode, combine=combine, avoid_run_breaks=avoid_run_breaks)
207
+ encoded = apftool.apf2.encode(img_bytes, lineskip=lineskip, findbestlineskip=fbls, legacy=legacy, trans=trans, pal=maxpalette, desc=description, prepalette= None, dodithering=dither, returnbytes=True, compress=compress, topside_first=flip, mode=mode, combine=combine, avoid_run_breaks=avoid_run_breaks, width=wid, height=hei, maxrunerror = maxrunerror, maxmotionerror = maxmotionerror, verbose = True, useadvancedquant=useadvancedquant, dither_mode=dither_mode)
151
208
 
152
209
  else:
153
210
  raise ValueError("Unsupported Image Format!")
@@ -156,7 +213,7 @@ TODO: MQIF encoding
156
213
  print(f"Encoded {input_path} -> {output_path}")
157
214
 
158
215
  # APF/APF2 to PNG
159
- elif ext in apftool.extensions_all:
216
+ elif opext in supported_extensions:
160
217
  with open(input_path, "rb") as f:
161
218
  apf_content = f.read()
162
219
  decoded_bytes = apftool.decode(apf_content, forma)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apftool
3
- Version: 1.1.2
3
+ Version: 1.2.0
4
4
  Summary: Encoder and decoder for Aperture Science Image Format (apf), Aperture Science Image Format 2 (apf2), Wireless Bitmap (wbmp), Over-The-Air Bitmap (otb), and FaceDev's .bruh format
5
5
  Author: Mari (kbity)
6
6
  Requires-Python: >=3.10
@@ -40,7 +40,9 @@ bruh is an intentionally terrible format made by facedev. info can be found [her
40
40
 
41
41
  `apf.encode(img: bytes | Image.Image, lineskip: int = 1, findbestlineskip: bool = False, returnbytes: bool = False)` takes image bytes or pil image object and outputs apf string. lineskip is interleave value, findbestlineskip brute-forces different interleave values to the possible max of 199 and uses the smallest one. returnbytes makes it return bytes instead of a string.
42
42
 
43
- `apf2.encode(img: bytes | Image.Image, lineskip: int = 1, findbestlineskip: bool = False, legacy: bool = False, trans = False, pal: int = 95, desc: str = "", prepalette: str = None, dodithering: bool = False, returnbytes: bool = False, compress: bool = True, topside_first: bool = False, emit_redundant_flag: bool = False, mode: int = None, combine: bool = False, avoid_run_breaks: bool = False)` takes image bytes or pil image object and outputs apf2 as a string or ascii bytes. lineskip is interleave value, findbestlineskip brute-forces different interleave values to the provided lineskip and uses the smallest one. legacy uses apf1-style 2 color data instead of a 95 color palette. trans sets transparency mode, with mode 0 being off, mode 1 being index 0 transparency (GIF-like, it overrides a color), and mode 2 being indexed alpha. pal allows you to manually force a smaller or larger palette anything over 95 will use APF2-1994's Dual Indexed Mode. Prepalette is an already encoded APF2 palette you input into the encoder to make the colors deterministic. dodithering enables or disables dithering. returnbytes makes it return bytes instead of a string. These options use the APF2-2000 mode: compress - disable RLE compression, allows for smaller file sizes where RLE is not beneficial. Topside_first makes the image scan top-to-bottom, like most formats. emit_redundant_flag - adds the implicit i flag explicitally to baseline 95 color images. mode - selects apf2 coding mode [0: 2 colors, 1: 95 colors, 2: 9025 colors, 3: near-truecolor, 4: near-truecolor with alpha, 5: truecolor, 6: truecolor with alpha, 7: quantized grayscale (based on 95 color mode), 8: 8-bit grayscale (based on 9025 color mode)], combine attempts to do cross-frame delta coding. avoid_run_breaks reduces the amount of transparency pixels emitted by combine, typically harms efficiency
43
+ `apf2.encode(img: bytes | Image.Image, lineskip: int = 1, findbestlineskip: bool = False, legacy: bool = False, trans = False, pal: int = 95, desc: str = "", prepalette: str = None, dodithering: bool = False, returnbytes: bool = False, compress: bool = True, topside_first: bool = False, emit_redundant_flag: bool = False, mode: int = None, combine: bool = False, avoid_run_breaks: bool = False, width: int = None, height: int = None)`
44
+
45
+ takes image bytes or pil image object and outputs apf2 as a string or ascii bytes. lineskip is interleave value, findbestlineskip brute-forces different interleave values to the provided lineskip and uses the smallest one. legacy uses apf1-style 2 color data instead of a 95 color palette. trans sets transparency mode, with mode 0 being off, mode 1 being index 0 transparency (GIF-like, it overrides a color), and mode 2 being indexed alpha. pal allows you to manually force a smaller or larger palette anything over 95 will use APF2-1994's Dual Indexed Mode. Prepalette is an already encoded APF2 palette you input into the encoder to make the colors deterministic. dodithering enables or disables dithering. returnbytes makes it return bytes instead of a string. These options use the APF2-2000 mode: compress - disable RLE compression, allows for smaller file sizes where RLE is not beneficial. Topside_first makes the image scan top-to-bottom, like most formats. emit_redundant_flag - adds the implicit i flag explicitally to baseline 95 color images. mode - selects apf2 coding mode [0: 2 colors, 1: 95 colors, 2: 9025 colors, 3: near-truecolor, 4: near-truecolor with alpha, 5: truecolor, 6: truecolor with alpha, 7: quantized grayscale (based on 95 color mode), 8: 8-bit grayscale (based on 9025 color mode)], combine attempts to do cross-frame delta coding. avoid_run_breaks reduces the amount of transparency pixels emitted by combine, typically harms efficiency. width and height force the output resolution of the image. if both are specified, the output is streched to the specified resolution. if only 1 is present, the other is determined based on aspect ratio. If unspecfied, no scaling happens.
44
46
 
45
47
  `wbmp.encode(img: Image)` takes pil image object and outputs wbmp bytes
46
48
 
@@ -88,14 +90,20 @@ apftool provides many extensions tuples:
88
90
 
89
91
  `extensions_all` - all supported apftool extensions, useful for universal decoder
90
92
 
93
+ `version` - reports apftool version
94
+
91
95
  ## dependancies
92
96
 
93
97
  `PIL (pillow)` is required
94
98
 
95
- `numpy` and `scikit-learn` are needed for high-speed APF2-1994 257+ Color Encoding, if missing the code will use a very slow fallback.
99
+ `numpy` and `scipy` are needed for high-quality, high-speed APF2 encoding, if missing the code will use a very slow and low quality fallback.
96
100
 
97
101
  ## changelog:
98
102
 
103
+ 1.2.0 - increase apfcli verbosity, add lossy encoding options. Allow apfcli to take its own formats as inputs and outputs, also adds a much better quantization algorithm and removes the limitations on indexed animations.
104
+
105
+ 1.1.3 - add version field, add width and height options to apf2 encoding
106
+
99
107
  1.1.2 - fix apftool
100
108
 
101
109
  1.1.1 - fix A2K's q and n modes (~ should map to 255 in those modes)
@@ -5,6 +5,7 @@ setup.cfg
5
5
  apftool/__init__.py
6
6
  apftool/apf.py
7
7
  apftool/apf2.py
8
+ apftool/apf2quant.py
8
9
  apftool/apfcli.py
9
10
  apftool/bittools.py
10
11
  apftool/bruh.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "apftool"
7
- version = "1.1.2"
7
+ version = "1.2.0"
8
8
  description = "Encoder and decoder for Aperture Science Image Format (apf), Aperture Science Image Format 2 (apf2), Wireless Bitmap (wbmp), Over-The-Air Bitmap (otb), and FaceDev's .bruh format"
9
9
  authors = [{name = "Mari (kbity)"}]
10
10
  readme = "README.md"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes