Coverage for qubalab/objects/image_feature.py: 93%

164 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-31 11:24 +0000

1from __future__ import annotations 

2import geojson 

3import uuid 

4import math 

5import numpy as np 

6import geojson.geometry 

7from typing import Union, Any 

8import rasterio 

9import rasterio.features 

10import shapely 

11from .object_type import ObjectType 

12from .classification import Classification 

13from .geometry import add_plane_to_geometry 

14 

15 

16_NUCLEUS_GEOMETRY_KEY = 'nucleus' 

17 

18 

19class ImageFeature(geojson.Feature): 

20 """ 

21 GeoJSON Feature with additional properties for image objects. 

22 

23 The added properties are: 

24 

25 - A classification (defined by a name and a color). 

26 

27 - A name. 

28 

29 - A list of measurements. 

30 

31 - A type of QuPath object (e.g. detection, annotation). 

32 

33 - A color. 

34 

35 - Additional geometries. 

36  

37 - And any other property. 

38 """ 

39 

40 def __init__( 

41 self, 

42 geometry: geojson.geometry.Geometry, 

43 classification: Union[Classification, dict] = None, 

44 name: str = None, 

45 measurements: dict[str, float] = None, 

46 object_type: ObjectType = ObjectType.ANNOTATION, 

47 color: tuple[int, int, int] = None, 

48 extra_geometries: dict[str, geojson.geometry.Geometry] = None, 

49 id: Union[str, int, uuid.UUID] = None, 

50 extra_properties: dict[str, Any] = None 

51 ): 

52 """ 

53 Except from the geometry and id parameters, all parameters of this 

54 constructor will be added to the list of properties of this feature 

55 (if provided). 

56  

57 :param geometry: the geometry of the feature 

58 :param classification: the classification of this feature, or a dictionnary with the 

59 'name' and 'color' properties defining respectively a string 

60 and a 3-long int tuple with values between 0 and 255 

61 :param name: the name of this feature 

62 :param measurements: a dictionnary containing measurements. Measurements 

63 with NaN values will not be added 

64 :param object_type: the type of QuPath object this feature represents 

65 :param color: the color of this feature 

66 :param extra_geometries: a dictionnary containing additional geometries 

67 that represent this feature 

68 :param id: the ID of the feature. If not provided, an UUID will be generated 

69 :param extra_properties: a dictionnary of additional properties to add 

70 """ 

71 props = {} 

72 if classification is not None: 

73 if isinstance(classification, Classification): 

74 props['classification'] = { 

75 "name": classification.name, 

76 "color": classification.color 

77 } 

78 else: 

79 props['classification'] = { 

80 "name": classification.get('name'), 

81 "color": classification.get('color') 

82 } 

83 if name is not None: 

84 props['name'] = name 

85 if measurements is not None: 

86 props['measurements'] = ImageFeature._remove_NaN_values_from_measurements(measurements) 

87 if object_type is not None: 

88 props['object_type'] = object_type.name 

89 if color is not None: 

90 props['color'] = color 

91 if extra_geometries is not None: 

92 props['extra_geometries'] = {k: add_plane_to_geometry(v) for k, v in extra_geometries.items()} 

93 if extra_properties is not None: 

94 props.update(extra_properties) 

95 

96 super().__init__( 

97 geometry=add_plane_to_geometry(geometry), 

98 properties=props, 

99 id=ImageFeature._to_id_string(id) 

100 ) 

101 self['type'] = 'Feature' 

102 

103 

104 @classmethod 

105 def create_from_feature(cls, feature: geojson.Feature) -> ImageFeature: 

106 """ 

107 Create an ImageFeature from a GeoJSON feature. 

108 

109 The ImageFeature properties will be searched in the provided 

110 feature and in the properties of the provided feature. 

111  

112 :param feature: the feature to convert to an ImageFeature 

113 :return: an ImageFeature corresponding to the provided feature 

114 """ 

115 geometry = cls._find_property(feature, 'geometry') 

116 plane = cls._find_property(feature, 'plane') 

117 if plane is not None: 

118 geometry = add_plane_to_geometry(geometry, z=getattr(plane, 'z', None), t=getattr(plane, 't', None)) 

119 

120 object_type_property = cls._find_property(feature, 'object_type') 

121 if object_type_property is None: 

122 object_type_property = cls._find_property(feature, 'objectType') 

123 object_type = next((o for o in ObjectType if o.name.lower() == str(object_type_property).lower()), None) 

124 

