공공데이터로 제공받을 수 있는 도로가 실제 이동 가능한 모든 길을 포함하는 것은 아니다. 도로를 어느 기준까지 포함하여 작도할지는 각 자료가 제작된 목적에 따라 달라질 수도 있기 때문이다.
특히 주거지 내부의 골목길과 생활도로는 기존 도로 자료에서 누락되는 경우가 있으며, 이로 인해 보행 경로 분석에서는 실제보다 긴 우회 경로가 계산될 수 있다.
따라서 국토정보플랫폼에서 제공하는 정사영상을, 데이터는 표준노드링크의 링크 데이터를 사용하여 서울특별시 내 보행 가능한 골목길을 탐지하는 과제를 진행해보기로 했다.
우선 분석 전에 서울특별시 전체의 정사영상은 약 110~120GB 정도로 예상되는 상황이었으며, 무작정 분석을 돌리기엔 학원 PC의 사양이 문제가 되었다. 또한 시간적인 문제도 매우 큰 관계로 학습 시간을 단축하기 위해 CUDA 기반 GPU 연산을 검토하였다. 그러나 당시 사용한 학원 PC에는 CUDA를 지원하는 NVIDIA GPU가 설치되어 있지 않아 CPU만으로 학습을 진행해야 하는 상황이었다.
또한 영상 패치를 여러 연산 장치에 분산하는 과정에서 도로의 경계와 연속성이 충분히 유지되는지 우려되어 강사님께 해당 방법에 대한 문의를 진행하였다.
이 과정에서 여러 대의 PC를 활용하여 서울시 자치구별 학습을 분담하는 방안도 검토하였다. 우선 1개 구 분량의 데이터로 학습한 U-Net 모델을 기준 모델로 저장한 뒤, 동일한 모델을 각 PC에 배포하고 서로 다른 자치구의 정사영상으로 추가 학습하는 방식이다. 이후 자치구별 모델을 통합하고 서울시 전체 영상으로 다시 미세조정하는 방안을 구상하였다.
다만 서로 다른 데이터로 독립적으로 학습된 신경망의 가중치를 단순 평균하는 방식은 항상 안정적인 성능을 보장하지 않는다. 따라서 실제 적용 시에는 자치구별 학습 데이터를 순차적으로 사용하여 하나의 모델을 추가 학습하거나, 전체 데이터를 통합한 뒤 동일한 모델을 미세조정하는 방식과 비교 검증할 필요가 있다고 판단하였다.
따라서 베이스 모델을 위 종로구 정사영상을 이용한 최초 학습은 약 9시간이 소요될 것으로 예상되었다. 그러나 실행 중 Zoom과 QGIS 등 CPU와 메모리를 사용하는 프로그램이 함께 실행되고 있음을 확인하였다. 불필요한 프로그램을 종료한 후 전체 학습에는 약 7시간이 소요되었다.

▲ 표준노드링크의 링크 shp

