画像フィルタ付きFTPサーバを書いてみた


 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から設定だけ変更する、というのが簡単にやれるからいいか。←エンバグしてたので機能削除

 改善案とかのツッコミは歓迎である。

(Visited 91 times, 1 visits today)

コメントを残す

メールアドレスが公開されることはありません。

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください