Coverage for src/arraybridge/array_operations.py: 58%

247 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 11:15 +0000

1"""Typed array-operation leaves carried by ``MemoryType`` declarations.""" 

2 

3from collections.abc import Callable, Sequence 

4from dataclasses import dataclass 

5from typing import Any 

6 

7import numpy as np 

8 

9ToNumpy = Callable[[Any, Any], Any] 

10FromNumpy = Callable[[Any, Any, int], Any] 

11StackArrays = Callable[[Sequence[Any], Any], Any] 

12ScaleDtype = Callable[[Any, Any, Any], Any] 

13DtypeName = Callable[[Any], str] 

14CastArray = Callable[[Any, Any, Any], Any] 

15LogicalAnd = Callable[[Any, Any, Any], Any] 

16 

17_SCALING_RANGES: dict[str, float | tuple[float, float]] = { 

18 "uint8": 255.0, 

19 "uint16": 65535.0, 

20 "uint32": 4294967295.0, 

21 "int16": (65535.0, 32768.0), 

22 "int32": (4294967295.0, 2147483648.0), 

23} 

24 

25 

26def _dtype_name(dtype: Any) -> str: 

27 declared_name = getattr(dtype, "name", None) 

28 if declared_name is not None: 

29 return str(declared_name) 

30 return getattr(dtype, "__name__", str(dtype).rsplit(".", maxsplit=1)[-1]) 

31 

32 

33def _numpy_dtype_name(dtype: Any) -> str: 

34 return str(np.dtype(dtype).name) 

35 

36 

37def _torch_dtype_name(dtype: Any) -> str: 

38 return str(dtype).rsplit(".", maxsplit=1)[-1] 

39 

40 

41def _tensorflow_dtype_name(dtype: Any) -> str: 

42 numpy_dtype = getattr(dtype, "as_numpy_dtype", dtype) 

43 return str(np.dtype(numpy_dtype).name) 

44 

45 

46def _scaled_values(result: Any, result_min: Any, result_max: Any, target_dtype: Any) -> Any: 

47 normalized = (result - result_min) / (result_max - result_min) 

48 range_info = _SCALING_RANGES.get(_dtype_name(target_dtype)) 

49 if range_info is None: 

50 return normalized 

51 if isinstance(range_info, tuple): 

52 scale, offset = range_info 

53 return normalized * scale - offset 

54 return normalized * range_info 

55 

56 

57def _clamp_bounds(target_dtype: Any) -> tuple[float, float] | None: 

58 range_info = _SCALING_RANGES.get(_dtype_name(target_dtype)) 

59 if range_info is None: 

60 return None 

61 if isinstance(range_info, tuple): 

62 scale, offset = range_info 

63 return -offset, scale - offset - 128 

64 return 0, range_info 

65 

66 

67def _identity_to_numpy(data: Any, module: Any) -> Any: 

68 del module 

69 return data 

70 

71 

72def _identity_from_numpy(data: Any, module: Any, device_id: int) -> Any: 

73 del module, device_id 

74 return data 

75 

76 

77def _numpy_stack(values: Sequence[Any], module: Any) -> Any: 

78 return module.stack(values, axis=0) 

79 

80 

81def _numpy_cast(data: Any, dtype: Any, module: Any) -> Any: 

82 del module 

83 return data.astype(dtype, copy=False) 

84 

85 

86def _module_logical_and(left: Any, right: Any, module: Any) -> Any: 

87 return module.logical_and(left, right) 

88 

89 

90def _numpy_scale(result: Any, target_dtype: Any, module: Any) -> Any: 

91 if not hasattr(result, "dtype"): 

92 return result 

93 if not ( 

94 module.issubdtype(result.dtype, module.floating) 

95 and module.issubdtype(target_dtype, module.integer) 

96 ): 

97 return result.astype(target_dtype) 

98 result_min = result.min() 

99 result_max = result.max() 

100 if result_max <= result_min: 

101 return result.astype(target_dtype) 

102 scaled = _scaled_values(result, result_min, result_max, target_dtype) 

103 bounds = _clamp_bounds(target_dtype) 

104 if bounds is not None: 

105 scaled = module.clip(scaled, *bounds) 

