基于cornerstone3D的dicom影像浏览器 第二章 加载本地文件夹中的dicom文件并归档

先看页面效果

本地dicom文件归档

2.页面结构

我这里用的是二级路由

复制代码
<template>
  <div class="page">
    <div class="header">操作栏</div>
    <div class="body">
      <div class="study">
        <router-view></router-view>
      </div>
      <div class="operate">
        <div class="navbar">
          <div class="navlist-item" v-for="(item, index) in navbar" :key="index">{{ item.label }}</div>
        </div>
        <div class="series">
          <series-bar></series-bar>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import seriesBar from "@/components/seriesBar/index"
export default {
  name: "treatmentManagement",
  components: {
    seriesBar
  },
  data() {
    return {
      navbar: [
        {
          label: '预览',
          path: '/treatmentManagement/dicomView'
        },
        {
          label: '分割',
          path: '/treatmentManagement/segment'
        },
        {
          label: '调整',
          path: '/treatmentManagement/adjust'
        },
        {
          label: '计划',
          path: '/treatmentManagement/plan'
        }
      ],
    };
  },
};
</script>

<style scoped lang="scss">
.page {
  width: 100%;
  height: 100%;
  .header {
    width: 100%;
    height: 50px;
  }
  .body {
    height: calc(100% - 50px);
    display: flex;
    flex-direction: row;
    align-items: center;
    .study, .operate{
      height: 100%;
    }
    .study{
      width: 80%;
    }
    .operate{
      width: 20%;
      .navbar{
        height: 50px;
        display: flex;
        flex-direction: row;
        align-items: center;
        justify-content: space-between;
      }
      .series{
        height: calc(100% - 50px);
      }
    }
  }
}
</style>

3.seriesBar

获取存在vuex中的影像(归档后的影像)

复制代码
<script>
import seriesItem from "@/components/seriesItem/index.vue";
// import { useArchiveStore } from "@/store/archiveStore.js";
// const archiveStore = useArchiveStore();
import { mapState } from "vuex"
export default {
  name: "series",
  components: {
    seriesItem,
  },
  data() {
    return {
      seriesItemRefs: [],
      domWidth: 146,
      seriesListData: [],
    };
  },
  computed:{
    ...mapState('archiveStore', ['currentStudy']),
    currentStudy(){
        return this.$store.state.currentStudy
    }
  },
  watch: {
    "$store.state.archiveStore.currentStudy": {
      handler(newValue, oldValue) {
        let { seriesList } = newValue;
        this.seriesListData = seriesList;
      },
      immediate: true,
    },
  },
  methods: {
    getItemRef(el, idx) {
      if (el) {
        this.seriesItemRefs[idx] = el;
      }
    },
    onSelected({ pos }) {
      for (let i = 0; i < this.seriesItemRefs.length; i++) {
        if (i === pos) {
          this.seriesItemRefs[i].setSelected(true);
        } else {
          this.seriesItemRefs[i].setSelected(false);
        }
      }
    },
    setWidth(width) {
      this.domWidth = width;
    },
  },
};
</script>

<template>
  <div class="seriesbar">
    <series-item
      v-for="(series, idx) in seriesListData"
      :key="series.seriesInsUid"
      :ref="(el) => getItemRef(el, idx)"
      :series="series"
      :pos="idx"
      @selected="onSelected"
    ></series-item>
  </div>
</template>

<style lang="scss" scoped>
.seriesbar {
  display: flex;
  flex-direction: column;
  width: 100%;
  height: 100%;
  white-space: nowrap;
  overflow-x: hidden;
  overflow-y: auto;
  padding: 2px 1px;
  font-size: 1.2rem;
  flex-shrink: 0;
}
</style>

3.seriesItem

主要在这显示归档的dicom影像

复制代码
<script>
import { ref, computed, onMounted } from "vue";
import { toolGroup } from "@/utils/initTools";
import { desensitizeSubstring } from "@/utils/index.js";
export default {
  name: "series",
  props: {
    series: {
      type: Object,
      required: true,
    },
    pos: {
      type: Number,
      required: true,
    },
  },
  computed: {
    seriesTitle() {
      let patName = this.$props.series.study.patientName;
      // patName = desensitizeSubstring(patName, 1, -1);脱敏
      return (
        this.$props.series.study.modality +
        " - " +
        this.$props.series.seriesNumber +
        " " +
        patName
      );
    },
    thumbClass() {
      let ss = "thumbnail";
      let s = "unselect";
      if (this.IsSel) {
        s = "selected";
      }
      return ss + " " + s;
    },
  },
  data() {
    return {
      IsSel: "",
      imgSrc: "",
    };
  },
  methods: {
    onClick(e) {
      this.$emit("selected", { pos: this.$props.pos });
    },
    setSelected(bSel) {
      this.IsSel = bSel;
    }
  },
  mounted() {
    this.$props.series.GetThumb().then((png) => {
      this.imgSrc = new URL(png, import.meta.url).href;
    });
  },
};
</script>

