Re:Readme

PCトラブルや環境構築、家電量販店とかで買ったもののメモ。ご利用は自己責任で。

Google Drive API v3によるデジタルフォトフレーム作成(2)

Google Drive API v3によるデジタルフォトフレームの機能を実現するプログラムをPythonで実装してみました。アルゴリズムhttp://www.asahi-net.or.jp/~rn8t-nkmr/family/pic/pf/index.htmlを参考にさせていただいています。
移植性とかが残念なのはご勘弁を。ソースコードの動作は保証しませんし、また使用によるいかなる不利益についても、当方は責任は負いかねますので悪しからずご了承下さい。

使い方

前回の記事でGoogle Drive APIを使用するための環境構築について記述。
hal-drumas.hatenablog.com

足りないパッケージは適宜

$ sudo pip install --upgrade <パッケージ名>

で入手する。

ソースコードの定数群は適宜要修正。

  • APPLICATION_NAME

初回の認証画面(ブラウザ)に表示するアプリケーション名。

  • PROJECT_PATH

本アプリケーションで管理するソースや認証情報、画像を置くためのディレクトリ。予め作成しておく。

  • CLIENT_SECRET_FILE

前回の記事で取得したclient_secret.jsonのパス。

  • CREDENTIALS_DIR

CREDENTIAL_FILEの保存先。ディレクトリが無い場合は自動的に作成される。

  • CREDENTIAL_FILE

認証画面で本アプリを認証後、得られた認証情報をjsonファイルとして保存することで、次回以降のアプリ実行時にいちいち認証せずに済む。そのjsonファイルの名前を指定する。
jsonファイルの読み込みについては下記の記事が参考になった。
peaceandhilightandpython.hatenablog.com

  • SCOPES

Google Driveへのアクセス権限。変更不要。

  • CLOUD_DIR_ROOT

Google Driveにここで指定した名前のディレクトリを予め作成しておく。そのディレクトリ内の画像を本アプリで表示する(CLOUD_DIR_ROOT内にさらにディレクトリを作成することも可能)。

  • LOCAL_DIR_ROOT

本アプリはCLOUD_DIR_ROOTにある画像をローカルにダウンロードし、これを表示する。そのローカルでの保存先を指定する。このディレクトリは予め作成しておく必要がある。

  • PAGE_SIZE_MAX

一度のリクエストで読み込むファイル数の上限。デフォルトでは100で、最大値は1000。

  • WAIT_SEC

1枚の画像を表示する秒数。ただし、同期が走るタイミングでその分画像の切り替わりが遅延することがある。

ソースコード

本記事の末尾のPythonスクリプトをmain.pyのような名前で保存し、

$ python main.py

のように実行する。画像の表示中にEscを長押しすることで終了。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
[ライブラリ]
"""
from __future__ import print_function
import sys
import io
import os
import shutil
import glob
import subprocess
import httplib2
import pygame

import apiclient
import oauth2client

try:
    import argparse
    flags = argparse.ArgumentParser(parents = [oauth2client.tools.argparser]).parse_args()
except ImportError:
    flags = None

"""
[定数]
"""
APPLICATION_NAME = u'Raspberry Photo Sync'                  # 本クライアントアプリケーションの名称
PROJECT_PATH = os.path.expanduser(u'~/projects/photosync')  # 本アプリのプロジェクトのパス
CLIENT_SECRET_FILE = os.path.join(PROJECT_PATH,             # 予め取得したキーの場所
                                  u'client_secret.json')
CREDENTIALS_DIR = os.path.join(PROJECT_PATH,                # 認証情報の保存先(ディレクトリ)
                               u'.credentials')
CREDENTIAL_FILE = u'raspberry_photo_sync.json'              # 認証情報の保存先(ファイル)
SCOPES = u'https://www.googleapis.com/auth/drive.readonly'  # Google Driveへのアクセス権限の指定
                                                            # 変更時はCREDENTIAL_FILEを削除し、改めて取得する必要がある

CLOUD_DIR_ROOT = u'Raspberry'                               # 本アプリで扱う、Google Driveの画像フォルダ名
                                                            # このリストに登録したフォルダに画像以外のデータを置く事を禁ずる
LOCAL_DIR_ROOT = os.path.join(PROJECT_PATH,                 # Google Driveと同期した画像の、ローカルにおける保存先
                              u'img')
PAGE_SIZE_MAX = 1000                                        # 1度に読み込むファイル数の上限(1〜1000)
                                                            # デフォルト値は100
WAIT_SEC = 10                                               # 画像の表示時間

"""
[関数]
main

