【技術文】為香港的演出場地建立座位表:hkperf-venue-seats 的資料設計

這篇文章分享的,是我最近做的一個項目:hkperf-venue-seats。它把香港主要表演場地的座位,整理成公開、有結構、可核對的座位表(seat list)。

【技術文】為香港的演出場地建立座位表:hkperf-venue-seats 的資料設計

幾年前,我寫過一篇 Excel 文章:如何為音樂廳的座號排序?當時的問題是,「A1、A2、A10、B1」這樣的座號,用字串排序會亂成一遍。

當時我以為,那是座號最麻煩的地方。後來才發現,我錯了。

排序只是第一關。真正麻煩的,是座號本身並不老實:有些座位沒有號碼,有些排沒有 I,有些場地的座位 1 在左邊,有些在右邊,有些場地是圓形的,根本沒有「左右」。

這篇文章分享的,是我最近做的一個項目:hkperf-venue-seats。它把康樂及文化事務署(康文署)轄下表演場地的座位,整理成公開、有結構、可核對的座位表(seat list)。談得興高采烈,是因為我給自己定下三個前設:

  1. 只發佈事實,不發佈圖紙:每個座位的排、號、標記,而不是座位的畫面;
  2. 每一份座位表都要自己與自己驗證得來:數目對不上,就不准出街;
  3. 資料來源只有康文署公開的座位圖,不碰任何票務資料。

項目由 2026 年 9 月 26 日開始,到 10 月 4 日,九日內有 54 次 commit,目前共 43 份座位表。

截圖 1:Viewer 畫出一個場地的座位地圖

為什麼是「座位表」,不是「座位圖」

先交代用詞,因為這在項目中很重要。我在項目裏寫了一份 GLOSSARY.md,因為一個詞用得不準,後面整個設計都會歪:

  • Seat plan(座位圖):康文署印出來的文件;
  • Seat list(座位表):發佈的紀錄;
  • Seat map(座位地圖):畫出來的圖。

我發佈的是第二樣。每個座位沒有座標,沒有底圖,也不附上任何一張康文署的圖。有的只是:這個場地有哪些部份(part of house),每部份有哪些排,每排有哪些座位,哪些是輪椅位,哪些是管理席。

這個取捨,一部份是版權考慮(見下文),另一部份是因為我相信:事實比圖紙耐用。圖可以重新畫,但資料一旦錯了,所有用它的人都會錯。

整體配置

先描述各個組件:

  • Seat lists:每個場地一份 JSON,加一份 CSV。例如 ktt-aud.json 是葵青劇院演藝廳。另有 data/index.json 列出所有場地。
  • Viewer:畫出座位表,讓你按配置(例如啟用樂池)查看座位數目。純 HTML / JS,沒有 build step。
  • Planner:定價與座位規劃,可分價區、估算總收入、封鎖或預留座位。
  • Customiser:讓使用者在公開座位表上加自己的修改,而不是複製一份。
  • draft_rows.py:由座位圖草擬每排資料的 Python 工具,用 pymupdf、numpy,並在 macOS 上用 Apple Vision 做 OCR。
  • Reviewer:核對座號與構圖的內部頁面。這個只存在於 repo 裏,網站上沒有,下文會細談。

Viewer 與 Planner 用的,就是公開的那份座位表。沒有私有的後門資料。

Schema 的設計邏輯

Schema 目前是 hkperf-venue-seats/seatlist@0.5。它不是一次設計好的,而是由九日內不斷遇到新場地逼出來的。回頭看,背後有幾條邏輯。

一、層級跟着現實走:部份 → 排 → 塊 → 座位

zones            部份(例如 Stalls、Balcony)
 └ rows          排
    └ blocks     塊(被走道分開的一段)
       └ seats   座位 ID,由座位 1 那一邊起,按物理次序

「塊」是這個結構的關鍵。同一排被走道切開,各段的號碼未必連續,例如 1–6,然後 19–24。如果把整排當成一個陣列,走道就沒有地方放。

座位的次序,是物理次序,而不是數字次序。號碼只是標籤。

二、座位 ID 是字串