<template>
  <!-- <a-tooltip placement="RightTop" color="#2db7f5">
    
  </a-tooltip> -->

  <div>
    <div>
      <!-- {{ title }}<br /> -->
      {{ series.seriesDesc }}<br />
      {{ series.seriesDate }}<br />
      {{ series.seriesTime }}
    </div>
    <!-- <template #title>
      <span>
        {{ seriesTitle }}<br />
        {{ series.seriesDesc }}<br />
        {{ series.seriesDate }}<br />
        {{ series.seriesTime }}
      </span>
    </template> -->

    <div
      :class="thumbClass"
      draggable="true"
      @click="onClick"
    >
      <h3 class="thumb-title">{{ seriesTitle }}</h3>
      <div class="img-container">
        <img class="img-contain" :src="imgSrc" alt="" draggable="false" />
      </div>
      <p class="serdesc">{{ series.seriesDesc }}</p>
      <p class="serdate">{{ series.seriesDate }}</p>
      <p class="sertime">{{ series.seriesTime }}</p>
      <p class="imgcnt">{{ series.count }}</p>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.thumbnail {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  position: relative;
  background-color: var(--color-study-bg);
  border-radius: 4px;
  border-top: 1px solid var(--color-border-lt);
  border-left: 1px solid var(--color-border-lt);
  border-right: 2px groove var(--color-border-rb);
  border-bottom: 2px groove var(--color-border-rb);
  flex-shrink: 0;
}

.thumbnail {
  width: 100%;
  aspect-ratio: 4/3;
}

.thumb-title {
  height: 2rem;
  font-weight: normal;
}

h3 {
  cursor: default;
  font-size: 1.4rem;
  margin: 0;
}

.tooltip-thumb {
  font-size: 1.6rem;
}

p {
  position: absolute;
  color: #12e08a;
  font-size: 1.25rem;

  &.serdesc {
    width: 100%;
    text-align: left;
    white-space: nowrap;
    text-overflow: ellipsis;
    overflow: hidden;
    left: 0.2rem;
    top: 2rem;
  }
  &.serdate {
    left: 2px;
    top: 3.9rem;
  }
  &.sertime {
    left: 0.2rem;
    top: 5.7rem;
  }

  &.imgcnt {
    right: 0.6rem;
    bottom: 0.6rem;
  }
}

.img-container {
  display: flex;
  background-color: black;
  width: 100%;
  height: 7.5rem;
  align-items: center;
  justify-content: center;
  flex: 1;
}

img {
  width: auto;
  height: 90%;
}

.selected {
  background-color: var(--color-study-active);
  color: var(--color-selected-text);
}

.unselect {
  background-color: var(--color-study-bg);
  color: var(--color-unselect-text);
}
</style>

4.router-view对应的页面

复制代码
<template>
  <div class="page">
    <displayer-area></displayer-area>
  </div>
</template>

<script>
import displayerArea from "@/components/displayerArea/index.vue"
export default {
    name: "dicomView",
    components: {
        displayerArea
    }
}
</script>

<style scoped lang="scss">

</style>

5.displayerArea

复制代码
<template>
  <div class="displayarea" :style="containerStyle">
    <Displayer
      v-for="(v, idx) in pageSize"
      :key="idx"
      :ref="(el) => getDispRef(el, idx)"
      :pos="idx"
      @selected="onSelected"
    />
  </div>
</template>

<script>
import { mapState } from "vuex";
import Displayer from "@/components/displayer/index";
export default {
  name: "displayerArea",
  components: {
    Displayer,
  },
  data() {
    return {
    };
  },
  computed: {
    containerStyle() {
      const repeat = (n, s) => {
        let dst = "";
        for (let i = 0; i < n; i++) {
          dst += s + " ";
        }
        return dst;
      };
      let result = {
        display: "gird",
        "grid-template-columns": repeat(
          this.$store.getters.dicomViewPlayout.row,
          "1fr"
        ),
        height: "100%",
      };
      return result;
    },
    pageSize() {
      return this.$store.getters.dicomViewPageSize;
    },
  },
  methods: {
    onSelected: ({ pos }) => {},
  },
  mounted() {},
};
</script>

<style scoped lang="scss">
.displayarea {
  width: 100%;
  height: 100%;
  display: grid;
  grid-gap: 1px 1px;
  background-color: black;
  color: #fff;
}
</style>

6.displayer

