apftool 1.1.3__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.3
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
@@ -96,10 +96,12 @@ apftool provides many extensions tuples:
96
96
 
97
97
  `PIL (pillow)` is required
98
98
 
99
- `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.
100
100
 
101
101
  ## changelog:
102
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
+
103
105
  1.1.3 - add version field, add width and height options to apf2 encoding
104
106
 
105
107
  1.1.2 - fix apftool
@@ -85,10 +85,12 @@ apftool provides many extensions tuples:
85
85
 
86
86
  `PIL (pillow)` is required
87
87
 
88
- `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.
89
89
 
90
90
  ## changelog:
91
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
+
92
94
  1.1.3 - add version field, add width and height options to apf2 encoding
93
95
 
94
96
  1.1.2 - fix apftool
@@ -13,7 +13,7 @@ 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.1.3"
16
+ version = "1.2.0"
17
17
 
18
18
  extensions_all = []
19
19
  extensions_all.extend(extensions_apf+extensions_apf2+extensions_wbmp+extensions_otb+extensions_bruh+extensions_mqif)
@@ -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, width: int = None, height: int = None):
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
@@ -1276,7 +1269,7 @@ def encode(img: bytes | Image.Image, lineskip: int = None, findbestlineskip: boo
1276
1269
  if legacy and not find_bgfg:
1277
1270
  img, frametiming = reduce_to_apf_in_apf2_quality(img, animated)
1278
1271
  elif not truecolor and not gray:
1279
- 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)
1280
1273
  else:
1281
1274
  img, frametiming = slice_frames(img, animated, trans)
1282
1275
 
@@ -1427,16 +1420,20 @@ def encode(img: bytes | Image.Image, lineskip: int = None, findbestlineskip: boo
1427
1420
 
1428
1421
  for lsk in range(lineskip):
1429
1422
  lsk+=1
1430
- 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)
1431
1426
  all_outputs[str(lsk)] = output
1432
1427
  for lsk in all_outputs:
1433
1428
  if outsize > len(all_outputs[lsk])+len(lsk):
1434
1429
  output = all_outputs[lsk]
1435
1430
  outsize = len(all_outputs[lsk])+len(lsk)
1436
1431
  lineskip = int(lsk)
1432
+ if verbose:
1433
+ print(f"Smallest: {lineskip}")
1437
1434
  metadata.append(str(lineskip))
1438
1435
  else:
1439
- 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)
1440
1437
 
1441
1438
 
1442
1439
  if desc or frametiming:
@@ -1468,6 +1465,17 @@ def encode(img: bytes | Image.Image, lineskip: int = None, findbestlineskip: boo
1468
1465
 
1469
1466
  apflist.append(output)
1470
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
+
1471
1479
  if returnbytes:
1472
1480
  return apftext.encode()
1473
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]
@@ -21,17 +21,22 @@ encoding:
21
21
  --lineskip=INT - (APF/APF2) how many lines are skipped in progressive scanning. (default is 1)
22
22
  --findbestlineskip - (APF/APF2) finds the lineskip value with the smallest file size. (APF2) search is limited to supplied lineskip argument (default is 10)
23
23
 
24
- --desc=STR - (APF2) file description
24
+ --desc=STR - (APF2) file description [Alias: -D=STR]
25
25
  --legacy - (APF2) use bi-level runs
26
26
  --transparent - (APF2) alias for --transmode=1
27
- --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]
28
28
  --palette=INT - (APF2) sets max colors. 2 will use legacy + bg.fg, >95 will use dual-indexed mode. 9025 is max.
29
- --dither - (APF2) enables dithering (off by default outside of legacy)
29
+ --dither - (APF2) enables dithering (off by default outside of legacy) [Alias: -D]
30
30
  --topdown - (APF2) reverses scan order
31
31
  --uncompressed - (APF2) disables RLE (not applicable for legacy mode)
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.
33
- --avoidrunbreaks - (APF2) avoid breaking runs on combine mode. May improve or harm efficiency.
34
- --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]
35
40
  0 - 2 colors
36
41
  1 - 95 colors
37
42
  2 - 9025 colors
@@ -42,8 +47,8 @@ encoding:
42
47
  7 - Mode 1-based Grayscale
43
48
  8 - Mode 2-based Grayscale
