Maritime Situational Awareness ตอน 3: Fusion กับ Track Correlation ให้เรือหนึ่งลำเหลือหมุดเดียวบนแผนที่

หลอมเป้าจาก AIS เรดาร์ และกล้อง ให้เรือลำเดียวที่โผล่สี่หมุดเหลือหมุดเดียว ตั้งแต่ gating กับ association ไปจนถึงการหั่น window ตอนเป้าเป็นพัน แล้ววาด tiled map กับเป้าทั้งกองเองด้วย WebGPU

15 กันยายน 2026เวลาอ่าน 14 นาที#GIS#Software Engineer
Maritime Situational Awareness ตอน 3: Fusion กับ Track Correlation ให้เรือหนึ่งลำเหลือหมุดเดียวบนแผนที่

ฮัลโหลโหม๋ ตอนที่แล้ว เราแกะเรดาร์ชายฝั่งทั้งสาม Message Format แล้วต่อด้วยกล้อง EO/IR ที่คลิกเป้าแล้วหมุนไปเอง ส่วนตอนที่ 1 คือ Signal Chain ตั้งแต่ขา RS-232 จนได้หมุด AIS หมุดแรกขึ้นจอ ทั้งหมดยังเป็นโจทย์เดียวกับที่เล่าภาพใหญ่ไว้ใน Maritime Domain Awareness คือ "ตอนนี้ในทะเลมีอะไรอยู่บ้างวะ"

ตอนที่ 3 เป็นตอนจบของชุดเรือที่ยอมให้เราเห็น เลาค้างไว้ตอนที่แล้วว่าจะเอาเป้าทั้งหมดลงแผนที่ GIS จริงๆ ซึ่งจะทำแน่ แต่ขอสลับลำดับนิดเดียว เพราะก่อนจะวาด ต้องตอบให้ได้ก่อนอ่ะว่า จะวาดกี่หมุด ตอนนี้เลยไล่ติดกันสามเรื่อง คือ Fusion, Track Correlation แล้วค่อยลงแผนที่ (โค้ดยังเป็น Python เหมือนเดิม ส่วนก้อน Track ที่โค้ดข้างล่างอ้างถึง ผมนิยามไว้ตั้งแต่ตอนที่ 1 แล้ว)

Fusion — ทำไมเรือลำเดียวถึงโผล่สี่หมุด

แผนภาพการหลอมรวมเป้า จากหลาย sensor ปรับเวลาและพิกัดให้ตรงกัน ผ่านการ gating ด้วยระยะและเวลา แล้ว associate เป็นเป้าเดียวที่เก็บที่มาของทุกค่าไว้

ลองนึกภาพตามนะฮะ เรือประมงลำหนึ่งวิ่งอยู่หน้าอ่าว มันเปิด AIS อยู่ เรดาร์สองสถานีก็เห็นมันทั้งคู่ แล้วกล้อง EO/IR ที่เรา slew ไปเมื่อกี้ก็ล็อกมันค้างไว้พอดี เห้ย บนจอเรามีหมุดสี่หมุดกองอยู่ตรงนั้นแล้ว ห่างกันนิดเดียว — ถ้าปล่อยไว้แบบนั้น operator ที่นั่งเฝ้าจะสรุปว่ามีเรือสี่ลำ แล้วภาพสถานการณ์ทั้งหมดก็ผิดตั้งแต่บรรทัดแรก

งานทำให้สี่หมุดเหลือหมุดเดียวนี่แหละครับที่เรียกว่า Fusion ส่วนการตัดสินว่าหมุดไหนคือลำเดียวกันเรียกว่า Correlation (หรือ Association) สองคำนี้คนชอบใช้ปนกัน แต่คนละงานกันชัดๆ — Correlation คือตัดสินใจว่า "อันนี้กับอันนั้นคือลำเดียวกัน" ส่วน Fusion คือสิ่งที่ทำหลังตัดสินใจแล้ว คือหลอมค่าจากหลายแหล่งให้เป็นเป้าเดียวที่ดีกว่าเดิม ผมเคยบ่นไว้ใน Radar on the Rock ว่าคณิตศาสตร์ของมันหนักมาก ซึ่งก็ยังหนักเหมือนเดิม แต่โครงของมันเข้าใจได้ไม่ยาก

ขั้นที่ 0 — ทำให้ทุกอย่างอยู่ในหน่วยเดียวกันก่อน ข้อนี้ไม่มีคณิตศาสตร์สักตัว แต่พังบ่อยที่สุดในชีวิตจริง: เวลาเป็น UTC epoch มิลลิวินาทีทั้งหมด, พิกัดเป็น WGS84 ทั้งหมด, มุมเป็นองศา true ทั้งหมด (ไม่ใช่ relative ไม่ใช่ magnetic) และทุกเครื่องต้องเดินนาฬิกาตรงกัน ถ้ากล่องแปลงสัญญาณที่สถานีเวลาเพี้ยนไปสิบวินาที เป้าจากสถานีนั้นจะโผล่ผิดที่เสมอ แล้วเราจะนั่งแก้อัลกอริทึมทั้งวันโดยไม่มีวันเจอต้นเหตุ

