OpenCVの直線検知で流星群画像を仕分け
GUI版
Python環境を必要としない実行可能形式です。
CUI版使い方
Python環境がある方向け。
detect_meteor.py
の第一引数に.jpg
画像の入ったフォルダパスを指定することで、フォルダ内の*.jpg
画像に対して検知処理を走らせます。
pip install -r requirements.txt
python detect_meteor.py path/to/directory
--area-threshold
の値の調整#!/usr/bin/env python3 | |
""" | |
detect_meteor.py | |
Copyright (c) 2021, AstroArts Inc. | |
This software is released under the 3-clause BSD license, see LICENSE. | |
""" | |
import argparse | |
import math | |
import os | |
import sys | |
import typing | |
import cv2 | |
import numpy | |
from tqdm import tqdm | |
def load_gray(filepath: str) -> numpy.array: | |
""" | |
グレイスケール画像読み込み | |
:param str filepath: 入力ファイルパス | |
:return: グレイスケール画像データ | |
""" | |
filepath = str(filepath) | |
img = cv2.imread(filepath, cv2.IMREAD_GRAYSCALE) | |
return img | |
def detect_lines(img: numpy.array) -> typing.List[numpy.array]: | |
""" | |
直線検出 | |
:param numpy.array img: 入力画像(グレイスケール) | |
:return: 検出直線リスト | |
""" | |
ret, thr = cv2.threshold(img, 127, 255, cv2.ADAPTIVE_THRESH_MEAN_C) | |
lines = cv2.HoughLinesP(thr, rho=1, theta=math.pi/180, threshold=200, minLineLength=20) | |
return lines | |
def detect_area(img: numpy.array, threshold: float = 0.0001) -> typing.List[numpy.array]: | |
""" | |
閾値を超える面積を持つ輪郭の検出 | |
:param numpy.array img: 入力画像 | |
:param float threshold: 閾値(画像全体の何%を`(0, 1]`で指定) | |
:return: 閾値を超えた面積の領域リスト | |
""" | |
height, width = img.shape | |
img_area = width * height | |
ret, thr = cv2.threshold(img, 127, 255, cv2.ADAPTIVE_THRESH_MEAN_C) | |
contours, hierarchy = cv2.findContours(thr, 1, 2) | |
contours = [cnt for cnt in contours if (cv2.contourArea(cnt) / img_area) > threshold] | |
return contours | |
T = typing.TypeVar("T") | |
def clamp(v: T, min_v: T, max_v: T) -> T: | |
""" | |
clamp関数 | |
入力値を`[min_v, max_v]`の範囲に収める | |
:param T v: 入力値 | |
:param T min_v: 最小値 | |
:param T max_v: 最大値 | |
:return: `[min_v, max_v]`内に収めた値 | |
""" | |
return min(max_v, max(v, min_v)) | |
def fill_area(img: numpy.array, contours: typing.List[numpy.array], buffer_ratio: float = 0.01, color: typing.Optional[float] = None) -> numpy.array: | |
""" | |
領域の外接矩形で塗りつぶす | |
:param numpy.array img: 入力画像 | |
:param contours: 領域リスト | |
:param float buffer_ratio: バッファ率 (e.g. 6000 * 0.01 => 60px) | |
:param float color: 塗りつぶしの色(未指定の場合は入力画像の中央値で塗りつぶす) | |
:return: 塗りつぶし後の画像 | |
""" | |
height, width = img.shape | |
x_buffer = int(width * buffer_ratio) | |
y_buffer = int(height * buffer_ratio) | |
# detect fill color | |
if color is None: | |
color = numpy.median(img) | |
# fill bounding rect | |
for cnt in contours: | |
x, y, w, h = cv2.boundingRect(cnt) | |
left = clamp(x - x_buffer, 0, width) | |
top = clamp(y - y_buffer, 0, height) | |
right = clamp(x + w + x_buffer, 0, width) | |
bottom = clamp(y + h + y_buffer, 0, height) | |
pts = numpy.asarray([ | |
[left, top], | |
[left, bottom], | |
[right, bottom], | |
[right, top], | |
]) | |
img = cv2.fillPoly(img, pts=[pts], color=(color,)) | |
return img | |
def line_length(line: numpy.array) -> float: | |
""" | |
直線の長さの算出 | |
`cv2.HoughLinesP()`の返り値は`[ [ [start_x, start_y, end_x, end_y] ], ... ]`形式になっている | |
:param line: `[ [start_x, start_y, end_x, end_y] ]`であること | |
:return: 直線の長さ | |
""" | |
assert len(line) == 1 | |
sx, sy, ex, ey = line[0] | |
dx = ex - sx | |
dy = ey - sy | |
return math.sqrt(dx * dx + dy * dy) | |
def detect_meteor(filepath: str, area_threshold: float = 0.0001, line_threshold: float = 100) -> typing.Optional[typing.Tuple[str, typing.List[numpy.array]]]: | |
""" | |
流星の検出 | |
:param str filepath: 入力画像ファイルパス | |
:param float area_threshold: 面積のある領域検知用閾値(`detect_area()`関数`threshold`参照) | |
:param float line_threshold: 検出した直線を流星と判定する最小の長さ | |
:return: (画像ファイルパス, 検出した直線) or None | |
""" | |
img = load_gray(filepath) | |
area_contours = detect_area(img, area_threshold) | |
if area_contours: | |
img = fill_area(img, area_contours) | |
lines = detect_lines(img) | |
if lines is not None: | |
length = max([line_length(x) for x in lines]) | |
if length > line_threshold: | |
return lines | |
return None | |
def main(argv: typing.List[str]) -> int: | |
parser = argparse.ArgumentParser() | |
parser.add_argument("directory") | |
parser.add_argument("--area-threshold", type=float, default=0.0001) | |
parser.add_argument("--line-threshold", type=float, default=100) | |
args = parser.parse_args(argv[1:]) | |
# 拡張子が`.jpg`の画像リストを作成 | |
image_list = [] | |
for dirname, _, filenames in os.walk(args.directory): | |
image_list.extend([os.path.join(dirname, x) for x in filenames if x.lower().endswith(".jpg") or x.lower().endswith(".jpeg")]) | |
image_list.sort() | |
# 流星の写っていると思われる画像を抽出 | |
result = [] | |
for filepath in tqdm(image_list): | |
filepath = str(filepath) | |
lines = detect_meteor(filepath, args.area_threshold, args.line_threshold) | |
if lines is not None: | |
result.append((filepath, lines)) | |
print("detected: {}/{}".format(len(result), len(image_list))) | |
if result: | |
print("files:") | |
for filepath, lines in result: | |
print(filepath) | |
return 0 | |
if __name__ == "__main__": | |
sys.exit(main(sys.argv)) |
BSD 3-Clause License | |
Copyright (c) 2021, AstroArts Inc. | |
All rights reserved. | |
Redistribution and use in source and binary forms, with or without | |
modification, are permitted provided that the following conditions are met: | |
1. Redistributions of source code must retain the above copyright notice, this | |
list of conditions and the following disclaimer. | |
2. Redistributions in binary form must reproduce the above copyright notice, | |
this list of conditions and the following disclaimer in the documentation | |
and/or other materials provided with the distribution. | |
3. Neither the name of the copyright holder nor the names of its | |
contributors may be used to endorse or promote products derived from | |
this software without specific prior written permission. | |
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" | |
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE | |
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE | |
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE | |
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL | |
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR | |
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER | |
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, | |
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE US | |
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
numpy | |
opencv-python | |
tqdm |