125 args = dict( 

126 geometry=geometry, 

127 id=cls._find_property(feature, 'id'), 

128 classification=cls._find_property(feature, 'classification'), 

129 name=cls._find_property(feature, 'name'), 

130 color=cls._find_property(feature, 'color'), 

131 measurements=cls._find_property(feature, 'measurements'), 

132 object_type=object_type, 

133 ) 

134 

135 nucleus_geometry = cls._find_property(feature, 'nucleusGeometry') 

136 if nucleus_geometry is not None: 

137 if plane is not None: 

138 nucleus_geometry = add_plane_to_geometry(nucleus_geometry, z=getattr(plane, 'z', None), t=getattr(plane, 't', None)) 

139 args['extra_geometries'] = dict(nucleus=nucleus_geometry) 

140 

141 args['extra_properties'] = {k: v for k, v in feature['properties'].items() if k not in args and v is not None} 

142 return cls(**args) 

143 

144 @classmethod 

145 def create_from_label_image( 

146 cls, 

147 input_image: np.ndarray, 

148 object_type: ObjectType = ObjectType.ANNOTATION, 

149 connectivity: int = 4, 

150 scale: float = 1.0, 

151 include_labels = False, 

152 classification_names: Union[str, dict[int, str]] = None 

153 ) -> list[ImageFeature]: 

154 """ 

155 Create a list of ImageFeatures from a binary or labeled image. 

156 

157 The created geometries will be polygons, even when representing points or line. 

158 

159 :param input_image: a 2-dimensionnal binary (with a boolean type) or labeled 

160 (with a uint8 type) image containing the features to create. 

161 If a binary image is given, all True pixel values will be 

162 considered as potential features. If a labeled image is given, 

163 all pixel values greater than 0 will be considered as potential features 

164 :param object_type: the type of object to create 

165 :param connectivity: the pixel connectivity for grouping pixels into features (4 or 8) 

166 :param scale: a scale value to apply to the shapes 

167 :param include_labels: whether to include a 'Label' measurement in the created features 

168 :param classification_names: if str, the name of the classification to apply to all features. 

169 if dict, a dictionnary mapping a label to a classification name 

170 :return: a list of image features representing polygons present in the input image 

171 """ 

172 features = [] 

173 

174 if input_image.dtype == bool: 

175 mask = input_image 

176 input_image = input_image.astype(np.uint8) 

177 else: 

178 mask = input_image > 0 

179 

180 transform = rasterio.transform.Affine.scale(scale) 

181 

182 existing_features = {} 

183 for geometry, label in rasterio.features.shapes(input_image, mask=mask, connectivity=connectivity, transform=transform): 

184 if label in existing_features: 

185 existing_features[label]['geometry'] = shapely.geometry.shape(geometry).union( 

186 shapely.geometry.shape(existing_features[label]['geometry']) 

187 ) 

188 else: 

189 if isinstance(classification_names, str): 

190 classification_name = classification_names 

191 elif isinstance(classification_names, dict) and int(label) in classification_names: 

192 classification_name = classification_names[int(label)] 

193 else: 

194 classification_name = None 

195 

196 feature = cls( 

197 geometry=geometry, 

198 classification=Classification.get_cached_classification(classification_name), 

199 measurements={'Label': float(label)} if include_labels else None, 

200 object_type=object_type 

201 ) 

202 

203 existing_features[label] = feature 

204 features.append(feature) 

205 

206 # Ensure we have GeoJSON-compatible geometries 

207 for feature in features: 

208 feature['geometry'] = geojson.mapping.to_mapping(feature['geometry']) 

209 

210 return features 

211 

212 @property 

213 def classification(self) -> Classification: 

214 """ 

215 The classification of this feature (or None if not defined). 

216 """ 

217 if "classification" in self.properties: 

218 return Classification(self.properties['classification'].get('name'), self.properties['classification'].get('color')) 

219 else: 

220 return None 

221 

222 @property 

223 def name(self) -> str: 

224 """ 

225 The name of this feature (or None if not defined). 

226 """ 

227 return self.properties.get('name') 

228 

229 @property 

230 def measurements(self) -> dict[str, float]: 

231 """ 

232 The measurements of this feature. 

233 """ 

234 measurements = self.properties.get('measurements') 

235 if measurements is None: 

236 measurements = {} 

237 self.properties['measurements'] = measurements 

238 return measurements 

239 

240 @property 

241 def object_type(self) -> ObjectType: 

242 """ 

243 The QuPath object type (e.g. detection, annotation) this feature represents 

244 or None if the object type doesn't exist or is not recognised. 

245 """ 

246 return next((o for o in ObjectType if o.name.lower() == str(self.properties['object_type']).lower()), None) 

