Jak odczytać tagi DICOM w Pythonie

 



Nie ukrywam faktu, że pliki DICOM spędzały mi sen z powiek. Nie miałam intrukcji jak się do nich dostać, więc wszystko musiałam robić sama. To było przed erą LLM, a w Internecie poradników jak na lekarstwo. Nie było nic, oprócz ogólnych poradników w internecie i 90-stronicowej dokumentacji na stronie DICOM-a. Spróbuję więc Ci wyjaśnić, czym tak naprawdę jest DICOM.


Plik DICOM to nie jest obrazek z opisem, tylko słownik, w którym obraz jest jedną z pozycji. Każda informacja siedzi pod adresem złożonym z dwóch liczb szesnastkowych, a żeby ją wyciągnąć, wystarczy pydicom i trzy linijki kodu.Inaczej jest z dostaniem się do bardziej wartościowych danych, które często są zakodowane. Jednak dziś pokażę Ci tę prostszą drogę.

Czym są tagi?

Tag to para liczb w formacie (grupa, element), na przykład (0010,0010) to nazwisko pacjenta, a (0028,0010) to liczba wierszy obrazu. Grupa mówi, o czym jest ta informacja: 0010 dotyczy pacjenta, 0018 parametrów akwizycji, 0020 położenia w przestrzeni i serii, 0028 samego obrazu.

Przy każdym tagu zapisany jest jeszcze VR, czyli Value Representation, dwie litery określające typ danych. PN to nazwisko, DS liczba dziesiętna zapisana jako tekst, US liczba całkowita bez znaku, SQ zagnieżdżona sekwencja. VR jest powodem, dla którego liczby z DICOM-a często przychodzą jako napisy i trzeba je rzutować.

Struktura pliku

Na początku jest 128 bajtów wypełniacza, potem cztery znaki DICM. Dalej idzie grupa 0002, czyli metadane pliku, zawsze zapisana w tym samym formacie. Dopiero w niej, pod tagiem (0002,0010), siedzi informacja o tym, jak zakodowana jest cała reszta. To dlatego nie da się czytać DICOM-a od środka: trzeba zacząć od nagłówka.
import pydicom

ds = pydicom.dcmread("skan.dcm")
# przez nazwę
print(ds.PatientID)
print(ds.Modality)
# przez tag, gdy nazwy nie pamiętasz
print(ds[0x0028, 0x0010].value)
# gdy tag może nie istnieć
print(ds.get("SliceThickness", "brak"))
# wszystko, co jest w pliku
for elem in ds:
    if elem.tag != (0x7FE0, 0x0010):   # pomijamy piksele
        print(elem)


Ostatnia pętla to najszybszy sposób, żeby zobaczyć, co producent skanera faktycznie zapisał. Standard dopuszcza mnóstwo pól opcjonalnych i każdy aparat wypełnia je po swojemu.

NazwaDo czego
(0008,0060)ModalityCT, MR, CR, US
(0010,0020)PatientIDidentyfikator pacjenta
(0020,000D)StudyInstanceUIDcałe badanie
(0020,000E)SeriesInstanceUIDjedna seria
(0020,0032)ImagePositionPatientpozycja przekroju w mm
(0028,0010) / (0028,0011)Rows / Columnsrozmiar w pikselach
(0028,0030)PixelSpacingrozmiar piksela w mm
(0018,0050)SliceThicknessgrubość warstwy
(0028,1052) / (0028,1053)RescaleIntercept / Slopeprzeliczenie na jednostki Hounsfielda
(7FE0,0010)PixelData

Uważaj na pułapki!

Surowe piksele to nie są jednostki Hounsfielda. Wartości z PixelData trzeba przeliczyć: HU = piksel * RescaleSlope + RescaleIntercept. Pominięcie tego kroku to najczęstszy powód, dla którego model uczony na kilku zbiorach nagle przestaje działać.

Kolejność plików w folderze nie jest kolejnością przekrojów. Nazwy plików bywają losowe. Sortuj po trzeciej współrzędnej z ImagePositionPatient, a nie po InstanceNumber, bo ten drugi bywa zapisany od góry albo od dołu, zależnie od aparatu.

Usunięcie nazwiska to nie anonimizacja. Dane pacjenta potrafią siedzieć w tagach prywatnych, w polach opisowych i czasem wprost wypalone w pikselach, co sygnalizuje tag (0028,0301). Do publikacji zbioru używaj gotowego profilu anonimizacji, nie własnej listy pól do wyczyszczenia.

Gdzie to się gryzie w praktyce?

Większość błędów w projektach z obrazami medycznymi nie bierze się z architektury sieci, tylko z tego, że ktoś nie zajrzał do nagłówka. Dwa zbiory o różnym PixelSpacing dają obiekty tej samej wielkości fizycznej w innej liczbie pikseli, a sieć uczy się wtedy rozpoznawać producenta skanera zamiast zmiany chorobowej.

Komentarze