106 return scaled.astype(target_dtype) 

107 

108 

109def _cupy_to_numpy(data: Any, module: Any) -> Any: 

110 del module 

111 return data.get() 

112 

113 

114def _cupy_from_numpy(data: Any, module: Any, device_id: int) -> Any: 

115 del device_id 

116 return module.array(data) 

117 

118 

119def _cupy_stack(values: Sequence[Any], module: Any) -> Any: 

120 return module.stack(values, axis=0) 

121 

122 

123def _cupy_scale(result: Any, target_dtype: Any, module: Any) -> Any: 

124 if not hasattr(result, "dtype"): 

125 return result 

126 if not ( 

127 module.issubdtype(result.dtype, module.floating) 

128 and not module.issubdtype(target_dtype, module.floating) 

129 ): 

130 return result.astype(target_dtype) 

131 result_min = module.min(result) 

132 result_max = module.max(result) 

133 if result_max <= result_min: 

134 return result.astype(target_dtype) 

135 scaled = _scaled_values(result, result_min, result_max, target_dtype) 

136 bounds = _clamp_bounds(target_dtype) 

137 if bounds is not None: 

138 scaled = module.clip(scaled, *bounds) 

139 return scaled.astype(target_dtype) 

140 

141 

142def _torch_to_numpy(data: Any, module: Any) -> Any: 

143 del module 

144 return data.cpu().numpy() 

145 

146 

147def _torch_from_numpy(data: Any, module: Any, device_id: int) -> Any: 

148 host_data = ( 

149 np.ascontiguousarray(data) 

150 if any(stride < 0 for stride in getattr(data, "strides", ())) 

151 else data 

152 ) 

153 return module.from_numpy(host_data).to(f"cuda:{device_id}") 

154 

155 

156def _torch_stack(values: Sequence[Any], module: Any) -> Any: 

157 return module.stack(tuple(values), dim=0) 

158 

159 

160def _torch_cast(data: Any, dtype: Any, module: Any) -> Any: 

161 return data.to(dtype=_mapped_dtype(dtype, module)) 

162 

163 

164def _mapped_dtype(target_dtype: Any, module: Any) -> Any: 

165 try: 

166 dtype_name = np.dtype(target_dtype).name 

167 except TypeError as error: 

168 raise TypeError(f"Unsupported target dtype {target_dtype!r}") from error 

169 mapped = getattr(module, dtype_name, None) 

170 if mapped is None: 

171 module_name = getattr(module, "__name__", type(module).__name__) 

172 raise TypeError(f"{module_name} does not expose dtype {dtype_name}") 

173 return mapped 

174 

175 

176def _torch_scale(result: Any, target_dtype: Any, module: Any) -> Any: 

177 if not hasattr(result, "dtype"): 

178 return result 

179 mapped = _mapped_dtype(target_dtype, module) 

180 floats = (module.float16, module.float32, module.float64) 

181 if not (result.dtype in floats and np.issubdtype(np.dtype(target_dtype), np.integer)): 

182 return result.to(mapped) 

183 result_min = result.min() 

184 result_max = result.max() 

185 if result_max <= result_min: 

186 return result.to(mapped) 

187 scaled = _scaled_values(result, result_min, result_max, target_dtype) 

188 bounds = _clamp_bounds(target_dtype) 

189 if bounds is not None: 

190 scaled = module.clamp(scaled, min=bounds[0], max=bounds[1]) 

191 return scaled.to(mapped) 

192 

193 

194def _tensorflow_to_numpy(data: Any, module: Any) -> Any: 

195 del module 

196 return data.numpy() 

197 

198 

199def _tensorflow_from_numpy(data: Any, module: Any, device_id: int) -> Any: 

200 del device_id 

201 return module.convert_to_tensor(data) 

202 

203 

204def _tensorflow_stack(values: Sequence[Any], module: Any) -> Any: 

205 return module.stack(tuple(values), axis=0) 

206 

207 

208def _tensorflow_cast(data: Any, dtype: Any, module: Any) -> Any: 

209 return module.cast(data, _mapped_dtype(dtype, module)) 

210 

211 

