快速摘要
StreamDiffusion 要接進互動系統,先把通訊層搭起來。本文把責任拆成兩條線:用 OSC 收提示詞等控制訊息,用 NDI 收送影像。這層骨架一旦穩住,後面接 TouchDesigner 或 Unity 都會輕鬆很多。

前一篇把 StreamDiffusion 裝到能跑起來之後,還有一個很現實的問題:它目前仍然像一支範例程式。我們每改一次 Prompt,都要停下來、改碼、重跑;每生成一張圖,又只是存成檔案。這樣當然可以驗證模型,但還談不上互動系統。
要把它接進外部軟體,最先該補的是通訊層。這一層如果先穩住,後面不管是 TouchDesigner、Unity,還是你自己的前端介面,都只是在接同一套入口和出口。
本文把責任拆成兩條線來做:OSC 負責接收控制訊息,像是 Prompt;NDI 負責送出生成影像,也可以反過來接收外部影像來做 Img2Img。拆成這樣之後,系統角色就很清楚了。
為什麼是 OSC 加 NDI
這裡先講結論。控制訊息和影像資料最好不要混在同一條路上。
OSC 適合做參數控制。它很輕,傳一段 Prompt、模式切換或數值參數都很自然。TouchDesigner、Unity 或其他互動系統如果只需要發控制訊息過來,這條線就夠了。
NDI 適合做影像進出。它的角色是把每一幀畫面穩定送出去,或者把外部畫面送進來。只要把這一層接好,StreamDiffusion 就能從「範例程式」轉成「影像服務」。
安裝通訊層會用到的套件
本文沿用前一篇的 Python 3.10 環境。這裡刻意不升版,原因很單純:ndi-python 目前支援的 Windows Python 版本仍到 3.10,StreamDiffusion 本身也要求 3.10 起跳。環境不換,後面事情會單純很多。
請在專案根目錄輸入:
1uv add opencv-python python-osc ndi-python==5.1.1.1
這三個套件的分工如下:
python-osc:接收外部傳來的 OSC 訊息。ndi-python:收送 NDI 影像串流。opencv-python:做影像格式轉換,讓輸出的陣列格式能被 NDI 正常送出。
先把 Txt2Img 改成可被外部控制
第一步先不要急著做雙向影像交換,先把最小骨架搭起來:外部能更新 Prompt,Python 端能持續生成,並把結果即時送出去。
這段程式有幾個重點:
- 用一個全域的 Prompt 狀態儲存當前文字。
- 開一個 OSC Server,接收
/prompt訊息。 - 用 NDI 持續送出目前生成的畫面。
請把 main.py 改成下面這段:
1from threading import Lock, Thread
2
3import cv2
4import NDIlib as ndi
5import numpy as np
6from pythonosc.dispatcher import Dispatcher
7from pythonosc.osc_server import ThreadingOSCUDPServer
8
9from utils.wrapper import StreamDiffusionWrapper
10
11OSC_HOST = "127.0.0.1"
12OSC_PORT = 7000
13NDI_OUTPUT_NAME = "streamdiffusion-output"
14
15prompt = "cat, detailed, fantasy, 8k"
16prompt_lock = Lock()
17
18
19def set_prompt(_, value):
20 global prompt
21 with prompt_lock:
22 prompt = value
23
24
25def get_prompt():
26 with prompt_lock:
27 return prompt
28
29
30def main():
31 stream = StreamDiffusionWrapper(
32 model_id_or_path="stabilityai/sd-turbo",
33 t_index_list=[0, 24, 32],
34 width=512,
35 height=512,
36 cfg_type="none",
37 acceleration="xformers",
38 mode="txt2img",
39 output_type="np"
40 )
41 stream.prepare(prompt=get_prompt())
42
43 dispatcher = Dispatcher()
44 dispatcher.map("/prompt", set_prompt)
45 osc_server = ThreadingOSCUDPServer((OSC_HOST, OSC_PORT), dispatcher)
46 osc_thread = Thread(target=osc_server.serve_forever)
47 osc_thread.start()
48
49 ndi.initialize()
50 ndi_send_cfg = ndi.SendCreate()
51 ndi_send_cfg.ndi_name = NDI_OUTPUT_NAME
52 ndi_send = ndi.send_create(ndi_send_cfg)
53 ndi_frame = ndi.VideoFrameV2()
54 ndi_frame.FourCC = ndi.FOURCC_VIDEO_TYPE_RGBA
55
56 for _ in range(stream.batch_size - 1):
57 stream(prompt=get_prompt())
58
59 try:
60 while True:
61 current_prompt = get_prompt()
62 output = stream(prompt=current_prompt)
63 output = (output * 255).astype(np.uint8)
64 output = cv2.cvtColor(output, cv2.COLOR_RGB2RGBA)
65
66 ndi_frame.data = output
67 ndi.send_send_video_v2(ndi_send, ndi_frame)
68
69 except KeyboardInterrupt:
70 print("中斷執行")
71
72 finally:
73 ndi.send_destroy(ndi_send)
74 ndi.destroy()
75 osc_server.shutdown()
76 osc_thread.join()
77 print("已停止")
78
79
80if __name__ == "__main__":
81 main()
這裡我特別把輸出的 NDI 名稱設成 streamdiffusion-output。原因很實際,後面不管是 TouchDesigner 還是 Unity,看到這個名字就知道要接哪一路,不用再回頭猜。
這一版程式在做什麼
StreamDiffusionWrapper 還是同一套,但它現在不再只跑一次,而是放進持續生成的主循環裡。每次迴圈都先讀目前的 Prompt,再根據這個狀態生成新畫面。
OSC Server 是另一條執行緒。它的工作很單純,就是在收到 /prompt 訊息時,把字串寫回目前的 Prompt 狀態。這裡用 Lock,目的是避免之後外部更新 Prompt 的時候,主循環剛好也在讀,結果讓狀態變得很難追。
NDI 這一層則只做一件事:把生成後的 numpy.ndarray 轉成 RGBA,再穩定送出。到這一步為止,StreamDiffusion 已經可以像一個影像節點那樣被別的系統接收,不再只是把結果存成 PNG。
實際測試:Prompt 可以在外部改動
這個階段最簡單的驗證方式,就是用 TouchDesigner 先送一個 Prompt 過來,看畫面有沒有跟著變。