▲ 표준링크를 이용한 학습/추출 결과
본 과제의 목표는 기존 표준노드링크에 포함되지 않은 생활도로와 골목길까지 정사영상으로부터 자동 추출하는 것이었다. 그러나 표준노드링크는 도로 외곽선(Line) 형태의 벡터 데이터이므로, U-Net과 같은 의미론적 분할 모델에서 요구하는 픽셀 단위의 도로 라벨로 직접 활용하기에는 한계가 있었다.
이에 따라 표준노드링크를 일정 폭의 Polygon으로 변환하여 도로 영역을 생성하고, Polygon 내부의 픽셀을 도로 클래스로 학습시키는 방식을 적용하였다.
이를 통해 모델이 단순히 링크의 위치를 기억하는 것이 아니라, 정사영상에서 나타나는 도로의 시각적 특성을 학습하여 표준링크에 존재하지 않는 미세도로까지 일반화할 수 있는지를 확인하고자 하였다.
초기 모델의 탐지 결과를 확인한 결과 표준노드링크가 구축된 간선도로와 광폭도로는 비교적 안정적으로 분할되었다. 도로 폭이 넓고 포장면이 연속적으로 나타나는 구간에서는 예측 경계도 실제 도로 형태와 대체로 유사하게 형성되었다.
반면, 주거지 내부의 골목길이나 건물 사이의 협소도로는 상당 부분 누락되었다. 이러한 구간은 도로 폭이 좁고 건물 그림자, 주차 차량, 보도 및 인접 지붕과의 경계가 복잡하여 영상만으로 도로의 시각적 특징을 구분하기 어려웠다. 또한 학습 라벨로 사용한 표준노드링크가 주로 주요 도로 중심으로 구축되어 있기에 미세도로에 대한 학습 정보가 충분하지 않았던 점도 원인으로 판단하였다.
즉, 초기 모델은 주요 도로를 탐지하는 데에는 일정 수준의 성능을 보였으나, 보행 네트워크 보완에 필요한 골목길과 생활도로까지 추출하기에는 한계가 있었다. 이에 따라 이후 단계에서는 미세도로를 보다 강하게 반영할 수 있는 추가 학습 라벨을 구축하고 모델을 미세조정할 필요가 있다고 판단하였다.
미세도로에 대한 학습 정보를 보완하기 위해, 다음 단계에서는 세분류 토지피복지도의 도로 클래스를 추가 학습 라벨로 활용하였다. 기존 표준노드링크 기반 학습에서 가장 우수한 성능을 보인 모델을 초기 모델로 불러온 뒤, 세분류 토지피복지도에서 도로로 분류된 영역을 정답 라벨로 사용하여 Fine-tuning을 수행하고자 하였다.
세분류 토지피복지도는 표준노드링크에 비해 골목길과 생활도로가 더 세밀하게 표현되어 있다는 장점이 있었다. 따라서 기존 모델이 충분히 학습하지 못했던 협소도로의 특징을 보완할 수 있을 것으로 기대하였다.
학습결과 (펼치기)
==============================================================================
세분류 토지피복도 도로 기반 U-Net 미세조정 시작
토지피복도 폴더: C:\Users\itwill\Desktop\세분류
도로 조건: L3_CODE=154
기존 모델: D:\Final\02_Output\Jongro_Ortho_Road_Segmentation_v3\04_road_unet.keras
출력 폴더: D:\Final\02_Output\Jongro_Landcover_Road_Finetune
==============================================================================
▶ 정사영상 모자이크 준비 시작 | RAM 1.19GB / 시스템 47%
기존 정사영상 모자이크 재사용: D:\Final\02_Output\Jongro_Ortho_Road_Segmentation_v3\01_jongro_orthomosaic_5179_050cm.tif
✓ 정사영상 모자이크 준비 완료: 0.0초 (0.00분) | RAM 1.19GB / 시스템 47%
PROJ DATA: C:\Users\itwill\anaconda3\Lib\site-packages\rasterio\proj_data
TensorFlow GPU: []
Mixed precision 비활성화
모자이크 CRS: EPSG:5179
모자이크 크기: 17,827 × 22,464
해상도: 0.500m × 0.500m
▶ 교차 도엽 도로 추출 시작 | RAM 1.19GB / 시스템 47%
세분류 SHP 전체 수: 139
정사영상과 교차하는 도엽 선별: 0%| | 0/139 [00:00<?, ?file/s]
정사영상과 교차하는 SHP: 37개
- 37608038.shp
- 37608039.shp
- 37608040.shp
- 37608047.shp
- 37608048.shp
- 37608049.shp
- 37608050.shp
- 37608057.shp
- 37608058.shp
- 37608059.shp
- 37608060.shp
- 37608067.shp
- 37608068.shp
- 37608069.shp
- 37608070.shp
- 37608077.shp
- 37608078.shp
- 37608079.shp
- 37608080.shp
- 37608087.shp
- 37608088.shp
- 37608089.shp
- 37608090.shp
- 37705031.shp
- 37705032.shp
- 37705041.shp
- 37705042.shp
- 37705051.shp
- 37705052.shp
- 37705061.shp
- 37705062.shp
- 37705071.shp
- 37705072.shp
- 37705081.shp
- 37705082.shp
- 37608039.shp
- 37608049.shp
L3_CODE=154 도로 추출: 0%| | 0/37 [00:00<?, ?file/s]
추출된 도로 폴리곤 수: 6,546
도로 폴리곤 총면적: 27,134,246.3㎡
클립 도로 폴리곤 저장: D:\Final\02_Output\Jongro_Landcover_Road_Finetune\02_landcover_road_polygons_clipped.gpkg
✓ 교차 도엽 도로 추출 완료: 57.4초 (0.96분) | RAM 1.26GB / 시스템 47%
▶ 토지피복도 도로 마스크 rasterize 시작 | RAM 1.26GB / 시스템 47%
토지피복도 도로 rasterize: 0%| | 0/6546 [00:00<?, ?geom/s]
✓ 토지피복도 도로 마스크 rasterize 완료: 39.2초 (0.65분) | RAM 1.66GB / 시스템 51%
학습 마스크 도로 픽셀: 117,473,400 (29.3342%)
▶ 밴드 정규화 통계 시작 | RAM 2.41GB / 시스템 56%
밴드별 2~98 percentile: [(0.0, 181.0), (0.0, 190.0), (0.0, 176.0)]
✓ 밴드 정규화 통계 완료: 9.6초 (0.16분) | RAM 2.81GB / 시스템 58%
▶ 학습·검증 패치 구성 시작 | RAM 2.81GB / 시스템 58%
학습 패치 좌표 검사: 0%| | 0/24325 [00:00<?, ?patch/s]
양성 패치: 12,069
배경 패치 사용: 3,017
학습 좌표: 12,021
검증 좌표: 3,065
✓ 학습·검증 패치 구성 완료: 39.0초 (0.65분) | RAM 0.03GB / 시스템 46%
▶ 토지피복도 라벨 미세조정 학습 시작 | RAM 0.03GB / 시스템 46%
Epoch 1/8
3006/3006 ━━━━━━━━━━━━━━━━━━━━ 0s 521ms/step - accuracy: 0.7699 - dice_coefficient: 0.5082 - loss: 1.4282 - pr_auc: 0.6696 - precision: 0.6759 - recall: 0.4918 - roc_auc: 0.8087
Epoch 1: val_dice_coefficient improved from None to 0.58338, saving model to D:\Final\02_Output\Jongro_Landcover_Road_Finetune\04_road_unet_landcover_finetuned.keras
Epoch 1: finished saving model to D:\Final\02_Output\Jongro_Landcover_Road_Finetune\04_road_unet_landcover_finetuned.keras
3006/3006 ━━━━━━━━━━━━━━━━━━━━ 1687s 553ms/step - accuracy: 0.7699 - dice_coefficient: 0.5082 - loss: 1.4282 - pr_auc: 0.6696 - precision: 0.6759 - recall: 0.4918 - roc_auc: 0.8087 - val_accuracy: 0.7890 - val_dice_coefficient: 0.5834 - val_loss: 0.9397 - val_pr_auc: 0.7603 - val_precision: 0.7331 - val_recall: 0.5496 - val_roc_auc: 0.8708 - learning_rate: 1.0000e-05
Epoch 2/8
3006/3006 ━━━━━━━━━━━━━━━━━━━━ 0s 453ms/step - accuracy: 0.7801 - dice_coefficient: 0.5328 - loss: 1.2390 - pr_auc: 0.6980 - precision: 0.6768 - recall: 0.5533 - roc_auc: 0.8340
Epoch 2: val_dice_coefficient did not improve from 0.58338
3006/3006 ━━━━━━━━━━━━━━━━━━━━ 1452s 483ms/step - accuracy: 0.7801 - dice_coefficient: 0.5328 - loss: 1.2390 - pr_auc: 0.6980 - precision: 0.6768 - recall: 0.5533 - roc_auc: 0.8340 - val_accuracy: 0.7903 - val_dice_coefficient: 0.5768 - val_loss: 0.9555 - val_pr_auc: 0.7686 - val_precision: 0.7598 - val_recall: 0.5171 - val_roc_auc: 0.8743 - learning_rate: 1.0000e-05
Epoch 3/8
3006/3006 ━━━━━━━━━━━━━━━━━━━━ 0s 455ms/step - accuracy: 0.7835 - dice_coefficient: 0.5449 - loss: 1.1693 - pr_auc: 0.7066 - precision: 0.6743 - recall: 0.5806 - roc_auc: 0.8419
Epoch 3: val_dice_coefficient did not improve from 0.58338
3006/3006 ━━━━━━━━━━━━━━━━━━━━ 1463s 487ms/step - accuracy: 0.7835 - dice_coefficient: 0.5449 - loss: 1.1693 - pr_auc: 0.7066 - precision: 0.6743 - recall: 0.5806 - roc_auc: 0.8419 - val_accuracy: 0.7897 - val_dice_coefficient: 0.5722 - val_loss: 0.9127 - val_pr_auc: 0.7730 - val_precision: 0.7726 - val_recall: 0.4981 - val_roc_auc: 0.8788 - learning_rate: 1.0000e-05
Epoch 4/8
3006/3006 ━━━━━━━━━━━━━━━━━━━━ 0s 456ms/step - accuracy: 0.7862 - dice_coefficient: 0.5508 - loss: 1.1163 - pr_auc: 0.7161 - precision: 0.6705 - recall: 0.6071 - roc_auc: 0.8488
Epoch 4: val_dice_coefficient did not improve from 0.58338
3006/3006 ━━━━━━━━━━━━━━━━━━━━ 1466s 488ms/step - accuracy: 0.7862 - dice_coefficient: 0.5508 - loss: 1.1163 - pr_auc: 0.7161 - precision: 0.6705 - recall: 0.6071 - roc_auc: 0.8488 - val_accuracy: 0.7941 - val_dice_coefficient: 0.5828 - val_loss: 0.8998 - val_pr_auc: 0.7763 - val_precision: 0.7618 - val_recall: 0.5312 - val_roc_auc: 0.8800 - learning_rate: 1.0000e-05
Epoch 5/8
3006/3006 ━━━━━━━━━━━━━━━━━━━━ 0s 448ms/step - accuracy: 0.7877 - dice_coefficient: 0.5562 - loss: 1.0712 - pr_auc: 0.7229 - precision: 0.6658 - recall: 0.6298 - roc_auc: 0.8537
Epoch 5: val_dice_coefficient improved from 0.58338 to 0.58913, saving model to D:\Final\02_Output\Jongro_Landcover_Road_Finetune\04_road_unet_landcover_finetuned.keras
Epoch 5: finished saving model to D:\Final\02_Output\Jongro_Landcover_Road_Finetune\04_road_unet_landcover_finetuned.keras
3006/3006 ━━━━━━━━━━━━━━━━━━━━ 1439s 479ms/step - accuracy: 0.7877 - dice_coefficient: 0.5562 - loss: 1.0712 - pr_auc: 0.7229 - precision: 0.6658 - recall: 0.6298 - roc_auc: 0.8537 - val_accuracy: 0.7952 - val_dice_coefficient: 0.5891 - val_loss: 0.8726 - val_pr_auc: 0.7779 - val_precision: 0.7566 - val_recall: 0.5434 - val_roc_auc: 0.8817 - learning_rate: 1.0000e-05
Epoch 6/8
3006/3006 ━━━━━━━━━━━━━━━━━━━━ 0s 447ms/step - accuracy: 0.7894 - dice_coefficient: 0.5641 - loss: 1.0364 - pr_auc: 0.7293 - precision: 0.6631 - recall: 0.6487 - roc_auc: 0.8578
Epoch 6: val_dice_coefficient improved from 0.58913 to 0.59639, saving model to D:\Final\02_Output\Jongro_Landcover_Road_Finetune\04_road_unet_landcover_finetuned.keras
Epoch 6: finished saving model to D:\Final\02_Output\Jongro_Landcover_Road_Finetune\04_road_unet_landcover_finetuned.keras
3006/3006 ━━━━━━━━━━━━━━━━━━━━ 1436s 478ms/step - accuracy: 0.7894 - dice_coefficient: 0.5641 - loss: 1.0364 - pr_auc: 0.7293 - precision: 0.6631 - recall: 0.6487 - roc_auc: 0.8578 - val_accuracy: 0.7982 - val_dice_coefficient: 0.5964 - val_loss: 0.8594 - val_pr_auc: 0.7822 - val_precision: 0.7525 - val_recall: 0.5631 - val_roc_auc: 0.8833 - learning_rate: 1.0000e-05
Epoch 7/8
3006/3006 ━━━━━━━━━━━━━━━━━━━━ 0s 446ms/step - accuracy: 0.7905 - dice_coefficient: 0.5653 - loss: 1.0219 - pr_auc: 0.7327 - precision: 0.6613 - recall: 0.6611 - roc_auc: 0.8603
Epoch 7: val_dice_coefficient did not improve from 0.59639
3006/3006 ━━━━━━━━━━━━━━━━━━━━ 1431s 476ms/step - accuracy: 0.7905 - dice_coefficient: 0.5653 - loss: 1.0219 - pr_auc: 0.7327 - precision: 0.6613 - recall: 0.6611 - roc_auc: 0.8603 - val_accuracy: 0.7947 - val_dice_coefficient: 0.5857 - val_loss: 0.8802 - val_pr_auc: 0.7808 - val_precision: 0.7716 - val_recall: 0.5211 - val_roc_auc: 0.8828 - learning_rate: 1.0000e-05
Epoch 8/8
3006/3006 ━━━━━━━━━━━━━━━━━━━━ 0s 447ms/step - accuracy: 0.7910 - dice_coefficient: 0.5638 - loss: 1.0079 - pr_auc: 0.7342 - precision: 0.6598 - recall: 0.6697 - roc_auc: 0.8620
Epoch 8: val_dice_coefficient improved from 0.59639 to 0.60129, saving model to D:\Final\02_Output\Jongro_Landcover_Road_Finetune\04_road_unet_landcover_finetuned.keras
Epoch 8: finished saving model to D:\Final\02_Output\Jongro_Landcover_Road_Finetune\04_road_unet_landcover_finetuned.keras
3006/3006 ━━━━━━━━━━━━━━━━━━━━ 1433s 477ms/step - accuracy: 0.7910 - dice_coefficient: 0.5638 - loss: 1.0079 - pr_auc: 0.7342 - precision: 0.6598 - recall: 0.6697 - roc_auc: 0.8620 - val_accuracy: 0.8001 - val_dice_coefficient: 0.6013 - val_loss: 0.8429 - val_pr_auc: 0.7836 - val_precision: 0.7442 - val_recall: 0.5844 - val_roc_auc: 0.8844 - learning_rate: 1.0000e-05
Restoring model weights from the end of the best epoch: 8.
✓ 토지피복도 라벨 미세조정 학습 완료: 11811.1초 (196.85분) | RAM 1.41GB / 시스템 54%
▶ 미세조정 모델 전체 영상 추론 시작 | RAM 1.41GB / 시스템 54%
전체 영상 추론: 0%| | 0/6082 [00:00<?, ?batch/s]
✓ 미세조정 모델 전체 영상 추론 완료: 1170.1초 (19.50분) | RAM 3.24GB / 시스템 65%
확률 최소/평균/최대: 0.00000000 / 0.23502274 / 0.99900025
▶ 임계값 0.40 후처리 시작 | RAM 3.24GB / 시스템 65%
✓ 임계값 0.40 후처리 완료: 19.8초 (0.33분) | RAM 3.31GB / 시스템 62%
▶ 임계값 0.40 폴리곤 export 시작 | RAM 3.31GB / 시스템 62%
예측 마스크 폴리곤화: 0geom [00:00, ?geom/s]
✓ 임계값 0.40 폴리곤 export 완료: 13.0초 (0.22분) | RAM 3.93GB / 시스템 68%
threshold=0.40 | 도로 픽셀=77,780,021 | 폴리곤=3,301
▶ 임계값 0.50 후처리 시작 | RAM 3.33GB / 시스템 64%
✓ 임계값 0.50 후처리 완료: 19.1초 (0.32분) | RAM 3.55GB / 시스템 61%
▶ 임계값 0.50 폴리곤 export 시작 | RAM 3.55GB / 시스템 61%
예측 마스크 폴리곤화: 0geom [00:00, ?geom/s]
✓ 임계값 0.50 폴리곤 export 완료: 11.3초 (0.19분) | RAM 3.76GB / 시스템 62%
threshold=0.50 | 도로 픽셀=61,012,745 | 폴리곤=4,565
▶ 임계값 0.60 후처리 시작 | RAM 3.19GB / 시스템 59%
✓ 임계값 0.60 후처리 완료: 18.7초 (0.31분) | RAM 3.57GB / 시스템 61%
▶ 임계값 0.60 폴리곤 export 시작 | RAM 3.57GB / 시스템 61%
예측 마스크 폴리곤화: 0geom [00:00, ?geom/s]
✓ 임계값 0.60 폴리곤 export 완료: 11.8초 (0.20분) | RAM 3.68GB / 시스템 64%
threshold=0.60 | 도로 픽셀=48,051,276 | 폴리곤=4,895
==============================================================================
모든 처리 완료
결과 폴더: D:\Final\02_Output\Jongro_Landcover_Road_Finetune
임계값 비교표: D:\Final\02_Output\Jongro_Landcover_Road_Finetune\threshold_comparison_summary.csv
==============================================================================