复制代码
<script>
export default {
  name: "displayer",
  data() {
    return {
      IsSel: false,
      IsHover: false,
    };
  },
  computed: {
    borderClass() {
      let s = "selected";
      if (this.IsSel) {
        s = "selected";
      } else {
        if (this.IsHover) {
          s = "hovered";
        } else {
          s = "unselect";
        }
      }
      return s;
    },
  },
};
</script>

<template>
  <div class="displaybox" :class="borderClass">
    <div class="displayer" ref="displayer"></div>
  </div>
</template>
<style lang="scss" scoped>
.displaybox {
  position: relative;
  display: flex;
  flex-direction: row;
  background-color: black;

  .scroll-right {
    width: 20px;
  }
}
.displayer {
  flex: 1;
  text-align: left;
  cursor: default;
  user-select: none;

  $font-size: 14px;
  @mixin orient($text-align: left) {
    position: absolute;
    color: white;
    font-size: $font-size;
    text-align: $text-align;
    z-index: 10;
  }

  .orient_top {
    @include orient(center);
    top: 2px;
    left: calc(50% - 30px);
    width: 60px;
  }

  .orient_bottom {
    @include orient(center);
    bottom: 2px;
    left: calc(50% - 30px);
    width: 60px;
  }

  .orient_left {
    @include orient();
    top: calc(50% - 20px);
    left: 2px;
  }

  .orient_right {
    @include orient();
    top: calc(50% - 20px);
    right: 2px;
  }
}

.selected {
  border: 1px solid red;
}
.hovered {
  border: 1px dashed yellow;
}
.unselect {
  border: 1px solid #fff;
}
</style>

7.选择本地文件夹

对dicom文件进行归档,并存在vuex里

复制代码
<template>
  <el-dialog
    class="my_dialog"
    title="请确认患者信息"
    :visible.sync="dialogVisible"
    width="950px"
    :close-on-click-modal="false"
    :close-on-press-escape="false"
    @close="cancelClick"
  >
    <div class="m-content">
      <el-form ref="form" :model="params" label-width="100px">
        <el-form-item label="患者姓名" prop="name">
          <el-input v-model="params.name"></el-input>
        </el-form-item>
        <el-form-item label="性别" prop="gender">
          <el-radio-group v-model="params.gender">
            <el-radio :label="1">男</el-radio>
            <el-radio :label="0">女</el-radio>
          </el-radio-group>
        </el-form-item>
        <el-form-item label="出生日期" prop="birthday">
          <el-date-picker
            v-model="params.birthday"
            type="date"
            placeholder="选择日期"
          >
          </el-date-picker>
        </el-form-item>
        <el-form-item label="dicom文件">
          <el-button @click="loadFolder">选择文件</el-button>
           </el-form-item>
      </el-form>
    </div>
  </el-dialog>
</template>

<script>
import {mapSate, mapGetters, mapMutations, mapActions} from 'vuex'
export default {
  data() {
    return {
      dialogVisible: false,
      params: {}
    };
  },
  methods: {
    ...mapActions('archiveStore',['archiveFile']),
    //打开弹窗
    handleOpen() {
      this.dialogVisible = true;
    },
    //关闭弹窗
    cancelClick() {
      this.dialogVisible = false;
    },
    //选择文件夹
    async loadFolder() {
      const fileHandles = await this.openFolder();
      if (Array.isArray(fileHandles)) {
        fileHandles.forEach(async (fileHandle) => {
          // const file = fileHandle.name;
          const file = await fileHandle.getFile();
          // await archiveFile(file);
          await this.$store.dispatch('archiveStore/archiveFile', file)

          this.$router.push("/treatmentManagement/dicomView")
        });
      }
    },
    //打开系统弹窗
    async openFolder() {
      try {
        const handle = await showDirectoryPicker();
        const fileHandles = [];
        await this.enumFiles(handle, fileHandles);

        return fileHandles;
      } catch (err) {
        return null;
      }
    },
    //处理文件
    async enumFiles(handle, fileHandles) {
      try {
        // 处理文件
        if (handle.kind === "file") {
          if (handle.name.toLowerCase().endsWith(".dcm")) {
            fileHandles.push(handle);
          }
          return;
        }

        // 处理目录
        const itr = await handle.values();
        for await (const entry of itr) {
          try {
            await this.enumFiles(entry, fileHandles);
          } catch (e) {
            console.warn(`无法访问 ${entry.name}:`, e);
          }
        }
      } catch (err) {
        console.error(`处理 ${handle.name} 时出错:`, err);
      }
    },
  },
};
</script>

<style lang="scss" scoped>
</style>

8.vuex: archiveStore.js

