From 0177a9b4025164b9d961f65fd71a49077817d8a2 Mon Sep 17 00:00:00 2001 From: "Adam R. Nelson" Date: Mon, 11 Aug 2025 13:28:01 -0400 Subject: [PATCH] Add RAW file thumbnailing support via rawpy (#567) * add RAW image file types to mimetype list * add RAW thumbnailer via rawpy --------- Signed-off-by: Adam R. Nelson Signed-off-by: ed --- README.md | 3 + copyparty/__main__.py | 4 +- copyparty/svchub.py | 4 ++ copyparty/th_cli.py | 6 +- copyparty/th_srv.py | 127 +++++++++++++++++++++++++++++++++--------- copyparty/util.py | 3 + docs/chungus.conf | 5 +- 7 files changed, 122 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 8f470a50..b3a2d134 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,7 @@ also see [comparison to similar software](./docs/versus.md) * ☑ realtime streaming of growing files (logfiles and such) * ☑ [thumbnails](#thumbnails) * ☑ ...of images using Pillow, pyvips, or FFmpeg + * ☑ ...of RAW images using rawpy * ☑ ...of videos using FFmpeg * ☑ ...of audio (spectrograms) using FFmpeg * ☑ cache eviction (max-age; maybe max-size eventually) @@ -2795,6 +2796,7 @@ enable [thumbnails](#thumbnails) of... * **HEIF pictures:** `pyvips` or `ffmpeg` or `pyheif-pillow-opener` (requires Linux or a C compiler) * **AVIF pictures:** `pyvips` or `ffmpeg` or `pillow-avif-plugin` or pillow v11.3+ * **JPEG XL pictures:** `pyvips` or `ffmpeg` +* **RAW images:** `rawpy`, plus one of `pyvips` or `Pillow` (for some formats) enable sending [zeromq messages](#zeromq) from event-hooks: `pyzmq` @@ -2828,6 +2830,7 @@ set any of the following environment variables to disable its associated optiona | `PRTY_NO_PIL_HEIF` | disable 3rd-party Pillow plugin for [HEIF support](https://pypi.org/project/pyheif-pillow-opener/) | | `PRTY_NO_PIL_WEBP` | disable use of native webp support in Pillow | | `PRTY_NO_PSUTIL` | do not use [psutil](https://pypi.org/project/psutil/) for reaping stuck hooks and plugins on Windows | +| `PRTY_NO_RAW` | disable all [rawpy](https://pypi.org/project/rawpy/)-based thumbnail support for RAW images | | `PRTY_NO_VIPS` | disable all [libvips](https://pypi.org/project/pyvips/)-based thumbnail support; will fallback to Pillow or ffmpeg | example: `PRTY_NO_PIL=1 python3 copyparty-sfx.py` diff --git a/copyparty/__main__.py b/copyparty/__main__.py index be7c9979..946b3d0a 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1431,7 +1431,7 @@ def add_thumbnail(ap): ap2.add_argument("--th-ram-max", metavar="GB", type=float, default=th_ram, help="max memory usage (GiB) permitted by thumbnailer; not very accurate") ap2.add_argument("--th-crop", metavar="TXT", type=u, default="y", help="crop thumbnails to 4:3 or keep dynamic height; client can override in UI unless force. [\033[32my\033[0m]=crop, [\033[32mn\033[0m]=nocrop, [\033[32mfy\033[0m]=force-y, [\033[32mfn\033[0m]=force-n (volflag=crop)") ap2.add_argument("--th-x3", metavar="TXT", type=u, default="n", help="show thumbs at 3x resolution; client can override in UI unless force. [\033[32my\033[0m]=yes, [\033[32mn\033[0m]=no, [\033[32mfy\033[0m]=force-yes, [\033[32mfn\033[0m]=force-no (volflag=th3x)") - ap2.add_argument("--th-dec", metavar="LIBS", default="vips,pil,ff", help="image decoders, in order of preference") + ap2.add_argument("--th-dec", metavar="LIBS", default="vips,pil,raw,ff", help="image decoders, in order of preference") ap2.add_argument("--th-no-jpg", action="store_true", help="disable jpg output") ap2.add_argument("--th-no-webp", action="store_true", help="disable webp output") ap2.add_argument("--th-ff-jpg", action="store_true", help="force jpg output for video thumbs (avoids issues on some FFmpeg builds)") @@ -1442,9 +1442,11 @@ def add_thumbnail(ap): ap2.add_argument("--th-covers", metavar="N,N", type=u, default="folder.png,folder.jpg,cover.png,cover.jpg", help="folder thumbnails to stat/look for; enabling \033[33m-e2d\033[0m will make these case-insensitive, and try them as dotfiles (.folder.jpg), and also automatically select thumbnails for all folders that contain pics, even if none match this pattern") # https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html # https://github.com/libvips/libvips + # https://stackoverflow.com/a/47612661 # ffmpeg -hide_banner -demuxers | awk '/^ D /{print$2}' | while IFS= read -r x; do ffmpeg -hide_banner -h demuxer=$x; done | grep -E '^Demuxer |extensions:' ap2.add_argument("--th-r-pil", metavar="T,T", type=u, default="avif,avifs,blp,bmp,cbz,dcx,dds,dib,emf,eps,epub,fits,flc,fli,fpx,gif,heic,heics,heif,heifs,icns,ico,im,j2p,j2k,jp2,jpeg,jpg,jpx,pbm,pcx,pgm,png,pnm,ppm,psd,qoi,sgi,spi,tga,tif,tiff,webp,wmf,xbm,xpm", help="image formats to decode using pillow") ap2.add_argument("--th-r-vips", metavar="T,T", type=u, default="avif,exr,fit,fits,fts,gif,hdr,heic,jp2,jpeg,jpg,jpx,jxl,nii,pfm,pgm,png,ppm,svg,tif,tiff,webp", help="image formats to decode using pyvips") + ap2.add_argument("--th-r-raw", metavar="T,T", type=u, default="arw,cr2,crw,dcr,dng,erf,k25,kdc,mrw,nef,orf,pef,raf,raw,sr2,srf,x3f", help="image formats to decode using rawpy") ap2.add_argument("--th-r-ffi", metavar="T,T", type=u, default="apng,avif,avifs,bmp,cbz,dds,dib,epub,fit,fits,fts,gif,hdr,heic,heics,heif,heifs,icns,ico,jp2,jpeg,jpg,jpx,jxl,pbm,pcx,pfm,pgm,png,pnm,ppm,psd,qoi,sgi,tga,tif,tiff,webp,xbm,xpm", help="image formats to decode using ffmpeg") ap2.add_argument("--th-r-ffv", metavar="T,T", type=u, default="3gp,asf,av1,avc,avi,flv,h264,h265,hevc,m4v,mjpeg,mjpg,mkv,mov,mp4,mpeg,mpeg2,mpegts,mpg,mpg2,mts,nut,ogm,ogv,rm,ts,vob,webm,wmv", help="video formats to decode using ffmpeg") ap2.add_argument("--th-r-ffa", metavar="T,T", type=u, default="aac,ac3,aif,aiff,alac,alaw,amr,apac,ape,au,bonk,dfpwm,dts,flac,gsm,ilbc,it,itgz,itxz,itz,m4a,mdgz,mdxz,mdz,mo3,mod,mp2,mp3,mpc,mptm,mt2,mulaw,oga,ogg,okt,opus,ra,s3m,s3gz,s3xz,s3z,tak,tta,ulaw,wav,wma,wv,xm,xmgz,xmxz,xmz,xpk", help="audio formats to decode using ffmpeg") diff --git a/copyparty/svchub.py b/copyparty/svchub.py index 1af13276..5e0ab886 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -40,6 +40,7 @@ from .th_srv import ( HAVE_PIL, HAVE_VIPS, HAVE_WEBP, + HAVE_RAW, ThumbSrv, ) from .up2k import Up2k @@ -321,6 +322,8 @@ class SvcHub(object): decs.pop("vips", None) if not HAVE_PIL: decs.pop("pil", None) + if not HAVE_RAW: + decs.pop("raw", None) if not HAVE_FFMPEG or not HAVE_FFPROBE: decs.pop("ff", None) @@ -811,6 +814,7 @@ class SvcHub(object): (HAVE_ZMQ, "pyzmq", "send zeromq messages from event-hooks"), (HAVE_HEIF, "pillow-heif", "read .heif images with pillow (rarely useful)"), (HAVE_AVIF, "pillow-avif", "read .avif images with pillow (rarely useful)"), + (HAVE_RAW, "rawpy", "read RAW images"), ] if ANYWIN: to_check += [ diff --git a/copyparty/th_cli.py b/copyparty/th_cli.py index 6c2632b5..384316ee 100644 --- a/copyparty/th_cli.py +++ b/copyparty/th_cli.py @@ -36,11 +36,15 @@ class ThumbCli(object): if not c: raise Exception() except: - c = {k: set() for k in ["thumbable", "pil", "vips", "ffi", "ffv", "ffa"]} + c = { + k: set() + for k in ["thumbable", "pil", "vips", "raw", "ffi", "ffv", "ffa"] + } self.thumbable = c["thumbable"] self.fmt_pil = c["pil"] self.fmt_vips = c["vips"] + self.fmt_raw = c["raw"] self.fmt_ffi = c["ffi"] self.fmt_ffv = c["ffv"] self.fmt_ffa = c["ffa"] diff --git a/copyparty/th_srv.py b/copyparty/th_srv.py index 7b07376a..b37591c6 100644 --- a/copyparty/th_srv.py +++ b/copyparty/th_srv.py @@ -2,6 +2,7 @@ from __future__ import print_function, unicode_literals import hashlib +import io import logging import os import re @@ -121,6 +122,17 @@ try: except: HAVE_VIPS = False +try: + if os.environ.get("PRTY_NO_RAW"): + raise Exception() + + HAVE_RAW = True + import rawpy + + logging.getLogger("rawpy").setLevel(logging.WARNING) +except: + HAVE_RAW = False + th_dir_cache = {} @@ -205,11 +217,19 @@ class ThumbSrv(object): if self.args.th_clean: Daemon(self.cleaner, "thumb.cln") - self.fmt_pil, self.fmt_vips, self.fmt_ffi, self.fmt_ffv, self.fmt_ffa = [ + ( + self.fmt_pil, + self.fmt_vips, + self.fmt_raw, + self.fmt_ffi, + self.fmt_ffv, + self.fmt_ffa, + ) = [ set(y.split(",")) for y in [ self.args.th_r_pil, self.args.th_r_vips, + self.args.th_r_raw, self.args.th_r_ffi, self.args.th_r_ffv, self.args.th_r_ffa, @@ -232,6 +252,9 @@ class ThumbSrv(object): if "vips" in self.args.th_dec: self.thumbable |= self.fmt_vips + if "raw" in self.args.th_dec: + self.thumbable |= self.fmt_raw + if "ff" in self.args.th_dec: for zss in [self.fmt_ffi, self.fmt_ffv, self.fmt_ffa]: self.thumbable |= zss @@ -313,6 +336,7 @@ class ThumbSrv(object): "thumbable": self.thumbable, "pil": self.fmt_pil, "vips": self.fmt_vips, + "raw": self.fmt_raw, "ffi": self.fmt_ffi, "ffv": self.fmt_ffv, "ffa": self.fmt_ffa, @@ -368,6 +392,8 @@ class ThumbSrv(object): funs.append(self.conv_pil) elif lib == "vips" and ext in self.fmt_vips: funs.append(self.conv_vips) + elif lib == "raw" and ext in self.fmt_raw: + funs.append(self.conv_raw) elif can_au and (want_png or want_au): if want_opus: funs.append(self.conv_opus) @@ -480,35 +506,38 @@ class ThumbSrv(object): return im + def conv_image_pil(self, im: "Image.Image", tpath: str, fmt: str, vn: VFS) -> None: + try: + im = self.fancy_pillow(im, fmt, vn) + except Exception as ex: + self.log("fancy_pillow {}".format(ex), "90") + im.thumbnail(self.getres(vn, fmt)) + + fmts = ["RGB", "L"] + args = {"quality": 40} + + if tpath.endswith(".webp"): + # quality 80 = pillow-default + # quality 75 = ffmpeg-default + # method 0 = pillow-default, fast + # method 4 = ffmpeg-default + # method 6 = max, slow + fmts.extend(("RGBA", "LA")) + args["method"] = 6 + else: + # default q = 75 + args["progressive"] = True + + if im.mode not in fmts: + # print("conv {}".format(im.mode)) + im = im.convert("RGB") + + im.save(tpath, **args) + def conv_pil(self, abspath: str, tpath: str, fmt: str, vn: VFS) -> None: self.wait4ram(0.2, tpath) with Image.open(fsenc(abspath)) as im: - try: - im = self.fancy_pillow(im, fmt, vn) - except Exception as ex: - self.log("fancy_pillow {}".format(ex), "90") - im.thumbnail(self.getres(vn, fmt)) - - fmts = ["RGB", "L"] - args = {"quality": 40} - - if tpath.endswith(".webp"): - # quality 80 = pillow-default - # quality 75 = ffmpeg-default - # method 0 = pillow-default, fast - # method 4 = ffmpeg-default - # method 6 = max, slow - fmts.extend(("RGBA", "LA")) - args["method"] = 6 - else: - # default q = 75 - args["progressive"] = True - - if im.mode not in fmts: - # print("conv {}".format(im.mode)) - im = im.convert("RGB") - - im.save(tpath, **args) + self.conv_image_pil(im, tpath, fmt, vn) def conv_vips(self, abspath: str, tpath: str, fmt: str, vn: VFS) -> None: self.wait4ram(0.2, tpath) @@ -531,6 +560,50 @@ class ThumbSrv(object): assert img # type: ignore # !rm img.write_to_file(tpath, Q=40) + def conv_raw(self, abspath: str, tpath: str, fmt: str, vn: VFS) -> None: + self.wait4ram(0.2, tpath) + with rawpy.imread(abspath) as raw: + thumb = raw.extract_thumb() + if thumb.format == rawpy.ThumbFormat.JPEG and tpath.endswith(".jpg"): + # if we have a jpg thumbnail and no webp output is available, + # just write the jpg directly (it'll be the wrong size, but it's fast) + with open(tpath, "wb") as f: + f.write(thumb.data) + if HAVE_VIPS: + crops = ["centre", "none"] + if "f" in fmt: + crops = ["none"] + w, h = self.getres(vn, fmt) + kw = {"height": h, "size": "down", "intent": "relative"} + + for c in crops: + try: + kw["crop"] = c + if thumb.format == rawpy.ThumbFormat.BITMAP: + img = pyvips.Image.new_from_array( + thumb.data, interpretation="rgb" + ) + img = img.thumbnail_image(w, **kw) + else: + img = pyvips.Image.thumbnail_buffer(thumb.data, w, **kw) + break + except: + if c == crops[-1]: + raise + + assert img # type: ignore # !rm + img.write_to_file(tpath, Q=40) + elif HAVE_PIL: + if thumb.format == rawpy.ThumbFormat.BITMAP: + im = Image.fromarray(thumb.data, "RGB") + else: + im = Image.open(io.BytesIO(thumb.data)) + self.conv_image_pil(im, tpath, fmt, vn) + else: + raise Exception( + "either pil or vips is needed to process embedded bitmap thumbnails in raw files" + ) + def conv_ffmpeg(self, abspath: str, tpath: str, fmt: str, vn: VFS) -> None: self.wait4ram(0.2, tpath) ret, _ = ffprobe(abspath, int(vn.flags["convt"] / 2)) diff --git a/copyparty/util.py b/copyparty/util.py index b5a2c45e..1578d291 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -399,6 +399,9 @@ application swf=x-shockwave-flash m3u=vnd.apple.mpegurl db3=vnd.sqlite3 sqlite=v text ass=plain ssa=plain image jpg=jpeg xpm=x-xpixmap psd=vnd.adobe.photoshop jpf=jpx tif=tiff ico=x-icon djvu=vnd.djvu image heic=heic-sequence heif=heif-sequence hdr=vnd.radiance svg=svg+xml +image arw=x-sony-arw cr2=x-canon-cr2 crw=x-canon-crw dcr=x-kodak-dcr dng=x-adobe-dng erf=x-epson-erf +image k25=x-kodak-k25 kdc=x-kodak-kdc mrw=x-minolta-mrw nef=x-nikon-nef orf=x-olympus-orf +image pef=x-pentax-pef raf=x-fuji-raf raw=x-panasonic-raw sr2=x-sony-sr2 srf=x-sony-srf x3f=x-sigma-x3f audio caf=x-caf mp3=mpeg m4a=mp4 mid=midi mpc=musepack aif=aiff au=basic qcp=qcelp video mkv=x-matroska mov=quicktime avi=x-msvideo m4v=x-m4v ts=mp2t video asf=x-ms-asf flv=x-flv 3gp=3gpp 3g2=3gpp2 rmvb=vnd.rn-realmedia-vbr diff --git a/docs/chungus.conf b/docs/chungus.conf index 7ac7ba9a..a8e34516 100644 --- a/docs/chungus.conf +++ b/docs/chungus.conf @@ -1083,7 +1083,7 @@ th-x3: n # default # image decoders, in order of preference - th-dec: vips,pil,ff # default + th-dec: vips,pil,raw,ff # default # disable jpg output th-no-jpg @@ -1115,6 +1115,9 @@ # image formats to decode using pyvips th-r-vips: a,very,long,list,of,file,extensions # hint + # image formats to decode using rawpy + th-r-raw: a,very,long,list,of,file,extensions # hint + # image formats to decode using ffmpeg th-r-ffi: a,very,long,list,of,file,extensions # hint