35ミリでスキャンされた画像をハーフに分割するプログラム2020年09月18日 07時06分09秒

さて、最近古い写真を公開しているが、それらの大多数はEPSONのフラットベッドスキャナGT-F740またはX830のフイルムモードでスキャンしている。

このスキャナはなかなか良いスキャンをしてくれるが、残念ながらハーフで撮影されたフィルムに対応していない。フィルムでは基本的には35ミリ幅でコマ割り=ハーフで2枚分が1つに入ってくるが、時々ハーフで数コマ抜ける。しかも何処が抜けるかは傾向はあるが不定。しかも抜けた画像はコマ割りモードでは絶対にスキャンできないので通常モードに切り替えて個別にスキャン領域を設定する必要がある。スライドのハーフでは自動認識がほとんど失敗するため個別設定が必要。

というバグが有るのだが、それ以上に小回りできたとしても35ミリ単位なので後で半分に切り出す作業が必要になる。少数なら良いが、例えば今回の私の写真スキャンでは実に14000枚もあったのでそんなコトしてられない。

ということで、自動的にハーフで分割するプログラムを作った。Pythonというプログラム言語で書いたので、その実行環境さえ作れればWindows/Mac/Linuxいずれでも動くはずだ(Windows以外はフォルダ構成部分をちょっと書き換える必要はあるかもしれないけど)。

画像を35ミリの真ん中で分割する、という単純な処理ではない。ハーフの境界線は実は微妙にずれるのでちゃんとそれを認識している。その他フイルムスキャンでやりがちな鏡像スキャンしたものを戻す処理も入れた。

・・・

一応フリーで公開しますが、当方現在無職のため、これを使って役に立ったと思った方は志ある方は金銭でも食料でも寄付していただけるとありがたいです。寄付先などはメイルでお問い合わせくださいませ。

そうそう、転載は禁止です。

・・・使い方・・・
python half5.py JPEG画像ファイル名またはフォルダ名 (オプション)

画像ファイルの場合、拡張子は省略可能(フォルダかどうかは自動識別)

画像のあるフォルダの下にhalfというフォルダを作って、その下に分割した画像が格納される。フォルダで指定したときはその中にあるJPEG画像をすべて処理する。子フォルダまでの再帰処理はしない。分割の縦横は自動認識する(35ミリベースで回転されている時)。
すでに処理済み、35ミリ幅のものは分割せずhalfにコピーされる(ただし、ファイルとしてのコピーではなく一旦展開→再圧縮はかかる)。

主なオプション
-m 鏡像変換もする
-f  強制的に真ん中で分割する
  境界線が認識できないような画像の時に使う
  (真っ黒に近い画像だとか境界線部分に焼き込みがあるとか)

詳しいことはプログラムを読むか、質問して。

・・・ここから・・・プログラム名はhalf5.py・・・

# -*- coding:utf-8 -*-

#JPEGのハーフ画像を分割する
#ハーフとフルが混じっているフイルムにも対応
#境界線は前後少しずれてもOK
#しかし、真っ黒に近い画像だと誤検出することもあるので、強制モードも有り
#Copyright (C) 2020 by おたくら編集局

#Python3用
import sys
import os
from pathlib import Path
from PIL import Image, ImageFilter,ImageOps #画像処理ライブラリ
from argparse import ArgumentParser #引数解釈

#グローバル変数/定数
fDebug  = False #デバッグ表示など追加(コマンドラインオプション)
fDebug2 = False #〃(プログラム的強制)
q = 97 # JPEG出力画質
sepWidth = 60 # ハーフの隙間幅(大体)
sepStep  = sepWidth//10 #ハーフ隙間間検出ステップ
sepPer = 0.18 # 境界線と見なす割合(この割合以上明るい色があれば境界線ではない)
if fDebug:
    print("境界線割合=",sepPer);
HSVLight = 0.23 #HSVの明るい色閾値
OutputFolder = "half" #出力フォルダ名

