基于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
}
相关推荐
念念不忘 必有回响3 小时前
js设计模式-装饰器模式
javascript·设计模式·装饰器模式
用户21411832636023 小时前
Nano Banana免费方案来了!Docker 一键部署 + 魔搭即开即用,小白也能玩转 AI 图像编辑
前端
weixin_584121433 小时前
vue3+ts导出PDF
javascript·vue.js·pdf
Zacks_xdc4 小时前
【前端】使用Vercel部署前端项目,api转发到后端服务器
运维·服务器·前端·安全·react.js
给月亮点灯|4 小时前
Vue基础知识-脚手架开发-使用Axios发送异步请求+代理服务器解决前后端分离项目的跨域问题
前端·javascript·vue.js
叫我阿柒啊4 小时前
从Java全栈到前端框架:一次真实的面试对话与技术解析
java·javascript·typescript·vue·springboot·react·前端开发
张迅之4 小时前
【React】Ant Design 5.x 实现tabs圆角及反圆角效果
前端·react.js·ant-design
@CLoudbays_Martin114 小时前
为什么动态视频业务内容不可以被CDN静态缓存?
java·运维·服务器·javascript·网络·python·php
蔗理苦5 小时前
2025-09-05 CSS3——盒子模型
前端·css·css3