Dicom tag editing

Describe Your Question

  • I was trying to implement a functionality in OHIF of editing tags in the tag browser and sync that with DCM4CHEE, but currently encountering some issues, it would be great help if someone who has done the same can help me out.
  • OHIF v3

What steps can we follow to reproduce the bug?

  1. it’s not a bug but rather a functionality.
  2. But still, I added an edit button, on click of which I’m changing the values of the tag to input element with the same value, after editing those values.
  3. After that I’m hitting the DCM4CHEE APIs, respectively for the tags, DCM offers multiple API to update the data for different levels, like patient, study and series. I’m unable to perfectly mould my API payload accordingly.

Below is how my DicomTagBrowser file currently look like -

import dcmjs from 'dcmjs';
import moment from 'moment';
import React, { useState, useMemo, useEffect } from 'react';
import { classes } from '@ohif/core';
import { InputRange, Select, Typography, InputFilterText, Button } from '@ohif/ui';
import debounce from 'lodash.debounce';

import DicomTagTable from './DicomTagTable';
import './DicomTagBrowser.css';
import {
  editableTags, editableTagsPatientDemographics, editableTagsSeriesLevel, editableTagsStudy,
  editableTagsSeriesLevelSend,
  editableTagsPatientDemographicsSend,
  editableTagsStudySend


} from '../editableTags';

const { ImageSet } = classes;
const { DicomMetaDictionary } = dcmjs.data;
const { nameMap } = DicomMetaDictionary;

