經過 Day 13 跟 Day 14 的介紹,要讓一間店運作我們需要有追蹤 (tracking) 跟旅程 (journey) 兩個服務,而這兩個服務都得手動調整相機校準 (calibration)、追蹤的 RoI 設定檔、旅程的 RoI 設定檔才能順利運作。
在一開始的開發階段,我們需要跑完現實世界 2 小時的追蹤服務加上現實世界 2 小時的旅程服務,再透過 1 fps 的視覺化影片來觀察追蹤與旅程的 RoI 設定檔是否有正確追蹤車輛且給予車輛正確的狀態,再由人工修改設定檔內的數值,包括 RoI 的座標及參數。
一次迭代的整段流程大概需要 2 到 3 小時才能完成,相機數量更多的店花費的時間還會再增加,而要將一間店的所有設定檔調整到上線標準通常需要迭代 5 到 6 次,依賴這樣的流程調整一間店非常花時間。
此外在現實世界中得來速場域的佈局是非常不同的,有些店是 U 字型車道搭配一個點餐機一個取餐窗口,有些店雖然也是一個點餐機一個取餐窗口但卻是 S 字型車道,甚至有 Y 字型車道搭配兩個點餐機和一個取餐窗口,因此我們無法用一套固定的設定檔去適配如此多樣化的場景。
在一個月要上線百餘家店的情況下,這樣的迭代速度是沒辦法符合時程的,因此我們內部需要一個工具,滿足以下需求:
有了工具之後,我們把調一間店的過程標準化成一條流水線,每個階段都有固定的負責角色,以及進到下一階段的條件,避免大家各調各的,也避免無止盡地調下去。
這三件事都完成後才會通知進入調校階段,否則調出來的結果沒有東西可以對答案。

介面示意圖。
這邊我們選擇用 streamlit 來開發本工具,我們不是專業的前後端工程師,這種現成的 Python GUI library 對開發來說比較快上手,此外也有 streamlit-image-coordinates 能夠知道滑鼠點擊影像時的相關資訊,方便工程師實作互動式介面,讓使用者透過點擊相機畫面來調整 RoI 的位置。
透過 streamlit 與相關套件來實作調整 RoI 的介面,包含新增點座標、修改點座標等功能,這邊提出幾個開發過程遇到的眉角。
適度使用 st.session_state
如果我們在不同檔案的程式碼內都有使用到 streamlit,且需要跨越這些程式碼傳送變數的話,除了寫到檔案或雲端上,我們也可以透過 st.session_state 來傳遞,要特別注意的是每個 streamlit 的 input widget 都會有一個獨一無二的 key 存在 st.session_state,如果我們要手動給 key 的話要避免重複使用到 input widget 的 key 導致錯誤產生。
以跨檔案共用分店設定為例:
# app.py:初始化一次,整個 app 都拿得到
if "store" not in st.session_state:
st.session_state["store"] = "store_a"
st.selectbox("選擇分店", options=stores, key="store_picker")
# 選完之後,st.session_state["store_picker"] 就是使用者選的值
# roi_panel.py:不用把變數一路當參數傳進來,直接讀
store = st.session_state["store"]
camera = st.session_state["store_picker"] # widget 的 key 也在同一個 session_state
# 自己設的 key 不要跟 widget 的 key 撞名,
# 否則 widget 每次 rerun 都會把你寫進去的值蓋掉
st.session_state["store_picker"] = "store_b" # ✗ 不要這樣寫
善用 callback
streamlit 的執行順序是由上往下,因此一開始我們的做法是使用者變更值時,透過一次 st.rerun() 來更新整個介面,但是直接給予變數操作後的值,常會遇到值回溯、未正確賦予變數值的狀況,主因是 st.rerun() 後會重新由上往下執行,顯示跟編輯如果在不同區域的話還是會有數值回溯等異常行為發生。
因此我們採用 callback function,每個可操作的介面,例如 st.button、st.number_input、st.text_input 等 input widget,都有 on_change= 這個參數,搭配每個 widget 都需要有一個獨一無二的 key 且這些值都會存在 st.session_state 內,我們可以用 function 把 input widget 的 key 賦予給變數,在數字有變動時便會自動執行 on_change 的 function 並更新。
以調整 RoI 點座標為例:
# 一開始的寫法:自己判斷值有沒有變,再手動 rerun
x = st.number_input("RoI 點 X 座標", value=roi.x, key="roi_x")
if x != roi.x:
roi.x = x
st.rerun() # 顯示與編輯不在同一段程式碼時,容易出現值回溯
# 改用 callback:值變動時自動觸發,不用自己 rerun
def update_roi_x(key: str, roi):
roi.x = st.session_state[key] # widget 的值都存在 session_state 裡
st.number_input(
"RoI 點 X 座標",
value=roi.x,
key="roi_x", # 每個 widget 一個獨一無二的 key
on_change=update_roi_x,
args=("roi_x", roi), # 把 key 傳進 callback 就能取到最新的值
)
streamlit-image-coordinatesstreamlit-drawable-canvasstreamlit-webrtcRECVONLY 模式把影片來源放在 server 端,再透過 video_processor_factory 逐幀把追蹤跟旅程的結果畫上去。要注意的是推論速度通常跟不上播放速度,可以改寫 recv_queued() 只處理最新一張影格並回傳上次的結果,另外 video processor 跑在另一個 thread,裡面沒辦法使用 st.session_state 等 streamlit 的功能。工具做出來其實只解決了一半的問題,另一半是「誰來調」。一開始所有的設定檔都是工程師在調 — 得知道畫面上哪一塊 RoI、哪一個數值對應到演算法裡的哪個行為 — 但工程師的人力沒辦法跟著店家數量一起成長,因此我們把調校這件事轉移給每天都在看影片、最熟悉得來速場域行為的標註人員。
這個轉移大致做了三件事:
把參數翻譯成場域的語言
介面上不出現程式裡的變數名稱,改用「排隊區」、「點餐機區」、「多久沒移動視為停止」這種標註人員本來就在用的詞彙,讓他們不需要先理解 IoU 或狀態機的 transition 條件,也知道自己正在調的是什麼。
用即時預覽取代厚厚的文件
比起寫一份幾十頁的參數說明要大家讀完,直接讓他們改一個值、馬上看到車輛的框與狀態怎麼變化,學習成本低很多,不少演算法的行為是這樣被「試」出來的,工程師只需要在旁邊補充為什麼會這樣。
定義清楚的求助時機
流程裡明確規定標註人員看到錯誤時要先判斷屬於「參數還可以調」、「標註要修正」還是「演算法與標註的定義本來就不同」,前兩種自己處理掉,第三種才回報工程師,工程師就不會被每一個個案打斷。
實際運作後,工程師的角色從「每一間店都要自己調」變成「只處理演算法層級的問題」,也因為實際在調的人就是最熟悉場域行為的人,很多我們沒預期到的特例反而更早被發現。
開發出即時調校工具大幅減少我們調整一間店設定檔的時間,從原本 2 到 3 小時迭代一次加速成 5 到 10 分鐘迭代一次,增加我們每個月能上線店家的上限,同時也將演算法的內部知識簡化到非工程師也能理解的程度,讓非工程師的同事也能接手調整設定檔。從即時調校工具開始使用至今,我們已經調整過 1128 間店,總計 14470 次迭代版本,若是還維持每次迭代 2 到 3 小時,可能需要調校 3.5 到 5 年的時間,現在只需要 50 到 100 天即可達成,未來將往自動進行迭代的目標來開發。
本系列由 Berry AI 工程團隊出品。更多工程實戰紀錄都在 Berry AI 技術部落格。