From 3bcc342169a49a4086cea71b067d12bb5de36505 Mon Sep 17 00:00:00 2001 From: chin Date: Tue, 2 May 2023 22:09:14 +0800 Subject: [PATCH] feat: add retrieve thumbnail APIs - Add `ThumbnailService` to handle getting thumbnail and do response - `ThumbnailFactory` to get thumbnail instance - Return median instance's median frame > models/mongodb/models/dicomSeries.js > models/mongodb/models/dicom.js - Add every level's thumbnail's API --- .../WADO-RS/service/thumbnail.service.js | 172 ++++++++++++++++++ .../controller/WADO-RS/thumbnail/frame.js | 52 ++++++ .../controller/WADO-RS/thumbnail/instance.js | 51 ++++++ .../controller/WADO-RS/thumbnail/series.js | 50 +++++ .../controller/WADO-RS/thumbnail/study.js | 49 +++++ api/dicom-web/wado-rs-thumbnail.route.js | 118 ++++++++++++ docs/swagger/openapi.json | 122 +++++++++++++ models/mongodb/models/dicom.js | 27 ++- models/mongodb/models/dicomSeries.js | 23 ++- routes.js | 1 + 10 files changed, 663 insertions(+), 2 deletions(-) create mode 100644 api/dicom-web/controller/WADO-RS/service/thumbnail.service.js create mode 100644 api/dicom-web/controller/WADO-RS/thumbnail/frame.js create mode 100644 api/dicom-web/controller/WADO-RS/thumbnail/instance.js create mode 100644 api/dicom-web/controller/WADO-RS/thumbnail/series.js create mode 100644 api/dicom-web/controller/WADO-RS/thumbnail/study.js create mode 100644 api/dicom-web/wado-rs-thumbnail.route.js diff --git a/api/dicom-web/controller/WADO-RS/service/thumbnail.service.js b/api/dicom-web/controller/WADO-RS/service/thumbnail.service.js new file mode 100644 index 00000000..65b7d72d --- /dev/null +++ b/api/dicom-web/controller/WADO-RS/service/thumbnail.service.js @@ -0,0 +1,172 @@ +const dicomModel = require("../../../../../models/mongodb/models/dicom"); +const dicomSeriesModel = require("../../../../../models/mongodb/models/dicomSeries"); +const errorResponse = require("../../../../../utils/errorResponse/errorResponseMessage"); +const renderedService = require("../service/rendered.service"); +const _ = require("lodash"); +class ThumbnailService { + + /** + * + * @param {import("express").Request} req + * @param {import("express").Response} res + * @param {typeof ThumbnailFactory} thumbnailFactory + */ + constructor(req, res, apiLogger, thumbnailFactory) { + this.request = req; + this.response = res; + this.thumbnailFactory = new thumbnailFactory(this.request.params); + this.apiLogger = apiLogger; + } + + async getThumbnailAndResponse() { + if (!_.get(this.request, "query.viewport")) { + _.set(this.request, "query.viewport", "100,100"); + } + + let instanceFramesObj = await this.thumbnailFactory.getThumbnailInstance(); + if (this.checkInstanceExists(instanceFramesObj)) { + return; + } + + let thumbnail = await this.getThumbnailByInstance(instanceFramesObj); + if (thumbnail) { + return this.response.end(thumbnail, "binary"); + } + throw new Error(`Can not process this image, instanceUID: ${instanceFramesObj.instanceUID}`); + } + + async getThumbnailByInstance(instanceFramesObj) { + let dicomNumberOfFrames = _.get(instanceFramesObj, "00280008.Value.0", 1); + dicomNumberOfFrames = parseInt(dicomNumberOfFrames); + let medianFrame = 1; + if (dicomNumberOfFrames > 1) medianFrame = dicomNumberOfFrames >> 1; + if (this.request.params.frameNumber) { + medianFrame = this.request.params.frameNumber[0]; + } + + let postProcessResult = await renderedService.postProcessFrameImage(this.request, medianFrame, instanceFramesObj); + if (postProcessResult.status) { + this.response.writeHead(200, { + "Content-Type": "image/jpeg" + }); + this.apiLogger.logger.info(`Get instance's thumbnail successfully, instance UID: ${instanceFramesObj.instanceUID}`); + return postProcessResult.magick.toBuffer(); + } + return undefined; + } + + checkInstanceExists(instanceFramesObj) { + if (!instanceFramesObj) { + this.response.writeHead(404, { + "Content-Type": "application/dicom+json" + }); + let notFoundMessage = errorResponse.getNotFoundErrorMessage(`Not Found, ${this.thumbnailFactory.getUidsString()}`); + + let notFoundMessageStr = JSON.stringify(notFoundMessage); + + this.apiLogger.logger.warn(`[${notFoundMessageStr}]`); + + return this.response.end(notFoundMessageStr); + } + return undefined; + } +} + +class ThumbnailFactory { + /** + * + * @param {import("../../../../../utils/typeDef/dicom").Uids} uids + */ + constructor(uids) { + this.uids = uids; + } + + async getThumbnailInstance() { } + + getUidsString() { + let uidsKeys = Object.keys(this.uids); + let strArr = []; + for (let i = 0; i < uidsKeys.length; i++) { + let key = uidsKeys[i]; + strArr.push(`${key}: ${this.uids[key]}`); + } + return strArr.join(", "); + } +} + +class StudyThumbnailFactory extends ThumbnailFactory { + constructor(uids) { + super(uids); + } + + /** + * + * @param {import("../../../../../utils/typeDef/dicom").Uids} uids + */ + async getThumbnailInstance() { + let medianSeries = await dicomSeriesModel.getSeriesOfMedianIndex(this.uids.studyUID); + if (!medianSeries) return undefined; + + let medianInstance = await dicomModel.getInstanceOfMedianIndex(this.uids.studyUID, medianSeries.seriesUID); + if (!medianInstance) return undefined; + + let instanceFramesObj = await renderedService.getInstanceFrameObj({ + studyUID: this.uids.studyUID, + seriesUID: medianSeries.seriesUID, + instanceUID: medianInstance.instanceUID + }); + + return instanceFramesObj; + } + +} + +class SeriesThumbnailFactory extends ThumbnailFactory { + constructor(uids) { + super(uids); + } + + /** + * + * @param {import("../../../../../utils/typeDef/dicom").Uids} uids + */ + async getThumbnailInstance() { + let medianInstance = await dicomModel.getInstanceOfMedianIndex(this.uids.studyUID, this.uids.seriesUID); + if (!medianInstance) return undefined; + + let instanceFramesObj = await renderedService.getInstanceFrameObj({ + studyUID: this.uids.studyUID, + seriesUID: this.uids.seriesUID, + instanceUID: medianInstance.instanceUID + }); + + return instanceFramesObj; + } + +} + +class InstanceThumbnailFactory extends ThumbnailFactory { + constructor(uids) { + super(uids); + } + + /** + * + * @param {import("../../../../../utils/typeDef/dicom").Uids} uids + */ + async getThumbnailInstance() { + let instanceFramesObj = await renderedService.getInstanceFrameObj({ + studyUID: this.uids.studyUID, + seriesUID: this.uids.seriesUID, + instanceUID: this.uids.instanceUID + }); + + return instanceFramesObj; + } + +} + +module.exports.ThumbnailService = ThumbnailService; +module.exports.StudyThumbnailFactory = StudyThumbnailFactory; +module.exports.SeriesThumbnailFactory = SeriesThumbnailFactory; +module.exports.InstanceThumbnailFactory = InstanceThumbnailFactory; \ No newline at end of file diff --git a/api/dicom-web/controller/WADO-RS/thumbnail/frame.js b/api/dicom-web/controller/WADO-RS/thumbnail/frame.js new file mode 100644 index 00000000..2354aad0 --- /dev/null +++ b/api/dicom-web/controller/WADO-RS/thumbnail/frame.js @@ -0,0 +1,52 @@ +const { Controller } = require("../../../../controller.class"); +const { ApiLogger } = require("../../../../../utils/logs/api-logger"); +const { + ThumbnailService, + InstanceThumbnailFactory +} = require("../service/thumbnail.service"); + + + +class RetrieveFrameThumbnailController extends Controller { + constructor(req, res) { + super(req, res); + } + + async mainProcess() { + + let apiLogger = new ApiLogger(this.request, "WADO-RS"); + apiLogger.addTokenValue(); + + apiLogger.logger.info(`Get Study's Series' Instance Thumbnail [study UID: ${this.request.params.studyUID},\ + series UID: ${this.request.params.seriesUID}]\ + instance UID: ${this.request.params.instanceUID}\ + frames: ${JSON.stringify(this.request.params.frameNumber)}`); + + try { + let thumbnailService = new ThumbnailService(this.request, this.response, apiLogger, InstanceThumbnailFactory); + return thumbnailService.getThumbnailAndResponse(); + } catch (e) { + let errorStr = JSON.stringify(e, Object.getOwnPropertyNames(e)); + apiLogger.logger.error(errorStr); + + this.response.writeHead(500, { + "Content-Type": "application/dicom+json" + }); + return this.response.end({ + code: 500, + message: "An exception occurred" + }); + } + } +} + +/** + * + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse} res + */ +module.exports = async function (req, res) { + let controller = new RetrieveFrameThumbnailController(req, res); + + await controller.doPipeline(); +}; \ No newline at end of file diff --git a/api/dicom-web/controller/WADO-RS/thumbnail/instance.js b/api/dicom-web/controller/WADO-RS/thumbnail/instance.js new file mode 100644 index 00000000..12a6a80c --- /dev/null +++ b/api/dicom-web/controller/WADO-RS/thumbnail/instance.js @@ -0,0 +1,51 @@ +const { Controller } = require("../../../../controller.class"); +const { ApiLogger } = require("../../../../../utils/logs/api-logger"); +const { + ThumbnailService, + InstanceThumbnailFactory +} = require("../service/thumbnail.service"); + + + +class RetrieveInstanceThumbnailController extends Controller { + constructor(req, res) { + super(req, res); + } + + async mainProcess() { + + let apiLogger = new ApiLogger(this.request, "WADO-RS"); + apiLogger.addTokenValue(); + + apiLogger.logger.info(`Get Study's Series' Instance Thumbnail [study UID: ${this.request.params.studyUID},\ + series UID: ${this.request.params.seriesUID}]\ + instance UID: ${this.request.params.instanceUID}`); + + try { + let thumbnailService = new ThumbnailService(this.request, this.response, apiLogger, InstanceThumbnailFactory); + return thumbnailService.getThumbnailAndResponse(); + } catch (e) { + let errorStr = JSON.stringify(e, Object.getOwnPropertyNames(e)); + apiLogger.logger.error(errorStr); + + this.response.writeHead(500, { + "Content-Type": "application/dicom+json" + }); + return this.response.end({ + code: 500, + message: "An exception occurred" + }); + } + } +} + +/** + * + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse} res + */ +module.exports = async function (req, res) { + let controller = new RetrieveInstanceThumbnailController(req, res); + + await controller.doPipeline(); +}; \ No newline at end of file diff --git a/api/dicom-web/controller/WADO-RS/thumbnail/series.js b/api/dicom-web/controller/WADO-RS/thumbnail/series.js new file mode 100644 index 00000000..346ca652 --- /dev/null +++ b/api/dicom-web/controller/WADO-RS/thumbnail/series.js @@ -0,0 +1,50 @@ +const { Controller } = require("../../../../controller.class"); +const { ApiLogger } = require("../../../../../utils/logs/api-logger"); +const { + ThumbnailService, + SeriesThumbnailFactory +} = require("../service/thumbnail.service"); + + + +class RetrieveSeriesThumbnailController extends Controller { + constructor(req, res) { + super(req, res); + } + + async mainProcess() { + + let apiLogger = new ApiLogger(this.request, "WADO-RS"); + apiLogger.addTokenValue(); + + apiLogger.logger.info(`Get Study's Series' Thumbnail [study UID: ${this.request.params.studyUID},\ + series UID: ${this.request.params.seriesUID}]`); + + try { + let thumbnailService = new ThumbnailService(this.request, this.response, apiLogger, SeriesThumbnailFactory); + return thumbnailService.getThumbnailAndResponse(); + } catch (e) { + let errorStr = JSON.stringify(e, Object.getOwnPropertyNames(e)); + apiLogger.logger.error(errorStr); + + this.response.writeHead(500, { + "Content-Type": "application/dicom+json" + }); + return this.response.end({ + code: 500, + message: "An exception occurred" + }); + } + } +} + +/** + * + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse} res + */ +module.exports = async function (req, res) { + let controller = new RetrieveSeriesThumbnailController(req, res); + + await controller.doPipeline(); +}; \ No newline at end of file diff --git a/api/dicom-web/controller/WADO-RS/thumbnail/study.js b/api/dicom-web/controller/WADO-RS/thumbnail/study.js new file mode 100644 index 00000000..c9e3899f --- /dev/null +++ b/api/dicom-web/controller/WADO-RS/thumbnail/study.js @@ -0,0 +1,49 @@ +const { Controller } = require("../../../../controller.class"); +const { ApiLogger } = require("../../../../../utils/logs/api-logger"); +const { + ThumbnailService, + StudyThumbnailFactory +} = require("../service/thumbnail.service"); + + + +class RetrieveStudyThumbnailController extends Controller { + constructor(req, res) { + super(req, res); + } + + async mainProcess() { + + let apiLogger = new ApiLogger(this.request, "WADO-RS"); + apiLogger.addTokenValue(); + + apiLogger.logger.info(`Get Study's Thumbnail [study UID: ${this.request.params.studyUID}]`); + + try { + let thumbnailService = new ThumbnailService(this.request, this.response, apiLogger, StudyThumbnailFactory); + return thumbnailService.getThumbnailAndResponse(); + } catch (e) { + let errorStr = JSON.stringify(e, Object.getOwnPropertyNames(e)); + apiLogger.logger.error(errorStr); + + this.response.writeHead(500, { + "Content-Type": "application/dicom+json" + }); + return this.response.end({ + code: 500, + message: "An exception occurred" + }); + } + } +} + +/** + * + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse} res + */ +module.exports = async function (req, res) { + let controller = new RetrieveStudyThumbnailController(req, res); + + await controller.doPipeline(); +}; \ No newline at end of file diff --git a/api/dicom-web/wado-rs-thumbnail.route.js b/api/dicom-web/wado-rs-thumbnail.route.js new file mode 100644 index 00000000..9b9e6c4c --- /dev/null +++ b/api/dicom-web/wado-rs-thumbnail.route.js @@ -0,0 +1,118 @@ +const express = require("express"); +const Joi = require("joi"); +const { validateParams, intArrayJoi } = require("../validator"); +const router = express(); + + +//#region WADO-RS Retrieve Transaction Thumbnail Resources + +/** + * @openapi + * /dicom-web/studies/{studyUID}/thumbnail: + * get: + * tags: + * - WADO-RS + * description: Retrieve Study's instances' metadata + * parameters: + * - $ref: "#/components/parameters/studyUID" + * responses: + * 200: + * description: The response payload for WADO-RS Thumbnail + * content: + * "image/jpeg": + * schema: + * type: string + * format: binary + * + */ +router.get( + "/studies/:studyUID/thumbnail", + require("./controller/WADO-RS/thumbnail/study") +); + +/** + * @openapi + * /dicom-web/studies/{studyUID}/series/{seriesUID}/thumbnail: + * get: + * tags: + * - WADO-RS + * description: Retrieve Study's series' thumbnail + * parameters: + * - $ref: "#/components/parameters/studyUID" + * - $ref: "#/components/parameters/seriesUID" + * responses: + * 200: + * description: The response payload for WADO-RS Thumbnail + * content: + * "image/jpeg": + * schema: + * type: string + * format: binary + * + */ +router.get( + "/studies/:studyUID/series/:seriesUID/thumbnail", + require("./controller/WADO-RS/thumbnail/series") +); + +/** + * @openapi + * /dicom-web/studies/{studyUID}/series/{seriesUID}/instances/{instanceUID}/thumbnail: + * get: + * tags: + * - WADO-RS + * description: Retrieve Study's Series' instances' Thumbnail + * parameters: + * - $ref: "#/components/parameters/studyUID" + * - $ref: "#/components/parameters/seriesUID" + * - $ref: "#/components/parameters/instanceUID" + * responses: + * 200: + * description: The response payload for WADO-RS Thumbnail + * content: + * "image/jpeg": + * schema: + * type: string + * format: binary + * + */ +router.get( + "/studies/:studyUID/series/:seriesUID/instances/:instanceUID/thumbnail", + require("./controller/WADO-RS/thumbnail/instance") +); + +/** + * @openapi + * /dicom-web/studies/{studyUID}/series/{seriesUID}/instances/{instanceUID}/frames/{frameNumbers}/thumbnail: + * get: + * tags: + * - WADO-RS + * description: Retrieve Study's instances' metadata + * parameters: + * - $ref: "#/components/parameters/studyUID" + * - $ref: "#/components/parameters/seriesUID" + * - $ref: "#/components/parameters/instanceUID" + * - $ref: "#/components/parameters/frameNumbers" + * responses: + * 200: + * description: The response payload for WADO-RS Thumbnail + * content: + * "image/jpeg": + * schema: + * type: string + * format: binary + * + */ +router.get( + "/studies/:studyUID/series/:seriesUID/instances/:instanceUID/frames/:frameNumber/thumbnail", + validateParams({ + frameNumber : intArrayJoi.intArray().items(Joi.number().integer().min(1)).single() + } , "params" , {allowUnknown : true}), + require("./controller/WADO-RS/thumbnail/frame") +); + + + +//#endregion + +module.exports = router; \ No newline at end of file diff --git a/docs/swagger/openapi.json b/docs/swagger/openapi.json index 13d12aab..455e7cfd 100644 --- a/docs/swagger/openapi.json +++ b/docs/swagger/openapi.json @@ -822,6 +822,128 @@ } } }, + "/dicom-web/studies/{studyUID}/thumbnail": { + "get": { + "tags": [ + "WADO-RS" + ], + "description": "Retrieve Study's instances' metadata", + "parameters": [ + { + "$ref": "#/components/parameters/studyUID" + } + ], + "responses": { + "200": { + "description": "The response payload for WADO-RS Thumbnail", + "content": { + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/dicom-web/studies/{studyUID}/series/{seriesUID}/thumbnail": { + "get": { + "tags": [ + "WADO-RS" + ], + "description": "Retrieve Study's series' thumbnail", + "parameters": [ + { + "$ref": "#/components/parameters/studyUID" + }, + { + "$ref": "#/components/parameters/seriesUID" + } + ], + "responses": { + "200": { + "description": "The response payload for WADO-RS Thumbnail", + "content": { + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/dicom-web/studies/{studyUID}/series/{seriesUID}/instances/{instanceUID}/thumbnail": { + "get": { + "tags": [ + "WADO-RS" + ], + "description": "Retrieve Study's Series' instances' Thumbnail", + "parameters": [ + { + "$ref": "#/components/parameters/studyUID" + }, + { + "$ref": "#/components/parameters/seriesUID" + }, + { + "$ref": "#/components/parameters/instanceUID" + } + ], + "responses": { + "200": { + "description": "The response payload for WADO-RS Thumbnail", + "content": { + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/dicom-web/studies/{studyUID}/series/{seriesUID}/instances/{instanceUID}/frames/{frameNumbers}/thumbnail": { + "get": { + "tags": [ + "WADO-RS" + ], + "description": "Retrieve Study's instances' metadata", + "parameters": [ + { + "$ref": "#/components/parameters/studyUID" + }, + { + "$ref": "#/components/parameters/seriesUID" + }, + { + "$ref": "#/components/parameters/instanceUID" + }, + { + "$ref": "#/components/parameters/frameNumbers" + } + ], + "responses": { + "200": { + "description": "The response payload for WADO-RS Thumbnail", + "content": { + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, "/wado": { "get": { "tags": [ diff --git a/models/mongodb/models/dicom.js b/models/mongodb/models/dicom.js index 7c5d2d02..7f68406d 100644 --- a/models/mongodb/models/dicom.js +++ b/models/mongodb/models/dicom.js @@ -397,6 +397,30 @@ dicomModelSchema.statics.getPathOfInstance = async function(iParam) { } }; +/** + * + * @param {string} studyUID + * @param {string} seriesUID + */ +dicomModelSchema.statics.getInstanceOfMedianIndex = async function (studyUID, seriesUID) { + let instanceCountOfSeries = await mongoose.model("dicomSeries").countDocuments({ + studyUID, + seriesUID + }); + + return await mongoose.model("dicom").findOne({ + studyUID + }, { + studyUID: 1, + seriesUID: 1, + instanceUID: 1, + instancePath: 1 + }) + .skip(instanceCountOfSeries >> 1) + .limit(1) + .exec(); +}; + /** * @typedef {function(s: string, b: boolean): Promise} getDicomJson * @param {object} iParam @@ -413,7 +437,8 @@ dicomModelSchema.statics.getPathOfInstance = async function(iParam) { * seriesUID: string, * instanceUID: string * }): Promise; - * getDicomJson: function(queryOptions: import("../../../utils/typeDef/dicom").DicomJsonMongoQueryOptions): Promise + * getDicomJson: function(queryOptions: import("../../../utils/typeDef/dicom").DicomJsonMongoQueryOptions): Promise; + * getInstanceOfMedianIndex: function(studyUID: string, seriesUID: string): Promise * }} DicomModelSchema */ diff --git a/models/mongodb/models/dicomSeries.js b/models/mongodb/models/dicomSeries.js index 6eff8998..09f02451 100644 --- a/models/mongodb/models/dicomSeries.js +++ b/models/mongodb/models/dicomSeries.js @@ -152,6 +152,26 @@ dicomSeriesSchema.statics.getPathGroupOfInstances = async function(iParam) { } }; +/** + * + * @param {string} studyUID + */ +dicomSeriesSchema.statics.getSeriesOfMedianIndex = async function (studyUID) { + let seriesCountOfStudy = await mongoose.model("dicomSeries").countDocuments({ + studyUID + }); + + return await mongoose.model("dicomSeries").findOne({ + studyUID + }, { + studyUID: 1, + seriesUID: 1 + }) + .skip(seriesCountOfStudy >> 1) + .limit(1) + .exec(); +}; + /** * @typedef { mongoose.Model & { @@ -159,7 +179,8 @@ dicomSeriesSchema.statics.getPathGroupOfInstances = async function(iParam) { * studyUID: string, * seriesUID: string * }): Promise; - * getDicomJson: function(queryOptions: import("../../../utils/typeDef/dicom").DicomJsonMongoQueryOptions): Promise + * getDicomJson: function(queryOptions: import("../../../utils/typeDef/dicom").DicomJsonMongoQueryOptions): Promise; + * getSeriesOfMedianIndex: function(studyUID: string): Promise * }} DicomSeriesModel */ diff --git a/routes.js b/routes.js index 69cad062..73e66943 100644 --- a/routes.js +++ b/routes.js @@ -19,6 +19,7 @@ module.exports = function (app) { app.use("/dicom-web", require("./api/dicom-web/wado-rs-metadata.route")); app.use("/dicom-web", require("./api/dicom-web/wado-rs-rendered.route")); app.use("/dicom-web", require("./api/dicom-web/wado-rs-bulkdata.route")); + app.use("/dicom-web", require("./api/dicom-web/wado-rs-thumbnail.route")); app.use("/dicom-web", require("./api/dicom-web/delete.route")); app.use("/wado", require("./api/WADO-URI"));