:DirectShow采集與net PACS歸檔實戰(zhàn))
簡介這是一套基于C#與DirectShow技術棧的TCT病理圖文分析系統(tǒng)完整源碼采用Visual Studio 2008開發(fā)數(shù)據(jù)庫為Access可無縫升級為網(wǎng)絡版適合醫(yī)療信息化開發(fā)者、PACS方向?qū)W習者及C#桌面應用進階者參考。源碼已在醫(yī)院實際投入使用涵蓋運行期界面設計、所見即所得報告書寫窗口、DirectShow音視頻與圖像采集以及多個自定義組件的開發(fā)范例對理解net pacs類系統(tǒng)的業(yè)務與實現(xiàn)路徑有直接幫助。壓縮包共1093個文件約9.54MB以515個rtf報告模板、136個cs源碼、109個gif與41個ico界面資源、54個resx與53個resources資源文件為主另含38個dll依賴、9個csproj工程文件及sln解決方案結(jié)構(gòu)完整可直接打開運行。目前已有177人學習下載適合通過真實項目源碼掌握C#組件開發(fā)、圖像采集與醫(yī)療報告模塊設計。1. TCT病理圖文分析系統(tǒng)從玻片掃描到PACS歸檔的C#落地路徑TCT液基薄層細胞學檢測病理圖文分析系統(tǒng)核心工作流是顯微鏡相機采集玻片圖像C#上位機通過DirectShow驅(qū)動UVC相機完成幀回調(diào)再對圖像做預處理、標注、結(jié)構(gòu)化存儲最終以DICOM或自定義格式對接net PACS歸檔。這套系統(tǒng)解決的是病理科從“肉眼閱片手工報告”轉(zhuǎn)向“數(shù)字化采集圖文報告網(wǎng)絡歸檔”的問題適合做醫(yī)療影像上位機、PACS對接、工業(yè)相機采集的C#開發(fā)者。標題里的四個關鍵詞——C#、DirectShow、net、pacs——分別對應開發(fā)語言、采集框架、網(wǎng)絡通信層和歸檔目標。熱詞里高頻出現(xiàn)的“c#上位機”“c# usb攝像頭免費開源第三方組件”“c# directshow uvc 回調(diào)里區(qū)分多個攝像頭”恰好是這套系統(tǒng)最先撞上的三堵墻相機枚舉、多路回調(diào)區(qū)分、幀數(shù)據(jù)零拷貝。下面按“采集→處理→存儲→歸檔→避坑→進階”的順序把每個環(huán)節(jié)的參數(shù)、代碼和邊界講清楚。2. DirectShow采集UVC相機枚舉、回調(diào)與多路區(qū)分2.1 為什么在C#里選DirectShow而不是MediaFoundationWindows平臺下C#采集UVC相機有三條路DirectShow、MediaFoundation、廠商SDK。廠商SDK最省事但綁定品牌換相機就要重寫MediaFoundation是微軟主推的現(xiàn)代框架但C#封裝少、調(diào)試信息晦澀DirectShow雖然老但封裝成熟、資料多、對UVC免驅(qū)相機兼容性最好。常見做法是用DirectShowLib這個開源封裝它把IBaseFilter、ISampleGrabber、ICaptureGraphBuilder2等接口暴露給C#能在WinForm或WPF里直接跑。選DirectShow的另一個理由是回調(diào)模型清晰通過ISampleGrabberCB的SampleCB拿到每一幀的原始buffer不需要輪詢。對于TCT場景顯微鏡相機通常是200萬到500萬像素、幀率15到30fpsSampleCB的零拷貝特性比Bitmap輪詢省一半CPU。注意DirectShowLib本身不處理色彩空間轉(zhuǎn)換UVC相機輸出的YUY2或MJPG需要自己轉(zhuǎn)RGB否則SaveImage出來是花屏。2.2 枚舉設備與構(gòu)建采集圖的完整代碼下面這段代碼完成三件事枚舉所有視頻輸入設備、按名稱匹配目標相機、構(gòu)建CaptureGraph并掛上SampleGrabber。這是整個采集鏈路的骨架。using DirectShowLib; using System; using System.Runtime.InteropServices; public class CameraCapture { private IFilterGraph2 _graph; private ICaptureGraphBuilder2 _builder; private IBaseFilter _source; private ISampleGrabber _grabber; private IBaseFilter _grabberFilter; // 枚舉所有視頻輸入設備返回 名稱-設備路徑 映射 public static DsDevice[] EnumCameras() { return DsDevice.GetDevicesOfCat(FilterCategory.VideoInputDevice); } // 按名稱打開指定相機frameCallback 每幀觸發(fā) public void Start(string cameraName, ActionIntPtr, int, int, int frameCallback) { DsDevice[] devices EnumCameras(); DsDevice target null; foreach (var d in devices) { if (d.Name.Contains(cameraName)) { target d; break; } } if (target null) throw new Exception(未找到相機: cameraName); _graph (IFilterGraph2)new FilterGraph(); _builder (ICaptureGraphBuilder2)new CaptureGraphBuilder2(); _builder.SetFiltergraph(_graph); // 綁定設備到 Source Filter _graph.AddSourceFilterForMoniker(target.Mon, null, target.Name, out _source); // 創(chuàng)建 SampleGrabber _grabber (ISampleGrabber)new SampleGrabber(); _grabberFilter (IBaseFilter)_grabber; _graph.AddFilter(_grabberFilter, Grabber); // 設置媒體類型RGB24寬高由相機協(xié)商 AMMediaType mt new AMMediaType(); mt.majorType MediaType.Video; mt.subType MediaSubType.RGB24; _grabber.SetMediaType(mt); // 連接 Source - Grabber _builder.RenderStream(PinCategory.Capture, MediaType.Video, _source, null, _grabberFilter); // 設置回調(diào)1 表示 SampleCB零拷貝0 表示 BufferCB _grabber.SetCallback(new GrabberCallback(frameCallback), 1); IMediaControl control (IMediaControl)_graph; control.Run(); } }邏輯說明EnumCameras用DsDevice.GetDevicesOfCat拿到所有UVC設備這是區(qū)分多攝像頭的第一步。AddSourceFilterForMoniker把設備moniker綁定成Source Filter比直接按FriendlyName創(chuàng)建更可靠。SetMediaType指定RGB24讓SampleGrabber在回調(diào)前完成色彩轉(zhuǎn)換省掉手動YUY2轉(zhuǎn)RGB。SetCallback第二個參數(shù)傳1走SampleCB傳0走BufferCB——SampleCB的buffer指針在回調(diào)返回后失效必須立即拷貝或處理。參數(shù)說明PinCategory.Capture對應預覽/采集引腳如果相機有Still Pin需要抓單幀改成PinCategory.Still。RenderStream的第三個參數(shù)傳null表示自動選引腳多路相機時建議顯式指定引腳避免串流。2.3 多攝像頭回調(diào)里區(qū)分數(shù)據(jù)源的三個手段熱詞里“c# directshow uvc 回調(diào)里區(qū)分多個攝像頭”是真實痛點。SampleCB是全局回調(diào)多個相機共用一個回調(diào)實例時buffer里沒有設備ID。三種區(qū)分手段第一每個相機創(chuàng)建獨立的GrabberCallback實例在構(gòu)造函數(shù)里傳入cameraIndex回調(diào)里直接讀成員變量。這是最干凈的做法代價是每個相機一套Graph。第二用SampleGrabber的SetCallback時傳入不同的callback對象但共享同一個處理隊列隊列元素帶cameraIndex。適合相機數(shù)量多、Graph資源緊張的場景。第三在buffer頭部寫入自定義頭相機啟動時協(xié)商回調(diào)里解析頭拿ID。不推薦因為UVC相機不一定支持自定義媒體類型。我一般用第一種代碼直觀調(diào)試時看callback實例的hashcode就能定位是哪路相機。多相機場景下每個Graph獨立RunCPU占用會線性上升4路200萬像素RGB24、15fps大約吃掉25%的i5核心需要評估。3. 圖像預處理與TCT圖文結(jié)構(gòu)化存儲3.1 從幀buffer到可分析Bitmap的轉(zhuǎn)換鏈SampleCB拿到的IntPtr指向RGB24數(shù)據(jù)寬高和stride需要從GetCurrentBuffer或媒體類型里取。轉(zhuǎn)Bitmap的常見做法private void OnFrame(IntPtr buffer, int width, int height, int stride) { // RGB24 每像素3字節(jié)stride 可能大于 width*3內(nèi)存對齊 int dataSize stride * height; byte[] managed new byte[dataSize]; Marshal.Copy(buffer, managed, 0, dataSize); // 構(gòu)造 Bitmap注意 stride 對齊 Bitmap bmp new Bitmap(width, height, stride, System.Drawing.Imaging.PixelFormat.Format24bppRgb, Marshal.UnsafeAddrOfPinnedArrayElement(managed, 0)); // 立即 Clone否則 managed 被 GC 后 bmp 懸空 Bitmap safeCopy (Bitmap)bmp.Clone(); bmp.Dispose(); ProcessFrame(safeCopy); }邏輯說明Marshal.Copy把非托管buffer拷到托管數(shù)組這是必須的因為SampleCB返回后buffer失效。new Bitmap用stride構(gòu)造避免寬度不對齊導致的斜紋。Clone是關鍵——直接用托管數(shù)組構(gòu)造的Bitmap在數(shù)組被回收后會崩潰Clone出一份獨立內(nèi)存。參數(shù)說明stride通常等于width*3向上取到4的倍數(shù)。如果相機輸出MJPG需要先解碼再走這條鏈DirectShowLib不內(nèi)置MJPG解碼常見做法是接一個MJPG Decompressor Filter或用OpenCVSharp的Cv2.ImDecode。3.2 TCT圖文報告的數(shù)據(jù)模型與落庫TCT報告的核心字段病例號、采樣日期、玻片編號、鏡下描述、診斷結(jié)論、圖像列表。圖像不是存BLOB而是存文件路徑縮略圖BLOB。這樣數(shù)據(jù)庫輕圖像文件可以單獨備份。CREATE TABLE tct_case ( case_id VARCHAR(32) PRIMARY KEY, patient_name VARCHAR(64), slide_no VARCHAR(32), sample_date DATETIME, description TEXT, diagnosis VARCHAR(256), created_at DATETIME DEFAULT GETDATE() ); CREATE TABLE tct_image ( image_id INT IDENTITY PRIMARY KEY, case_id VARCHAR(32) FOREIGN KEY REFERENCES tct_case(case_id), file_path VARCHAR(512), thumb VARBINARY(MAX), capture_time DATETIME, camera_index INT );邏輯說明tct_case存文本報告tct_image存圖像元數(shù)據(jù)和縮略圖。camera_index字段對應多相機場景方便回溯是哪路相機拍的??s略圖存VARBINARY控制在50KB以內(nèi)列表頁直接讀不用碰磁盤。參數(shù)說明file_path用相對路徑根目錄配置在app.config里遷移服務器時只改配置。thumb建議統(tǒng)一256x256 JPEG質(zhì)量70太大拖慢列表查詢。3.3 圖像標注與報告生成的銜接病理醫(yī)生在圖像上畫感興趣區(qū)域ROI標注坐標要跟圖像一起存。常見做法是存JSON{rois:[{x:120,y:340,w:80,h:60,label:上皮細胞}]}。報告生成時用GDI或iTextSharp把原圖ROI框文本拼成PDF。這里不展開PDF庫選型只提醒一點ROI坐標基于原圖分辨率如果報告里縮放顯示要按比例換算否則框會偏。4. net PACS對接DICOM封裝與網(wǎng)絡傳輸4.1 為什么TCT圖像要封成DICOM而不是直接傳JPEGPACS是DICOM的世界。直接傳JPEGPACS能存但沒法按DICOM標簽檢索等于存了一堆死圖。封成DICOM把病例號、玻片號、采集時間寫進標準TagPACS才能按PatientID、StudyDate查詢。TCT圖像屬于Secondary CaptureSC或Visible Light Photographic用SecondaryCaptureImageStorageSOP Class足夠。4.2 用fo-dicom構(gòu)建SC圖像的代碼fo-dicom是C#里最成熟的DICOM庫支持net standardWinForm和WPF都能用。using Dicom; using Dicom.Imaging; public DicomDataset BuildTctDicom(Bitmap image, string caseId, string patientName) { var dataset new DicomDataset(DicomTransferSyntax.ExplicitVRLittleEndian); // SOP Class 和 Instance dataset.Add(DicomTag.SOPClassUID, DicomUID.SecondaryCaptureImageStorage); dataset.Add(DicomTag.SOPInstanceUID, DicomUID.Generate()); // 患者與檢查信息 dataset.Add(DicomTag.PatientID, caseId); dataset.Add(DicomTag.PatientName, patientName); dataset.Add(DicomTag.StudyDate, DateTime.Now.ToString(yyyyMMdd)); dataset.Add(DicomTag.Modality, SC); // 圖像像素數(shù)據(jù) var pixelData DicomPixelData.Create(dataset, true); pixelData.BitsAllocated 8; pixelData.BitsStored 8; pixelData.HighBit 7; pixelData.SamplesPerPixel 3; pixelData.PhotometricInterpretation PhotometricInterpretation.Rgb; pixelData.PlanarConfiguration 0; pixelData.Width image.Width; pixelData.Height image.Height; // Bitmap 轉(zhuǎn) byte[] byte[] pixels BitmapToRgbBytes(image); pixelData.AddFrame(pixels); return dataset; }邏輯說明SecondaryCaptureImageStorage是SC圖像的SOP ClassPACS通用支持。DicomPixelData.Create第二個參數(shù)true表示要寫像素。PhotometricInterpretation.Rgb對應彩色圖像如果是灰度涂片改成Monochrome2。AddFrame傳入的byte數(shù)組長度必須是WidthHeight3RGB。參數(shù)說明BitsAllocated8對應8位每通道TCT彩色圖像夠用。如果要做16位灰度改成16并調(diào)整HighBit。PlanarConfiguration0表示RGB交錯存儲這是DICOM默認。4.3 C-STORE發(fā)送到PACS與超時處理封好DICOM后用DicomClient發(fā)C-STOREvar client new DicomClient(); client.NegotiateAsyncOps(); var request new DicomCStoreRequest(dataset); client.AddRequest(request); await client.SendAsync(192.168.1.100, 104, false, TCT_SCU, PACS_AE);邏輯說明SendAsync參數(shù)依次是PACS IP、端口、useTLS、調(diào)用方AE Title、目標AE Title。104是DICOM標準端口。NegotiateAsyncOps開啟異步協(xié)商大圖像傳輸時避免阻塞。參數(shù)說明AE Title必須和PACS配置里登記的一致否則會被拒絕。超時默認30秒大圖像或網(wǎng)絡差時在DicomClient上設AssociationRequestTimeout。熱詞里“c# restclient.execute返回異常無法將數(shù)據(jù)寫入傳輸連接”在DICOM場景對應的是連接被PACS主動斷開常見原因是AE Title不匹配或PACS磁盤滿。注意C-STORE失敗時先看PACS返回的Status碼0x0000成功0xA700拒絕0xA801未授權。別一上來就抓包先核對AE Title和SOP Class。5. 避坑與排查TCT采集歸檔的五個血淚教訓5.1 相機枚舉到了但RenderStream失敗現(xiàn)象EnumCameras能看到設備AddSourceFilterForMoniker也成功但RenderStream拋異?;蚍祷刎撝怠T蛳鄼C被其他進程占用或者UVC驅(qū)動沒正確加載。Windows下相機是獨占設備QQ、釘釘、瀏覽器都可能偷偷占用。解決先control.Stop()釋放Graph再檢查設備管理器里相機有沒有黃色感嘆號。如果是筆記本內(nèi)置相機和外接USB相機同時存在確認moniker選的是外接那個。實在不行用GraphEdit工具手動連一遍看哪個引腳連不上。5.2 SampleCB回調(diào)里Bitmap花屏或斜紋現(xiàn)象保存的圖像上半部分正常下半部分斜紋或者整體顏色錯亂。原因stride沒對齊。RGB24每行字節(jié)數(shù)必須是4的倍數(shù)width641時stride1923但實際分配可能是1924。直接用width*3算偏移就錯位。解決從媒體類型里讀AMMediaType.lSampleSize或調(diào)用GetCurrentBuffer拿實際stride。構(gòu)造Bitmap時傳stride而不是width*3。顏色錯亂則檢查相機輸出是YUY2還是MJPGSetMediaType時指定RGB24讓DirectShow自動轉(zhuǎn)。5.3 多相機回調(diào)串數(shù)據(jù)現(xiàn)象兩路相機同時跑A相機的圖像偶爾出現(xiàn)在B相機的報告里。原因共用了同一個SampleGrabber實例或同一個callback對象回調(diào)里沒有區(qū)分數(shù)據(jù)源。解決每個相機獨立Graph、獨立SampleGrabber、獨立callback實例。callback構(gòu)造函數(shù)傳入cameraIndex處理時按index分發(fā)。不要圖省事用靜態(tài)變量存當前相機。5.4 DICOM發(fā)送成功但PACS查不到現(xiàn)象C-STORE返回0x0000但PACS客戶端按病例號搜不到。原因PatientID或StudyInstanceUID沒填或者StudyDate格式不對。PACS按Study級檢索沒有StudyInstanceUID就歸不到一次檢查里。解決補DicomTag.StudyInstanceUID和DicomTag.SeriesInstanceUID用DicomUID.Generate()生成。StudyDate用yyyyMMdd別用yyyy-MM-dd。Modality填SC或OT別留空。5.5 net framework 3.5安裝報錯導致老設備跑不起來現(xiàn)象客戶現(xiàn)場是Windows 7或Windows 10 LTSC裝net framework 3.5報0x80072f8f或0x80d03805。原因離線環(huán)境沒有Windows Update源或者系統(tǒng)鏡像的sxs目錄缺失。解決用離線安裝包dotnetfx35.exe配合/lang:zh-cn參數(shù)或者掛載系統(tǒng)ISO用dism /online /enable-feature /featurename:NetFx3 /source:D:\sources\sxs。如果目標機器是Windows 11net framework 3.5和4.8可以共存但安裝順序建議先3.5后4.8。6. 進階用DirectShowfo-dicom做端到端自動化歸檔把采集、處理、歸檔串成一條流水線是這套系統(tǒng)從“能用”到“好用”的分界線。我一般會做一個TctPipeline類把相機回調(diào)、圖像處理、DICOM封裝、C-STORE發(fā)送串起來用生產(chǎn)者-消費者隊列解耦采集和歸檔避免C-STORE的網(wǎng)絡延遲拖慢幀率。using System.Collections.Concurrent; using System.Threading.Tasks; public class TctPipeline { private BlockingCollectionBitmap _queue new BlockingCollectionBitmap(10); private string _pacsIp 192.168.1.100; private int _pacsPort 104; public void Start(string cameraName) { // 消費者后臺線程處理歸檔 Task.Run(() { foreach (var bmp in _queue.GetConsumingEnumerable()) { var ds BuildTctDicom(bmp, CASE001, 張三); var client new DicomClient(); client.AddRequest(new DicomCStoreRequest(ds)); client.Send(_pacsIp, _pacsPort, false, TCT_SCU, PACS_AE); bmp.Dispose(); } }); // 生產(chǎn)者相機回調(diào)入隊 var cap new CameraCapture(); cap.Start(cameraName, (buf, w, h, s) { var bmp ConvertToBitmap(buf, w, h, s); if (!_queue.TryAdd(bmp, 100)) // 100ms 超時 { bmp.Dispose(); // 隊列滿丟棄當前幀 } }); } }邏輯說明BlockingCollection容量10采集端TryAdd超時100ms隊列滿就丟幀保證采集不阻塞。消費端單線程發(fā)C-STORE網(wǎng)絡慢時隊列堆積丟幀比卡死好。BuildTctDicom和DicomClient復用第4章的代碼。參數(shù)說明隊列容量根據(jù)PACS響應速度調(diào)局域網(wǎng)PACS一般10夠用跨網(wǎng)段建議加到30。TryAdd超時別設0否則隊列滿時直接丟設100ms給一點緩沖。驗證方法跑起來后看三個指標——采集幀率是否穩(wěn)定、隊列長度是否持續(xù)為0、PACS端Study列表是否按病例號遞增。如果隊列長度一直漲說明C-STORE太慢先查PACS磁盤和網(wǎng)絡RTT。我踩過最深的坑是隊列沒設上限相機30fps往里灌PACS斷網(wǎng)時內(nèi)存半小時漲到2GB最后OOM。后來所有采集歸檔鏈路都加BlockingCollection上限丟幀比崩潰強。希望幫到你。本文還有配套的精品資源點擊獲取