[概要]
Google Driveの指定ディレクトリ(CLOUD_DIR_ROOT)以下にある画像をダウンロードし、スクリーンに表示する関数。

[引数]
なし

[戻り値]
0: 正常終了(実際には到達しない)
"""
def main():
    api_info = GoogleDriveAPIsInfo()
    file_handle = FileSynchronizer(api_info.service,
                                   CLOUD_DIR_ROOT,
                                   LOCAL_DIR_ROOT)
    viewer = ImageViewer(WAIT_SEC)
    
    while True:
        file_handle.syncFileTree()
        if len(file_handle.local.file_tree) == 0:
            viewer.resetDisplay()
        elif not viewer.drawImages(file_handle.local.file_tree[u'children'], u'modifiedTime'):
            viewer.resetDisplay()
    return 0

"""
[クラス]
GoogleDriveAPIsInfo

[概要]
Google Drive APIの認証情報やハンドルを管理するクラス。
"""
class GoogleDriveAPIsInfo:
    """
    [コンストラクタ]
    __init__
    
    [引数]
    なし
    """
    def __init__(self):
        self.credentials = self.getCredentials()
        self.http = self.credentials.authorize(httplib2.Http())
        self.service = apiclient.discovery.build(u'drive',
                                                 u'v3',
                                                 http = self.http)

    """
    [関数]
    getCredentials

    [概要]
    Google Drive APIの認証情報を取得する関数。
    ローカルに有効な認証情報が無い場合、ブラウザの認証画面が表示される。
    画面操作で認証を行うと情報がローカルに保存され、次回以降はローカルの認証情報を読み込む。

    [引数]
    なし
    
    [戻り値]
    credentials: 認証情報
    """
    def getCredentials(self):
        if not os.path.exists(CREDENTIALS_DIR):
            os.makedirs(CREDENTIALS_DIR)
        credential_path = os.path.join(CREDENTIALS_DIR,
                                       CREDENTIAL_FILE)

        store = oauth2client.file.Storage(credential_path)
        credentials = store.get()
        if not credentials or credentials.invalid:
            flow = oauth2client.client.flow_from_clientsecrets(CLIENT_SECRET_FILE,
                                                               SCOPES)
            flow.user_agent = APPLICATION_NAME
            if flags:
                credentials = oauth2client.tools.run_flow(flow, store, flags)
            else: # Python 2.6との互換
                credentials = oauth2client.tools.run(flow, store)
            print(u'Storing credentials to ' + credential_path)
        return credentials

"""
[クラス]
FileSynchronizer