▲ 세분류 토지 피복지도의 도로

▲ 세분류 토지 피복지도의 도로를 이용한 학습/탐지 결과
그러나 세분류 토지피복지도의 도로 클래스는 차량이나 보행자의 실제 이동 경로만을 구분한 데이터가 아니었다. 일부 구간에서는 도로와 연결된 광장, 주차장, 넓은 포장면 등도 함께 도로 영역으로 분류되어 있었다. 이 영역들을 그대로 정답 라벨로 사용하면 모델은 선형적인 통행로뿐 아니라 도로 주변의 넓은 포장 공간까지 모두 도로로 학습하게 된다.
세분류 토지피복지도를 이용한 Fine-tuning 이후에는 기존 모델에서 누락되던 골목길과 협소도로가 이전보다 많이 탐지되는 것을 확인할 수 있었다. 이는 세분류 토지피복지도가 표준노드링크보다 훨씬 세밀한 도로 정보를 포함하고 있었기 때문으로 판단된다.
그러나 예측 결과를 정사영상과 비교한 결과, 도로뿐 아니라 건물 주변의 넓은 포장면과 광장, 주차장 등이 함께 도로로 분류되는 현상이 확인되었다. 일부 구간에서는 건물 외곽을 따라 넓은 영역이 도로로 확장되거나, 실제 도로 폭보다 훨씬 넓게 예측되는 과대 분할이 발생하였다.
이는 세분류 토지피복지도의 도로 클래스가 실제 차량 통행로뿐 아니라 다양한 포장 공간을 함께 포함하고 있기 때문으로 판단된다. 즉, 모델은 라벨에 포함된 모든 영역을 도로의 특성으로 학습하였으며, 그 결과 미세도로 탐지 성능은 향상되었지만 도로 경계는 이전보다 거칠어지고 비도로 영역까지 함께 예측하는 경향이 나타났다.
결과적으로 세분류 토지피복지도는 미세도로에 대한 공간 정보를 보완하는 데에는 효과적이었지만, 본 연구에서 목표로 하는 '실제 통행 가능한 선형 도로'와 토지피복도에서 정의하는 '도로 및 포장면'의 개념이 서로 일치하지 않아 추가적인 라벨 정제가 필요함을 확인하였다.
이 문제를 해결하기 위해 도로 Polygon 전체를 정답으로 사용하는 대신, 통행 가능한 영역만 학습시키는 방법을 고민하기 시작하였다.
가장 먼저 떠올린 아이디어는 도로 Polygon 내부에서 중심선을 추출한 뒤, 중심선을 기준으로 약 2m 정도의 얇은 Buffer를 생성하여 새로운 학습 라벨로 사용하는 것이었다.
이렇게 하면 광장이나 넓은 포장면은 대부분 제외하면서도 실제 차량이나 보행자가 이용하는 도로 중심부만 모델에게 학습시킬 수 있을 것으로 기대하였다.
처음에는 Polygon 내부에서 중심선을 생성하는 방법으로 Voronoi 기반 중심선 추출을 검토하였다.
그러나 실제 적용 결과 중심선이 매우 잘게 끊어지고 불필요한 가지(branch)가 많이 생성되었다. 특히 복잡한 형태의 교차로나 굴곡이 있는 도로에서는 중심선이 불안정하게 생성되어 학습 라벨로 사용하기에는 적합하지 않았다.

▲ Voronio 기반 중심선 추출 결과
이후 여러 중심선 추출 방법을 비교한 결과 Skeletonization(골격화) 기법을 적용하기로 하였다.
Skeleton은 Polygon 내부를 한 픽셀 두께의 중심선으로 점진적으로 수축시키면서 전체 연결성을 최대한 유지하는 방식이다. 따라서 Voronoi보다 실제 도로 중심을 안정적으로 표현할 수 있었으며, 복잡한 도로 형태에서도 끊김이 적고 연속성이 높은 중심선을 생성할 수 있었다.
이를 기반으로 중심선 주변에 약 2m Buffer를 생성하여 새로운 학습 라벨을 구축하는 실험을 진행하였다.

▲ Skeletonization 기법을 적용한 중심선 추출 결과

