python으로 공간 데이터 시각화를 하는 방법은 두 가지가 있습니다.

  1. matplotlib
  2. plotly

과학자들은 matplotlib으로 그래프를 그리는 것이 익숙합니다.
공간 데이터 시각화에도 matplotlib 기반으로 많이 접근합니다.
plotly는 지도를 그려놓고 그 위에서 공간 데이터를 시각화합니다.
오늘 할 이야기는 matplotlib을 기반으로 공간 데이터를 시각화하는 얘기, 그 중에서도 Basemap을 사용하는 방법에 대해 알아보고자 합니다.

왜 Basemap이냐?

matplotlib기반 공간 정보 시각화 방법에는 cartopy와 basemap이 있습니다.
이때까지는 cartopy를 많이 썼습니다.
제가 matplotlib 기반으로 그래프는 많이 그려봤지만 cartopy는 그 명성은 익히 들었지만 거의 사용해 본 적이 없었습니다.
일단 무겁고, 설치 환경에서 자꾸 환경 설정에 실패했기 때문입니다.
그 대안으로 어떤 라이브러리를 사용할 까 고민하던 와중에 발견한 것이 Basemap입니다.

cartopy와 basemap의 차이점은 다음과 같습니다.
둘 다 matplotlib 기반이지만, basemap은 matplotlib에서 만든 라이브러리고 cartopy는 그 기반이 matplotlib이지만 자체 생태계를 구축했습니다.
저도 basemap이 matplotlib에서 만든 줄은 이번에 글을 쓰기 위해 자료를 찾아보던 중 알게 되었습니다.
basemap 공식 문서

어떻게 사용하는가?

GEOS Projection?

이번 포스팅에서는 GEOS Projection을 따르는 위성 영상을 시각화 하기 위해서 basemap을 사용합니다.
GEOS Projection이 뭔지에 대해서는 아래 링크를 참조하세요.
GEOS

쉽게 얘기하자면 위성 영상이 전 지구(Full Disk)를 찍은 모습을 나타내는 projection입니다.
아래 그림처럼요.


이 projection을 나타내는 Proj string은 다음과 같습니다.

+proj=geos +h=35785831.0 +lon_0=128.2 +sweep=y

lon_0은 세로로 지구본을 얼마나 돌릴 건지 결정합니다.

설치

  • 설치 링크 : https://matplotlib.org/basemap/
  • pip install : https://pypi.org/project/basemap/
    • basemap-data-hires : 라이브러리 사용 시 high-resolution 자료들(해안선 등)을 지도에 쓰려면 반드시 설치해야 합니다.
    • 기본 해상도 데이터는 basemap-data로 basemap 설치할 때 같이 깔립니다.
      python -m pip install basemap
      python -m pip install basemap-data-hires

사용 예시

기본 GEOS 표출

from mpl_toolkits.basemap import Basemap, cm
import matplotlib.pyplot as plt

plt.figure(figsize=(10,10))
m2 = Basemap(rsphere=(6378137.00,6356752.3142), projection='geos',lon_0=128.2,resolution='h')
m2.drawcoastlines()

원하는 영역만 표출하기

lat1, lat2, long1, long2 = (46.830415916691464,
 -16.043669898267705,
 90.29636166074948,
 172.08684795392023)

plt.figure(figsize=(10,10))
'''
llcrnr : left lower corner
urcrnr : upper right corner
resolution : basemap에서 제공하는 해안선 등의 데이터를 어떤 해상도로 표출할 지 결정. h면 high resolution을 의미
lon_0 : longitude 방향으로 중앙
lat_0 : latitude 방향으로 중앙
'''
m= Basemap(llcrnrlon=long1,llcrnrlat=lat2,urcrnrlon=long2,urcrnrlat=lat1, resolution='h',epsg=4326,lon_0=(long2-long1) / 2,lat_0=(lat1-lat2) / 2)
m.drawcoastlines()

표출 예시

NOAA에서 제공하는 GOES위성의 어떤 밴드 영상을 단순 시각화한 예시

결론

Cartopy보다 덜 무거우면서도 친숙한 인터페이스 내에 Projected map을 사용하고 싶다면 basemap 사용을 고려할 것.

그리고 기초편이 있다는 것은 심화 편도 있다는 얘기.
심화 편에서는 그렇다면 cartopy에서 그리던 것들을 어떻게 basemap 코드로 옮길 수 있는데?에 대해 쓸 예정입니다.
일하면서 틈틈히 메뉴얼을 작성하고 그 메뉴얼을 더 다듬어서 틈나는 대로 블로그 포스팅을 하다 보니 시간이 좀 걸립니다.

Reference

목적

배경

  • 공간 정보 데이터는 맞는데, 그렇다고 이를 바로 지도에 표출하려니 데이터가 다른 분야에서 다루는 데이터라서 공간 정보 데이터의 형식에 맞춰줘야 할때
  • 기상 데이터들은 공간에서 일어나는 현상을 다룬 데이터라 위치 정보들이 있지만, 우리가 생각하는 EPSG:4326 혹은 EPSG:3857 같은 GIS 계에서 흔히 쓰는 좌표계를 쓰지 않는다
    • lambert conformal coordinate
    • 각 픽셀마다 좌표 정보가 있다지만, 다음의 두 가지 형식으로 저장된다
      • 픽셀 마다 좌표 정보가 있다. 각 픽셀들은 lcc 좌표계 내에서 등간격으로 존재하고 표기는 위경도로 표기된다
      • 상대적인 범위 내에 격자 형식으로 좌표를 기술할 수도 있다. 아래 그림은 기상청에서 쓰는 격자 좌표계를 기술한 문서의 일부
  • 기상 데이터들은 정해진 데이터 모델에 위치 정보와 데이터를 넣어 그에 맞춰 시각화를 한다. 지구 위에서 보려면 별도의 처리 과정이 필요하다.
    • 데이터 모델을 통해 가시화 하면 이런 형태로 가시화 한 것을 볼 수 있다. 위성으로 측정한 지표면 절대온도 데이터
    • lcc에서는 분명 직사각형으로 보였는데 EPSG:4326에서는 이렇게 부채꼴로 보인다.