212def _tensorflow_scale(result: Any, target_dtype: Any, module: Any) -> Any: 

213 if not hasattr(result, "dtype"): 

214 return result 

215 mapped = _mapped_dtype(target_dtype, module) 

216 floats = (module.float16, module.float32, module.float64) 

217 if not (result.dtype in floats and np.issubdtype(np.dtype(target_dtype), np.integer)): 

218 return module.cast(result, mapped) 

219 result_min = module.reduce_min(result) 

220 result_max = module.reduce_max(result) 

221 if result_max <= result_min: 

222 return module.cast(result, mapped) 

223 scaled = _scaled_values(result, result_min, result_max, target_dtype) 

224 bounds = _clamp_bounds(target_dtype) 

225 if bounds is not None: 

226 scaled = module.clip_by_value(scaled, *bounds) 

227 return module.cast(scaled, mapped) 

228 

229 

230def _jax_to_numpy(data: Any, module: Any) -> Any: 

231 del module 

232 return np.asarray(data) 

233 

234 

235def _jax_from_numpy(data: Any, module: Any, device_id: int) -> Any: 

236 devices = tuple(device for device in module.devices() if device.platform == "gpu") 

237 return module.device_put(data, devices[device_id]) 

238 

239 

240def _jax_stack(values: Sequence[Any], module: Any) -> Any: 

241 return module.numpy.stack(tuple(values), axis=0) 

242 

243 

244def _jax_cast(data: Any, dtype: Any, module: Any) -> Any: 

245 return data.astype(_mapped_dtype(dtype, module.numpy)) 

246 

247 

248def _jax_logical_and(left: Any, right: Any, module: Any) -> Any: 

249 return module.numpy.logical_and(left, right) 

250 

251 

252def _jax_scale(result: Any, target_dtype: Any, module: Any) -> Any: 

253 if not hasattr(result, "dtype"): 

254 return result 

255 if np.dtype(target_dtype) == np.dtype(np.float64): 

256 x64_enabled = getattr(module.config, "x64_enabled", None) 

257 if x64_enabled is None: 

258 x64_enabled = module.config.read("jax_enable_x64") 

259 if not x64_enabled: 

260 raise ValueError( 

261 "JAX float64 output requires x64 mode; set JAX_ENABLE_X64=true before import" 

262 ) 

263 jnp = module.numpy 

264 mapped = _mapped_dtype(target_dtype, jnp) 

265 floats = (jnp.float16, jnp.float32, jnp.float64) 

266 if not (result.dtype in floats and np.issubdtype(np.dtype(target_dtype), np.integer)): 

267 return result.astype(mapped) 

268 result_min = jnp.min(result) 

269 result_max = jnp.max(result) 

270 if result_max <= result_min: 

271 return result.astype(mapped) 

272 scaled = _scaled_values(result, result_min, result_max, target_dtype) 

273 bounds = _clamp_bounds(target_dtype) 

274 if bounds is not None: 

275 scaled = jnp.clip(scaled, *bounds) 

276 return scaled.astype(mapped) 

277 

278 

279def _pyclesperanto_to_numpy(data: Any, module: Any) -> Any: 

280 return module.pull(data) 

281 

282 

283def _pyclesperanto_from_numpy(data: Any, module: Any, device_id: int) -> Any: 

284 del device_id 

285 return module.push(data) 

286 

287 

288def _pyclesperanto_stack(values: Sequence[Any], module: Any) -> Any: 

289 if not values: 

290 raise ValueError("Cannot stack an empty pyclesperanto sequence") 

291 if len(values) == 1: 

292 source = values[0] 

293 result = module.create((1, *source.shape), dtype=source.dtype) 

294 return module.copy_slice(source, result, 0) 

295 result = values[0] 

296 for value in values[1:]: 

297 result = module.concatenate_along_z(result, value) 

298 return result 

299 

300 

301def _pyclesperanto_cast(data: Any, dtype: Any, module: Any) -> Any: 

302 return module.push(module.pull(data).astype(dtype, copy=False)) 

303 

304 

305def _pyclesperanto_logical_and(left: Any, right: Any, module: Any) -> Any: 

306 return module.push(np.logical_and(module.pull(left), module.pull(right))) 