复制代码
import DCMStudy from "@/pacs/DCMStudy.js"
import DCMSeries from "@/pacs/DCMSeries.js"
import DCMImage from "@/pacs/DCMImage.js"
import cornerstoneWADOImageLoader from 'cornerstone-wado-image-loader';
import * as cornerstone from "@cornerstonejs/core"
import dicomParser from "dicom-parser"
cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
cornerstoneWADOImageLoader.external.dicomParser = dicomParser
export default {
    namespaced: true,
    state: {
        archiveData: {
            studyList: [],
            mainStudyId: "",
            orgId: "",
            dataformat: ""
        },
        currentStudy: null,
    },
    mutations: {
        setArchiveData(state, payload){
                state.archiveData = Object.assign({}, state.archiveData, payload)

        },
        setCurrentStudy(state, payload) {
			state.currentStudy = payload;
		},
    },
    actions: {
        async archiveFile(store, file) {
            let imageId = "";
            if (typeof file === "string") {
                imageId = "wadouri:/apii/webcloms/static/2022144725/dcm_org/" + file
            } else {
                imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(file)
            }

            const dcmImage = new DCMImage({ imageId });
            await dcmImage.parse();

            if (!store.state.archiveData.mainStudyId) {
                store.commit('setArchiveData', {
                    dataformat: "StudyList",
                    mainStudyId: dcmImage.studyInsUid,
                    orgId: "",
                    studyList: []
                })
            }

            const study = store.state.archiveData.studyList.find(s => s.studyInsUid === dcmImage.studyInsUid);

            if (study) {
                const series = study.seriesList.find(s => s.seriesInsUid === dcmImage.seriesInsUid);

                if (series) {
                    series.AddImage(dcmImage);
                } else {
                    const series = new DCMSeries({
                        study,
                        seriesInsUid: dcmImage.seriesInsUid,
                        seriesNumber: dcmImage.seriesNumber,
                        sereisDesc: dcmImage.sereisDesc,
                        seriesDate: dcmImage.seriesDate,
                        seriesTime: dcmImage.seriesTime
                    });
                    series.AddImage(dcmImage);
                    study.AddSeries(series);
                }
            } else {
                const study = new DCMStudy({
                    patientId: dcmImage.patientId,
                    patientName: dcmImage.patientName,
                    studyAge: dcmImage.studyAge,
                    birthday: dcmImage.birthday,
                    gender: dcmImage.gender,
                    studyId: dcmImage.studyId,
                    studyInsUid: dcmImage.studyInsUid,
                    studyDate: dcmImage.studyDate,
                    modality: dcmImage.modality,
                    isMain: dcmImage.PatientID === store.state.archiveData.mainStudyId
                });
                const series = new DCMSeries({
                    study,
                    seriesNumber: dcmImage.seriesNumber,
                    seriesInsUid: dcmImage.seriesInsUid,
                    sereisDesc: dcmImage.seriesDesc,
                    seriesDate: dcmImage.seriesDate,
                    seriesTime: dcmImage.seriesTime
                });

                series.AddImage(dcmImage);
                study.AddSeries(series);
                store.state.archiveData.studyList.push(study);
                store.commit('setCurrentStudy', study)
            }
        }
    }
}

9.vuex: dicom.js

复制代码
export default {
    namespaced: true,
    state: {
        layout: {
            col: 2,
            row: 2
        },
        pageSize: 4
    },
    mutations: {
        setLayout(state, { col, row }) {
            state.col = col
            state.row = row
        }
    },
    actions: {

    }
}

10.工具函数: pacs/DCMImage.js

复制代码
import cornerstoneDICOMImageLoader from "@cornerstonejs/dicom-image-loader";
// import config from "@/config/index.js"
import { RenderingEngine, getRenderingEngine, Enums, metaData } from "@cornerstonejs/core";
// import * as cornerstone from "@cornerstonejs/core"
import { readTagAsString, decodeChinese, formartDcmDate, trimLeft, desensitizeSubstring, formartDcmTime } from "@/utils/readImage.js"
// cornerstone.init()


// 注册图像加载器
import * as  cornerstone from "@cornerstonejs/core";
import dicomParser from 'dicom-parser'
import cornerstoneWADOImageLoader from 'cornerstone-wado-image-loader'
cornerstone.init()
cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
cornerstoneWADOImageLoader.external.dicomParser = dicomParser;

// cornerstoneWADOImageLoader.configure({
//   beforeSend: function (xhr) {
//     const apiKey = localStorage.getItem('token');
//     if (apiKey) {
//       xhr.setRequestHeader('token', apiKey);
//     }
//   }
// });

var config = {
  maxWebWorkers: navigator.hardwareConcurrency || 1,//创建web worker的最大数量,默认为1
  startWebWorkersOnDemand: true, //默认情况下在需要时才创建web worker,如果希望在项目初始化时创建可设置为:false
  taskConfiguration: {
    decodeTask: {
      initializeCodecsOnStartup: false,//默认情况下web worker 不会在启动时初始化图片解码器,如果希望开启设置为:true
    }
  },
};
cornerstoneWADOImageLoader.webWorkerManager.initialize(config);
cornerstone.imageLoader.registerImageLoader("wadouri", cornerstoneWADOImageLoader.imageLoader)

