惑星カメラとは,聞き慣れないかもしれませんが,最近は惑星を動画撮影からコンポジット(スタック)することを可能にした高感度天体用カメラが普及しています。 センサーは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()