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

162 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-10-22 18:11 +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 "names": classification.names, 

76 "color": classification.color, 

77 } 

78 else: 

79 props["classification"] = { 

80 "names": classification.get("names") 

81 if "names" in classification.keys() 

82 else (classification.get("name"),), 

83 "color": classification.get("color"), 

84 } 

85 if name is not None: 

86 props["name"] = name 

87 if measurements is not None: 

88 props["measurements"] = ImageFeature._remove_NaN_values_from_measurements( 

89 measurements 

90 ) 

91 if object_type is not None: 

92 props["object_type"] = object_type.name 

93 if color is not None: 

94 props["color"] = color 

95 if extra_geometries is not None: 

96 props["extra_geometries"] = { 

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

98 } 

99 if extra_properties is not None: 

100 props.update(extra_properties) 

101 

102 super().__init__( 

103 geometry=add_plane_to_geometry(geometry), 

104 properties=props, 

105 id=ImageFeature._to_id_string(id), 

106 ) 

107 self["type"] = "Feature" 

108 

109 @classmethod 

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

111 """ 

112 Create an ImageFeature from a GeoJSON feature. 

113 

114 The ImageFeature properties will be searched in the provided 

115 feature and in the properties of the provided feature. 

116 

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

118 :return: an ImageFeature corresponding to the provided feature 

119 """ 

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

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

122 if plane is not None: 

123 geometry = add_plane_to_geometry( 

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

125 ) 

126 

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

128 if object_type_property is None: 

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

130 object_type = next( 

131 ( 

132 o 

133 for o in ObjectType 

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

135 ), 

136 None, 

137 ) 

138 

139 args = dict( 

140 geometry=geometry, 

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

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

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

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

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

146 object_type=object_type, 

147 ) 

148 

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

150 if nucleus_geometry is not None: 

151 if plane is not None: 

152 nucleus_geometry = add_plane_to_geometry( 

153 nucleus_geometry, 

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

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

156 ) 

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

158 

159 args["extra_properties"] = { 

160 k: v 

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

162 if k not in args and v is not None 

163 } 

164 return cls(**args) 

165 

166 @classmethod 

167 def create_from_label_image( 

168 cls, 

169 input_image: np.ndarray, 

170 object_type: ObjectType = ObjectType.ANNOTATION, 

171 connectivity: int = 4, 

172 scale: float = 1.0, 

173 include_labels=False, 

174 classification_names: Optional[Union[str, dict[int, str]]] = None, 

175 ) -> list[ImageFeature]: 

176 """ 

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

178 

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

180 

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

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

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

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

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

186 :param object_type: the type of object to create 

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

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

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

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

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

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

193 """ 

194 features = [] 

195 

196 if input_image.dtype == bool: 

197 mask = input_image 

198 input_image = input_image.astype(np.uint8) 

199 else: 

200 mask = input_image > 0 

201 

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

203 

204 existing_features = {} 

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

206 input_image, mask=mask, connectivity=connectivity, transform=transform 

207 ): 

208 if label in existing_features: 

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

210 geometry 

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

212 else: 

213 if isinstance(classification_names, str): 

214 classification_name = classification_names 

215 elif ( 

216 isinstance(classification_names, dict) 

217 and int(label) in classification_names 

218 ): 

219 classification_name = classification_names[int(label)] 

220 else: 

221 classification_name = None 

222 

223 feature = cls( 

224 geometry=geometry, 

225 classification=Classification(classification_name), 

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("names") 

247 if "names" in self.properties["classification"].keys() 

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

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

250 ) 

251 else: 

252 return None 

253 

254 @property 

255 def name(self) -> str: 

256 """ 

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

258 """ 

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

260 

261 @property 

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

263 """ 

264 The measurements of this feature. 

265 """ 

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

267 if measurements is None: 

268 measurements = {} 

269 self.properties["measurements"] = measurements 

270 return measurements 

271 

272 @property 

273 def object_type(self) -> ObjectType: 

274 """ 

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

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

277 """ 

278 return next( 

279 ( 

280 o 

281 for o in ObjectType 

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

283 ), 

284 None, 

285 ) 

286 

287 @property 

288 def is_detection(self) -> bool: 

289 """ 

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

291 feature is a detection, cell, or tile. 

292 """ 

293 return self.object_type in [ 

294 ObjectType.DETECTION, 

295 ObjectType.CELL, 

296 ObjectType.TILE, 

297 ] 

298 

299 @property 

300 def is_cell(self) -> bool: 

301 """ 

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

303 feature is a cell. 

304 """ 

305 return self.object_type == ObjectType.CELL 

306 

307 @property 

308 def is_tile(self) -> bool: 

309 """ 

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

311 feature is a tile. 

312 """ 

313 return self.object_type == ObjectType.TILE 

314 

315 @property 

316 def is_annotation(self) -> bool: 

317 """ 

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

319 feature is an annotation. 

320 """ 

321 return self.object_type == ObjectType.ANNOTATION 

322 

323 @property 

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

325 """ 

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

327 """ 

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

329 

330 @property 

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

332 """ 

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

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

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

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

337 property when creating an ImageFeature from a GeoJSON feature. 

338 """ 

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

340 if extra is not None: 

341 return extra.get(_NUCLEUS_GEOMETRY_KEY) 

342 return None 

343 

344 def __setattr__(self, name, value): 

345 if name == "classification": 

346 if isinstance(value, Classification): 

347 self.properties["classification"] = { 

348 "name": value.name, 

349 "color": value.color, 

350 } 

351 else: 

352 self.properties["classification"] = value 

353 elif name == "name": 

354 self.properties["name"] = value 

355 elif name == "measurements": 

356 self.properties[ 

357 "measurements" 

358 ] = ImageFeature._remove_NaN_values_from_measurements(value) 

359 elif name == "object_type": 

360 if isinstance(value, str): 

361 self.properties["object_type"] = value 

362 elif isinstance(value, ObjectType): 

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

364 elif name == "color": 

365 if len(value) != 3: 

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

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

368 self.properties["color"] = rgb 

369 elif name == "nucleus_geometry": 

370 if "extra_geometries" not in self.properties: 

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

372 self.properties["extra_geometries"][ 

373 _NUCLEUS_GEOMETRY_KEY 

374 ] = add_plane_to_geometry(value) 

375 else: 

376 super().__setattr__(name, value) 

377 

378 @staticmethod 

379 def _remove_NaN_values_from_measurements( 

380 measurements: dict[str, float] 

381 ) -> dict[str, float]: 

382 return { 

383 k: float(v) 

384 for k, v in measurements.items() 

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

386 } 

387 

388 @staticmethod 

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

390 if object_id is None: 

391 return str(uuid.uuid4()) 

392 else: 

393 return str(object_id) 

394 

395 @staticmethod 

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

397 if property_name in feature: 

398 return feature[property_name] 

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

400 return feature["properties"][property_name] 

401 else: 

402 return None 

403 

404 @staticmethod 

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

406 if isinstance(value, float): 

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

408 if isinstance(value, int): 

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

410 return value 

411 raise ValueError( 

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

413 )