ขั้นที่ 1 — ดึงทุกเป้ามาที่เวลาเดียวกัน เป้าจากคนละ sensor ไม่มีทางมาถึงพร้อมกัน ก่อนเทียบต้องพยากรณ์ตำแหน่งไปที่เวลาอ้างอิงเดียวกันก่อน วิธีง่ายสุดคือเดินตรงตาม COG/SOG ที่รู้ล่าสุด (constant velocity) ส่วนวิธีที่ดีกว่าคือใช้ Kalman filter ซึ่งให้ทั้งตำแหน่งที่พยากรณ์และ ความไม่แน่นอน ของมันมาด้วย ซึ่งจะได้ใช้ในขั้นถัดไป

ขั้นที่ 2 — หักค่าเพี้ยนประจำสถานีออกก่อน เรื่องที่คนลืมกันประจำคือ registration bias เรดาร์แต่ละตัวมักเพี้ยนเป็นระบบของมันเอง เช่น ระยะเกินคงที่นิดหน่อย หรือมุมเบี้ยวเท่าๆ กันทุกเป้า ถ้าไม่ชดเชย เรือลำเดียวกันจะปรากฏเป็นสองเป้าที่ห่างกันเท่าเดิมตลอดกาล ไม่ว่าอัลกอริทึมจะฉลาดแค่ไหนก็จับคู่ไม่ติด วิธีหาคือเก็บสถิติจากเป้าที่มั่นใจว่าลำเดียวกัน (เช่น เป้าที่ขี่ AIS อยู่) แล้วสะสมค่าเฉลี่ยความต่างของแต่ละสถานีไว้เป็นค่าชดเชย

Track Correlation — ตัดสินว่าหมุดไหนคือลำเดียวกัน

ขั้นที่ 3 — Gating คัดคู่ที่เป็นไปไม่ได้ทิ้งก่อน อย่าเพิ่งไปคำนวณอะไรแพงๆ อ่ะ ตัดด้วยกฎหยาบๆ ก่อน เช่น ห่างกันเกินเวลาที่ยอมรับได้ = ตัด ห่างกันเกินระยะที่ยอมรับได้ = ตัด ที่ละเอียดกว่านั้นคือใช้ระยะแบบ Mahalanobis ที่ถ่วงด้วยความไม่แน่นอนของแต่ละ sensor แล้วตั้งเกณฑ์จากตาราง chi-square — พูดเป็นภาษาคนคือ "เป้าจากตัวที่แม่นกว่า ต้องอยู่ใกล้กว่าถึงจะยอมให้จับคู่"

python
GATE_M = 500          # ระยะสูงสุดที่ยอมให้เป็นลำเดียวกัน (เมตร)
GATE_MS = 30_000      # อายุข้อมูลที่ยอมให้เทียบกันได้ (มิลลิวินาที)

def cost(a: Track, b: Track) -> float:
    if abs(a.t - b.t) > GATE_MS:
        return math.inf

    p = extrapolate(b, a.t)                          # ดึงมาที่เวลาเดียวกันก่อนเสมอ
    dist = haversine(a.lat, a.lon, p.lat, p.lon)
    if dist > GATE_M:
        return math.inf

    d_course = ang_diff(a.cog or 0, p.cog or 0)      # 0..180
    d_speed = abs((a.sog or 0) - (p.sog or 0))

    # ถ่วงน้ำหนักให้ตำแหน่งสำคัญสุด แต่เข็มกับความเร็วช่วยตัดสินตอนเรือวิ่งใกล้กัน
    return dist / GATE_M + d_course / 180 + d_speed / 10

ขั้นที่ 4 — Association เลือกคู่ที่ดีที่สุดทั้งภาพ บั๊กแรกที่คนทำเรื่องนี้เจอกันทุกคนคือ ไล่จับคู่ทีละอันแบบ greedy ซึ่งพอเรือสองลำวิ่งคู่กันเข้าท่า มันจะสลับตัวกันเอง วิธีที่ถูกคือมองเป็นปัญหาจับคู่ทั้งเมทริกซ์แล้วหาผลรวมต้นทุนต่ำสุด (Global Nearest Neighbour ด้วยอัลกอริทึม Hungarian ซึ่งฝั่ง Python มี scipy.optimize.linear_sum_assignment ให้ใช้ ไม่ต้องเขียนเอง) และถ้าพื้นที่แน่นมากๆ ค่อยขยับไปเป็นพวก JPDA หรือ MHT ที่ยอมถือความเป็นไปได้หลายแบบไว้พร้อมกัน

ขั้นที่ 4.5 — พอเป้าเยอะ ของที่เคยสวยจะเริ่มอืด ตอนเทสต์ในห้องมีเป้าสามสิบตัว อะไรก็ลื่นไปหมดแหละ แต่วันจริงในอ่าวมีเป้าเป็นพัน แล้วปัญหาโผล่ทันที เพราะการเทียบทุกคู่มันคือ O(n²) — เป้าพันตัวแปลว่าราวห้าแสนคู่ต่อรอบ ถ้าต้อง extrapolate แล้วยิง haversine ทุกคู่ทุกวินาที server ยกธงขาวแน่นอน จริงๆ นะ ทางออกไม่ใช่ไปซื้อเครื่องแรงขึ้น แต่คือ อย่าไปเทียบคู่ที่ไม่มีทางเป็นลำเดียวกันตั้งแต่แรก