대상

  • 기상 데이터와 같이 위치 정보는 있는데, 자주 쓰는 좌표계로 데이터를 georeference 해주고 싶을 때
  • gcp를 이용해서(특히 네 구석의 점) 어떻게 georeference를 하는가? 코드로 하고 싶다
  • npy로 출력한 이미지를 어떻게 지구상에 표출할까?

내용

상황

  • 주어진 건, 모델의 예측 결과(npy)와 각 array의 요소들의 위경도 좌표 리스트
    • lat, lon 정보가 따로 따로 2차원 배열로 들어옴
  • lcc 좌표계 내에서 정보는 아래와 같은 모양새로 분포하고 있다.

코드

작업할 좌표계에 맞춰 기준 좌표 준비하기

좌표는 위경도로 서술되어 있지만 이미지를 가장 쉽게 Georeference하는 방법은 직사각형의 이미지의 상하좌우 꼭지를 그대로 좌표계에 붙여넣는 것이다.

이미지의 회전, w-e/n-s 간의 gsd 차이 등을 신경쓰지 않아도 되기 때문이다.

좌표는 위경도 좌표계에서 부채꼴의 양상을 띄기 때문에, 만약 이를 그대로 Georeference하려면 이미지의 테두리에 있는 상당한 점들을 써야 한다.

그래서 위경도 좌표계로 서술한 점을 lcc 좌표계에 맞게 변환한다.

import pyproj

proj_102012 = "+proj=lcc +lat_0=0 +lon_0=105 +lat_1=30 +lat_2=62 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs +type=crs"

# 1. 기준으로 삼을 네 구석의 점을 준비
corner_points = [(45.55741, 118.57613),
 (45.55741, 133.8462),
 (32.30801, 118.57613),
 (32.30801, 133.8462)]

# 2. 위경도 좌표계로 기술된 점을 원본 lcc 좌표계의 점으로 변환
lcc_corner_points = [pyproj.Proj(proj_102012)(x[1],x[0]) for x in corner_points]

gdal gcp 생성하기

from osgeo import gdal

# 1. 생성할 이미지의 사이즈를 지정
# width가 col, height가 row에 해당함
row = lat_array.shape[0]
col = lat_array.shape[1]

# 2. 맞춰줄 픽셀 좌표 정의
# 픽셀 좌표와 실 좌표를 어떻게 맞춰줄 지 정하려면 이미지의 어느 부분이 실제 어느 위치에 존재하는 지 이어줘야 한다
# 이미지의 원점은 좌상단. upper_left, upper_right, lower_right, lower_left
pixel_index = [(0,0), (0,col),(row,col),(row, 0)]
gcp_list = []

# 3. 픽셀 좌표와 맞는 실 좌표의 쌍을 만든다. 
for x in range(4) : 
    gcp_list.append((pixel_index[x][0],pixel_index[x][1],lcc_corner_points[x][0],lcc_corner_points[x][1]))
'''
gcp_list = 
[(0, 0, 1013848.356912874, 5479167.3746448895),
 (0, 576, 2116635.162965722, 5784374.250365057),
 (720, 576, 2629740.4931766586, 4450362.210115054),
 (720, 0, 1259620.9893719808, 4071168.434106591)]
'''  
# 4. gdal 내의 gcp class를 이용해서 gcp 객체들을 생성
gdal_gcp_ins_list = []

for item in gcp_list:
    pixel, line, x, y = map(float, item)
    z = 0
        # pixel, line이라는 단어가 등장하는데, col과 row에 해당한다고 생각하면 된다. 
    gcp = gdal.GCP(x, y, z, pixel, line)
    gdal_gcp_ins_list.append(gcp)

새로운 geotiff 생성

먼저 좌표계 정보를 알려주기 위해서 wkt string으로 표기된 ESRI:101210 좌표계의 정보를 가져왔다.

#lcc_wkt_str = 'PROJCS["Asia_Lambert_Conformal_Conic",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]],PROJECTION["Lambert_Conformal_Conic_2SP"],PARAMETER["latitude_of_origin",0],PARAMETER["central_meridian",105],PARAMETER["standard_parallel_1",30],PARAMETER["standard_parallel_2",62],PARAMETER["false_easting",0],PARAMETER["false_northing",0],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Easting",EAST],AXIS["Northing",NORTH],AUTHORITY["ESRI","102012"]]'

이 부분은 gdal의 geotiff 드라이버의 영상 생성 기능을 이용해 geotiff로 영상을 저장하는 함수이다.

