Practice of Programming

プログラム とか Linuxとかの話題

Pythonで目標をセンターに入れてCrop と 月を撮るカメラの設定 と ChatGPT

ちょっと前の話になりますが(2022/11/8)、皆既月食天王星食がありましたね。見られましたでしょうか?

僕は途中から、望遠レンズを持ち出して撮影していましたが、久しぶりに撮ると設定とかを忘れちゃっててだめですね。なかなか手こずりました。

で、何枚も撮ったわけですが、ご存知の通り月は動くので...常にセンターで撮れていたわけもなく...。写真を並べても月があっちこっちに行っていて、時系列に順番に見ようと思っても、ちょっと残念な感じです。

とはいえ、これをいい感じに手でトリミングするのも、めんどくさすぎる...ということで、OpenCVを使って、軽くやってみました。

なお、下記の記事が非常に参考になりました。

最初に結果

こんな感じのgifができあがります。animation pngも作りましたが、重いので。

目標をセンターに入れてCropする Python の script

大して長くも無いのでコメントに説明を書いています。 前提として、「写真内の月の大きさは同じ」です。

import cv2
import glob
import numpy as np
import os
import re
from PIL import Image

# 円の半径
min_radius = 520
# ~/Downloads/の下のdirectory名
path_prefix = 'eclipse'

# クロップするサイズ
crop_radius = min_radius + 150

# ファイルを取得(Lumixで撮ると、Pがファイルの最初に付く)
files: list = glob.glob("/home/ktat/Downloads/" + path_prefix + "/P*.JPG")
files = sorted(files, key = lambda item: int(re.sub('/.+/P(\d+)\.(?:jpg|JPG)$', r'\1', item, 1)) )

n = 0
for file in files:
    n += 1

    # tmp に cropしたファイルを保存します
    fname = '/tmp/{}-{}.png'.format(path_prefix,n)

    # 途中で設定変えて続きからやりたいときのために、ファイルがあったらskip 
    if os.path.isfile(fname) is True:
        continue

    img = cv2.imread(file)
    # ノイズ除去
    dst = cv2.GaussianBlur(img, (3,3), 0)
    # グレースケール化
    gray = cv2.cvtColor(dst, cv2.COLOR_BGR2GRAY)
    # 円の検出
    circles = cv2.HoughCircles(gray, cv2.HOUGH_GRADIENT, dp=1.1, minDist=100, param1=100, param2=30, minRadius=min_radius, maxRadius=min_radius + 50)

    if circles is None:
        print("skip {}".format(file))
        # 検出できなかったものがどんなものだったかわかるように保存しておく
        cv2.imwrite("sample-gray-{}.png".format(n), gray)
        cv2.imwrite("sample-noize-cancel-{}.png".format(n), dst)
        continue
    else:
        print("OK {}".format(file))

    circles = np.uint16(np.around(circles))

    x = 0
    y = 0
    i = 0
    for circle in circles[0, :]:
        i = i +1
        x += circle[0]
        y += circle[1]

    # 円が複数検出される可能性があるので(別の場所ではなくて、同じところに複数)
    # 中心座標(平均)を出す
    x = x / i
    y = y / i

    im = Image.open(file)
    left = x - crop_radius
    right = x + crop_radius
    up = y - crop_radius
    bottom = y + crop_radius

    # crop したファイルを保存
    im.crop((left,up,right,bottom)).save(fname, format='png')

以下、ポイントだけ。

ノイズ除去

下記の部分です。除去しないと円の検出がうまく行かないケースが多かったので、先にノイズ除去を行っています。 コントラストがはっきりしていればよいのでしょうが、月食が終わってくると、明るい部分はどうしても輪郭がわかりにくくなってしまうところがありますね。それに対して、多少の寄与はありそうです。

dst = cv2.GaussianBlur(img, (3,3), 0)

円の検出

円の検出は、下記の部分で、minRadius, maxRadius が、円の最小、最大直径です。 最初に書いたように、写真内の月の大きさは同じです(同じ焦点距離で撮っていたので)。

    circles = cv2.HoughCircles(gray, cv2.HOUGH_GRADIENT, dp=1.1, minDist=min_radius param1=100, param2=30, minRadius=min_radius, maxRadius=min_radius + 50)

dp は、0.8-1.2くらいが良いと

circlesに検出された円の情報が複数(同じ場所で複数)来る場合があるで、平均を取っています。

    for circle in circles[0, :]:
        i = i +1
        x += circle[0]
        y += circle[1]

    # 円が複数検出される可能性があるので(別の場所ではなくて、同じところに複数)
    # 中心座標(平均)を出す
    x = x / i
    y = y / i

crop して保存

中心座標(x,y)がわかれば、後は、crop して、保存しているだけです。

    im = Image.open(file)
    left = x - crop_radius
    right = x + crop_radius
    up = y - crop_radius
    bottom = y + crop_radius

    # tmp に cropしたファイルを保存します
    fname = '/tmp/{}-{}.png'.format(path_prefix,n)
    # crop したファイルを保存
    im.crop((left,up,right,bottom)).save(fname, format='png')