在 Excel 時代,我把座號當成「一個字母加一個數字」。在這個項目裏,座位 ID 是字串:"1",或者 "W1"、"X1"。因為輪椅位與管理席,座位圖上常常只印 W 或 X,沒有號碼。

要指認一個座位,需要的是 {部份, 排, 座位 ID},而不是 {排, 號}。因為同一個字母,可以在不同樓層重複出現。我在 glossary 裏,甚至禁止自己用「seat number」這個詞,因為它暗示每個座位都有號碼。

故事教訓:如果你的資料模型,無法表達「這個座位沒有號碼」,它就會逼你在資料裏撒謊。

三、「印出來的」與「推算出來的」要分開

這是我最堅持的一條。以葵青劇院演藝廳的 Q 排為例(節錄):

{
  "row": "Q",
  "blocks": [{ "seats": ["W1", "W2", "3", "4", "…", "44", "W3", "W4"] }],
  "marks": { "W1": "W", "W2": "W", "W3": "W", "W4": "W" },
  "inferred_numbers": { "W1": "1", "W2": "2" },
  "note": "Four wheelchair boxes print no number. W1-W2 sit before seat 3, so they fill 1-2 exactly. W3-W4 follow seat 44 at the row end; 45-46 is likely but not printed."
}

W1、W2 的號碼是推算的,所以放在 inferred_numbers;W3、W4「很可能」是 45、46,但圖上沒有印,所以不推算,只在 note 寫明。CSV 裏還有一欄 number_basis,值是 printed 或 inferred。

推算的規則只有一條:兩邊的印刷號碼之間,空缺剛好吻合才推算。

6 · X X · 9   →  X 是 7、8

同一個思路,也用在樂池:rows_basis: "inferred",連同推算過程與原文引句,一併寫進資料。(見下文「樂池謎題」。)

四、冗餘是故意的

每份座位表都帶着 printed_totals(圖上印的總數)與 count_check(我數出來的)。同一個數字存兩次,目的是讓機器可以對數。

五、來源要可追溯

source 記錄圖紙的發佈機構、網址、檔案名、SHA-256 與版本;location 的地址與座標,各自帶有來源;orchestra_pits 附有原文引句與擷取日期。

六、Schema 只加、不改:向後相容

版本歷史是這樣的:

  • 0.3:加入 banks,記錄觀眾坐在舞台的哪一邊(前、左、右);
  • 0.4:加入 back,記錄對面的觀眾(橫向或環繞舞台);
  • 0.5:加入 arc,用方位角與距離描述圓形場地。

每一次,舊檔案都不用改,讀法也一樣。更重要的是,arc 只是附加的一層:一個不認識 arrangement 的程式,仍然可以讀到每一個座位、每一項核對。

這個原則是:幾何只是畫圖用的,事實層不能依賴它。

七、每個場地的「圖例」自己帶着

marks 是每個場地自己的圖例:W 輪椅、X 管理席、R 視線受阻、L 腿部空間有限。每一排的 marks 是「座位 ID → 標記字母」,所以標記是座位的屬性,不是座位 ID 的一部份。

沒有號碼的座位,以及更多怪事

輪椅位與管理席沒有號碼,只是其中一個問題。以下是我在不同場地遇到的:

  • 座號分區固定。文化中心大劇院的樓座,分塊的號碼段是 1–、12–、34–。號碼在塊與塊之間會跳。
  • 沒有 1 至 7 號。東九文化中心 The Turns 的端式舞台配置,A 至 J 九排(沒有 I),由左至右是 8 至 27。所有排都沒有座位 1–7。
  • 缺了某些字母。葵青劇院演藝廳堂座沒有 I 排,樓座沒有 BI。但沙田與屯門大會堂,卻有 I 排。大會堂劇院則沒有 I、O、U。
  • 雙字母排。上環文娛中心有 AA、BB,在 A 之前。
  • 座位 1 在哪一邊?每份座位表都有 seat_1_side。葵青、大埔演藝廳是右邊,多數場地是左邊。
  • 只有輪椅位的排。元朗劇院的 Y 排,只有 Y1、Y2、Y34、Y35 四個輪椅位。
  • 側牆的包廂。葵青演藝廳的 BA–BD,每格座位面向舞台,但編號是從右邊數起。