如果畫面能從貓切到馬,代表外部控制真的進到生成迴圈了。這是後面所有整合工作的起點。
再補上 Img2Img 的影像輸入
有了外部 Prompt 控制之後,下一步才是讓外部畫面流進來。這一版會多一條 NDI 輸入,讓外部軟體可以把攝影機、即時渲染或任何 TOP/Texture 畫面送進 StreamDiffusion。
和前一段相比,真正多出來的事情只有兩件:
- 啟動時先找到指定的 NDI 來源。
- 在主循環裡先收影像,再把影像和 Prompt 一起丟進 StreamDiffusion。
請把 main.py 改成下面這段:
1from threading import Lock, Thread
2
3from PIL import Image
4import cv2
5import NDIlib as ndi
6import numpy as np
7from pythonosc.dispatcher import Dispatcher
8from pythonosc.osc_server import ThreadingOSCUDPServer
9
10from utils.wrapper import StreamDiffusionWrapper
11
12OSC_HOST = "127.0.0.1"
13OSC_PORT = 7000
14NDI_INPUT_NAME = "td-input"
15NDI_OUTPUT_NAME = "streamdiffusion-output"
16
17prompt = "cat, detailed, fantasy, 8k"
18prompt_lock = Lock()
19
20
21def set_prompt(_, value):
22 global prompt
23 with prompt_lock:
24 prompt = value
25
26
27def get_prompt():
28 with prompt_lock:
29 return prompt
30
31
32def wait_for_ndi_source(target_name: str):
33 finder = ndi.find_create_v2()
34 try:
35 while True:
36 ndi.find_wait_for_sources(finder, 1000)
37 sources = ndi.find_get_current_sources(finder)
38 for source in sources:
39 if source.ndi_name == target_name:
40 return source
41 finally:
42 ndi.find_destroy(finder)
43
44
45def main():
46 stream = StreamDiffusionWrapper(
47 model_id_or_path="stabilityai/sd-turbo",
48 t_index_list=[24, 32],
49 width=512,
50 height=512,
51 acceleration="xformers",
52 mode="img2img",
53 output_type="np"
54 )
55 stream.prepare(prompt=get_prompt())
56
57 dispatcher = Dispatcher()
58 dispatcher.map("/prompt", set_prompt)
59 osc_server = ThreadingOSCUDPServer((OSC_HOST, OSC_PORT), dispatcher)
60 osc_thread = Thread(target=osc_server.serve_forever)
61 osc_thread.start()
62
63 ndi.initialize()
64
65 source = wait_for_ndi_source(NDI_INPUT_NAME)
66 ndi_recv_cfg = ndi.RecvCreateV3()
67 ndi_recv_cfg.color_format = ndi.RECV_COLOR_FORMAT_BGRX_BGRA
68 ndi_recv = ndi.recv_create_v3(ndi_recv_cfg)
69 ndi.recv_connect(ndi_recv, source)
70
71 ndi_send_cfg = ndi.SendCreate()
72 ndi_send_cfg.ndi_name = NDI_OUTPUT_NAME
73 ndi_send = ndi.send_create(ndi_send_cfg)
74 ndi_frame = ndi.VideoFrameV2()
75 ndi_frame.FourCC = ndi.FOURCC_VIDEO_TYPE_RGBA
76
77 for _ in range(stream.batch_size - 1):
78 stream(prompt=get_prompt())
79
80 try:
81 while True:
82 frame_type, video_frame, _, _ = ndi.recv_capture_v2(ndi_recv, 1000)
83 if frame_type != ndi.FRAME_TYPE_VIDEO:
84 continue
85
86 input_image = np.copy(video_frame.data)
87 ndi.recv_free_video_v2(ndi_recv, video_frame)
88
89 input_image = Image.fromarray(input_image)
90 input_image = stream.preprocess_image(input_image)
91
92 current_prompt = get_prompt()
93 output = stream(prompt=current_prompt, image=input_image)
94 output = (output * 255).astype(np.uint8)
95 output = cv2.cvtColor(output, cv2.COLOR_RGB2RGBA)
96
97 ndi_frame.data = output
98 ndi.send_send_video_v2(ndi_send, ndi_frame)
99
100 except KeyboardInterrupt:
101 print("中斷執行")
102
103 finally:
104 ndi.recv_destroy(ndi_recv)
105 ndi.send_destroy(ndi_send)
106 ndi.destroy()
107 osc_server.shutdown()
108 osc_thread.join()
109 print("已停止")
110
111
112if __name__ == "__main__":
113 main()
這裡我把輸入來源名稱寫成 td-input,是因為下一篇我們會用 TouchDesigner 當第一個前端。之後若要換成 Unity 或其他工具,只要改這個名稱,不用整段程式跟著改。
這一版多了什麼
上一版是「外部改 Prompt,程式自己生畫面」。這一版則是「外部同時丟 Prompt 和影像進來」。也就是說,StreamDiffusion 會開始真的扮演一個服務節點,不再只是自己關起門來產圖。
wait_for_ndi_source() 的作用,是在啟動時等待指定名稱的來源出現。這比直接硬抓第一個 NDI Source 安全很多,因為實際做系統時,電腦上常常不只一條 NDI 串流。先把來源名稱講清楚,後面才不會在錯的畫面上追 bug。
實際測試:外部畫面已經能進來
這個階段一樣可以先用 TouchDesigner 測。只要把一條即時畫面送成 td-input,再把 streamdiffusion-output 接回來,就能看到外部畫面真的進了 Img2Img 流程。

