الأدلة التقنية

كيف تعمل أنظمة الصور الطبية؟ DICOM وPACS من الداخل

كيف تحفظ المستشفيات صور الأشعة وتنقلها بمعيار DICOM، وكيف تشغل أرشيفاً وعارضاً على سيرفرك وتجربهما على صور حقيقية.

كيف تعمل أنظمة الصور الطبية؟ DICOM وPACS من الداخل

عندما يطلب منك مستشفى أو مركز أشعة أن تعرض صور المرضى في تطبيقه، أو أن تربط نموذج ذكاء اصطناعي بقسم الأشعة، فأول ما يخطر في البال هو أن الصورة الطبية صورة كأي صورة: ملف JPEG أو PNG تحفظه في مجلد وتضع اسمه في قاعدة البيانات. ولكن بعد أول ملف تستلمه من جهاز الأشعة المقطعية سوف تكتشف أنه ليس صورة واحدة وإنما مئات الشرائح، وأن كل بكسل فيه رقم من 16 بت يمثل كثافة نسيج حقيقية وليس لوناً، وأن مع الصورة اسم المريض ورقمه وتاريخ الفحص والجهاز والمسافة بين البكسلات بالمليمتر واتجاه الجسم، وأن كل هذا يتحرك بين الأجهزة بمعيار واحد عمره أكثر من ثلاثين سنة اسمه DICOM. والطريقة البسيطة (تحويل كل شيء إلى JPEG) تفقدك كل ذلك دفعة واحدة، كما سوف نرى بالأرقام.

وهذا هو المقال الأول في مرحلة التقنية في الصحة، وسوف نناقش فيه ما يلي:

  • ماذا تسجل كل صورة طبية فيزيائياً: الأشعة السينية X-ray والأشعة المقطعية CT والرنين المغناطيسي MRI والموجات فوق الصوتية Ultrasound، باختصار يكفي المطور.
  • ملف DICOM من الداخل: الوسوم Tags وبيانات البكسل Pixel Data، والهرم من المريض إلى الصورة، والمعرفات UID، وصيغ النقل Transfer Syntax والضغط ومنه JPEG 2000.
  • وحدات هاونسفيلد HU والنافذة Window في الأشعة المقطعية، بمثال محسوب رقماً رقماً.
  • نظام أرشفة الصور PACS، وواجهات DICOMweb الثلاث QIDO-RS وWADO-RS وSTOW-RS، والعارض Viewer.
  • إزالة الهوية De-identification بحسب ملفات التعريف في الجزء PS3.15 من المعيار، وخطر النص المحروق داخل الصورة.
  • الذكاء الاصطناعي في الأشعة: التقسيم Segmentation بشبكة U-Net وإطار MONAI، وكم جهازاً معتمداً لدى FDA، ولماذا لا يكفي رقم الدقة.
  • مختبر كامل على Docker: Orthanc وعارض OHIF وصور أشعة مقطعية حقيقية من أرشيف عام برخصة مفتوحة، نقرأ وسومها ونطبق النوافذ ونستعلم عنها بال curl ونزيل الهوية ونقارن الوسوم قبل وبعد.

ماذا تسجل كل صورة طبية؟

قبل الملفات والبروتوكولات تحتاج أن تعرف ماذا يمثل البكسل نفسه، والسبب أن كل قرار تقني بعد ذلك (كم بت للبكسل، وهل يقبل الضغط مع فقدان، وكيف يعرض على الشاشة) يعتمد على نوع الجهاز الذي أنتج الصورة، ويسميه المعيار نمط التصوير Modality ويحفظه في الوسم Modality بقيم مثل CR وDX للأشعة السينية وCT وMR وUS. والصورة التالية تبين الأنواع الأربعة الأشهر:

أربع لوحات: الأشعة السينية X-ray أنبوب فوق لوح كاشف تمر الأشعة بينهما فتخرج صورة ظل ثنائية الأبعاد، والأشعة المقطعية CT حلقة يدور فيها الأنبوب حول المريض فيخرج الحاسب شرائح ثلاثية الأبعاد بوحدات HU، والرنين المغناطيسي MRI مغناطيس كبير وموجات راديو تلتقط إشارة الهيدروجين دون إشعاع مؤين، والموجات فوق الصوتية Ultrasound مجس يرسل نبضات صوت ويستقبل صداها فيخرج فيديو مباشر
كل جهاز يقيس خاصية فيزيائية مختلفة، لذلك يختلف معنى رقم البكسل من جهاز لآخر
  • الأشعة السينية X-ray: أنبوب يرسل أشعة تمر في الجسم إلى لوح كاشف، والعظم يمتص أكثر من الرئة فيظهر أبيض، والنتيجة ظل ثنائي الأبعاد تتراكب فيه كل الأعضاء التي مرت بها الأشعة فوق بعضها، لذلك يطلب الطبيب أحياناً صورتين من زاويتين.
  • الأشعة المقطعية CT: الأنبوب نفسه ولكنه يدور حول المريض ويأخذ مئات الإسقاطات، ثم يعيد الحاسب بناء شرائح Slices كل بكسل فيها رقم يمثل كثافة النسيج بوحدة هاونسفيلد HU، وهي وحدة ثابتة بالفيزياء (الهواء قرابة 1000 سالب والماء صفر)، وهذا ما يجعل CT أسهل مكان يبدأ منه الذكاء الاصطناعي، لأن الرقم نفسه له معنى في كل جهاز.
  • الرنين المغناطيسي MRI: لا يوجد فيه إشعاع مؤين، وإنما مغناطيس قوي وموجات راديو تلتقط إشارة ذرات الهيدروجين في الأنسجة، ويخرج منه عدة أنواع من الصور لنفس المكان (T1 وT2 وغيرها) بتباين مختلف، ولكن قيمة البكسل فيه نسبية وليست وحدة ثابتة مثل HU، فلا تقارن رقم بكسل من جهاز MRI برقم من جهاز آخر دون معالجة.
  • الموجات فوق الصوتية Ultrasound: مجس يرسل نبضات صوت ويحسب العمق من زمن رجوع الصدى، والناتج فيديو مباشر بجانب المريض، وفي ملف DICOM يكون غالباً عدة إطارات Frames في ملف واحد، وكثيراً ما تجد عليه نصوصاً مكتوبة داخل الصورة نفسها، وهذه سوف نعود إليها في قسم إزالة الهوية.

وفي هذا المقال نعمل على الأشعة المقطعية CT، والسبب أن فيها كل ما يحتاجه المطور ليفهم الباقي: عدد كبير من الملفات للفحص الواحد، وبكسل من 16 بت له وحدة فيزيائية، ونوافذ عرض، وحجم يجعل الضغط والتخزين سؤالاً حقيقياً.

لماذا لا نحفظها JPEG ونرتاح؟

لنفرض أنك أخذت الطريق البسيط وحولت كل شريحة إلى PNG بثمانية بتات وحفظت اسم المريض في جدول بجانبها، فما الذي يضيع؟

  • الدقة الرقمية: شريحة المختبر التي سوف نستخدمها فيها قيم من 1024 سالب إلى 1421 بوحدة HU، أي قرابة 2,445 قيمة مختلفة، والصورة بثمانية بتات تحمل 256 قيمة فقط، فإما أن تضغط كل المدى في 256 درجة فتختفي الفروق بين الأنسجة الرخوة، أو تختار نافذة واحدة وتحذف الباقي، ولن يستطيع الطبيب بعد ذلك أن يرى الرئة والعظم من نفس الملف.
  • الهندسة: المسافة بين البكسلات بالمليمتر (PixelSpacing) وموقع الشريحة واتجاهها (ImagePositionPatient وImageOrientationPatient) هي ما يسمح للعارض بقياس حجم ورم بالمليمتر وبناء صورة ثلاثية الأبعاد، وPNG لا يحمل شيئاً منها.
  • الربط: الشريحة تنتمي إلى سلسلة تنتمي إلى فحص لمريض محدد بمعرفات عالمية فريدة، وأي نظام آخر في المستشفى (التقرير، والطلب، والفوترة) يشير إليها بهذه المعرفات، فإذا ضاعت أصبحت الصورة يتيمة.
  • التبادل: كل جهاز أشعة وكل نظام أرشفة وكل عارض في العالم يتكلم DICOM، فإذا اخترعت صيغتك فأنت تكتب محولاً لكل جهاز ولكل نظام.

لذلك فالحل هو أن تقبل DICOM كما هو وتفهمه، والمعيار نفسه منشور مجاناً على الموقع الرسمي للمعيار الذي تديره جمعية NEMA، وهو مقسم إلى أجزاء مرقمة PS3.1 وPS3.2 وهكذا، وسوف نشير في هذا المقال إلى الأجزاء التي نحتاجها بالاسم والرابط.

ملف DICOM من الداخل

ملف DICOM بحسب الفصل السابع من الجزء PS3.10 يبدأ بمقدمة Preamble طولها 128 بايت (غالباً أصفار)، ثم الحروف الأربعة DICM، ثم مجموعة الوسوم الوصفية File Meta Information التي تبدأ كلها بالمجموعة 0002 وأهمها صيغة النقل Transfer Syntax، ثم مجموعة البيانات Data Set: قائمة طويلة من العناصر Data Elements، وكل عنصر له وسم Tag من رقمين (المجموعة والعنصر) مثل (0010,0010) لاسم المريض، ونوع قيمة Value Representation واختصاراً VR مثل PN لاسم الشخص وDA للتاريخ وUI للمعرف، ثم القيمة. وآخر عنصر عادة هو (7FE0,0010) بيانات البكسل Pixel Data، والصورة التالية تبين ذلك بقيم من ملف المختبر:

على اليسار ملف DICOM من الأعلى للأسفل: مقدمة 128 بايت، ثم DICM، ثم الوسوم الوصفية في المجموعة 0002 ومنها Transfer Syntax، ثم وسوم مثل Patient ID وStudy UID وModality وRescale Intercept، وفي الأسفل Pixel Data بحجم 512 في 512 في 2 بايت. وعلى اليمين الهرم: Patient بمعرف PatientID، ثم Study بمعرف StudyInstanceUID وهي زيارة واحدة للجهاز، ثم Series بمعرف SeriesInstanceUID وفيها 181 شريحة، ثم Instance بمعرف SOPInstanceUID وهي صورة واحدة في ملف واحد
الملف وسوم ثم بكسلات، والملفات نفسها مرتبة في هرم من أربعة مستويات

لاحظ أن الملف لا يحمل صورة فقط وإنما يحمل معها كل ما يلزم لفهمها، ولهذا يسمي المعيار الملف كائناً Object وليس صورة، فهناك كائنات DICOM لا تحتوي بكسلات أصلاً، مثل التقرير المنظم Structured Report واختصاراً SR الذي سوف نراه عند الحديث عن الذكاء الاصطناعي.

