#!/usr/bin/env python3 """ test_pillow.py — standalone pure-Python functional test for the android wheels. Works with both Pillow (12.3.0) and Pillow-SIMD (9.5.0.post2). Only uses the Python stdlib plus the PIL package itself (no numpy / third-party deps). Run on the device/emulator: python /path/to/test_pillow.py Exit code: 0 = all critical tests passed, 1 = at least one failure. Optional/skippable tests (missing font, Tk/Qt, missing codec feature) are reported as SKIP and do not affect the exit code. Generated by RIMI """ import os import sys import tempfile import traceback import io # -------------------------------------------------------------------------- # tiny test harness # -------------------------------------------------------------------------- RESULTS = [] # (kind, name, detail) KIND_OK = "ok" KIND_FAIL = "fail" KIND_SKIP = "skip" def check(name, fn, *args, **kwargs): try: fn(*args, **kwargs) RESULTS.append((KIND_OK, name, "")) except SkipTest as e: RESULTS.append((KIND_SKIP, name, str(e))) except Exception: RESULTS.append((KIND_FAIL, name, traceback.format_exc().strip())) class SkipTest(Exception): pass def require(cond, why): if not cond: raise SkipTest(why) def ok_or_skip(cond, why): if not cond: raise SkipTest(why) # -------------------------------------------------------------------------- # 1. imports # -------------------------------------------------------------------------- CORE_MODULES = [ "PIL", "PIL._version", "PIL._binary", "PIL._util", "PIL._deprecate", "PIL.Image", "PIL.ImageMode", "PIL.ImageFile", "PIL.ImageFilter", "PIL.ImageDraw", "PIL.ImageDraw2", "PIL.ImageFont", "PIL.ImageColor", "PIL.ImageChops", "PIL.ImageEnhance", "PIL.ImageOps", "PIL.ImageStat", "PIL.ImageMath", "PIL.ImagePath", "PIL.ImageSequence", "PIL.ImageMorph", "PIL.ImageTransform", "PIL.ImagePalette", "PIL.ImageShow", "PIL.ImageGrab", "PIL.ExifTags", "PIL.TiffTags", "PIL.features", "PIL.BdfFontFile", "PIL.FontFile", "PIL.PcfFontFile", "PIL.TarIO", "PIL.WalImageFile", # plugins "PIL.BlpImagePlugin", "PIL.BmpImagePlugin", "PIL.BufrStubImagePlugin", "PIL.CurImagePlugin", "PIL.DcxImagePlugin", "PIL.DdsImagePlugin", "PIL.EpsImagePlugin", "PIL.FliImagePlugin", "PIL.FtexImagePlugin", "PIL.GbrImagePlugin", "PIL.GdImageFile", "PIL.GifImagePlugin", "PIL.GimpGradientFile", "PIL.GimpPaletteFile", "PIL.GribStubImagePlugin", "PIL.Hdf5StubImagePlugin", "PIL.IcnsImagePlugin", "PIL.IcoImagePlugin", "PIL.ImImagePlugin", "PIL.ImtImagePlugin", "PIL.IptcImagePlugin", "PIL.Jpeg2KImagePlugin", "PIL.JpegImagePlugin", "PIL.JpegPresets", "PIL.McIdasImagePlugin", "PIL.MpegImagePlugin", "PIL.MpoImagePlugin", "PIL.MspImagePlugin", "PIL.PalmImagePlugin", "PIL.PcdImagePlugin", "PIL.PcxImagePlugin", "PIL.PdfImagePlugin", "PIL.PdfParser", "PIL.PixarImagePlugin", "PIL.PngImagePlugin", "PIL.PpmImagePlugin", "PIL.PsdImagePlugin", "PIL.PSDraw", "PIL.QoiImagePlugin", "PIL.SgiImagePlugin", "PIL.SpiderImagePlugin", "PIL.SunImagePlugin", "PIL.TgaImagePlugin", "PIL.TiffImagePlugin", "PIL.WebPImagePlugin", "PIL.WmfImagePlugin", "PIL.XbmImagePlugin", "PIL.XpmImagePlugin", "PIL.XVThumbImagePlugin", ] OPTIONAL_MODULES = [ # these may legitimately fail to import on a headless/android build ("PIL.ImageQt", "Qt bindings not installed"), ("PIL.ImageTk", "tkinter not available"), ("PIL.ImageWin", "Windows-only"), ("PIL.MicImagePlugin", "requires olefile (optional dep)"), ("PIL.FitsStubImagePlugin", "stub module absent in Pillow 12.3"), ("PIL.FpxImagePlugin", "requires olefile (optional dep)"), ] FAILED_IMPORTS = [] def test_imports(): for name in CORE_MODULES: try: __import__(name) except Exception: FAILED_IMPORTS.append(name) RESULTS.append((KIND_FAIL, "import " + name, traceback.format_exc().strip())) for name, why in OPTIONAL_MODULES: try: __import__(name) except Exception: RESULTS.append((KIND_SKIP, "import " + name, why)) # -------------------------------------------------------------------------- # 2. version / features # -------------------------------------------------------------------------- def test_version_and_features(): import PIL from PIL import features ver = getattr(PIL, "__version__", "?") print(f"[info] PIL package: {ver}") print(f"[info] PIL.__file__: {PIL.__file__}") print(f"[info] PIL.__version__: {ver}") feature_names = features.get_supported() print("[info] supported features: " + ", ".join(sorted(feature_names))) for feat in ("jpg", "jpg_2000", "zlib", "libtiff", "webp", "webp_mux", "libimagequant", "raqm", "freetype2", "littlecms2", "png"): try: avail = features.check(feat) except Exception: avail = None print(f"[info] feature {feat}: {avail}") # sanity: extension modules were actually imported (native build present) from PIL import Image as _Img core = _Img.core assert hasattr(core, "blend"), "_imaging native module not loaded" from PIL import _imagingft, _imagingcms, _webp, _imagingmath, _imagingmorph for mod, pyinit in ((_imagingft, "getfont"), (_imagingcms, "createProfile"), (_webp, "WebPEncode"), (_imagingmath, "unop"), (_imagingmorph, "apply")): assert any(hasattr(mod, n) for n in (pyinit,)), f"{mod.__name__} looks empty — attrs: {[a for a in dir(mod) if not a.startswith('_')][:10]}" RESULTS.append((KIND_OK, "version+features+native modules", "")) # -------------------------------------------------------------------------- # helpers # -------------------------------------------------------------------------- def make_test_image(mode="RGB", size=(64, 48)): from PIL import Image im = Image.new(mode, size) px = im.load() w, h = size for y in range(h): for x in range(w): if mode == "L": px[x, y] = (x * 3 + y * 5) % 256 elif mode == "RGB": px[x, y] = ((x * 4) % 256, (y * 6) % 256, (x * y) % 256) elif mode == "RGBA": px[x, y] = ((x * 4) % 256, (y * 6) % 256, (x * y) % 256, (x + y) % 256) elif mode == "1": px[x, y] = (x + y) % 2 elif mode == "P": px[x, y] = (x + y) % 256 else: px[x, y] = (x * y) % 256 return im def roundtrip(name, ext, save_kwargs, load_kwargs=None, mode="RGB", check_size=None): from PIL import Image im = make_test_image(mode) tmpdir = tempfile.mkdtemp(prefix="pillow_test_") try: path = os.path.join(tmpdir, "img" + ext) im.save(path, **save_kwargs) with Image.open(path, **(load_kwargs or {})) as out: out.load() sz = check_size or im.size assert out.size == sz, f"size {out.size} != {sz}" assert out.format, "format not detected" with open(path, "rb") as fh: buf = io.BytesIO(fh.read()) with Image.open(buf) as out2: out2.load() assert out2.size == sz RESULTS.append((KIND_OK, "roundtrip " + ext, "")) finally: import shutil shutil.rmtree(tmpdir, ignore_errors=True) def roundtrip_assert_approx(im1, im2, tol=6, frac=0.05): """Check pixel match within tolerance on a sampled grid.""" from PIL import Image assert im1.size == im2.size, f"size mismatch {im1.size} vs {im2.size}" p1 = im1.convert("RGB").load() p2 = im2.convert("RGB").load() w, h = im1.size mism = 0 total = 0 for y in range(0, h, max(1, h // 8)): for x in range(0, w, max(1, w // 8)): total += 1 c1 = p1[x, y] c2 = p2[x, y] if max(abs(a - b) for a, b in zip(c1, c2)) > tol: mism += 1 assert mism <= max(1, total * frac), f"{mism}/{total} pixels differ > {tol}" # -------------------------------------------------------------------------- # 3. codec round-trips (needs merged libpillow_codecs.so + zlib) # -------------------------------------------------------------------------- def test_codecs(): from PIL import features def codec(cond, why): if not cond: raise SkipTest(why) codec(features.check("zlib") or True, "") # png needs zlib (always present) codec(features.check("jpg"), "no jpeg") roundtrip("jpeg", ".jpg", {"quality": 90}) roundtrip("jpeg-gray", ".jpg", {"quality": 90}, mode="L") codec(features.check("zlib"), "no zlib") roundtrip("png", ".png", {}) roundtrip("png-rgba", ".png", {}, mode="RGBA") roundtrip("png-palette", ".png", {}, mode="P") codec(features.check("zlib"), "no zlib") roundtrip("gif", ".gif", {}, mode="P") roundtrip("tiff-lzw", ".tiff", {"compression": "tiff_lzw"}) roundtrip("tiff-packbits", ".tiff", {"compression": "packbits"}) codec(features.check("libtiff"), "no libtiff") roundtrip("tiff-none", ".tiff", {}) codec(features.check("webp"), "no webp") roundtrip("webp-lossless", ".webp", {"lossless": True}) roundtrip("webp-lossy", ".webp", {"quality": 80}) codec(features.check("jpg_2000"), "no openjpeg") roundtrip("jpeg2000", ".jp2", {"irreversible": False}) roundtrip("bmp", ".bmp", {}) roundtrip("ppm", ".ppm", {}) roundtrip("pcx", ".pcx", {}) roundtrip("tga", ".tga", {}) roundtrip("dib", ".dib", {}) roundtrip("sgi", ".sgi", {}, mode="L") roundtrip("xbm", ".xbm", {}, mode="1") # ICO / ICO with embedded PNG from PIL import Image as _Image ico = make_test_image("RGB").resize((64, 48)) _tmp_ico = io.BytesIO() ico.save(_tmp_ico, format="ICO", sizes=[(16, 16), (32, 32)]) _tmp_ico.seek(0) ico_back = _Image.open(_tmp_ico) assert ico_back.size[0] <= 64 and ico_back.size[1] <= 48 and ico_back.size[0] > 0 assert ico_back.load()[0, 0] is not None RESULTS.append((KIND_OK, "codec round-trips", "")) # -------------------------------------------------------------------------- # 4. core image API # -------------------------------------------------------------------------- def test_core_api(): from PIL import (Image, ImageFilter, ImageChops, ImageEnhance, ImageStat, ImagePalette, ImageOps) im = make_test_image("RGB") w, h = im.size # copy / crop / resize / rotate / transpose im.copy().crop((0, 0, 10, 10)).load() r = im.resize((32, 24)) assert r.size == (32, 24) r = im.resize((16, 12), getattr(Image, "Resampling", object).LANCZOS) assert r.size == (16, 12) assert im.rotate(90, expand=True).size == (h, w) assert im.transpose(Image.Transpose.FLIP_LEFT_RIGHT).size == (w, h) # point / convert / quantize / palette g = im.convert("L") g.point(lambda v: 255 - v) g.convert("1") q = im.quantize(colors=16, method=Image.Quantize.MEDIANCUT) assert q.mode == "P" if hasattr(Image.Quantize, "LIBIMAGEQUANT"): try: q2 = im.quantize(colors=16, method=Image.Quantize.LIBIMAGEQUANT) assert q2.mode == "P" except Exception: raise SkipTest("LIBIMAGEQUANT quantize failed at runtime") # split / merge bands = im.split() merged = Image.merge("RGB", bands) assert merged.size == im.size # paste / composite / alpha im2 = Image.new("RGB", im.size, (255, 0, 0)) im2.paste(im, (0, 0)) composite = Image.composite(im, im2, Image.new("L", im.size, 128)) assert composite.size == im.size overlay = make_test_image("RGBA") base = Image.new("RGBA", im.size, (0, 0, 0, 255)) base.alpha_composite(overlay) assert base.size == im.size # filters for f in (ImageFilter.BLUR, ImageFilter.GaussianBlur(2.0), ImageFilter.BoxBlur(2), ImageFilter.SMOOTH, ImageFilter.SHARPEN, ImageFilter.EDGE_ENHANCE, ImageFilter.FIND_EDGES, ImageFilter.EMBOSS, ImageFilter.CONTOUR, ImageFilter.UnsharpMask(radius=2, percent=120), ImageFilter.MaxFilter(3), ImageFilter.MedianFilter(3), ImageFilter.MinFilter(3), ImageFilter.ModeFilter(3)): fimg = im.filter(f) assert fimg.size == im.size # convolution kernel kern = [1 / 9] * 9 assert im.filter(ImageFilter.Kernel((3, 3), kern)).size == im.size # histogram / stat / getbbox im.histogram() ImageStat.Stat(im) assert im.getbbox() is not None # enhancers / chops ImageEnhance.Brightness(im).enhance(1.2) ImageEnhance.Contrast(im).enhance(1.2) ImageEnhance.Color(im).enhance(1.2) ImageEnhance.Sharpness(im).enhance(1.5) ImageChops.add(im, im2) ImageChops.subtract(im, im2) ImageChops.difference(im, im2) ImageChops.multiply(im, im2) ImageChops.offset(im, 5, 5) ImageChops.invert(im) # pixel access, getdata/putdata, getpixel/putpixel pa = im.load() px0 = pa[0, 0] pa[0, 0] = (0, 0, 0) pa[0, 0] = px0 im.putpixel((1, 1), im.getpixel((1, 1))) im.getdata() # ops ImageOps.invert(g) ImageOps.autocontrast(g) ImageOps.equalize(g) ImageOps.grayscale(im) ImageOps.flip(im) ImageOps.mirror(im) ImageOps.crop(im, border=2) ImageOps.scale(im, 0.5) ImageOps.fit(im, (20, 20)) ImageOps.pad(im, (20, 20)) ImageOps.expand(im, border=2, fill=0) # transform im.transform((32, 24), Image.Transform.AFFINE, (1, 0, 0, 0, 1, 0)) im.transform((32, 24), Image.Transform.QUAD, (0, 0, 0, h, w, h, w, 0)) # info / metadata style assert im.format is None assert im.mode == "RGB" assert im.size == (w, h) RESULTS.append((KIND_OK, "core image API", "")) # -------------------------------------------------------------------------- # 5. ImageDraw + ImageFont (freetype) # -------------------------------------------------------------------------- def find_font(): candidates = [ "/system/fonts/Roboto-Regular.ttf", "/system/fonts/Roboto-Medium.ttf", "/system/fonts/DroidSans.ttf", "/system/fonts/NotoSans-Regular.ttf", "/system/fonts/NotoSansCJK-Regular.ttc", ] import glob for c in candidates: if os.path.isfile(c): return c hits = sorted(glob.glob("/system/fonts/*.ttf")) return hits[0] if hits else None def test_draw_font(): from PIL import Image, ImageDraw, ImageFont font_path = find_font() im = Image.new("RGB", (200, 100), "white") d = ImageDraw.Draw(im) d.ellipse((10, 10, 50, 50), fill="red", outline="blue") d.rectangle((60, 10, 120, 50), fill="green") d.line((130, 10, 190, 50), fill="black", width=3) d.polygon([(10, 60), (60, 90), (110, 60)], fill="orange") d.point((150, 80), fill="purple") d.arc((160, 60, 190, 90), start=0, end=180, fill="black") d.rounded_rectangle((10, 60, 80, 95), radius=5, fill="cyan") if font_path is None: raise SkipTest("no system font available") font = ImageFont.truetype(font_path, 18) assert hasattr(font, "getbbox") or hasattr(font, "getsize") d.text((10, 10), "Hello PIL", font=font, fill="black") d.multiline_text((10, 40), "Line1\nLine2", font=font, fill="black") if hasattr(d, "textbbox"): d.textbbox((10, 10), "Hello", font=font) if hasattr(d, "textlength"): d.textlength("Hello", font=font) if hasattr(d, "textsize"): d.textsize("Hello", font=font) # draw2 from PIL import ImageDraw2 im2 = Image.new("RGB", (100, 60), "white") d2 = ImageDraw2.Draw(im2) d2.polygon([(10, 10), (40, 50), (70, 10)], ImageDraw2.Brush("blue")) d2.line([(0, 0), (90, 50)], ImageDraw2.Pen("black", 2)) RESULTS.append((KIND_OK, "ImageDraw + ImageFont", "")) # -------------------------------------------------------------------------- # 6. ImageMath / ImagePath / ImageMorph / ImageSequence # -------------------------------------------------------------------------- def test_misc_modules(): from PIL import Image, ImageMath, ImagePath, ImageSequence l1 = make_test_image("L") l2 = l1.point(lambda v: v // 2 + 10) if hasattr(ImageMath, 'eval'): res = ImageMath.eval("a + b", a=l1, b=l2) elif hasattr(ImageMath, 'unsafe_eval'): res = ImageMath.unsafe_eval("a + b", a=l1, b=l2) else: raise SkipTest("ImageMath.eval/unsafe_eval not available") assert res.size == l1.size p = ImagePath.Path([(0, 0), (10, 0), (10, 10), (0, 10)]) assert len(p) == 4 p.transform((1, 0, 1, 0, 1, 1)) seq = list(ImageSequence.Iterator(l1)) assert len(seq) == 1 # ImageMorph from PIL import ImageMorph mm = ImageMorph.MorphOp(op_name="dilation4") bc, out = mm.apply(l1.convert("L").point(lambda v: 255 if v > 128 else 0)) assert out.size == l1.size mm2 = ImageMorph.MorphOp(lut=mm.lut) _, out2 = mm2.apply(l1.convert("L").point(lambda v: 255 if v > 128 else 0)) assert out2.size == l1.size mm.match(l1.convert("L").point(lambda v: 255 if v > 128 else 0)) RESULTS.append((KIND_OK, "ImageMath/Path/Morph/Sequence", "")) # -------------------------------------------------------------------------- # 7. ImageCms (littlecms2) # -------------------------------------------------------------------------- def test_cms(): try: from PIL import ImageCms except ImportError: raise SkipTest("no PIL.ImageCms") from PIL import Image sRGB = ImageCms.createProfile("sRGB") lab = ImageCms.createProfile("LAB") tf = ImageCms.buildTransform(sRGB, lab, "RGB", "LAB") im = make_test_image("RGB") out = ImageCms.applyTransform(im, tf) assert out.size == im.size try: prof = ImageCms.ImageCmsProfile(sRGB) assert prof.profile.profile_id except Exception: pass RESULTS.append((KIND_OK, "ImageCms (littlecms)", "")) # -------------------------------------------------------------------------- # 8. numpy interop (optional, only if numpy installed) # -------------------------------------------------------------------------- def test_numpy(): try: import numpy as np except ImportError: raise SkipTest("numpy not installed") from PIL import Image arr = np.zeros((32, 32, 3), dtype=np.uint8) arr[..., 0] = 255 im = Image.fromarray(arr) assert im.size == (32, 32) and im.mode == "RGB" back = np.asarray(im) assert back.shape == (32, 32, 3) assert back[0, 0, 0] == 255 RESULTS.append((KIND_OK, "numpy interop", "")) # -------------------------------------------------------------------------- # 9. pillow-simd specific exercise (resample/boxblur/reduce paths) # -------------------------------------------------------------------------- def test_simd_paths(): from PIL import Image, ImageFilter im = make_test_image("RGB", (256, 192)) Res = getattr(Image, "Resampling", None) if Res is None: raise SkipTest("Resampling enum missing") for filt in (Res.BOX, Res.HAMMING, Res.BILINEAR, Res.BICUBIC, Res.LANCZOS): small = im.resize((48, 36), filt) assert small.size == (48, 36) up = small.resize((256, 192), filt) assert up.size == (256, 192) # reduce via thumbnail th = im.copy() th.thumbnail((32, 32)) assert max(th.size) <= 32 # box blur for n in (1, 2, 3): assert im.filter(ImageFilter.BoxBlur(n)).size == im.size # unsharp mask (uses Reduce-like / convolution paths in SIMD builds) assert im.filter(ImageFilter.UnsharpMask(radius=3, percent=150)).size == im.size # rank / min / max filters for f in (ImageFilter.MinFilter(3), ImageFilter.MaxFilter(3), ImageFilter.MedianFilter(3)): assert im.filter(f).size == im.size # ImageOps scale/fit (uses resize internally) ImageOps_scale = __import__("PIL.ImageOps", fromlist=["scale"]) ImageOps_scale.scale(im, 0.5) ImageOps_scale.fit(im, (24, 24)) # color LUT / point LUT path lut = list(range(256)) * 3 assert im.point(lut).size == im.size RESULTS.append((KIND_OK, "SIMD/exercise paths", "")) # -------------------------------------------------------------------------- # main # -------------------------------------------------------------------------- def main(): print("=" * 64) print("Pillow / Pillow-SIMD functional test") print("python:", sys.version.split()[0]) print("platform:", sys.platform) print("=" * 64) test_imports() check("version+features", test_version_and_features) check("codecs", test_codecs) check("core API", test_core_api) check("draw+font", test_draw_font) check("misc modules", test_misc_modules) check("ImageCms", test_cms) check("numpy interop", test_numpy) check("SIMD paths", test_simd_paths) print() print("=" * 64) ok = sum(1 for k, _, _ in RESULTS if k == KIND_OK) fail = sum(1 for k, _, _ in RESULTS if k == KIND_FAIL) skip = sum(1 for k, _, _ in RESULTS if k == KIND_SKIP) print(f"RESULT: {ok} ok, {fail} failed, {skip} skipped (of {len(RESULTS)} total)") # print any skips for k, name, detail in RESULTS: if k == KIND_SKIP: print(f" [skip] {name}: {detail}") # print failures with traceback for k, name, detail in RESULTS: if k == KIND_FAIL: print() print(f"### FAIL: {name}") print(detail) # separate top-level import failures if FAILED_IMPORTS: print() print("### FAILED MODULE IMPORTS:") for n in FAILED_IMPORTS: print(" " + n) print("=" * 64) sys.exit(1 if fail else 0) if __name__ == "__main__": main()