-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: implement convert study to video #14 gitlab
- Loading branch information
1 parent
e41f1fc
commit 7b693d5
Showing
5 changed files
with
477 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
22 changes: 21 additions & 1 deletion
22
api/dicom-web/controller/convert-image/service/convert-image.service.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,26 @@ | ||
const { StudyVideoFactory } = require("./video-factory"); | ||
|
||
class ConvertImageService { | ||
constructor(req, res) { | ||
/** @type { import("express").Request } */ | ||
this.request = req; | ||
/** @type { import("express").Response } */ | ||
this.response = res; | ||
} | ||
} | ||
|
||
async convert() { | ||
const videoMimeType = [ | ||
"video/mp4", | ||
"video/x-msvideo", | ||
"video/mpeg", | ||
"video/H265" | ||
]; | ||
|
||
if (videoMimeType.includes(this.request.headers.accept)) { | ||
let videoFactory = new StudyVideoFactory(this.request.headers.accept, this.request.params); | ||
return await videoFactory.convert(); | ||
} | ||
} | ||
} | ||
|
||
module.exports.ConvertImageService = ConvertImageService; |
268 changes: 268 additions & 0 deletions
268
api/dicom-web/controller/convert-image/service/video-factory.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,268 @@ | ||
const { DicomWebServiceError } = require("@error/dicom-web-service"); | ||
const { StudyImagePathFactory } = require("../../WADO-RS/service/WADO-RS.service"); | ||
const { Dcm2JpgExecutor$Dcm2JpgOptions } = require("@java-wrapper/org/github/chinlinlee/dcm2jpg/Dcm2JpgExecutor$Dcm2JpgOptions"); | ||
const { InstanceModel } = require("@models/sql/models/instance.model"); | ||
const { Dcm2JpgExecutor } = require("@java-wrapper/org/github/chinlinlee/dcm2jpg/Dcm2JpgExecutor"); | ||
const path = require("path"); | ||
const mkdirp = require("mkdirp"); | ||
const fsP = require("fs/promises"); | ||
const { v4: uuidV4 } = require("uuid"); | ||
const os = require("os"); | ||
const ffmpegInstance = require("ffmpeg-static"); | ||
const ffmpeg = require("fluent-ffmpeg"); | ||
const { EventEmitter } = require("events"); | ||
ffmpeg.setFfmpegPath(ffmpegInstance); | ||
|
||
class VideoFactory { | ||
constructor(mimeType, uids) { | ||
this.mimeType = mimeType; | ||
this.uids = uids; | ||
this.imagePathFactory = undefined; | ||
this.imageTempFolder = path.join(__dirname, "../../../../../tempUploadFiles", this.uids.studyUID, uuidV4()); | ||
} | ||
|
||
/** | ||
* @param {string} folder the folder location that contains frames jpeg images | ||
* @param {string[]} jpegs the jpeg images' file location | ||
*/ | ||
async convert(folder, jpegs) { | ||
let filename = await this.getFfmpegInputData(jpegs); | ||
if (this.mimeType === "video/mp4") { | ||
let converter = new Mp4Converter(); | ||
return await converter.convert(folder, filename); | ||
} else if (this.mimeType === "video/x-msvideo") { | ||
let converter = new AviConverter(); | ||
return await converter.convert(folder, filename); | ||
} else if (this.mimeType === "video/mpeg") { | ||
let converter = new MpegConverter(); | ||
return await converter.convert(folder, filename); | ||
} else if (this.mimeType === "video/H265") { | ||
let converter = new H265Converter(); | ||
return await converter.convert(folder, filename); | ||
} | ||
} | ||
|
||
async getFfmpegInputData(images) { | ||
let ffmpegInputData = images.map(jpeg => `file 'file:${jpeg}'`).join("\n"); | ||
let filename = path.join(__dirname, "../../../../../tempUploadFiles", `${uuidV4()}.txt`); | ||
await fsP.writeFile(filename, ffmpegInputData); | ||
return filename; | ||
} | ||
|
||
async getImagePaths() { | ||
await this.imagePathFactory.getImagePaths(); | ||
let checkAllImageExistResult = await this.imagePathFactory.checkAllImageExist(); | ||
if (!checkAllImageExistResult.status) { | ||
throw new DicomWebServiceError("404", checkAllImageExistResult.message, 400); | ||
} | ||
|
||
return this.imagePathFactory.imagePaths; | ||
} | ||
|
||
async getInstancesFrames() { | ||
let imagePaths = await this.getImagePaths(); | ||
let instancesFrames = await Promise.all(imagePaths.map(async imagePath => await InstanceModel.getInstanceFrame(imagePath))); | ||
instancesFrames.sort((a, b) => a["x00200013"] - b["x00200013"]); | ||
return instancesFrames; | ||
} | ||
|
||
/** | ||
* TODO: 也許需要重構成 wado-rs/uri 通用的產圖 class | ||
* @param {import("@root/utils/typeDef/dicomImage").InstanceFrameObj} frame | ||
* @param {string} folder | ||
* @returns | ||
*/ | ||
async generateJpegImage(frame, folder) { | ||
let jsDcm2Jpegs = []; | ||
|
||
let imageFrameNumber = frame["00280008"]?.["Value"]?.[0] || 1; | ||
mkdirp.sync(folder, "0755"); | ||
imageFrameNumber = parseInt(imageFrameNumber); | ||
let jpegBaseFilename = path.resolve( | ||
path.join(folder, frame["instanceUID"]) | ||
); | ||
let jpegs = []; | ||
for (let i = 1; i <= imageFrameNumber; i++) { | ||
|
||
let jpegFilename = `${jpegBaseFilename}.${i - 1}.jpg`; | ||
jpegs.push(jpegFilename); | ||
|
||
let dcm2jpgOpt = new Dcm2JpgExecutor$Dcm2JpgOptions(); | ||
let windowCenter = frame["00281050"]?.["Value"]?.[0]; | ||
let windowWidth = frame["00281051"]?.["Value"]?.[0]; | ||
|
||
if (windowCenter && windowWidth) { | ||
dcm2jpgOpt.windowCenter = windowCenter; | ||
dcm2jpgOpt.windowWidth = windowWidth; | ||
} | ||
|
||
dcm2jpgOpt.frameNumber = i; | ||
|
||
jsDcm2Jpegs.push({ | ||
jsDcm2JpegOption: dcm2jpgOpt, | ||
jpegFilename: jpegFilename | ||
}); | ||
|
||
|
||
if (i % os.cpus().length === 0) { | ||
await Promise.allSettled( | ||
jsDcm2Jpegs.map(async (j) => | ||
Dcm2JpgExecutor.convertDcmToJpgFromFilename(frame["instancePath"], j.jpegFilename, j.jsDcm2JpegOption) | ||
) | ||
); | ||
jsDcm2Jpegs = []; | ||
} | ||
} | ||
|
||
await Promise.allSettled( | ||
jsDcm2Jpegs.map(async (j) => | ||
Dcm2JpgExecutor.convertDcmToJpgFromFilename(frame["instancePath"], j.jpegFilename, j.jsDcm2JpegOption) | ||
) | ||
); | ||
|
||
return jpegs; | ||
} | ||
} | ||
|
||
class FfmpegVideoConverter { | ||
constructor() { | ||
this.removeTempFolderEvent = new EventEmitter(); | ||
this.removeTempFolderEvent.on("start", (folder) => { | ||
fsP.rm(folder, { recursive: true }); | ||
}); | ||
} | ||
|
||
async convert() { | ||
throw new Error("Method not implemented."); | ||
} | ||
} | ||
|
||
class Mp4Converter extends FfmpegVideoConverter { | ||
constructor() { | ||
super(); | ||
} | ||
async convert(folder, ffmpegInputData) { | ||
return new Promise((resolve, reject) => { | ||
ffmpeg(ffmpegInputData) | ||
.inputOption("-f concat") | ||
.inputOption("-safe 0") | ||
.inputFPS(60) | ||
.videoCodec("libx264") | ||
.outputOption("-crf 25") | ||
.save(`${folder}.mp4`) | ||
.on("error", (err) => { | ||
fsP.unlink(ffmpegInputData); | ||
this.removeTempFolderEvent.emit("start", folder); | ||
return reject(err.message); | ||
}) | ||
.on("end", () => { | ||
fsP.unlink(ffmpegInputData); | ||
this.removeTempFolderEvent.emit("start", folder); | ||
return resolve(`${folder}.mp4`); | ||
}); | ||
}); | ||
} | ||
} | ||
|
||
class AviConverter extends FfmpegVideoConverter { | ||
constructor() { | ||
super(); | ||
} | ||
|
||
async convert(folder, ffmpegInputData) { | ||
return new Promise((resolve, reject) => { | ||
ffmpeg(ffmpegInputData) | ||
.inputOption("-f concat") | ||
.inputOption("-safe 0") | ||
.inputFPS(60) | ||
.outputOption("-crf 25") | ||
.save(`${folder}.avi`) | ||
.on("error", (err) => { | ||
fsP.unlink(ffmpegInputData); | ||
this.removeTempFolderEvent.emit("start", folder); | ||
return reject(err.message); | ||
}) | ||
.on("end", () => { | ||
fsP.unlink(ffmpegInputData); | ||
this.removeTempFolderEvent.emit("start", folder); | ||
return resolve(`${folder}.avi`); | ||
}); | ||
}); | ||
} | ||
} | ||
|
||
class MpegConverter extends FfmpegVideoConverter { | ||
constructor() { | ||
super(); | ||
} | ||
|
||
async convert(folder, ffmpegInputData) { | ||
return new Promise((resolve, reject) => { | ||
ffmpeg(ffmpegInputData) | ||
.inputOption("-f concat") | ||
.inputOption("-safe 0") | ||
.inputFPS(60) | ||
.outputOption("-crf 25") | ||
.save(`${folder}.mpeg`) | ||
.on("error", (err) => { | ||
fsP.unlink(ffmpegInputData); | ||
this.removeTempFolderEvent.emit("start", folder); | ||
return reject(err.message); | ||
}) | ||
.on("end", () => { | ||
fsP.unlink(ffmpegInputData); | ||
this.removeTempFolderEvent.emit("start", folder); | ||
return resolve(`${folder}.mpeg`); | ||
}); | ||
}); | ||
} | ||
} | ||
|
||
class H265Converter extends FfmpegVideoConverter { | ||
constructor() { | ||
super(); | ||
} | ||
|
||
async convert(folder, ffmpegInputData) { | ||
return new Promise((resolve, reject) => { | ||
ffmpeg(ffmpegInputData) | ||
.inputOption("-f concat") | ||
.inputOption("-safe 0") | ||
.inputFPS(60) | ||
.videoCodec("libx265") | ||
.outputOption("-crf 25") | ||
.save(`${folder}.h265`) | ||
.on("error", (err) => { | ||
fsP.unlink(ffmpegInputData); | ||
this.removeTempFolderEvent.emit("start", folder); | ||
return reject(err.message); | ||
}) | ||
.on("end", () => { | ||
fsP.unlink(ffmpegInputData); | ||
this.removeTempFolderEvent.emit("start", folder); | ||
return resolve(`${folder}.h265`); | ||
}); | ||
}); | ||
} | ||
} | ||
|
||
class StudyVideoFactory extends VideoFactory { | ||
constructor(mimeType, uids) { | ||
super(mimeType, uids); | ||
this.imagePathFactory = new StudyImagePathFactory(this.uids); | ||
} | ||
|
||
async convert() { | ||
let instancesFrames = await this.getInstancesFrames(); | ||
let allJpegs = []; | ||
for (let instanceFramesObj of instancesFrames) { | ||
let jpegs = await this.generateJpegImage(instanceFramesObj, this.imageTempFolder); | ||
allJpegs.push(...jpegs); | ||
} | ||
|
||
return await super.convert(this.imageTempFolder, allJpegs); | ||
} | ||
|
||
} | ||
|
||
module.exports.StudyVideoFactory = StudyVideoFactory; |
Oops, something went wrong.