class Half:
    def __init__(self):
        "クラス初期化処理"
        #print(self)
    #end of __init__

    def cutImage1(self, picpath, fMirror=False, fForce=False, save_dir=None):
        """1枚処理
        picpath     画像群のあるフォルダ名または1ファイル名
        save_dir    出力フォルダ名
        リターン値  True=分割した , False=そのまま出力した
        """
#       print(type(picpath))
#       picname = picpath.name #ファイル名+拡張子
        picname = picpath.stem #ファイル名のみ
#       print("picname=",picname)
        if save_dir!=None:
            #フォルダ指定がある時
            picname_base = str(save_dir / picname)
        else:
            picname_base = str(picname)
        
        ext = ".jpg"
        if fMirror:
            ext = "M.jpg"

        im = Image.open(picpath)
        if fMirror:
            #鏡像モード
            im = ImageOps.mirror(im)

        size = im.size #画像サイズを得る
        print(picpath,size,end=":")
        gx = size[0]
        gy = size[1]
        
        f = False
        if gx>=gy:
            print("横長")
            ret = (True, gx/2, 1) #強制半分分割時の情報
            if not fForce:
                ret = self.isHalf(im, True)
            if not ret[0]:
                print("すでに分割済み")
                #オリジナルのまま出力
                im.crop((0, 0, gx, gy)).save(picname_base + ext , quality=q)
            else :
                hw = ret[1] #境界線座標
                im.crop((0        , 0, hw-1, gy)).save(picname_base + 'L' + ext , quality=q)
                im.crop((hw+ret[2], 0, gx  , gy)).save(picname_base + 'R' + ext , quality=q)
                f = True
        else:
            print("縦長")
            ret = (True, gy/2, 1) #強制半分分割時の情報
            if not fForce:
                ret = self.isHalf(im, False)
            if not ret[0]:
                print("すでに分割済み")
                #オリジナルのまま出力
                im.crop((0, 0, gx, gy)).save(picname_base + ext , quality=q)
            else :
                hh = ret[1] #境界線座標
                im.crop((0, 0        , gx, hh-1)).save(picname_base + 'U' + ext , quality=q)
                im.crop((0, hh+ret[2], gx, gy  )).save(picname_base + 'D' + ext , quality=q)
                f = True
        return f
    #end of cutImage1

    def isHalf(self, im, fHorizontal):
        """ハーフ画像かどうか調べる
        im 画像
        fHorizontal True=横分割 , Flase=縦分割
        return値 (True=ハーフ画像と思われる,境界線座標,境界線幅) または (False=ハーフでは無いと思われる,-1,0)
        """
        size = im.size #画像サイズを得る
        gx = size[0]
        gy = size[1]
        
        if fHorizontal: #横分割
            #境界線が黒か調べる
            x = (gx+1)//2
            if fDebug or fDebug2:
                #境界線部分を画像として書き出す
                im.crop((x-sepWidth, 0, x+sepWidth, gy)).save("LR.jpg" , quality=q)
            
            #中央に境界線がある可能性が高いので、まずは中央を見る
            ret = self.yoko(im ,x, gy)
            if ret[0] == True:
                return ret
            
            #中央にないときはずらして再調査
            if fDebug2:
                print("xx=", x-sepWidth, "~", x+sepWidth)
            for xx in range(x-sepWidth, x+sepWidth): #少し前ずれにも対応
                if xx == x:
                    #上で中央は見ているので飛ばす
                    continue
                ret = self.yoko(im, xx, gy)
                if ret[0] == True:
                    return ret
            #for xx
        else: #縦分割
            #境界線が黒か調べる
            y = (gy+1)//2
            if fDebug or fDebug2:
                #境界線部分を画像として書き出す
                im.crop((0, y-sepWidth, gx, y+sepWidth)).save("UD.jpg" , quality=q)
            
            #中央に境界線がある可能性が高いので、まずは中央を見る
            ret = self.tate(im, y, gx)
            if ret[0] == True:
                return ret
            
            #中央にないときはずらして再調査
            if fDebug2:
                print("yy=", y-sepWidth, "~", y+sepWidth)
            for yy in range(y-sepWidth, y+sepWidth): #少し前ずれにも対応
                if yy == y:
                    #上で中央は見ているので飛ばす
                    continue
                ret = self.tate(im, yy, gx)
                if ret[0] == True:
                    return ret
            #for yy
        #endif
        #明るい色だらけだった
        return (False, -1, 0)
    #end of isHalf
    
    def yoko(self, im, xx, gy):
        "横の処理"
        if fDebug:
            print("--横:xx=", xx, "--")
        AllCnt = 0 #全ドット数
        LCnt   = 0 #明るい色の数
        for y in range(0, gy):
            AllCnt += 1
            rgb = im.getpixel((xx, y))
            hsv = self.rgb2hsv(rgb)
            if fDebug:
                print("横(", xx, "," , y, ")rgb=", rgb, "->hsv=", hsv)
            if hsv[2] > HSVLight: #明るい色があった
                LCnt+=1
        #for y
        #明るい色の割合が一定以上なら境界線ではない
        per = float(LCnt)/float(AllCnt)
        if fDebug or fDebug2:
            print("明るい色=", LCnt, "全ドット数=", AllCnt, "割合=", per)
        if per < sepPer:
            #sepPer未満なら境界線かもしれない
            #境界線幅取得
            wd = self.getSepWidth(im, xx, True)
            if wd[1]:
                return (True, xx, wd[0])
        
        #明るい色だらけだった
        return (False, -1, 0)

    def tate(self, im, yy, gx):
        "縦の処理"
        if fDebug:
            print("--縦:yy=", yy, "--")
        AllCnt = 0 #全ドット数
        LCnt   = 0 #明るい色の数
        for x in range(0, gx):
            AllCnt += 1
            rgb = im.getpixel((x, yy))
            hsv = self.rgb2hsv(rgb)
            if fDebug:
                print("縦(", x, ",", y, ")rgb=", rgb, "->hsv=", hsv)
            if hsv[2] > HSVLight: #明るい色があった
                LCnt += 1
        #for x
        #明るい色の割合が一定以上なら境界線ではない
        per = float(LCnt)/float(AllCnt)
        if fDebug or fDebug2:
            print("明るい色=", LCnt, "全ドット数=", AllCnt, "割合=", per)
        if per<sepPer:
            #sepPer未満なら境界線かもしれない
            #境界線幅取得
            wd = self.getSepWidth(im, yy, False)
            if wd[1]:
                return (True, yy, wd[0])
        
        #明るい色だらけだった
        return (False, -1, 0)

    def getSepWidth(self, im, st, fHorizontal):
        """境界線幅取得
        return値 (幅,境界線と見なすかどうか)
        """
        size = im.size #画像サイズを得る
        gx = size[0]
        gy = size[1]
        wd = 1 #最初の1ラインはすでに判定済みなので
        if fHorizontal: #横分割
            ed = gy
        else: #縦分割
            ed = gx
        for i in range(st+1, st+sepWidth):
            AllCnt = 0 #全ドット数
            LCnt   = 0 #明るい色の数
            for j in range(0, ed):
                AllCnt += 1
                if fHorizontal: #横分割
                    xy = (i, j)
                else: #縦分割
                    xy = (j, i)
                rgb = im.getpixel(xy)
                hsv = self.rgb2hsv(rgb)
                if hsv[2] > HSVLight: #明るい色があった
                    LCnt+=1
            #for y
            #明るい色の割合が一定以上なら境界線ではない
            per = float(LCnt)/float(AllCnt)
            if per < sepPer:
                #sepPer未満なら境界線とみなす
                wd += 1
        #for xx
        if fDebug or fDebug2:
            print("境界線幅=", wd)
        f = (wd>=sepWidth//4)
        if f:
            #境界線幅が規定以上なら境界線と見なす
            if fDebug or fDebug2:
                print("境界線有り")
        return (wd, f)
    #end of getSepWidth
    
    def rgb2hsv(self, rgb):
        "RGB→HSV変換"
        r = float(rgb[0]) / 255.0 #0.0~1.0に正規化
        g = float(rgb[1]) / 255.0
        b = float(rgb[2]) / 255.0
        max = r   if r   > g else g
        max = max if max > b else b
        min = r   if r   < g else g
        min = min if min < b else b
        h = max - min
        if h > 0.0:
            if max == r:
                h = (g - b) / h
                if h < 0.0:
                    h += 6.0
            elif max == g:
                h = 2.0 + (b - r) / h
            else:
                h = 4.0 + (r - g) / h
        h /= 6.0
        s = (max - min)
        if max != 0.0:
            s /= max
        v = max
        return (h,s,v)
    #end of rgb2hsv

    #----------------------------------------------------------------------------

    def main(self):
        "メイン"
        p = Path(sys.argv[1]) # pは文字列型ではない
        if p.is_dir():
            #フォルダ名指定の時
            print("フォルダ名指定")
            #出力先フォルダ作成
            save_dir = p / OutputFolder  #フォルダ名
            print("出力先フォルダ:",save_dir)
            save_dir.mkdir(exist_ok=True) #フォルダ作成

            num  = 0
            hnum = 0
            for picpath in list(p.glob("*.jpg")):
                if self.cutImage1(picpath, fMirror, fForce, save_dir):
                    hnum += 1
                num += 1
            print(num,"枚中",hnum,"枚分割完了")
        else:
            #フォルダ名ではないとき
            ext = p.suffix #拡張子
            if ext == "":
                #拡張子が省略されているとき
                #pathlibには拡張子結合がないようなので一旦文字列で結合して再変換
                p = Path(str(p) + ".jpg")
            print("ファイル名指定:",p)
            if p.exists():
                #出力先は元ファイルと同じフォルダの下
                fullPath = p.resolve() #フルパス名化
                dirname = fullPath.parent #ファイルに対しては存在フォルダになる
                if fDebug or fDebug2:
                    print("dirname=",dirname)
                save_dir = dirname / OutputFolder  #フォルダ名
                print("出力先フォルダ:",save_dir)
                save_dir.mkdir(exist_ok=True) #フォルダ作成
                self.cutImage1(p, fMirror, fForce, save_dir)
            else:
                print("ファイルまたはフォルダが見つかりません")
    #end of main

#end of class Half

def get_option():
    "引数解釈"
    argparser = ArgumentParser()
    argparser.add_argument('フォルダ名またはJpegファイル名', type=str , help="フォルダ名またはJpegファイル名")
    argparser.add_argument('-m'     ,'--mirror' , help='鏡像モード'     , action="store_true")
    argparser.add_argument('-r'     ,'--reverse', help='鏡像モード'     , action="store_true")
    argparser.add_argument('-f'     ,'--force'  , help='強制分割モード' , action="store_true")
    argparser.add_argument('-debug' ,'--debug'  , help='デバッグ表示'   , action="store_true")
    return argparser.parse_args()


#コマンドライン実行時
if __name__ == "__main__":
    argc = len(sys.argv)
    if argc==1:
        print(sys.argv[0], " フォルダ名またはJpegファイル名 (オプション)")
        #以下のような指定が可能
        #half? jpg / half? folder
        #half? ~ -m -f
        #拡張子(.jpg)は省略可能
        sys.exit(1)
    
    #引数解釈
    args = get_option()
    fMirror = args.mirror #鏡像モード
    if args.reverse:
        fMirror =  args.reverse
    fForce = args.force #境界線判定無視で強制半分分割
    fDebug = args.debug

    if fMirror:
        print("鏡像モード")
    if fForce:
        print("強制分割モード")
    if fDebug:
        print("デバッグ表示モード")

    app = Half()
    app.main()

#end of program

・・・ここまで・・・

Pythonのベテランプログラマーの方には怒られるかもしれないが、そのコーディング規約に反している部分が多々あると思う。私はまだPythonのプログラムを作り始めて日が浅いし、もともとC/Objective-Cの人なのでどうしてもそれに従った書き方になってしまう。気に入らない人は勝手に書き換えて。ただし、書き換えたものは公開はしないで。

後2本サポートツールも作ったので、それらはこれに反響があったら公開。
(C)おたくら編集局