247 

248 @property 

249 def is_detection(self) -> bool: 

250 """ 

251 Wether the QuPath object type (e.g. detection, annotation) represented by this 

252 feature is a detection, cell, or tile. 

253 """ 

254 return self.object_type in [ObjectType.DETECTION, ObjectType.CELL, ObjectType.TILE] 

255 

256 @property 

257 def is_cell(self) -> bool: 

258 """ 

259 Wether the QuPath object type (e.g. detection, annotation) represented by this 

260 feature is a cell. 

261 """ 

262 return self.object_type == ObjectType.CELL 

263 

264 @property 

265 def is_tile(self) -> bool: 

266 """ 

267 Wether the QuPath object type (e.g. detection, annotation) represented by this 

268 feature is a tile. 

269 """ 

270 return self.object_type == ObjectType.TILE 

271 

272 @property 

273 def is_annotation(self) -> bool: 

274 """ 

275 Wether the QuPath object type (e.g. detection, annotation) represented by this 

276 feature is an annotation. 

277 """ 

278 return self.object_type == ObjectType.ANNOTATION 

279 

280 @property 

281 def color(self) -> tuple[int, int, int]: 

282 """ 

283 The color of this feature (or None if not defined). 

284 """ 

285 return self.properties.get('color') 

286 

287 @property 

288 def nucleus_geometry(self) -> geojson.geometry.Geometry: 

289 """ 

290 The nucleus geometry of this feature (or None if not defined). 

291 It can be defined when passed as an extra_geometry with the 'nucleus' 

292 key when creating an ImageFeature, by defining the 'nucleus_geometry' 

293 property of an ImageFeature, or when passed as a 'nucleusGeometry' 

294 property when creating an ImageFeature from a GeoJSON feature. 

295 """ 

296 extra = self.properties.get('extra_geometries') 

297 if extra is not None: 

298 return extra.get(_NUCLEUS_GEOMETRY_KEY) 

299 return None 

300 

301 def __setattr__(self, name, value): 

302 if name == 'classification': 

303 if isinstance(value, Classification): 

304 self.properties['classification'] = { 

305 "name": value.name, 

306 "color": value.color 

307 } 

308 else: 

309 self.properties['classification'] = value 

310 elif name == 'name': 

311 self.properties['name'] = value 

312 elif name == 'measurements': 

313 self.properties['measurements'] = ImageFeature._remove_NaN_values_from_measurements(value) 

314 elif name == 'object_type': 

315 if isinstance(value, str): 

316 self.properties['object_type'] = value 

317 elif isinstance(value, ObjectType): 

318 self.properties['object_type'] = value.name 

319 elif name == 'color': 

320 if len(value) != 3: 

321 raise ValueError('Color must be a tuple of length 3') 

322 rgb = tuple(ImageFeature._validate_rgb_value(v) for v in value) 

323 self.properties['color'] = rgb 

324 elif name == 'nucleus_geometry': 

325 if 'extra_geometries' not in self.properties: 

326 self.properties['extra_geometries'] = {} 

327 self.properties['extra_geometries'][_NUCLEUS_GEOMETRY_KEY] = add_plane_to_geometry(value) 

328 else: 

329 super().__setattr__(name, value) 

330 

331 @staticmethod 

332 def _remove_NaN_values_from_measurements(measurements: dict[str, float]) -> dict[str, float]: 

333 return { 

334 k: float(v) for k, v in measurements.items() 

335 if isinstance(k, str) and isinstance(v, (int, float)) and not math.isnan(v) 

336 } 

337 

338 @staticmethod 

339 def _to_id_string(object_id: Union[int, str, uuid.UUID]) -> str: 

340 if object_id is None: 

341 return str(uuid.uuid4()) 

342 elif isinstance(object_id, str) or isinstance(object_id, int): 

343 return object_id 

344 else: 

345 return str(object_id) 

346 

347 @staticmethod 

348 def _find_property(feature: geojson.Feature, property_name: str): 

349 if property_name in feature: 

350 return feature[property_name] 

351 elif 'properties' in feature and property_name in feature['properties']: 

352 return feature['properties'][property_name] 

353 else: 

354 return None 

355 

356 @staticmethod 

357 def _validate_rgb_value(value: Union[int, float]) -> int: 

358 if isinstance(value, float): 

359 value = int(math.round(value * 255)) 

360 if isinstance(value, int): 

361 if value >= 0 and value <= 255: 

362 return value 

363 raise ValueError('Color value must be an int between 0 and 255, or a float between 0 and 1')