アニメーションgif/pngを作る Python の script

cropする処理が重いので、毎回やりたくはないので、別のプログラムとしました。 crop後に余計な画像は削除して連結をやり直しとかしたいからです。

単純に画像を取ってきて並べ替えて、連結してアニメーションにしているだけです。

import glob
import re
from PIL import Image

path_prefix = 'eclipse'
files: list = glob.glob('/tmp/' + path_prefix + "*.png") 
files = sorted(files, key = lambda item: int(re.sub('/tmp/' + path_prefix + '-(\d+)\.png', r'\1', item, 1)) )
cropped: list[Image] = []
cropped_mini: list[Image] = []

for item in files:
    im = Image.open(item)
    cropped.append(im)
    cropped_mini.append(im.resize((200,200)))

cropped[0].save("result-{}.png".format(path_prefix), format="png", save_all=True, append_images=cropped[1:])
cropped_mini[0].save("result-{}-mini.gif".format(path_prefix), format="gif", save_all=True, append_images=cropped_mini[1:])

おしまい

月の大きさが同じだとかなり良い感じにcropできます。しかも割と簡単。

月の大きさが違う場合は、複数の半径の候補を持っておいて、大きいのから試していって、len(circles) == 1 を見つけるまでやると良いかもしれません。

以下は、2018/01/31の月食ですが、これはサイズがバラバラでした。色々試してみましたが、もう少し調整が必要そうです。 (これでも、うまく行かなかった画像は、目視で削除)

おまけ: 月を撮るときのカメラの設定

僕が使ったのは、下記の機材です。ちなみに普段は鳥を撮っているので、月をしょっちゅう撮ってるわけではないです。

普通の月を撮る

普通の月はかなり明るいので、手持ちでも大丈夫です。 iso700-800くらいで、シャッタースピードも1/300-400とかでも十分撮れると思います。

下記は、

皆既月食を撮る

対して、皆既月食は暗いので、三脚を使ったほうが良いです。また、安物の三脚の場合、シャッターを切る指の動きでブレる可能性もあるので、シャッターディレイ(シャッターボタンを押してから数秒後にシャッターが切れる設定)も行うと良いかもしれません。 後、三脚使う場合は、手ブレ補正はオフにしましょう。

下記は、

おまけ2: ChatGPTにお願いしてみた

コード書いてから、ChatGPTにお願いしたらどうなるだろうか、試してみました。

お願い(英語はだいぶ雑)。

Write code with python. The spcification is the following. 
1. several photos is given as commad line argument in which there is any shape of moon. 
2. sort them with file mtime
3. detect a moon center position in image. 
4. crop it from center of the moon to its radius with 200px margin. 
5. resize them as their radius are same. 
6. create animation gif with the results.

restriction:
1. consider memory usage. don't use huge memroy.. 
2. avoid misdetection of a moon as possible 
3. Please check your code with mypy and if error occurs, fix it.
4. implent whole code. avoid todo code.
5. you can assume the largest object in image as a moon.
6. the size of animation gif shoud be less than 20MB

ありがたいお言葉を若干修正したもの。

import os
import sys
from typing import List, Tuple

import cv2
import imageio

# Get the file paths of the images from the command line arguments
image_paths = sys.argv[1:]

# Sort the image paths by file modification time
image_paths.sort(key=lambda x: os.path.getmtime(x))

# Create a list to store the cropped and resized images
images = []

# Iterate over the image paths
for image_path in image_paths:
    # Load the image
    image = cv2.imread(image_path)

    # Convert the image to grayscale
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

    # Use Otsu's thresholding method to get a binary image
    _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

    # Find the contours in the binary image
    contours, _ = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)

    # Find the contour with the largest area
    cnt = max(contours, key=cv2.contourArea)

    # Compute the center of the contour
    M = cv2.moments(cnt)

    # 修正
    if M['m00'] == 0:
        continue

    cx = int(M['m10'] / M['m00'])
    cy = int(M['m01'] / M['m00'])

    # Compute the radius of the contour
    radius = int(cv2.minEnclosingCircle(cnt)[1])

    # 修正
    if radius == 0:
        continue

    # Crop the image from the center of the moon to its radius with a 200px margin
    cropped = image[cy - radius - 100:cy + radius + 100, cx - radius - 100:cx + radius + 100]

    # Get the width and height of the cropped image in pixels
    h, w = cropped.shape[:2]

    # 修正
    if w == 0 or h == 0:
        continue

    # Resize the cropped image so that the radii of the moons in all images are the same
    resized = cv2.resize(cropped, (w, h))

    # Add the resized image to the list of images
    images.append(resized)

# Set the frame rate of the gif to 24 frames per second
fps = 24

# Create a writer object that will write the gif to a file
writer = imageio.get_writer('animation2.gif', fps=fps)

# Write the frames to the gif file
for image in images:
    writer.append_data(image)

# Close the writer object
writer.close()

サイズ制限は無視されてしまったので、手で修正(画像の大きさで指定したほうが良かった)。 後、明らかに変なフレームは手で削除しました。

割と良い線いきますね...。