[概要]
ローカル・クラウドにある画像の同期を取るクラス。
"""
class FileSynchronizer:
    """
    [コンストラクタ]
    __init__

    [引数]
    api_handle: Google Drive APIのハンドル
    cloud_root_name: 本アプリが管理するクラウド上のルートディレクトリ名
    local_root_path: クラウドからダウンロードした画像を保存するディレクトリのルートのパス
    """
    def __init__(self, api_handle, cloud_root_name, local_root_path):
        self.cloud = CloudFileManager(api_handle, cloud_root_name)
        self.local = LocalFileManager(local_root_path)

    """
    [関数]
    syncFileTree

    [概要]
    クラウドとローカルのファイルの同期を取る関数。

    [引数]
    なし
    
    [戻り値]
    なし
    """
    def syncFileTree(self):
        self.cloud.update()                        # 現在クラウドにあるファイル構成を取得する
        self.local.update()                        # 現在ローカルにあるファイル構成を取得する
        self.compareFileTree(self.cloud.file_tree,
                             self.local.file_tree) # クラウドとローカルのファイル・ディレクトリを比較する
        self.local.update()                        # ファイルのダウンロード・削除後、ファイルツリー情報を更新する
        self.getMetaData(u'modifiedTime',          # 更新時間情報をクラウドファイルツリーからコピーする。
                         self.cloud.file_tree,
                         self.local.file_tree)
        return
    
    """
    [関数]
    compareFileTree
    
    [概要]
    同じ階層のファイル・ディレクトリの名前を比較し、クラウドにのみ存在するファイルのダウンロードの実行と、ローカルにのみ存在するファイルの削除を再帰的に行う。
    
    [引数]
    cloud_tree: クラウドのファイルツリー。
    local_tree: ローカルのファイルツリー。
    
    [戻り値]
    なし
    """

    def compareFileTree(self, cloud_tree, local_tree):
        cloud_list = sorted(cloud_tree[u'children'],              # 引数の部分木のルート直下をnameでソート(辞書順)
                            key = lambda member: member[u'name'])
        local_list = sorted(local_tree[u'children'],              # 引数の部分木のルート直下をnameでソート(辞書順)
                            key = lambda member: member[u'name'])
        i_cloud = i_local = 0
        
        existence = u''
        while (i_cloud < len(cloud_list)) or (i_local < len(local_list)):
            if   i_local >= len(local_list): # クラウドにしか無いファイルを検出(ローカル検索完了)
                existence = u'cloud'
            elif i_cloud >= len(cloud_list): # ローカルにしか無いファイルを検出(クラウド検索完了)
                existence = u'local'
            elif cloud_list[i_cloud][u'name'] < local_list[i_local][u'name']: # クラウドにしか無いファイルを検出
                existence = u'cloud'
            elif cloud_list[i_cloud][u'name'] > local_list[i_local][u'name']: # ローカルにしか無いファイルを検出
                existence = u'local'
            else: # ローカルにもクラウドにも存在するファイル(同期が取れているファイル)
                existence = u'both'

            if   existence == u'cloud':
                attribute = u'dir' if cloud_list[i_cloud][u'mimeType'] \
                                   == u'application/vnd.google-apps.folder' else \
                            u'file'
                if attribute == u'dir':
                    self.downloadCloudTree(cloud_list[i_cloud])
                else: # attribute == u'file'
                    self.downloadCloudFile(cloud_list[i_cloud])
                i_cloud += 1
            elif existence == u'local':
                attribute = u'dir' if os.path.isdir(os.path.join(local_list[i_local][u'path'],
                                                                 local_list[i_local][u'name'])) else \
                            u'file'
                if attribute == u'dir':
                    self.deleteLocalTree(local_list[i_local])
                else: # attribute == u'file'
                    self.deleteLocalFile(local_list[i_local])
                i_local += 1
            else: # existence == u'both'
                attribute = u'dir' if cloud_list[i_cloud][u'mimeType'] \
                                   == u'application/vnd.google-apps.folder' else \
                            u'file'
                if attribute == u'dir':
                    self.compareFileTree(cloud_list[i_cloud], local_list[i_local])
                i_cloud += 1
                i_local += 1
        return

    """
    [関数]
    downloadCloudFile
    
    [概要]
    クラウドのファイルのダウンロードを行う。
    
    [引数]
    cloud_node: クラウドのファイルツリーからダウンロードする、構成要素のメタデータ
    
    [戻り値]
    なし
    """
    def downloadCloudFile(self, cloud_node):
        id = cloud_node['id']
        name = cloud_node['name']
        path = os.path.join(self.local.root_path + cloud_node['path'][len(self.cloud.root_name) + 1:],
                            cloud_node['name'])
                            
        print(u'Download: %s' % name)
        request = self.cloud.api_handle.files().get_media(fileId = id) # ID検索でヒットしたファイルをダウンロード
        fstream = io.FileIO(path, mode = u'wb')
        downloader = apiclient.http.MediaIoBaseDownload(fstream, request)
        
        done = False
        while done is False:
            status, done = downloader.next_chunk()
            print(u'Download %d%%.' % int(status.progress() * 100))

        exiftran = u'/usr/bin/exiftran'
        command = u'%s -ai %s' % (exiftran, path) # exiftranコマンドの実行(exifの画像の回転情報を元に画像を上書きする)
        print(command)
        result = subprocess.Popen(command,
                                  shell = True,
                                  stdout = subprocess.PIPE,
                                  stderr = subprocess.PIPE)
        (stdout_msg, stderr_msg) = result.communicate()
        print(stdout_msg)
        print(stderr_msg)
        return
    
    """
    [関数]
    DownloadCloudTree

    [概要]
    クラウドのディレクトリおよびディレクトリの内包するファイルを再帰的にダウンロードする。

    [引数]
    cloud_node: ローカルのファイルツリーからダウンロードする、部分木のルートのメタデータ

    [戻り値]
    なし
    """
    def downloadCloudTree(self, cloud_node):
        path = os.path.join(self.local.root_path + cloud_node['path'][len(self.cloud.root_name) + 1:],
                            cloud_node['name'])
        os.makedirs(path)
        for child in cloud_node['children']:
            if child['mimeType'] == 'application/vnd.google-apps.folder':
                self.downloadCloudTree(child)
            else:
                self.downloadCloudFile(child)
        return

    """
    [関数]
    deleteLocalFile
    
    [概要]
    ローカルのファイルの削除を行う。
    
    [引数]
    local_node: ローカルのファイルツリーから削除する、構成要素のメタデータ
    
    [戻り値]
    なし
    """
    def deleteLocalFile(self, local_node):
        name = local_node['name']
        path = local_node['path']
        print(u'Delete: %s' % name)
        os.remove(os.path.join(path, name))
        return

    """
    [関数]
    deleteLocalTree
    
    [概要]
    ローカルのディレクトリおよびディレクトリの内包するファイルを再帰的に削除する。
    
    [引数]
    local_node: ローカルのファイルツリーから削除する、部分木のルートのメタデータ
    
    [戻り値]
    なし
    """
    def deleteLocalTree(self, local_file_tree):
        name = local_file_tree['name']
        path = local_file_tree['path']
        print(u'Delete: %s/' %  name)
        shutil.rmtree(os.path.join(path, name))
        return

    """
    [関数]
    getMetaData
    
    [概要]
    クラウドのファイル情報をローカルのファイル情報にコピーする。
    
    [引数]
    meta_data_name: コピーするメタデータのキー値
    cloud_tree:     クラウドのファイルツリー
    local_tree:     ローカルのファイルツリー(cloud_treeと同期済みである必要あり)
    
    [戻り値]
    なし
    """
    def getMetaData(self, meta_data_name, cloud_tree, local_tree):
        cloud_list = sorted(cloud_tree[u'children'],
                            key = lambda member: member[u'name'])
        local_list = sorted(local_tree[u'children'],
                            key = lambda member: member[u'name'])
        
        for index in range(len(cloud_list)):
            local_list[index][meta_data_name] = cloud_list[index][meta_data_name]
            if cloud_list[index][u'mimeType'] == u'application/vnd.google-apps.folder':
                self.getMetaData(meta_data_name,
                                 cloud_list[index],
                                 local_list[index])

        local_tree['children'] = local_list
        return local_tree

"""
[クラス]
CloudFileManager

