Image.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. # import
  2. # ------------------------------------------------------------------------------------------
  3. from . import Exif, ColorSpace, imageType, channel
  4. import miam.processing.TMO_CCTF
  5. import miam.processing.ColorSpaceTransform as MCST
  6. import rawpy, colour, os, skimage.transform, copy
  7. import numpy as np
  8. import json
  9. import matplotlib.pyplot as plt
  10. # ------------------------------------------------------------------------------------------
  11. # MIAM project 2020
  12. # ------------------------------------------------------------------------------------------
  13. # author: remi.cozot@univ-littoral.fr
  14. # ------------------------------------------------------------------------------------------
  15. class Image(object):
  16. """
  17. class image:
  18. attribute(s):
  19. name: image file name (str)
  20. colorData: array of pixels (np.ndarray)
  21. type: type of image (image.imageType)
  22. linear: image encoding is linear (boolean)
  23. colorSpace: colorspace (colour.models.RGB_COLOURSPACES)
  24. scalingFactor: scaling factor to [0..1] space (float)
  25. """
  26. def __init__(self, colorData, name, type, linear, colorspace, scalingFactor):
  27. """ constructor """
  28. self.name = name
  29. self.colorData = colorData
  30. self.shape = self.colorData.shape
  31. self.type = type
  32. self.linear = linear
  33. self.colorSpace = colorspace
  34. self.scalingFactor = scalingFactor
  35. # ----------------------------------------------------
  36. # update May 2020
  37. # ----------------------------------------------------
  38. # mask mask shape is image shape with only one channel
  39. self.mask = None
  40. # ----------------------------------------------------
  41. # ----------------------------------------------------
  42. def isHDR(self): return self.type == imageType.imageType.HDR
  43. def __repr__(self):
  44. res = " Image{ name:" + self.name + "\n" + \
  45. " shape: " + str(self.shape) + "\n" + \
  46. " type: " + str(self.type) + "\n" + \
  47. " color space: " + self.colorSpace.__repr__() + "\n" + \
  48. " linear: " + str(self.linear) + "\n" + \
  49. " scaling factor: " + str(self.scalingFactor) + "\n }"
  50. return res
  51. def __str__(self) :
  52. res = " Image{ name:" + self.name + "\n" + \
  53. " shape: " + str(self.shape) + "\n" + \
  54. " type: " + str(self.type) + "\n" + \
  55. " color space: " + self.colorSpace.name + "\n" + \
  56. " linear: " + str(self.linear) + "\n" + \
  57. " scaling factor: " + str(self.scalingFactor) + "\n }"
  58. return res
  59. def writeImage(self, filename):
  60. """
  61. Write Image: two output files are generated: filename+".json" contains additional data, filename+".jpg|hdr" contains colorData
  62. @params:
  63. filename - Required : filename WITHOUT extension [for example "../images/image00"] (Str)
  64. """
  65. # outfile depend on image type
  66. ext, bitDepth = None, None
  67. if self.type == imageType.imageType.SDR:
  68. ext, bitDepth = "jpg", "uint8"
  69. elif self.type == imageType.imageType.HDR:
  70. ext, bitDepth = "hdr", "float32"
  71. else:
  72. print("WARNING[miam.image.Image.writeImage(",filename,"):: writing RAW image not yet supported!]")
  73. (fpath, fname) = os.path.split(filename)
  74. output = { 'name': self.name,
  75. 'filename': fname+"."+ext,
  76. 'shape': str(self.shape),
  77. 'type': str(self.type),
  78. 'colorspace': self.colorSpace.name,
  79. 'linear': str(self.linear),
  80. 'scaling': str(self.scalingFactor)
  81. }
  82. with open(filename+".json", 'w') as outfile:
  83. json.dump(output, outfile, indent=4)
  84. colour.write_image(self.colorData,
  85. filename+"."+ext,
  86. bit_depth=bitDepth,
  87. method='Imageio')
  88. def getChannelVector(self, channel):
  89. """ get channel vector: works only for sR|sG|sB, X|Y|Z and L|a|b """
  90. # take into account colorSpace
  91. destColor = channel.colorSpace()
  92. image = MCST.ColorSpaceTransform().compute(self,dest=destColor)
  93. if channel.getValue() <3:
  94. imgVector = Image.array2vector(image.colorData)
  95. return imgVector[:,channel.getValue()]
  96. else:
  97. return None
  98. def getChannel(self, channel):
  99. """ get channel : works only for sR|sG|sB, X|Y|Z and L|a|b """
  100. # take into account colorSpace
  101. destColor = channel.colorSpace()
  102. image = MCST.ColorSpaceTransform().compute(self,dest=destColor)
  103. if channel.getValue() <3:
  104. return image.colorData[:,:,channel.getValue()]
  105. else:
  106. return None
  107. def getMinMaxPerChannel(self):
  108. img = self.colorData
  109. R, G, B = img[:,:,0], img[:,:,1], img[:,:,2]
  110. minR, minG, minB = np.amin(R), np.amin(G), np.amin(B)
  111. maxR, maxG, maxB = np.amax(R), np.amax(G), np.amax(B)
  112. return ((minR,maxR),(minG,maxG),(minB,maxB))
  113. def getDynamicRange(self, percentile = None, mode=None):
  114. """ return dynamic range of image
  115. @params:
  116. percentile - Optional : percentile if None: just remove zero values (Float)
  117. mode - Optional : "maxmin" (default)| "f-stops" (Str)
  118. """
  119. Y_min,Y_max = None, None
  120. Y = self.getChannelVector(channel.channel.Y)
  121. if percentile == None :
  122. # remove zeros
  123. Y = vY[vY>0]
  124. # use min and max
  125. Y_min = np.amin(Y)
  126. Y_max = np.amax(Y)
  127. else:
  128. Y_min = np.percentile(Y,percentile)
  129. Y_max = np.percentile(Y,100-percentile)
  130. # take mode into account
  131. if not mode: mode = 'maxmin'
  132. if mode == "f-stops":
  133. return np.log2(Y_max)-np.log2(Y_min)
  134. if mode=="maxmin":
  135. return (Y_max,Y_min)
  136. # functionnal methods
  137. def removeZeros(self, minPencitile=None):
  138. # copy gimage
  139. res = copy.deepcopy(self)
  140. img = res.colorData
  141. # get channels
  142. R, G, B = img[:,:,0], img[:,:,1], img[:,:,2]
  143. # remove zero value per channel
  144. notZeroR, notZeroG, notZeroB = R[R>0], G[G>0],B[B>0]
  145. if minPencitile==None:
  146. # take none zero min of channels
  147. Rmin, Gmin, Bmin = np.amin(notZeroR), np.amin(notZeroG), np.amin(notZeroB)
  148. # replace zero values by min (per channel)
  149. R[R==0] = Rmin
  150. G[G==0] = Gmin
  151. B[B==0] = Bmin
  152. else:
  153. # replace zeros by min Pecentile
  154. Rmin = np.percentile(R,minPencitile)
  155. Gmin = np.percentile(G,minPencitile)
  156. Bmin = np.percentile(B,minPencitile)
  157. R[R<Rmin] = Rmin
  158. G[G<Gmin] = Gmin
  159. B[B<Bmin] = Bmin
  160. # combine channels
  161. img[:,:,0], img[:,:,1], img[:,:,2] = R, G, B
  162. res.colorData = img
  163. return res
  164. def clip(self, min=0.0, max=1.0):
  165. res = copy.deepcopy(self)
  166. res.colorData[res.colorData>max] = max
  167. res.colorData[res.colorData<min] = min
  168. return res
  169. def smartResize(self,maxSize=400,anti_aliasing=False):
  170. res = copy.deepcopy(self)
  171. x, y, c = tuple(res.colorData.shape)
  172. maxImage = max(x,y)
  173. factor = maxImage/maxSize if maxImage > maxSize else 1
  174. res.colorData = skimage.transform.resize(res.colorData, (x // factor, y // factor), anti_aliasing)
  175. res.shape = res.colorData.shape
  176. return res
  177. def resize(self,size=(None,None),anti_aliasing=False):
  178. res = copy.deepcopy(self)
  179. x, y, c = tuple(res.colorData.shape)
  180. ny,nx = size
  181. if nx and (not ny):
  182. factor = nx/x
  183. res.colorData = skimage.transform.resize(res.colorData, (nx, int(y * factor)), anti_aliasing)
  184. res.shape = res.colorData.shape
  185. elif (not nx) and ny:
  186. factor = ny/y
  187. res.colorData = skimage.transform.resize(res.colorData, (int(x * factor),ny), anti_aliasing)
  188. res.shape = res.colorData.shape
  189. elif nx and ny:
  190. res.colorData = skimage.transform.resize(res.colorData, (nx,ny), anti_aliasing)
  191. res.shape = res.colorData.shape
  192. return res
  193. def process(self, process, **kwargs):
  194. """ process image:
  195. (Object) process must have compute(image, **kwargs) method
  196. **kwargs optionnal parameters packed as dict
  197. """
  198. return process.compute(self,**kwargs)
  199. def buildMaskByValue(self, channel, min, max, zero = 0, one=1):
  200. # convert into channel color space
  201. ch = MCST.ColorSpaceTransform().compute(self,dest=channel.colorSpace()).colorData[:,:,channel.getValue()]
  202. # mask
  203. ch[ch<=min] = zero
  204. ch[ch>=max] = zero
  205. ch[ch!=zero] = one
  206. res = Image.newImage(self.shape, colorSpaceName='sRGB',name='mask')
  207. res = copy.deepcopy(self)
  208. res.colorData[:,:,0] = ch
  209. res.colorData[:,:,1] = ch
  210. res.colorData[:,:,2] = ch
  211. return res
  212. # ----------------------------------------------------
  213. # update May 2020
  214. # ----------------------------------------------------
  215. def addMask(self, one=True):
  216. [height, width, channel] = self.shape
  217. self.mask = np.ones((height, width)) if one else np.zeros((height, width))
  218. def removeMask(self) : self.mask = None
  219. def hasMask(self): return isinstance(self.mask,np.ndarray)
  220. def binaryMask(self): return (self.mask[self.mask!=1] ==0).all()
  221. def isMaskOne(self): return (self.mask ==1).all()
  222. # ----------------------------------------------------
  223. # ----------------------------------------------------
  224. # plot
  225. def plot(self, ax, shortName=True, title=True):
  226. if not (self.type == imageType.imageType.HDR):
  227. ax.imshow(self.colorData)
  228. sep = '/' if ('/' in self.name) else '\\'
  229. name = self.name.split(sep)[-1] if shortName else self.name
  230. if title: ax.set_title(name+"("+self.colorSpace.name +"/"+ str(self.type)+")")
  231. else: # HDR
  232. ax.imshow(self.process(miam.processing.TMO_CCTF.TMO_CCTF(), function='sRGB').colorData)
  233. sep = '/' if ('/' in self.name) else '\\'
  234. name = self.name.split(sep)[-1] if shortName else self.name
  235. if title : ax.set_title(name+"("+self.colorSpace.name +"/"+ str(self.type)+"/"+ "sRGB_CCTF" +")")
  236. ax.axis("off")
  237. # magic operators
  238. def __add__(self, other):
  239. # create a copy
  240. res = copy.deepcopy(self)
  241. if isinstance(other,type(self)): # both are images
  242. if self.shape == other.shape: # both share same size
  243. if (not self.hasMask()) and (not other.hasMask()): # none has mask
  244. res.colorData = self.colorData + other.colorData
  245. else: # self or other has a mask
  246. if not self.hasMask(): self.addMask()
  247. if not other.hasMask(): other.addMask()
  248. # both have a mask
  249. res.colorData = self.colorData*self.mask[...,np.newaxis] \
  250. + other.colorData*other.mask[...,np.newaxis]
  251. # mask
  252. res.mask = self.mask + other.mask
  253. # regularization
  254. res.colorData[res.mask>1] = res.colorData[res.mask>1] / res.mask[res.mask>1,np.newaxis]
  255. res.mask[res.mask>1] = 1
  256. else:
  257. print("WARNING[Image.__add__(self,other): both image must have the same shape ! return a copy of ",self,"]")
  258. # hdr or sdr
  259. res.type = imageType.imageType.HDR if (self.isHDR() or other.isHDR()) else imageType.imageType.SDR
  260. elif isinstance(other, (int, float)): # sum with int or float
  261. res.colorData = self.colorData + other
  262. if not self.isHDR(): # if not HDR clip value
  263. res.colorData[res.colorData>1] = 1
  264. res.colorData[res.colorData<0] = 0
  265. return res
  266. def __radd__(self, other): return self.__add__(other)
  267. def __sub__(self, other):
  268. # create a copy
  269. res = copy.deepcopy(self)
  270. if isinstance(other,type(self)): # both are image
  271. if self.shape == self.other: # same size
  272. if (not self.hasMask()) and (not other.hasMask()): # mask management
  273. res.colorData = self.colorData - other.colorData
  274. else: # self or other has a mask
  275. if not self.hasMask(): self.addMask()
  276. if not other.hasMask(): other.addMask()
  277. # both have a mask
  278. res.colorData = self.colorData*self.mask[...,np.newaxis] \
  279. - other.colorData*other.mask[...,np.newaxis]
  280. # mask
  281. res.mask = self.mask + other.mask[...,np.newaxis]
  282. res.mask[res.mask>1] = 1
  283. else:
  284. print("WARNING[Image.__sub__(self,other): both image must have the same shape ! return a copy of ",self,"]")
  285. elif isinstance(other, (int, float)):
  286. res.colorData = self.colorData + other
  287. return res
  288. def __rsub__(self, other):
  289. # create a copy
  290. res = copy.deepcopy(self)
  291. if isinstance(other,type(self)):
  292. if self.shape == self.other:
  293. res.colorData = other.colorData - self.colorData
  294. else:
  295. print("WARNING[Image.__sub__(self,other): both image must have the same shape ! return a copy of ",self,"]")
  296. elif isinstance(other, (int, float)):
  297. res.colorData = other - self.colorData
  298. return res
  299. def __mul__ (self, other):
  300. # create a copy
  301. res = copy.deepcopy(self)
  302. if isinstance(other,type(self)):
  303. if self.shape == self.other:
  304. res.colorData = self.colorData * other.colorData
  305. else:
  306. print("WARNING[Image.__mul__(self,other): both image must have the same shape ! return a copy of ",self,"]")
  307. elif isinstance(other, (int, float)):
  308. res.colorData = self.colorData * other
  309. return res
  310. def __rmul__ (self, other): return self.__mul__(other)
  311. def __pow__(self,other):
  312. # create a copy
  313. res = copy.deepcopy(self)
  314. if isinstance(other, (int, float)):
  315. res.colorData = self.colorData**other
  316. return res
  317. def __rpow__(self,other): return self.__pow__(other)
  318. # class methods
  319. def readImage(filename,readExif=True):
  320. # default values
  321. scalingFactor = 1.0
  322. type = None
  323. linear = None
  324. # image name
  325. path, name = os.path.split(filename)
  326. if readExif:
  327. # reading metadata then build exposure and colour space from exif
  328. exif = Exif.Exif.buildFromFileName(filename)
  329. colorspace = ColorSpace.ColorSpace.buildFromExif(exif)
  330. else:
  331. colorspace = ColorSpace.ColorSpace.build('sRGB')
  332. # extension sensitive
  333. splits = filename.split('.')
  334. ext = splits[-1].lower()
  335. # load raw file using rawpy
  336. if ext=="arw" or ext=="dng":
  337. outBit = 16
  338. raw = rawpy.imread(filename)
  339. ppParams = rawpy.Params(demosaic_algorithm=None, half_size=False,
  340. four_color_rgb=False, dcb_iterations=0,
  341. dcb_enhance=False, fbdd_noise_reduction=rawpy.FBDDNoiseReductionMode.Off,
  342. noise_thr=None, median_filter_passes=0,
  343. use_camera_wb=True, # default False
  344. use_auto_wb=False,
  345. user_wb=None,
  346. output_color=rawpy.ColorSpace.sRGB, # output in SRGB
  347. output_bps=outBit, # default 8
  348. user_flip=None, user_black=None,
  349. user_sat=None, no_auto_bright=False,
  350. auto_bright_thr=None, adjust_maximum_thr=0.75,
  351. bright=1.0, highlight_mode=rawpy.HighlightMode.Clip,
  352. exp_shift=None, exp_preserve_highlights=0.0,
  353. no_auto_scale=False,
  354. gamma=None, # linear output
  355. chromatic_aberration=None, bad_pixels_path=None)
  356. imgDouble = colour.utilities.as_float_array(raw.postprocess(ppParams))/(pow(2,16)-1)
  357. raw.close()
  358. type = imageType.RAW
  359. linear = True
  360. # load jpg, tiff, hdr file using colour
  361. else:
  362. imgDouble = colour.read_image(filename, bit_depth='float32', method='Imageio')
  363. imgDouble = Image.forceColorData3(imgDouble)
  364. type = imageType.imageType.SDR
  365. linear = False
  366. # post processing for HDR scaling to [ ,1]
  367. if ext =="hdr":
  368. imgDouble, scalingFactor = Image.scaleMaxOne(imgDouble)
  369. type = imageType.imageType.HDR
  370. linear = True
  371. #return Image(imgDouble, filename, type, linear, colorspace, scalingFactor) # long name
  372. return Image(imgDouble, name, type, linear, colorspace, scalingFactor) # short name
  373. def read(filename,exif=True): return Image.readImage(filename,readExif=exif)
  374. def newImage(shape, colorSpaceName=None, color=None, type=None,name=None):
  375. """ description """
  376. # default values
  377. if not colorSpaceName : colorSpaceName = 'sRGB'
  378. if not color : color = np.asarray([0.0,0.0,0.0])
  379. if not type : type : imageType.imageType.SDR
  380. linear = False if ((type==imageType.imageType.SDR) and (colorSpaceName=='sRGB')) else True
  381. if not name : name = "no name["+colorSpaceName+"]"
  382. scalingFactor = 1.0
  383. # colorSpace
  384. colorSpace = ColorSpace.ColorSpace.build(colorSpaceName)
  385. # colorData
  386. if len(shape) == 2 :
  387. (h,w) = shape
  388. newShape = (h,w,3)
  389. else:
  390. newShape = shape
  391. colorData = np.ones(newShape)
  392. colorData[:,:,0] = colorData[:,:,0]*color[0]
  393. colorData[:,:,1] = colorData[:,:,1]*color[1]
  394. colorData[:,:,2] = colorData[:,:,2]*color[2]
  395. return Image(colorData, name, type, linear, colorSpace, scalingFactor)
  396. def forceColorData3(imgDouble):
  397. """ force color data to hace 3 channels """
  398. # force image to 3 channels
  399. if len(imgDouble.shape) == 2:
  400. # single channel image
  401. h,w = imgDouble.shape
  402. img3 = np.ones([h,w,3])
  403. img3[:,:,0] = imgDouble
  404. img3[:,:,1] = imgDouble
  405. img3[:,:,2] = imgDouble
  406. else:
  407. h,w,c = imgDouble.shape
  408. if c==4:
  409. # remove alpha channel
  410. img3 = np.ones([h,w,3])
  411. img3[:,:,0] = imgDouble[:,:,0]
  412. img3[:,:,1] = imgDouble[:,:,1]
  413. img3[:,:,2] = imgDouble[:,:,2]
  414. else:
  415. img3 = imgDouble
  416. return img3
  417. def array2vector(img):
  418. """ transform 2D array of color data to vector """
  419. if len(img.shape) ==2 :
  420. x,y = img.shape
  421. c = 1
  422. else:
  423. x,y,c = img.shape
  424. return np.reshape(img, (x * y, c))
  425. def scaleMaxOne(img):
  426. """ scale image colorData in [0, 1] space """
  427. imgVector = Image.array2vector(img)
  428. R, G, B = imgVector[:,0], imgVector[:,1], imgVector[:,2]
  429. maxRGB = max([np.amax(R), np.amax(G), np.amax(B)])
  430. return img/maxRGB, 1.0/maxRGB