export default class DCMImage {
	constructor(args) {
		//用于归档的数据-start
		this.series = null; // 所属序列
		this.parsed = false; // 是否已解析
		this.imageId = "";
		this.studyInsUid = "";
		this.seriesInsUid = "";
		this.sopInsUid = "";
		this.instanceNumber = 1;
		this.width = 0;
		this.height = 0;
		this.defWW = 400;
		this.defWL = 40;
		this.thickness = 0;
		this.patientId = "";
		this.studyAge = "";
		this.birthday = "";
		this.gender = "";
		this.modality = "";
		this.sereisDate = "";
		this.seriesTime = "";
		this.seriesDesc = "";
		//用于归档的数据-end


		//图像信息-start
		this.PatientName = ""; // 姓名
		this.AgeAndSex = ""; // 年龄(或者生日)+性别
		this.ImageComments = ""; // 图像说明
		this.EchoNumbers = ""; // 回波组号
		this.PatientID = ""; // 病人病号
		this.SeriesNo = ""; // 序列号
		this.SeriesDateTime = ""; // 序列日期时间
		this.StudyDateTime = ""; // 检查日期时间
		this.HospitalName = ""; // 医院名称
		this.StudyDesc = ""; // 检查描述
		this.SeriesDesc = ""; // 序列描述
		this.PatientPosition = ""; // 患者位置
		this.DeviceName = ""; // 设备名称
		this.StudyDate = ""; // 检查日期
		this.AcquisitionDate = ""; // 采集日期
		this.AcquisitionTime = ""; // 采集时间
		this.ContentDate = ""; // 内容日期
		this.ContentTime = ""; // 内容时间
		this.Manufacturer = ""; // 制造商
		this.Position = ""; // 方位 0x200B, 0x1011
		this.ThicknessLocation = ""; // 厚度位置
		this.Exposure = ""; // 曝光参数
		this.TrTe = ""; // 拍片参数
		this.AcquisitionDuration = ""; // 采集时长
		//图像信息-end

		if (args) {
			this.imageId = args.imageId;

		}
	}

	async parse() {

		// const image = await cornerstoneDICOMImageLoader.wadouri.loadImage(
		// 	this.imageId
		// ).promise;

		
		await cornerstone.imageLoader.loadImage(this.imageId).then(image => {
			this.sopInsUid = image.data.string("x00080018"); // SOP Instance UID
			this.studyInsUid = image.data.string("x0020000d"); // Study Instance UID
			this.seriesInsUid = image.data.string("x0020000e"); // Series Instance UID
			this.instanceNumber = image.data.string("x00200013"); // Instance Number

			this.seriesDesc = image.data.string("x0008103E"); // Series Description
			this.seriesDate = image.data.string("x00080021"); // Series Date
			this.seriesTime = image.data.string("x00080031"); // Series Time

			this.PatientPosition = image.data.string("x00180015"); // Body Part Examined


			this.width = image.width;
			this.height = image.height;
			this.defWL = image.windowCenter;
			this.defWW = image.windowWidth;

			let val = image.data.string("x00180050");
			if (val) this.thickness = parseFloat(val);
			this.parsed = true;

			//解析图像信息
			this.readCornerText(image)
		});


	}