แผนภาพการหั่น window สำหรับ correlation — grid บนแผนที่ช่องละประมาณเท่า gate ให้เป้าเทียบเฉพาะช่องตัวเองกับ 8 ช่องรอบ หั่นตามเวลาเอาเฉพาะเป้าที่เพิ่งอัปเดต แยก sector ให้ Hungarian เป็นเมทริกซ์เล็กหลายอัน และรันเป็นรอบ tick

  • หั่นตามพื้นที่ แบ่งแผนที่เป็น grid ช่องละประมาณเท่า gate (เช่น 0.01 องศา ราวๆ 1 กิโลเมตร) แล้วเป้าแต่ละตัวเทียบเฉพาะเป้าในช่องตัวเองกับช่องข้างๆ อีก 8 ช่อง ที่เหลือไกลเกินจะเป็นลำเดียวกันอยู่แล้ว
  • หั่นตามเวลา เอาเฉพาะเป้าที่อัปเดตในช่วง N วินาทีล่าสุดเข้ารอบ ที่เหลือถือว่า coasting ไปก่อน ไม่ต้องลากมาคิดใหม่ทุกรอบ
  • แยก sector หรือแยกสถานี แล้วรัน Hungarian เป็นเมทริกซ์เล็กๆ หลายอัน ดีกว่าเมทริกซ์ยักษ์อันเดียว เพราะ Hungarian เป็น O(n³) เมทริกซ์ใหญ่ขึ้นเท่าตัวคือแพงขึ้นแปดเท่า
  • รันเป็นรอบ (tick) ทุก 1–2 วินาที ไม่ใช่คิดใหม่ทุกครั้งที่ข้อความวิ่งเข้ามา และ gate ถูกๆ อย่างเวลากับระยะต้องมาก่อน Mahalanobis เสมอ อย่าเอาของแพงขึ้นหน้า

ตัวหั่นช่องก็ไม่ได้ฉลาดอะไรนะ ประมาณนี้

python
CELL = 0.01                                   # องศา ~1 กม. ตั้งให้พอๆ กับ GATE_M

def cell_of(tr: Track):
    return (int(tr.lat / CELL), int(tr.lon / CELL))

def candidate_pairs(tracks: list[Track]):
    buckets: dict[tuple[int, int], list[Track]] = {}
    for tr in tracks:
        buckets.setdefault(cell_of(tr), []).append(tr)

    for (gy, gx), here in buckets.items():
        near = [o
                for dy in (-1, 0, 1) for dx in (-1, 0, 1)
                for o in buckets.get((gy + dy, gx + dx), ())]   # ช่องตัวเอง + 8 ช่องรอบๆ
        for a in here:
            for b in near:
                if a.id < b.id:               # กันคู่ซ้ำ และกันเทียบกับตัวเอง
                    yield a, b                # เหลือเท่านี้ค่อยส่งเข้า cost()

จากห้าแสนคู่จะเหลือหลักพันคู่ ด้วย dict ธรรมดาอันเดียว

ขั้นที่ 5 — อย่าเพิ่งรีบเชื่อ และอย่ารีบเลิกเชื่อ การจับคู่ต้องมีความหน่วง ใช้กฎแบบ "ตรงกัน M ครั้งจาก N ครั้งล่าสุดถึงจะยืนยัน" และตอนจะแยกออกจากกันก็ต้องมีเกณฑ์ที่หลวมกว่าตอนจับ (hysteresis) ไม่งั้นเป้าจะกะพริบเข้าๆ ออกๆ ทุกสองวินาที ซึ่งบนจอมันน่ารำคาญกว่าไม่มี fusion เสียอีก

ขั้นที่ 6 — เก็บที่มาของทุกค่าไว้เสมอ ข้อนี้เลาอยากให้จำที่สุดจากทั้งซีรีส์นะฮะ เป้าที่หลอมแล้วต้องไม่ใช่ก้อนเดียวที่กลืนทุกอย่างจนแยกไม่ออก แต่ต้องเก็บว่า ค่าไหนมาจากใคร

  • ตำแหน่งกับความเร็ว → เชื่อเรดาร์ (ในระยะที่มันแม่น) เพราะมันวัดจริง
  • ตัวตน ชื่อ MMSI ปลายทาง → เชื่อ AIS เพราะเรดาร์ไม่มีทางรู้
  • ชนิดเรือที่เห็นด้วยตา → มาจาก EO/IR

เหตุผลที่ต้องแยกให้ออกคือวันที่เรื่องมันน่าสนใจ มันคือวันที่ ข้อมูลสองชั้นขัดกัน เช่น เป้าเรดาร์บอกว่าของชิ้นนี้ใหญ่และวิ่งเร็ว แต่ AIS ที่ขี่อยู่บอกว่าเป็นเรือเล็กความเร็วต่ำ ถ้าระบบกลืนรวมกันไปแล้วความขัดแย้งนั้นจะหายไปเงียบๆ แต่ถ้าเก็บที่มาไว้ครบ ระบบจะยกมือขึ้นมาบอกได้ว่า "ตรงนี้แปลกนะ" — และนั่นคือประตูที่เปิดไปสู่เรื่องของตอนหน้าพอดี