from osgeo import gdal, osr
'''
인자 설명
- gcps : gdal의 gcp 클래스 객체가 들어간다. 최소 세 개는 들어가야 한다(평면을 구성하는 최소 꼭지점의 갯수)
- img_path : 이미지를 저장할 경로
- band_num : rgb 저장이 필요하면 3
- radar_array : 저장하려는 numpy array
'''
def radar_to_geotiff(gcps, img_path, band_num, radar_array) : 
    format = "GTiff"
    # 1. 이미지 크기 정하기
        col = radar_array.shape[0]
    row = radar_array.shape[1]    
    driver = gdal.GetDriverByName( format )
    metadata = driver.GetMetadata()
    # 주의해야 할 점 1 : col, row의 순서다 보니 numpy array를 넣으려면 transpose가 필요하다. 
    # 2. geotiff를 생성하기 위해 data source 생성
        '''
        인자들을 설명한다. 
        - img_path : 저장경로
        - col : 영상의 width
        - row : 영상의 height
        - band_num : 당시 RGB로 저장했기에 3이 들어감
        - gdal.GDT_Float32 : 데이터 타입
        - options : 별도 옵션. 이 옵션을 정해주지 않으면 자꾸 qgis에서 gray scale로 파일을 읽어 성가셨음
        '''
        dst_ds=driver.Create(img_path, col, row, band_num ,gdal.GDT_Float32,options = [ 'PHOTOMETRIC=RGB' ])
    for i in range(1, band_num+1) : 
        dst_ds.GetRasterBand(i).WriteArray( np.transpose(radar_array[:,:,i-1]) )
                # nodatavalue를 (255,255,255)로 설정한다    
        dst_ds.GetRasterBand(i).SetNoDataValue(255)

        # 3. 영상의 좌표계를 설정한다. 우리는 ESRI:102012를 쓸 건데, osr의 ImportFromEPSG에 ESRI 코드도 들어감
        # 다만 GDAL 3.x 버전을 사용할 것
    srs_lcc = osr.SpatialReference()
    srs_lcc.ImportFromEPSG(102012)
        # 3-1. data source에 projection 설정
    dst_ds.SetProjection(srs_lcc.ExportToWkt())
        # 4. gcp 설정 
    dst_ds.SetGCPs(gcps, srs_lcc.ExportToWkt())    
        # 5. 4에서 입력한 GCP에서 geotransform을 계산해서 data source에 세팅하기
        '''
        gdal의 c++ 코드를 까면 대부분 GeoTransform이 data source에 설정되어 있다는 것을 가정하고 계산을 한다.
        gdal 함수 중 입력한 gcp에서 역으로 GeoTransform을 계산해서 설정하는 함수가 있다. 꼭 쓰자. 
        '''
    geotransform = gdal.GCPsToGeoTransform(dst_ds.GetGCPs())
    dst_ds.SetGeoTransform(list(geotransform))
    # 6. 설정 저장. data source의 포인터를 저장한 변수에 None을 넣으면 여태 입력한 정보를 영상에 넣어 영상 생성
        dst_ds = None

reproject

이 부분은 정말 아쉽게도 아직 코드로 구현하는 방법을 찾지 못해서, cli를 통해 ESRI:102012에서 EPSG:4326으로 reproject 해줬다. 이 부분은 추후에 코드로 다루는 방법을 보완할 예정

  • nodata설정을 해줘야 나중에 시각화 할 때 nodata 부분은 투명하게 볼 수 있다.
**gdalwarp -t_srs EPSG:4326 -dstnodata 255 ${src_path} ${dst_path}**

결론

  • 이 문서는 GDAL 3.x 버전을 기준으로 작성되었다. 2.4 버전에서는 lcc좌표계(ESRI:102012)가 인식이 안되더라
  • GDAL vrt로 reproject하는 cli를 코드로 대체할 수 있을 것 같다. GDAL vrt에 대한 학습이 필요

개요

해당 문서의 대상 독자(이하 서술 조건의 대부분에 속하신다면 대상이십니다)

  • 급하게 ArcGIS로 데이터를 처리해야 함
  • javascript보다는 python이 편함
  • ArcGIS에 데이터는 올렸는데 편집이 필요하다. 근데 나 맥 써서...ArcGIS Pro로 대응을 할 수 없다. <- 글쓴이는 이 경우였음
  • ArcGIS Online이 편하다.

해당 문서의 주요 키워드

  • ArcGIS
  • ArcGIS Online
  • ArcGIS Python API

내용

1. ArcGIS API package를 통한 local 사용

전제 : ArcGIS Pro 또는 ArcGIS Server가 깔려 있는 컴퓨터

  • 2021년까지만 해도 ESRI는 ArcGIS 제품이 해당 머신에 깔려 있는 경우에만 API를 사용하도록 허용했다.
  • 2021년 부터는 새로운 방법을 지원한다고 하는데, 솔직히 뭐가 바뀌었는지는 모르겠다.(문서 작성 당시인 2022년 10월에 시도했지만 실패)
  • 해당 방법은 기존 API 사용시 가장 기본으로 생각하는 경우이다.
  • 두 제품군 모두 윈도우에서만 지원한다.
  • arcgis (python) package가 미리 깔려있음을 전제함
  • 다음의 두가지 방법이 있다(참고 : 문서)
    • python pakcage manager를 사용하라고 안내한다.
    • arcgis가 설치된 폴더 내에 위치한 Python Command Prompt를 켜서 conda명령어를 통해 python api를 깐다.

전제 : ArcGIS 제품군이 없는/설치 불가인 머신

(문서 작성 시 참 많은 시도를 해봤는데, 잘 되지 않았다.)

  • conda를 사용해서 리눅스에도 깔 수 있다고 문서에 나와있지만 실패.
  • install offline
    • 파일 다운로드 받아서 압축 풀기 > conda에서 offline 설치 가능하게 환경 설정 > 명령어
    • 공식 문서에는 The conda
      utility will pull all the arcgis package dependencies from the installed set of Anaconda libraries instead of searching the internet. 라고 되어있지만 cli로 테스트해보니까 제대로 import 되지 않아 포기
  • docker install
    • 사실상 윈도우만 지원 (참고 : 문서)

2. ArcGIS Online을 통한 API 사용