[概要]
クラウドにある画像の情報を管理するクラス。
"""
class CloudFileManager:
    """
    [コンストラクタ]
    __init__

    [引数]
    api_handle: Google Drive APIのハンド
    root_name:  クラウド上のルートディレクトリ名
    """
    def __init__(self, api_handle, root_name):
        self.api_handle = api_handle # Google Drive APIを叩くハンドル
        self.root_id = u''           # 管理するルートフォルダのID
        self.root_name = root_name   # 管理するルートフォルダの名前
        self.file_tree = {}          # ファイル情報一覧 {id, name}
                                     # ファイル名の重複を禁ずる
        
        # 指定のフォルダ名とMIME Typeに合致するフォルダのIDを取得
        mime_type = u'application/vnd.google-apps.folder' # Google Driveのディレクトリを示すMIME Type
        dir_list = self.api_handle.files().list(q = u'name = \'%s\' and mimeType = \'%s\''
                                                % (CLOUD_DIR_ROOT, mime_type)
                                               ).execute().get(u'files')
        if len(dir_list) > 2: # ルートディレクトリが複数存在する場合
            sys.exit(-1)
        self.root_id = dir_list[0][u'id']

    """
    [関数]
    update
    
    [概要]
    クラウドにあるファイルの情報を更新する。
    
    [引数]
    なし
    
    [戻り値]
    なし
    """
    def update(self):
        self.file_tree = self.createFileTree(self.root_id, u'')
        return

    """
    [関数]
    createFileTree
    
    [概要]
    クラウドにあるファイルを構成ごと取得する。
    
    [引数]
    id: ファイルまたはフォルダのID
    
    [戻り値]
    引数のIDが示すファイルまたはIDが示すディレクトリ内のファイル・ディレクトリを含むリスト
    """
    def createFileTree(self, id, parents):
        metadata = self.api_handle.files().get(fileId = id,
                                               fields = u'id, name, mimeType, modifiedTime').execute()
        metadata[u'path'] = parents
        metadata[u'children'] = []
        if metadata[u'mimeType'] == 'application/vnd.google-apps.folder':
            result = self.api_handle.files().list(pageSize = PAGE_SIZE_MAX,
                                                  fields = u'nextPageToken, \
                                                             files(id, name, mimeType, modifiedTime)',
                                                  q = (u'\'%s\' in parents' % id)
                                                 ).execute().get(u'files', [])
            for element in result:
                metadata[u'children'].append(self.createFileTree(element[u'id'],
                                                                 '%s/%s' % (parents, metadata['name'])))
        return metadata

"""
[クラス]
LocalFileManager