Plot บนแผนที่ GIS — จอต้องบอกความจริง

หลอมเป้าเสร็จแล้วก็ถึงคิวที่ค้างไว้ตั้งแต่ตอนที่แล้ว คือเอาทุกอย่างลงแผนที่จริงๆ ส่วนนี้คนชอบคิดว่าเป็นงานตกแต่ง แต่เอาจริง เลาถือว่ามันเป็นงานวิศวกรรมพอๆ กับฝั่ง parse เพราะกฎมันมีข้อเดียวคือ จอต้องบอกความจริง รวมถึงความจริงที่ว่าเราไม่รู้

เริ่มจากการจัดชั้น ชั้นล่างสุดคือฐานแผนที่ที่ดึงจากบริการมาตรฐานเปิด (พวก WMTS/WMS หรือแผนที่เดินเรือตามมาตรฐานสากล) ด้วยเหตุผลเดิมจากตอนที่ 1 คือวันที่ต้องเปลี่ยนแหล่งแผนที่ เราอยากเปลี่ยนแค่ URL ไม่ใช่เขียนจอใหม่ ถัดขึ้นมาเป็น layer ดิบแยกตาม sensor และแยกตามสถานี ปิดเปิดอิสระ แล้วบนสุดค่อยเป็น layer ของเป้าที่หลอมแล้ว ข้อดีของการแยกแบบนี้คือวันที่สงสัยว่า fusion มั่วรึเปล่า เปิด layer ดิบดูได้ทันทีว่าของจริงที่แต่ละตัวเห็นเป็นยังไง ไม่ต้องไปงมใน log

ทีนี้มาถึงคำถามที่ต้องตอบก่อนวาดจริง คือ จะวาดด้วยอะไร ตอนทำ prototype เลาก็หยิบไลบรารีแผนที่สำเร็จรูปมาใช้เหมือนที่ทุกคนทำ เร็วจริง สิบบรรทัดได้หมุดขึ้นจอ แต่พอเอาของจริงใส่เข้าไปมันไปตายตรงที่ไลบรารีพวกนี้ส่วนใหญ่สร้าง marker หนึ่งตัวเป็นหนึ่ง DOM/SVG node เป้าหลักร้อยยังไหว แต่พอเป็นหลักพันถึงหมื่นที่ขยับใหม่ทุกวินาที เบราว์เซอร์ต้องไล่จัดของทั้งกองใหม่ทุกเฟรม ผลคือลากแผนที่ทีกลายเป็นสไลด์โชว์

แผนภาพชั้นของแผนที่เฝ้าระวัง — ชั้นล่างเป็นฐาน raster tile และ vector tile ที่นิ่งและ cache ได้ ชั้นกลางเป็น layer ข้อมูลดิบแยกต่อสถานีที่เปิดปิดได้ ชั้นบนสุดเป็นเป้าที่หลอมแล้วกับกรวยมุมมองกล้องที่วาดเป็น instanced quad บน WebGPU ใหม่ทุกเฟรม

ทางออกที่ไลบรารีพวกนี้เสนอคือ cluster เป้าที่อยู่ใกล้กันให้เหลือวงกลมตัวเลขวงเดียว หรือซ่อนเป้าตอนซูมออก กับงานทั่วไปก็ถูกของเขานะ แต่กับงานเฝ้าระวัง ใช้ไม่ได้ เพราะ operator ต้องเห็นเป้าครบทุกตัวพร้อมกันตลอดเวลา เป้าที่ถูกยุบเข้า cluster คือเป้าที่หายไปจากสายตาคนเฝ้า UX ของจอแบบนี้ไม่ใช่คำว่า "สวย" แต่คือคำว่า "เห็นครบ"

สุดท้ายจึงต้องลงมือวาดเอง และรอบนี้เลาเลือก WebGPU เป็นตัวหลัก วิธีคิดคือยกทั้งจอขึ้นไปไว้บน GPU ก้อนเดียว ทั้ง tile ฐานและเป้าที่หลอมแล้ววาดอยู่ใน render pass เดียวกัน — tile แต่ละแผ่นคือ texture ขนาด 256×256 ที่อัปโหลดขึ้นการ์ดครั้งเดียวแล้ว cache ทิ้งไว้ จากนั้นวาดเป็น quad ตาม offset/scale ของ z/x/y เฉพาะแผ่นที่อยู่ในจอ ส่วนเป้าเป็น point/quad แบบ instanced ที่อ่านจาก buffer ก้อนเดียว เขียนทับใหม่ทุก tick ไม่มี object ต่อเป้าสักตัวเหมือนเคย