전제 : ArcGIS Online 내 notebook 서버 사용

  • arcgis package만 지원하는 서버 : 무료

    • 우리가 하는 일을 하려면 arcgis 서버에 접근해야 하는데, 접근 권한을 얻기 위해서는 arcgis package에서 지원하는 GIS class 를 통해 접근 권한을 얻어야 한다.

    • 예시

      from arcgis.gis import GIS
      gis = GIS() # anonymous connection to www.arcgis.com
      # Search for 'USA major cities' feature layer collection
      search_results = gis.content.search('title: USA Major Cities',
                                        'Feature Layer')
  • 다른 옵션

    • arcpy 지원하는 서버 : 크레딧
    • arcpy + gpu resouces : 크레딧

ArcGIS Online을 통한 Python API 사용 예시

1. feature layer access

주의 : 아래 나오는 코드들은 모두 예시 코드로, 실제 접속 정보는 제외함. 데이터도 이하동문.

password = 'blablabla'
# jungham.map.arcgis.com은 해당 계정이 속한 organization의 주소
# source로 해당 계정이 접속할 수 있는 데이터에 접근 가능(contents)
source = GIS("https://jungham.maps.arcgis.com", f"{arcgis_user_account}", f"{password}", verify_cert=False)
# 이 레이어의 이름은 ham, 자원 유형은 'Feature Layer'이다 
sample_data_layer = source.content.search('ham',item_type='Feature Layer')

2. feature layer update

1) geometry 형태

{"x": -10617585.164750207,"y": 5160968.961654072,"spatialReference": {"wkid": 102100, "latestWkid": 3856}
}

2) feature 형태

아래는 layer를 이루는 feature 하나의 정보이다.

    {"geometry": 
    {"x": 13948339.367548944, "y": 4677527.208890018, "spatialReference": {"wkid": 4326}}, 
    "attributes": {"FID": 6, "type": 'candyshop', "content": "old-fashioned candyshop", "create_datetime": "2019-03-11 00:00:00"}
    }

3) 업데이트 예시

ArcGIS 내에서 데이터 구조를 이해하면 아래의 코드가 쉽게 이해된다.
OGC 표준에서는 보통 featurecollection > features > feature 이런 단계로 정의가 되는데
ArcGIS에서는 featurelayer > layer > featureset > feature 이런 구조로 데이터를 관리하고 있다.
그리고 Pandas의 DataFrame을 확장한 spatially enabled DataFrame(sedf)이라는 독특한 데이터 타입을 정의해서 featureset에 이 타입을 대응해서 사용하고 있다.

# spatially enabled Dataframe -> featureset -> update
# features in layer are treated as 'featureset'
sedf = pd.DataFrame.spatial.from_geodataframe(sample_event_df)
# adds, updates, deletes 등의 키워드가 있다. 
sample_data_layer[1].layers[0].edit_features(adds=sedf.spatial.to_featureset())

ArcGIS 내에서 데이터를 관리하는 구조와 그 타입

해당 내용은 Python API 사용 방법을 이해하는데 도움이 되었기에 사용 예제와 함께 적는다.

데이터 구조

  • FeatureLayer > layer > featureset > feature
  • 실제 서버에서 publish된 layer(feature layer를 예시로 들면) 아래에는 여러 layer가 포함되어 있을 수 있다. 그리고 하나의 layer는 featureset 내에 feature 데이터를 저장한다. featureset은 feature의 집합이다. feature들을 list로 가지고 있다.

시간 활성화

시간 활성화 메뉴얼에 대한 추가 설명

시간 활성화 메뉴는 메뉴얼을 봐도 찾기 힘들었다.
published feature layer 아래에 layer의 집합이 있다. 그러니까 feature layer는 layer의 집합이다.
예를 들면, 'the location of the opened shops' 라는 이름의 feaure layer가 있으면 그 아래에 'the location of the opened shops_0'이라는 point layer가 존재한다. 실제로 사용한 feature layer의 경우 {feature layer name}_0라는 이름을 가진 기하 레이어가 그 아래에 존재한다.

피처 레이어는 건물, 필지, 도시, 도로, 지진 진앙 등 유사한 지리적 피처를 그룹화한 것입니다. 피처는 포인트, 라인 또는 폴리곤(영역)일 수 있습니다.

문구 출처 : 피처 레이어

아래 첫번째 그림은 feature layer아래에 위치한 layer에 들어가서 시간 옵션을 활성화하는 예시이다.


아래 두번째 그림은 시간 옵션을 활성화 했을 때 arcgis online map viewer에서 해당 layer를 열었을 경우 자동으로 time series bar가 생긴 화면이다.

date field가 활성화 되어 있어야 하며, 이 때 해당 column에 유효하지 않은 값이 저장되어 있다면 해당 시간 옵션은 활성화되지 않는다.

그림 출처 : How To: Enable time on a layer in ArcGIS Online and create a web app with time animation

후기와 회고

  • ArcGIS Python API를 사용하는데, 문서간의 연속성이 없어서 문서 간 이동을 통해 얻어낸 내용을 정리하고 싶었다.
  • 좀 더 직설적으로 '구려!'라고 말하고 싶지만, 누구에게는 ArcGIS가 많은 일들을 해주고 있을 것이고, 실제로도 그렇기 때문에 그렇게는 적고 싶지 않았다. 문제는 존재하니까 남은건 푸는 것일 뿐. 다만 아래 한 마디만 남기겠다.
  • 한국 서비스 지원 센터에 문의를 했으나...
  • ArcGIS 한국인 사용자 중 python api를 어떻게 써야 할지 감이 잡히지 않는다면, 데이터를 불러오는 간단한 예제부터 따라해보라고 말하고 싶다. 이 글이 그런 분들에게 도움이 되길 바라는 마음에서 작성했다.
  • 정리하다 보니, feature layer crud 예제까지 작성해도 될 것 같았다. 그것은...! 시간이 되면 이 글에다가 추가하겠다.