[概要]
ローカルにある画像の情報を管理するクラス。
"""
class LocalFileManager:
    """
    [コンストラクタ]
    __init__

    [引数]
    path: クラウドからダウンロードする画像の保存先
    """
    def __init__(self, root_path):
        self.root_path = root_path # 管理するファイルのあるディレクトリ
        self.file_tree = {}        # ファイル情報一覧 {path, name, その他メタデータ}
    
    """
    [関数]
    update

    [概要]
    ローカルにあるファイルの情報を更新する。

    [引数]
    なし

    [戻り値]
    なし
    """
    def update(self):
        self.file_tree = self.createFileTree(self.root_path)
        return

    """
    [関数]
    createFileTree

    [概要]
    ローカルにあるファイルを構成ごと取得する。

    [引数]
    path: ファイルまたはフォルダのID

    [戻り値]
    引数のIDが示すファイルまたはIDが示すディレクトリ内のファイル・ディレクトリを含むリスト
    """
    def createFileTree(self, path):
        # pathが異常値の場合の処理を書く
        metadata = {}
        metadata[u'name'] = os.path.basename(path)
        metadata[u'path'] = path[:-(len(metadata[u'name']) + 1)]
        metadata[u'children'] = []
        if os.path.isdir(path):
            dir_list = os.listdir(path)
            for dir_name in dir_list:
                metadata[u'children'].append(self.createFileTree(os.path.join(path, dir_name)))
        return metadata

"""
[クラス]
ImageViewer

