getImageExtracts.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. 'use strict'
  2. import express from 'express'
  3. import sharp from 'sharp'
  4. import { constants as fsConstants, promises as fs } from 'fs'
  5. import path from 'path'
  6. import boom from '@hapi/boom'
  7. import { asyncMiddleware, checkSceneName, checkRequiredParameters } from '../functions'
  8. import { imageServedUrl, imagesPath, extractsDirName } from '../../config'
  9. import { getImage } from './getImage'
  10. const router = express.Router()
  11. /**
  12. * @api {get} /getImageExtracts?sceneName=:sceneName&imageQuality=:imageQuality&horizontalExtractCount=:horizontalExtractCount&verticalExtractCount=:verticalExtractCount&nearestQuality=:nearestQuality /getImageExtracts
  13. * @apiVersion 0.1.0
  14. * @apiName getImageExtracts
  15. * @apiGroup API
  16. *
  17. * @apiDescription Get an image from a scene with the required quality and cut it into multiple extracts with the requested configuration
  18. *
  19. * @apiParam {String} sceneName The selected scene
  20. * @apiParam {String="min","max","median", "any integer"} imageQuality The required quality of the image (can be an integer, `min`, `max` or `median`)
  21. * @apiParam {Number} horizontalExtractCount The amount of extracts for the horizontal axis
  22. * @apiParam {Number} verticalExtractCount The amount of extracts for the vertical axis
  23. * @apiParam {Boolean} [nearestQuality=false] if selected quality not availabie, select the nearest one
  24. *
  25. * @apiExample Usage example
  26. * curl -i -L -X GET "https://diran.univ-littoral.fr/api/getImageExtracts?sceneName=bathroom&imageQuality=200&horizontalExtractCount=1&verticalExtractCount=2"
  27. *
  28. * @apiSuccess {Object} data Path to the extracted images
  29. * @apiSuccess {String[]} data.extracts Path to the extracted images
  30. * @apiSuccess {Object} data.info Informations on the original image
  31. * @apiSuccess {String} data.info.link Path to the original image
  32. * @apiSuccess {String} data.info.fileName File name of the original image
  33. * @apiSuccess {String} data.info.sceneName Scene name of the original image
  34. * @apiSuccess {Number} data.info.quality Quality of the original image
  35. * @apiSuccess {String} data.info.ext Extension of the original image
  36. * @apiSuccess {Object} data.metadata Metadata of the image, @see https://sharp.dimens.io/en/stable/api-input/#metadata
  37. * @apiSuccess {Object} data.info.extractsConfig Configuration used to cut the image
  38. * @apiSuccess {Number} data.info.extractsConfig.x Number of extracts per line (horizontal)
  39. * @apiSuccess {Number} data.info.extractsConfig.y Number of extracts per row (vertical)
  40. * @apiSuccess {Object} data.info.extractsSize Size of extracted images
  41. * @apiSuccess {Number} data.info.extractsSize.width Width of the extracted images
  42. * @apiSuccess {Number} data.info.extractsSize.height Height of the extracted images
  43. * @apiSuccessExample {json} Success response example
  44. * HTTP/1.1 200 OK /api/getImageExtracts?sceneName=bathroom&imageQuality=200&horizontalExtractCount=1&verticalExtractCount=2
  45. * {
  46. * "data": {
  47. * "extracts": [
  48. * "/api/images/bathroom/extracts/x1_y2/zone00001/bathroom_zone00001_200.png",
  49. * "/api/images/bathroom/extracts/x1_y2/zone00002/bathroom_zone00002_200.png"
  50. * ],
  51. * "info": {
  52. * "extractsConfig": {
  53. * "x": 1,
  54. * "y": 2
  55. * },
  56. * "extractsSize": {
  57. * "width": 800,
  58. * "height": 400
  59. * },
  60. * "image": {
  61. * "link": "/api/images/bathroom/bathroom_00200.png",
  62. * "fileName": "bathroom_00200.png",
  63. * "sceneName": "bathroom",
  64. * "quality": 200,
  65. * "ext": "png"
  66. * "metadata": {
  67. * "format": "png",
  68. * "width": 800,
  69. * "height": 800,
  70. * "space": "rgb16",
  71. * "channels": 3,
  72. * "depth": "ushort",
  73. * "density": 72,
  74. * "isProgressive": false,
  75. * "hasProfile": false,
  76. * "hasAlpha": false
  77. * }
  78. * }
  79. * }
  80. * }
  81. * }
  82. *
  83. * @apiError (Error 4xx) 400_[1] Missing parameter(s)
  84. * @apiErrorExample {json} Missing parameter
  85. * HTTP/1.1 400 Bad Request
  86. * {
  87. * "message": "Missing parameter(s). Required parameters : sceneName, imageQuality, horizontalExtractCount, verticalExtractCount."
  88. * }
  89. *
  90. * @apiError (Error 4xx) 400_[2] Invalid query parameter
  91. * @apiErrorExample {json} Invalid query parameter(s)
  92. * HTTP/1.1 400 Bad Request
  93. * {
  94. * "message": "Invalid query parameter(s).",
  95. * "data": [
  96. * "The requested scene name \".//../\" is not valid.",
  97. * "The specified quality is not an integer.",
  98. * "The specified number of extract for the horizontal axis is not an integer.",
  99. * "The specified number of extract for the vertical axis is not an integer.",
  100. * "Impossible to use \"min\", \"max\" or \"median\" with \"nearestQuality\" on."
  101. * ]
  102. * }
  103. *
  104. * @apiError (Error 4xx) 400_[3] Invalid configuration
  105. * @apiErrorExample {json} Invalid configuration
  106. * HTTP/1.1 400 Bad Request
  107. * {
  108. * "message": "Invalid query parameter(s).",
  109. * "data": [
  110. * "Incompatible number of horizontal extracts (width % numberOfExtracts != 0).",
  111. * "Incompatible number of vertical extracts (height % numberOfExtracts != 0)."
  112. * ]
  113. * }
  114. *
  115. * @apiError (Error 4xx) 404_[1] Quality not found
  116. * @apiErrorExample {json} Quality not found
  117. * HTTP/1.1 404 Not Found
  118. * {
  119. * "message": "The requested quality (9999) was not found for the requested scene (bathroom)."
  120. * }
  121. *
  122. * @apiError (Error 5xx) 500_[1] Can't access the `IMAGES_PATH` directory
  123. * @apiErrorExample {json} Images directory not accessible
  124. * HTTP/1.1 500 Internal Server Error
  125. * {
  126. * "message": "Can't access the \"images\" directory. Check it exists and you have read permission on it"
  127. * }
  128. *
  129. * @apiError (Error 5xx) 500_[2] Failed to parse a file's name
  130. * @apiErrorExample {json} Failed to parse a file's name
  131. * HTTP/1.1 500 Internal Server Error
  132. * {
  133. * "message": "Failed to parse file names in the \"bathroom\"'s scene directory.",
  134. * "data": [
  135. * "The file name does not match convention (scene_000150.ext - /^(.*)?_([0-9]{2,})\\.(.*)$/) : \"bathroom_adz00020.png\".",
  136. * "The file name does not match convention (scene_000150.ext - /^(.*)?_([0-9]{2,})\\.(.*)$/) : \"bathroom_adz00020.png\"."
  137. * ]
  138. * }
  139. *
  140. */
  141. /**
  142. * Cut an image, save its extracts and get the url of these extracts
  143. *
  144. * @param {object} image the path to the image to cut
  145. * @param {Number} xExtracts the number of extract to do on the horizontal axis (integer)
  146. * @param {Number} yExtracts the number of extract to do on the vertical axis (integer)
  147. * @returns {Promise<Image[]>} the list of extracted images
  148. */
  149. export const cutImage = async (image, xExtracts, yExtracts) => {
  150. const input = sharp(image.path)
  151. const { width, height } = await input.metadata()
  152. const xCropSize = width / xExtracts
  153. const yCropSize = height / yExtracts
  154. // Check the image is cuttable with the current parameters
  155. let errorsList = []
  156. if (!Number.isInteger(xCropSize)) errorsList.push('Incompatible number of horizontal extracts (width % numberOfExtracts != 0).')
  157. if (!Number.isInteger(yCropSize)) errorsList.push('Incompatible number of vertical extracts (height % numberOfExtracts != 0).')
  158. if (errorsList.length > 0) throw boom.badRequest('Invalid query parameter(s).', errorsList)
  159. let extracts = []
  160. // Cut images
  161. // Vertical
  162. for (let y = 0; y < yExtracts; y++) {
  163. // Horizontal
  164. for (let x = 0; x < xExtracts; x++) {
  165. // How to cut the image
  166. const config = {
  167. left: x * xCropSize,
  168. top: y * yCropSize,
  169. width: xCropSize,
  170. height: yCropSize
  171. }
  172. // Zone number of the extract `00020`
  173. const fileNameCount = (extracts.length + 1).toString().padStart(5, '0')
  174. const fileNameQuality = image.quality.toString().padStart(5, '0')
  175. // File name of the extract : `Scene2_zone00199_100.png`
  176. const extractName = `${image.sceneName}_zone${fileNameCount}_${fileNameQuality}.${image.ext}`
  177. // Configured path to the image (Check defined convention)
  178. const pathToImage = [image.sceneName, extractsDirName, `x${xExtracts}_y${yExtracts}`, `zone${fileNameCount}`, extractName]
  179. // File system path to the extract
  180. const extractPath = path.resolve(imagesPath, ...pathToImage)
  181. // URL to the extract on the app
  182. const extractLink = `${imageServedUrl}/${pathToImage.join('/')}`
  183. const extractObj = {
  184. link: extractLink,
  185. path: extractPath,
  186. fileName: extractName,
  187. sceneName: image.sceneName,
  188. originalWidth: width,
  189. originalHeight: height,
  190. width: xCropSize,
  191. height: yCropSize
  192. }
  193. // Check the file already exist
  194. let fileAlreadyExists = false
  195. try {
  196. await fs.access(extractPath, fsConstants.R_OK)
  197. fileAlreadyExists = true
  198. }
  199. catch (err) {
  200. // File does not exist already
  201. }
  202. // File already exist, just send its data
  203. if (fileAlreadyExists) {
  204. extracts.push(extractObj)
  205. continue
  206. }
  207. // File does not already exist, create it
  208. // Create the arborescence
  209. try {
  210. await fs.mkdir(path.resolve(imagesPath, ...pathToImage.slice(0, pathToImage.length - 1)), { recursive: true })
  211. }
  212. catch (err) {
  213. // An error was caught, add it and go to next extract
  214. errorsList.push(err.message)
  215. continue
  216. }
  217. // Cut and save the extract
  218. try {
  219. await input.extract(config).toFile(extractPath)
  220. extracts.push(extractObj)
  221. }
  222. catch (err) {
  223. // Error while cutting image
  224. errorsList.push(err)
  225. }
  226. }
  227. }
  228. // Extraction finished, check for errors
  229. if (errorsList.length > 0) throw boom.internal('Error(s) while extracting from image.', errorsList)
  230. return extracts
  231. }
  232. router.get('/', asyncMiddleware(async (req, res) => {
  233. // Check the request contains all the required parameters
  234. checkRequiredParameters(['sceneName', 'imageQuality', 'horizontalExtractCount', 'verticalExtractCount'], req.query)
  235. const { sceneName, imageQuality, horizontalExtractCount, verticalExtractCount } = req.query
  236. const nearestQuality = req.query.nearestQuality === 'true'
  237. let errorList = []
  238. // Check the scene name is valid
  239. try {
  240. checkSceneName(sceneName)
  241. }
  242. catch (err) {
  243. errorList.push(err.message)
  244. }
  245. // Check `imageQuality` is an integer or `min`, `max` or `median`
  246. const qualityInt = parseInt(imageQuality, 10)
  247. let quality = null
  248. if (['min', 'median', 'max'].some(x => x === imageQuality)) {
  249. if (nearestQuality)
  250. errorList.push('Impossible to use "min", "max" or "median" with "nearestQuality" on.')
  251. else quality = imageQuality
  252. }
  253. else if (!isNaN(qualityInt))
  254. quality = qualityInt
  255. else
  256. errorList.push('The specified quality is not an integer or "min", "max" or "median".')
  257. // Check `horizontalExtractCount` is an integer
  258. const horizontalExtractCountInt = parseInt(horizontalExtractCount, 10)
  259. if (isNaN(horizontalExtractCountInt)) errorList.push('The specified number of extract for the horizontal axis is not an integer.')
  260. // Check `verticalExtractCountInt` is an integer
  261. const verticalExtractCountInt = parseInt(verticalExtractCount, 10)
  262. if (isNaN(verticalExtractCountInt)) errorList.push('The specified number of extract for the vertical axis is not an integer.')
  263. // Check there is no errors with parameters
  264. if (errorList.length > 0)
  265. throw boom.badRequest('Invalid query parameter(s).', errorList)
  266. // Get the image path and link
  267. const image = await getImage(sceneName, quality, nearestQuality)
  268. // Cut the image
  269. const extracts = await cutImage(image, horizontalExtractCountInt, verticalExtractCountInt)
  270. image.path = undefined
  271. // Send an array of links
  272. res.json({
  273. data: {
  274. extracts: extracts.map(x => x.link),
  275. info: {
  276. extractsConfig: {
  277. x: horizontalExtractCountInt,
  278. y: verticalExtractCountInt
  279. },
  280. extractsSize: {
  281. width: extracts[0].width,
  282. height: extracts[0].height
  283. },
  284. image
  285. }
  286. }
  287. })
  288. }))
  289. export default router