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.
- {apftool-1.1.3 → apftool-1.2.0}/PKG-INFO +4 -2
- {apftool-1.1.3 → apftool-1.2.0}/README.md +3 -1
- {apftool-1.1.3 → apftool-1.2.0}/apftool/__init__.py +1 -1
- {apftool-1.1.3 → apftool-1.2.0}/apftool/apf2.py +137 -129
- apftool-1.2.0/apftool/apf2quant.py +236 -0
- {apftool-1.1.3 → apftool-1.2.0}/apftool/apfcli.py +73 -22
- {apftool-1.1.3 → apftool-1.2.0}/apftool.egg-info/PKG-INFO +4 -2
- {apftool-1.1.3 → apftool-1.2.0}/apftool.egg-info/SOURCES.txt +1 -0
- {apftool-1.1.3 → apftool-1.2.0}/pyproject.toml +1 -1
- {apftool-1.1.3 → apftool-1.2.0}/LICENSE +0 -0
- {apftool-1.1.3 → apftool-1.2.0}/apftool/apf.py +0 -0
- {apftool-1.1.3 → apftool-1.2.0}/apftool/bittools.py +0 -0
- {apftool-1.1.3 → apftool-1.2.0}/apftool/bruh.py +0 -0
- {apftool-1.1.3 → apftool-1.2.0}/apftool/mqif.py +0 -0
- {apftool-1.1.3 → apftool-1.2.0}/apftool/otb.py +0 -0
- {apftool-1.1.3 → apftool-1.2.0}/apftool/scqoi.py +0 -0
- {apftool-1.1.3 → apftool-1.2.0}/apftool/wbmp.py +0 -0
- {apftool-1.1.3 → apftool-1.2.0}/apftool.egg-info/dependency_links.txt +0 -0
- {apftool-1.1.3 → apftool-1.2.0}/apftool.egg-info/entry_points.txt +0 -0
- {apftool-1.1.3 → apftool-1.2.0}/apftool.egg-info/requires.txt +0 -0
- {apftool-1.1.3 → apftool-1.2.0}/apftool.egg-info/top_level.txt +0 -0
- {apftool-1.1.3 → apftool-1.2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: apftool
|
|
3
|
-
Version: 1.
|
|
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 `
|
|
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 `
|
|
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.
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
--
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
138
|
-
|
|
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
|
|
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.
|
|
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 `
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "apftool"
|
|
7
|
-
version = "1.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|