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から設定だけ変更する、というのが簡単にやれるからいいか。←エンバグしてたので機能削除
改善案とかのツッコミは歓迎である。