الهرم: المريض والفحص والسلسلة والصورة

كل نظام DICOM يرتب الملفات في أربعة مستويات، وهذا الترتيب هو أساس كل استعلام سوف تكتبه:

  • المريض Patient: يعرف ب PatientID، وهو رقم داخلي في المستشفى وليس عالمياً، فالمريض نفسه قد يكون له رقم مختلف في مستشفى آخر.
  • الفحص Study: زيارة واحدة للجهاز بطلب واحد من الطبيب، ويعرف ب StudyInstanceUID، ومعه غالباً رقم الطلب Accession Number الذي يربطه بنظام معلومات الأشعة RIS.
  • السلسلة Series: مجموعة صور أخذت بنفس الإعدادات، مثل الشرائح بسماكة 1 مليمتر قبل الصبغة وبعدها، وتعرف ب SeriesInstanceUID.
  • الصورة Instance: كائن واحد في ملف واحد عادة، ويعرف ب SOPInstanceUID.

والمعرف UID سلسلة أرقام تفصلها نقاط مثل 1.2.826.0.1.3680043.2.1125...، وبحسب الفصل التاسع من الجزء PS3.5 لا يتجاوز طوله 64 حرفاً، ويبدأ ببادئة Root تملكها الجهة المصنعة أو المؤسسة ثم أرقام تضمن أنه لا يتكرر في العالم. وقد يتساءل البعض: لماذا لا نستخدم UUID كما نفعل في تطبيقاتنا؟ والإجابة أن المعيار أقدم من انتشار UUID، ولكن الملحق B.2 من الجزء PS3.5 يسمح بتحويل UUID إلى UID يبدأ ب 2.25. ثم رقم UUID بالنظام العشري، وهذا ما تفعله بعض الأدوات الحديثة. وقد شرحنا أنواع المعرفات وكيف تضمن عدم تكرارها في مقال المعرفات الفريدة.

تجهيز المختبر: Orthanc وOHIF وصور حقيقية

سوف نشغل ثلاث Containers على شبكة واحدة: Orthanc وهو سيرفر DICOM خفيف يعمل كنظام أرشفة PACS صغير ومعه واجهة DICOMweb، وOHIF Viewer وهو عارض صور طبية يعمل في المتصفح، وContainer لبايثون نقرأ فيه الملفات بمكتبة pydicom. وكل المنافذ على 127.0.0.1 فقط، والسبب أن Orthanc في هذا المختبر يعمل دون تسجيل دخول كما سوف ترى في سجله.

ويجدر الانتباه إلى الرخص قبل أن تبني عليها منتجاً: Orthanc نفسه برخصة GPLv3، أما إضافة DICOMweb التي نستخدمها فبرخصة AGPLv3 كما في ملف README الخاص بالإضافة، وتشرح صفحة الترخيص في كتاب Orthanc أن الإضافات الخاصة بالسحابة والتوسع اختيرت لها AGPL حتى لا يستخدمها أحد كخدمة على الإنترنت دون أن يعيد تعديلاته للمجتمع، أما OHIF فبرخصة MIT. وقد شرحنا الفرق بين هذه الرخص في مقال البرمجيات مفتوحة المصدر وتراخيصها.

الصور: فحص أشعة مقطعية من أرشيف TCIA

لا تقم بتجربة أي شيء في هذا المقال على صور مرضى حقيقيين من مستشفى، والسبب أن البيانات الصحية بيانات حساسة بنص نظام حماية البيانات الشخصية. لذلك نأخذ فحصاً من مجموعة Pancreas-CT في أرشيف The Cancer Imaging Archive واختصاراً TCIA، وهي صور أشعة مقطعية للبطن أزيلت هويتها ونشرت برخصة CC BY 3.0 مع شرط النسبة إلى المصدر (DOI المجموعة)، ومع سياسة استخدام تمنع أي محاولة للتعرف على أصحاب الصور. والأرشيف يوفر واجهة REST عامة، فنطلب أولاً قائمة السلاسل في المجموعة مع عدد الصور ورخصة كل سلسلة:

mkdir -p ~/lab-health && cd ~/lab-health
curl -s "https://services.cancerimagingarchive.net/nbia-api/services/v1/getSeries?Collection=Pancreas-CT&format=json" -o series.json
python3 -c "
import json; d = sorted(json.load(open('series.json')), key=lambda s: s['ImageCount'])
print(len(d), 'series'); s = d[0]
print(s['PatientID'], s['ImageCount'], 'images', s['FileSize'], 'bytes', s['LicenseName'])
print(s['SeriesInstanceUID'])"
80 series
PANCREAS_0080 181 images 95117092 bytes Creative Commons Attribution 3.0 Unported License
1.2.826.0.1.3680043.2.1125.1.41202274843063370955090296887703130

ثم ننزل أصغر سلسلة، وهي 181 شريحة في ملف ZIP حجمه 43 ميغابايت تقريباً:

curl -s -o ct.zip "https://services.cancerimagingarchive.net/nbia-api/services/v1/getImage?SeriesInstanceUID=1.2.826.0.1.3680043.2.1125.1.41202274843063370955090296887703130"
mkdir ct && cd ct && unzip -q ../ct.zip && ls | wc -l && du -sh . && cd ..
182
92M	.

والملف الزائد هو LICENSE الذي يرفقه الأرشيف مع كل تنزيل وفيه نص الرخصة وسياسة الاستخدام، فاحتفظ به مع الصور.

تشغيل Orthanc وOHIF

العارض OHIF يطلب الصور من المتصفح مباشرة، فإذا كان على منفذ وOrthanc على منفذ آخر فسوف يمنعك المتصفح بسبب سياسة CORS، وقد شرحناها في مقال CORS للمطورين. والحل الأبسط أن يخدم nginx الموجود داخل Docker Image الخاصة ب OHIF العارض والمسار /orthanc/ معاً، فيصبح الاثنان من نفس الأصل Origin. لذلك نكتب ملف إعداد nginx بدل الملف الافتراضي في ال Image:

# ohif.conf.template
server {
  gzip_static  always;
  gunzip       on;
  listen ${PORT} default_server;
  location / {
    root   /usr/share/nginx/html;
    index  index.html;
    try_files $uri $uri/ ${PUBLIC_URL}index.html;
  }
  # same origin for the viewer: /orthanc/ goes to Orthanc's REST and DICOMweb API
  location /orthanc/ {
    proxy_pass http://lab-health-orthanc:8042/;
    proxy_set_header Host $host;
  }
}

ثم ملف إعداد العارض الذي يخبره أين يجد واجهات DICOMweb:

// app-config.js
window.config = {
  routerBasename: '/',
  showStudyList: true,
  extensions: [],
  modes: [],
  dataSources: [
    {
      namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
      sourceName: 'orthanc',
      configuration: {
        friendlyName: 'Orthanc lab',
        name: 'orthanc',
        qidoRoot: '/orthanc/dicom-web',
        wadoRoot: '/orthanc/dicom-web',
        wadoUriRoot: '/orthanc/wado',
        qidoSupportsIncludeField: false,
        imageRendering: 'wadors',
        thumbnailRendering: 'wadors',
        enableStudyLazyLoad: true,
        supportsFuzzyMatching: false,
        supportsWildcard: true,
        staticWado: false,
        singlepart: 'bulkdata,video',
        bulkDataURI: { enabled: true, relativeResolution: 'studies' },
      },
    },
  ],
  defaultDataSourceName: 'orthanc',
};

والآن نشغل الثلاثة:

docker network create lab-health-net

docker run -d --name lab-health-orthanc --network lab-health-net -p 127.0.0.1:18042:8042 \
  -e ORTHANC__AUTHENTICATION_ENABLED=false -e DICOM_WEB_PLUGIN_ENABLED=true -e ORTHANC__NAME=lab-health \
  orthancteam/orthanc:26.9.1

docker run -d --name lab-health-ohif --network lab-health-net -p 127.0.0.1:18043:80 \
  -v "$PWD/ohif.conf.template:/usr/src/default.conf.template:ro" \
  -v "$PWD/app-config.js:/usr/share/nginx/html/app-config.js" \
  ohif/app:v3.13.12

docker run -d --name lab-health-py --network lab-health-net -v "$PWD:/work" -w /work python:3.13.16-slim sleep infinity
docker exec lab-health-py pip install -q pydicom==3.0.2 numpy==2.5.3 pillow==12.3.0 requests==2.34.2

docker logs lab-health-orthanc 2>&1 | grep -E "INSECURE|listening|started"
curl -s 127.0.0.1:18042/plugins
W1009 08:47:39.174637             MAIN main.cpp:1438] DICOM server listening with AET ORTHANC on port: 4242
W1009 08:47:39.174754             MAIN main.cpp:1146] ====> Remote access is enabled while user authentication is explicitly disabled, your setup is POSSIBLY INSECURE <====
W1009 08:47:39.186807             MAIN HttpServer.cpp:2028] HTTP server listening on port: 8042 (HTTPS encryption is disabled, remote access is allowed)
W1009 08:47:39.186887             MAIN main.cpp:957] Orthanc has started
[
   "explorer.js",
   "dicom-web",
   "gdcm",
   "orthanc-explorer-2"
]

في الإعداد أعلاه لاحظ التالي:

  • الوسم 26.9.1 هو إصدار حزمة Orthanc Team، وبداخلها Orthanc نفسه بالإصدار 1.13.0 كما يظهر في curl 127.0.0.1:18042/system، فلا تخلط بين الرقمين عند قراءة التوثيق.
  • Orthanc يفتح منفذين: 8042 لواجهة HTTP وDICOMweb، و4242 لبروتوكول DICOM التقليدي الذي ترسل عليه أجهزة الأشعة، ولم ننشر 4242 لأننا لن نستخدمه هنا.
  • السطر POSSIBLY INSECURE تحذير صريح من Orthanc نفسه: تسجيل الدخول معطل والوصول من الشبكة مسموح، وهذا مقبول فقط لأن المنفذ مربوط ب 127.0.0.1، ولا تقم بذلك على سيرفر فيه صور مرضى إطلاقاً.
  • أسماء متغيرات البيئة بالشكل ORTHANC__AUTHENTICATION_ENABLED تتحول داخل ال Container إلى الإعداد AuthenticationEnabled في ملف JSON الخاص ب Orthanc، أي أن الشرطتين السفليتين تفصلان مستويات الإعداد.

نقرأ الوسوم بأنفسنا مع pydicom