개요

  • qgis 3.22버전에서 새롭게 annotation layer라는 layer가 새로 생겼다.
    qgis에서 annotation layer가 포함된 qgs를 열면 다음과 같이 보인다. raster와 qgs가 한 쌍으로 움직인다.
  • 이 layer는 벡터 데이터가 아니며, qgis project file(qgs)에 xml로 도형을 기록한다
  • 어떻게 annotation의 geometry를 추출할 수 있는가?

참고 자료

  • qgis annotation layer란?
  • 3.22에는 annotation layer을 대응할 수 있는 tool bar가 있는 것으로 보이나, 내가 쓰는 QGIS 3.26 버전에서는 찾을 수 없었다.
    • (수정 : 22.11.18) macos QGIS 3.28 버전 기준, View > Toolbar > annotation toolbar(주석 툴바)로 추가하시면 다음의 툴바를 확인할 수 있습니다. 글 작성 시점으로는 3.29까지 나왔습니다. 

  • annotation layer 정보는 qgs 내에 xml로 기술되어있다.

    위 그림에서 볼 수 있듯, qgs내에 기술된 annotation layer와 qgs가 바라보는 raster layer 두 레이어가 뜨나, qgz를 압축해제해서 얻은 결과는 qgs와 링크된 raster 데이터 둘 뿐이었다.

코드

import shapely.wkt
import xml.etree.ElementTree as elemTree
from osgeo import ogr
import geopandas as gpd

'''
box, polygon, mask
'''
'''
parse_annotation은 여기서 qgs 내에 xml으로 기술되어있는 프로젝트 정보 중 'layer'단위의 xml을 파싱한다. 
return : annotation 파일 내의 geometry를 모아서 shapely.geometry 타입으로 list를 반환
'''
def parse_annotation(elem) : 
    items = elem.findall('.//items/item[@wkt]')
    wkts = [x.get('wkt') for x in items]
    geoms = list()
    for wkt in wkts : 
        if 'Curve' in wkt :
            try : 
                geom = ogr.CreateGeometryFromWkt(wkt)
                geom_approx = geom.GetLinearGeometry()
                geom_shapely = shapely.wkt.loads(geom_approx.ExportToWkt())
                geoms.append(geom_shapely)
            except : 
                print(wkt)
        else : 
            geom_shapely = shapely.wkt.loads(wkt)
            geoms.append(geom_shapely)
    return geoms

'''
path : 파일의 위치
return : annotation 파일 내의 전체 geometry를 list로 반환 
'''

def parse_qgs_for_annotation(path) : 
    tree = elemTree.parse(path)
    root = tree.getroot()

    main_annotation_layer = root.findall('.//main-annotation-layer')[0]
    extra_annotation_layers = root.findall(".//projectlayers/*[@type='annotation']")

    geoms = list()
    geoms.extend(parse_annotation(main_annotation_layer))
    for extra_annotation_layer in extra_annotation_layers :
        geoms.extend(parse_annotation(extra_annotation_layer))
    return geoms

주의사항

CurvePolygon 때문에 ogr 모듈을 썼다. esri shapefile format에서는 geometry에서 curve 타입을 지원하지 않는다고 했기 때문에, shapefile 말고 geojson으로 내보내길

용례

tree = elemTree.parse(path)
root = tree.getroot()
srid = root.find('./projectCrs//srid')
srid = int(srid.text)

res = parse_qgs_for_annotation(path)
gdf = gpd.GeoDataFrame(geometry=res)
gdf = gdf.set_crs(f'epsg:{srid}')

gdf.to_file("test.geojson", driver='GeoJSON')

전제 및 가정

지오레퍼런싱(georeference)을 하기 위해서는 다음 세 가지 정보가 꼭 필요하다.

  1. 좌표계 정보(EPSG 코드가 명시되어 있으면 가장 좋다.)
    1. 한국에서 쓰는 좌표계는 몇 가지가 있는데, 중부 원점(5186)을 가장 많이 쓰고 조선소의 경우 동부 원점으로 기술된 경우가 있다.
    2. 한국 내에서 영상의 지리 정보를 기술할 때 표준과 상관없이 기술하는 경우가 대다수이므로, 이럴 때는 다음을 확인해 보자. 한국에서 정의된 좌표계는 좌표계의 원점을 (0,0)으로 뒀을 때, 이를 좌표계 상에서 동쪽으로 얼마, 북쪽으로 얼마 이동한 가상 원점을 사용한다. 예를 들어 동쪽으로 200,000m 북쪽으로 600,000m 이동했다고 적혀있으면 epsg:5186에 해당하는 중부 원점이다.
  2. 좌표계에서의 영상의 좌상단 좌표
    1. gsd : pixel 2 geo 할 때 1pixel이 좌표계 상에서는 얼마에 해당하는지 그 비율을 서술함

1. 이미지를 georeference 하기

목적

  • 아예 지리 정보가 없는 이미지를 지도 위에 놓기
    • 예 : inference 생성 결과는 단순 이미지일 뿐, 좌표계 정보가 없다.
  • 파이썬으로 처리할 수 있는 방법이 없을까?