▲ Voronio 기반 중심선과 Skeletonization 기반 중심선 차이 (ChatGPT 사용 그림)
해당 과정 이후 생성된 도로 라벨을 정사영상과 대조하며 수작업으로 검수하였다. 이 과정에서 수목에 가려져 토지피복도에서는 도로로 적절히 표현되지 않았으나 실제 통행이 가능한 임도와 같은 예외 사례를 확인하였으며, 반대로 세분류 토지피복지도 구축 당시에는 도로로 분류되었지만 현재는 공사, 재개발 또는 토지 이용 변화로 인해 더 이상 도로로 사용되지 않는 구간은 학습 라벨에서 제외하였다.
해당 코드 (펼치기) ▼
# -*- coding: utf-8 -*-
"""
세분류 토지피복 도로 폴리곤 → 타일 기반 중심선 추출
======================================================================
핵심 흐름
도로 폴리곤
→ 타일별 조회
→ 2m 래스터화
→ 형태학적 잡음 정리
→ skeletonize
→ 중심선 벡터화
→ 짧은 가지 제거
→ 타일 경계 중복 제거
→ GPKG 저장
특징
- Voronoi 방식 사용 안 함
- 서울 전체 래스터를 한 번에 만들지 않음
- 타일 겹침 영역을 사용하여 타일 경계 단절 최소화
- Jupyter와 .py 실행 모두 가능
- tqdm 진행률과 예상 잔여 시간 표시
필수 패키지
pip install -U geopandas pyogrio rasterio shapely scikit-image scipy tqdm
권장
shapely >= 2.0
geopandas >= 0.14
rasterio >= 1.3
scikit-image >= 0.22
"""
from __future__ import annotations
import math
import os
import time
import warnings
from pathlib import Path
from typing import Iterable
import geopandas as gpd
import numpy as np
import pandas as pd
from affine import Affine
from rasterio.features import rasterize
from shapely import make_valid
from shapely.geometry import (
LineString,
MultiLineString,
Polygon,
box,
)
from shapely.ops import linemerge, unary_union
from skimage.morphology import (
binary_closing,
binary_opening,
disk,
remove_small_holes,
remove_small_objects,
skeletonize,
)
from tqdm import tqdm
warnings.filterwarnings("ignore", category=UserWarning)
# =============================================================================
# 1. 사용자 설정
# =============================================================================
INPUT_FILE = Path(
r"C:\Users\itwill\Desktop\세분류\세분류_도로_154_통합.shp"
)
OUTPUT_GPKG = Path(
r"C:\Users\itwill\Desktop\세분류\road_centerline_tile_2m_preview.gpkg"
)
TARGET_CRS = "EPSG:5179"
# -------------------------------------------------------------------------
# 1차 미리보기 권장값
# -------------------------------------------------------------------------
# 픽셀 크기(m)
RESOLUTION_M = 2.0
# 한 타일의 본체 크기(픽셀)
# 2048 × 2m = 약 4.096km
TILE_SIZE_PIXELS = 2048
# 타일 외곽에 추가로 읽는 중첩 폭(픽셀)
# 중심선은 마지막에 본체 영역으로 잘라서 중복을 제거함
TILE_OVERLAP_PIXELS = 64
# 도로 면의 작은 틈을 메우는 closing 반경(픽셀)
# 1픽셀 = 2m이므로 1이면 약 2m
CLOSING_RADIUS_PIXELS = 1
# 고립된 작은 돌기 제거용 opening 반경(픽셀)
# 너무 크게 하면 좁은 도로가 사라질 수 있으므로 기본 0
OPENING_RADIUS_PIXELS = 0
# 작은 도로 조각 제거 면적(㎡)
MIN_ROAD_COMPONENT_AREA_M2 = 40.0
# 작은 내부 구멍을 메우는 최대 면적(㎡)
MAX_HOLE_AREA_M2 = 36.0
# 중심선의 짧은 가지 제거 기준(m)
MIN_BRANCH_LENGTH_M = 20.0
# 최종 선 단순화 허용오차(m)
SIMPLIFY_TOLERANCE_M = 1.5
# 입력 폴리곤 최소 면적(㎡)
MIN_INPUT_POLYGON_AREA_M2 = 10.0
# 래스터화 시 픽셀 중심만 포함할지 여부
# False가 도로 폭 과대 추출을 줄이는 데 유리
ALL_TOUCHED = False
# 최종 전체 선 병합 여부
# True면 타일 경계의 중복·분절을 정리하지만 마지막 단계가 조금 오래 걸릴 수 있음
GLOBAL_MERGE = True
# 디버그용 타일 경계 레이어 저장
SAVE_TILE_GRID = False
# 오류 CSV 저장
SAVE_ERROR_CSV = True
# =============================================================================
# 2. 입력 전처리
# =============================================================================
def load_road_polygons() -> gpd.GeoDataFrame:
if not INPUT_FILE.exists():
raise FileNotFoundError(f"입력 파일이 없습니다:\n{INPUT_FILE}")
print(f"입력 읽는 중: {INPUT_FILE}")
roads = gpd.read_file(INPUT_FILE, engine="pyogrio")
if roads.empty:
raise ValueError("입력 파일에 객체가 없습니다.")
if roads.crs is None:
raise ValueError(
"입력 데이터에 좌표계가 없습니다. "
"QGIS에서 올바른 좌표계를 먼저 지정하세요."
)
roads = roads.to_crs(TARGET_CRS)
roads = roads[
roads.geometry.notna()
& ~roads.geometry.is_empty
].copy()
roads["geometry"] = roads.geometry.apply(make_valid)
roads = roads.explode(
index_parts=False,
ignore_index=True,
)
roads = roads[
roads.geometry.geom_type.isin(["Polygon", "MultiPolygon"])
].copy()
roads = roads[
roads.geometry.area >= MIN_INPUT_POLYGON_AREA_M2
].copy()
roads = roads.reset_index(drop=True)
if roads.empty:
raise ValueError("유효한 도로 폴리곤이 없습니다.")
# 공간 인덱스 미리 생성
_ = roads.sindex
print(f"처리 대상 폴리곤: {len(roads):,}개")
print(f"총 도로 면적: {roads.geometry.area.sum():,.1f}㎡")
return roads
# =============================================================================
# 3. 타일 생성
# =============================================================================
def align_down(value: float, step: float) -> float:
return math.floor(value / step) * step
def align_up(value: float, step: float) -> float:
return math.ceil(value / step) * step
def build_tile_grid(
roads: gpd.GeoDataFrame,
) -> list[dict]:
minx, miny, maxx, maxy = roads.total_bounds
core_size_m = TILE_SIZE_PIXELS * RESOLUTION_M
overlap_m = TILE_OVERLAP_PIXELS * RESOLUTION_M
grid_minx = align_down(minx, core_size_m)
grid_miny = align_down(miny, core_size_m)
grid_maxx = align_up(maxx, core_size_m)
grid_maxy = align_up(maxy, core_size_m)
n_cols = int(round((grid_maxx - grid_minx) / core_size_m))
n_rows = int(round((grid_maxy - grid_miny) / core_size_m))
tiles = []
tile_id = 0
for row in range(n_rows):
for col in range(n_cols):
core_minx = grid_minx + col * core_size_m
core_miny = grid_miny + row * core_size_m
core_maxx = core_minx + core_size_m
core_maxy = core_miny + core_size_m
core_geom = box(
core_minx,
core_miny,
core_maxx,
core_maxy,
)
# 도로가 전혀 없는 타일은 제외
candidate_idx = list(
roads.sindex.query(
core_geom,
predicate="intersects",
)
)
if not candidate_idx:
continue
tile_id += 1
tiles.append(
{
"tile_id": tile_id,
"row": row,
"col": col,
"core_bounds": (
core_minx,
core_miny,
core_maxx,
core_maxy,
),
"expanded_bounds": (
core_minx - overlap_m,
core_miny - overlap_m,
core_maxx + overlap_m,
core_maxy + overlap_m,
),
"core_geometry": core_geom,
}
)
print(f"도로가 포함된 처리 타일: {len(tiles):,}개")
return tiles
# =============================================================================
# 4. 타일 래스터화 및 형태학적 정리
# =============================================================================
def rasterize_tile(
roads: gpd.GeoDataFrame,
expanded_bounds: tuple[float, float, float, float],
) -> tuple[np.ndarray, Affine]:
minx, miny, maxx, maxy = expanded_bounds
width = int(round((maxx - minx) / RESOLUTION_M))
height = int(round((maxy - miny) / RESOLUTION_M))
transform = Affine(
RESOLUTION_M,
0.0,
minx,
0.0,
-RESOLUTION_M,
maxy,
)
tile_geom = box(minx, miny, maxx, maxy)
candidate_idx = list(
roads.sindex.query(
tile_geom,
predicate="intersects",
)
)
if not candidate_idx:
return np.zeros((height, width), dtype=bool), transform
clipped_geometries = []
for geom in roads.geometry.iloc[candidate_idx]:
clipped = geom.intersection(tile_geom)
if clipped.is_empty:
continue
if clipped.geom_type == "Polygon":
clipped_geometries.append(clipped)
elif clipped.geom_type == "MultiPolygon":
clipped_geometries.extend(
part
for part in clipped.geoms
if not part.is_empty
)
if not clipped_geometries:
return np.zeros((height, width), dtype=bool), transform
mask = rasterize(
(
(geom, 1)
for geom in clipped_geometries
),
out_shape=(height, width),
transform=transform,
fill=0,
dtype="uint8",
all_touched=ALL_TOUCHED,
).astype(bool)
min_component_pixels = max(
1,
int(
round(
MIN_ROAD_COMPONENT_AREA_M2
/ (RESOLUTION_M ** 2)
)
),
)
max_hole_pixels = max(
1,
int(
round(
MAX_HOLE_AREA_M2
/ (RESOLUTION_M ** 2)
)
),
)
mask = remove_small_objects(
mask,
min_size=min_component_pixels,
connectivity=2,
)
mask = remove_small_holes(
mask,
area_threshold=max_hole_pixels,
connectivity=2,
)
if CLOSING_RADIUS_PIXELS > 0:
mask = binary_closing(
mask,
footprint=disk(CLOSING_RADIUS_PIXELS),
)
if OPENING_RADIUS_PIXELS > 0:
mask = binary_opening(
mask,
footprint=disk(OPENING_RADIUS_PIXELS),
)
return mask, transform
# =============================================================================
# 5. Skeleton 픽셀 → 벡터 선
# =============================================================================
def pixel_center(
row: int,
col: int,
transform: Affine,
) -> tuple[float, float]:
x = transform.c + (col + 0.5) * transform.a
y = transform.f + (row + 0.5) * transform.e
return float(x), float(y)
def skeleton_to_segments(
skeleton: np.ndarray,
transform: Affine,
) -> list[LineString]:
"""
8방향 연결에서 중복 없이 다음 4방향만 확인:
오른쪽, 아래왼쪽, 아래, 아래오른쪽
"""
rows, cols = np.nonzero(skeleton)
if len(rows) == 0:
return []
foreground = set(zip(rows.tolist(), cols.tolist()))
forward_neighbors = (
(0, 1),
(1, -1),
(1, 0),
(1, 1),
)
segments = []
for row, col in foreground:
start_xy = pixel_center(row, col, transform)
for drow, dcol in forward_neighbors:
nrow = row + drow
ncol = col + dcol
if (nrow, ncol) not in foreground:
continue
end_xy = pixel_center(nrow, ncol, transform)
segments.append(
LineString([start_xy, end_xy])
)
return segments
def flatten_lines(geometry) -> list[LineString]:
if geometry is None or geometry.is_empty:
return []
if isinstance(geometry, LineString):
return [geometry]
if isinstance(geometry, MultiLineString):
return list(geometry.geoms)
if hasattr(geometry, "geoms"):
lines = []
for part in geometry.geoms:
lines.extend(flatten_lines(part))
return lines
return []
def merge_and_filter_lines(
segments: list[LineString],
clip_geometry: Polygon,
) -> list[LineString]:
if not segments:
return []
# 교차점에서 선을 분할한 뒤 연결 가능한 부분 병합
noded = unary_union(segments)
merged = linemerge(noded)
candidate_lines = flatten_lines(merged)
final_lines = []
for line in candidate_lines:
if line.is_empty:
continue
clipped = line.intersection(clip_geometry)
for part in flatten_lines(clipped):
if part.length < MIN_BRANCH_LENGTH_M:
continue
if SIMPLIFY_TOLERANCE_M > 0:
part = part.simplify(
SIMPLIFY_TOLERANCE_M,
preserve_topology=False,
)
if (
not part.is_empty
and part.length >= MIN_BRANCH_LENGTH_M
):
final_lines.append(part)
return final_lines
# =============================================================================
# 6. 타일 처리
# =============================================================================
def process_one_tile(
roads: gpd.GeoDataFrame,
tile: dict,
) -> tuple[list[dict], dict | None]:
tile_id = tile["tile_id"]
try:
mask, transform = rasterize_tile(
roads,
tile["expanded_bounds"],
)
if not mask.any():
return [], None
skeleton = skeletonize(
mask,
method="zhang",
)
segments = skeleton_to_segments(
skeleton,
transform,
)
lines = merge_and_filter_lines(
segments,
tile["core_geometry"],
)
records = []
for part_id, line in enumerate(lines, start=1):
records.append(
{
"tile_id": tile_id,
"part_id": part_id,
"length_m": round(float(line.length), 2),
"geometry": line,
}
)
return records, None
except Exception as exc:
return [], {
"tile_id": tile_id,
"row": tile["row"],
"col": tile["col"],
"error": repr(exc),
}
# =============================================================================
# 7. 전체 실행
# =============================================================================
def run_centerline_extraction(
roads: gpd.GeoDataFrame,
tiles: list[dict],
) -> tuple[gpd.GeoDataFrame, pd.DataFrame]:
records = []
errors = []
print()
print("=" * 72)
print("타일 기반 중심선 추출 시작")
print(f"해상도: {RESOLUTION_M}m")
print(
f"타일 본체: {TILE_SIZE_PIXELS}px "
f"({TILE_SIZE_PIXELS * RESOLUTION_M:,.0f}m)"
)
print(
f"타일 중첩: {TILE_OVERLAP_PIXELS}px "
f"({TILE_OVERLAP_PIXELS * RESOLUTION_M:,.0f}m)"
)
print(f"짧은 가지 제거: {MIN_BRANCH_LENGTH_M}m 미만")
print("=" * 72)
start = time.perf_counter()
for tile in tqdm(
tiles,
total=len(tiles),
desc="타일 중심선 추출",
unit="tile",
dynamic_ncols=True,
):
tile_records, error = process_one_tile(
roads,
tile,
)
records.extend(tile_records)
if error is not None:
errors.append(error)
elapsed = time.perf_counter() - start
print()
print(f"타일 처리 시간: {elapsed / 60:,.1f}분")
if not records:
raise RuntimeError(
"생성된 중심선이 없습니다. "
"입력 좌표계와 설정값을 확인하세요."
)
lines = gpd.GeoDataFrame(
records,
crs=roads.crs,
)
error_df = pd.DataFrame(errors)
print(f"타일별 중심선 조각: {len(lines):,}개")
print(f"오류 타일: {len(error_df):,}개")
return lines, error_df
# =============================================================================
# 8. 전체 병합 및 최종 정리
# =============================================================================
def global_merge_centerlines(
tile_lines: gpd.GeoDataFrame,
) -> gpd.GeoDataFrame:
if not GLOBAL_MERGE:
result = tile_lines.copy()
result["centerline_id"] = np.arange(
1,
len(result) + 1,
dtype=np.int64,
)
return result[
[
"centerline_id",
"tile_id",
"length_m",
"geometry",
]
]
print()
print("타일 경계 중복 및 분절 정리 중...")
merged_geometry = linemerge(
unary_union(
tile_lines.geometry.tolist()
)
)
parts = flatten_lines(merged_geometry)
records = []
for line in parts:
if line.is_empty:
continue
if line.length < MIN_BRANCH_LENGTH_M:
continue
if SIMPLIFY_TOLERANCE_M > 0:
line = line.simplify(
SIMPLIFY_TOLERANCE_M,
preserve_topology=False,
)
if line.length < MIN_BRANCH_LENGTH_M:
continue
records.append(
{
"length_m": round(float(line.length), 2),
"geometry": line,
}
)
result = gpd.GeoDataFrame(
records,
crs=tile_lines.crs,
)
result["centerline_id"] = np.arange(
1,
len(result) + 1,
dtype=np.int64,
)
return result[
[
"centerline_id",
"length_m",
"geometry",
]
]
# =============================================================================
# 9. 저장
# =============================================================================
def save_results(
roads: gpd.GeoDataFrame,
tiles: list[dict],
centerlines: gpd.GeoDataFrame,
errors: pd.DataFrame,
) -> None:
OUTPUT_GPKG.parent.mkdir(
parents=True,
exist_ok=True,
)
if OUTPUT_GPKG.exists():
OUTPUT_GPKG.unlink()
print()
print("GPKG 저장 중...")
centerlines.to_file(
OUTPUT_GPKG,
layer="road_centerline_2m",
driver="GPKG",
engine="pyogrio",
)
if SAVE_TILE_GRID:
tile_grid = gpd.GeoDataFrame(
[
{
"tile_id": tile["tile_id"],
"row": tile["row"],
"col": tile["col"],
"geometry": tile["core_geometry"],
}
for tile in tiles
],
crs=roads.crs,
)
tile_grid.to_file(
OUTPUT_GPKG,
layer="processing_tiles",
driver="GPKG",
engine="pyogrio",
)
if SAVE_ERROR_CSV and not errors.empty:
error_path = OUTPUT_GPKG.with_name(
OUTPUT_GPKG.stem + "_errors.csv"
)
errors.to_csv(
error_path,
index=False,
encoding="utf-8-sig",
)
print(f"오류 CSV: {error_path}")
print(f"결과 파일: {OUTPUT_GPKG}")
print("결과 레이어: road_centerline_2m")
# =============================================================================
# 10. 메인
# =============================================================================
def main() -> None:
print("=" * 72)
print("도로 폴리곤 타일 기반 2m 중심선 추출")
print("=" * 72)
roads = load_road_polygons()
tiles = build_tile_grid(roads)
tile_lines, errors = run_centerline_extraction(
roads,
tiles,
)
centerlines = global_merge_centerlines(
tile_lines,
)
print()
print(f"최종 중심선 개수: {len(centerlines):,}개")
print(
f"최종 중심선 총길이: "
f"{centerlines.geometry.length.sum() / 1000:,.2f}km"
)
save_results(
roads,
tiles,
centerlines,
errors,
)
print()
print("전체 작업 완료")
if __name__ == "__main__":
main()
결과 (펼치기) ▼
PROJ DB 선택: C:\Users\itwill\anaconda3\Lib\site-packages\rasterio\proj_data\proj.db
PROJ DB minor: 6
==============================================================================
2개 정사영상 + 2m 중심선 약한 라벨 파인튜닝
==============================================================================
PROJ DATA: C:\Users\itwill\anaconda3\Lib\site-packages\rasterio\proj_data
TensorFlow GPU: []
기존 모델: D:\Final\02_Output\Jongro_Ortho_Road_Segmentation_v3\04_road_unet.keras
약한 라벨 중심선: D:\Final\03_sebunryu\2m_centerline.gpkg
출력 폴더: D:\Final\02_Output\Jongro_WeakLabel_Finetune_2Tiles
▶ 정사영상 2장 모자이크 시작 | RAM 1.52GB / 시스템 69%
기존 모자이크 재사용: D:\Final\02_Output\Jongro_WeakLabel_Finetune_2Tiles\01_two_tile_orthomosaic_5179_050cm.tif
✓ 정사영상 2장 모자이크 완료: 0.0초 (0.00분) | RAM 1.52GB / 시스템 69%
모자이크 CRS: EPSG:5179
모자이크 크기: 9,063 × 11,274
▶ 중심선 2m 버퍼 약한 라벨 생성 시작 | RAM 1.52GB / 시스템 69%
자동 선택 중심선 레이어: road_centerline_tile_2m_preview__road_centerline_2m
영상 범위 중심선: 11,876개
중심선 총길이: 466.23km
약한 라벨 rasterize 입력: 0%| | 0/11876 [00:00<?, ?geom/s]
약한 라벨 양성 픽셀 비율: 0.0854
✓ 중심선 2m 버퍼 약한 라벨 생성 완료: 2.9초 (0.05분) | RAM 1.62GB / 시스템 70%
▶ 밴드 정규화 통계 시작 | RAM 1.72GB / 시스템 70%
밴드별 2~98 percentile: [(0.0, 182.0), (0.0, 188.0), (0.0, 172.0)]
✓ 밴드 정규화 통계 완료: 0.0초 (0.00분) | RAM 1.72GB / 시스템 70%
▶ 학습·검증 패치 구성 시작 | RAM 1.72GB / 시스템 70%
파인튜닝 패치 검사: 0%| | 0/6160 [00:00<?, ?patch/s]
양성 패치: 2,819
배경 패치 사용: 296
학습 패치: 2,648
검증 패치: 467
✓ 학습·검증 패치 구성 완료: 6.3초 (0.11분) | RAM 1.69GB / 시스템 69%
▶ 기존 베스트 모델 약한 라벨 파인튜닝 시작 | RAM 1.69GB / 시스템 69%
기존 모델 입력 형태: (None, 256, 256, 3)
Epoch 1/6
662/662 ━━━━━━━━━━━━━━━━━━━━ 0s 457ms/step - accuracy: 0.8923 - dice_coefficient: 0.1967 - loss: 1.3812 - pr_auc: 0.1458 - precision: 0.1998 - recall: 0.0670 - roc_auc: 0.6362
Epoch 1: val_dice_coefficient improved from None to 0.18133, saving model to D:\Final\02_Output\Jongro_WeakLabel_Finetune_2Tiles\03_weaklabel_finetuned_best.keras
Epoch 1: finished saving model to D:\Final\02_Output\Jongro_WeakLabel_Finetune_2Tiles\03_weaklabel_finetuned_best.keras
662/662 ━━━━━━━━━━━━━━━━━━━━ 329s 483ms/step - accuracy: 0.8923 - dice_coefficient: 0.1967 - loss: 1.3812 - pr_auc: 0.1458 - precision: 0.1998 - recall: 0.0670 - roc_auc: 0.6362 - val_accuracy: 0.9106 - val_dice_coefficient: 0.1813 - val_loss: 1.2940 - val_pr_auc: 0.1457 - val_precision: 0.1940 - val_recall: 0.0091 - val_roc_auc: 0.6578 - learning_rate: 1.0000e-05
Epoch 2/6
662/662 ━━━━━━━━━━━━━━━━━━━━ 0s 465ms/step - accuracy: 0.9031 - dice_coefficient: 0.2014 - loss: 1.3221 - pr_auc: 0.1520 - precision: 0.2120 - recall: 0.0294 - roc_auc: 0.6529
Epoch 2: val_dice_coefficient improved from 0.18133 to 0.19694, saving model to D:\Final\02_Output\Jongro_WeakLabel_Finetune_2Tiles\03_weaklabel_finetuned_best.keras
Epoch 2: finished saving model to D:\Final\02_Output\Jongro_WeakLabel_Finetune_2Tiles\03_weaklabel_finetuned_best.keras
662/662 ━━━━━━━━━━━━━━━━━━━━ 323s 487ms/step - accuracy: 0.9031 - dice_coefficient: 0.2014 - loss: 1.3221 - pr_auc: 0.1520 - precision: 0.2120 - recall: 0.0294 - roc_auc: 0.6529 - val_accuracy: 0.9017 - val_dice_coefficient: 0.1969 - val_loss: 1.2672 - val_pr_auc: 0.1590 - val_precision: 0.2388 - val_recall: 0.0603 - val_roc_auc: 0.6692 - learning_rate: 1.0000e-05
Epoch 3/6
662/662 ━━━━━━━━━━━━━━━━━━━━ 0s 449ms/step - accuracy: 0.9004 - dice_coefficient: 0.2101 - loss: 1.2868 - pr_auc: 0.1567 - precision: 0.2237 - recall: 0.0447 - roc_auc: 0.6610
Epoch 3: val_dice_coefficient improved from 0.19694 to 0.20462, saving model to D:\Final\02_Output\Jongro_WeakLabel_Finetune_2Tiles\03_weaklabel_finetuned_best.keras
Epoch 3: finished saving model to D:\Final\02_Output\Jongro_WeakLabel_Finetune_2Tiles\03_weaklabel_finetuned_best.keras
662/662 ━━━━━━━━━━━━━━━━━━━━ 312s 471ms/step - accuracy: 0.9004 - dice_coefficient: 0.2101 - loss: 1.2868 - pr_auc: 0.1567 - precision: 0.2237 - recall: 0.0447 - roc_auc: 0.6610 - val_accuracy: 0.8954 - val_dice_coefficient: 0.2046 - val_loss: 1.2222 - val_pr_auc: 0.1685 - val_precision: 0.2501 - val_recall: 0.1020 - val_roc_auc: 0.6829 - learning_rate: 1.0000e-05
Epoch 4/6
662/662 ━━━━━━━━━━━━━━━━━━━━ 0s 461ms/step - accuracy: 0.8967 - dice_coefficient: 0.2085 - loss: 1.2771 - pr_auc: 0.1594 - precision: 0.2260 - recall: 0.0626 - roc_auc: 0.6653
Epoch 4: val_dice_coefficient did not improve from 0.20462
Epoch 4: ReduceLROnPlateau reducing learning rate to 4.999999873689376e-06.
662/662 ━━━━━━━━━━━━━━━━━━━━ 321s 484ms/step - accuracy: 0.8967 - dice_coefficient: 0.2085 - loss: 1.2771 - pr_auc: 0.1594 - precision: 0.2260 - recall: 0.0626 - roc_auc: 0.6653 - val_accuracy: 0.8949 - val_dice_coefficient: 0.2038 - val_loss: 1.2336 - val_pr_auc: 0.1659 - val_precision: 0.2391 - val_recall: 0.0963 - val_roc_auc: 0.6815 - learning_rate: 1.0000e-05
Epoch 5/6
662/662 ━━━━━━━━━━━━━━━━━━━━ 0s 458ms/step - accuracy: 0.8975 - dice_coefficient: 0.2123 - loss: 1.2656 - pr_auc: 0.1609 - precision: 0.2286 - recall: 0.0604 - roc_auc: 0.6684
Epoch 5: val_dice_coefficient improved from 0.20462 to 0.21061, saving model to D:\Final\02_Output\Jongro_WeakLabel_Finetune_2Tiles\03_weaklabel_finetuned_best.keras
Epoch 5: finished saving model to D:\Final\02_Output\Jongro_WeakLabel_Finetune_2Tiles\03_weaklabel_finetuned_best.keras
Epoch 5: ReduceLROnPlateau reducing learning rate to 2.499999936844688e-06.
662/662 ━━━━━━━━━━━━━━━━━━━━ 317s 479ms/step - accuracy: 0.8975 - dice_coefficient: 0.2123 - loss: 1.2656 - pr_auc: 0.1609 - precision: 0.2286 - recall: 0.0604 - roc_auc: 0.6684 - val_accuracy: 0.8920 - val_dice_coefficient: 0.2106 - val_loss: 1.2243 - val_pr_auc: 0.1706 - val_precision: 0.2422 - val_recall: 0.1142 - val_roc_auc: 0.6883 - learning_rate: 5.0000e-06
Epoch 6/6
662/662 ━━━━━━━━━━━━━━━━━━━━ 0s 450ms/step - accuracy: 0.8954 - dice_coefficient: 0.2102 - loss: 1.2613 - pr_auc: 0.1620 - precision: 0.2290 - recall: 0.0702 - roc_auc: 0.6712
Epoch 6: val_dice_coefficient improved from 0.21061 to 0.21108, saving model to D:\Final\02_Output\Jongro_WeakLabel_Finetune_2Tiles\03_weaklabel_finetuned_best.keras
Epoch 6: finished saving model to D:\Final\02_Output\Jongro_WeakLabel_Finetune_2Tiles\03_weaklabel_finetuned_best.keras
662/662 ━━━━━━━━━━━━━━━━━━━━ 312s 472ms/step - accuracy: 0.8954 - dice_coefficient: 0.2102 - loss: 1.2613 - pr_auc: 0.1620 - precision: 0.2290 - recall: 0.0702 - roc_auc: 0.6712 - val_accuracy: 0.8939 - val_dice_coefficient: 0.2111 - val_loss: 1.1925 - val_pr_auc: 0.1720 - val_precision: 0.2471 - val_recall: 0.1080 - val_roc_auc: 0.6927 - learning_rate: 2.5000e-06
Restoring model weights from the end of the best epoch: 6.
✓ 기존 베스트 모델 약한 라벨 파인튜닝 완료: 1916.0초 (31.93분) | RAM 1.66GB / 시스템 65%
▶ 기존 모델 동일 영상 추론 시작 | RAM 1.66GB / 시스템 65%
04_baseline 전체 추론: 0%| | 0/1540 [00:00<?, ?batch/s]
마스크 폴리곤화: 0geom [00:00, ?geom/s]
04_baseline 양성 픽셀 비율: 0.0603
04_baseline 예측 폴리곤: 492개
WARNING:tensorflow:From C:\Users\itwill\anaconda3\Lib\site-packages\keras\src\backend\common\global_state.py:82: The name tf.reset_default_graph is deprecated. Please use tf.compat.v1.reset_default_graph instead.
✓ 기존 모델 동일 영상 추론 완료: 308.7초 (5.15분) | RAM 1.64GB / 시스템 70%
▶ 파인튜닝 모델 동일 영상 추론 시작 | RAM 1.64GB / 시스템 70%
05_finetuned 전체 추론: 0%| | 0/1540 [00:00<?, ?batch/s]
마스크 폴리곤화: 0geom [00:00, ?geom/s]
05_finetuned 양성 픽셀 비율: 0.0280
05_finetuned 예측 폴리곤: 1,612개
✓ 파인튜닝 모델 동일 영상 추론 완료: 360.2초 (6.00분) | RAM 1.69GB / 시스템 75%
▶ 기존 모델 대비 변화 영역 생성 시작 | RAM 1.69GB / 시스템 75%
마스크 폴리곤화: 0geom [00:00, ?geom/s]
06_added_after_finetune: 886,788픽셀, 1,211개 폴리곤
마스크 폴리곤화: 0geom [00:00, ?geom/s]
07_removed_after_finetune: 4,178,879픽셀, 669개 폴리곤
✓ 기존 모델 대비 변화 영역 생성 완료: 9.0초 (0.15분) | RAM 1.71GB / 시스템 76%
==============================================================================
완료
출력 폴더: D:\Final\02_Output\Jongro_WeakLabel_Finetune_2Tiles
==============================================================================

