개발 환경 구축
IDE 및 Python 설정
PyCharm 커뮤니티 에디션을 설치하고, Python 3.8 버전을 권장 환경으로 구성합니다. 버전 호환성 이슈를 방지하기 위해 너무 최신이나 구형 버전은 피하는 것이 바람직합니다.
Annotation 도구 설치
가상 환경을 활성화한 뒤清華 대학 미러를 통해 labelme 패키지를 설치합니다.
pip install labelme -i https://pypi.tuna.tsinghua.edu.cn/simple
태양광 패널 Polygon Annotation
터미널에 labelme 명령을 입력하여 GUI를 실행하고, 이미지 폴더를 불러옵니다. 다각형 생성 도구를 선택하여 패널 외곽을 정밀하게 둘러싸며, 클래스명은 panel로 통일합니다.
핵심 가이드라인:
- 패널 전체를 4개 이상의 점으로 정확히 커버 (복잡한 형태는 추가 점 사용)
- 경계가 과도하게 넓거나 잘리지 않도록 주의
- 물리적으로 분리된 패널은 개별 객체로 분리 표기
- 연결된 패널은 단일 polygon으로 처리
작업 완료 후 저장하면 JSON 형식의 메타데이터가 생성됩니다. 이미지 1장당 JSON 파일 1개가 대응됩니다.
JSON → 마스크 이미지 변환
다음 스크립트를 실행하여 LabelMe JSON을 개별 폴더 구조로 변환합니다.
import os
target_dir = r"E:\projects\solar_annot\json_files"
os.chdir(target_dir)
entries = os.listdir(target_dir)
os.system("activate labelme")
for entry in entries:
if entry.endswith(".json"):
os.system(f"labelme_json_to_dataset {entry}")
생성된 폴더들에서 label.png와 img.png를 추출하여 재배치합니다.
import os
import shutil
src_root = r"E:\projects\solar_annot\json_files"
mask_dest = r"E:\projects\solar_annot\masks"
image_dest = r"E:\projects\solar_annot\images"
os.makedirs(mask_dest, exist_ok=True)
os.makedirs(image_dest, exist_ok=True)
for cur_path, sub_dirs, _ in os.walk(src_root):
for folder in sub_dirs:
full = os.path.join(cur_path, folder)
contents = os.listdir(full)
base = "_".join(folder.split('_')[:2]) + ".png"
if "label.png" in contents:
shutil.copy(os.path.join(full, "label.png"), os.path.join(mask_dest, base))
if "img.png" in contents:
shutil.copy(os.path.join(full, "img.png"), os.path.join(image_dest, base))
마스크 색상 정규화 (붉은색 → 흰색)
OpenCV를 활용해 바이너리 마스크로 변환합니다.
import os
import cv2
mask_dir = r"E:\projects\solar_annot\masks"
os.chdir(mask_dir)
for fname in os.listdir(mask_dir):
raw = cv2.imread(fname)
gray = cv2.cvtColor(raw, cv2.COLOR_BGR2GRAY)
_, binary = cv2.threshold(gray, 1, 255, cv2.THRESH_BINARY)
cv2.imwrite(fname, binary)
YOLO 세그멘테이션 포맷 변환
다각형 좌표를 YOLO segmentation 형식(정화된 꼭짓점 나열)으로 변환합니다.
import os
import json
CLASS_TABLE = {"panel": 0}
def polygon_to_yolo_seg(json_file, out_file):
with open(json_file, 'r', encoding='utf-8') as f:
meta = json.load(f)
w, h = meta['imageWidth'], meta['imageHeight']
lines = []
for shape in meta['shapes']:
lbl = shape['label']
if lbl not in CLASS_TABLE or shape['shape_type'] != 'polygon':
continue
cid = CLASS_TABLE[lbl]
pts = shape['points']
normed = []
for px, py in pts:
normed.extend([px / w, py / h])
coord_str = " ".join([f"{v:.6f}" for v in normed])
lines.append(f"{cid} {coord_str}")
if lines:
with open(out_file, 'w') as f:
f.write("\n".join(lines))
def batch_convert(input_folder, output_folder):
os.makedirs(output_folder, exist_ok=True)
jfiles = sorted([j for j in os.listdir(input_folder) if j.endswith('.json')])
for idx, jname in enumerate(jfiles, 1):
jpath = os.path.join(input_folder, jname)
out_path = os.path.join(output_folder, f"{idx:04d}.txt")
polygon_to_yolo_seg(jpath, out_path)
print(f"변환 완료: {jname} → {idx:04d}.txt")
print(f"\n총 {len(jfiles)}개 파일 변환 완료")
batch_convert(
r"E:\projects\solar_annot\json_yolo",
r"E:\projects\solar_annot\yolo_labels"
)
데이터셋 분할 (Train / Validation / Test)
8:1:1 비율으로 무작위 분할합니다.
import os
import random
import shutil
def partition_dataset(label_dir, picture_dir, dest,
train_r=0.8, val_r=0.1, test_r=0.1, seed_val=42):
assert abs(train_r + val_r + test_r - 1.0) < 1e-6, "비율 합계는 1이어야 합니다"
for subset in ['train', 'val', 'test']:
os.makedirs(os.path.join(dest, subset, 'labels'), exist_ok=True)
os.makedirs(os.path.join(dest, subset, 'images'), exist_ok=True)
stems = sorted([os.path.splitext(t)[0] for t in os.listdir(label_dir) if t.endswith('.txt')])
random.seed(seed_val)
random.shuffle(stems)
total = len(stems)
t_end = int(total * train_r)
v_end = t_end + int(total * val_r)
groups = {
'train': stems[:t_end],
'val': stems[t_end:v_end],
'test': stems[v_end:]
}
for group, names in groups.items():
for stem in names:
shutil.copy2(os.path.join(label_dir, f"{stem}.txt"),
os.path.join(dest, group, 'labels', f"{stem}.txt"))
for ext in ['.jpg', '.jpeg', '.png']:
ipath = os.path.join(picture_dir, f"{stem}{ext}")
if os.path.exists(ipath):
shutil.copy2(ipath, os.path.join(dest, group, 'images', f"{stem}{ext}"))
break
else:
print(f"경고: {stem} 이미지 누락")
for g, n in groups.items():
print(f"{g}: {len(n)} 샘플")
partition_dataset(
r"E:\projects\solar_annot\yolo_labels",
r"E:\projects\solar_annot\raw_images",
r"E:\projects\solar_annot\dataset"
)
YOLO11 세그테이션 모델 추론
훈련된 모델을 로드하여 바이너리 마스크를 생성합니다.
from ultralytics import YOLO
import cv2
import os
import numpy as np
model = YOLO(r"E:\projects\solar_annot\weights\best.pt")
in_dir = r"E:\projects\solar_annot\inference_input"
out_dir = r"E:\projects\solar_annot\binary_outputs"
os.makedirs(out_dir, exist_ok=True)
for fname in os.listdir(in_dir):
if not fname.lower().endswith(('.jpg', '.png', '.jpeg')):
continue
fpath = os.path.join(in_dir, fname)
preds = model.predict(fpath, task='segment', save=False, verbose=False)
for res in preds:
if res.masks is None:
print(f"감지 실패: {fname}")
continue
h, w = res.orig_shape[:2]
accumulator = np.zeros((h, w), dtype=np.uint8)
for m in res.masks.data:
arr = m.cpu().numpy()
accumulator = np.logical_or(accumulator, arr).astype(np.uint8)
bin_img = accumulator * 255
out_name = os.path.splitext(fname)[0] + "_seg.png"
cv2.imwrite(os.path.join(out_dir, out_name), bin_img)
print(f"저장 완료: {out_name}")