307 

308 

309def _pyclesperanto_scale(result: Any, target_dtype: Any, module: Any) -> Any: 

310 if not hasattr(result, "dtype"): 

311 return result 

312 target_is_int = np.issubdtype(np.dtype(target_dtype), np.integer) 

313 if not (np.issubdtype(result.dtype, np.floating) and target_is_int): 

314 return module.push(module.pull(result).astype(target_dtype)) 

315 result_min = float(module.minimum_of_all_pixels(result)) 

316 result_max = float(module.maximum_of_all_pixels(result)) 

317 if result_max <= result_min: 

318 return module.push(module.pull(result).astype(target_dtype)) 

319 normalized = module.subtract_image_from_scalar(result, scalar=result_min) 

320 normalized = module.multiply_image_and_scalar( 

321 normalized, 

322 scalar=1.0 / (result_max - result_min), 

323 ) 

324 range_info = _SCALING_RANGES.get(_dtype_name(target_dtype)) 

325 if isinstance(range_info, tuple): 

326 scale, offset = range_info 

327 scaled = module.multiply_image_and_scalar(normalized, scalar=scale) 

328 scaled = module.subtract_image_from_scalar(scaled, scalar=offset) 

329 elif range_info is not None: 

330 scaled = module.multiply_image_and_scalar(normalized, scalar=range_info) 

331 else: 

332 scaled = normalized 

333 host_values = module.pull(scaled) 

334 bounds = _clamp_bounds(target_dtype) 

335 if bounds is not None: 

336 host_values = np.clip(host_values, *bounds) 

337 return module.push(host_values.astype(target_dtype)) 

338 

339 

340@dataclass(frozen=True, slots=True) 

341class ArrayOperations: 

342 """Framework-specific array leaves referenced by one declaration.""" 

343 

344 to_numpy: ToNumpy 

345 from_numpy: FromNumpy 

346 stack: StackArrays 

347 scale_dtype: ScaleDtype 

348 dtype_name: DtypeName = _numpy_dtype_name 

349 cast: CastArray = _numpy_cast 

350 logical_and: LogicalAnd = _module_logical_and 

351 

352 

353NUMPY_OPERATIONS = ArrayOperations( 

354 to_numpy=_identity_to_numpy, 

355 from_numpy=_identity_from_numpy, 

356 stack=_numpy_stack, 

357 scale_dtype=_numpy_scale, 

358) 

359CUPY_OPERATIONS = ArrayOperations( 

360 to_numpy=_cupy_to_numpy, 

361 from_numpy=_cupy_from_numpy, 

362 stack=_cupy_stack, 

363 scale_dtype=_cupy_scale, 

364) 

365TORCH_OPERATIONS = ArrayOperations( 

366 to_numpy=_torch_to_numpy, 

367 from_numpy=_torch_from_numpy, 

368 stack=_torch_stack, 

369 scale_dtype=_torch_scale, 

370 dtype_name=_torch_dtype_name, 

371 cast=_torch_cast, 

372) 

373TENSORFLOW_OPERATIONS = ArrayOperations( 

374 to_numpy=_tensorflow_to_numpy, 

375 from_numpy=_tensorflow_from_numpy, 

376 stack=_tensorflow_stack, 

377 scale_dtype=_tensorflow_scale, 

378 dtype_name=_tensorflow_dtype_name, 

379 cast=_tensorflow_cast, 

380) 

381JAX_OPERATIONS = ArrayOperations( 

382 to_numpy=_jax_to_numpy, 

383 from_numpy=_jax_from_numpy, 

384 stack=_jax_stack, 

385 scale_dtype=_jax_scale, 

386 cast=_jax_cast, 

387 logical_and=_jax_logical_and, 

388) 

389PYCLESPERANTO_OPERATIONS = ArrayOperations( 

390 to_numpy=_pyclesperanto_to_numpy, 

391 from_numpy=_pyclesperanto_from_numpy, 

392 stack=_pyclesperanto_stack, 

393 scale_dtype=_pyclesperanto_scale, 

394 cast=_pyclesperanto_cast, 

395 logical_and=_pyclesperanto_logical_and, 

396)