	readCornerText(image) {
		const lang = "zh_CN";
		let charset = image.data.string("x00080005") || "ISO_IR 100";
		const isUtf8 = charset.indexOf("ISO_IR 192") != -1;
		const isCN = lang === "zh_CN";

		let val;
		let tag;
		// 姓名
		if (this.series && this.series.study && this.series.study.patientName) {
			val = this.series.study.patientName;
		} else {
			tag = image.data.elements["x00100010"]; // 医院名称
			if (tag) {
				val = readTagAsString(
					image.data.byteArray,
					tag.dataOffset,
					tag.length
				);
			}
			if (val) {
				val = decodeChinese(val, isUtf8);
			}
		}
		if (config.desensitize) {
			val = desensitizeSubstring(val, 1, -1);
		}
		this.PatientName = val;
		val = image.data.string("x00101010"); // 年龄(或者生日)+性别
		if (!val || val.length <= 0) {
			val = image.data.string("x00100030");
			if (!val) val = "";
		} else {
			if (isCN) {
				val = val.replace(/Y|y/, "岁");
				val = val.replace(/M|m/, "月");
				val = val.replace(/D|d/, "天");
			}
		}
		let tmp = image.data.string("x00100040");
		if (!tmp || tmp.length <= 0) tmp = isCN ? "未知" : "UNKNOWN";
		if (isCN) {
			if (tmp === "M" || tmp === "m") tmp = "男";
			else if (tmp === "F" || tmp === "f") tmp = "女";
			else tmp = "未知";
		}
		val += " " + tmp;
		this.AgeAndSex = val.replace(/^0+/g, "");

		val = image.data.string("x00204000"); // 图像说明
		if (val) {
			this.ImageComments = decodeChinese(val, isUtf8);
		}

		val = image.data.string("x00180086"); // 回波组号
		if (val) this.EchoNumbers = val;
		this.PatientID = image.data.string("x00100020"); // 病人病号

		val = image.data.string("x00200011"); // 序列号
		if (val) this.SeriesNo = "Se:" + val;

		// 序列日期时间
		val = "";
		if (this.series && this.series.seriesDate) val = this.series.seriesDate;
		else val = image.data.string("x00080021"); // 序列日期
		if (val) {
			this.SeriesDateTime = formartDcmDate(val);
		}
		val = "";
		if (this.series && this.series.seriesTime) val = this.series.seriesTime;
		else val = image.data.string("x00080031"); // 序列时间
		if (val) {
			this.SeriesDateTime += " " + formartDcmTime(val);
		}


		if (this.series && this.series.study && this.series.study.studyDate)
			this.StudyDateTime = this.series.study.studyDate; // 检查日期时间
		else {
			val = "";
			val = image.data.string("x00080020"); // 检查日期
			if (val) {
				this.StudyDateTime = formartDcmDate(val);
			}
			val = "";
			val = image.data.string("x00080030"); // 检查时间
			if (val) {
				this.StudyDateTime += " " + formartDcmTime(val);
			}
		}

		val = "";
		tag = image.data.elements["x00080080"]; // 医院名称
		if (tag) {
			val = readTagAsString(
				image.data.byteArray,
				tag.dataOffset,
				tag.length
			);
		}

		if (val) {
			val = decodeChinese(val, isUtf8);
			if (config.desensitize) {
				val = desensitizeSubstring(val, 3, val.length - 2);
			}
			this.HospitalName = val;
		}

		val = "";
		tag = image.data.elements["x00081030"]; // 检查描述
		if (tag) {
			val = readTagAsString(
				image.data.byteArray,
				tag.dataOffset,
				tag.length
			);
		}

		if (val) {
			this.StudyDesc = decodeChinese(val, isUtf8);
			this.StudyDesc = trimLeft(this.StudyDesc);
		}

		val = "";
		tag = image.data.elements["x0008103e"]; // 序列描述
		if (tag) {
			val = readTagAsString(
				image.data.byteArray,
				tag.dataOffset,
				tag.length
			);
		}

		if (val) {
			this.SeriesDesc = decodeChinese(val, isUtf8);
			this.SeriesDesc = trimLeft(this.SeriesDesc);
		}

		val = image.data.string("x00185100"); // 患者位置
		if (val) this.PatientPosition = val;

		if (this.series && this.series.study && this.series.study.modality)
			this.DeviceName = this.series.study.modality; // 设备名称
		else this.DeviceName = image.data.string("x00080060"); // 设备名称 x00080060 Device Name

		if (this.series && this.series.study && this.series.study.studyDate) {
			this.StudyDate = this.series.study.studyDate; // 检查日期
		} else {
			val = image.data.string("x00080020"); // 检查日期 x00080020 Study Date
			this.StudyDate = formartDcmDate(val);
		}

		val = image.data.string("x00080022"); // 接收日期
		if (val) {
			this.AcquisitionDate = formartDcmDate(val);
		}
		val = image.data.string("x00080032"); // TA
		if (val) {
			this.AcquisitionTime = formartDcmTime(val);
		}

		val = image.data.string("x00080023"); // 内容日期
		if (val) {
			this.ContentDate = formartDcmDate(val);
		}
		val = image.data.string("x00080033"); // 内容时间
		if (val) {
			this.ContentTime = formartDcmTime(val);
		}

		val = "";
		tag = image.data.elements["x00080070"]; // 检查描述
		if (tag) {
			val = readTagAsString(
				image.data.byteArray,
				tag.dataOffset,
				tag.length
			);
		}

		if (val) {
			this.Manufacturer = decodeChinese(val, isUtf8);
		}

		val = image.data.string("x200B1011"); // 方位 0x200B, 0x1011
		if (val) this.Position = val;

		val = image.data.string("x00180050"); // 厚度位置
		if (val) {
			val = "T:" + parseFloat(val).toFixed(1);
		}
		tmp = image.data.string("x00200032", 2);
		if (tmp) {
			val += " L:" + parseFloat(tmp).toFixed(1);
		}
		this.ThicknessLocation = val;

		val = "";
		let current = image.data.string("x00181151"); // 曝光参数
		if (current) {
			if (current.indexOf(".") !== -1)
				current = current.substring(0, current.indexOf("."));
		}
		if (current && current.length > 0) {
			current += "mA";
			val += current;
		}
		let kvp = image.data.string("x00180060"); // 电压值
		if (kvp) {
			if (kvp.indexOf(".") !== -1)
				kvp = kvp.substring(0, kvp.indexOf("."));
		}
		if (kvp && kvp.length > 0) {
			kvp += "kV";
			val += " " + kvp;
		}
		let exposuretime = image.data.string("x00181150");
		if (exposuretime) {
			if (exposuretime.indexOf(".") !== -1)
				exposuretime = exposuretime.substring(
					0,
					exposuretime.indexOf(".")
				);
		}
		if (exposuretime && exposuretime.length > 0) {
			exposuretime += "ms";
			val += " " + exposuretime;
		}
		let exposure = image.data.string("x00181152");
		if (exposure) {
			if (exposure.indexOf(".") !== -1)
				exposure = exposure.substring(0, exposure.indexOf("."));
		}
		if (exposure && exposure.length > 0) {
			exposure += "mAs";
			val += " " + exposure;
		}
		this.Exposure = val;

		let tr = image.data.string("x00180080"); // 重复时间
		let te = image.data.string("x00180081"); // 回响时间
		if (tr && te) {
			// 拍片参数
			this.TrTe = "TR:" + tr + " TE:" + te;
		}

		val = image.data.string("x00189073"); // 采集时长AD
		if (val) this.AcquisitionDuration = "AD:" + val;
	}