ทำไมไม่ WebGL ล่ะ? เพราะ instancing กับ compute ของ WebGPU มันตรงไปตรงมากว่ามาก จองบัฟเฟอร์แล้วสั่ง draw ตามจำนวน instance ได้ตรงๆ ไม่ต้องหลอกด้วย texture trick แบบสมัย WebGL ที่ต้องยัดพิกัดเป้าลงไปใน texture แล้วให้ shader อ่านกลับออกมา (แลกกับว่าเบราว์เซอร์รองรับยังไม่ครบ ฝั่ง Chrome/Edge มาก่อน Firefox กับ Safari ทยอยตามมา เพราะงั้นโค้ดจริงต้องมีทาง fallback เป็น WebGL2 ไว้ก่อน ยิ่งห้องเฝ้าระวังหลายที่ล็อกเบราว์เซอร์รุ่นเก่าไว้ด้วย)

js
const adapter = await navigator.gpu.requestAdapter();
const device  = await adapter.requestDevice();
const ctx     = canvas.getContext("webgpu");

ctx.configure({
    device, 
    format: navigator.gpu.getPreferredCanvasFormat(), 
    alphaMode: "premultiplied" 
});

const shader = device.createShaderModule({ code: `
struct View { origin: vec2f, px: vec2f };
@group(0) @binding(0) var<uniform> view: View;
@group(0) @binding(1) var samp: sampler;
@group(0) @binding(2) var tex: texture_2d<f32>;
var<private> QUAD = array<vec2f, 6>(vec2f(0,0), vec2f(1,0), vec2f(0,1), vec2f(0,1), vec2f(1,0), vec2f(1,1));
struct VSOut { @builtin(position) pos: vec4f, @location(0) uv: vec2f };
@vertex fn vs(@builtin(vertex_index) vi: u32, @location(0) off: vec2f, @location(1) size: vec2f) -> VSOut {
  let q = QUAD[vi];
  let p = (off - view.origin + q * size) / view.px * 2.0 - 1.0;
  return VSOut(vec4f(p.x, -p.y, 0.0, 1.0), q);
}
@fragment fn fs(o: VSOut) -> @location(0) vec4f { return textureSample(tex, samp, o.uv); }
`});

const tiles = new Map();

async function tile(z, x, y) {
  const key = `${z}/${x}/${y}`;
  if (tiles.has(key)) return tiles.get(key);
  const bmp = await createImageBitmap(await (await fetch(tileUrl(z, x, y))).blob());
  const texture = device.createTexture({ size: [256, 256], format: "rgba8unorm",
    usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT });
  device.queue.copyExternalImageToTexture({ source: bmp }, { texture }, [256, 256]);
  const t = { bind: bindFor(texture), rect: rectBuffer(z, x, y) };   // rect = vertex buffer 1 instance (off/size)
  tiles.set(key, t); return t;
}