السكربت التالي يقرأ ملفاً واحداً ويطبع الوسوم التي سوف نحتاجها في بقية المقال:

# show_tags.py
import pydicom, sys
ds = pydicom.dcmread(sys.argv[1])
print("File Meta:")
for k in ["MediaStorageSOPClassUID", "TransferSyntaxUID"]:
    e = ds.file_meta[k]; print(f"  {e.tag} {e.keyword:26} {e.value} ({e.value.name})")
print("Dataset:")
for k in ["PatientID","PatientName","PatientBirthDate","PatientSex","StudyInstanceUID","SeriesInstanceUID","SOPInstanceUID",
          "Modality","StudyDate","Manufacturer","InstanceNumber","SliceThickness","PixelSpacing","Rows","Columns",
          "BitsAllocated","BitsStored","PixelRepresentation","RescaleIntercept","RescaleSlope","WindowCenter","WindowWidth"]:
    if k in ds:
        e = ds[k]; print(f"  {e.tag} {e.VR} {e.keyword:22} {e.value}")
    else:
        print(f"  {'':11} -- {k:22} (absent)")
px = ds["PixelData"]
print(f"  {px.tag} {px.VR} PixelData              {len(px.value)} bytes")
docker exec lab-health-py python show_tags.py ct/00000090.dcm

والمخرج سوف يكون كما يلي:

File Meta:
  (0002,0002) MediaStorageSOPClassUID    1.2.840.10008.5.1.4.1.1.2 (CT Image Storage)
  (0002,0010) TransferSyntaxUID          1.2.840.10008.1.2 (Implicit VR Little Endian)
Dataset:
  (0010,0020) LO PatientID              PANCREAS_0080
  (0010,0010) PN PatientName            PANCREAS_0080
  (0010,0030) DA PatientBirthDate
  (0010,0040) CS PatientSex
  (0020,000D) UI StudyInstanceUID       1.2.826.0.1.3680043.2.1125.1.60401621965073299400071712795433179
  (0020,000E) UI SeriesInstanceUID      1.2.826.0.1.3680043.2.1125.1.41202274843063370955090296887703130
  (0008,0018) UI SOPInstanceUID         1.2.826.0.1.3680043.2.1125.1.6713824710102630254823818254255612
  (0008,0060) CS Modality               CT
  (0008,0020) DA StudyDate              20151124
  (0008,0070) LO Manufacturer
  (0020,0013) IS InstanceNumber         3
  (0018,0050) DS SliceThickness         None
  (0028,0030) DS PixelSpacing           [0.9765625, 0.9765625]
  (0028,0010) US Rows                   512
  (0028,0011) US Columns                512
  (0028,0100) US BitsAllocated          16
  (0028,0101) US BitsStored             16
  (0028,0103) US PixelRepresentation    1
  (0028,1052) DS RescaleIntercept       0
  (0028,1053) DS RescaleSlope           1
              -- WindowCenter           (absent)
              -- WindowWidth            (absent)
  (7FE0,0010) OW PixelData              524288 bytes

في المخرج أعلاه لاحظ التالي:

  • الملف اسمه 00000090.dcm ولكن رقمه في السلسلة InstanceNumber هو 3، فاسم الملف لا يعني شيئاً، ولا تقم بترتيب الشرائح بأسماء الملفات أبداً، والصحيح أن ترتبها بموقعها في الفضاء ImagePositionPatient، فقد يكون InstanceNumber نفسه ناقصاً في بعض الأجهزة.
  • تاريخ الميلاد والجنس والجهة المصنعة فارغة، واسم المريض هو رقم الحالة في المجموعة، والسبب أن TCIA أزالت الهوية قبل النشر بأداتها CTP، وسوف نرى أثرها في وسم خاص (0013,0010) قيمته CTP.
  • حجم البكسلات 524,288 بايت، أي 512 في 512 بكسل في 2 بايت لكل بكسل، وPixelRepresentation قيمته 1 أي أن الأرقام بإشارة Signed، وهذا ضروري لأن قيم HU فيها سالب.
  • RescaleSlope وRescaleIntercept هما المعادلة التي تحول الرقم المخزن إلى HU، وهنا 1 و0 أي أن الرقم المخزن هو HU نفسه، ولكن أغلب الأجهزة تخزن أرقاماً موجبة وتضع RescaleIntercept بقيمة 1024 سالب، وسوف نكسر العرض عمداً بسبب هذا الفرق بعد قليل.
  • لا توجد نافذة عرض مقترحة WindowCenter في هذا الملف، فالعارض سوف يختار نافذة من عنده أو يطلبها منك.

وبحسب ما سبق فالملف الواحد هنا حجمه قرابة نصف ميغابايت، والفحص 181 ملفاً، أي قرابة 90 ميغابايت لفحص بطن واحد بدون ضغط، وهذا يقودنا إلى صيغ النقل والضغط.

صيغة النقل Transfer Syntax والضغط

صيغة النقل هي اتفاق على ثلاثة أشياء: هل نوع القيمة VR مكتوب صراحة في الملف (Explicit) أم يستنتجه القارئ من القاموس (Implicit)، وترتيب البايتات Little Endian أو Big Endian، وهل البكسلات مضغوطة وبأي طريقة. ولكل صيغة معرف UID، فالملف الذي قرأناه صيغته 1.2.840.10008.1.2 أي Implicit VR Little Endian، وهي الصيغة الافتراضية الأقدم. أما الضغط فتعرف له صيغ كثيرة منها JPEG Lossless وJPEG-LS وJPEG 2000 في القسم 8.2.4 من الجزء PS3.5، وكل صيغة تحدد هل الضغط دون فقدان Lossless أو مع فقدان Lossy. وقد شرحنا فكرة الترميز Codec وكيف يصغر الحجم في مقال مفاهيم الفيديو، والفكرة هنا نفسها مع صورة واحدة بدل الإطارات.

والسؤال العملي: كم نوفر؟ Orthanc يستطيع إعادة كتابة أي صورة بصيغة نقل أخرى بالأمر modify مع الحقل Transcode، فنجربه على الشريحة 91 بخمس صيغ:

OID=$(curl -s -X POST http://127.0.0.1:18042/tools/lookup -d 1.2.826.0.1.3680043.2.1125.1.70847515432480805264903410287002376 | jq -r '.[0].ID')
for ts in 1.2.840.10008.1.2.1 1.2.840.10008.1.2.4.70 1.2.840.10008.1.2.4.80 1.2.840.10008.1.2.4.90 1.2.840.10008.1.2.5 1.2.840.10008.1.2.4.91; do
  curl -s -X POST "http://127.0.0.1:18042/instances/$OID/modify" -d "{\"Transcode\":\"$ts\",\"Keep\":[\"SOPInstanceUID\"],\"Force\":true}" -o ts-$ts.dcm
  echo "$ts $(stat -c %s ts-$ts.dcm)"
done
1.2.840.10008.1.2.1 525534
1.2.840.10008.1.2.4.70 197386
1.2.840.10008.1.2.4.80 161684
1.2.840.10008.1.2.4.90 165824
1.2.840.10008.1.2.5 258
1.2.840.10008.1.2.4.91 165824

الملف الذي حجمه 258 بايت ليس صورة وإنما رسالة خطأ، فلنقرأها:

cat ts-1.2.840.10008.1.2.5.dcm
{
	"HttpError" : "Internal Server Error",
	"HttpStatus" : 500,
	"Message" : "Not implemented yet",
	"Method" : "POST",
	"OrthancError" : "Not implemented yet",
	"OrthancStatus" : 2,
	"Uri" : "/instances/5f0600fb-89477ce2-f39326eb-c251cecc-3b306163/modify"
}

أي أن هذا الإصدار لا يكتب صيغة RLE، وهذا درس صغير: لا تفترض أن كل سيرفر يدعم كل صيغة، وافحص الحجم ورمز الحالة قبل أن تحفظ الناتج. والآن نتحقق من أن الضغط دون فقدان فعلاً لم يغير بكسلاً واحداً، وذلك بفك كل ملف ومقارنته بالأصل، ونحتاج لذلك مكتبات فك الضغط مع pydicom:

docker exec lab-health-py pip install -q pylibjpeg==2.1.0 pylibjpeg-libjpeg==2.4.0 pylibjpeg-openjpeg==2.6.0 pyjpegls==1.5.1
# tscheck.py
import pydicom, numpy as np, os, glob
ref = pydicom.dcmread("ts-1.2.840.10008.1.2.1.dcm").pixel_array
for f in sorted(glob.glob("ts-*.dcm")):
    if os.path.getsize(f) < 1000: continue
    ds = pydicom.dcmread(f)
    a = ds.pixel_array
    diff = int(np.abs(a.astype(int) - ref.astype(int)).max())
    print(f"{ds.file_meta.TransferSyntaxUID:24} {ds.file_meta.TransferSyntaxUID.name:44} {os.path.getsize(f):7} bytes  max diff {diff}")
1.2.840.10008.1.2.1      Explicit VR Little Endian                     525534 bytes  max diff 0
1.2.840.10008.1.2.4.70   JPEG Lossless, Non-Hierarchical, First-Order Prediction (Process 14 [Selection Value 1])  197386 bytes  max diff 0
1.2.840.10008.1.2.4.80   JPEG-LS Lossless Image Compression            161684 bytes  max diff 0
1.2.840.10008.1.2.4.90   JPEG 2000 Image Compression (Lossless Only)   165824 bytes  max diff 0
1.2.840.10008.1.2.4.91   JPEG 2000 Image Compression                   165824 bytes  max diff 0

في المخرج أعلاه لاحظ التالي:

  • الضغط دون فقدان صغر الشريحة من 525 كيلوبايت إلى 162 كيلوبايت مع JPEG-LS، أي قرابة الثلث، و166 كيلوبايت مع JPEG 2000، والفرق الأقصى بين البكسلات صفر في كل الحالات، أي أن كل قيمة HU بقيت كما هي.
  • الصيغة .4.91 اسمها JPEG 2000 بدون كلمة Lossless، وهي تسمح بالنوعين، ولكن الحجم والفرق الصفري يبينان أن Orthanc كتبها دون فقدان، فاسم الصيغة وحده لا يخبرك هل فقدت الصورة شيئاً.

وماذا عن الضغط مع الفقدان؟ مكتبة pydicom تستطيع الترميز بالمكتبة openjpeg بنسبة ضغط نحددها، فنجرب ثلاث نسب ونقيس الخطأ بوحدة HU نفسها:

# lossy.py
import pydicom, numpy as np, os
from pydicom.uid import JPEG2000
ref = pydicom.dcmread("ts-1.2.840.10008.1.2.1.dcm")
a = ref.pixel_array.astype(int)
for ratio in (10, 20, 40):
    ds = pydicom.dcmread("ts-1.2.840.10008.1.2.1.dcm")
    ds.compress(JPEG2000, compression_ratios=[ratio])
    ds.LossyImageCompression = "01"            # pydicom does not set it, the standard expects it
    ds.LossyImageCompressionRatio = ratio
    ds.LossyImageCompressionMethod = "ISO_15444_1"
    f = f"j2k-lossy-{ratio}.dcm"; ds.save_as(f)
    b = pydicom.dcmread(f).pixel_array.astype(int)
    d = np.abs(a - b)
    print(f"ratio {ratio:2}: {os.path.getsize(f):6} bytes  max diff {d.max():4} HU  mean diff {d.mean():.2f} HU  "
          f"pixels off by >10 HU: {(d > 10).sum()}")
ratio 10:  53728 bytes  max diff   26 HU  mean diff 3.27 HU  pixels off by >10 HU: 8600
ratio 20:  27448 bytes  max diff   54 HU  mean diff 6.07 HU  pixels off by >10 HU: 52456
ratio 40:  14358 bytes  max diff  111 HU  mean diff 8.50 HU  pixels off by >10 HU: 80307

بنسبة 40 أصبحت الشريحة 14 كيلوبايت بدل 525، ولكن بعض البكسلات تغيرت 111 وحدة HU، وهذا الفرق أكبر من الفرق بين الدهون والماء، أي أن الضغط قد يغير ما يبدو عليه نسيج في الصورة. ولاحظ السطر الذي أضفناه بأيدينا: LossyImageCompression = "01"، فالمكتبة لم تضعه، وهو الوسم الذي يخبر كل نظام بعدك أن هذه الصورة فقدت شيئاً عند ضغطها ولا يجوز أن تعامل كأصل. لذلك فالقاعدة العملية: احفظ الأصل في الأرشيف دون فقدان، واستخدم الضغط مع الفقدان فقط لنسخة العرض السريع على الشبكات البطيئة إذا قبل بذلك القسم الطبي، وضع الوسم دائماً.

وحدات هاونسفيلد HU والنافذة Window

بحسب وصف وحدة صورة CT في القسم C.8.2 من الجزء PS3.3 فإن الرقم المخزن يتحول إلى قيمة حقيقية بالمعادلة RescaleSlope × stored + RescaleIntercept، وفي صور CT الأصلية تكون هذه القيمة بوحدة هاونسفيلد HU. ولكن الشاشة تعرض قرابة 256 درجة رمادي فقط، والعين لا تميز أكثر من ذلك بكثير، لذلك يختار الطبيب نافذة Window لها مركز Center وعرض Width: كل ما هو أقل من النافذة أسود، وكل ما هو أعلى منها أبيض، والدرجات الرمادية كلها تصرف على ما بداخلها. والمعادلة بالضبط في القسم C.11.2.1.2.1 من الجزء PS3.3، والصورة التالية تبينها مع ثلاث نوافذ مشهورة:

شريط تدرج من الأسود إلى الأبيض من 1450 سالب إلى 1600 بوحدة HU، وعليه مواقع تقريبية للهواء عند 1000 سالب والرئة والدهون والماء عند صفر والأنسجة الرخوة والصبغة والعظم. وتحته ثلاث نوافذ: الأنسجة الرخوة بمركز 40 وعرض 400 من 160 سالب إلى 240، والرئة بمركز 600 سالب وعرض 1500، والعظم بمركز 400 وعرض 1800. وفي الأسفل معادلة DICOM ومثال: القيمة 60 تصبح درجة رمادي 140 في نافذة الأنسجة الرخوة
النافذة تقرر أي جزء من مدى HU يصبح مرئياً، وما خارجها أسود أو أبيض

لنأخذ مثالاً محسوباً بنافذة الأنسجة الرخوة: المركز C يساوي 40 والعرض W يساوي 400، فالحد الأدنى بحسب المعادلة هو 40 − 0.5 − 399/2 أي 160 سالب تقريباً، والحد الأعلى 239.5. وبكسل قيمته 60 HU يصبح ((60 − 39.5) / 399 + 0.5) × 255 أي 140 تقريباً، وهو رمادي أفتح قليلاً من المنتصف، وبكسل قيمته 240 أو أكثر أبيض تماماً 255، والهواء عند 1000 سالب أسود تماماً. والسكربت التالي يطبق المعادلة نفسها على الشريحة 91 من المختبر، ويعد البكسلات في كل مدى، ويرسم الشريحة بأربع طرق:

# window.py
import pydicom, numpy as np
from PIL import Image, ImageDraw, ImageFont

ds = pydicom.dcmread("ct/00000098.dcm")              # InstanceNumber 91, the middle slice
raw = ds.pixel_array                                  # stored values, int16 here
hu = raw * float(ds.RescaleSlope) + float(ds.RescaleIntercept)
print("stored dtype:", raw.dtype, " min/max stored:", raw.min(), raw.max())
print("HU min/max:", hu.min(), hu.max())
for name, lo, hi in [("air", -1100, -900), ("lung/gas", -900, -200), ("fat", -200, -30),
                     ("water/soft tissue", -30, 100), ("contrast/dense", 100, 300), ("bone", 300, 4000)]:
    n = ((hu >= lo) & (hu < hi)).sum()
    print(f"  {name:18} [{lo:5}, {hi:5}) HU: {n:6} pixels ({100*n/hu.size:4.1f}%)")

def window(x, c, w):
    # DICOM PS3.3 C.11.2.1.2.1, linear VOI LUT, output 0..255
    lo, hi = c - 0.5 - (w - 1) / 2, c - 0.5 + (w - 1) / 2
    y = ((x - (c - 0.5)) / (w - 1) + 0.5) * 255
    return np.where(x <= lo, 0, np.where(x > hi, 255, y)).clip(0, 255).astype(np.uint8)

for hu_val in (-1000, -100, 40, 60, 240, 700):
    print(f"HU {hu_val:5}: soft tissue (C40 W400) -> {int(window(np.array(hu_val), 40, 400))}, "
          f"lung (C-600 W1500) -> {int(window(np.array(hu_val), -600, 1500))}, "
          f"bone (C400 W1800) -> {int(window(np.array(hu_val), 400, 1800))}")

full = ((hu - hu.min()) / (hu.max() - hu.min()) * 255).astype(np.uint8)
panels = [("full range", full), ("soft tissue C40 W400", window(hu, 40, 400)),
          ("lung C-600 W1500", window(hu, -600, 1500)), ("bone C400 W1800", window(hu, 400, 1800))]
out = Image.new("L", (4 * 512 + 3 * 16, 512 + 44), 255)
d = ImageDraw.Draw(out)
font = ImageFont.load_default(size=26)
for i, (title, img) in enumerate(panels):
    out.paste(Image.fromarray(img), (i * 528, 44))
    d.text((i * 528 + 8, 6), title, fill=0, font=font)
out.save("windows.png")
print("saved windows.png")
stored dtype: int16  min/max stored: -1024 1421
HU min/max: -1024.0 1421.0
  air                [-1100,  -900) HU: 189851 pixels (72.4%)
  lung/gas           [ -900,  -200) HU:   8448 pixels ( 3.2%)
  fat                [ -200,   -30) HU:   9402 pixels ( 3.6%)
  water/soft tissue  [  -30,   100) HU:  30090 pixels (11.5%)
  contrast/dense     [  100,   300) HU:  22166 pixels ( 8.5%)
  bone               [  300,  4000) HU:   2187 pixels ( 0.8%)
HU -1000: soft tissue (C40 W400) -> 0, lung (C-600 W1500) -> 59, bone (C400 W1800) -> 0
HU  -100: soft tissue (C40 W400) -> 38, lung (C-600 W1500) -> 212, bone (C400 W1800) -> 56
HU    40: soft tissue (C40 W400) -> 127, lung (C-600 W1500) -> 236, bone (C400 W1800) -> 76
HU    60: soft tissue (C40 W400) -> 140, lung (C-600 W1500) -> 239, bone (C400 W1800) -> 79
HU   240: soft tissue (C40 W400) -> 255, lung (C-600 W1500) -> 255, bone (C400 W1800) -> 104
HU   700: soft tissue (C40 W400) -> 255, lung (C-600 W1500) -> 255, bone (C400 W1800) -> 170

والصورة الناتجة هي الشريحة نفسها بأربع نوافذ:

أربع نسخ من نفس شريحة الأشعة المقطعية للبطن: الأولى بكامل المدى رمادية باهتة لا تظهر فيها الأعضاء، والثانية بنافذة الأنسجة الرخوة يظهر فيها الكبد والأوعية والأمعاء بوضوح، والثالثة بنافذة الرئة يصبح الجسم كله فاتحاً ويظهر الغاز في الأمعاء، والرابعة بنافذة العظم تبدو الأعضاء رمادية داكنة ويظهر العظم وحده واضحاً
نفس البكسلات بأربع نوافذ، من شريحة المختبر في مجموعة Pancreas-CT (TCIA، برخصة CC BY 3.0)

في المخرج أعلاه لاحظ التالي:

  • 72% من البكسلات هواء حول الجسم، و0.8% فقط عظم، فإذا وزعت 256 درجة على كامل المدى كما في الصورة الأولى ذهبت أغلب الدرجات للهواء والعظم، وأصبح الكبد والأمعاء رمادياً واحداً تقريباً.
  • الرقم 140 للقيمة 60 هو المثال نفسه الذي حسبناه باليد، و127 للقيمة 40 أي منتصف النافذة بالضبط تقريباً.
  • العظم عند 700 أبيض تماماً في نافذة الأنسجة الرخوة، ودرجة 170 في نافذة العظم، فلا يوجد شيء اسمه «لون العظم»، وإنما لون العظم في هذه النافذة.
  • الصورة مقلوبة رأساً على عقب مقارنة بما اعتاده الأطباء (الظهر في الأعلى)، والسبب أن الوسم ImageOrientationPatient في هذه المجموعة قيمته [1, 0, 0, 0, -1, 0] ورسمنا البكسلات كما هي دون أن نقرأه، وسوف ترى أن العارض OHIF يقرأه ويضع الحرف P (الخلف Posterior) في أعلى الشاشة، فاحترم هذا الوسم في أي معالجة تكتبها.

نكسر العرض: ننسى معادلة التحويل

أشهر خطأ يقع فيه المطور عند أول تعامل مع CT هو أن يطبق النافذة على الرقم المخزن مباشرة، وهو لا يلاحظ شيئاً على ملفات مثل ملفاتنا لأن RescaleIntercept فيها صفر. لذلك نعيد حفظ الشريحة نفسها كما تحفظها أغلب الأجهزة، أرقاماً موجبة مع RescaleIntercept بقيمة 1024 سالب، ثم نطبق النافذة مرة بالمعادلة ومرة دونها:

# rescale_break.py
import pydicom, numpy as np
ds = pydicom.dcmread("ct/00000098.dcm")
hu = ds.pixel_array.astype(np.int32)
# store the same slice the way many scanners do: unsigned values with Rescale Intercept -1024
ds.PixelRepresentation = 0
ds.PixelData = (hu + 1024).clip(0).astype(np.uint16).tobytes()
ds.RescaleIntercept = -1024
ds.save_as("slice-unsigned.dcm")

ds2 = pydicom.dcmread("slice-unsigned.dcm")
stored = ds2.pixel_array
print("stored min/max:", stored.min(), stored.max(), stored.dtype)
def window(x, c=40, w=400):
    lo, hi = c - 0.5 - (w - 1) / 2, c - 0.5 + (w - 1) / 2
    return np.where(x <= lo, 0, np.where(x > hi, 255, ((x - (c - 0.5)) / (w - 1) + 0.5) * 255)).astype(int)
for label, v in (("air", 0), ("fat", 924), ("soft tissue", 1084)):    # stored values for -1024, -100, 60 HU
    print(f"{label:12} stored {v:5}: without rescale -> gray {int(window(np.array(v)))}, "
          f"with rescale ({v + int(ds2.RescaleIntercept)} HU) -> gray {int(window(np.array(v + float(ds2.RescaleIntercept))))}")
img = window(stored.astype(float))
print(f"whole slice without rescale: {100*(img == 255).mean():.1f}% pure white, {100*(img == 0).mean():.1f}% pure black")
stored min/max: 0 2445 uint16
air          stored     0: without rescale -> gray 102, with rescale (-1024 HU) -> gray 0
fat          stored   924: without rescale -> gray 255, with rescale (-100 HU) -> gray 38
soft tissue  stored  1084: without rescale -> gray 255, with rescale (60 HU) -> gray 140
whole slice without rescale: 26.2% pure white, 0.0% pure black

دون المعادلة أصبح الهواء رمادياً متوسطاً 102، وأصبح الجسم كله أبيض بما فيه الدهون، ولا يوجد بكسل أسود واحد في الشريحة، أي صورة لا تصلح لشيء. والأخطر من العرض أن نموذج ذكاء اصطناعي دربته على ملفات تخزن HU مباشرة سوف يرى من جهاز آخر أرقاماً مزاحة بمقدار 1024 دون أي رسالة خطأ. لذلك طبق RescaleSlope وRescaleIntercept دائماً قبل أي معالجة، حتى لو كانت قيمتهما في ملفاتك 1 و0، ودالة pydicom الجاهزة لذلك هي pydicom.pixels.apply_rescale.

نظام أرشفة الصور PACS: من الجهاز إلى الشاشة

نظام أرشفة الصور والاتصال Picture Archiving and Communication System واختصاراً PACS هو المكان الذي تذهب إليه كل صورة يخرجها جهاز في المستشفى، ويحتوي في أبسط صوره ثلاثة أجزاء: فهرس في قاعدة بيانات يحفظ الوسوم المهمة للبحث، ومخزن للملفات نفسها، وواجهات يتكلم بها مع الأجهزة والعارضات. وتسمع أيضاً مصطلح الأرشيف المحايد Vendor Neutral Archive واختصاراً VNA، وهو أرشيف يفصل التخزين عن العارض حتى لا تبقى صورك رهينة شركة واحدة. والصورة التالية تبين مسار الفحص في المستشفى:

مخطط: نظام معلومات الأشعة RIS في الأعلى يرسل الطلب بصيغة HL7 إلى PACS ويرسل قائمة العمل MWL إلى جهاز CT، والجهاز يرسل الصور إلى PACS ب C-STORE، وPACS فيه فهرس وتخزين وواجهة DICOMweb، والعارض يستعلم ويجلب ب QIDO-RS وWADO-RS، وعقدة الذكاء الاصطناعي تستلم الفحص الجديد وترجع النتائج ب STOW-RS على شكل SR أو SEG، وتقرير الطبيب يرجع إلى RIS بصيغة HL7 ORU أو FHIR
مسار شائع للفحص من الطلب إلى التقرير، وكل سهم بروتوكول له اسم في المعيار
  1. يطلب الطبيب الفحص في السجل الطبي أو نظام معلومات الأشعة Radiology Information System واختصاراً RIS، فيصل الطلب إلى قائمة العمل في الجهاز Modality Worklist واختصاراً MWL، وبهذا لا يكتب الفني اسم المريض بيده فيقل الخطأ.
  2. يرسل الجهاز الصور إلى PACS بالأمر C-STORE من خدمات DICOM التقليدية التي تسمى DIMSE، وهي بروتوكول ثنائي على TCP عمره من بداية المعيار، وتتعرف فيه الأجهزة على بعضها باسم يسمى AE Title، وهو في Orthanc الاسم ORTHANC على المنفذ 4242 كما رأينا في السجل.
  3. يفتح الطبيب العارض فيبحث ويجلب الصور، وفي الأنظمة الحديثة يتم ذلك عبر DICOMweb فوق HTTP.
  4. عقدة الذكاء الاصطناعي تستلم الفحص الجديد، وترجع نتيجتها إلى PACS ككائن DICOM جديد في نفس الفحص.
  5. يكتب الطبيب التقرير فيعود إلى RIS والسجل الطبي برسالة HL7، وهذا موضوع المقال التالي تبادل البيانات الصحية بين الأنظمة.

DICOMweb: الصور عبر HTTP

DICOMweb هو الجزء PS3.18 من المعيار، وهو مجموعة واجهات REST فوق HTTP بدل بروتوكول DIMSE الثنائي، وتلخص صفحة DICOMweb في موقع المعيار الخدمات الثلاث الأساسية: البحث QIDO-RS في القسم 10.6، والجلب WADO-RS في القسم 10.4، والتخزين STOW-RS في القسم 10.5. وإذا كنت قد صممت API من قبل فسوف تجد الشكل مألوفاً كما شرحنا في مقال تصميم واجهات API: موارد هرمية /studies/{study}/series/{series}/instances/{instance} وأفعال HTTP عادية.

أولاً نرفع ملفات الفحص إلى Orthanc. والطريقة الأسرع في Orthanc هي واجهته الخاصة POST /instances لكل ملف:

time (for f in ct/*.dcm; do curl -s -o /dev/null -X POST --data-binary @$f http://127.0.0.1:18042/instances; done)
curl -s 127.0.0.1:18042/statistics
real	0m16.065s
user	0m1.668s
sys	0m2.555s
{
   "CountInstances" : 181,
   "CountPatients" : 1,
   "CountSeries" : 1,
   "CountStudies" : 1,
   "TotalDiskSize" : "95117092",
   "TotalDiskSizeMB" : 90,
   "TotalUncompressedSize" : "95117092",
   "TotalUncompressedSizeMB" : 90
}

181 ملفاً أصبحت مريضاً واحداً وفحصاً واحداً وسلسلة واحدة، أي أن Orthanc بنى الهرم من الوسوم وحدها دون أن نخبره بشيء.

البحث QIDO-RS

البحث على مستوى الفحوص بمعرف المريض، مع طلب عدد الصور في الفحص بالحقل 00201208:

B=http://127.0.0.1:18042/dicom-web
curl -s "$B/studies?PatientID=PANCREAS_0080&includefield=00201208" \
  | jq -c '.[] | {study: .["0020000D"].Value[0], date: .["00080020"].Value[0], modalities: .["00080061"].Value, instances: .["00201208"].Value[0]}'
{"study":"1.2.826.0.1.3680043.2.1125.1.60401621965073299400071712795433179","date":"20151124","modalities":["CT"],"instances":181}

ثم البحث داخل السلسلة عن الشريحة رقم 91:

S=1.2.826.0.1.3680043.2.1125.1.60401621965073299400071712795433179
SE=1.2.826.0.1.3680043.2.1125.1.41202274843063370955090296887703130
curl -s "$B/studies/$S/series/$SE/instances?InstanceNumber=91" | jq -c '.[] | {sop: .["00080018"].Value[0], n: .["00200013"].Value[0], url: .["00081190"].Value[0]}'
{"sop":"1.2.826.0.1.3680043.2.1125.1.70847515432480805264903410287002376","n":91,"url":"http://127.0.0.1:18042/dicom-web/studies/1.2.826.0.1.3680043.2.1125.1.60401621965073299400071712795433179/series/1.2.826.0.1.3680043.2.1125.1.41202274843063370955090296887703130/instances/1.2.826.0.1.3680043.2.1125.1.70847515432480805264903410287002376"}

لاحظ أن الرد JSON مفاتيحه أرقام الوسوم وليست أسماءها، مثل 0020000D بدل StudyInstanceUID، ومع كل قيمة نوعها vr، وهذا الشكل معرف في الملحق F من الجزء PS3.18، والسبب أن أسماء الوسوم قد تتغير بين إصدارات المعيار أما الأرقام فثابتة.

الجلب WADO-RS

نجلب الشريحة نفسها بثلاثة أشكال: الملف كاملاً، والبكسلات وحدها، وصورة JPEG جاهزة للعرض، ثم الوسوم دون البكسلات:

I=1.2.826.0.1.3680043.2.1125.1.70847515432480805264903410287002376
U="$B/studies/$S/series/$SE/instances/$I"
curl -s -D - -o inst.bin -H 'Accept: multipart/related; type="application/dicom"' "$U" | grep -iE "^HTTP|content-type|content-length"
curl -s -D - -o frame.bin "$U/frames/1" | grep -iE "^HTTP|content-type"
curl -s -D - -o rendered.jpg -H "Accept: image/jpeg" "$U/rendered" | grep -iE "^HTTP|content-type"
wc -c inst.bin frame.bin rendered.jpg
curl -s "$U/metadata" | jq -c '.[0] | {rows: .["00280010"].Value[0], cols: .["00280011"].Value[0], spacing: .["00280030"].Value, intercept: .["00281052"].Value[0], pixeldata: .["7FE00010"]}'
HTTP/1.1 200 OK
Content-Type: multipart/related; type="application/dicom"; boundary=ef435969-218c-44d5-a7cd-c1f699ba402e-94154361-e533-4a8c-92d1-86b3a3f61
Content-Length: 525764
HTTP/1.1 200 OK
Content-Type: multipart/related; type="application/octet-stream; transfer-syntax=1.2.840.10008.1.2.1"; boundary=da139102-1bbc-463f-8e3a-0212cfb7edc1-7838ca2b-6722-4c2f-a241-5f3d473d9
HTTP/1.1 200 OK
Content-Type: image/jpeg
 525764 inst.bin
 524843 frame.bin
  26971 rendered.jpg
1077578 total
{"rows":512,"cols":512,"spacing":["0.9765625","0.9765625"],"intercept":"0","pixeldata":{"BulkDataURI":"http://127.0.0.1:18042/dicom-web/studies/1.2.826.0.1.3680043.2.1125.1.60401621965073299400071712795433179/series/1.2.826.0.1.3680043.2.1125.1.41202274843063370955090296887703130/instances/1.2.826.0.1.3680043.2.1125.1.70847515432480805264903410287002376/bulk/7fe00010","vr":"OW"}}

في المخرج أعلاه لاحظ التالي:

  • الملف يأتي داخل غلاف multipart/related حتى لو كان ملفاً واحداً، لأن الواجهة نفسها ترجع سلسلة كاملة بطلب واحد، وفي أول الجزء ستجد المقدمة 128 بايت أصفاراً ثم الحروف DICM كما في الفصل السابع من PS3.10.
  • طلب /frames/1 يرجع البكسلات وحدها 524,288 بايت مع غلافها، وهذا ما يطلبه العارض الحديث فعلاً، فهو لا يحتاج الملف كاملاً لكل شريحة.
  • /rendered يرجع JPEG بحجم 27 كيلوبايت، وهو مفيد لصورة مصغرة Thumbnail أو معاينة في تطبيق، ولكنه صورة بثمانية بتات بنافذة اختارها السيرفر، فلا تبن عليه أي قياس.
  • في /metadata لا تأتي البكسلات وإنما رابط BulkDataURI لجلبها عند الحاجة، فيستطيع العارض أن يقرأ وسوم 181 شريحة بطلب واحد صغير ثم يجلب البكسلات شريحة شريحة.

التخزين STOW-RS

الرفع في DICOMweb طلب POST واحد بجسم من نوع multipart/related فيه جزء لكل ملف، والسكربت التالي يرفع ملف الاختبار CT_small.dcm الذي يأتي مع مكتبة pydicom (نسخة مصغرة من صورة CT من مجموعة اختبارات المكتبة، وسوف نستخدمه في إزالة الهوية لأن فيه وسوماً كثيرة):

docker exec lab-health-py cp /usr/local/lib/python3.13/site-packages/pydicom/data/test_files/CT_small.dcm /work/CT_small.dcm
# stow.py
# STOW-RS: one HTTP POST, body = multipart/related with one part per DICOM file
import sys, uuid, requests
url = "http://lab-health-orthanc:8042/dicom-web/studies"
boundary = uuid.uuid4().hex
body = b""
for path in sys.argv[1:]:
    body += f"--{boundary}\r\nContent-Type: application/dicom\r\n\r\n".encode() + open(path, "rb").read() + b"\r\n"
body += f"--{boundary}--\r\n".encode()
r = requests.post(url, data=body, headers={
    "Content-Type": f'multipart/related; type="application/dicom"; boundary={boundary}',
    "Accept": "application/dicom+json"})
print(r.status_code)
j = r.json()
for item in j.get("00081199", {}).get("Value", []):          # ReferencedSOPSequence: stored
    print("stored:", item["00081155"]["Value"][0], "->", item["00081190"]["Value"][0])
for item in j.get("00081198", {}).get("Value", []):          # FailedSOPSequence
    print("failed:", item)
docker exec lab-health-py python stow.py CT_small.dcm
200
stored: 1.3.6.1.4.1.5962.1.1.1.1.1.20040119072730.12322 -> http://lab-health-orthanc:8042/dicom-web/studies/1.3.6.1.4.1.5962.1.2.1.20040119072730.12322/series/1.3.6.1.4.1.5962.1.3.1.1.20040119072730.12322/instances/1.3.6.1.4.1.5962.1.1.1.1.1.20040119072730.12322

الرد نفسه كائن DICOM بصيغة JSON، فيه قائمة 00081199 بما تم حفظه وقائمة 00081198 بما فشل، فلا تكتف برمز الحالة 200، وافحص القائمة الثانية، لأن الطلب قد ينجح جزئياً.

العارض: OHIF في المتصفح

افتح http://127.0.0.1:18043/ فسوف تجد قائمة الفحوص التي جاءت من QIDO-RS، ومنها فحص البطن والملف الذي رفعناه للتو:

قائمة الفحوص في OHIF Viewer بخلفية داكنة: فحص PANCREAS_0080 بتاريخ 24 نوفمبر 2015 من نوع CT فيه 181 صورة، وفحص CompressedSamples CT1 بتاريخ 19 يناير 2004 فيه صورة واحدة، مع حقول بحث بالاسم والتاريخ والنوع
قائمة الفحوص في OHIF، وكلها تأتي من Orthanc عبر QIDO-RS

وعند فتح الفحص ثم الضغط على الرقم 1 في لوحة المفاتيح يطبق العارض نافذة الأنسجة الرخوة، وقد نزلنا إلى الشريحة 91 بعجلة الفأرة:

عارض OHIF يعرض الشريحة 91 من 181 من فحص البطن بنافذة W 400 وL 40 في أسفل اليسار، والحرف P في أعلى الصورة والحرف R على يسارها، وعلى اليسار السلسلة Pancreas بصورة مصغرة، وفي الأعلى أدوات القياس والتكبير والنافذة
نفس النافذة التي حسبناها باليد، والحرف P في الأعلى قرأه العارض من وسم الاتجاه

لاحظ في الأسفل W: 400 L: 40 وهي النافذة نفسها التي حسبناها، وI: 91 (91/181) رقم الشريحة، والحرف P أعلى الصورة والحرف R على يسارها، أي أن العارض قرأ ImageOrientationPatient وأخبرك أن الظهر في الأعلى ويمين المريض على يسار الشاشة بحسب اصطلاح الأشعة. ويظهر OHIF عند أول فتح رسالة بأنه للاستخدام البحثي فقط For Investigational Use Only، والسبب أنه برنامج مفتوح المصدر وليس جهازاً طبياً معتمداً، فإذا أردت أن يقرأ به الأطباء صوراً للتشخيص فأنت تحتاج منتجاً حصل على موافقة الجهة المنظمة، وهذا ما نفصله في مقال الشركات الناشئة في الذكاء الاصطناعي الطبي.

إزالة الهوية De-identification

عندما تخرج الصور من المستشفى لبحث أو لتدريب نموذج، فيجب أن تخرج دون ما يدل على المريض، والجزء PS3.15 الملحق E من المعيار يعرف ملف التعريف الأساسي Basic Application Level Confidentiality Profile: جدول طويل (Table E.1-1) يحدد لكل وسم فيه بيانات تعريفية رمزاً للفعل، فالرمز X حذف، وZ تفريغ أو قيمة وهمية، وD قيمة وهمية، وU معرف UID جديد ثابت داخل نفس المجموعة، وK إبقاء. ومعه خيارات Options في القسم E.3 تزيد أو تنقص منه، مثل إبقاء التواريخ بعد إزاحتها، أو تنظيف البكسلات Clean Pixel Data Option.

والطريقة البسيطة التي يكتبها كثيرون هي حذف PatientName وPatientID فقط، والسبب في فشلها أن الملف فيه عشرات الأماكن الأخرى: تاريخ الفحص، واسم المؤسسة، واسم الجهاز، والطبيب المحيل، ومعرفات تحتوي أحياناً تاريخاً ووقتاً، ووسوم خاصة Private Tags تضعها الشركة المصنعة بما تشاء. لذلك نستخدم أداة تطبق الجدول كاملاً، وOrthanc يطبقه بحسب توثيق إزالة الهوية في Orthanc على إصدارات من المعيار آخرها 2023b. فنطبقه على CT_small.dcm الذي رفعناه، ثم نقارن الملفين وسماً وسماً:

OID=$(curl -s -X POST http://127.0.0.1:18042/tools/lookup -d 1.3.6.1.4.1.5962.1.1.1.1.1.20040119072730.12322 | jq -r '.[0].ID')
curl -s -X POST "http://127.0.0.1:18042/instances/$OID/anonymize" -d '{"DicomVersion":"2023b"}' -o CT_small_anon.dcm
# deid_diff.py
import pydicom, sys
a = pydicom.dcmread(sys.argv[1]); b = pydicom.dcmread(sys.argv[2])
fa, fb = {e.tag: e for e in a}, {e.tag: e for e in b}
removed = [t for t in fa if t not in fb]; added = [t for t in fb if t not in fa]
changed = [t for t in fa if t in fb and fa[t].value != fb[t].value and t != 0x7FE00010]
same_px = a.PixelData == b.PixelData
print(f"top-level elements: before {len(fa)}, after {len(fb)}")
print(f"removed {len(removed)} (private: {sum(t.is_private for t in removed)}), changed {len(changed)}, added {len(added)}, pixel data identical: {same_px}")
show = lambda e: (repr(e.value)[:48] if e.VR != "SQ" else f"<{len(e.value)} item(s)>")
print("\nchanged:")
for t in changed: print(f"  {t} {fa[t].keyword or fa[t].name:28} {show(fa[t]):50} -> {show(fb[t])}")
print("\nremoved (public only):")
for t in removed:
    if not t.is_private: print(f"  {t} {fa[t].keyword:28} {show(fa[t])}")
print("\nadded:")
for t in added: print(f"  {t} {fb[t].keyword:28} {show(fb[t])}")
docker exec lab-health-py python deid_diff.py CT_small.dcm CT_small_anon.dcm
top-level elements: before 258, after 65
removed 195 (private: 179), changed 14, added 2, pixel data identical: True

changed:
  (0008,0014) InstanceCreatorUID           '1.3.6.1.4.1.5962.3'                               -> '1.2.276.0.7230010.3.1.4.842490420.1.1791536046.
  (0008,0018) SOPInstanceUID               '1.3.6.1.4.1.5962.1.1.1.1.1.20040119072730.12322   -> '1.2.276.0.7230010.3.1.4.842490420.1.1791536046.
  (0008,0020) StudyDate                    '20040119'                                         -> ''
  (0008,0023) ContentDate                  '19970430'                                         -> ''
  (0008,0030) StudyTime                    '072730'                                           -> ''
  (0008,0033) ContentTime                  '113008'                                           -> ''
  (0010,0010) PatientName                  'CompressedSamples^CT1'                            -> 'Anonymized1'
  (0010,0020) PatientID                    '1CT1'                                             -> '75a5a330-84f4-4271-a87b-a90218948cac'
  (0010,0040) PatientSex                   'O'                                                -> ''
  (0018,0010) ContrastBolusAgent           'ISOVUE300/100'                                    -> ''
  (0020,000D) StudyInstanceUID             '1.3.6.1.4.1.5962.1.2.1.20040119072730.12322'      -> '1.2.276.0.7230010.3.1.2.842490420.1.1791536046.
  (0020,000E) SeriesInstanceUID            '1.3.6.1.4.1.5962.1.3.1.1.20040119072730.12322'    -> '1.2.276.0.7230010.3.1.3.842490420.1.1791536046.
  (0020,0010) StudyID                      '1CT1'                                             -> ''
  (0020,0052) FrameOfReferenceUID          '1.3.6.1.4.1.5962.1.4.1.1.20040119072730.12322'    -> '1.2.276.0.7230010.3.1.4.842490420.1.1791536046.

removed (public only):
  (0008,0012) InstanceCreationDate         '20040119'
  (0008,0013) InstanceCreationTime         '072731'
  (0008,0021) SeriesDate                   '19970430'
  (0008,0022) AcquisitionDate              '19970430'
  (0008,0031) SeriesTime                   '112749'
  (0008,0032) AcquisitionTime              '112936'
  (0008,0080) InstitutionName              'JFK IMAGING CENTER'
  (0008,0201) TimezoneOffsetFromUTC        '-0500'
  (0008,1010) StationName                  'CT01_OC0'
  (0008,1030) StudyDescription             'e+1'
  (0010,1002) OtherPatientIDsSequence      <2 item(s)>
  (0010,1010) PatientAge                   '000Y'
  (0010,1030) PatientWeight                '0.000000'
  (0010,21B0) AdditionalPatientHistory     ''
  (0020,4000) ImageComments                'Uncompressed'
  (FFFC,FFFC) DataSetTrailingPadding       b'\n\x00\xfe\x00\x04\x00\x01\x00\x00\x00\x00\x00

added:
  (0012,0062) PatientIdentityRemoved       'YES'
  (0012,0063) DeidentificationMethod       'Orthanc 1.13.0 - PS 3.15-2023b Table E.1-1 Basi

والصورة التالية تلخص ما حدث:

ثلاث لوحات: على اليسار الملف الأصلي فيه اسم المريض CompressedSamples CT1 ورقمه وتاريخ الفحص واسم المؤسسة JFK IMAGING CENTER واسم الجهاز والمعرفات و179 وسماً خاصاً، وفي الوسط رموز ملف التعريف الأساسي X حذف وZ تفريغ وU معرف جديد وD قيمة وهمية وK إبقاء، وعلى اليمين الملف بعد Orthanc: الاسم Anonymized1 ورقم جديد وتاريخ فارغ والمؤسسة محذوفة ومعرفات جديدة والوسوم الخاصة صفر، ولكن البكسلات بنفس البايتات والنص المحروق داخل الصورة لم يتغير
الوسوم تغيرت كلها تقريباً، والبكسلات لم يلمسها أحد

في المخرج أعلاه لاحظ التالي:

  • من 258 وسماً بقي 65، والجزء الأكبر من المحذوف 179 وسماً خاصاً للشركة المصنعة، وهذه أخطر ما في الملف لأنك لا تعرف ماذا تحمل، والقاعدة في الملف الأساسي حذفها إلا ما ثبت أنه آمن.
  • المعرفات الثلاثة للفحص والسلسلة والصورة تغيرت إلى معرفات جديدة تبدأ ببادئة Orthanc 1.2.276.0.7230010.3، والسبب أن المعرف الأصلي قد يكشف المؤسسة والتاريخ، ولاحظ أن المعرف الأصلي هنا فيه 20040119072730 وهو التاريخ والوقت حرفياً.
  • بقيت الجهة المصنعة GE MEDICAL SYSTEMS وإعدادات الجهاز مثل KVP وسماكة الشريحة، لأنها لازمة للبحث ولا تعرف بالمريض وحدها.
  • أضيف الوسمان PatientIdentityRemoved وDeidentificationMethod، فكل نظام يستلم الملف يعرف أنه معالج وبأي طريقة.
  • السطر الأهم: pixel data identical: True. أي أن الصورة نفسها لم تتغير ببايت واحد.

وهذا ينقلنا إلى أكبر ثغرة في إزالة الهوية: النص المحروق في البكسلات Burned-in Annotation. فأجهزة الموجات فوق الصوتية والصور الملتقطة من الشاشة Secondary Capture والصور الممسوحة ضوئياً كثيراً ما تكتب اسم المريض وتاريخ الفحص داخل الصورة نفسها، وأي أداة تعمل على الوسوم لن تراه. والمعيار يعرف الوسم BurnedInAnnotation (0028,0301) في القسم C.7.6 من الجزء PS3.3 بقيمتين YES وNO، ويقول صراحة إنه إذا غاب فالصورة قد تحتوي نصاً وقد لا تحتوي، أي أن غيابه لا يعني الأمان. لذلك عامل كل صورة من الموجات فوق الصوتية أو التقاط الشاشة على أنها قد تحمل نصاً، وقم بإخفاء المنطقة التي يكتب فيها الجهاز عادة أو مرر الصور على أداة كشف نصوص Optical Character Recognition ثم مراجعة بشرية، وهو ما يسميه المعيار خيار تنظيف البكسلات Clean Pixel Data Option وينص على أنه قد يحتاج تدخل إنسان. وهناك خطر آخر من نفس النوع: الأشعة المقطعية أو الرنين للرأس يمكن إعادة بناء الوجه منها ثلاثي الأبعاد، والمعيار يعرف لذلك خياراً منفصلاً اسمه Clean Recognizable Visual Features Option.

ولاحظ أخيراً أن إزالة الهوية في Orthanc تعطي معرفاً جديداً في كل مرة، فإذا أزلت هوية فحصين لنفس المريض في يومين مختلفين فلن تعرف أنهما لنفس الشخص، وهذا مطلوب أحياناً وكارثة لبحث يتابع المريض عبر الزمن، والحل أن تحفظ جدول ربط بين المعرف الأصلي والمعرف الجديد داخل المستشفى فقط، وتعامله كبيانات حساسة بالصلاحيات وسجل التدقيق كما شرحنا في مقال سجل التدقيق، أو أن تولد المعرف الجديد بدالة HMAC بمفتاح سري كما في قسم HMAC في مقال التشفير للمطورين، فيبقى ثابتاً للمريض نفسه ولا يمكن عكسه دون المفتاح.

الذكاء الاصطناعي في الأشعة

أغلب نماذج الذكاء الاصطناعي في الأشعة تقوم بواحدة من ثلاث مهام: التصنيف Classification (هل في الصورة نزيف؟)، والكشف Detection (أين العقدة في الرئة؟ مع مربع حولها)، والتقسيم Segmentation (ما حدود الكبد بالضبط؟ بكسلاً بكسلاً)، وقد شرحنا الفرق بين التصنيف والانحدار وكيف يتعلم النموذج من الأمثلة في مقال التعلم الآلي للمبتدئين. والتقسيم هو الأقرب للمطور هنا لأنه يرجع صورة بنفس حجم الصورة الأصلية، والشبكة الأشهر له هي U-Net التي نشرها Ronneberger وزملاؤه سنة 2015 لصور الخلايا في المجهر ثم انتشرت في كل الأشعة.

على اليسار شكل U-Net: خمسة مستويات تصغر فيها الصورة من 512 إلى 32 بكسل ويزيد عدد القنوات من 16 إلى 256 وهو المشفر Encoder، ثم أربعة مستويات تكبر فيها الصورة مرة أخرى وهو المفكك Decoder، وخطوط متقطعة بين المستويات المتقابلة تنقل التفاصيل الدقيقة، وتحتها أن نموذج MONAI في المختبر فيه 1,625,161 معاملاً ومدخله 1 في 512 في 512 ومخرجه 2 في 512 في 512. وعلى اليمين دائرتان متقاطعتان للقناع المرجعي وقناع النموذج ومعادلة Dice، وقيم Dice عند إزاحة قناع العظم بكسلاً 0.807 وبكسلين 0.647 وأربعة 0.439 وثمانية 0.254
U-Net تصغر الصورة لتفهم السياق ثم تكبرها لترسم الحدود، وDice يقيس التطابق بين القناعين

والإطار المفتوح الأشهر لبناء هذه النماذج هو MONAI، وهو مبني على PyTorch ومنشور برخصة Apache-2.0، وآخر إصدار له عند كتابة المقال 1.6.1 في سبتمبر 2026، ومعه مشاريع أخرى من نفس الفريق مثل MONAI Label لمساعدة الأطباء على التحديد وMONAI Deploy لتغليف النموذج كتطبيق يستلم DICOM ويرجع DICOM. ولكي لا يبقى الكلام نظرياً، نبني U-Net من MONAI ونمرر عليها شريحة المختبر، ثم نحسب معامل Dice الذي تقاس به كل نماذج التقسيم:

docker exec lab-health-py pip install -q torch==2.14.1 --index-url https://download.pytorch.org/whl/cpu
docker exec lab-health-py pip install -q monai==1.6.1
# unet_dice.py
import numpy as np, pydicom, torch
from monai.networks.nets import UNet
from monai.metrics import DiceMetric

ds = pydicom.dcmread("ct/00000098.dcm")
hu = ds.pixel_array.astype(np.float32) * float(ds.RescaleSlope) + float(ds.RescaleIntercept)
x = torch.from_numpy(np.clip((hu + 160) / 400, 0, 1))[None, None]      # soft tissue window scaled to 0..1

net = UNet(spatial_dims=2, in_channels=1, out_channels=2,
           channels=(16, 32, 64, 128, 256), strides=(2, 2, 2, 2), num_res_units=2)
print("parameters:", sum(p.numel() for p in net.parameters()))
with torch.no_grad():
    y = net(x)
print("input", tuple(x.shape), "-> output", tuple(y.shape), "(one score map per class: background, target)")

# Dice: how much two masks overlap, 2|A∩B| / (|A| + |B|)
ref = torch.from_numpy(hu >= 300)[None, None].float()                     # bone pixels as a reference mask
metric = DiceMetric(include_background=True, reduction="mean")
print("reference mask pixels:", int(ref.sum()))
for shift in (1, 2, 4, 8):
    pred = torch.roll(ref, shifts=shift, dims=3)                           # same mask, moved right by N pixels
    metric.reset(); metric(y_pred=pred, y=ref)
    print(f"shifted {shift} px ({shift * float(ds.PixelSpacing[1]):.1f} mm): Dice {metric.aggregate().item():.3f}")
parameters: 1625161
input (1, 1, 512, 512) -> output (1, 2, 512, 512) (one score map per class: background, target)
reference mask pixels: 2187
shifted 1 px (1.0 mm): Dice 0.807
shifted 2 px (2.0 mm): Dice 0.647
shifted 4 px (3.9 mm): Dice 0.439
shifted 8 px (7.8 mm): Dice 0.254

في المخرج أعلاه لاحظ التالي:

  • النموذج غير مدرب، والهدف هو الشكل: صورة واحدة بقناة واحدة تدخل، وتخرج خريطتان بنفس الحجم، واحدة لكل فئة (الخلفية والعضو)، وكل بكسل يأخذ الفئة الأعلى، أي أن الناتج قناع Mask يمكن حفظه ككائن DICOM من نوع التقسيم Segmentation IOD في القسم A.51 من PS3.3 ويعرضه OHIF فوق الصورة الأصلية.
  • مليون وستمئة ألف معامل لنموذج ثنائي الأبعاد صغير، ونماذج التقسيم ثلاثية الأبعاد المستخدمة فعلاً أكبر من ذلك بكثير وتحتاج كرت شاشة GPU للتدريب.
  • إزاحة قناع العظم بمليمتر واحد فقط أنزلت Dice إلى 0.81، وبأربعة مليمترات إلى 0.44، والسبب أن العظم في هذه الشريحة تراكيب رفيعة، والتراكيب الرفيعة والأورام الصغيرة تعاقب أي خطأ في الحدود بشدة، أما عضو كبير مثل الكبد فيعطي Dice عالياً حتى مع أخطاء واضحة على حدوده. لذلك فرقم Dice وحده لا يكفي لتقارن نموذجين على أعضاء مختلفة.

كم نموذجاً معتمداً، ولماذا لا تكفي الأرقام؟

تنشر إدارة الغذاء والدواء الأمريكية FDA قائمة بالأجهزة الطبية المعتمدة التي تستخدم الذكاء الاصطناعي، وعند قراءتنا لها في 9 أكتوبر 2026 (والصفحة محدثة في 4 سبتمبر 2026، وآخر قرار فيها بتاريخ 29 يونيو 2026) عددنا فيها 1,614 جهازاً، منها 1,230 في لجنة الأشعة Radiology، أي قرابة 76%، والباقي في القلب والأعصاب وغيرها. ومن حيث مسار الموافقة فإن 1,553 منها مرت بمسار 510(k) و40 بمسار De Novo و21 بمسار PMA. أي أن الأشعة هي أنضج مجال للذكاء الاصطناعي الطبي، والسبب أن الصور رقمية منذ عقود ومحفوظة بمعيار واحد في كل مستشفى.

ولكن الموافقة لا تعني أن النموذج سوف يعمل في مستشفاك بنفس الأرقام، وفيما يلي أهم الأسباب:

  • تغير البيانات Dataset Shift: نموذج تدرب على أجهزة شركة واحدة وبروتوكول سماكة 1 مليمتر قد يتراجع أداؤه على جهاز آخر أو سماكة 5 مليمتر أو فئة سكانية مختلفة، لذلك فالتحقق الخارجي External Validation على بيانات من مستشفى لم يشارك في التدريب هو ما يهم فعلاً.
  • الانتشار Prevalence: نموذج بحساسية ونوعية 95% يعطي أغلب تنبيهاته خاطئة إذا كان المرض نادراً في من يفحصهم، وهذا حساب نفصله بالأرقام في مقال الشركات الناشئة في الذكاء الاصطناعي الطبي، وتجد أساسه في قسم مصفوفة الالتباس في مقال التعلم الآلي.
  • مكان النموذج في سير العمل: نموذج يرتب قائمة القراءة حسب الأولوية يختلف عن نموذج يكتب التشخيص، والجهة المنظمة تعتمد استخداماً محدداً Intended Use وليس النموذج بشكل عام.
  • ما بعد التشغيل: الأجهزة تتحدث، والبروتوكولات تتغير، وأداء النموذج يجب أن يراقب كل شهر كما تراقب أي خدمة في الإنتاج.

والمقال الثالث في هذه المرحلة يتابع هذه الأسئلة من جهة الشركة التي تبني النموذج: من أين تأتي البيانات، وكيف يحدد الأطباء الإجابة الصحيحة، وكيف يحصل المنتج على الموافقة في السعودية وأمريكا وأوروبا.

من المختبر إلى الإنتاج

  • تسجيل الدخول والتشفير: فعل AuthenticationEnabled في Orthanc أو ضعه خلف Reverse Proxy يتولى تسجيل الدخول وTLS، ولا تنشر المنفذ 8042 أو 4242 على الإنترنت أبداً، فصور المرضى من أكثر البيانات حساسية، ولمنفذ DIMSE حدد عناوين الأجهزة المسموح لها فقط. وقد شرحنا التفاصيل في تطبيقك خلف Reverse Proxy وفي شرح OAuth وOpenID Connect إذا أردت ربط العارض بنظام الدخول الموحد.
  • قاعدة البيانات والتخزين: Orthanc يحفظ الفهرس في SQLite افتراضياً، وفي الإنتاج ضعه على PostgreSQL بإضافته الرسمية، وضع الملفات على تخزين مناسب، فقد رأينا أن فحص بطن واحد 90 ميغابايت دون ضغط، فإذا كان القسم يجري مئة فحص في اليوم فأنت أمام قرابة 3 تيرابايت في السنة لهذا النوع وحده، وقد شرحنا الفرق بين أنواع التخزين في مقال أنواع التخزين.
  • النسخ الاحتياطي والاحتفاظ: الصور الطبية لها مدد احتفاظ نظامية تختلف بحسب نوع السجل والجهة، فاعرف المدة المطلوبة منك قبل أن تصمم الأرشيف، واختبر الاستعادة كما في النسخ الاحتياطي بقاعدة 3-2-1.
  • سجل الوصول: من فتح صور هذا المريض ومتى؟ سؤال يجب أن يجيب عنه النظام، فسجل كل قراءة وتنزيل.
  • الرخص: إذا عدلت إضافة DICOMweb في Orthanc وقدمتها كخدمة لعملاء عبر الشبكة فرخصة AGPL تلزمك بنشر تعديلاتك، فاقرأ جدول الحالات في صفحة الترخيص قبل أن تبني منتجاً تجارياً.
  • الاستخدام الطبي: OHIF وOrthanc أدوات ممتازة للبحث والتطوير والتكامل، ولكن العرض للتشخيص يحتاج منتجاً معتمداً كجهاز طبي.

تنظيف المختبر

نحذف ال Containers بالاسم ثم الشبكة، ثم الصور التي نزلناها لأننا التزمنا بشروط الأرشيف وانتهى غرضنا منها، ثم ال Images إن لم تعد تحتاجها:

docker rm -f lab-health-orthanc lab-health-ohif lab-health-py
docker network rm lab-health-net
rm -rf ~/lab-health
docker image rm orthancteam/orthanc:26.9.1 ohif/app:v3.13.12

تدريب عملي

  • استعلم ب QIDO-RS عن كل الفحوص من نوع CT في تاريخ محدد بالمعامل StudyDate=20151124، ثم أضف ModalitiesInStudy=CT وقارن.
  • عدل window.py ليقرأ WindowCenter وWindowWidth من الملف إن وجدا، ويستخدم نافذة الأنسجة الرخوة إن غابا، ثم جربه على CT_small.dcm.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • كل جهاز يقيس شيئاً مختلفاً، وفي CT البكسل رقم بوحدة HU ثابتة بالفيزياء، لذلك لا تحول الصور الطبية إلى JPEG بثمانية بتات وتعتبرها أصلاً.
  • ملف DICOM مقدمة وDICM ثم وسوم ثم بكسلات، والملفات ترتب في هرم: مريض ثم فحص ثم سلسلة ثم صورة، ولكل مستوى معرف UID لا يتجاوز 64 حرفاً.
  • صيغة النقل تحدد الترتيب والضغط، والضغط دون فقدان صغر الشريحة إلى قرابة الثلث دون أن يغير بكسلاً، والضغط مع الفقدان بنسبة 40 غير بعض البكسلات 111 وحدة HU.
  • طبق RescaleSlope وRescaleIntercept قبل أي نافذة أو نموذج، فنسيانها جعل الشريحة بيضاء دون أي رسالة خطأ.
  • DICOMweb يجعل PACS واجهة REST: البحث QIDO-RS والجلب WADO-RS والتخزين STOW-RS، وردودها JSON مفاتيحه أرقام الوسوم.
  • إزالة الهوية بملف التعريف الأساسي حذفت 195 وسماً وغيرت 14 في ملف الاختبار، ولم تلمس البكسلات، فالنص المحروق في الصورة يحتاج معالجة ومراجعة منفصلة.
  • في قائمة FDA عند قراءتنا 1,614 جهازاً بالذكاء الاصطناعي منها 1,230 في الأشعة، ومع ذلك فالتحقق الخارجي ومراقبة الأداء بعد التشغيل هما ما يحدد إن كان النموذج يصلح لمستشفاك.
دليلتبادل البيانات الصحية بين الأنظمة: HL7 وFHIRكيف تنتقل نتائج المختبر وبيانات المريض بين أنظمة المستشفى برسائل HL7 وواجهات FHIR، وكيف تبني ذلك وتختبره بنفسك.دليلكيف تعمل شركة ناشئة في الذكاء الاصطناعي الطبي؟ما الذي تحتاجه شركة ذكاء اصطناعي للأشعة غير النموذج: البيانات والتحقق والموافقة والتكامل مع المستشفى، بحسابات حقيقية.دليلأنواع التخزين وواجهة S3: أين تضع بيانات تطبيقك؟أين تضع قاعدة البيانات وملفات المستخدمين والنسخ الاحتياطية، ولماذا يناسب كلاً منها نوع مختلف من التخزين.دليلالتعلم الآلي Machine Learning للمبتدئينكيف تتعلم الآلة القواعد من الأمثلة، وكيف تعرف أن النموذج تعلم فعلاً ولم يحفظ الإجابات، ومتى تختار كل خوارزمية.دليلكيف تصمم API لا تضطر إلى كسره بعد سنةمبادئ تصميم API يتطور دون أن يكسر التطبيقات التي تعتمد عليه، وكيف تختار الشكل المناسب لنظامك.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ المختبر على Orthanc 1.13.0 (orthancteam/orthanc:26.9.1) وOHIF Viewer 3.13.12 وpydicom 3.0.2 وMONAI 1.6.1، بفحص من مجموعة Pancreas-CT في TCIA برخصة CC BY 3.0، على Docker Engine 29.8.0.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.