▲ Skeletonization 기반 중심선을 이용한 학습/탐지 결과
하지만 결과는 기대와 달랐다. 넓게 퍼지던 오탐은 줄었지만, 이번에는 도로가 짧게 끊기거나 가느다란 선분처럼 파편화되어 나타났다. 특히 교차로나 넓은 도로에서는 실제 도로 폭을 제대로 표현하지 못했다.
(여러 방식을 종로구 전체에 반복 적용하기에는 시간이 너무 오래 걸렸기 때문에, 이 단계부터는 정사영상 TIFF 2개만 선정해 실험하였다. 먼저 작은 범위에서 라벨 방식이 실제 도로를 제대로 설명하는지 확인한 뒤, 효과가 검증된 방법만 전체 지역에 적용하려는 목적이었다. 무엇보다 이 모델은 이후 다른 자치구를 Fine-tuning하기 위한 기준 모델이므로, 종로구에서부터 도로의 형태를 안정적으로 설명할 수 있어야 했다.)
자동 생성된 라벨을 여러 차례 검토한 결과, 연구 목적에 부합하는 정답 데이터를 구축하기 위해서는 직접 라벨을 작성하는 것이 가장 적절하다고 판단하였다. 이에 따라 하나의 정사영상 TIFF 도엽을 선정하고, 영상에서 육안으로 통행 가능한 도로의 중심선을 직접 디지타이징하였다. 이후 수작업 중심선을 기준으로 약 2m의 Buffer를 생성하여, 연구 목적을 비교적 명확하게 반영한 고품질 정답 라벨로 사용하였다.
표준노드링크 기반 학습에서 가장 우수한 성능을 보인 U-Net 모델을 초기 모델로 불러온 뒤, 수작업 라벨이 구축된 정사영상을 이용하여 Fine-tuning을 수행하였다. 이후 모델의 성능을 두 가지 관점에서 확인하였다. 먼저 학습 라벨을 구축한 동일한 정사영상에서 수작업 도로를 얼마나 충실하게 재현하는지 확인하고, 다음으로 학습에 사용하지 않은 종로구 내 다른 정사영상에 적용하여 새로운 공간에서도 도로의 특징을 탐지할 수 있는지 검토하였다.
이를 위해 총 2개의 정사영상 TIFF 도엽을 사용하였다. 첫 번째 도엽은 수작업 라벨 구축과 Fine-tuning에 사용한 학습 대상 영상이며, 두 번째 도엽은 동일한 자치구에 위치하지만 추가 학습에는 사용하지 않은 외부 검증용 영상이다. 이를 통해 모델이 학습 영상을 단순히 기억한 것인지, 아니면 동일 지역의 다른 영상에도 적용 가능한 도로의 시각적 특징을 학습한 것인지를 정성적으로 비교하고자 하였다.