const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => {
  // The column indices that are to be excluded during a filter of the table.
  // At present the column indices are:
  // 0: DICOM tag
  // 1: VR
  // 2: Keyword
  // 3: Value
  const excludedColumnIndicesForFilter: Set<number> = new Set([1]);

  const [selectedDisplaySetInstanceUID, setSelectedDisplaySetInstanceUID] =
    useState(displaySetInstanceUID);
  const [instanceNumber, setInstanceNumber] = useState(1);
  const [filterValue, setFilterValue] = useState('');
  const [editTag, setEditTag] = useState(false);
  const [changedTags, setChangedTags] = useState({});
  const [changedTagsPatient, setChangedTagsPatient] = useState({});
  const [changedTagsStudy, setChangedTagsStudy] = useState({});
  const [changedTagsSeries, setChangedTagsSeries] = useState({});

  const onSelectChange = value => {
    setSelectedDisplaySetInstanceUID(value.value);
    setInstanceNumber(1);
  };

  const activeDisplaySet = displaySets.find(
    ds => ds.displaySetInstanceUID === selectedDisplaySetInstanceUID
  );

  console.log("selectedDisplaySetInstanceUID", selectedDisplaySetInstanceUID, activeDisplaySet);

  const isImageStack = _isImageStack(activeDisplaySet);
  const showInstanceList = isImageStack && activeDisplaySet.images.length > 1;

  const displaySetList = useMemo(() => {
    displaySets.sort((a, b) => a.SeriesNumber - b.SeriesNumber);
    return displaySets.map(displaySet => {
      const {
        displaySetInstanceUID,
        SeriesDate,
        SeriesTime,
        SeriesNumber,
        SeriesDescription,
        Modality,
      } = displaySet;

      /* Map to display representation */
      const dateStr = `${SeriesDate}:${SeriesTime}`.split('.')[0];
      const date = moment(dateStr, 'YYYYMMDD:HHmmss');
      const displayDate = date.format('ddd, MMM Do YYYY');

      return {
        value: displaySetInstanceUID,
        label: `${SeriesNumber} (${Modality}): ${SeriesDescription}`,
        description: displayDate,
      };
    });
  }, [displaySets]);

  const rows = useMemo(() => {
    let metadata;
    if (isImageStack) {
      metadata = activeDisplaySet.images[instanceNumber - 1];
    } else {
      metadata = activeDisplaySet.instance || activeDisplaySet;
    }
    const tags = getSortedTags(metadata);
    return getFormattedRowsFromTags(tags, metadata);
  }, [instanceNumber, selectedDisplaySetInstanceUID]);

  const filteredRows = useMemo(() => {
    if (!filterValue) {
      return rows;
    }

    const filterValueLowerCase = filterValue.toLowerCase();
    return rows.filter(row => {
      return row.reduce((keepRow, col, colIndex) => {
        if (keepRow) {
          // We are already keeping the row, why do more work so return now.
          return keepRow;
        }

        if (excludedColumnIndicesForFilter.has(colIndex)) {
          return keepRow;
        }

        return keepRow || col.toLowerCase().includes(filterValueLowerCase);
      }, false);
    });
  }, [rows, filterValue]);

  const debouncedSetFilterValue = useMemo(() => {
    return debounce(setFilterValue, 200);
  }, []);

  useEffect(() => {
    return () => {
      debouncedSetFilterValue?.cancel();
    };
  }, []);
  const [isDarkMode, setIsDarkMode] = useState(localStorage.getItem('isLightMode') == 'true' ? false : true)

  useEffect(() => {
    setIsDarkMode(!isDarkMode);
  }, [localStorage.getItem('isLightMode')]);

  const handleSaveTags = async () => {
    const updatedTags = rows.map(row => {
      if (changedTags[row[0]]) {
        return {
          tag: row[0],
          value: changedTags[row[0]],
        };
      }
      return null;
    }).filter(tag => tag !== null);

    // Call your API or method to save updatedTags
    console.log('rrrrrrrrrrrrrrrrrrrrrrrrrrr', updatedTags);
    const tagsOfInterest = ['(0010,0020)', '(0010,0021)', '(0020,000E)'];
    dummy
    // Filter rows to get only those that match the tags of interest
    const filteredRows = rows.filter(row => tagsOfInterest.includes(row[0]));

    // Extract values
    const values = filteredRows.map(row => row[3]);

    console.log('values', values[0] /*for patientid and value[1] for issuerofPatientId value[1], 2 for seriesInstancedid */);

    const currentUrl = window.location.href;
    const currUrl = new URL(currentUrl);
    const searchParams = new URLSearchParams(currUrl.search);
    const studyInstanceUID = searchParams.get('StudyInstanceUIDs');

    // console.log('dddddd', transformDataConditionally(dummy));
    const isTagInList = (tag, list) => list.some(item => item.tag === tag);

    // Study Level Tags
    const studyLevelTags = editableTagsStudy;
    const studyLevelChanges = updatedTags.filter(tag => isTagInList(tag.tag, studyLevelTags));
    
    console.log("🚀 ~ handleSaveTags ~ updatedTags:", updatedTags)
    console.log("🚀 ~ handleSaveTags ~ transformDataConditionally(dummy):", transformDataConditionally(dummy))
    console.log("🚀 ~ handleSaveTags ~ dummy:", dummy)
    console.log("🚀 ~ handleSaveTags ~ changedTagsStudy:", changedTagsStudy)
    
    transformDataConditionally(dummy);


    if (studyLevelChanges.length > 0) {
      //if study level tag
      const fetchedData = await fetch(`http://localhost:8081/dcm4chee-arc/aets/DCM4CHEE/rs/studies/${studyInstanceUID}`, {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(changedTagsStudy),
      });
    }

    // If patient demographics level tag

    const patientDemographicsTags = editableTagsPatientDemographics;
    const patientDemographicsChanges = updatedTags.filter(tag => isTagInList(tag.tag, patientDemographicsTags));

    if (patientDemographicsChanges.length > 0) {
      const patientID = values[0]; // Replace with the actual patient ID if dynamic
      const patientIdentifier = values[1]; // Replace with the actual identifier if dynamic


      // Define the URL for the API call
      const url = `http://localhost:8081/dcm4chee-arc/aets/DCM4CHEE/rs/patients/${patientID}^^^${patientIdentifier}`;
      // Transform the data to match the API requirements

      //Make the API call to update the study data
      const fetchedDataStudy = await fetch(url, {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(changedTagsPatient),
      });
    }

    // if series level tag
    // Define the URL for the API call
    const seriesLevelTags = editableTagsSeriesLevel;
    const seriesLevelChanges = updatedTags.filter(tag => isTagInList(tag.tag, seriesLevelTags));

    if (seriesLevelChanges.length > 0) {
      const seriesInstanceUid = values[2];
      const urlSeries = `http://localhost:8081/dcm4chee-arc/aets/DCM4CHEE/rs/studies/${studyInstanceUID}/series/${seriesInstanceUid}`;

      const fetchedDataSeries = await fetch(urlSeries, {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(changedTagsSeries),
      });
    }

    setChangedTags({});
    setEditTag(false);
  }


  const transformDataConditionally = (rows) => {

    const updatedTags = rows.map(row => {
      if (changedTags[row[0]]) {
        return {
          tag: row[0],
          value: changedTags[row[0]],
        };
      }
      return null;
    }).filter(tag => tag !== null);

    const isTagInList = (tag, list) => list.some(item => item.tag === tag);

    // Study Level Tags
    const studyLevelTags = editableTagsStudy;
    const studyLevelChanges = updatedTags.filter(tag => isTagInList(tag.tag, studyLevelTags));

    if (studyLevelChanges.length > 0) {
      // Convert editableTags array into a Set for fast lookup
      let studyEditableTagsSet = new Set(editableTagsStudySend.map(tag => tag.tag.replace(/[()]/g, '').replace(',', '')));
      let temp = returnSpecificTagList(studyEditableTagsSet, null);
      setChangedTagsStudy(temp);
    }


    const seriesLevelTags = editableTagsSeriesLevel;
    const seriesLevelChanges = updatedTags.filter(tag => isTagInList(tag.tag, seriesLevelTags));

    if (seriesLevelChanges.length > 0) {
      let seriesEditableTagsSet = new Set(editableTagsSeriesLevelSend.map(tag => tag.tag.replace(/[()]/g, '').replace(',', '')));
      let temp = returnSpecificTagList(seriesEditableTagsSet, null);
      setChangedTagsSeries(temp);
    }


    const patientDemographicsTags = editableTagsPatientDemographics;
    const patientDemographicsChanges = updatedTags.filter(tag => isTagInList(tag.tag, patientDemographicsTags));

    if (patientDemographicsChanges.length > 0) {
      let patientEditableTags = new Set(editableTagsPatientDemographicsSend.map(tag => tag.tag.replace(/[()]/g, '').replace(',', '')));
      let temp = returnSpecificTagList(patientEditableTags, patientDemographicsChanges);
      setChangedTagsPatient(temp);
    }

  };


  const returnSpecificTagList = (editableTagsSet, patientDemographicsChanges) => {
    const result = {};

    rows.forEach(([tag, vr, ...values]) => {
      const formattedTag = tag.replace(/[()]/g, '').replace(',', '');

      // Check if the tag is in the editableTags list
      if (editableTagsSet.has(formattedTag)) {
        let value = values[1];

        // Special handling for Patient Name (PN) field in Patient Demographics
        if (formattedTag === '00100010' && vr === 'PN' && patientDemographicsChanges != null && patientDemographicsChanges.length > 0) {

          value = value.split('^').map((nameComponent) => nameComponent.trim()).join('^');

          result[formattedTag] = {
            vr: vr,
            Value: [
              {
                Alphabetic: value
              }
            ]
          };
        } else {
          result[formattedTag] = {
            vr: vr,
            Value: [value]
          };
        }
      }
    });

    return result;
  }


  const transformData = (rows) => {
    // Convert editableTags array into a Set for fast lookup
    const editableTagsSet = new Set(editableTags.map(tag => tag.tag.replace(/[()]/g, '').replace(',', '')));


    const result = {};

    rows.forEach(([tag, vr, ...values]) => {
      const formattedTag = tag.replace(/[()]/g, '').replace(',', '');

      // Check if the tag is in the editableTags list
      if (editableTagsSet.has(formattedTag)) {
        result[formattedTag] = {
          vr: vr,
          Value: [values[1]]
        };
      }
    });

    return result;
  };

  const [dummy, setDummy] = useState(transformData(filteredRows));

  //console.log(JSON.stringify(dummy, null, 4));

  return (
    <div className="dicom-tag-browser-content">
      <div className="mb-6 flex flex-row items-center pl-1">
        <div className="flex w-1/2 flex-row items-center">
          <Typography
            variant="subtitle"
            className={isDarkMode ? "text-black mr-4" : "mr-4"}
          >
            Series
          </Typography>
          <div className="mr-8 grow">
            <Select
              id="display-set-selector"
              isClearable={false}
              onChange={onSelectChange}
              options={displaySetList}
              value={displaySetList.find(ds => ds.value === selectedDisplaySetInstanceUID)}
              className="text-white"
            />
          </div>
        </div>
        <div className="flex w-1/2 flex-row items-center">
          {showInstanceList && (
            <Typography
              variant="subtitle"
              className={isDarkMode ? "text-black mr-4" : "mr-4"}
            >
              Instance Number
            </Typography>
          )}
          {showInstanceList && (
            <div className="grow">
              <InputRange
                value={instanceNumber}
                key={selectedDisplaySetInstanceUID}
                onChange={value => {
                  setInstanceNumber(parseInt(value));
                }}
                minValue={1}
                maxValue={activeDisplaySet.images.length}
                step={1}
                inputClassName="w-full"
                labelPosition="left"
                trackColor={'#3a3f99'}
              />
            </div>
          )}
        </div>
      </div>
      <div className="h-1 w-full bg-black"></div>
      <div className="my-3 flex flex-row justify-between">

        <InputFilterText
          className="mr-8 block"
          placeholder="Search metadata..."
          onDebounceChange={setFilterValue}
        />
        <div>
          <Button onClick={() => { setEditTag(!editTag) }} className='mr-3' disabled={filterValue !== ''}>Edit Tags</Button>
          <Button onClick={handleSaveTags} className='bg-primaryGreen-foreground hover:bg-primaryGreen' disabled={filterValue !== ''}>Save</Button>
        </div>
      </div>
      <DicomTagTable rows={filteredRows} editTag={editTag} handleSaveTags={handleSaveTags} dummy={dummy} setDummy={setDummy} setChangedTags={setChangedTags} />
    </div>
  );
};