원리

  • GCP를 지정해서 지리정보가 없는 이미지와 그 이미지를 놓을 위치 간의 참조를 만든다.
  • 평면 대 평면을 매핑하면 된다.
  • 특정 지역을 매핑하는 것이 아니라면, 이미지의 가장자리 4개 꼭지점과 이미지를 놓을 지도 위 영역 가장자리 4개 꼭지점을 매핑한다.
  • 매칭 순서
    • 최소 점 3개가 필요하다(평면을 이루는 최소 단위)
    • 픽셀 좌표계 x좌표 ⇒ longitude
    • 픽셀 좌표계 y좌표 ⇒ latitude
    • ccw인지 cw인지는 상관없고 하나로 통일해서 매칭하면 된다
  • 주의할 점
    • 두 좌표계 간 원점이 다르다 : 픽셀 좌표계는 원점이 좌상단, 위경도 좌표계는 원점이 중앙에 위치한다. (원점은 0,0)
    • 경도 값은 중간을 0으로, 북반구가 양의 값으로 90까지 증가하고 남반구가 음의 값으로 -90까지 감소한다.
    • 픽셀 좌표계는 상단에서 하단으로 값이 증가한다.
    • 위 두 사항 때문에 좌표값을 매칭할 때 y값과 경도 값을 매칭하는 일이 헷갈린다. 아래 그림에서도 볼 수 있듯 좌상단 꼭지점의 y값은 '값'으로 봤을 때 max값이다.



