~17 min · open-data, provenance, spatial-index, geometry, swift
Level 0길 잃음
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
공공 데이터셋, 읽는 도구는 하나
단속 카메라 경고는 Pathfinder가 직접 들고 다니는 유일한 도로 지식이야. 출처는 공개 데이터야. 경찰청이 내는 전국무인교통단속카메라표준데이터, data.go.kr의 데이터셋 15028200. 포털 설명으로는 고정식 카메라만 담고 이동식은 빼. 속도, 신호, 통행위반, 기타는 경찰청이, 버스전용차로랑 불법주정차는 지방자치단체가 넣고, 갱신은 반기마다야.
앱은 그 API를 절대 안 불러. 앱에 번들되는 카메라 파일을 쓰는 건 PathfinderKit 안의 실행 파일 pathfinder-cameras 하나뿐이야. fetch는 환경 변수나 cwkPippa의 env 파일에서 키를 읽어서 API를 천 행씩 넘기며 받아 오고, 압축한 번들을 쓰고, 표를 하나 찍어. verify는 저장해 둔 원본에서 같은 표를 찍고. 앱에는 키가 안 실려. 갱신은 fetch 한 번에 새 빌드 하나야.
왜 작성자가 하나뿐이야?
데이터셋은 카메라가 뭘 단속하는지 regltSe라는 코드로 적어. 그리고 Pathfinder가 그 코드에 붙인 뜻은 인용이 아니라 추론이야. 전체 데이터가 자기 숫자로 답해 줬어. 1번이랑 2번은 실제 제한속도가 붙어 있어(과속, 그리고 대부분 속도도 같이 잡는 신호 카메라). 4번은 대부분 제한속도가 0이라 주정차야. 3번은 제한속도 0에 버스 정류장에 서 있으니 버스전용차로고. 99번엔 제한속도 100~110이 붙은 고속도로 구간 단속 카메라가 실려 있어. 버스전용차로랑 주정차는 운전과 상관없으니 빼.
추론은 소리 없이 낡을 수 있어. 경찰청이 코드 번호를 바꾸면, 손으로 고친 JSON이나 두 번째 가져오기 스크립트는 옛 뜻을 조용히 끌고 가. 그래서 파일을 쓰는 도구가 추론을 다시 확인하는 도구이기도 해. fetch 할 때마다 코드별 제한속도 표를 찍으니까, 뜻이 바뀌면 이름표랑 안 맞는 행으로 드러나. 작성자가 하나라는 건, 그 확인을 건너뛸 수 없는 자리가 하나라는 뜻이야.
원본이 뒤섞은 것도 같은 작성자가 정리해. 단속 구간 길이는 대부분 킬로미터로, 일부는 미터로 와. 그래서 100 미만은 킬로미터로 읽어. 한국에 길이 100킬로짜리 구간 단속은 없고, 100미터 미만짜리도 없으니까. 한국의 위도·경도 범위를 벗어난 행은 버려. 관리번호는 기관끼리, 심지어 한 기관 안에서도 겹쳐. 그래서 카메라 id는 기관 코드에 번호를 붙이고, 겹치면 꼬리 번호를 하나 더 달아.
지금 실린 데이터는 43,724행에서 왔어. 그중 운전과 상관있는 32,184개가 번들에 들어갔고, 그 가운데 1,100개가 구간 단속 카메라야. 구간이 시작하는 자리 551개, 끝나는 자리 549개.
문서는 약속하고 데이터엔 없는 출처 정보
불변식 8번은 번들이 출처, 데이터셋 수정일, 받아 온 시각, 행별 기준일자의 범위를 밝힌다고 해. 모델도 맞장구를 쳐. CameraDataset엔 datasetModified 필드가 있어. 근데 번들을 만드는 코드는 매번 datasetModified: nil을 넘기고, 인코더는 nil인 옵셔널을 빼고 써. 그래서 실린 헤더엔 출처, 받아 온 시각, 제일 새 기준일자와 제일 오래된 기준일자만 있고 데이터셋 수정일은 아예 없어. 포털 페이지엔 수정일이 떡하니 보이는데, 도구가 그걸 안 물어볼 뿐이야.
가르칠 건 코드야. 그리고 그 코드가 품은 교훈도 챙겨. 유일한 작성자가 늘 nil로 채우는 출처 필드는 문서 속 약속이지, 데이터 속 사실이 아니야. 고칠 날이 오면 고칠 자리는 작성자고, 작성자가 하나인 이유가 바로 그거야.
이 길 위에 있는 카메라는 뭐야?
CameraIndex는 카메라를 0.01도짜리 격자 칸에 나눠 담아. 한국에선 대략 동서로 0.9킬로, 남북으로 1.1킬로인 칸이야. 그래서 "이 길 근처에 뭐 있어?"를 물을 때 나라 전체를 훑지 않아. 경로를 400미터마다 표본으로 찍고, 표본이 든 칸과 둘레 여덟 칸의 카메라를 모아. 그다음 후보마다, 그 카메라를 본 첫 표본 1,000미터 앞부터 마지막 표본 1,000미터 뒤까지의 구간에만 투영해. 후보를 경로 전체에 투영하던 시절엔, 경로를 고르거나 미리 볼 때마다 긴 경로 하나에 선분 검사가 수천만 번씩 메인 액터에서 돌았어.
카메라는 경로에서 30미터 안, 그것도 주행 방향 쪽에 떨어질 때만 쳐. 데이터셋의 방향 코드는 기하 정보가 아니라서 규칙이 그 자리를 대신해. 한국은 우측 통행이니까 이 방향 카메라는 선의 오른쪽이나 머리 위에 서 있고, 왼쪽으로 10미터 넘게 떨어진 카메라는 반대편 차로 거야. 어느 쪽인지는 부호가 붙은 횡방향 거리로 재. 진행 방향 오른쪽이 양수야.
피파가 뭐라고, 어디서 말해?
CameraWatch는 두 방식으로 경고해. 안내 중인 운전에선 경로 위에 카메라를 한 번 올려놓고 경로를 따라간 거리로 경고해. 경로 없이 도는 카메라 주행 모드에선 차 진행 방향 앞 25도 부채꼴, 진행선에서 35미터 이내, 초속 5미터 이상일 때만 봐. 첫 경고는 500미터에서, 초속 19.4미터(시속 70킬로쯤) 이상이면 800미터에서 나와. "500미터 앞에 과속 단속 카메라 제한속도 60". 200미터 안에서 제한속도보다 시속 3킬로 넘게 빠르면 긴급 줄 "아빠, 속도 줄여"가 뒤따라. 카메라 하나당 단계마다 한 번만 경고해. 구간 단속 카메라는 단속이 시작하고 끝나는 자리를 말해. "구간 단속 시작", "구간 단속 끝".
걷는 길엔 카메라 경고가 없고, 구글이 안내하는 여정에도 없어. 구글은 자기 지도에 자기 제한속도를 띄우니까. 그리고 #401 이후로 카메라 주행 모드는 한국 전용이야. 버튼은 폰의 실제 위치가 대한민국 안일 때만 보여. 지도 가운데나 시뮬레이션 위치로는 절대 안 정해. 첫 위치를 잡기 전에만 앱이 정한 지역을 참고하고, 이미 돌고 있는 주행은 언제든 끌 수 있게 남겨 둬. 예전 버튼은 생김새가 대화용 카메라 같았고, 세비야에서는 "한국 전용"이라고만 대답했어. 그냥 소음이었지. 지금 아이콘은 속도계야.
추론한 뜻에는 쓸 때마다 다시 증명하는 작성자 하나가 필요해. 읽는 게 아니라 해석하는 데이터는 발밑에서 뜻이 슬쩍 바뀔 수 있어. 해석, 그 근거, 결과물을 도구 하나에 몰아넣고 근거를 매번 찍게 해. 그래야 뜻이 바뀐 걸 출시해 버리는 게 아니라 눈으로 보게 돼.
Code
cwkPathfinder, PathfinderKit/Sources/PathfinderKit/Cameras/PoliceCameraRow.swift: CameraDataset.build, datasetModified가 늘 nil인 자리 (원문 그대로, 81-104행)·swift
extension CameraDataset {
public static let policeSource = "National Police Agency standard dataset 15028200 (data.go.kr)"
/// The bundled dataset from a full pull: driving-relevant cameras only,
/// each with an id unique across providers (the management number repeats
/// between and even within institutions).
public static func build(rows: [PoliceCameraRow], fetchedAt: String) -> CameraDataset {
var seen: [String: Int] = [:]
var cameras: [SpeedCamera] = []
for row in rows {
let base = [row.insttCode, row.mnlssRegltCameraManageNo].compactMap(PoliceCameraRow.text).joined(separator: "-")
let count = seen[base, default: 0]
seen[base] = count + 1
let id = count == 0 ? base : "\(base)-\(count)"
guard let camera = row.camera(id: id), camera.isDrivingRelevant else { continue }
cameras.append(camera)
}
let dates = cameras.compactMap(\.referenceDate).sorted()
return CameraDataset(
source: "\(policeSource): \(cameras.count) driving-relevant of \(rows.count) rows",
datasetModified: nil, fetchedAt: fetchedAt, newestReferenceDate: dates.last,
oldestReferenceDate: dates.first, cameras: cameras)
}
}
cwkPathfinder, PathfinderKit/Sources/PathfinderKit/Cameras/CameraIndex.swift: 통로, 주행 방향 쪽, 부호 붙은 횡거리 (원문 그대로, 94-118행)·swift
var found: [CameraOnRoute] = []
for (index, seen) in candidates {
let camera = cameras[index]
let window = (seen.lowerBound - Self.projectionReach)...(seen.upperBound + Self.projectionReach)
guard camera.isDrivingRelevant,
let match = polyline.project(camera.coordinate, within: window), match.lateral <= corridor
else { continue }
let side = Self.signedSide(of: camera.coordinate, from: match)
if side < -leftTolerance { continue }
found.append(CameraOnRoute(camera: camera, along: match.distanceAlong, lateral: match.lateral))
}
return found.sorted { $0.along < $1.along }
}
/// Signed cross-track distance: positive to the right of the direction of
/// travel, negative to the left.
static func signedSide(of point: Coordinate, from match: Polyline.Match) -> Double {
let frame = Geo.LocalFrame(origin: match.coordinate)
let (px, py) = frame.project(point)
let heading = match.bearing * .pi / 180
// Unit vector of travel (east = sin, north = cos); right-hand normal.
let rightX = cos(heading)
let rightY = -sin(heading)
return px * rightX + py * rightY
}
최소 스케치: 평면 좌표계의 가상 점으로 보는 주행 방향 규칙 (swiftc로 컴파일해서 돌렸어, 출력은 맨 아래)·swift
import Foundation
// A minimal sketch of Pathfinder's driving-side rule in a flat local frame
// (x east, y north, metres). The points are synthetic, not real cameras.
struct Point { var x: Double; var y: Double }
/// Signed cross-track distance: positive to the right of travel, negative to
/// the left (the same right-hand normal CameraIndex.signedSide uses).
func signedSide(of p: Point, from foot: Point, bearing degrees: Double) -> Double {
let heading = degrees * .pi / 180
let (px, py) = (p.x - foot.x, p.y - foot.y)
return px * cos(heading) + py * -sin(heading)
}
func verdict(_ camera: Point, foot: Point, bearing: Double,
corridor: Double = 30, leftTolerance: Double = 10) -> String {
let lateral = hypot(camera.x - foot.x, camera.y - foot.y)
guard lateral <= corridor else { return "not on this road (\(Int(lateral)) m off)" }
let side = signedSide(of: camera, from: foot, bearing: bearing)
if side < -leftTolerance { return "other carriageway (\(Int(side)) m)" }
return "on the route (side \(Int(side)) m)"
}
// A road running due north (bearing 0) along x = 0; the foot of each camera
// is the closest point on the road.
let northbound: [(String, Point)] = [
("pole on the right shoulder", Point(x: 7, y: 120)),
("gantry over the median", Point(x: -4, y: 310)),
("pole across the median", Point(x: -18, y: 540)),
("camera on a side street", Point(x: 55, y: 800)),
]
for (name, camera) in northbound {
print("north", name.padding(toLength: 28, withPad: " ", startingAt: 0),
verdict(camera, foot: Point(x: 0, y: camera.y), bearing: 0))
}
// The same pole seen from the opposite direction (bearing 180) is on the right.
let pole = Point(x: -18, y: 540)
print("south", "pole across the median".padding(toLength: 28, withPad: " ", startingAt: 0),
verdict(pole, foot: Point(x: 0, y: 540), bearing: 180))
// Output:
// north pole on the right shoulder on the route (side 7 m)
// north gantry over the median on the route (side -4 m)
// north pole across the median other carriageway (-18 m)
// north camera on a side street not on this road (55 m off)
// south pole across the median on the route (side 18 m)
네 프로젝트에서 외부 데이터로 만들어서 커밋해 둔 파일을 하나 찾아봐. 나라 목록, 가격표, 시간대 매핑, 코드 묶음 같은 거. 누가 그 파일을 쓰는지 알아내. 답이 "예전에 스크립트 한 번, 그 뒤로 손으로 조금씩"이면, 작성자 하나를 만들어. 받아 오고, 바꾸고, 파일을 쓰고, 자기가 한 해석마다 근거를 찍는 명령 하나(코드별 개수, 바꾼 단위, 버린 행). 그다음, 파일이 담고 있다고 주장하는 출처 필드가 정말 다 채워져 있는지 확인해.
Hint
지금 파일 내용과 새로 받아 온 결과를 먼저 비교해. 다른 행을 손으로 고친 자리라고 부르기 전에 심심한 원인부터 지워. 파일을 쓴 뒤로 원본이 바뀌었을 수 있고, 지금 변환기가 그 파일을 쓴 버전이 아닐 수 있고, 출력이 매번 같지 않을 수도 있어(행 순서, 타임스탬프, 숫자 표기). 저장해 둔 원본에 옛 변환기를 다시 돌려 보면 대부분 가려져. 그래도 남는 차이는 손으로 고친 자리일 가능성이 커. 하나하나가 작성자가 배워야 할 규칙이거나, 그만 반복해야 할 실수야. 늘 비어 있는 필드는 찾기도 쉽고, 한 번 보기만 하면 고치기도 쉬워.
Progress
Progress is local-only — sign in to sync across devices.