Skip to content
C.W.K.
Stream
Lesson 03 of 04 · published

Crossings From Geometry

~16 min · python, swift, geometry, kakao, walking, korean

Level 0Lost
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

A walking step that describes the road, not the turn

In Korea a walk can be routed by Kakao, and Kakao's route reaches the phone through cwkPippa's maps door, which keeps the key on the server and hands the phone a route in Pathfinder's own shape. Pathfinder's engine then guides it, so the route has to say what every guided route says: at this point on the line, this maneuver. Invariant 3 is the contract. A step's instruction is the maneuver at the end of the step, and each provider translates its own convention into NavStep.maneuverAlong before guidance ever sees it.

Kakao's walking steps do not speak that way. They describe the stretch you are about to walk. "122m 이동(세종대로20길)" means walk 122 m along that road. "루쏘소까지 횡단보도 이용" means use the crossing as far as a landmark. A side, left or right, appears in the words only at a fork: "퀸프라자 앞에서 오른쪽길로 39m 이동". So where a step begins is where something happens, and very often the words do not say what.

The clearest case was measured on a real Kakao answer: a step whose words said only "…까지 31m 이동", walk 31 m to a junction, while the line itself turned by -88° at that step's start. Left, nearly square. After a crossing, the turn onto the far pavement lives in the geometry alone. A voice that read only Kakao's words would say nothing at that corner, and a person who missed it would walk on.

Words first, then the line

kakao_walk_words in cwkPippa's pathfinder_maps.py rebuilds the maneuver at the start of each step. It parses Kakao's words with one grammar: an optional where-clause ("세종대로사거리에서 "), an optional destination ("…까지"), an optional fork, and what the stretch is ("31m 이동", "횡단보도 이용", "역사 내 이동", stairs or a footbridge "… 이용"). If the words name a fork, that is the side. If they do not, the side comes from the line: turn_at measures how far the route turns at the point where the step begins.

turn_at looks 10 m back and 10 m ahead from that point and compares the two bearings, so a jog in the digitizing a few metres long is not read as a turn. Its answer is signed: left is negative, right is positive, wrapped into -180…180. Then the thresholds decide. A turn between 45° and 150° gets a side. Under 45° the path is bending, not turning. Over 150° the line is doubling back on itself, and a reversal is not a side to turn to.

Kakao's stepTurn at its startWhat Pippa's route says
a crossing-91°왼쪽 횡단보도 건너기 (turnLeft)
a crossing, with a where-clause88°…에서 오른쪽 횡단보도 건너기 (turnRight)
a crossing3°…에서 횡단보도 건너기 (straight)
a stretch on a named road80°{road}(으)로 우회전
a stretch on a named road0°nothing: the walk just goes on

Those rows are the server's own test table. Stairs, a footbridge, an underpass or a lift keep Kakao's words exactly, because nothing better can be said about them. A station exit becomes "3번 출구로 나가서 …". Inside a station and at its exit the line is only a sketch, so there the side comes from Kakao's words or not at all. And nothing is said at the route's first point: the phone speaks a maneuver only when there is a way to it.

The crossing that turned left by itself

The first version of turn_at looked its 10 m ahead wherever that led. A second read of the code found the flaw. A crossing shorter than 10 m has its own corner at each end. Reading 10 m ahead from the near kerb went past the far kerb and picked up the next corner's turn, and a crossing straight ahead came out as "왼쪽 횡단보도", a left crossing. The fix gives turn_at a floor and a ceiling: it never looks past the neighbouring steps' starts. Each step's turn is measured inside its own step.

From the server's words to Pippa's voice

Most of the words the server writes come from a closed set. On the phone, phrases.ko.json maps each to how Pippa says it: "왼쪽 횡단보도 건너기" becomes "왼쪽 횡단보도 건너", in her 반말, as one cached chunk, and "계단 이용" becomes "계단으로 가". The set has one open edge, and it is the pass-through above: a wording the server keeps verbatim reaches the phone whether or not the phrase book knows it. "육교 이용" has no entry, so the voice synthesizes "육교" and "이용" fresh, word by word. "엘리베이터 또는 계단 이용" gets its cached "계단으로 가" ending but synthesizes "엘리베이터 또는" fresh. So a road or landmark name is synthesized fresh, and so is any Kakao wording for stairs, a footbridge, an underpass or a lift that the phrase book has not learned yet. The server also attaches a kind to every maneuver (turnLeft, turnRight, straight, other), so the arrow on the banner and the tap on the wrist agree with the words.