- 참고 자료 : [https://www.earthdatascience.org/courses/use-data-open-source-python/intro-raster-data-python/fundamentals-raster-data/raster-metadata-in-python/](https://www.earthdatascience.org/courses/use-data-open-source-python/intro-raster-data-python/fundamentals-raster-data/raster-metadata-in-python/)

구현 코드

from osgeo import gdal, ogr, osr

def georef_inference(raster_path, infer_path) :
    '''
    raster_path : 대상이 되는 원본 영상의 경로
    infer_path : 생성한 inference의 경로
    '''

    '''
    원본 영상에서 inference에 매칭할 기준점들 추출(가장자리 4개 점)-> epsg코드 파싱으로 얻기 -> 원본 영상이랑 inference랑 대응되는 점들 gdal.GCP 생성으로 mapping
    -> SetGCP로 inference georeference 하기
    '''
    origin_ds = gdal.Open(raster_path)
    geoTransform = origin_ds.GetGeoTransform()
    minx = geoTransform[0]
    maxy = geoTransform[3]
    maxx = minx + geoTransform[1] * origin_ds.RasterXSize
    miny = maxy + geoTransform[5] * origin_ds.RasterYSize
    origin_points = [minx, miny, maxx, maxy]

    crs_str = origin_ds.GetProjectionRef()
    res = crs_str.replace("[",',').replace("]",',').replace('\"',',').split(',')
    num_list = [x for x in res if x.isdecimal() == True]
    crs_num = num_list[-1]

    target_ds = gdal.Open(infer_path)
    sr = osr.SpatialReference()
    sr.ImportFromEPSG(int(crs_num))
    target_maxx = target_ds.RasterXSize
    target_maxy = target_ds.RasterYSize
    target_points = [0, 0, target_maxx, target_maxy]

        '''
        geotransform[0] = top left x
        geotransform[1] = w-e pixel resolution
        geotransform[2] = 0
        geotransform[3] = top left y
        geotransform[4] = 0
        geotransform[5] = n-s pixel resolution (negative value)
        '''

    gcps = [gdal.GCP(origin_points[0],origin_points[3],0,0,0),
            gdal.GCP(origin_points[0],origin_points[1],0,0,target_points[3]),
            gdal.GCP(origin_points[2],origin_points[1],0,target_points[2],target_points[3]),
            gdal.GCP(origin_points[2],origin_points[3],0,target_points[2],target_points[1])
           ]
    target_ds.SetGCPs(gcps, sr.ExportToWkt())
    target_ds.SetGeoTransform(origin_ds.GetGeoTransform())
    target_ds = None

2. 처음부터 georeference 하기

목적

  • 이미지 생성시 georeference하는 방법
  • 다른 이미지를 읽어와서 생성하는 게 아니라, array에 담긴 데이터를 georeference 정보를 담아서 저장

원리

코드

'''
    original_img_path : georeference 정보를 가져올 이미지의 경로
    img_write_path : 이미지를 저장할 경로
  im_array : numpy array로 저장된 데이터
'''
def save_img_with_georef(original_img_path, img_write_path, im_array) :
    original_img = gdal.Open(original_img_path)
        # 이미지는 c,h,w의 형태로 들어오는 것을 기대한다. 
    num_channel = im_array.shape[0]
    nrows = im_array.shape[1]
    ncols = im_array.shape[2]

        # gdal에서 파일 생성(create)이 지원되는 드라이버는 geotiff가 대표적이다.
    driver = gdal.GetDriverByName('GTiff')
    output_img = driver.Create(img_write_path, ncols, nrows, num_channel ,gdal.GDT_Int16, ['COMPRESS=LZW'])

    output_img.SetGeoTransform(original_img.GetGeoTransform())
    for i in range(1, num_channel+1) :
        output_img.GetRasterBand(i).WriteArray(im_array[i-1])

    output_img.SetProjection(original_img.GetProjection())
    output_img = None

3. 이미지 좌표 → 지도 좌표

목적

패치 단위로 이미지를 읽어와 얻어낸 이미지 좌표를 지도 좌표로 바꾸고자 할 때

원리

패치의 x, y offset만큼 평행이동하고 (픽셀 : 실제 거리) 간의 비율인 gsd를 곱해 이미지의 크기를 조정한다.

gdal에서 geotransform을 얻을 때 좌상단의 x/y좌표, 이미지의 가로/세로 gsd를 구할 수 있다.

이를 이용해서 변환에 필요한 행렬을 계산한다.

코드

Affine transformation

'''
from shapely import affinity 
from shapely.geometry import Polygon
from shaeply.geometry import Point
'''

def canvas2WorldCoord(geom, geo_transform,xoff=0, yoff=0) :
    tl_x, x_gsd, _, tl_y, _y, y_gsd = geo_transform

    geom = affinity.affine_transform(geom, [1, 0, 0, 1, xoff, yoff])
    return affinity.affine_transform(geom, [x_gsd, 0, 0, y_gsd, tl_x, tl_y])

4. 이미지 + gis 정보가 metadata로 따로 있을 때

목적

이미지를 전달 받았는데, 이미지는 이미지 대로 찍고 georeference 정보는 이미지와 별개로 다른 파일/시스템에 저장되어 있는 경우에 이미지에 georefence 적용하기

원리

이 글의 대전제 부분에도 명시했지만, 이미지의 좌상단 점을 기준으로 원본 좌표계에 먼저 자리를 잡고 gsd에 따라 영상의 비율을 수정하는 작업을 거친다. 좌표계를 모른다면 답이 없다.

코드

from osgeo import gdal, osr
from typing import List

def set_geo_reference(input_path : str , output_path : str , epsg_code : int , geo_transform : List[float]) : 
    input_ds = gdal.Open(input_path, gdal.GA_Update)
    input_ds.SetGeoTransform(geo_transform)
    srs = osr.SpatialReference()
    srs.ImportFromEPSG(epsg_code)
    input_ds.SetProjection(srs.ExportToWkt())
    input_ds = None

def get_epsg_code(path : str) -> str :
    ds = gdal.Open(path)
    prj = ds.GetProjection()
    prj = osr.SpatialReference(wkt=prj)
    return int(prj.GetAuthorityCode(None))

def set_reproject(input_path : str, output_path : str , target_epsg_code : int) : 
    cmd_dict = {'input_path' : input_path, 'output_path' : output_path, 'source_srs' : f'{get_epsg_code(input_path)}', 'target_srs' : f'EPSG:{target_epsg_code}'}
    base_command = 'gdalwarp {input_path} {output_path} -s_srs {input_srs} -t_srs {output_srs}'
    process = subprocess.run(base_command.format(input_path=cmd_dict['input_path'],output_path=cmd_dict['output_path'], input_srs=cmd_dict['source_srs'], output_srs=cmd_dict['target_srs']), shell=True, check=True)

설명

  1. set_geo_reference
  2. 인자로 받아들인 epsg code와 geo_transform을 통해 georeference를 하는 함수
  3. get_epsg_code
  4. georefence가 된 영상의 epsg code를 얻어내는 함수
  5. set_reproject
  6. georeference가 된 영상을 다른 좌표계로 reprojection 시키는 함수

환경 설정

혹시 본인이 가지고 있는 docker에서 gdal 2.4.0을 사용하고, pyproj 3.2 미만 버전을 사용한다면

error 6: unable to load proj.4 library (libproj.so), creation of ogrcoordinatetransformation failed.

라는 에러를 볼 수 있다. 이 때 해결법은 더 높은 버전의 pyproj를 깔아보고, 그래도 안되면 다음을 실행한다. proj의 개발 버전을 까는 명령어이다.

sudo apt update
sudo apt install libproj-dev

5. 아예 아무 정보도 없을 때

목적

crs(srs)정보가 누락된 경우, 이를 원본 영상에 할당하고 싶을 때.

코드

gdal_edit.py -a_srs EPSG:4326 {파일 이름}

Q1.  왜 블로그가 텅텅 비었소?

A. 정리를 딴 데다 하고 있었다. 

 

이 블로그를 잊고 있었다...! 라기 보다는

구현 결과라던가 업무 후 나오는 팁들은 모두 

사내 노션에다 메뉴얼화 해서 정리하고 있었다. 

이 메뉴얼이 꽤 쌓이다 보니, 메뉴얼들을 조각모음 할 필요성도 느끼고

나도 여태 구글링으로 여러 사람들의 팁을 잘 받았기 때문에

공리주의 차원에서 여기에 공유를 하겠다. 

 

Q2. 그래서 22년까지 뭐 했는데?

A. 인생 제 3막, 절찬리에 시작! 

 

인생 1막, 대학교 까지는 부모님이 낳아주고 키워주셨다면

인생 2막, 대학원-20대 중후반(왜 인생 2막이라 하냐면 많은 것을 배웠고, 바꿨고, 바꿈당했기 때문)

에서는 GIS라는 낯선 세계로 발을 들이게 된다. 

인생 1막은 놀랍게도 기억이 뜨문뜨문하다. 생각해보면 너무 공부벌레로만 살아서 그런 것 같다. 

(그럴 일은 전무해서 하는 말이지만) 다시 돌아간다면 반항도 좀 하고, 더 넓은 세상을 보려고 하고, 

(장학금만 아니었다면) 학점에'만' 그렇게 목을 매지 않았을 거다. 

 

인생 2막

첫 회사에서 이직하기 전까지는 완전히 공간 정보의 세계를 유영했다. 

대학원에서는 OGC 데이터들, geometry 데이터들 등 데이터 처리와 설계, 실증을 많이 했었다. 

오랫만에 본 기하학과 데이터 설계는 차곡 차곡 무언가를 착착 정리하거나 과정을 따라가서 새로운 것을 만드는 

내 성격 상 꽤 마음에 들었다.

기하의 정의, 실제 상황에서의 실증, 나아가서 이 데이터 구조가 이전의 어떤 데이터 처리 체계 혹은 관리 체계를 개선할 수 있는가. 

생각지도 못한 일도 많이 했다. 

첫 직장에서도 돌아보면 짧은 기간에 많은 경험을 했다. 

2D 위에서만 놀 줄 알았는데 첫 직장에서 사수님이 전해주신 3D 데이터 처리 지식을 배울 수 있었다. 

물론 그래픽스 전공이 아니라서 3D 데이터 저장이라던가 처리, 시각화 정도에 그치지만.

거기에 미니 프로젝트 PM겸 1인 개발자까지(실질적으로) 해보면서 대략 프로젝트가 어떻게 굴러가는지 몸으로 떼우면서 배웠다. 

무수한 할말들이 남아 있지만, 배웠던 좋은 경험을 꼽아보라면 한 사람이 모든 것을 다 해야 하는 상황에서 어떻게 해야 하는지

PM과 개발자가 같은 사람이 된다면 어떤 일이 벌어지는지 등. 두 번 다시는 이런 일은 내 사전에 없게 만들고 싶지만 세상은 녹록치 않더라. 

인생 2막을 거치면서 저연차에 비기너였지만 짧은 기간 내에 참 다양한 경험을 했다. 

 

 

컴퓨터 전공인데 왜 대학원 때 spatial database를 선택했냐. 매우 특이하다. 는 말을 많이 들었다. 

지금도 많이 듣는다. 공간정보학 전공이 컴퓨터를 배워서 개발하는 경우는 있지만 역의 경우는 거의 드물다고. 

나도 학석 둘다 같은 컴퓨터공학을 나왔고, 석사는 데이터 과학과 엔지니어링을 반반 섞은 듯한 느낌이다. 

(다시 한번 말하지만 석사'도' 컴퓨터공학이다. 연구실 이름에 낚이지 말라.)

 

본론으로 돌아가서 내가 왜 GIS를 선택했냐 질문을 받을 때 늘 대답하는 말이 있다. 

'사람의 삶은 공간이 규정한다. 삶과 공간의 개념은 떼어놓을 수 없다.'

 

현재진행형, 인생 3막.

인생 3막, 20대 후반부터 현재 진행중. 

공간정보 관련 일을 할 수 있다면 어디던 상관없다고 생각했다.

내가 더 성장할 수 있는 곳, 그리고 내가 성장함이 곧 회사의 성장을 의미하는 곳. 

내가 회사에 어떤 기여를 할 수 있는지 치열하게 생각할 테니 회사도 내게 성장의 기회를 달라. 

그렇게 해서 대전의 위성영상 관련 스타트업에 취업하게 되었다. 

완전히 새로운 곳. 

심지어 대전은 대학교때 딱 한번 가보고 두번째였다. 

작년 여름부터 합류해서 지금 겨울까지 거진 1년 반이 다 되어간다. 

원래 하던 C++와 Javascript를 뒤로 하고 Python을 집어드니 참 어색했다. 

새 오피스, 밝은 톤의 회사 오피스(대부분 하얀색), 새 동료, 새 일들. 

처음에 닥친 일도 '내가 할 수 있을까?' 수없이 고민하고 밤을 지새웠는데 결국 해냈다. 

그 과정에서 느낀 것은, 

좋은 엔지니어는 어떤 조건을 갖춰야 하는가?

예전 같았으면 뭐가 됬던 고객이 원하는 기능을 꾸역 꾸역 개발하는 엔지니어가 능력있는 게 아닐까? 생각했지만

지금은 조금 바뀌었다. 

 

1. (고객과) 소통할 줄 알아야 한다.
2. 요구사항을 현재 상황과 자원, 고객의 뜻에 따라 구현할 수 있도록 정의할 수 있어야 한다.
3. 주어진 자원과 현재 상황 내에서 빠르게 서비스를 제공할 수 있게 설계 및 구현해야 한다. (최신 기술 도입이 전부는 아니다!)
4. 숲도 중요하다. 나무도 중요하다. 헷갈린다면 다시 숲을 바라보자. 전체 그림을 잘 잡자.

특히 3번에 대해 많이 배웠다. 

이전에는 다루던 데이터 용량이 크지 않았는데, 이제는 제약이 걸리면서 어떻게 하면 효율적으로 처리할 수 있는지 머리를 굴리면서

동시에 현 가용 자원으로 어떻게 고객이 원하는 바를 데드라인까지 수행하는지를 예전보다 많이 고려하게 되었다. 

화려한 기술이 아니더라도.

 

그래서 지금은 대전에서 GIS 데이터 엔지니어로 vector, raster 데이터 다루면서 살고 있다. 

데이터 전처리, 2D 데이터 기반 공간 연산, 공간 데이터 분석 및 시각화까지. 

 

Q3.  다시 블로그로 돌아온 이유는?

A. 여태까지 한 거 정리 싹 다 하고, 이제는 좀 나누려고. 

어느정도 익숙해지니 기술적으로 한 단계 더 나아가고 싶었다.

그래서 내부에만 두던 메뉴얼을 GIS 엔지니어가 아닌 일반 개발자, 엔지니어가 봐도 이해가 잘 되게끔 정리 하려고 한다. 

글이 뭉텅이로 올라온다면 전에 쓴 메뉴얼을 재조립했을 가능성이 있다. 

메뉴얼은 내가 다음에 같은 일을 할 때, 내가 이전에 고려했던 선택지와 당시 상황을 돌아보고 빠른 판단을 내리기 위해 작성하고 있다. 

이 블로그를 찾는 사람들이 내 메뉴얼, 혹은 기술문서들을 읽고 자신에게 맞는 선택지를 찾을 수 있길 바란다. 

'Develope' 카테고리의 다른 글

[Javascript] 빈 문자열과 조건문  (0) 2022.01.24

+ Recent posts