▲ 디지타이징한 도로 중심선
도로탐지결과 (펼치기) ▼
================================================================================
종로구 전체 정사영상 도로 탐지
추가 학습 없음 / 타일별 정규화 추론
================================================================================
모델: D:\Final\02_Output\Jongro_Digit_StrongLabel_Finetune_B060\03_digit_stronglabel_finetuned_best.keras
입력 폴더: D:\Final\00_Raw\06_map_25\01_jongro\georeferenced
TIFF 수: 12
출력 폴더: D:\Final\02_Output\Jongro_Digit_Model_Full_Test_PerTileNorm
임계값: 0.40
최소 폴리곤 면적: 8.00 m²
[1/4] 베스트모델 로드
모델: D:\Final\02_Output\Jongro_Digit_StrongLabel_Finetune_B060\03_digit_stronglabel_finetuned_best.keras
입력 형태: (None, 256, 256, 3)
출력 형태: (None, 256, 256, 1)
[2/4] 종로구 전체 TIFF 순차 추론
[1/12] (B060)정사영상_2025_37608048_EPSG5179.tif
LOW RGB : [12. 31. 25.]
HIGH RGB: [160. 169. 154.]
완료: (B060)정사영상_2025_37608048_EPSG5179.tif | 도로 픽셀 비율 3.15% | 폴리곤 615개
[2/12] (B060)정사영상_2025_37608049_EPSG5179.tif
LOW RGB : [ 7. 19. 16.]
HIGH RGB: [119. 125. 102.]
완료: (B060)정사영상_2025_37608049_EPSG5179.tif | 도로 픽셀 비율 4.14% | 폴리곤 2,955개
[3/12] (B060)정사영상_2025_37608050_EPSG5179.tif
LOW RGB : [13. 28. 22.]
HIGH RGB: [86. 91. 71.]
완료: (B060)정사영상_2025_37608050_EPSG5179.tif | 도로 픽셀 비율 1.61% | 폴리곤 670개
[4/12] (B060)정사영상_2025_37608058_EPSG5179.tif
LOW RGB : [ 4. 21. 19.]
HIGH RGB: [170. 182. 172.]
완료: (B060)정사영상_2025_37608058_EPSG5179.tif | 도로 픽셀 비율 5.65% | 폴리곤 1,058개
[5/12] (B060)정사영상_2025_37608059_EPSG5179.tif
LOW RGB : [ 6. 20. 17.]
HIGH RGB: [146. 159. 145.]
완료: (B060)정사영상_2025_37608059_EPSG5179.tif | 도로 픽셀 비율 4.55% | 폴리곤 758개
[6/12] (B060)정사영상_2025_37608060_EPSG5179.tif
LOW RGB : [ 7. 20. 17.]
HIGH RGB: [136. 151. 136.]
완료: (B060)정사영상_2025_37608060_EPSG5179.tif | 도로 픽셀 비율 2.87% | 폴리곤 689개
[7/12] (B060)정사영상_2025_37608069_EPSG5179.tif
LOW RGB : [ 4. 14. 11.]
HIGH RGB: [157. 168. 152.]
완료: (B060)정사영상_2025_37608069_EPSG5179.tif | 도로 픽셀 비율 5.78% | 폴리곤 1,140개
[8/12] (B060)정사영상_2025_37608070_EPSG5179.tif
LOW RGB : [ 7. 20. 14.]
HIGH RGB: [176. 181. 165.]
완료: (B060)정사영상_2025_37608070_EPSG5179.tif | 도로 픽셀 비율 6.63% | 폴리곤 1,032개
[9/12] (B060)정사영상_2025_37608079_EPSG5179.tif
LOW RGB : [26. 52. 42.]
HIGH RGB: [192. 196. 181.]
완료: (B060)정사영상_2025_37608079_EPSG5179.tif | 도로 픽셀 비율 4.96% | 폴리곤 2,568개
[10/12] (B060)정사영상_2025_37608080_EPSG5179.tif
LOW RGB : [15. 39. 27.]
HIGH RGB: [187. 193. 179.]
완료: (B060)정사영상_2025_37608080_EPSG5179.tif | 도로 픽셀 비율 5.54% | 폴리곤 2,636개
[11/12] (B060)정사영상_2025_37705061_EPSG5179.tif
LOW RGB : [ 8. 29. 29.]
HIGH RGB: [184. 195. 185.]
완료: (B060)정사영상_2025_37705061_EPSG5179.tif | 도로 픽셀 비율 7.44% | 폴리곤 2,290개
[12/12] (B060)정사영상_2025_37705071_EPSG5179.tif
LOW RGB : [18. 43. 38.]
HIGH RGB: [191. 196. 184.]
완료: (B060)정사영상_2025_37705071_EPSG5179.tif | 도로 픽셀 비율 5.33% | 폴리곤 2,980개
[3/4] 타일별 도로 폴리곤 병합
폴리곤 읽기: 100%|██████████| 12/12 [00:01<00:00, 7.75it/s]
통합 폴리곤 수: 19,391
저장: D:\Final\02_Output\Jongro_Digit_Model_Full_Test_PerTileNorm\Jongro_all_predicted_roads.gpkg
[4/4] 처리 요약
전체 TIFF: 12
폴리곤 생성 타일: 12
실패 타일: 0
요약: D:\Final\02_Output\Jongro_Digit_Model_Full_Test_PerTileNorm\processing_summary.txt
전체 통합 GPKG: D:\Final\02_Output\Jongro_Digit_Model_Full_Test_PerTileNorm\Jongro_all_predicted_roads.gpkg
================================================================================
완료
================================================================================
출력 구조
normalization\ : 타일별 RGB 정규화 기록
probability\ : 타일별 도로 확률 TIFF
binary_mask\ : 타일별 이진 마스크 TIFF
polygon\ : 타일별 도로 폴리곤 GPKG
Jongro_all_predicted_roads.gpkg
: 종로구 전체 통합 폴리곤

