Spaces:
Sleeping
Sleeping
File size: 19,684 Bytes
131589b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 | """모두의 빛길 — 광주 공공데이터 Excel 로더.
`data/광주공공데이터_2026-05-04.xlsx` (13시트) → GraphData 변환.
채울 수 있는 노드
----------------
✅ VenueNode : 광주 관광명소 487개 (단, 좌표는 별도 지오코딩 필요)
✅ TransitNode : 도시철도 1호선 정거장 (이름은 운행데이터에서, 좌표는 하드코딩)
✅ AmenityNode : 공공화장실 + 무더위쉼터 + CCTV + 가로등 (수만 개)
✅ HazardNode : 점자블록·턱낮춤·조명 미비 횡단보도
채울 수 없는 노드 (별도 수집 필요)
--------------------------------
❌ EventNode : 공연·전시 데이터 — 광주문화재단 API / 문화포털 API
❌ 정확한 HazardNode (사고다발) — 도로교통공단 보행자/노인 사고다발지점
❌ VenueNode 좌표 — Kakao/Naver 지오코딩 API로 시설명 → 좌표 변환
학습 단계에 추가로 필요
---------------------
- (질문, 정답 시설) 라벨 페어 ~500개 (수동 라벨링)
- 시나리오 페이지 130개 발화 BIO 태깅 (수동)
"""
from __future__ import annotations
import os
from typing import List, Optional, Tuple, Dict
from .data_schema import (
GraphData, VenueNode, EventNode, TransitNode, AmenityNode, HazardNode,
)
# ============================================================
# 광주 도시철도 1호선 19개역 좌표 (운영데이터에 좌표가 없어 하드코딩)
# 출처: 위키피디아 + 다음맵 검증
# ============================================================
GWANGJU_LINE1_COORDS: Dict[str, Tuple[float, float]] = {
"녹동": (35.1182, 126.9479),
"소태": (35.1257, 126.9387),
"학동": (35.1364, 126.9269),
"학동·증심사입구": (35.1364, 126.9269),
"증심사입구": (35.1364, 126.9269),
"남광주": (35.1417, 126.9209),
"문화전당": (35.1473, 126.9181),
"금남로4가": (35.1499, 126.9136),
"금남로5가": (35.1521, 126.9094),
"양동시장": (35.1497, 126.9027),
"돌고개": (35.1500, 126.8978),
"농성": (35.1511, 126.8866),
"화정": (35.1520, 126.8769),
"쌍촌": (35.1532, 126.8676),
"운천": (35.1535, 126.8554),
"상무": (35.1571, 126.8455),
"김대중컨벤션센터": (35.1577, 126.8408),
"공항": (35.1262, 126.8085),
"송정공원": (35.1448, 126.8050),
"광주송정역": (35.1399, 126.7916),
"도산": (35.1422, 126.8000),
"평동": (35.1399, 126.7800),
}
# 광주 중심 (관광명소 좌표 임시 부여용 — 지오코딩 전 fallback)
GWANGJU_CENTER = (35.1595, 126.8526)
# ============================================================
# 메인 로더
# ============================================================
def load_gwangju_excel(
path: str,
streetlight_sample_rate: int = 50,
skip_streetlights: bool = False,
venue_geocoder: Optional[callable] = None,
) -> GraphData:
"""엑셀 파일 → GraphData.
파라미터
--------
path : 엑셀 경로
streetlight_sample_rate : 가로등 N개당 1개만 샘플링 (22000개 전부는 너무 많음)
skip_streetlights : True면 가로등 제외 (테스트용)
venue_geocoder : 시설명 → (lat, lng) 함수. None이면 광주 중심으로 임시 부여.
예: lambda name: kakao_geocode(name + " 광주")
"""
try:
import openpyxl
except ImportError as e:
raise ImportError(
"openpyxl이 필요합니다: pip install openpyxl"
) from e
if not os.path.exists(path):
raise FileNotFoundError(f"엑셀 파일을 찾을 수 없습니다: {path}")
print(f"[load_gwangju_excel] 파일 열기: {path}")
wb = openpyxl.load_workbook(path, data_only=True, read_only=True)
venues = _load_venues(wb, geocoder=venue_geocoder)
transits = _load_transits(wb)
amenities = _load_amenities(
wb,
streetlight_sample_rate=streetlight_sample_rate,
skip_streetlights=skip_streetlights,
)
hazards = _load_hazards(wb)
gdata = GraphData(
venues=venues, events=[], # ⚠️ EventNode는 별도 API에서 수집
transits=transits, amenities=amenities, hazards=hazards,
)
print(f"[load_gwangju_excel] 완료. 노드 수: {gdata.stats()}")
print(f" ⚠️ EventNode 0개 (공연·전시 데이터는 광주문화재단 API에서 별도 수집)")
print(f" ⚠️ VenueNode 좌표 — venue_geocoder가 None이면 광주 중심으로 임시 부여됨")
return gdata
# ============================================================
# 1. VenueNode — 관광명소
# ============================================================
def _load_venues(wb, geocoder: Optional[callable] = None) -> List[VenueNode]:
"""GJ_Tourism_Attractions 시트 → VenueNode 리스트.
헤더: 연번 | 구분 | 세부구분 | 시설명 | 시설명영문명 | 데이터기준일자
좌표가 없어 (1) geocoder 함수가 있으면 그걸로 변환,
(2) 없으면 광주 중심으로 임시 부여.
"""
if "GJ_Tourism_Attractions" not in wb.sheetnames:
return []
ws = wb["GJ_Tourism_Attractions"]
venues: List[VenueNode] = []
for i, row in enumerate(ws.iter_rows(min_row=2, values_only=True)):
if not row or len(row) < 4:
continue
cat, sub, name = row[1], row[2], row[3]
if not name:
continue
venue_type = _map_venue_type(str(cat or ""), str(sub or ""))
# 좌표 결정
if geocoder is not None:
try:
lat, lng = geocoder(str(name))
except Exception:
lat, lng = GWANGJU_CENTER
else:
lat, lng = GWANGJU_CENTER # ⚠️ 임시. 실사용 시 지오코딩 필수
# 카테고리에서 indoor/free 추정
indoor = any(kw in str(sub or "") for kw in ["박물관", "미술관", "도서관", "공연장", "실내"])
free = any(kw in str(sub or "") for kw in ["공원", "광장"]) # 무료가 명시된 경우
venues.append(VenueNode(
id=i,
name=str(name).strip(),
lat=lat, lng=lng,
venue_type=venue_type,
accessibility_score=0.5, # 별도 데이터로 보정 필요
indoor=indoor,
free=free,
))
return venues
def _map_venue_type(cat: str, sub: str) -> str:
"""관광명소 카테고리 → VenueNode.venue_type."""
if "박물관" in sub or "박물관" in cat: return "박물관"
if "미술관" in sub or "갤러리" in sub: return "미술관"
if "도서관" in sub: return "도서관"
if "공연" in sub or "극장" in sub: return "공연장"
if "체험" in sub: return "체험관"
if "카페" in sub or "식당" in sub: return "카페"
if "문화" in cat or "문화" in sub: return "문화센터"
if "공원" in sub or "광장" in sub: return "공원"
return "기타"
# ============================================================
# 2. TransitNode — 도시철도 1호선
# ============================================================
def _load_transits(wb) -> List[TransitNode]:
"""N_Urban_Railway_Operation_Stand 시트의 운행구간정거장에서 정거장명 추출.
좌표는 GWANGJU_LINE1_COORDS 딕셔너리에서 룩업.
버스 정류장은 본 엑셀에 없으므로 추후 별도 데이터 추가 필요.
"""
if "N_Urban_Railway_Operation_Stand" not in wb.sheetnames:
return []
ws = wb["N_Urban_Railway_Operation_Stand"]
station_set = set()
for row in ws.iter_rows(min_row=2, values_only=True):
if not row or len(row) < 8:
continue
stations_field = row[7] # 운행구간정거장
if not stations_field:
continue
for token in str(stations_field).split(","):
token = token.strip()
if not token:
continue
# "녹동-001" → "녹동"
name = token.split("-")[0].strip()
if name:
station_set.add(name)
transits: List[TransitNode] = []
for i, name in enumerate(sorted(station_set)):
if name not in GWANGJU_LINE1_COORDS:
continue # 알 수 없는 역명 (오기 등) 스킵
lat, lng = GWANGJU_LINE1_COORDS[name]
transits.append(TransitNode(
id=i, name=name, lat=lat, lng=lng,
transit_type="지하철",
has_elevator=True, # 광주 1호선은 전 역 엘리베이터 보유
has_toilet=True,
line="1호선",
))
return transits
# ============================================================
# 3. AmenityNode — 화장실/쉼터/CCTV/가로등
# ============================================================
def _load_amenities(
wb,
streetlight_sample_rate: int = 50,
skip_streetlights: bool = False,
) -> List[AmenityNode]:
amenities: List[AmenityNode] = []
next_id = [0] # nonlocal 대신 list로 카운터
def _add(name: str, lat: float, lng: float, atype: str,
accessible: bool = False, open_24h: bool = False):
amenities.append(AmenityNode(
id=next_id[0], name=name, lat=lat, lng=lng,
amenity_type=atype, accessible=accessible, open_24h=open_24h,
))
next_id[0] += 1
# ---- 1) 공공화장실 ----
if "N_Public_Toilet_Standard_GJMetr" in wb.sheetnames:
ws = wb["N_Public_Toilet_Standard_GJMetr"]
# 헤더: 구분, 화장실명, ..., 위도(18), 경도(19), ..., 개방시간(16), 남성장애인대변기수(7), 여성장애인대변기수(12)
for row in ws.iter_rows(min_row=2, values_only=True):
if not row or len(row) < 20:
continue
try:
name = str(row[1] or "공공화장실").strip()
lat = _safe_float(row[18])
lng = _safe_float(row[19])
male_disabled = _safe_int(row[7])
fem_disabled = _safe_int(row[12])
hours = str(row[16] or "")
except (TypeError, ValueError, IndexError):
continue
if lat is None or lng is None:
continue
disabled = (male_disabled > 0) or (fem_disabled > 0)
open_24 = "24" in hours or "00:00~24:00" in hours
_add(name, lat, lng,
atype="장애인화장실" if disabled else "공공화장실",
accessible=disabled, open_24h=open_24)
# ---- 2) 무더위쉼터 ----
if "N_Heat_Shelter_Standard_GJMetro" in wb.sheetnames:
ws = wb["N_Heat_Shelter_Standard_GJMetro"]
# 헤더: 쉼터명(0), ..., 위도(17), 경도(18)
for row in ws.iter_rows(min_row=2, values_only=True):
if not row or len(row) < 19:
continue
try:
name = str(row[0] or "쉼터").strip()
lat = _safe_float(row[17])
lng = _safe_float(row[18])
except (TypeError, ValueError, IndexError):
continue
if lat is None or lng is None:
continue
_add(name, lat, lng, atype="무더위쉼터", accessible=True)
# ---- 3) CCTV (표준 1137개) ----
if "N_CCTV_Standard_GJMetro" in wb.sheetnames:
ws = wb["N_CCTV_Standard_GJMetro"]
# 헤더: 관리기관명(0), ..., 위도(10), 경도(11)
for row in ws.iter_rows(min_row=2, values_only=True):
if not row or len(row) < 12:
continue
try:
lat = _safe_float(row[10])
lng = _safe_float(row[11])
except (TypeError, ValueError, IndexError):
continue
if lat is None or lng is None:
continue
_add(f"CCTV-{next_id[0]}", lat, lng, atype="CCTV")
# ---- 4) CCTV (구별 6749개 — 너무 많아 샘플링) ----
if "GJ_CCTV" in wb.sheetnames:
ws = wb["GJ_CCTV"]
# 헤더: ..., 위도(8), 경도(9)
for i, row in enumerate(ws.iter_rows(min_row=2, values_only=True)):
if i % 5 != 0: # 5개당 1개
continue
if not row or len(row) < 10:
continue
try:
lat = _safe_float(row[8])
lng = _safe_float(row[9])
except (TypeError, ValueError, IndexError):
continue
if lat is None or lng is None:
continue
_add(f"CCTV-{next_id[0]}", lat, lng, atype="CCTV")
# ---- 5) 가로등 (북구·서구 합 22000개 — 큰 폭으로 샘플링) ----
if not skip_streetlights:
if "GJ_Bukgu_Streetlight_Locations" in wb.sheetnames:
ws = wb["GJ_Bukgu_Streetlight_Locations"]
# 헤더: 등록번호, 표찰번호, 지번주소, 경도(3), 위도(4), 데이터제공일자
for i, row in enumerate(ws.iter_rows(min_row=2, values_only=True)):
if i % streetlight_sample_rate != 0:
continue
if not row or len(row) < 5:
continue
try:
lng = _safe_float(row[3])
lat = _safe_float(row[4])
except (TypeError, ValueError, IndexError):
continue
if lat is None or lng is None:
continue
_add(f"가로등-{next_id[0]}", lat, lng, atype="가로등")
if "GJ_Seogu_Streetlight_Status" in wb.sheetnames:
ws = wb["GJ_Seogu_Streetlight_Status"]
# 헤더: 표찰번호, 지번주소, 위도(2), 경도(3)
for i, row in enumerate(ws.iter_rows(min_row=2, values_only=True)):
if i % streetlight_sample_rate != 0:
continue
if not row or len(row) < 4:
continue
try:
lat = _safe_float(row[2])
lng = _safe_float(row[3])
except (TypeError, ValueError, IndexError):
continue
if lat is None or lng is None:
continue
_add(f"가로등-{next_id[0]}", lat, lng, atype="가로등")
if "GJ_Namgu_Security_Lights" in wb.sheetnames:
ws = wb["GJ_Namgu_Security_Lights"]
# 헤더: 보안등위치명(0), 설치개수, 주소, 위도(3), 경도(4)
for row in ws.iter_rows(min_row=2, values_only=True):
if not row or len(row) < 5:
continue
try:
name = str(row[0] or "보안등").strip()
lat = _safe_float(row[3])
lng = _safe_float(row[4])
except (TypeError, ValueError, IndexError):
continue
if lat is None or lng is None:
continue
_add(f"보안등-{name}", lat, lng, atype="가로등")
return amenities
# ============================================================
# 4. HazardNode — 점자/턱/조명 미비 횡단보도
# ============================================================
def _load_hazards(wb) -> List[HazardNode]:
"""N_Crosswalk_Standard_GJMetro에서 위험 횡단보도 추출.
헤더: ..., 위도(9), 경도(10), ..., 보도턱낮춤여부(20), 점자블록유무(21), 집중조명시설유무(22)
Y/N 값. 셋 다 Y면 안전 → 제외. 하나라도 N이면 위험구간으로 등록.
"""
if "N_Crosswalk_Standard_GJMetro" not in wb.sheetnames:
return []
ws = wb["N_Crosswalk_Standard_GJMetro"]
hazards: List[HazardNode] = []
next_id = 0
for row in ws.iter_rows(min_row=2, values_only=True):
if not row or len(row) < 23:
continue
try:
lat = _safe_float(row[9])
lng = _safe_float(row[10])
except (TypeError, ValueError, IndexError):
continue
if lat is None or lng is None:
continue
curb_low = _yn(row[20]) # 보도턱낮춤
tactile = _yn(row[21]) # 점자블록
bright = _yn(row[22]) # 집중조명
if curb_low and tactile and bright:
continue # 안전한 곳
severity = 0.0
types = []
if not curb_low:
severity += 0.3
types.append("턱")
if not tactile:
severity += 0.3
types.append("점자블록미비")
if not bright:
severity += 0.2
types.append("저조도")
hazards.append(HazardNode(
id=next_id,
lat=lat, lng=lng,
hazard_type="턱" if "턱" in types else ("저조도" if "저조도" in types else "공사"),
severity=min(severity, 1.0),
night_only=(not bright) and curb_low and tactile,
))
next_id += 1
return hazards
# ============================================================
# 헬퍼
# ============================================================
def _safe_float(v) -> Optional[float]:
if v is None:
return None
try:
f = float(str(v).strip())
if f == 0.0:
return None
return f
except (TypeError, ValueError):
return None
def _safe_int(v) -> int:
if v is None:
return 0
try:
return int(float(str(v).strip()))
except (TypeError, ValueError):
return 0
def _yn(v) -> bool:
if v is None:
return False
s = str(v).strip().upper()
return s == "Y"
# ============================================================
# 지오코더 어댑터 (선택) — 실사용 시 주석 해제하고 API 키 입력
# ============================================================
def make_kakao_geocoder(rest_api_key: str) -> callable:
"""카카오 로컬 API 지오코더 생성.
사용법:
from src.data_loader import load_gwangju_excel, make_kakao_geocoder
geocoder = make_kakao_geocoder("YOUR_REST_API_KEY")
gdata = load_gwangju_excel("data/광주공공데이터.xlsx", venue_geocoder=geocoder)
카카오 개발자센터에서 REST API 키 발급 필요 (무료, 일 300,000회).
"""
import urllib.parse
import urllib.request
import json
import time
cache: Dict[str, Tuple[float, float]] = {}
def geocode(query: str) -> Tuple[float, float]:
if query in cache:
return cache[query]
url = "https://dapi.kakao.com/v2/local/search/keyword.json?query=" + \
urllib.parse.quote(query)
req = urllib.request.Request(url, headers={
"Authorization": f"KakaoAK {rest_api_key}",
})
try:
with urllib.request.urlopen(req, timeout=5) as r:
data = json.loads(r.read().decode("utf-8"))
docs = data.get("documents", [])
if not docs:
raise ValueError(f"No result for: {query}")
lat = float(docs[0]["y"])
lng = float(docs[0]["x"])
cache[query] = (lat, lng)
time.sleep(0.05) # rate limit 보호
return lat, lng
except Exception:
return GWANGJU_CENTER
return geocode
__all__ = [
"load_gwangju_excel",
"make_kakao_geocoder",
"GWANGJU_LINE1_COORDS",
"GWANGJU_CENTER",
]
|