44
49
 
45
- --width - (OTB/APF2) sets width (default is 255)
46
- --height - (OTB/APF2) 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]
47
52
 
48
53
  --transcolor - (MQIF) sets color to be made transparent (default is None)
49
54
 
@@ -82,6 +87,10 @@ TODO: MQIF encoding
82
87
  mode = None
83
88
  combine = False
84
89
  avoid_run_breaks = False
90
+ maxmotionerror = 0
91
+ maxrunerror = 0
92
+ useadvancedquant = True
93
+ dither_mode = "fs"
85
94
  if args:
86
95
  if "--findbestlineskip" in args:
87
96
  fbls = True
@@ -89,35 +98,57 @@ TODO: MQIF encoding
89
98
  legacy = True
90
99
  if "--transparent" in args:
91
100
  trans = 1
92
- if "--dither" in args:
101
+ if "--dither" in args or "-D" in args:
93
102
  dither = True
94
103
  if "--topdown" in args:
95
104
  flip = True
96
105
  if "--uncompressed" in args:
97
106
  compress = False
98
- if "--combine" in args:
107
+ if "--combine" in args or "-C" in args:
99
108
  combine = True
100
109
  if "--magenta" in args:
101
110
  magenta = True
102
111
  if "--avoidrunbreaks" in args:
103
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"
104
117
  for arg in args:
105
- if arg.startswith("--desc="):
106
- description = arg.replace("--desc=", "")
118
+ if arg.startswith("--desc=") or arg.startswith("-D="):
119
+ description = arg.replace("--desc=", "").replace("-D=", "")
107
120
  if arg.startswith("--format="):
108
121
  forma = arg.replace("--format=", "")
109
- if arg.startswith("--transmode="):
110
- trans = int(arg.replace("--transmode=", ""))
122
+ if arg.startswith("--transmode=") or arg.startswith("-T="):
123
+ trans = int(arg.replace("--transmode=", "").replace("-T=", ""))
111
124
  if arg.startswith("--palette="):
112
125
  maxpalette = int(arg.replace("--palette=", ""))
113
126
  if arg.startswith("--lineskip="):
114
127
  lineskip = int(arg.replace("--lineskip=", ""))
115
- if arg.startswith("--width="):
116
- wid = int(arg.replace("--width=", ""))
117
- if arg.startswith("--height="):
118
- 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
+
119
134
  if arg.startswith("--mode="):
120
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
+
121
152
  if arg.startswith("--transcolor="):
122
153
  transcolor_hex = arg.replace("--transcolor=", "").replace("#", "")
123
154
  tcr = int(transcolor_hex[0:2], 16)
@@ -133,9 +164,29 @@ TODO: MQIF encoding
133
164
  maxpalette = 95
134
165
  mode = 6 if trans == 2 else 5
135
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
+
136
183
  # PNG to APF/APF2
137
- if ext in supported_extensions:
138
- 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)
139
190
 
140
191
  if opext in apftool.extensions_apf:
141
192
  encoded = apftool.apf.encode(img_bytes, lineskip=lineskip, findbestlineskip=fbls)
@@ -153,7 +204,7 @@ TODO: MQIF encoding
153
204
  encoded = apftool.mqif.encode(img_bytes, transcolor)
154
205
 
155
206
  elif opext in apftool.extensions_apf2:
156
- 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)
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)
157
208
 
158
209
  else:
159
210
  raise ValueError("Unsupported Image Format!")
@@ -162,7 +213,7 @@ TODO: MQIF encoding
162
213
  print(f"Encoded {input_path} -> {output_path}")
163
214
 
164
215
  # APF/APF2 to PNG
165
- elif ext in apftool.extensions_all:
216
+ elif opext in supported_extensions:
166
217
  with open(input_path, "rb") as f:
167
218
  apf_content = f.read()
168
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.3
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
@@ -96,10 +96,12 @@ apftool provides many extensions tuples:
96
96
 
97
97
  `PIL (pillow)` is required
98
98
 
99
- `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.
100
100
 
101
101
  ## changelog:
102
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
+
103
105
  1.1.3 - add version field, add width and height options to apf2 encoding
104
106
 
105
107
  1.1.2 - fix apftool
@@ -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.3"
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