function frame() {
  device.queue.writeBuffer(targetBuf, 0, targets, 0, count * 4);  // เป้าทั้งกองก้อนเดียว อัปทุก tick
  const enc  = device.createCommandEncoder();
  const pass = enc.beginRenderPass({ colorAttachments: [{ view: ctx.getCurrentTexture().createView(),
    loadOp: "clear", storeOp: "store", clearValue: { r: 0, g: 0, b: 0, a: 1 } }] });
  pass.setPipeline(tilePipeline);
  for (const t of visibleTiles()) {            // ฐาน แผ่นละ quad
    pass.setBindGroup(0, t.bind);              // texture ของแผ่นนี้
    pass.setVertexBuffer(0, t.rect);           // off/size ของแผ่นเป็นพิกเซลจอ (1 instance)
    pass.draw(6);
  }
  pass.setPipeline(targetPipeline);            // shader คนละตัว quad เล็กๆ สีตามสถานะเป้า
  pass.setBindGroup(0, viewBind); pass.setVertexBuffer(0, targetBuf);
  pass.draw(6, count);                         // เป้าทุกตัวจบในคำสั่งเดียว instanced
  pass.end();
  device.queue.submit([enc.finish()]);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

แล้วชั้นฐานที่นิ่งๆ ทำไมต้องเป็น tiled map ล่ะ? ก็เพราะแผนที่ทั้งอ่าวที่ละเอียดพอจะซูมลงไปดูปากร่องน้ำได้ มันใหญ่เกินกว่าจะโหลดทีเดียวไหว ต้องหั่นเป็นตารางเล็กๆ ขนาด 256 หรือ 512 พิกเซล แล้วเรียกด้วยเลขสามตัวคือ z/x/y (ระดับซูม/คอลัมน์/แถว) ซูมแต่ละระดับมีชุดภาพของตัวเอง ไม่ต้องย่อขยายของเดิมจนเบลอ และไม่ว่าจะเป็น tile แบบภาพหรือแบบ geometry (เดี๋ยวแยกให้ดู) หลักการหั่นก็อันเดียวกัน

ที่มันเร็วและลื่นได้ ไม่ใช่เพราะแผ่นมันเล็กอย่างเดียว แต่เพราะที่อยู่ของมันเดาได้ล่วงหน้า รู้ z/x/y ก็ประกอบ URL ได้เองโดยไม่ต้องถามใครก่อน จอเราจึงชิง prefetch วงรอบๆ กรอบที่มองเห็นไว้ตั้งแต่ก่อนคนจะลากไปถึง แต่ละแผ่นก็เล็กพอจะโหลดขนานกันหลายเส้นพร้อมกัน และเพราะ tile แผ่นเดิมไม่เคยเปลี่ยนเนื้อใน มันจึง cache ได้ทุกชั้นตั้งแต่ browser ยัน CDN ยันดิสก์ของ server ที่สำคัญคือ server ไม่ต้องคิดอะไรตอนมีคนขอสักนิด แค่หยิบไฟล์ที่ทำไว้แล้วยื่นให้ — ข้อนี้จริงทั้งกับ raster และ vector

ความลื่นระดับนั้นมีราคาของมัน และราคาที่เราจ่ายคือการทำซ้ำกับพื้นที่เก็บของ

  • ทำซ้ำเป็นพีระมิด พื้นที่เดิมถูก render ใหม่หมดทุกระดับซูม ระดับ z มี 4 ยกกำลัง z แผ่นทั้งโลก พอรวมทุกระดับตั้งแต่ 0 ถึงระดับลึกสุด จำนวนแผ่นรวมจะราว 4/3 เท่าของระดับลึกสุดระดับเดียว
  • สี่เหลี่ยมจัตุรัสมันไม่สนใจว่าของจริงอยู่ตรงไหน เส้นฝั่งเส้นเดียวโดนหั่นไปนอนอยู่หลายสิบแผ่น ป้ายชื่อที่คร่อมขอบก็โดนตัดกลางคำ vector tile ถึงต้องเผื่อ buffer ซ้ำรอบขอบทุกแผ่นให้ client ต่อของได้เนียน ส่วนแผ่นกลางทะเลหรือกลางทะเลทรายที่ว่างเปล่า ก็ยังต้องมีที่ทางของมันอยู่ดี
  • ขนาดรวมโหดกว่าที่คิด ลองคิดคร่าวๆ ซูมระดับ 18 ทั้งโลกคือ 4^18 ≈ 6.9 หมื่นล้านแผ่น ถ้าเฉลี่ยแผ่นละสัก 15 KB ก็ราว 1 PB (petabyte) เฉพาะระดับเดียว บวกพีระมิดที่เหลืออีกราวหนึ่งในสาม ส่วน vector tile เบากว่ากันเป็นสิบเท่าในที่โล่ง แต่พอเข้าเขตเมืองที่ข้อมูลแน่นก็หนักได้ไม่แพ้กัน (ตัวเลขพวกนี้เป็นค่าประมาณให้พอเห็นภาพ ไม่ใช่ของจริงจากที่ไหน)
  • เขาถึงไม่มีใครตัดทั้งโลกลึกสุดจริงๆ ทางที่ทำกันคือตัดลึกเฉพาะพื้นที่ที่เรารับผิดชอบ อ่าวของเราลงลึกได้เต็มที่ ที่เหลือของโลกเอาไว้ตื้นๆ พอให้ซูมออกแล้วไม่โล่ง, แผ่นที่เป็นทะเลล้วนก็ข้ามไปไม่ต้องเก็บ ให้ client ถมสีแทน, หรือไม่ก็ render ตอนมีคนขอครั้งแรกแล้ว cache ไว้ให้คนถัดไป

จ่ายแพงขนาดนี้แล้วคุ้มไหม? คุ้ม เพราะสิ่งที่งานเฝ้าระวังต้องการที่สุดไม่ใช่แผนที่ที่สวยที่สุดหรือประหยัดดิสก์ที่สุด แต่คือ "ลากแล้วไม่สะดุด" ตอนตีสาม จังหวะที่คนเฝ้าจอกำลังไล่ตามเรืออยู่ลำนึง ดิสก์หรือพื้นที่เก็บ ซื้อเพิ่มได้ ส่วนวินาทีที่เสียไปตอนนั้นซื้อคืนไม่ได้

กลับมาที่ tile สองแบบที่ค้างไว้ raster คือภาพที่ render มาเสร็จแล้ว (ภาพถ่ายดาวเทียม หรือแผนที่เดินเรือที่สแกนมา) วาดไวมาก client แทบไม่ต้องคิด แต่เปลี่ยนสไตล์ไม่ได้ และพอหมุนแผนที่ ตัวหนังสือที่ฝังอยู่ในภาพก็หมุนตามจนอ่านไม่ออก ส่วน vector ส่งมาเป็น geometry กับ attribute แล้วให้ client render เอง สลับธีมกลางวัน/กลางคืนได้ หมุนตามหัวเรือแล้วป้ายยังตั้งตรง แถมคลิกวัตถุได้ แลกกับการกิน CPU/GPU ฝั่ง client หนักขึ้น เลาใช้ปนกัน — raster เป็นชั้นภาพจริง vector สำหรับชั้นที่ต้องโต้ตอบหรือเปลี่ยนสไตล์ ส่วนเป้ากับกรวยกล้องวาดเองเสมอ เพราะมันขยับทุกวินาที

ส่วนของที่ควรวาดคู่กับกล้องคือ กรวยมุมมอง (FOV cone) เราใช้มุมกล้องแบบ Perspective บนแผนที่ ชี้ตามแบริ่งปัจจุบันและกางตามค่าซูมจริง เพราะมันตอบคำถามที่ operator ถามบ่อยที่สุดสองข้อในภาพเดียว คือ "ตอนนี้กล้องมองอะไรอยู่" กับ "ภาพบนจอตรงกับเป้าไหน" และเวลาสั่ง slew-to-cue ให้บันทึกไว้ด้วยว่าใครสั่งไปที่เป้าไหนตอนไหน — ภายหลังมันคือหลักฐานชิ้นสำคัญ

อีกเรื่องที่ผมถือว่าเป็นจริยธรรมของคนทำจอเลยนะ คือ ข้อมูลที่ขาดการอัปเดตห้ามวาดเหมือนของสด ไม่ว่ามันจะมาจากแหล่งไหนก็ตาม เพราะถ้าวาดเหมือนกันหมด คนดูจะเชื่อว่าเรืออยู่ตรงนั้นจริงๆ ณ วินาทีนี้ ซึ่งมันไม่จริง

  • การแสดงข้อมูลต้องบอกอายุของข้อมูล คำว่า "ข้อมูลเมื่อ ... ที่แล้ว" ต้องตัวใหญ่พอให้เห็น และถ้าระหว่างจุดเราเดาเอา ก็เชื่อมด้วยเส้นประไปเลย
  • วงความไม่แน่นอนคำนวณจากความเร็วล่าสุดคูณเวลาที่ผ่านไป — เรือวิ่ง 8 นอตแล้วหายไปชั่วโมงนึง แปลว่ามันอยู่ที่ไหนก็ได้ในวงรัศมีหลายไมล์ วงนั้นแหละคือความจริง หรือภาษาชาวเรือจะเรียกว่า DR (Dead Reckoning)
  • อย่าเอาของที่ latency ต่างกันมากๆ มาเฉลี่ยกันตรงๆ นั่นคือการทำลายข้อมูลที่ดีด้วยข้อมูลที่เก่า

เวลาส่งของให้ฝั่งแผนที่ ผมชอบปั้นเป็น GeoJSON ก้อนเดียวที่ สถานะกับที่มาติดไปกับ feature ทุกอัน จอจะได้ตัดสินใจเรื่องสีกับเส้นได้เองโดยไม่ต้องถามกลับ

python
# ฝั่ง plot: ปั้น FeatureCollection ก้อนเดียวส่งให้แผนที่ — layer / สถานะ / อายุ / ที่มา ติดไปกับทุก feature
import math
from dataclasses import dataclass, field

@dataclass
class FusedTrack(Track):                                   # Track ของตอนที่ 1 + ของที่เกิดตอนหลอม
    state: str = "tentative"                               # tentative | confirmed | coasting — ตามขั้น M-of-N
    sources: list[str] = field(default_factory=list)       # ["ais", "radar:north", "eoir:cam1"] ที่มาของค่าในหมุดนี้

def point(tr: Track, layer: str, state: str, now_ms: float, sources=None):
    return {
        "type": "Feature",
        "geometry": {"type": "Point", "coordinates": [tr.lon, tr.lat]},   # GeoJSON เรียง lon, lat เสมอ
        "properties": {
            "layer": layer,                        # "fused" หรือ "raw:radar-north"
            "state": state,                        # confirmed | tentative | coasting
            "age_s": round((now_ms - tr.t) / 1000, 1),
            "sources": sources or [tr.source[0]],  # ["ais", "radar:north"] — ที่มาของค่าในหมุดนี้
            "cog": tr.cog, "sog": tr.sog,
        },
    }

def fov_cone(lat, lon, bearing_deg, hfov_deg, range_m, steps=24):
    R = 6371008.8
    ring, f1, d = [(lon, lat)], math.radians(lat), range_m / R
    for i in range(steps + 1):
        b = math.radians(bearing_deg - hfov_deg / 2 + hfov_deg * i / steps)
        f2 = math.asin(math.sin(f1) * math.cos(d) + math.cos(f1) * math.sin(d) * math.cos(b))
        l2 = math.radians(lon) + math.atan2(math.sin(b) * math.sin(d) * math.cos(f1),
                                            math.cos(d) - math.sin(f1) * math.sin(f2))
        ring.append((math.degrees(l2), math.degrees(f2)))
    ring.append((lon, lat))                                               # ปิดวงให้ครบตามสเปก Polygon
    return {
        "type": "Feature",
        "geometry": {"type": "Polygon", "coordinates": [ring]},
        "properties": {"layer": "camera-fov", "bearing": bearing_deg, "hfov": hfov_deg},
    }

def scene(fused: list[FusedTrack], raws: list[Track], cam, now_ms):
    feats  = [point(t, "fused", t.state, now_ms, t.sources) for t in fused]
    feats += [point(t, f"raw:{t.source[1]}", "raw", now_ms) for t in raws]
    feats += [fov_cone(cam.lat, cam.lon, cam.bearing, cam.hfov, cam.range_m)]
    return {"type": "FeatureCollection", "features": feats}

ปิดท้ายด้วยกฎของผมบนจอที่มีอยู่ข้อเดียว: ทุกหมุดต้องคลิกแล้วกางออกได้ ว่าข้างในประกอบจาก sensor อะไรบ้าง แต่ละตัวเห็นล่าสุดเมื่อไหร่ และต้องมีปุ่มให้คนสั่งแยกมันออกจากกันได้ด้วยมือ เพราะอัลกอริทึมผิดได้เสมอ และคนที่นั่งเฝ้าจอตอนตีสามมักจะเห็นในสิ่งที่โค้ดเราไม่เห็น

สรุปทั้งสามตอน — ของที่ยอมเปิดคือของที่ง่ายที่สุดแล้ว

ไล่มาตั้งแต่ตอนที่ 1 ถึงบรรทัดนี้ ถ้าจะเก็บกลับบ้านสักสามข้อ ผมขอสามข้อนี้

หนึ่ง งานส่วนใหญ่ไม่ได้อยู่ที่อัลกอริทึม แต่อยู่ที่สายกับการตั้งค่า baud rate ผิดค่าเดียว ทิศเหนือเบี้ยวสององศา นาฬิกาเพี้ยนสิบวินาที — สามอย่างนี้ทำให้ระบบทั้งระบบใช้ไม่ได้ ทั้งที่โค้ดถูกหมด

สอง ยืนบนมาตรฐานเข้าไว้ NMEA 0183, NMEA 2000, ASTERIX, ONVIF ไปจนถึงฝั่งแผนที่อย่าง WMTS/XYZ และ GeoJSON พวกนี้มีเอกสารเปิดให้อ่าน ให้ทดสอบ และให้เปลี่ยนยี่ห้อได้โดยไม่ต้องรื้อระบบ ทุกครั้งที่มีคนเสนอทางลัดที่ผูกกับของเขาคนเดียว ให้ถามคำถามเดิมเสมอ — "ถ้าวันหนึ่งผมเปลี่ยนอุปกรณ์ ผมต้องเขียนใหม่กี่บรรทัด" ถ้าคำตอบคือ "ทั้งระบบ" แปลว่าราคาที่ถูกกว่าวันนี้คือเงินดาวน์ของความเจ็บปวดในอนาคต

สาม จอต้องบอกความจริง รวมถึงความจริงที่ว่าเราไม่รู้ อายุของข้อมูล ที่มาของค่า วงความไม่แน่นอน เป้าที่ยังไม่ยืนยัน ของพวกนี้ไม่ได้ทำให้จอสวยขึ้นเลย แต่มันคือสิ่งที่กันไม่ให้คนตัดสินใจผิดบนข้อมูลที่ดูน่าเชื่อถือเกินจริง

และย้ำอีกทีว่าทั้งหมดที่เล่ามาสามตอน คือการจัดการกับเรือที่ ยอมให้เราเห็น ทั้งนั้น มันเปิด AIS ประกาศตัวเอง มันลอยอยู่เฉยๆ ให้เรดาร์กวาดเจอ มันยอมให้กล้องหันไปส่องแล้วอยู่ในเฟรมนิ่งๆ พูดง่ายๆ คือสามตอนที่ผ่านมาเราเล่นกันอยู่ใน easy mode มาตลอดเนอะ

แล้วลำที่ไม่อยากให้เห็นล่ะ? ลำที่ดับ AIS ตอนเข้าเขตหวงห้าม ลำที่แจ้งพิกัดคนละที่กับที่ตัวเองอยู่ ลำที่นัดเจอกันกลางทะเลตอนตีสองแล้วแยกย้ายก่อนฟ้าสาง — พวกนั้นเรดาร์ชายฝั่งตามไม่ทันหรอกครับ เพราะมันไปทำกันไกลฝั่งเกินระยะที่จะกวาดเห็น

ตอนที่ 4 ของซีรีส์นี้จะว่าด้วย dark activities ว่าเราจะไปหาเรือที่ไม่ยอมพูดได้ยังไง ตั้งแต่การไล่หาช่วงที่สัญญาณเงียบผิดปกติ การเทียบภาพถ่ายดาวเทียมกับสิ่งที่ควรจะมี ไปจนถึงการเอา Machine Learning มาชี้เป้าบนภาพเรดาร์จากดาวเทียม ว่ามันทำได้แค่ไหน หลอกได้ง่ายแค่ไหน และทำไมสุดท้ายมันก็ยังต้องกลับมาจับคู่กับของที่เราต่อสายกันไว้ในสามตอนนี้อยู่ดี

ใครเคยหลอมเป้าจากหลาย sensor แล้วเจอกับดักแปลกๆ มาเล่าให้ฟังบ้าง โดยเฉพาะเคสที่อัลกอริทึมมั่นใจเต็มร้อยแต่ผิดเต็มร้อยเหมือนกัน ผมเก็บเรื่องพวกนี้ไว้เยอะ แต่ยังอยากรู้ว่าของคนอื่นกวนตีนกว่าของผมไหม แล้วเจอกันตอนหน้าฮะ วัยรุ่น