The architecture records the limit honestly: turns are read only at step boundaries, so a bend inside one step is not announced. Kakao's step boundaries usually fall at junctions and crossings, where a walker needs them most, but a long step through a park that curves is walked in silence.

When the words are silent, the shape still speaks. A provider's text describes what its authors thought worth saying. The geometry describes what actually happens. Read the words first, because they carry intent; read the line where they are silent, and bound every reading to the piece it belongs to.

Code

cwkPippa, backend/services/pathfinder_maps.py: the side from the words, then from the line (verbatim excerpt of kakao_walk_words)·python
    fork = body["fork"] if body else ("왼쪽길로" if "왼쪽길로" in core else "오른쪽길로" if "오른쪽길로" in core else None)
    what = body["what"] if body else ""
    side = {"왼쪽길로": "left", "오른쪽길로": "right"}.get(fork)
    sketched = leaving is not None or what == "역사 내 이동"
    if (side is None and not sketched and turn is not None
            and WALK_TURN_DEGREES <= abs(turn) <= WALK_REVERSAL_DEGREES):
        side = "left" if turn < 0 else "right"
    if what.endswith("횡단보도 이용"):
        words = {"left": "왼쪽 횡단보도 건너기", "right": "오른쪽 횡단보도 건너기", None: "횡단보도 건너기"}[side]
        return where + words, {"left": "turnLeft", "right": "turnRight", None: "straight"}[side]
    if what.endswith(" 이용"):
        # Stairs, a footbridge, an underpass, a lift: Kakao's own words for it.
        return where + what, "other"
    if side is not None:
        onto = f"{road[1]}(으)로 " if road else ""
        return where + onto + ("좌회전" if side == "left" else "우회전"), "turnLeft" if side == "left" else "turnRight"
    if leaving:
        return f"{leaving['exit']}로 나가기", "other"
    return None
A minimal sketch: the turn-angle classifier in Swift, with the real 45° / 150° thresholds and the step-bounded look-ahead (compiled with swiftc and run)·swift
import Foundation

// A minimal sketch of reading a walking turn from the route's own line.
// Thresholds are the server's: WALK_TURN_DEGREES = 45, WALK_REVERSAL_DEGREES = 150.
struct Point { var lat: Double; var lon: Double }

func distance(_ a: Point, _ b: Point) -> Double {
  let la = a.lat * .pi / 180, lb = b.lat * .pi / 180
  let dl = (b.lon - a.lon) * .pi / 180
  let h = pow(sin((lb - la) / 2), 2) + cos(la) * cos(lb) * pow(sin(dl / 2), 2)
  return 6_371_000 * 2 * asin(min(1, h.squareRoot()))
}

func bearing(_ a: Point, _ b: Point) -> Double {
  let la = a.lat * .pi / 180, lb = b.lat * .pi / 180
  let dl = (b.lon - a.lon) * .pi / 180
  let y = sin(dl) * cos(lb)
  let x = cos(la) * sin(lb) - sin(la) * cos(lb) * cos(dl)
  let deg = atan2(y, x) * 180 / .pi
  return (deg + 360).truncatingRemainder(dividingBy: 360)
}

/// Degrees the line turns at points[index]: left negative, right positive.
/// Looks `reach` metres back and ahead, never past `floor` / `ceiling`
/// (the neighbouring steps' starts).
func turnAt(_ points: [Point], _ index: Int, reach: Double = 10, floor: Int = 0, ceiling: Int? = nil) -> Double? {
  guard index > 0, index < points.count - 1 else { return nil }
  let top = min(ceiling ?? points.count - 1, points.count - 1)
  var back = index - 1
  while back > max(0, floor), distance(points[back], points[index]) < reach { back -= 1 }
  var ahead = index + 1
  while ahead < top, distance(points[index], points[ahead]) < reach { ahead += 1 }
  guard distance(points[back], points[index]) >= 0.5, distance(points[index], points[ahead]) >= 0.5 else { return nil }
  let change = bearing(points[index], points[ahead]) - bearing(points[back], points[index])
  return (change + 540).truncatingRemainder(dividingBy: 360) - 180
}