走到這裡,Prompt 控制和影像進出都已經打通了。之後再接 TouchDesigner、Unity,重點就不再是 Python 端會不會通訊,而是各自前端的互動設計要怎麼安排。
FAQ
為什麼控制訊息和影像要分成 OSC 與 NDI 兩條線?
因為這兩種資料本來就不是同一種東西。OSC 很適合傳 Prompt、參數、開關狀態;NDI 則是拿來穩定送畫面。分開之後,每一層出問題時比較好找,系統也比較容易擴充。
為什麼這裡還是維持 Python 3.10?
一方面是 StreamDiffusion 官方要求 Python 3.10 起跳,另一方面是 ndi-python 目前支援的 Windows Python 版本到 3.10。通訊層一旦加入之後,環境就不只是模型能不能跑,還要顧到視訊傳輸套件能不能裝。
總結
把 StreamDiffusion 變成互動系統的一部分,真正的第一步是先把通訊層搭起來。本文先用 OSC 接 Prompt,再用 NDI 收送畫面,讓 StreamDiffusion 從單次執行的範例,變成可以被外部系統穩定驅動的節點。這層一旦站穩,TouchDesigner 和 Unity 的整合就不再需要碰模型核心,只要專心處理各自的互動前端。
本文提到的通訊元件與文件,請參考: