[追記:03/Aug/ 2022」当初,高感度の惑星撮影用のCMOSカメラを流星撮影に応用して,動画撮影したファイルからPythonのOpenCVモジュールを使って動体検知するプログラムを作りました。さらに2021年暮れになって,今までwebカメラとしては利用できなかったCOMSカメラにDirectshow版のドライバーが公開されたので(playerone社のカメラだけですが)リアルタイムで,パソコンでキャプチャーしつつ動体(流星)を検知して記録(静止画として保存,その後動画や比較明合成画像に)するプログラムに書き直しました。

 ところが,今年(2022年)になって,AtomCAM2という,いわゆる防犯カメラ(あるいは見守りカメラ)で流星をとらえる,という方法が流星研究者などの間で話題となり,私も5月から1台購入して使うようになりました。なんと,1台3~4000円のこのAtomCAM2(AC2)のナイトビジョンを使うと,都会の空でも明るい流星をとらえることができ,しかも防水機能とWi-Fi環境で屋外に備え付けで四六時中データを保存できます。ただし,AC2に備えられている動体検知機能は人や動物の動きをとらえるのですが,流星のようなわずかな画像の変化をとらえるには,別途画像解析用のソフトが必要になります。アマチュア天文家でエンジニアでもある長谷川均さんが,これを公開しているので,プログラムの知識があれば,利用できます(Python が動かせる環境)。私が,以前作った動体検知のアルゴリズムは,前後の画像を白黒2値化してその量の面積差から動体を検知するという単純なもので,少し雲などが流れてきても検知してしまうという欠点があります。長谷川さんのatomcam.pyでは,1秒間のframeを合成して,線状の構造を検知するので流星のみを(速い飛行機も)うまく検知できるようで,すぐれものです。

 というわけで,最近はもっぱらAtomCam2で流星や火球がでないか晴れれば毎日観測していますが,画像的にはカラーでもう少し鮮明なものも欲しくなってきました。そこで,長谷川さんのプログラムを拝借しながら以前のCMOSカメラでも流星を検知し,夏のペルセウス座流星群やふたご座流星群などに備えようと考えています。プログラムなどはエンジニアがつかうGitHubというところにおいて更新していきたいと思いますので,参考にしてください。

6月29日には,大きな火球をAtomCAM2でとらえることができました。


ここから下は旧記事です(2020年1月公開)

惑星カメラとは,聞き慣れないかもしれませんが,最近は惑星を動画撮影からコンポジット(スタック)することを可能にした高感度天体用カメラが普及しています。 センサーはSONY だということですが,基盤を含め中国で作られている製品がシェアを占めています。通常は,望遠鏡に付けて惑星や星雲などを撮影します。センサーサイズは1/3インチ~1インチ程度で,監視カメラやドライブレコーダーなどのUSBカメラの超高感度版と言えるかと思います。レンズに,これらと同じCCTVタイプのものを装着すれば,景色ももちろん写ります。ネットで検索するとタムロン2.8mmF1.2CCTV,という1万円以下のものがあったので,購入してみました。そして,流星を撮ろうと考えたのですが,およそ1/8秒のシャッター速度でも1分間に480フレーム,15分も撮影すると数ギガバイトもメモリーを食うことになると躊躇していました。市販の流星(UFO)などの動体検知ソフトもあるのですが,これらのカメラには適合しないような仕様になっています。

2020年1月9日未明に出現した火球
2020年8月12日午前0時~3時45分までに動画撮影で写ったペルセウス座流星群の流星19個を比較明合成

ということで,pythonのモジュールで画像を扱えるOpenCVを使って,動体検知プログラムを組んでみました。撮影しながらの動体検知をすることはできませんが,キャプチャーソフトを使って数時間撮影した動画ファイルを読み込んで,動体(流星など)を検知したらフレーム数(位置)と画像を比較明合成して出力できる,というものです。動画の保存には,外付けのSSDかHDDがあったほうが良いと思います。もちろん人が動画を見て流星が写っているか監視しても良いのですが,パソコンにやらせる方がはるかに楽になります。見ての通り,pythonのコード なので利用してもらうには多少プログラミングの知識も必要ですが, ファイルの読み込み部分はGUIになっているので,numpyが同梱されているpython(Anaconda)とOpenCVモジュールのインストール(pip install opencv-python です。)ができれば動くと思います。

どの程度の画像の変化を検知するかは,前後3枚の画像上の差分をとって白黒二値に変換し,変化のピクセル数を閾値としています。GUI画面でこの閾値(変数:スレッショルド=th:0~80)を変更できます。また,比較明画像の保存先のフォルダー名は日本語ではエラーになります。英数名のフォルダーを選ぶようにしてください。プログラムの作成には,ネット上の多くの方のコードを参考に(一部コピペ)させていただきました。下のほうにソースコードを貼り付けてあります。

  1. 上が,起動したときに現れるパネルです。
  2. 「ファイル選択ボタン」で,検知したい動画ファイル(AVIまたはMP4形式)を選んでください。
  3. 検知したら,その順番とフレームNo.,動体で生じた差分のピクセル数(大きさ)を記録するCSVファイルと比較明画像を保存するフォルダーを指定します(「保存フォルダーボタン」)
  4. 動体を検知する,閾値(スレッショルド)の量を変えることができます。飛行機などはデフォルト(15以上でも)で検知しますが,暗い流星などは10位の方が良いかもしれません。明るい雲が多いとほとんどのフレームを拾ってしまうので,そういうときは50位にする方が良いです。
  5. 「実行ボタン」で動体検知を開始します。
  6. 動画のモニターフレーム(image)と比較明画像(result:検知したとき)の2画面が現れるはずです。
  7. 途中で止めたいときは,キーボードの”q”キーを押すと,終了します。
  8. すべてのフレームが表示し終わると,コンソールに「おわりますか?」と表示されます。なにかキーを押すと画面は消えます。
  9. 検知結果は,3.で指定したフォルダーにプログラム動作開始時の時分が付いたcsvファイルに保存されています。
  10. 「プログラムを終了」ボタンまたは,右上の「×」でプログラムを終わらせます。
  11. 例外処理を行っていないので,上記以外の動作によっては,エラーが出ることがあります。終了ボタンで,再度実行してください。

"""
Created on Sun Jan  5 11:29:32 2020
Moving body detection program for Meteor use(detecting 3 frame)
file type MP4 & AVI   implement user interface
"""
import os
import sys
import datetime
import cv2
import csv
import tkinter as tk
import numpy as np
from tkinter import filedialog as tkFileDialog #python3

def select_file():
    global filenameselect
    #root.withdraw()
    fTyp = [(' AVI MP4 file','*.avi;*.mp4')]
    iDir = os.path.abspath(os.path.dirname(__file__))
    print(iDir)
    filenameselect = tkFileDialog.askopenfilename(filetypes=fTyp,initialdir=iDir,title = "select file")
    root.update()
    
def select_dir():
    global dirname
    #root.withdraw()
    iDir = os.path.abspath(os.path.dirname(__file__))
    dirname = tkFileDialog.askdirectory(initialdir=iDir,title = "select folder saving log and result images")
    root.update()
    
    
# Function of the comparison light composition
def Lighten(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    # It is a comparison and reference with Boolean value sequence
    is_BG_lighter = bg_img > fg_img
    
    result[is_BG_lighter] = bg_img[is_BG_lighter]
    result[~is_BG_lighter] = fg_img[~is_BG_lighter]
   
    return result

def main(var):
    # Starting the capture of the video file
    cap = cv2.VideoCapture(filenameselect)
    # Three frames of the beginning
    img1 = img2 = img3 = get_image(cap)[1]
    # Initial composition image
    dst = np.zeros(img1.shape)
    th = var    # threshold
    print('th=',th)
    date = datetime.datetime.now()
    num = 1
    with open(dirname + "/" + "log_" + str(date.hour) + "_"  + str(date.minute) +  ".csv", "a", newline='') as file:
        w = csv.writer(file, delimiter=",")
        while True:
        #  If "q" key is pushed, it is finished on the way
            if cv2.waitKey(1) & 0xFF == ord('q'): 
                break
        #  If an image cannot read, it is finished
            if not ret:
                break
        #  provide difference
            diff = check_image(img1, img2, img3)
        #  If there is difference that counted more than a value of th, judge movement that there was
            cnt = cv2.countNonZero(diff)
        
            if cnt > th:
                w.writerow([num, 'fr',int(cap.get(cv2.CAP_PROP_POS_FRAMES)),'cnt',cnt])
                print(f"detects movement {num} fr= {int(cap.get(cv2.CAP_PROP_POS_FRAMES))} cnt= {cnt}")
            #cv2.imshow('meteo-image', img2)
            # photographs to image
                bg_img = dst / 255    #clip 0~1
                fg_img = img2 / 255
                # to the comparison light composition
                result = Lighten(bg_img, fg_img).clip(0,1)
    
                dst = result*255
            
                cv2.imshow('result',result)
                num += 1
            img1,img2,img3 = (img2,img3,get_image(cap)[1])
    cv2.imwrite(dirname + "/" + "metro_" + str(num) +".jpg" , dst) #書き込み
    # final work
    file.close
    print('Close it? push any key ')
    cv2.waitKey(0)
    cap.release()
    cv2.destroyAllWindows() 
    
# the function of detecting
def check_image(img1, img2, img3):
    # convert into a gray scale image
    gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY)
    gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY)    
    gray3 = cv2.cvtColor(img3, cv2.COLOR_RGB2GRAY)
    
    # different of absolute
    diff1 = cv2.absdiff(gray1, gray2)
    diff2 = cv2.absdiff(gray2, gray3)
    # giving logical product.
    diff_and = cv2.bitwise_and(diff1, diff2)
    # binalize black and white
    _, diff_wb = cv2.threshold(diff_and, 15, 255, cv2.THRESH_BINARY)
    # removing noise
    diff = cv2.medianBlur(diff_wb, 3)
    return diff