▲ 디지타이징한 도로 중심선 + 2m buffer를 사용한 학습/탐지 결과
수작업으로 디지타이징한 도로 중심선과 2m Buffer를 이용해 Fine-tuning한 결과, 앞선 실험들보다 생활도로와 골목길의 연속성이 뚜렷하게 개선되었다. 특히 학습에 사용하지 않은 동일 자치구 내 다른 정사영상에서도 주요 도로뿐 아니라 일부 협소도로까지 비교적 잘 추출되어, 수작업 라벨이 모델의 공간적 일반화에 일정 부분 기여했음을 확인할 수 있었다.
그러나 예측 결과에는 여전히 상당한 노이즈가 남아 있었다. 건물 경계, 옥상, 주차장, 그림자, 밝은 포장면과 같이 도로와 유사한 시각적 특징을 가진 영역이 부분적으로 도로로 오분류되었고, 일부 구간에서는 짧은 선분이나 작은 폐곡선 형태의 예측이 다수 발생하였다.
앞서 수작업으로 구축한 중심선 라벨을 이용한 Fine-tuning에서도 생활도로의 탐지 성능은 개선되었지만, 중심선을 기준으로 동일한 2m Buffer를 적용하면서 도로의 실제 폭을 충분히 반영하지 못하는 문제가 남아 있었다. 광폭도로는 실제 도로의 일부 영역만 정답으로 학습되는 반면, 협소한 골목길에서는 Buffer가 건물 가장자리 등 비도로 영역까지 포함하기도 하였다. 이에 중심선이 아닌 실제 도로 경계를 Polygon 형태로 직접 디지타이징하여 정답 데이터를 다시 구축하기로 하였다.
다만 수작업 Polygon 구축에는 상당한 시간이 소요되므로, 이번 단계에서는 종로구 전체가 아닌 정사영상 TIFF 1개 도엽을 대상으로 고품질 정답 데이터를 구축하였다. 이는 종로구 전체를 대표하는 학습데이터를 완성하기 위한 단계라기보다, 정확하게 구축한 소규모 정답 데이터가 기존 모델의 탐지 결과를 개선할 수 있는지 우선 확인하기 위한 실험이다. 이후 해당 데이터를 이용해 Fine-tuning을 진행하고, 학습에 사용하지 않은 다른 정사영상에서도 개선 효과가 나타나는지 확인하고자 한다.

▲ 디지타이징한 도로
'개인 프로젝트 > 골목길 탐지하기' 카테고리의 다른 글
| [골목길 탐지하기]_종로구 정사영상 기준 실험 (2/2) (0) | 2026.08.06 |
|---|---|
| [골목길 탐지하기]_위성영상 탐색과 활용 : Sentinel-2 (0) | 2026.07.28 |