這些都不是「錯」,而是每個場地各有各的歷史。資料模型的任務,不是把它們抹平,而是記錄下來。

自我核對:數目對不上,就不准 build

每份座位圖,都印有座位總數。 finish() 函數,在寫出 JSON 之前會檢查:

圖上畫出的格數 − 管理席 = 圖上印的總數

每一個部份都要通過,各部份加起來也要等於總數。差一個,build 就失敗。

以葵青演藝廳堂座為例:629 格,其中 4 個管理席,4 個輪椅位。629 − 4 = 625,與圖上印的 625 一致。輪椅位、視線受阻、腿部空間有限的座位都算在總數之內,管理席則不算。這是康文署算法的「閱讀」,而每一個部份都吻合,所以我才有信心。

當然,也有例外。高山劇場的六個輪椅位,是圖上另外加到總數的;城市會堂音樂廳的 12 個走廊座位,畫了但沒有計算。這些例外,都要明確寫在資料裏(count_excludes),而不是悄悄修正。

樂池謎題

康文署的資料,常常只寫「啟用樂池後有 833 個座位」,而不說哪幾排被拆掉。

葵青演藝廳是 899 個座位。899 − 833 = 66。逐個試:

  • 只拆 A 排:15 個;
  • 拆 A 至 B:39 個;
  • 拆 A 至 C:15 + 24 + 27 = 66;
  • 拆 A 至 D:94 個。

只有 A 至 C 恰好是 66。所以座位表裏寫的是拆 A、B、C 三排,並標明 rows_basis: "inferred",附上推算過程。

對主辦者而言,樂池有沒有啟用,直接影響賣票的數量。文化中心大劇院是 1,734 個座位;用小升降台的話,剩 1,681;用大的,剩 1,632。

Reviewer:核對座號與構圖

這個部份,是整個項目裏我最倚賴、但你卻看不到的東西。

tools/review.html 是一個只存在於 repo 的內部頁面,沒有發佈到網站,因為它可以把座位圖畫在底下作對照。你要自己在本機跑一個 server 才能打開。

工作流程是這樣的:

  1. draft_rows.py 掃描座位圖,找出每一格座位,讀取號碼與標記,草擬每一排;
  2. 我把檢查過的事實,手寫進 venues/<id>.py;
  3. venues/common.py 核對總數,寫出 JSON;
  4. 在 Reviewer 逐排、逐塊覆核。

Reviewer 有兩個分頁。

Seats 分頁:核對座號。 每一排都有兩種表達方式:

  • 一行文字,例如 1-5 | W1=6 7r 8rl。| 是塊的邊界,小寫字母是標記,W1 是沒有號碼的格,=n 是推算號碼;
  • 一排座位小方塊(chip),可以用鍵盤選取、加標記、移動、新增、拆開、刪除。

這個頁面最有用的,是頁首會即時用與 build 完全相同的規則,核對每個部份的總數。我在修改一排時,數字當場就紅或綠,不需要等 build。

Geometry 分頁:核對構圖。 這是為圓形場地而設的。點選一個座位或一條軸線,就選中它所屬的塊(或直線段),然後用箭頭鍵微調,或者拖曳。地圖會即時標出:

  • 重疊的座位;
  • 超出所屬塊範圍的座位;
  • 落在 vomitorium(觀眾入口)之內的座位。

你還可以把座位圖放在地圖下面當底圖,直接對照。這個底圖只存在我自己的硬碟,不會寫進任何檔案。

其他功能:Undo / Redo 同時涵蓋兩個分頁;「Changes since load」會列出與原檔案的每一個差異。

故事教訓一:Download JSON 的輸出,格式與 common.py 一模一樣,所以 git diff 只會顯示我真正改過的地方。
故事教訓二(踩過的坑):在 Reviewer 改了排,如果沒有同步改 venues/<id>.py,下次 build 就會把改動沖掉。所以 Reviewer 有一個「Copy rows = [ … ]」按鈕,直接吐出 row(...) 的程式碼。而圓形場地的塊幾何,不在 venues/<id>.py 裏,是由 derive_runs.py 從座位圖量度而來。手動調整過的幾何,會被重新 build 覆蓋。後來文化中心音樂廳要手調,我就把調整另存成 hkcc-ch.geometry.json,每一塊用 first_seat 做保險:萬一那一塊移了位,工具會發現。
截圖 2:Reviewer 的 Seats 分頁
截圖 3:Reviewer 的 Seats 分頁,總數不符的狀態