enum Side: String { case left, right }

/// A side only for a real turn: under 45 degrees is the path bending,
/// over 150 is the line doubling back, and neither is a side to turn to.
func side(for turn: Double?) -> Side? {
  guard let turn, (45.0...150.0).contains(abs(turn)) else { return nil }
  return turn < 0 ? .left : .right
}

func crossingWords(_ side: Side?) -> String {
  switch side {
  case .left: return "왼쪽 횡단보도 건너기"
  case .right: return "오른쪽 횡단보도 건너기"
  case nil: return "횡단보도 건너기"
  }
}

// Synthetic metre offsets around a public landmark (Seoul City Hall).
let origin = Point(lat: 37.5663, lon: 126.9779)
func at(east: Double, north: Double) -> Point {
  Point(lat: origin.lat + north / 111_320,
        lon: origin.lon + east / (111_320 * cos(origin.lat * .pi / 180)))
}

// Walking east 60 m, then the crossing heads north (to the left).
let eastThenLeft = [at(east: 0, north: 0), at(east: 30, north: 0), at(east: 60, north: 0),
                    at(east: 60, north: 15), at(east: 60, north: 30)]
// Walking east, the crossing straight ahead.
let straightOn = [at(east: 0, north: 0), at(east: 30, north: 0), at(east: 60, north: 0),
                  at(east: 75, north: 0), at(east: 90, north: 0)]
// A digitizing jog of a few metres.
let jog = [at(east: 0, north: 0), at(east: 30, north: 0), at(east: 60, north: 0),
           at(east: 75, north: 4), at(east: 90, north: 4)]
// The line doubles back on itself.
let reversal = [at(east: 0, north: 0), at(east: 30, north: 0), at(east: 60, north: 0),
                at(east: 45, north: 2), at(east: 30, north: 2)]

for (name, line) in [("left turn", eastThenLeft), ("straight", straightOn), ("jog", jog), ("doubles back", reversal)] {
  let turn = turnAt(line, 2)
  let s = side(for: turn)
  print(name, String(format: "%.0f°", turn ?? .nan), s?.rawValue ?? "none", "->", crossingWords(s))
}

// A 6 m crossing straight ahead, then a left turn on the far kerb. The steps
// start at index 2 (the crossing) and index 3 (the far kerb).
let shortCrossing = [at(east: 0, north: 0), at(east: 30, north: 0), at(east: 60, north: 0),
                     at(east: 66, north: 0), at(east: 66, north: 20)]
let unbounded = turnAt(shortCrossing, 2)
let bounded = turnAt(shortCrossing, 2, floor: 0, ceiling: 3)
print("short crossing, reading past the next step:", String(format: "%.0f°", unbounded ?? .nan), "->", crossingWords(side(for: unbounded)))
print("short crossing, stopping at the next step: ", String(format: "%.0f°", bounded ?? .nan), "->", crossingWords(side(for: bounded)))

// Output:
// left turn -90° left -> 왼쪽 횡단보도 건너기
// straight 0° none -> 횡단보도 건너기
// jog -15° none -> 횡단보도 건너기
// doubles back -172° none -> 횡단보도 건너기
// short crossing, reading past the next step: -73° -> 왼쪽 횡단보도 건너기
// short crossing, stopping at the next step:  0° -> 횡단보도 건너기

External links

Exercise

Record a short walk of your own as a GPX track (most fitness apps export one), or draw a polyline by hand with five or six corners. Run the sketch's turnAt over every vertex with a 10 m reach and print the signed turn. Then mark by hand where a person would actually turn, and compare. Which vertices did the 45°/150° band classify differently from your judgement, and was the band or your digitizing at fault?
Hint
Raw GPS tracks wobble far more than a routed line, so expect many small turns under 45° and a few false sharp ones at stops. Try raising the reach to 20 m and watch which false turns disappear and whether any real corner disappears with them.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.