# geting image
def get_image(cap):
    global ret
    ret,img = cap.read()  #ret;judgment whether or not can read it
    if ret :
        #pass
        img = cv2.resize(img, (962, 720))
        cv2.imshow('image', img)
    else:       
        return (ret,img)
    return(ret,img)
    
"""        =========  GUI  ==========       """
class Application(tk.Frame):
    def __init__(self, master = None):
        super().__init__(master)
        master.title("Meteor(Fireball):Moving body detection program")
        master.geometry("640x420")
        self.pack()
        self.create_widgets()
    def create_widgets(self):
        
        self.lb1 = tk.Label(self,text='\n  キャプチャーした動画から流星や(動体)を検知するプログラムです。\n \n読み込む動画のファイルタイプは,AVIまたはMP4形式です。',\
               width=300,font=('MSゴシック', 11),anchor = 'nw' )
        self.lb1.pack()
        self.bt1 = tk.Button(self, text = 'Select file', width = 15, command = select_file)
        self.bt1.pack()
        self.lb2 = tk.Label(self,text='\n     比較明画像と検知ログの保存フォルダーを選んでください。\n \nフォルダー名は英数文字のみ(日本語は不可です)。\n    ファイル名には現在時刻と検知したフレームの数がつきます\n',\
                   font=('MSゴシック', 10),width=300,anchor = 'w' )
        self.lb2.pack()
        
        self.bt2 = tk.Button(self, text = 'Save folder',width = 15, command = select_dir)
        self.bt2.pack()
        self.lb3 = tk.Label(self,text='\n  \n  動体を判別する閾値(threshold)の値を入力してください。\n  \n(0~80)デフォルトは15です ',\
               font=('MSゴシック', 10), width=300 )
        self.lb3.pack()
        self.var = tk.IntVar(value = 15)
        #print(self.var)     
        self.spbox = tk.Spinbox(self, width = 6,from_ = 0, to = 80,textvariable = self.var)
        self.spbox.pack()        
        self.bt3 = tk.Button(self, text = 'Excute',width = 15,command = self.upd_scale)
        self.bt3.pack()
        self.lb4 = tk.Label(self,text='\n動画ファイルの読み込みをやめるときは’q’キーを押してください。\n \nモニター画面は処理が終わったら何かキーを押すと消えます。',fg='blue',font=('MSゴシック', 10),)
        self.lb4.pack()
        self.lb5 = tk.Label(self,text='\n      実行ボタンを押す前に,ファイルとフォルダーを選んでください。',fg = 'red',font=('MSゴシック', 10),width=300,anchor ='c')
        self.lb5.pack()        
        self.bt4 = tk.Button(self, text = ' Finish program ',width = 20,command = self.end)
        self.bt4.place(x=450,y=120)
    def upd_scale(self):
        self.var=self.var.get()
        main(self.var)
    def end(self):
        root.destroy()
        sys.exit()
         
root = tk.Tk()
app = Application(master = root)
app.mainloop()