圓形劇場:最難的部份

以上的場地,都是「排」與「塊」,大致可以用 bank 去描述:前、後、左、右。直到我遇到東九文化中心的劇院,和文化中心音樂廳。

東九劇院是圓形的,座位圍着舞台,排由 310° 逆時針開到約 50°。這令我新增了 schema 0.5,用方位角與距離描述每一塊座位:

  • 每塊記錄一個角度(正上方起順時針)與離中心的距離;
  • 座位在每條直線上均勻分佈;
  • 彎曲的塊,拆成幾段直線(runs)。

這個階段,Reviewer 的 Geometry 分頁幫了大忙。我用了幾天,才調到沒有任何座位重疊。

而文化中心音樂廳更麻煩:座位圖沒有文字層,數字是畫出來的筆劃,OCR 派不上用場。我寫了一個工具,用路徑指令的特徵去辨識數字,例如 "ccccllllll" 就是 1。

故事教訓:當圖紙「看起來是字」,但其實是形狀,OCR 以外,還有一條路:認形狀。
截圖 4:Reviewer 的 Geometry 分頁(東九劇院)
截圖 5:Viewer 中的文化中心音樂廳

一個設計決定:Customiser 為什麼不是複製

使用者常常想改一下座位表,例如拆掉某幾個座位,因為他們的演出有特別的舞台。最簡單的做法,是讓他們複製一份。但我選擇了另一條路:記錄修改,而不是複製資料。

每個修改指向一個座位的 {部份, 排, 座位 ID},而不是「第幾塊的第幾個」。原因是:公開的座位表會不斷修正。複製一份,就會永遠停留在舊資料。用位置去指認,當我日後把一個塊拆開,修改就會悄悄移到錯的座位上。

所以,修改附有原座位表的 SHA-256。座位表更新了,它不會拒絕你,而是提醒你「請重新檢查每個修改」;找不到的座位,會被列為「不再吻合」,而不是靜靜丟掉。

另外,Viewer 只顯示公開的資料,即使網址帶有修改。一張 Viewer 的截圖,應該永遠代表公開的座位表。

版權與取捨

康文署在座位圖上聲明版權,並要求事先取得書面授權。我的處理是:

  • 圖紙不放進 repo(sources/ 與 work/ 都 git-ignore);
  • 只發佈事實(排、號、標記、數目);
  • 每份資料都標示來源,並附上原圖的 SHA-256;
  • 康文署在 9 月 26 日項目公開時已獲通知,歡迎他們要求修改、移除,或者自行託管這些資料。

我判斷這個風險「低得可以先發佈、不必先問」。但這不是法律意見。

授權方面:程式碼是 MIT;Planner、Customiser 與 Reviewer 是 PolyForm Noncommercial(任何人都可以免費使用已發佈的工具,包括商業機構);座位表是 CC BY 4.0,只涵蓋我自己整理的部份,不涉及康文署在圖紙上的權利。

它的限制

  • 這些資料來自圖紙,不是實地點數。有些圖紙品質很低,例如元朗劇院的圖只有一張 JPG,是我放大後逐格目測的;
  • 少部份座位號碼是推算的,已標明 inferred,但仍然有可能出錯;
  • Reviewer 只能核對「資料是否符合圖紙」,圖紙本身若有錯,它不會知道;
  • 我一個人在做,覆核主要靠自己,所以非常歡迎大家改正:在 GitHub 提 issue 就可以,我預備了座位資料更正與場地建議兩個範本。

總結

九日、54 次 commit、43 份座位表。這個速度,不是靠畫出一張更漂亮的圖,而是做一份會自己檢查自己的資料,再配上一個讓我能快速覆核的工具。

Excel 那篇文章,教的是如何把座號排好。這篇文章想說的是,在排好之前,你要先決定「座位」是什麼。

網站:https://code.denniswu.org/hkperf-venue-seats/