	createThumb(type, size = 100) {
		return new Promise(async (res, rej) => {
			const elDiv = document.createElement("div");
			let divW = this.width || 100;
			let divH = this.height || 100;
			let ratio = divW / divH;

			if (size != 0) {
				if (this.width > size) {
					divW = size;
					divH = divW / ratio;
				} else if (this.height > size) {
					divH = size;
					divW = divH * ratio;
				}
			}

			// 确保最小尺寸
			divW = Math.max(1, divW);
			divH = Math.max(1, divH);

			elDiv.style.width = divW + "px";
			elDiv.style.height = divH + "px";
			elDiv.style.backgroundColor = "black";
			elDiv.style.position = 'absolute'; // 添加绝对定位
			elDiv.style.visibility = 'hidden'; // 初始隐藏
			document.body.appendChild(elDiv);


			requestAnimationFrame(async () => {
				const renderingEngineId = "thumbRenderingEngine";
				let renderingEngine = getRenderingEngine(renderingEngineId);
				if (!renderingEngine) {
					renderingEngine = new RenderingEngine(renderingEngineId);
				}

				const viewportId = "THUMBNAIL_STACK_" + this.sopInsUid;
				const ViewportType = Enums.ViewportType;
				const viewportInput = {
					viewportId,
					type: ViewportType.STACK,
					element: elDiv,
					defaultOptions: {
						background: [0, 0, 0]
					}
				};

				renderingEngine.enableElement(viewportInput);

				let viewport = renderingEngine.getViewport(viewportId);
				const stack = [this.imageId];
				await viewport.setStack(stack);
				viewport.render();

				// 使用requestAnimationFrame确保渲染完成
				await new Promise(resolve => requestAnimationFrame(resolve));

				setTimeout(() => {
					const cvs = elDiv.querySelector("canvas");
					if (type === "png") {
						const imgUri = cvs.toDataURL("image/png");
						elDiv.remove();
						res(imgUri);
					} else if (type === "jpg") {
						const imgUri = cvs.toDataURL("image/jpeg");
						elDiv.remove();
						res(imgUri);
					}
				}, 100)
			})


		});
	}


}

11.工具函数: pacs/DCMSeries.js

复制代码
export default class DCMSeries {
	constructor(args) {
		this.imageList = [];
		this.study = null;
		this.seriesInsUid = "";
		this.seriesNumber = "";
		this.sereisDesc = "";
		this.seriesDate = null;
		this.seriesTime = null;
		this.thumb = null;
		this.count = 0
		if (args) {
			this.study = args.study;
			this.seriesInsUid = args.seriesInsUid;
			this.seriesNumber = args.seriesNumber;
			this.sereisDesc = args.sereisDesc;
			this.seriesDate = args.seriesDate;
			this.seriesTime = args.seriesTime;
		}
	}

	AddImage(img, sort = false) {
		if (!img) return;
		const existImg = this.imageList.find(item => item.imageId === img.imageId);
		if (!existImg) {
			let { series, ...args } = img
			
			args.createThumb = img.createThumb
			this.imageList.push(args);
			this.count = this.imageList.length	
			img.series = this;
			if (sort) {
				this.Sort();
			}
		}
	}