[概要]
ローカルの画像を画面に表示するクラス。
"""
class ImageViewer:
    """
    [コンストラクタ]
    __init__
    
    [引数]
    なし
    """
    def __init__(self, wait_sec):
        pygame.init()
        self.display = pygame.display.set_mode((0, 0),
                                               pygame.FULLSCREEN | pygame.DOUBLEBUF | pygame.HWSURFACE)
        self.screen_size = self.display.get_size() # [width, height]
        self.wait = wait_sec                       # 次の画像を表示するまでの秒数
        pygame.mouse.set_visible(False)
    
    """
    [関数]
    resetDisplay
    
    [概要]
    画面を初期化する。
    
    [引数]
    なし
    """
    def resetDisplay(self):
        background_color = (255, 255, 255)
        self.display.fill(background_color) # 表示する画像がない場合に表示する画面のカラー(R, G, B)
        pygame.display.flip()               # 画面の表示
        self.waitSwitchingPhoto(self.wait)

    """
    [関数]
    drawImages

    [概要]
    リストにあるファイルを順次表示する。

    [引数]
    path: ローカルに保存した画像のあるパス
    list: ローカルに保存した画像のファイル名の一覧

    [戻り値]
    なし
    """
    def drawImages(self, list, sort_rule = u'name'):
        exist_image = False
        
        # 表示する画像がリストにない場合
        if len(list) == 0:
            exist_image = False
            return exist_image
    
        list = sorted(list,
                      key = lambda member: member[sort_rule])

        # ローカルにある画像の分だけループ
        for metadata in list:
            entity_path = os.path.join(metadata[u'path'], metadata[u'name'])
            if os.path.isdir(entity_path):
                exist_image |= self.drawImages(metadata[u'children'])
                continue
            exist_image |= True
            image = pygame.image.load(entity_path)

            image_width  = image.get_width()  # 画像データの幅
            image_height = image.get_height() # 画像データの高さ
            scale_w = float(self.screen_size[0]) / float(image_width)  # width  x scale_w = screen_width となる係数
            scale_h = float(self.screen_size[1]) / float(image_height) # height x scale_h = screen_heightとなる係数
            scale = scale_w if (scale_w < scale_h) else scale_h # 画像が画面からはみ出さない程度の拡大率
            resize_w = int(image_width * scale)  # 実際に描画するリサイズ後の画像の幅(アスペクト比維持)
            resize_h = int(image_height * scale) # 実際に描画するリサイズ後の画像の高さ(アスペクト比維持)

            if resize_w < self.screen_size[0]: # 高さを画面ぴったりに合わせる場合
                corner_x = (self.screen_size[0] - resize_w) / 2 # 水平方向でセンタリングする
                corner_y = 0
            else:                              # 幅を画面ぴったりに合わせる場合
                corner_x = 0
                corner_y = (self.screen_size[1] - resize_h) / 2 # 鉛直方向でセンタリングする

            background_color = (0, 0, 0)        # 画面余白の色(R, G, B)
            self.display.fill(background_color) # 単色画面の作成
            self.display.blit(pygame.transform.scale(image, (resize_w, resize_h)), # 単色画面に画像を重ねる
                              (corner_x, corner_y))
            pygame.display.flip() # 画面の表示
            self.waitSwitchingPhoto(self.wait)
        return exist_image

    """
    [関数]
    waitSwitchingPhoto
    
    [概要]
    本スクリプトの終了コマンド(Esc)の入力を1秒ごとに確認する。
    
    [引数]
    time: スリープする秒数
    
    [戻り値]
    なし
    """
    def waitSwitchingPhoto(self, time):
        for count in range(time):
            for event in pygame.event.get(): # イベント発生時
                if (event.type == pygame.KEYDOWN) and (event.key == pygame.K_ESCAPE): # ESCキー押下
                    pygame.quit()
                    sys.exit(0)
            pygame.time.delay(1000)          # 1000msスリープ
        return

if __name__ == u'__main__':
    sys.exit(main())