QRコード(2次元バーコード)で出退勤打刻システムを実現
私が勤務する会社の勤怠管理は、出勤簿という超アナログな手法です。
労働基準法の改定をはじめとする法定外時間管理の厳格化の流れが中小企業にも訪れたことに伴い、ようやく勤怠管理のWEBサービスを使用することに。
勤怠システムは、今まで使用していたグループウェアと連携してシングルサインオンで打刻できる、AKASHIを使用することに決定。
使い勝手を優先した選定にもかかわらず、試験段階で次のような意見が。
・出社してパソコンを起動しないと打刻できないのが煩わしい
とある地方中小企業の社員より
・退社時、退勤打刻を忘れてパソコンをうっかりオフしてしまう
・共有打刻PC機能を使っても、自分の名前を選択したりが面倒
社員証がICカードにでもなっていれば良いものの、ICカードどころか社員証も無いという悲しい現実。
というわけで、下記を実行するシステムを自作することにしました。
- Webカメラから映像を取得する
- 取得した映像から2次元バーコードを読み取り、デコードする
- デコード結果に応じて、HTTP POSTによってAKASHIの公開APIを叩く
- 余っているPCを電源自動運用し、上記の処理を実行させる
当記事では、これらの実現方法を寄せ集めた過程を記載した上で、最後に赤っ恥覚悟のPythonコードを公開します。
Pythonでの実現性を確認
上の一連の処理のうちどこか一つで躓いてもたちまち野望は頓挫するわけです。
週末へっぽこプログラマーを自認し、家族に白い目を向けられても家族サービスそっちのけでPCに向って時間を費やす筆者にとって、それが与えるダメージは痛恨の一撃です。
しかしPythonの素晴らしいところは、豊富なライブラリ群を無料で使え、その利用のためのTipsがWEBにたくさんあること。
つまり偉大な先行者が残す記録たちを事前に探った上で、できそうならばそれをパクれば、いや、引用すれば良いわけです。(いろいろなご批判もあるでしょうけど、リスク等は重々承知の上です)
WEBカメラを使ったQRコードリーダー
ライブラリ
OpenCV、PIL、pyzbar
参考コード
一番肝となるQRコード読み込み。下記のサイトのコードを拝借しました。ほぼそのままで動きます。深謝です。
WEBカメラ
ときは新型コロナウィルス(COVID-19)の緊急事態宣言発令時ということで、テレワーク需要からWEBカメラがネット上で軒並み値上げ。とあるルートで偶然入手したLogicool C270を使いましたが、偶然にもこれがベストフィット。
筆者はWEB会議システム関係の仕事をしたこともあるので、LogicoolのWEBカメラの性能の良さはよくわかっていましたが、C270は下位機種過ぎてWEB会議利用では手を出したことはありません。
Logicool製品上位機種は、フルHD解像度でオートフォーカスのものも1万円前後で手に入るため、あえて固定フォーカスの720Pを使う理由はなかったのです。
しかし、今回は、これでいい。いや、これがいい。
- 余計な機能がないから、安い!(3000円台で2年メーカー保証つき)
- QRコードをかざす距離にカメラをフォーカス固定すれば、オートよりむしろ認識が早い
しかも、固定フォーカス距離は、本体のカバーをカパット開けて自分で物理的に調整が可能です。
下のサイトが詳しいです。
勤怠システムAPIをHTTPで叩く
使用する期待システム:AKASHIは、APIが公開されています。
ライブラリ
HTTP POSTでパラメータを渡し、レスポンスはJSONのテキストデータ。
ライブラリはRequestsをimportします。
POSTデータの作成、HTTPリクエスト送出
下記のサイトを参考にして、出退勤に必要な各パラメータをAKASHIのアプリサーバーにPOSTします。
JSONレスポンスをパースする
JSON形式のデータは深く階層化されていることが多いため、自力でテキスト処理などしようものならたいへんです。Requestsライブラリのjson()関数で容易に必要なデータを取り出すことができます。参考サイト。
期限付きトークンを管理する
公開APIを使うには、使用者の識別及びデータ保護のために認証が必要になることが常です。
中にはリクエストの都度一時トークンが発行されるものや、目的のAPIとは別にサイトの会員認証用APIでログインする必要があるなど、なかなか厳重なものも見受けられます。
AKASHIの場合は、打刻等を実行する会員ごとに管理される「アクセストークン」をHTTPリクエスト時にセットします。
それだけなので非常に処理はわかりやすいのですが、アクセストークンの有効期限は1ヶ月プラス1日。つまり今回の手作り簡易打刻システムでPOSTするデータを定期更新する必要があります。
しかしそこは抜かりなく、「アクセストークン再発行」も公開API化されているという親切設計。
とはいえ毎回更新するのも無駄なので、打刻システム用PCのローカルにトークンをテキストファイルとして保存しておき、それを更新・上書きする別のPythonプログラムを用意して、そっちは月に2回だけ動かすことにしました。
PythonでのCSVファイル読み書きについてはたくさん情報がありますが、今回は社員番号とトークンを対応させてファイル化するため、辞書として読み書きできれば便利。標準クラスで使えるDictReaderを使いました。
参考サイト↓
Windowsの電源自動運用
これは、出退勤システム自体とは関係なく、単なるWindows PCの電源運用の話なので詳細は省略します。
簡単に言えば下の流れ。
BIOSで起動
→ 休日判定。今回は、PINGで業務システムのサーバー生死確認
→ 休日ならシャットダウン、そうでなければPython実行
→ 夜になったら、タスクスケジューラーでPythonのプロセスををKill
→ shutdownコマンドを実行
実装。そしてトラブルシュート
ここまででようやく、実現できそうな感触を使み、どんどんコピペでコーディング。真のプログラマーではないため恥もプライドもありません。
そしてそういうズルする人間には、必ず試練が訪れます。
tkinter canvasが更新されない
ライブラリtkinterにてGUIウィンドウを作成します。
1 2 3 |
# Canvas作成 canvas = tkinter.Canvas(root, width=CANVAS_X, height=CANVAS_Y) canvas.pack() |
そこにWEBカメラから取得した画像を定期的に更新する流れです。
1 2 3 4 5 6 7 8 9 10 |
""" WEBカメラの取得画像を表示する関数 """ def show_frame(): (略) # 取得したQRコードの範囲を描画 canvas.create_rectangle(left, top, left + width, top + height, outline="green", width=5) (略) # 500msごとにこの関数を呼び出す canvas.after(500, show_frame) |
QRコードを読み取って出勤のHTTPリクエストに成功したら、tkinterでcreate_image()して次の画像を数秒間出します。
1 2 3 |
#画像変更 tkimg = ImageTk.PhotoImage(img) canvas.create_image(320,270,image=tkimg,tag="pngimage") |
しかし、それをやろうとしても、画像表示タイミングが遅れる上に、create_image()後にsleep()関数などでディレイを入れたつもりでも一瞬だけしか画像が表示されない。
どうやら、WEBカメラの取得画像を描画するタイミングと、それとは別関数内で特定画像をcanvasに反映するタイミングが意図通りにならない模様。
しかしPythonの内部処理なので直接にはコントロールできません。
低級言語の側面を持つC言語などで組込みシステムを動かす場合には、フレームバッファという特殊なメモリ領域にアプリで描画した上で、LCDドライバに転送する、といった画面更新処理を意識的に実装します。
また、タスク優先度、ディスパッチタイミング、セマフォでのリソース排他など、RTOS特有のマルチスレッド設計を少しかじったことがある筆者としては、JavaやPythonなどの高級言語でそれらを意識しないで済むのが楽に感じる一方で、ガベージコレクションのトリガなど、ややもすれば非常にバグの温床となるような重要な処理を言語エンジン側の内部処理としてブラックボックス化されることに怖さも感じます。
Python with tkinter での描画タイミングについても同じことが言え、描画を止める、もしくは強制的に任意タイミングで描画させるといった制御手段が無い(わからない?)のはもどかしいです。
仕方ないので、出退勤画像の描画をcreate_image()で指示した後、waitするのではなくしばらくWebカメラ画像のcreate_rectangle()処理をスキップして処理ループだけ回してみたところ、その間所望の画像が表示されました。
1 2 3 4 5 |
#画像表示関数をコール show_img(input_type,json_data['response']['stampedAt'],memberArr[0]) #Webカメラ画像描画処理をスキップするための変数。スキップのたびにデクリメントする waitCount = 10 |
画像表示を要求したら処理スキップ用の変数に値をセットし、スキップの都度その変数をデクリメントします。
変数がゼロのときだけ、WEBカメラ画像を描画するようにしました。
モニタの電源ON処理に苦戦
できるだけモニタ電源はオフしておき、QRコードがWebカメラにかざされたときだけONしたい。
無操作15分でモニタ電源OFFになるようWindowsの電源設定をした上で、Pythonで必要なときにモニタ電源を起こそうと考えました。
PythonはWin32 APIも叩けるのでPostMessageで余裕かと思いきや、一瞬だけついてすぐモニタが消えてしまう。
マウスカーソルを擬似的に動かせば大丈夫という記事を発見し、ctypes.windll.user32.SetCursorPos()を使うも改善せず。
pyautoguiでもだめ。
半ばあきらめかけたところ、下のサイトで、Win32APIの.SendInputを使うやり方が紹介されていたので、やってみたところ、これが大成功!深謝!
なぜかどんどんWEBカメラ画像の反映が遅くなる
Webカメラの取得画像をtkinterで一定時間ごとに描画し続けてしばらくすると、カメラに写した映像が描画されるまで、数秒もかかる状態に。
メモリリーク?不要なスレッドの発生?などと考えてタスクマネージャーでプロセスの状況を見てみるも、起動直後と大きな違いはない。
上で書いたとおり、描画関数は500msごとにafter()で再呼び出ししています。その都度きっちりと同期処理で描画が完了してから関数を抜けているとしたら、こんなに遅れるはずがない。
上の「tkinter canvasが更新されない」の問題に苦心してからというもの、描画タイミングを制御できないことにすっかりガクブル状態の筆者は、「描画するデータが溜まっているのでは?」とざっくり推論。
よくわかりませんが、delete("all")で、tkinterのキャンバス上のアイテムを全削除する処理を10分ごとに実行してみることにしました。
とりあえず改善した様子なので、実運用まで様子見。最悪は、タスクスケジューラーでpyプログラムのkillと再実行でしょうか。
1 2 3 |
def dell_frame(): canvas.delete("all") # 内容削除 canvas.after(600000, dell_frame) |
その他の細かな工夫
ゆる画像をランダム表示し和ませる
QRコードをかざして表示される画像は、フリー画像から十数枚選択してランダム表示させます。
「会社」という堅苦しい空間にわずかでも安らぎをもたらしたいため、鳥獣戯画のフリー素材を使わせていただきました。
出勤/退勤のミス防止
出勤打刻なのか退勤打刻なのかは、読ませるQRコードを変えるだけです。
カード裏表で出勤/退勤を分けるつもりなので、裏表でカードの色を変えるなどわかりやすくする予定ですが、それでも「出勤打刻のつもりは間違えて退勤してしまった」などのミスはつきもの。
そこで単純に、時間を区切って早すぎる退勤打刻や遅すぎる出勤打刻はエラーにするようにしました。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
CommingHour = 15 #この時間より後に出勤打刻したら、たぶん退勤と間違い LeavingHour = 9 #この時間より後に退勤打刻したら、たぶん出勤と間違い (略) # 現在時刻を取得 now_hour = datetime.now().strftime("%H") # 時刻が想定より乖離している場合は出勤・退勤間違いと思われる。 if memberArr[1] == '11' and int(now_hour) > CommingHour: (出勤時刻異常エラー処理) elif memberArr[1] == '12' and int(now_hour) < LeavingHour: (退勤時刻異常エラー処理) |
ログを外部ファイルに記録
へっぽこ週末プログラマーの産物とはいえ、企業の出退勤時刻の管理は会社としても労働者としても重要です。
想定外のシステムエラーが発生したり、悪意を持った誰かが勤怠システムの記録をいじったり、といったことがないとも限らないので、テキストファイルにログ出力するようにしました。
Pythonではloggingライブラリに体系的によってログ出力を管理できます。
設定ファイルは外部ファイルとしてまとめました。↓参考サイト。
今回できたソースコード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 |
""" OpenCVの動画取得機能を利用し、TkのCanvasに描画して、QRコードを認識させるプログラム 認識後、AKASHIのAPIを利用して出勤・退勤をPOSTする """ from datetime import datetime from time import sleep import tkinter # GUI描画ライブラリ import csv import sys import cv2 # 画像処理ライブラリ(カメラ)OpenCV from PIL import Image, ImageTk # 画像処理ライブラリPillow from pyzbar import pyzbar # QRコード読み取り用ライブラリ import ctypes # 特定Key押下で終了できるようにする import requests # HTTPリクエスト用ライブラリ import logging.config # ログ用ライブラリ from pygame import mixer # 効果音鳴らすためのライブラリ import random from ctypes import * # ディスプレイ電源ONに使う import pyautogui import sendInput # マウスを動かす(外部py) # ログ設定ファイルからログ設定を読み込み logging.config.fileConfig('logging.conf') logger = logging.getLogger() logger.info('----------- QRCodeReader py start -----------') # サウンド初期化 mixer.init() mixer.music.load("sounds/coming.wav") root = tkinter.Tk() root.title("QR-Code reader") root.geometry("640x480") CANVAS_X = 640 CANVAS_Y = 480 # Canvas作成 canvas = tkinter.Canvas(root, width=CANVAS_X, height=CANVAS_Y) canvas.pack() # VideoCaptureの引数にカメラ番号を入れる。 # デフォルトでは0、ノートPCの内臓Webカメラは0、別にUSBカメラを接続した場合は1を入れる。 cap = cv2.VideoCapture(0) #ESCキーでプログラム終了 VK_ESC = 27 # グローバル変数 waitCount = 0 CommingHour = 15 #この時間より後に出勤打刻したら、たぶん退勤と間違い LeavingHour = 9 #この時間より後に退勤打刻したら、たぶん出勤と間違い # 社員名とコードの対応変数(dict変数) members = {} with open('members.csv', 'r', encoding='UTF-8') as f: reader = csv.DictReader(f) for row in reader: # keyとvalueの列を取り出してdataに追加 ※2 members[row['社員コード']] = row['名前'] """ ESCキーでプログラム終了 """ def isKeyPressed(vkey): return bool(ctypes.windll.user32.GetAsyncKeyState(vkey) & 0x8000) """ WEBカメラの取得画像を表示する関数 """ def show_frame(): global CANVAS_X, CANVAS_Y global waitCount global CommingHour global LeavingHour global members if isKeyPressed(VK_ESC): #ESCでプログラム終了 logger.info("Pressed ESC:プログラムを終了します") cap.release() root.quit() sys.exit() try: ret, frame = cap.read() except: logger.exception('例外') if ret == False: logger.error('カメラから画像を取得できませんでした') sys.exit() image_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) # RGBに変換 image_pil = Image.fromarray(image_rgb) # PILフォーマットへ変換 image_tk = ImageTk.PhotoImage(image_pil) # ImageTkフォーマットへ変換 canvas.image_tk = image_tk if waitCount == 0: # 出退勤画像表示終了後 # ImageTk 画像配置 画像の中心が指定した座標x,yになる canvas.create_image(CANVAS_X / 2, CANVAS_Y / 2, image=image_tk,tag="webcam") # Canvasに現在の日時を表示 now_time = datetime.now().strftime("%m月 %d日 %H時 %M分 %S秒") canvas.create_text(CANVAS_X / 2, 30, text=now_time, font=("Meirio UI", 22, "bold"), fill='yellow', tag="date") canvas.create_text(CANVAS_X / 2, 430, text='コードをカメラに写してください', font=("Meirio UI", 18), fill='yellow') # 現在時刻を取得 now_hour = datetime.now().strftime("%H") # 画像からQRコードを読み取る decoded_objs = pyzbar.decode(frame) # 配列要素がある場合 if decoded_objs != [] and waitCount == 0: #ディスプレイ電源ON display_ON() sleep(1.5) str_dec_obj = decoded_objs[0][0].decode('utf-8', 'ignore') logger.info('---- QRコード検出 ----'.format(str_dec_obj)) logger.info('decoded_objs:{}'.format(decoded_objs)) logger.info('QR cord: {}'.format(str_dec_obj)) # QRコードの情報から社員IDと出退勤情報に分割 memberArr = str_dec_obj.split(':') logger.info('memberArr:{}'.format(memberArr)) logger.info('社員名:{}'.format(members.get(memberArr[0]))) left, top, width, height = decoded_objs[0][2] # 時刻が想定より乖離している場合は出勤・退勤間違いと思われる。 if memberArr[1] == '11' and int(now_hour) > CommingHour: logger.info("出勤時刻異常") show_alert("[出勤時刻異常]","退勤と間違えていませんか?") waitCount = 15 elif memberArr[1] == '12' and int(now_hour) < LeavingHour: logger.info("退勤時刻異常") show_alert("[退勤時刻異常]","出勤と間違えていませんか?") waitCount = 15 else: #QRコード記載の社員IDから、特定のテキストファイルを読み込む path = 'token/{}.txt'.format(memberArr[0]) with open(path) as f: token = f.read() logger.info('token:{}'.format(token)) logger.info("HTTPリクエスト処理開始") #打刻 requrl = ' https://atnd.ak4.jp/api/cooperation/*****' post_data = { 'token': token, 'type': int(memberArr[1]), 'timezone': '+09:00' } res = requests.post(requrl, json=post_data) logger.info('HTTP Status:' + str(res.status_code)) json_data = res.json() logger.info('response body:' + res.text) if json_data['success']: input_type = json_data['response']['type'] #画像表示 show_img(input_type,json_data['response']['stampedAt'],memberArr[0]) waitCount = 10 else: logger.error('API was failed') logger.error(json_data['errors']) sleep(0.3) # 出退勤画像終了カウントダウンフラグのディリメント if waitCount > 0: waitCount = waitCount - 1 # 500msごとにこの関数を呼び出す canvas.after(500, show_frame) """ 打刻したときに背景画像とメッセージを表示する関数 """ def show_img(type,timeStr,code): global tkimg global CANVAS_X, CANVAS_Y # 効果音 mixer.music.load("sounds/coming.wav") mixer.music.play(1) if type == 11: img = Image.open("images/coming/{}.png".format(random.randint(1,10))) outtxt1 = '【出勤】' if int(timeStr[11:13]) < 10: outtxt2 = members[code] + 'さん、おはようございます' else:outtxt2 = members[code] + 'さん、おつかれさまです' elif type == 12: img = Image.open("images/leaving/{}.png".format(random.randint(1,11))) outtxt1 = '【退勤】' outtxt2 = 'お疲れさまでした' elif type == 21: img = Image.open("images/going/{}.png".format(random.randint(1,8))) outtxt1 = '【直行】' outtxt2 = 'お疲れさまです' elif type == 22: img = Image.open("images/bounce/{}.png".format(random.randint(1,8))) outtxt1 = '【直帰】' outtxt2 = 'お気をつけていってらっしゃいませ' if outtxt1: outtxt1 = outtxt1 + timeStr #画像変更 tkimg = ImageTk.PhotoImage(img) canvas.delete("all") canvas.create_image(320,270,image=tkimg,tag="pngimage") canvas.create_text(320, 50, text=outtxt1, font=("Meirio UI", 20, "bold"),tag="hitdate") canvas.create_text(320, 100, text=outtxt2, font=("Meirio UI", 20, "bold")) else: logger.error('type was wrong') """ 打刻種別が想定時間と乖離ある場合に警告を表示する関数 """ def show_alert(showStr1,showStr2): global CANVAS_X, CANVAS_Y canvas.delete("all") # 内容削除 sleep(1.0) canvas.create_text(CANVAS_X/2, 20, text=showStr1, font=("Meirio UI", 16, "bold")) canvas.create_text(CANVAS_X/2, 50, text=showStr2, font=("Meirio UI", 16, "bold")) mixer.music.load("sounds/leaving.wav") mixer.music.play(1) def display_ON(): logger.info("turn on the display") # 外部pyの関数 sendInput.moveMouse() def dell_frame(): canvas.delete("all") # 内容削除 canvas.after(600000, dell_frame) dell_frame() show_frame() root.mainloop() |