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

162 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-10-07 15:29 +0000

1from __future__ import annotations 

2import geojson 

3import uuid 

4import math 

5import numpy as np 

6import geojson.geometry 

7from typing import Union, Optional, 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: Optional[geojson.geometry.Geometry], 

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

44 name: Optional[str] = None, 

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

46 object_type: ObjectType = ObjectType.ANNOTATION, 

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

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

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

50 extra_properties: Optional[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( 

87 measurements 

88 ) 

89 if object_type is not None: 

90 props["object_type"] = object_type.name 

91 if color is not None: 

92 props["color"] = color 

93 if extra_geometries is not None: 

94 props["extra_geometries"] = { 

95 k: add_plane_to_geometry(v) for k, v in extra_geometries.items() 

96 } 

97 if extra_properties is not None: 

98 props.update(extra_properties) 

99 

100 super().__init__( 

101 geometry=add_plane_to_geometry(geometry), 

102 properties=props, 

103 id=ImageFeature._to_id_string(id), 

104 ) 

105 self["type"] = "Feature" 

106 

107 @classmethod 

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

109 """ 

110 Create an ImageFeature from a GeoJSON feature. 

111 

112 The ImageFeature properties will be searched in the provided 

113 feature and in the properties of the provided feature. 

114 

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

116 :return: an ImageFeature corresponding to the provided feature 

117 """ 

118 geometry = cls._find_property(feature, "geometry") 

119 plane = cls._find_property(feature, "plane") 

120 if plane is not None: 

121 geometry = add_plane_to_geometry( 

122 geometry, z=getattr(plane, "z", None), t=getattr(plane, "t", None) 

123 ) 

124 

125 object_type_property = cls._find_property(feature, "object_type") 

126 if object_type_property is None: 

127 object_type_property = cls._find_property(feature, "objectType") 

128 object_type = next( 

129 ( 

130 o 

131 for o in ObjectType 

132 if o.name.lower() == str(object_type_property).lower() 

133 ), 

134 None, 

135 ) 

136 

137 args = dict( 

138 geometry=geometry, 

139 id=cls._find_property(feature, "id"), 

140 classification=cls._find_property(feature, "classification"), 

141 name=cls._find_property(feature, "name"), 

142 color=cls._find_property(feature, "color"), 

143 measurements=cls._find_property(feature, "measurements"), 

144 object_type=object_type, 

145 ) 

146 

147 nucleus_geometry = cls._find_property(feature, "nucleusGeometry") 

148 if nucleus_geometry is not None: 

149 if plane is not None: 

150 nucleus_geometry = add_plane_to_geometry( 

151 nucleus_geometry, 

152 z=getattr(plane, "z", None), 

153 t=getattr(plane, "t", None), 

154 ) 

155 args["extra_geometries"] = dict(nucleus=nucleus_geometry) 

156 

157 args["extra_properties"] = { 

158 k: v 

159 for k, v in feature["properties"].items() 

160 if k not in args and v is not None 

161 } 

162 return cls(**args) 

163 

164 @classmethod 

165 def create_from_label_image( 

166 cls, 

167 input_image: np.ndarray, 

168 object_type: ObjectType = ObjectType.ANNOTATION, 

169 connectivity: int = 4, 

170 scale: float = 1.0, 

171 include_labels=False, 

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

173 ) -> list[ImageFeature]: 

174 """ 

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

176 

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

178 

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

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

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

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

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

184 :param object_type: the type of object to create 

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

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

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

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

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

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

191 """ 

192 features = [] 

193 

194 if input_image.dtype == bool: 

195 mask = input_image 

196 input_image = input_image.astype(np.uint8) 

197 else: 

198 mask = input_image > 0 

199 

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

201 

202 existing_features = {} 

203 for geometry, label in rasterio.features.shapes( 

204 input_image, mask=mask, connectivity=connectivity, transform=transform 

205 ): 

206 if label in existing_features: 

207 existing_features[label]["geometry"] = shapely.geometry.shape( 

208 geometry 

209 ).union(shapely.geometry.shape(existing_features[label]["geometry"])) 

210 else: 

211 if isinstance(classification_names, str): 

212 classification_name = classification_names 

213 elif ( 

214 isinstance(classification_names, dict) 

215 and int(label) in classification_names 

216 ): 

217 classification_name = classification_names[int(label)] 

218 else: 

219 classification_name = None 

220 

221 feature = cls( 

222 geometry=geometry, 

223 classification=Classification.get_cached_classification( 

224 classification_name 

225 ), 

226 measurements={"Label": float(label)} if include_labels else None, 

227 object_type=object_type, 

228 ) 

229 

230 existing_features[label] = feature 

231 features.append(feature) 

232 

233 # Ensure we have GeoJSON-compatible geometries 

234 for feature in features: 

235 feature["geometry"] = geojson.mapping.to_mapping(feature["geometry"]) 

236 

237 return features 

238 

239 @property 

240 def classification(self) -> Classification: 

241 """ 

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

243 """ 

244 if "classification" in self.properties: 

245 return Classification( 

246 self.properties["classification"].get("name"), 

247 self.properties["classification"].get("color"), 

248 ) 

249 else: 

250 return None 

251 

252 @property 

253 def name(self) -> str: 

254 """ 

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

256 """ 

257 return self.properties.get("name") 

258 

259 @property 

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

261 """ 

262 The measurements of this feature. 

263 """ 

264 measurements = self.properties.get("measurements") 

265 if measurements is None: 

266 measurements = {} 

267 self.properties["measurements"] = measurements 

268 return measurements 

269 

270 @property 

271 def object_type(self) -> ObjectType: 

272 """ 

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

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

275 """ 

276 return next( 

277 ( 

278 o 

279 for o in ObjectType 

280 if o.name.lower() == str(self.properties["object_type"]).lower() 

281 ), 

282 None, 

283 ) 

284 

285 @property 

286 def is_detection(self) -> bool: 

287 """ 

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

289 feature is a detection, cell, or tile. 

290 """ 

291 return self.object_type in [ 

292 ObjectType.DETECTION, 

293 ObjectType.CELL, 

294 ObjectType.TILE, 

295 ] 

296 

297 @property 

298 def is_cell(self) -> bool: 

299 """ 

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

301 feature is a cell. 

302 """ 

303 return self.object_type == ObjectType.CELL 

304 

305 @property 

306 def is_tile(self) -> bool: 

307 """ 

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

309 feature is a tile. 

310 """ 

311 return self.object_type == ObjectType.TILE 

312 

313 @property 

314 def is_annotation(self) -> bool: 

315 """ 

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

317 feature is an annotation. 

318 """ 

319 return self.object_type == ObjectType.ANNOTATION 

320 

321 @property 

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

323 """ 

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

325 """ 

326 return self.properties.get("color") 

327 

328 @property 

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

330 """ 

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

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

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

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

335 property when creating an ImageFeature from a GeoJSON feature. 

336 """ 

337 extra = self.properties.get("extra_geometries") 

338 if extra is not None: 

339 return extra.get(_NUCLEUS_GEOMETRY_KEY) 

340 return None 

341 

342 def __setattr__(self, name, value): 

343 if name == "classification": 

344 if isinstance(value, Classification): 

345 self.properties["classification"] = { 

346 "name": value.name, 

347 "color": value.color, 

348 } 

349 else: 

350 self.properties["classification"] = value 

351 elif name == "name": 

352 self.properties["name"] = value 

353 elif name == "measurements": 

354 self.properties[ 

355 "measurements" 

356 ] = ImageFeature._remove_NaN_values_from_measurements(value) 

357 elif name == "object_type": 

358 if isinstance(value, str): 

359 self.properties["object_type"] = value 

360 elif isinstance(value, ObjectType): 

361 self.properties["object_type"] = value.name 

362 elif name == "color": 

363 if len(value) != 3: 

364 raise ValueError("Color must be a tuple of length 3") 

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

366 self.properties["color"] = rgb 

367 elif name == "nucleus_geometry": 

368 if "extra_geometries" not in self.properties: 

369 self.properties["extra_geometries"] = {} 

370 self.properties["extra_geometries"][ 

371 _NUCLEUS_GEOMETRY_KEY 

372 ] = add_plane_to_geometry(value) 

373 else: 

374 super().__setattr__(name, value) 

375 

376 @staticmethod 

377 def _remove_NaN_values_from_measurements( 

378 measurements: dict[str, float] 

379 ) -> dict[str, float]: 

380 return { 

381 k: float(v) 

382 for k, v in measurements.items() 

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

384 } 

385 

386 @staticmethod 

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

388 if object_id is None: 

389 return str(uuid.uuid4()) 

390 else: 

391 return str(object_id) 

392 

393 @staticmethod 

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

395 if property_name in feature: 

396 return feature[property_name] 

397 elif "properties" in feature and property_name in feature["properties"]: 

398 return feature["properties"][property_name] 

399 else: 

400 return None 

401 

402 @staticmethod 

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

404 if isinstance(value, float): 

405 value = int(round(value * 255)) 

406 if isinstance(value, int): 

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

408 return value 

409 raise ValueError( 

410 "Color value must be an int between 0 and 255, or a float between 0 and 1" 

411 )