function getFormattedRowsFromTags(tags, metadata) {
  const rows = [];

  tags.forEach(tagInfo => {
    if (tagInfo.vr === 'SQ') {
      rows.push([`${tagInfo.tagIndent}${tagInfo.tag}`, tagInfo.vr, tagInfo.keyword, '']);

      const { values } = tagInfo;

      values.forEach((item, index) => {
        const formatedRowsFromTags = getFormattedRowsFromTags(item, metadata);

        rows.push([`${item[0].tagIndent}(FFFE,E000)`, '', `Item #${index}`, '']);

        rows.push(...formatedRowsFromTags);
      });
    } else {
      if (tagInfo.vr === 'xs') {
        try {
          const tag = dcmjs.data.Tag.fromPString(tagInfo.tag).toCleanString();
          const originalTagInfo = metadata[tag];
          tagInfo.vr = originalTagInfo.vr;
        } catch (error) {
          console.error(`Failed to parse value representation for tag '${tagInfo.keyword}'`);
        }
      }
      rows.push([`${tagInfo.tagIndent}${tagInfo.tag}`, tagInfo.vr, tagInfo.keyword, tagInfo.value]);
    }
  });

  return rows;
}

function getSortedTags(metadata) {
  const tagList = getRows(metadata);

  // Sort top level tags, sequence groups are sorted when created.
  _sortTagList(tagList);

  return tagList;
}

