_random_shapes.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. import math
  2. import numpy as np
  3. from .draw import polygon as draw_polygon, disk as draw_disk, ellipse as draw_ellipse
  4. from .._shared.utils import warn
  5. def _generate_rectangle_mask(point, image, shape, random):
  6. """Generate a mask for a filled rectangle shape.
  7. The height and width of the rectangle are generated randomly.
  8. Parameters
  9. ----------
  10. point : tuple
  11. The row and column of the top left corner of the rectangle.
  12. image : tuple
  13. The height, width and depth of the image into which the shape
  14. is placed.
  15. shape : tuple
  16. The minimum and maximum size of the shape to fit.
  17. random : `numpy.random.Generator`
  18. The random state to use for random sampling.
  19. Raises
  20. ------
  21. ArithmeticError
  22. When a shape cannot be fit into the image with the given starting
  23. coordinates. This usually means the image dimensions are too small or
  24. shape dimensions too large.
  25. Returns
  26. -------
  27. label : tuple
  28. A (category, ((r0, r1), (c0, c1))) tuple specifying the category and
  29. bounding box coordinates of the shape.
  30. indices : 2-D array
  31. A mask of indices that the shape fills.
  32. """
  33. available_width = min(image[1] - point[1], shape[1]) - shape[0]
  34. available_height = min(image[0] - point[0], shape[1]) - shape[0]
  35. # Pick random widths and heights.
  36. r = shape[0] + random.integers(max(1, available_height)) - 1
  37. c = shape[0] + random.integers(max(1, available_width)) - 1
  38. rectangle = draw_polygon(
  39. [
  40. point[0],
  41. point[0] + r,
  42. point[0] + r,
  43. point[0],
  44. ],
  45. [
  46. point[1],
  47. point[1],
  48. point[1] + c,
  49. point[1] + c,
  50. ],
  51. )
  52. label = ('rectangle', ((point[0], point[0] + r + 1), (point[1], point[1] + c + 1)))
  53. return rectangle, label
  54. def _generate_circle_mask(point, image, shape, random):
  55. """Generate a mask for a filled circle shape.
  56. The radius of the circle is generated randomly.
  57. Parameters
  58. ----------
  59. point : tuple
  60. The row and column of the top left corner of the rectangle.
  61. image : tuple
  62. The height, width and depth of the image into which the shape is placed.
  63. shape : tuple
  64. The minimum and maximum size and color of the shape to fit.
  65. random : `numpy.random.Generator`
  66. The random state to use for random sampling.
  67. Raises
  68. ------
  69. ArithmeticError
  70. When a shape cannot be fit into the image with the given starting
  71. coordinates. This usually means the image dimensions are too small or
  72. shape dimensions too large.
  73. Returns
  74. -------
  75. label : tuple
  76. A (category, ((r0, r1), (c0, c1))) tuple specifying the category and
  77. bounding box coordinates of the shape.
  78. indices : 2-D array
  79. A mask of indices that the shape fills.
  80. """
  81. if shape[0] == 1 or shape[1] == 1:
  82. raise ValueError('size must be > 1 for circles')
  83. min_radius = shape[0] // 2.0
  84. max_radius = shape[1] // 2.0
  85. left = point[1]
  86. right = image[1] - point[1]
  87. top = point[0]
  88. bottom = image[0] - point[0]
  89. available_radius = min(left, right, top, bottom, max_radius) - min_radius
  90. if available_radius < 0:
  91. raise ArithmeticError('cannot fit shape to image')
  92. radius = int(min_radius + random.integers(max(1, available_radius)))
  93. # TODO: think about how to deprecate this
  94. # while draw_circle was deprecated in favor of draw_disk
  95. # switching to a label of 'disk' here
  96. # would be a breaking change for downstream libraries
  97. # See discussion on naming convention here
  98. # https://github.com/scikit-image/scikit-image/pull/4428
  99. disk = draw_disk((point[0], point[1]), radius)
  100. # Until a deprecation path is decided, always return `'circle'`
  101. label = (
  102. 'circle',
  103. (
  104. (point[0] - radius + 1, point[0] + radius),
  105. (point[1] - radius + 1, point[1] + radius),
  106. ),
  107. )
  108. return disk, label
  109. def _generate_triangle_mask(point, image, shape, random):
  110. """Generate a mask for a filled equilateral triangle shape.
  111. The length of the sides of the triangle is generated randomly.
  112. Parameters
  113. ----------
  114. point : tuple
  115. The row and column of the top left corner of a up-pointing triangle.
  116. image : tuple
  117. The height, width and depth of the image into which the shape
  118. is placed.
  119. shape : tuple
  120. The minimum and maximum size and color of the shape to fit.
  121. random : `numpy.random.Generator`
  122. The random state to use for random sampling.
  123. Raises
  124. ------
  125. ArithmeticError
  126. When a shape cannot be fit into the image with the given starting
  127. coordinates. This usually means the image dimensions are too small or
  128. shape dimensions too large.
  129. Returns
  130. -------
  131. label : tuple
  132. A (category, ((r0, r1), (c0, c1))) tuple specifying the category and
  133. bounding box coordinates of the shape.
  134. indices : 2-D array
  135. A mask of indices that the shape fills.
  136. """
  137. if shape[0] == 1 or shape[1] == 1:
  138. raise ValueError('dimension must be > 1 for triangles')
  139. available_side = min(image[1] - point[1], point[0], shape[1]) - shape[0]
  140. side = shape[0] + random.integers(max(1, available_side)) - 1
  141. triangle_height = int(np.ceil(np.sqrt(3 / 4.0) * side))
  142. triangle = draw_polygon(
  143. [
  144. point[0],
  145. point[0] - triangle_height,
  146. point[0],
  147. ],
  148. [
  149. point[1],
  150. point[1] + side // 2,
  151. point[1] + side,
  152. ],
  153. )
  154. label = (
  155. 'triangle',
  156. ((point[0] - triangle_height, point[0] + 1), (point[1], point[1] + side + 1)),
  157. )
  158. return triangle, label
  159. def _generate_ellipse_mask(point, image, shape, random):
  160. """Generate a mask for a filled ellipse shape.
  161. The rotation, major and minor semi-axes of the ellipse are generated
  162. randomly.
  163. Parameters
  164. ----------
  165. point : tuple
  166. The row and column of the top left corner of the rectangle.
  167. image : tuple
  168. The height, width and depth of the image into which the shape is
  169. placed.
  170. shape : tuple
  171. The minimum and maximum size and color of the shape to fit.
  172. random : `numpy.random.Generator`
  173. The random state to use for random sampling.
  174. Raises
  175. ------
  176. ArithmeticError
  177. When a shape cannot be fit into the image with the given starting
  178. coordinates. This usually means the image dimensions are too small or
  179. shape dimensions too large.
  180. Returns
  181. -------
  182. label : tuple
  183. A (category, ((r0, r1), (c0, c1))) tuple specifying the category and
  184. bounding box coordinates of the shape.
  185. indices : 2-D array
  186. A mask of indices that the shape fills.
  187. """
  188. if shape[0] == 1 or shape[1] == 1:
  189. raise ValueError('size must be > 1 for ellipses')
  190. min_radius = shape[0] / 2.0
  191. max_radius = shape[1] / 2.0
  192. left = point[1]
  193. right = image[1] - point[1]
  194. top = point[0]
  195. bottom = image[0] - point[0]
  196. available_radius = min(left, right, top, bottom, max_radius)
  197. if available_radius < min_radius:
  198. raise ArithmeticError('cannot fit shape to image')
  199. # NOTE: very conservative because we could take into account the fact that
  200. # we have 2 different radii, but this is a good first approximation.
  201. # Also, we can afford to have a uniform sampling because the ellipse will
  202. # be rotated.
  203. r_radius = random.uniform(min_radius, available_radius + 1)
  204. c_radius = random.uniform(min_radius, available_radius + 1)
  205. rotation = random.uniform(-np.pi, np.pi)
  206. ellipse = draw_ellipse(
  207. point[0],
  208. point[1],
  209. r_radius,
  210. c_radius,
  211. shape=image[:2],
  212. rotation=rotation,
  213. )
  214. max_radius = math.ceil(max(r_radius, c_radius))
  215. min_x = np.min(ellipse[0])
  216. max_x = np.max(ellipse[0]) + 1
  217. min_y = np.min(ellipse[1])
  218. max_y = np.max(ellipse[1]) + 1
  219. label = ('ellipse', ((min_x, max_x), (min_y, max_y)))
  220. return ellipse, label
  221. # Allows lookup by key as well as random selection.
  222. SHAPE_GENERATORS = dict(
  223. rectangle=_generate_rectangle_mask,
  224. circle=_generate_circle_mask,
  225. triangle=_generate_triangle_mask,
  226. ellipse=_generate_ellipse_mask,
  227. )
  228. SHAPE_CHOICES = list(SHAPE_GENERATORS.values())
  229. def _generate_random_colors(num_colors, num_channels, intensity_range, random):
  230. """Generate an array of random colors.
  231. Parameters
  232. ----------
  233. num_colors : int
  234. Number of colors to generate.
  235. num_channels : int
  236. Number of elements representing color.
  237. intensity_range : {tuple of tuples of ints, tuple of ints}, optional
  238. The range of values to sample pixel values from. For grayscale images
  239. the format is (min, max). For multichannel - ((min, max),) if the
  240. ranges are equal across the channels, and
  241. ((min_0, max_0), ... (min_N, max_N)) if they differ.
  242. random : `numpy.random.Generator`
  243. The random state to use for random sampling.
  244. Raises
  245. ------
  246. ValueError
  247. When the `intensity_range` is not in the interval (0, 255).
  248. Returns
  249. -------
  250. colors : array
  251. An array of shape (num_colors, num_channels), where the values for
  252. each channel are drawn from the corresponding `intensity_range`.
  253. """
  254. if num_channels == 1:
  255. intensity_range = (intensity_range,)
  256. elif len(intensity_range) == 1:
  257. intensity_range = intensity_range * num_channels
  258. colors = [random.integers(r[0], r[1] + 1, size=num_colors) for r in intensity_range]
  259. return np.transpose(colors)
  260. def random_shapes(
  261. image_shape,
  262. max_shapes,
  263. min_shapes=1,
  264. min_size=2,
  265. max_size=None,
  266. num_channels=3,
  267. shape=None,
  268. intensity_range=None,
  269. allow_overlap=False,
  270. num_trials=100,
  271. rng=None,
  272. *,
  273. channel_axis=-1,
  274. ):
  275. """Generate an image with random shapes, labeled with bounding boxes.
  276. The image is populated with random shapes with random sizes, random
  277. locations, and random colors, with or without overlap.
  278. Shapes have random (row, col) starting coordinates and random sizes bounded
  279. by `min_size` and `max_size`. It can occur that a randomly generated shape
  280. will not fit the image at all. In that case, the algorithm will try again
  281. with new starting coordinates a certain number of times. However, it also
  282. means that some shapes may be skipped altogether. In that case, this
  283. function will generate fewer shapes than requested.
  284. Parameters
  285. ----------
  286. image_shape : tuple
  287. The number of rows and columns of the image to generate.
  288. max_shapes : int
  289. The maximum number of shapes to (attempt to) fit into the shape.
  290. min_shapes : int, optional
  291. The minimum number of shapes to (attempt to) fit into the shape.
  292. min_size : int, optional
  293. The minimum dimension of each shape to fit into the image.
  294. max_size : int, optional
  295. The maximum dimension of each shape to fit into the image.
  296. num_channels : int, optional
  297. Number of channels in the generated image. If 1, generate monochrome
  298. images, else color images with multiple channels. Ignored if
  299. ``multichannel`` is set to False.
  300. shape : {rectangle, circle, triangle, ellipse, None} str, optional
  301. The name of the shape to generate or `None` to pick random ones.
  302. intensity_range : {tuple of tuples of uint8, tuple of uint8}, optional
  303. The range of values to sample pixel values from. For grayscale
  304. images the format is (min, max). For multichannel - ((min, max),)
  305. if the ranges are equal across the channels, and
  306. ((min_0, max_0), ... (min_N, max_N)) if they differ. As the
  307. function supports generation of uint8 arrays only, the maximum
  308. range is (0, 255). If None, set to (0, 254) for each channel
  309. reserving color of intensity = 255 for background.
  310. allow_overlap : bool, optional
  311. If `True`, allow shapes to overlap.
  312. num_trials : int, optional
  313. How often to attempt to fit a shape into the image before skipping it.
  314. rng : {`numpy.random.Generator`, int}, optional
  315. Pseudo-random number generator.
  316. By default, a PCG64 generator is used (see :func:`numpy.random.default_rng`).
  317. If `rng` is an int, it is used to seed the generator.
  318. channel_axis : int or None, optional
  319. If None, the image is assumed to be a grayscale (single channel) image.
  320. Otherwise, this parameter indicates which axis of the array corresponds
  321. to channels.
  322. .. versionadded:: 0.19
  323. ``channel_axis`` was added in 0.19.
  324. Returns
  325. -------
  326. image : uint8 array
  327. An image with the fitted shapes.
  328. labels : list
  329. A list of labels, one per shape in the image. Each label is a
  330. (category, ((r0, r1), (c0, c1))) tuple specifying the category and
  331. bounding box coordinates of the shape.
  332. Examples
  333. --------
  334. >>> import skimage.draw
  335. >>> image, labels = skimage.draw.random_shapes((32, 32), max_shapes=3)
  336. >>> image # doctest: +SKIP
  337. array([
  338. [[255, 255, 255],
  339. [255, 255, 255],
  340. [255, 255, 255],
  341. ...,
  342. [255, 255, 255],
  343. [255, 255, 255],
  344. [255, 255, 255]]], dtype=uint8)
  345. >>> labels # doctest: +SKIP
  346. [('circle', ((22, 18), (25, 21))),
  347. ('triangle', ((5, 6), (13, 13)))]
  348. """
  349. if min_size > image_shape[0] or min_size > image_shape[1]:
  350. raise ValueError('Minimum dimension must be less than ncols and nrows')
  351. max_size = max_size or max(image_shape[0], image_shape[1])
  352. if channel_axis is None:
  353. num_channels = 1
  354. if intensity_range is None:
  355. intensity_range = (0, 254) if num_channels == 1 else ((0, 254),)
  356. else:
  357. tmp = (intensity_range,) if num_channels == 1 else intensity_range
  358. for intensity_pair in tmp:
  359. for intensity in intensity_pair:
  360. if not (0 <= intensity <= 255):
  361. msg = 'Intensity range must lie within (0, 255) interval'
  362. raise ValueError(msg)
  363. rng = np.random.default_rng(rng)
  364. user_shape = shape
  365. image_shape = (image_shape[0], image_shape[1], num_channels)
  366. image = np.full(image_shape, 255, dtype=np.uint8)
  367. filled = np.zeros(image_shape, dtype=bool)
  368. labels = []
  369. num_shapes = rng.integers(min_shapes, max_shapes + 1)
  370. colors = _generate_random_colors(num_shapes, num_channels, intensity_range, rng)
  371. shape = (min_size, max_size)
  372. for shape_idx in range(num_shapes):
  373. if user_shape is None:
  374. shape_generator = rng.choice(SHAPE_CHOICES)
  375. else:
  376. shape_generator = SHAPE_GENERATORS[user_shape]
  377. for _ in range(num_trials):
  378. # Pick start coordinates.
  379. column = rng.integers(max(1, image_shape[1] - min_size))
  380. row = rng.integers(max(1, image_shape[0] - min_size))
  381. point = (row, column)
  382. try:
  383. indices, label = shape_generator(point, image_shape, shape, rng)
  384. except ArithmeticError:
  385. # Couldn't fit the shape, skip it.
  386. indices = []
  387. continue
  388. # Check if there is an overlap where the mask is nonzero.
  389. if allow_overlap or not filled[indices].any():
  390. image[indices] = colors[shape_idx]
  391. filled[indices] = True
  392. labels.append(label)
  393. break
  394. else:
  395. warn(
  396. 'Could not fit any shapes to image, '
  397. 'consider reducing the minimum dimension'
  398. )
  399. if channel_axis is None:
  400. image = np.squeeze(image, axis=2)
  401. else:
  402. image = np.moveaxis(image, -1, channel_axis)
  403. return image, labels