	Sort(bAsc = true) {
		this.imageList.sort(function (a, b) {
			const s1 = parseInt(a.instanceNumber);
			const s2 = parseInt(b.instanceNumber);
			return  bAsc ? s1 - s2 : s2 - s1;
		});
	}

	async GetThumb() {
		if (this.thumb) {
			return this.thumb;
		} else {
			if (this.imageList.length > 0) {
				const img = this.imageList[0];
				
				this.thumb = await img.createThumb("png");
				return this.thumb;
			} else {
				return null;
			}
		}
	}
	
	GetImageByIndex(index){
		return this.imageList[index]
	}

	GetImageById(imageId){
		let result = this.imageList.find(item => item.imageId == imageId)
		if(result) return result
	}

	GetImageIds(){
		return this.imageList.map(item => item.imageId)
	}

	GetCount(){
		return this.imageList.length
	}
}

12,工具函数 pacs/DCMStudy.js

复制代码
export default class DCMStudy {
	constructor(args) {
		this.seriesList = []; // 序列列表
		this.patientId = "";
		this.patientName = "";
		this.studyAge = "";
		this.birthday = "";
		this.gender = "";
		this.studyId = "";
		this.studyInsUid = "";
		this.studyDate = "";
		this.modality = "";
		this.isMain = false;

		if (args) {
			this.patientId = args.patientId;
			this.patientName = args.patientName;
			this.studyAge = args.studyAge;
			this.birthday = args.birthday;
			this.gender = args.gender;
			this.studyId = args.studyId;
			this.studyInsUid = args.studyInsUid;
			this.studyDate = args.studyDate;
			this.modality = args.modality;
			this.isMain = args.isMain;
		}
	}

	AddSeries(series, sort = false) {
		if (!series) return;
		const existSer = this.seriesList.find(item => item.seriesInsUid === series.seriesInsUid);
		if (!existSer) {
			// series.study = this;
			this.seriesList.push(series);
			let { seriesList, ...agrs } = this
			series.study = agrs
			if (sort) {
				this.Sort();
			}
		}
	}

	Sort(bAsc = true) {
		this.seriesList.sort(function (a, b) {
			// 按序列号排序
			const s1 = parseInt(a.seriesNumber);
			const s2 = parseInt(b.seriesNumber);
			return bAsc ? s1 - s2 : s2 - s1;
		});
	}

	GetCount(){
		console.log('获取长度', this.seriesList)
	}
}

13.utils/readImage.js

复制代码
function readTagAsString(byteArray, offset, length) {
  // Create a view of the relevant portion of the array
  const slice = byteArray.slice(offset, offset + length);
  // Convert to string (assuming ISO-8859-1/Latin1 encoding)
  return String.fromCharCode.apply(null, slice);
}

function decodeChinese(value, isUtf8) {
  if (!value) return value;
  
  if (isUtf8) {
    // If UTF-8 encoded, decode using TextDecoder
    const encoder = new TextDecoder('utf-8');
    const bytes = new Uint8Array(value.split('').map(c => c.charCodeAt(0)));
    return encoder.decode(bytes);
  } else {
    // For GBK/GB18030 encoding (common for Chinese DICOM files)
    // Note: This requires a GBK decoder library or browser support
    try {
      // Modern browsers can handle GB18030
      const encoder = new TextDecoder('gb18030');
      const bytes = new Uint8Array(value.split('').map(c => c.charCodeAt(0)));
      return encoder.decode(bytes);
    } catch (e) {
      // Fallback to Latin1 if decoding fails
      return value;
    }
  }
}

function formartDcmDate(dateStr) {
  if (!dateStr || dateStr.length < 8) return dateStr;
  return `${dateStr.substring(0, 4)}-${dateStr.substring(4, 6)}-${dateStr.substring(6, 8)}`;
}

function formartDcmTime(timeStr) {
  if (!timeStr) return timeStr;
  
  // Handle various time formats
  const hours = timeStr.substring(0, 2) || '00';
  const mins = timeStr.length >= 4 ? timeStr.substring(2, 4) : '00';
  const secs = timeStr.length >= 6 ? timeStr.substring(4, 6) : '00';
  
  return `${hours}:${mins}:${secs}`;
}
function trimLeft(str) {
  return str ? str.replace(/^\s+/, '') : str;
}

function desensitizeSubstring(str, start, end) {
if (end == -1) {
end = str.length;
}

let len = end - start;
if (len > 5) len = 5;
let desenStr = str.substr(0, start) + "*".repeat(len) + str.substr(end);
return desenStr;
}

export {
    readTagAsString, decodeChinese, formartDcmDate, trimLeft, desensitizeSubstring, formartDcmTime
}
相关推荐
恋猫de小郭14 分钟前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅7 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了8 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅8 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅8 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment9 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅9 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊9 小时前
jwt介绍
前端