function getRows(metadata, depth = 0) {
  // Tag, Type, Value, Keyword

  const keywords = Object.keys(metadata);

  let tagIndent = '';

  for (let i = 0; i < depth; i++) {
    tagIndent += '>';
  }

  if (depth > 0) {
    tagIndent += ' '; // If indented, add a space after the indents.
  }

  const rows = [];
  for (let i = 0; i < keywords.length; i++) {
    let keyword = keywords[i];

    if (keyword === '_vrMap') {
      continue;
    }

    const tagInfo = nameMap[keyword];

    let value = metadata[keyword];

    if (tagInfo && tagInfo.vr === 'SQ') {
      const sequenceAsArray = toArray(value);

      // Push line defining the sequence

      const sequence = {
        tag: tagInfo.tag,
        tagIndent,
        vr: tagInfo.vr,
        keyword,
        values: [],
      };

      rows.push(sequence);

      if (value === null) {
        // Type 2 Sequence
        continue;
      }

      sequenceAsArray.forEach(item => {
        const sequenceRows = getRows(item, depth + 1);

        if (sequenceRows.length) {
          // Sort the sequence group.
          _sortTagList(sequenceRows);
          sequence.values.push(sequenceRows);
        }
      });

      continue;
    }

    if (Array.isArray(value)) {
      if (value.length > 0 && typeof value[0] != 'object') {
        value = value.join('\\');
      }
    }

    if (typeof value === 'number') {
      value = value.toString();
    }

    if (typeof value !== 'string') {
      if (value === null) {
        value = ' ';
      } else {
        if (typeof value === 'object') {
          if (value.InlineBinary) {
            value = 'Inline Binary';
          } else if (value.BulkDataURI) {
            value = `Bulk Data URI`; //: ${value.BulkDataURI}`;
          } else if (value.Alphabetic) {
            value = value.Alphabetic;
          } else {
            console.warn(`Unrecognised Value: ${value} for ${keyword}:`);
            console.warn(value);
            value = ' ';
          }
        } else {
          console.warn(`Unrecognised Value: ${value} for ${keyword}:`);
          value = ' ';
        }
      }
    }

    // tag / vr/ keyword/ value

    // Remove retired tags
    keyword = keyword.replace('RETIRED_', '');
    if (tagInfo) {
      rows.push({
        tag: tagInfo.tag,
        tagIndent,
        vr: tagInfo.vr,
        keyword,
        value,
      });
    } else {
      // skip properties without hex tag numbers
      const regex = /[0-9A-Fa-f]{6}/g;
      if (keyword.match(regex)) {
        const tag = `(${keyword.substring(0, 4)},${keyword.substring(4, 8)})`;
        rows.push({
          tag,
          tagIndent,
          vr: '',
          keyword: 'Private Tag',
          value,
        });
      }
    }
  }

  return rows;
}

function _isImageStack(displaySet) {
  return displaySet instanceof ImageSet;
}

function toArray(objectOrArray) {
  return Array.isArray(objectOrArray) ? objectOrArray : [objectOrArray];
}

function _sortTagList(tagList) {
  tagList.sort((a, b) => {
    if (a.tag < b.tag) {
      return -1;
    }

    return 1;
  });
}

export default DicomTagBrowser;