Nexus 7で、電子化した漫画とか読むのにさー。あんまり大きいと、転送速度的に糞重くなるじゃないですか。
でも、それを基準に画質落として保存するのもあれだし、わざわざ低画質版とかを分けて置いておくのも何となくアレで。
で、オンザフライで画像を加工するFTPサーバなら、Python + pyftpdlib + ImageMagicK + Wandで割とすぐに書けそうな気がしたので、書いてみた。
ついでに、削除したらゴミ箱に放り込む機能も欲しかったので、Send2Trashも使った。
(2014/7/10更新: 無害だと思ってたおまけ機能が稀にエラーを発生させていたので削除)
server.py:
from __future__ import division, print_function, absolute_import import ConfigParser import hashlib import argparse import sys import os import re import Tkinter from pyftpdlib.authorizers import DummyAuthorizer, AuthenticationFailed from pyftpdlib.handlers import FTPHandler, DTPHandler from pyftpdlib.servers import ThreadedFTPServer from pyftpdlib.filesystems import AbstractedFS, FilesystemError from send2trash import send2trash from wand.image import Image import wand.exceptions from StringIO import StringIO DEFAULT_CONF_FILE = os.path.splitext(__file__)[0] + '.ini' class Conf(object): _parser = argparse.ArgumentParser() _parser.add_argument('--hash', help='generate hash, copy to clipboard and exit') _parser.add_argument('--conf', default=DEFAULT_CONF_FILE, help='specifies configuration file(.ini)') args = _parser.parse_args() def __init__(self): self._load_conf() def _load_conf(self): self._conf = ConfigParser.RawConfigParser() self._conf.read(self.args.conf) @property def conf(self): return self._conf @property def listen_address(self): return self.conf.get('FTPServer', 'Host'), self.conf.getint('FTPServer', 'Port') @property def image_target_width(self): return self.conf.getint('ImageFilter', 'TargetWidth') @property def image_target_height(self): return self.conf.getint('ImageFilter', 'TargetHeight') @property def image_shrink_limit(self): return self.conf.getint('ImageFilter', 'ShrinkLimit') @property def image_rotate(self): return {'none': 0, 'right': 90, 'left': 270}[self.conf.get('ImageFilter', 'Rotate')] @property def image_stretch(self): return self.conf.getboolean('ImageFilter', 'Stretch') @property def image_shrink(self): return self.conf.getboolean('ImageFilter', 'Shrink') @property def image_stretch_filter(self): return self.conf.get('ImageFilter', 'StretchFilter') @property def image_shrink_filter(self): return self.conf.get('ImageFilter', 'ShrinkFilter') @property def image_stretch_filter_blur(self): return self.conf.getfloat('ImageFilter', 'StretchFilterBlur') @property def image_shrink_filter_blur(self): return self.conf.getfloat('ImageFilter', 'ShrinkFilterBlur') @property def image_jpeg_quality(self): return self.conf.getint('ImageFilter', 'JPEGQuality') @property def image_trim(self): return self.conf.getboolean('ImageFilter', 'Trim') @property def image_trim_fuzz(self): return self.conf.getfloat('ImageFilter', 'TrimFuzz') @property def re_target_filename(self): return re.compile(self.conf.get('ImageFilter', 'TargetFileName'), re.I) @property def use_trash(self): return self.conf.getboolean('Global', 'UseTrash') @property def users(self): return self.conf.items('Users') conf = Conf() class DummyHashAuthorizer(DummyAuthorizer): def validate_authentication(self, username, password, handler): hash = hashlib.sha512(password).hexdigest() try: if self.user_table[username]['pwd'] != hash: raise KeyError except KeyError: raise AuthenticationFailed class ModifiedAbstractedFS(AbstractedFS): def open(self, filename, mode): def call_super(): return super(ModifiedAbstractedFS, self).open(filename, mode) if not conf.re_target_filename.search(filename): return call_super() try: with open(filename, mode) as fd, Image(file=fd) as image: # Image(filename=filename) doesn't handle unicode filename if conf.image_trim: bg_color = image[0][0] image.trim(fuzz=conf.image_trim_fuzz) if image.width * image.height == 0: image.blank(1, 1, background=bg_color) target_width = conf.image_target_width target_height = conf.image_target_height shrink_limit = conf.image_shrink_limit if (image.width - image.height) * (target_width - target_height) < 0: # rotate rotated_target_width = target_height rotated_target_height = target_width degree = conf.image_rotate else: # not rotate rotated_target_width = target_width rotated_target_height = target_height degree = 0 # shrink first w, h = rotated_target_width, rotated_target_height if (conf.image_shrink and (image.width > w or image.height > h) and (image.width > shrink_limit and image.height > shrink_limit) ): if image.width / image.height > w / h: h = image.height * w / image.width if h < shrink_limit: w = w * shrink_limit / h h = shrink_limit else: w = image.width * h / image.height if w < shrink_limit: h = h * shrink_limit / w w = shrink_limit image.resize(width=int(w), height=int(h), filter=conf.image_shrink_filter, blur=conf.image_shrink_filter_blur) # rotate image.rotate(degree) # stretch last w, h = target_width, target_height if conf.image_stretch and (image.width < w and image.height < h): if image.width / image.height > w / h: h = image.height * w / image.width else: w = image.width * h / image.height image.resize(width=int(w), height=int(h), filter=conf.image_stretch_filter, blur=conf.image_stretch_filter_blur) image.compression_quality = conf.image_jpeg_quality blob = image.make_blob(format='jpeg') io = StringIO(blob) io.name = filename except wand.exceptions.WandException: return call_super() return io def rmdir(self, path): if not conf.use_trash: return super(ModifiedAbstractedFS, self).rmdir(path) send2trash(path) def remove(self, path): if not conf.use_trash: return super(ModifiedAbstractedFS, self).remove(path) send2trash(path) class FilteredImageFile(file): pass def main(): if conf.args.hash: hash = hashlib.sha512(conf.args.hash).hexdigest() print(hash) copy_to_clipboard(hash) sys.exit() start_server() def start_server(): handler = FTPHandler authorizer = DummyHashAuthorizer() for username, value in conf.users: homedir, perm, hash = value.split(',') authorizer.add_user(username, hash, homedir, perm=perm) handler.authorizer = authorizer fs = ModifiedAbstractedFS handler.abstracted_fs = fs server = ThreadedFTPServer(conf.listen_address, handler) server.serve_forever() def copy_to_clipboard(value): Tkinter.Text().clipboard_clear() Tkinter.Text().clipboard_append(value) if __name__ == '__main__': main()
server.ini
[Global] UseTrash=true [FTPServer] Host=0.0.0.0 Port=21 [ImageFilter] TargetFileName=\.(jpg|png|gif|bmp)$ JPEGQuality=90 Rotate=right ; left/right/none TargetWidth=1200 TargetHeight=1776 ShrinkLimit=800 ; Stretch/Shrink ; Filter: http://docs.wand-py.org/en/0.3.5/wand/image.html#wand.image.FILTER_TYPES ; Recommended Filters: ; "undefined", "mitchell", "lagrange", "lanczos", "lanczossharp", "lanczos2", "lanczos2sharp" ; FilterBlur: ; > 1 is blurry, < 1 is sharp Stretch=true StretchFilter=mitchell StretchFilterBlur=0.8 Shrink=true ShrinkFilter=lanczossharp ShrinkFilterBlur=0.8 ; Trim Edges ; TrimFuzz: how much tolerance is acceptable to consider two colors as the same Trim=true TrimFuzz=1 [Users] ;username=homedir,permissions,password_hash ; permissions: https://code.google.com/p/pyftpdlib/wiki/Tutorial#2.2_-_Users ; "e" = change directory (CWD, CDUP commands) ; "l" = list files (LIST, NLST, STAT, MLSD, MLST, SIZE commands) ; "r" = retrieve file from the server (RETR command) ; "a" = append data to an existing file (APPE command) ; "d" = delete file or directory (DELE, RMD commands) ; "f" = rename file or directory (RNFR, RNTO commands) ; "m" = create directory (MKD command) ; "w" = store a file to the server (STOR, STOU commands) ; "M" = change mode/permission (SITE CHMOD command) ; password_hash: ; generate with '--hash' option and paste it(copied to clipboard automatically) reader=D:\foo\bar,elr,ba3253876aed6bc22d4a6ff53d8406c6ad864195ed144ab5c87621b6c233b548baeae6956df346ec8c17f5ea10f35ee3cbc514797ed7ddd3145464e2a0bab413 user=D:\foo\bar,elrdfmM,b109f3bbbc244eb82441917ed06d618b9008dd09b3befd1b5e07394c706a8bb980b1d7785e5976ec049b46df5f1326af5a2ea6d103fd07c95385ffab0cacbc86
こんな感じで。なかなかのやっつけ感。つーかこれだけで変態FTPサーバが動いちゃうんだもんなー。
こういうのが欲しい人には非常に便利な気がするんだけど、人に使ってもらう気の無さすぎる記事である。説明めんどくて。コマンドラインに-h
付けて起動すれば、あとは勘で何とか…、ならないかなあ。誰かもっと普通に扱いやすいソフトとして作る人いないだろうか。既にあったら、または作ったら教えてください是非。こんなもん捨ててそっち使いますし。
サンプルがファイル書き込みだけ禁止してるのは、例えばファイルコピーをしようとするとフィルタ済み画像をコピーしちゃうことになるんで、このサーバの動作を考えると危険だな、と。コピーじゃなく移動なら大丈夫だけど。
あと、サンプルのreaderのパスワードは123456、userのパスワードはpasswordである。最悪パスワードの新旧王者であるな。
で、ESファイルエクスプローラーでアクセスしてみたんだけど、以前からFTPでの画像ロードに怪しさがあったのが再発して、どうも発生条件が分からんし普通のFTP鯖でも起きるんで、Rhythm SoftwareのFile Managerを使ってみているところ。この変態FTPサーバで使う以外の用途だと微妙だけど、それなりには行ける。ESファイルエクスプローラーと併用するかな。
Androidのファイルマネージャーってどうも一長一短な感じなんで、結構なチャンスだと思うんだけど、誰か完璧な奴を作ってくれんかなー。
せっかくなので、コードについての話も書いておく。ここは備忘録でもあるので。
- ThreadedFTPServerを採用したのは、低速なファイルシステムに近い状態になっていた為。つってもI/OじゃなくCPUなので、Pythonだとマルチスレッドよりマルチプロセス向けなのではないか、と思ったけど、マルチプロセス版のクラスはWindows対応ではないっぽいので。クラス名を一箇所書き換えるだけでマルチプロセス版になるけど。
- 画像変換のところは同じような処理を繰り返してて汚いけど、とりあえず動かすだけで疲れた。綺麗にするのめんどい。
- ConfigParser関連ももっとすっきりやれそうな。
- どうせやっつけなら設定ファイルをわざわざ分けない方がすっきりしたのに、と今では思う。
でもまあ、これのお陰で、サーバを動かしたまま別PCから設定だけ変更する、というのが簡単にやれるからいいか。←エンバグしてたので機能削除
改善案とかのツッコミは歓迎である。