senoquant 1.0.0b1__py3-none-any.whl → 1.0.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. senoquant/__init__.py +6 -2
  2. senoquant/_reader.py +1 -1
  3. senoquant/reader/core.py +201 -18
  4. senoquant/tabs/batch/backend.py +18 -3
  5. senoquant/tabs/batch/frontend.py +8 -4
  6. senoquant/tabs/quantification/features/marker/dialog.py +26 -6
  7. senoquant/tabs/quantification/features/marker/export.py +97 -24
  8. senoquant/tabs/quantification/features/marker/rows.py +2 -2
  9. senoquant/tabs/quantification/features/spots/dialog.py +41 -11
  10. senoquant/tabs/quantification/features/spots/export.py +163 -10
  11. senoquant/tabs/quantification/frontend.py +2 -2
  12. senoquant/tabs/segmentation/frontend.py +46 -9
  13. senoquant/tabs/segmentation/models/cpsam/model.py +1 -1
  14. senoquant/tabs/segmentation/models/default_2d/model.py +22 -77
  15. senoquant/tabs/segmentation/models/default_3d/model.py +8 -74
  16. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tools/create_zip_contents.py +0 -0
  17. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/probe.py +13 -13
  18. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/stardist_libs.py +171 -0
  19. senoquant/tabs/spots/frontend.py +42 -5
  20. senoquant/tabs/spots/models/ufish/details.json +17 -0
  21. senoquant/tabs/spots/models/ufish/model.py +129 -0
  22. senoquant/tabs/spots/ufish_utils/__init__.py +13 -0
  23. senoquant/tabs/spots/ufish_utils/core.py +357 -0
  24. senoquant/utils.py +1 -1
  25. senoquant-1.0.0b3.dist-info/METADATA +161 -0
  26. {senoquant-1.0.0b1.dist-info → senoquant-1.0.0b3.dist-info}/RECORD +41 -28
  27. {senoquant-1.0.0b1.dist-info → senoquant-1.0.0b3.dist-info}/top_level.txt +1 -0
  28. ufish/__init__.py +1 -0
  29. ufish/api.py +778 -0
  30. ufish/model/__init__.py +0 -0
  31. ufish/model/loss.py +62 -0
  32. ufish/model/network/__init__.py +0 -0
  33. ufish/model/network/spot_learn.py +50 -0
  34. ufish/model/network/ufish_net.py +204 -0
  35. ufish/model/train.py +175 -0
  36. ufish/utils/__init__.py +0 -0
  37. ufish/utils/img.py +418 -0
  38. ufish/utils/log.py +8 -0
  39. ufish/utils/spot_calling.py +115 -0
  40. senoquant/tabs/spots/models/rmp/details.json +0 -61
  41. senoquant/tabs/spots/models/rmp/model.py +0 -499
  42. senoquant/tabs/spots/models/udwt/details.json +0 -103
  43. senoquant/tabs/spots/models/udwt/model.py +0 -482
  44. senoquant-1.0.0b1.dist-info/METADATA +0 -193
  45. {senoquant-1.0.0b1.dist-info → senoquant-1.0.0b3.dist-info}/WHEEL +0 -0
  46. {senoquant-1.0.0b1.dist-info → senoquant-1.0.0b3.dist-info}/entry_points.txt +0 -0
  47. {senoquant-1.0.0b1.dist-info → senoquant-1.0.0b3.dist-info}/licenses/LICENSE +0 -0
@@ -1,499 +0,0 @@
1
- """RMP spot detector implementation."""
2
-
3
- from __future__ import annotations
4
-
5
- from contextlib import contextmanager
6
- from dataclasses import dataclass
7
- from typing import Iterable
8
-
9
- import numpy as np
10
- from scipy import ndimage as ndi
11
- from skimage.filters import threshold_otsu
12
- from skimage.measure import label
13
- from skimage.morphology import opening, rectangle
14
- from skimage.segmentation import watershed
15
- from skimage.feature import peak_local_max
16
- from skimage.transform import rotate
17
- from skimage.util import img_as_ubyte
18
-
19
- from ..base import SenoQuantSpotDetector
20
- from senoquant.utils import layer_data_asarray
21
-
22
- try:
23
- import dask.array as da
24
- except ImportError: # pragma: no cover - optional dependency
25
- da = None # type: ignore[assignment]
26
-
27
- try: # pragma: no cover - optional dependency
28
- from dask.distributed import Client, LocalCluster
29
- except ImportError: # pragma: no cover - optional dependency
30
- Client = None # type: ignore[assignment]
31
- LocalCluster = None # type: ignore[assignment]
32
-
33
- try: # pragma: no cover - optional dependency
34
- from dask_cuda import LocalCUDACluster
35
- except ImportError: # pragma: no cover - optional dependency
36
- LocalCUDACluster = None # type: ignore[assignment]
37
-
38
- try: # pragma: no cover - optional dependency
39
- import cupy as cp
40
- from cucim.skimage.filters import threshold_otsu as gpu_threshold_otsu
41
- from cucim.skimage.morphology import opening as gpu_opening, rectangle as gpu_rectangle
42
- from cucim.skimage.transform import rotate as gpu_rotate
43
- except ImportError: # pragma: no cover - optional dependency
44
- cp = None # type: ignore[assignment]
45
- gpu_threshold_otsu = None # type: ignore[assignment]
46
- gpu_opening = None # type: ignore[assignment]
47
- gpu_rectangle = None # type: ignore[assignment]
48
- gpu_rotate = None # type: ignore[assignment]
49
-
50
-
51
- Array2D = np.ndarray
52
-
53
-
54
- def _normalize_image(image: np.ndarray) -> np.ndarray:
55
- """Normalize an image to float32 in [0, 1]."""
56
- data = np.asarray(image, dtype=np.float32)
57
- min_val = float(data.min())
58
- max_val = float(data.max())
59
- if max_val <= min_val:
60
- return np.zeros_like(data, dtype=np.float32)
61
- data = (data - min_val) / (max_val - min_val)
62
- return np.clip(data, 0.0, 1.0)
63
-
64
-
65
- def _pad_for_rotation(image: Array2D) -> tuple[Array2D, tuple[int, int]]:
66
- """Pad image to preserve content after rotations."""
67
- nrows, ncols = image.shape[:2]
68
- diagonal = int(np.ceil(np.sqrt(nrows**2 + ncols**2)))
69
-
70
- rows_to_pad = int(np.ceil((diagonal - nrows) / 2))
71
- cols_to_pad = int(np.ceil((diagonal - ncols) / 2))
72
-
73
- padded_image = np.pad(
74
- image,
75
- ((rows_to_pad, rows_to_pad), (cols_to_pad, cols_to_pad)),
76
- mode="reflect",
77
- )
78
-
79
- return padded_image, (rows_to_pad, cols_to_pad)
80
-
81
-
82
- def _rmp_opening(
83
- input_image: Array2D,
84
- structuring_element: Array2D,
85
- rotation_angles: Iterable[int],
86
- ) -> Array2D:
87
- """Perform the RMP opening on an image."""
88
- padded_image, (newy, newx) = _pad_for_rotation(input_image)
89
- rotated_images = [
90
- rotate(padded_image, angle, mode="reflect") for angle in rotation_angles
91
- ]
92
- opened_images = [
93
- opening(image, footprint=structuring_element, mode="reflect")
94
- for image in rotated_images
95
- ]
96
- rotated_back = [
97
- rotate(image, -angle, mode="reflect")
98
- for image, angle in zip(opened_images, rotation_angles)
99
- ]
100
-
101
- stacked_images = np.stack(rotated_back, axis=0)
102
- union_image = np.max(stacked_images, axis=0)
103
- cropped = union_image[
104
- newy : newy + input_image.shape[0],
105
- newx : newx + input_image.shape[1],
106
- ]
107
- return cropped
108
-
109
-
110
- def _rmp_top_hat(
111
- input_image: Array2D,
112
- structuring_element: Array2D,
113
- rotation_angles: Iterable[int],
114
- ) -> Array2D:
115
- """Return the top-hat (background subtracted) image."""
116
- opened_image = _rmp_opening(input_image, structuring_element, rotation_angles)
117
- return input_image - opened_image
118
-
119
-
120
- def _compute_top_hat(input_image: Array2D, config: "RMPSettings") -> Array2D:
121
- """Compute the RMP top-hat response for a 2D image."""
122
- denoising_se = rectangle(1, config.denoising_se_length)
123
- extraction_se = rectangle(1, config.extraction_se_length)
124
- rotation_angles = tuple(range(0, 180, config.angle_spacing))
125
-
126
- working = (
127
- _rmp_opening(input_image, denoising_se, rotation_angles)
128
- if config.enable_denoising
129
- else input_image
130
- )
131
- return _rmp_top_hat(working, extraction_se, rotation_angles)
132
-
133
-
134
- def _binary_to_instances(mask: np.ndarray, start_label: int = 1) -> tuple[np.ndarray, int]:
135
- """Convert a binary mask to instance labels.
136
-
137
- Parameters
138
- ----------
139
- mask : numpy.ndarray
140
- Binary mask where foreground pixels are non-zero.
141
- start_label : int, optional
142
- Starting label index for the output. Defaults to 1.
143
-
144
- Returns
145
- -------
146
- numpy.ndarray
147
- Labeled instance mask.
148
- int
149
- Next label value after the labeled mask.
150
- """
151
- labeled = label(mask > 0)
152
- if start_label > 1 and labeled.max() > 0:
153
- labeled = labeled + (start_label - 1)
154
- next_label = int(labeled.max()) + 1
155
- return labeled.astype(np.int32, copy=False), next_label
156
-
157
-
158
- def _watershed_instances(
159
- image: np.ndarray,
160
- binary: np.ndarray,
161
- min_distance: int,
162
- ) -> np.ndarray:
163
- """Split touching spots using watershed segmentation."""
164
- if not np.any(binary):
165
- return np.zeros_like(binary, dtype=np.int32)
166
- if not np.any(~binary):
167
- labeled, _ = _binary_to_instances(binary)
168
- return labeled
169
-
170
- distance = ndi.distance_transform_edt(binary)
171
- coordinates = peak_local_max(
172
- distance,
173
- labels=binary.astype(np.uint8),
174
- min_distance=max(1, int(min_distance)),
175
- exclude_border=False,
176
- )
177
- if coordinates.size == 0:
178
- labeled, _ = _binary_to_instances(binary)
179
- return labeled
180
-
181
- peaks = np.zeros(binary.shape, dtype=bool)
182
- peaks[tuple(coordinates.T)] = True
183
- markers = label(peaks).astype(np.int32, copy=False)
184
- if markers.max() == 0:
185
- labeled, _ = _binary_to_instances(binary)
186
- return labeled
187
-
188
- labels = watershed(-distance, markers, mask=binary)
189
- return labels.astype(np.int32, copy=False)
190
-
191
-
192
- def _ensure_dask_available() -> None:
193
- """Ensure dask is installed for tiled execution."""
194
- if da is None: # pragma: no cover - import guard
195
- raise ImportError("dask is required for distributed spot detection.")
196
-
197
-
198
- def _ensure_distributed_available() -> None:
199
- """Ensure dask.distributed is installed for distributed execution."""
200
- if Client is None or LocalCluster is None: # pragma: no cover - import guard
201
- raise ImportError("dask.distributed is required for distributed execution.")
202
-
203
-
204
- def _ensure_cupy_available() -> None:
205
- """Ensure CuPy and cuCIM are installed for GPU execution."""
206
- if (
207
- cp is None
208
- or gpu_threshold_otsu is None
209
- or gpu_opening is None
210
- or gpu_rectangle is None
211
- or gpu_rotate is None
212
- ): # pragma: no cover - import guard
213
- raise ImportError("cupy + cucim are required for GPU execution.")
214
-
215
-
216
- def _dask_available() -> bool:
217
- """Return True when dask is available."""
218
- return da is not None
219
-
220
-
221
- def _distributed_available() -> bool:
222
- """Return True when dask.distributed is available."""
223
- return Client is not None and LocalCluster is not None and da is not None
224
-
225
-
226
- def _gpu_available() -> bool:
227
- """Return True when CuPy/cuCIM are available for GPU execution."""
228
- return (
229
- cp is not None
230
- and gpu_threshold_otsu is not None
231
- and gpu_opening is not None
232
- and gpu_rectangle is not None
233
- and gpu_rotate is not None
234
- and da is not None
235
- )
236
-
237
-
238
- def _recommended_overlap(config: "RMPSettings") -> int:
239
- """Derive a suitable overlap from structuring-element sizes."""
240
- lengths = [config.extraction_se_length]
241
- if config.enable_denoising:
242
- lengths.append(config.denoising_se_length)
243
- return max(1, max(lengths) * 2)
244
-
245
-
246
- @contextmanager
247
- def _cluster_client(use_gpu: bool):
248
- """Yield a connected Dask client backed by a local cluster."""
249
- _ensure_distributed_available()
250
-
251
- use_cuda_cluster = bool(use_gpu and cp is not None and LocalCUDACluster is not None)
252
- cluster_cls = LocalCUDACluster if use_cuda_cluster else LocalCluster
253
- with cluster_cls() as cluster: # type: ignore[call-arg]
254
- with Client(cluster) as client:
255
- yield client
256
-
257
-
258
- def _cpu_top_hat_block(block: np.ndarray, config: "RMPSettings") -> np.ndarray:
259
- """Return background-subtracted tile via the RMP top-hat pipeline."""
260
- denoising_se = rectangle(1, config.denoising_se_length)
261
- extraction_se = rectangle(1, config.extraction_se_length)
262
- rotation_angles = tuple(range(0, 180, config.angle_spacing))
263
-
264
- working = (
265
- _rmp_opening(block, denoising_se, rotation_angles)
266
- if config.enable_denoising
267
- else block
268
- )
269
- top_hat = working - _rmp_opening(working, extraction_se, rotation_angles)
270
- return np.asarray(top_hat, dtype=np.float32)
271
-
272
-
273
- def _gpu_pad_for_rotation(image: "cp.ndarray") -> tuple["cp.ndarray", tuple[int, int]]:
274
- nrows, ncols = image.shape[:2]
275
- diagonal = int(cp.ceil(cp.sqrt(nrows**2 + ncols**2)).item())
276
- rows_to_pad = int(cp.ceil((diagonal - nrows) / 2).item())
277
- cols_to_pad = int(cp.ceil((diagonal - ncols) / 2).item())
278
- padded = cp.pad(
279
- image,
280
- ((rows_to_pad, rows_to_pad), (cols_to_pad, cols_to_pad)),
281
- mode="reflect",
282
- )
283
- return padded, (rows_to_pad, cols_to_pad)
284
-
285
-
286
- def _gpu_rmp_opening(
287
- image: "cp.ndarray",
288
- structuring_element: "cp.ndarray",
289
- rotation_angles: Iterable[int],
290
- ) -> "cp.ndarray":
291
- padded, (newy, newx) = _gpu_pad_for_rotation(image)
292
- rotated = [gpu_rotate(padded, angle, mode="reflect") for angle in rotation_angles]
293
- opened = [
294
- gpu_opening(img, footprint=structuring_element, mode="reflect")
295
- for img in rotated
296
- ]
297
- rotated_back = [
298
- gpu_rotate(img, -angle, mode="reflect")
299
- for img, angle in zip(opened, rotation_angles)
300
- ]
301
-
302
- stacked = cp.stack(rotated_back, axis=0)
303
- union = cp.max(stacked, axis=0)
304
- return union[newy : newy + image.shape[0], newx : newx + image.shape[1]]
305
-
306
-
307
- def _gpu_top_hat(block: np.ndarray, config: "RMPSettings") -> np.ndarray:
308
- """CuPy-backed RMP top-hat for a single tile."""
309
- _ensure_cupy_available()
310
-
311
- gpu_block = cp.asarray(block, dtype=cp.float32)
312
- denoising_se = gpu_rectangle(1, config.denoising_se_length)
313
- extraction_se = gpu_rectangle(1, config.extraction_se_length)
314
- rotation_angles = tuple(range(0, 180, config.angle_spacing))
315
-
316
- working = (
317
- _gpu_rmp_opening(gpu_block, denoising_se, rotation_angles)
318
- if config.enable_denoising
319
- else gpu_block
320
- )
321
- top_hat = working - _gpu_rmp_opening(working, extraction_se, rotation_angles)
322
- return cp.asnumpy(top_hat).astype(np.float32, copy=False)
323
-
324
-
325
- def _rmp_top_hat_tiled(
326
- image: np.ndarray,
327
- config: "RMPSettings",
328
- chunk_size: tuple[int, int] = (1024, 1024),
329
- overlap: int | None = None,
330
- use_gpu: bool = False,
331
- distributed: bool = False,
332
- client: "Client | None" = None,
333
- ) -> np.ndarray:
334
- """Return the RMP top-hat image using tiled execution."""
335
- _ensure_dask_available()
336
- if use_gpu:
337
- _ensure_cupy_available()
338
-
339
- effective_overlap = _recommended_overlap(config) if overlap is None else overlap
340
-
341
- if use_gpu:
342
-
343
- def block_fn(block, block_info=None):
344
- return _gpu_top_hat(block, config)
345
-
346
- else:
347
-
348
- def block_fn(block, block_info=None):
349
- return _cpu_top_hat_block(block, config)
350
-
351
- arr = da.from_array(image.astype(np.float32, copy=False), chunks=chunk_size)
352
- result = arr.map_overlap(
353
- block_fn,
354
- depth=(effective_overlap, effective_overlap),
355
- boundary="reflect",
356
- dtype=np.float32,
357
- trim=True,
358
- )
359
-
360
- if distributed:
361
- _ensure_distributed_available()
362
- if client is None:
363
- with _cluster_client(use_gpu) as temp_client:
364
- return temp_client.compute(result).result()
365
- return client.compute(result).result()
366
-
367
- return result.compute()
368
-
369
-
370
- @dataclass(slots=True)
371
- class RMPSettings:
372
- """Configuration for the RMP detector."""
373
-
374
- denoising_se_length: int = 2
375
- extraction_se_length: int = 10
376
- angle_spacing: int = 5
377
- auto_threshold: bool = True
378
- manual_threshold: float = 0.05
379
- enable_denoising: bool = True
380
- use_3d: bool = False
381
-
382
- class RMPDetector(SenoQuantSpotDetector):
383
- """RMP spot detector implementation."""
384
-
385
- def __init__(self, models_root=None) -> None:
386
- super().__init__("rmp", models_root=models_root)
387
-
388
- def run(self, **kwargs) -> dict:
389
- """Run the RMP detector and return instance labels.
390
-
391
- Parameters
392
- ----------
393
- **kwargs
394
- layer : napari.layers.Image or None
395
- Image layer used for spot detection.
396
- settings : dict
397
- Detector settings keyed by the details.json schema.
398
-
399
- Returns
400
- -------
401
- dict
402
- Dictionary with ``mask`` key containing instance labels.
403
- """
404
- layer = kwargs.get("layer")
405
- if layer is None:
406
- return {"mask": None, "points": None}
407
- if getattr(layer, "rgb", False):
408
- raise ValueError("RMP requires single-channel images.")
409
-
410
- settings = kwargs.get("settings", {})
411
- manual_threshold = float(settings.get("manual_threshold", 0.5))
412
- manual_threshold = max(0.0, min(1.0, manual_threshold))
413
- config = RMPSettings(
414
- denoising_se_length=int(settings.get("denoising_kernel_length", 2)),
415
- extraction_se_length=int(settings.get("extraction_kernel_length", 10)),
416
- angle_spacing=int(settings.get("angle_spacing", 5)),
417
- auto_threshold=bool(settings.get("auto_threshold", True)),
418
- manual_threshold=manual_threshold,
419
- enable_denoising=bool(settings.get("enable_denoising", True)),
420
- use_3d=bool(settings.get("use_3d", False)),
421
- )
422
-
423
- if config.angle_spacing <= 0:
424
- raise ValueError("Angle spacing must be positive.")
425
- if config.denoising_se_length <= 0 or config.extraction_se_length <= 0:
426
- raise ValueError("Structuring element lengths must be positive.")
427
-
428
- data = layer_data_asarray(layer)
429
- if data.ndim not in (2, 3):
430
- raise ValueError("RMP expects 2D images or 3D stacks.")
431
-
432
- normalized = _normalize_image(data)
433
- if normalized.ndim == 3 and not config.use_3d:
434
- raise ValueError("Enable 3D to process stacks.")
435
-
436
- use_distributed = _distributed_available()
437
- use_gpu = _gpu_available()
438
- use_tiled = _dask_available() and (use_distributed or use_gpu)
439
-
440
- if normalized.ndim == 2:
441
- image_2d = normalized
442
- if use_tiled:
443
- top_hat = _rmp_top_hat_tiled(
444
- image_2d,
445
- config=config,
446
- use_gpu=use_gpu,
447
- distributed=use_distributed,
448
- )
449
- else:
450
- top_hat = _compute_top_hat(image_2d, config)
451
-
452
- threshold = (
453
- threshold_otsu(top_hat)
454
- if config.auto_threshold
455
- else config.manual_threshold
456
- )
457
- binary = img_as_ubyte(top_hat > threshold)
458
- labels = _watershed_instances(
459
- top_hat,
460
- binary > 0,
461
- min_distance=max(1, config.extraction_se_length // 2),
462
- )
463
- return {"mask": labels}
464
-
465
- top_hat_stack = np.zeros_like(normalized, dtype=np.float32)
466
- if use_tiled and use_distributed:
467
- with _cluster_client(use_gpu) as client:
468
- for z in range(normalized.shape[0]):
469
- top_hat_stack[z] = _rmp_top_hat_tiled(
470
- normalized[z],
471
- config=config,
472
- use_gpu=use_gpu,
473
- distributed=True,
474
- client=client,
475
- )
476
- elif use_tiled:
477
- for z in range(normalized.shape[0]):
478
- top_hat_stack[z] = _rmp_top_hat_tiled(
479
- normalized[z],
480
- config=config,
481
- use_gpu=use_gpu,
482
- distributed=False,
483
- )
484
- else:
485
- for z in range(normalized.shape[0]):
486
- top_hat_stack[z] = _compute_top_hat(normalized[z], config)
487
-
488
- threshold = (
489
- threshold_otsu(top_hat_stack)
490
- if config.auto_threshold
491
- else config.manual_threshold
492
- )
493
- binary_stack = img_as_ubyte(top_hat_stack > threshold)
494
- labels = _watershed_instances(
495
- top_hat_stack,
496
- binary_stack > 0,
497
- min_distance=max(1, config.extraction_se_length // 2),
498
- )
499
- return {"mask": labels}
@@ -1,103 +0,0 @@
1
- {
2
- "name": "udwt",
3
- "description": "Undecimated B3-spline wavelet spot detector",
4
- "version": "0.1.0",
5
- "order": 1,
6
- "settings": [
7
- {
8
- "key": "ld",
9
- "label": "Product threshold (ld)",
10
- "type": "float",
11
- "decimals": 2,
12
- "min": 0.0,
13
- "max": 10.0,
14
- "default": 1.0
15
- },
16
- {
17
- "key": "force_2d",
18
- "label": "Force 2D wavelets for 3D",
19
- "type": "bool",
20
- "default": false
21
- },
22
- {
23
- "key": "scale_1_enabled",
24
- "label": "Enable scale 1",
25
- "type": "bool",
26
- "default": true
27
- },
28
- {
29
- "key": "scale_1_sensitivity",
30
- "label": "Scale 1 sensitivity",
31
- "type": "float",
32
- "decimals": 1,
33
- "min": 1.0,
34
- "max": 100.0,
35
- "default": 100.0,
36
- "enabled_by": "scale_1_enabled"
37
- },
38
- {
39
- "key": "scale_2_enabled",
40
- "label": "Enable scale 2",
41
- "type": "bool",
42
- "default": true
43
- },
44
- {
45
- "key": "scale_2_sensitivity",
46
- "label": "Scale 2 sensitivity",
47
- "type": "float",
48
- "decimals": 1,
49
- "min": 1.0,
50
- "max": 100.0,
51
- "default": 100.0,
52
- "enabled_by": "scale_2_enabled"
53
- },
54
- {
55
- "key": "scale_3_enabled",
56
- "label": "Enable scale 3",
57
- "type": "bool",
58
- "default": true
59
- },
60
- {
61
- "key": "scale_3_sensitivity",
62
- "label": "Scale 3 sensitivity",
63
- "type": "float",
64
- "decimals": 1,
65
- "min": 1.0,
66
- "max": 100.0,
67
- "default": 100.0,
68
- "enabled_by": "scale_3_enabled"
69
- },
70
- {
71
- "key": "scale_4_enabled",
72
- "label": "Enable scale 4",
73
- "type": "bool",
74
- "default": false
75
- },
76
- {
77
- "key": "scale_4_sensitivity",
78
- "label": "Scale 4 sensitivity",
79
- "type": "float",
80
- "decimals": 1,
81
- "min": 1.0,
82
- "max": 100.0,
83
- "default": 100.0,
84
- "enabled_by": "scale_4_enabled"
85
- },
86
- {
87
- "key": "scale_5_enabled",
88
- "label": "Enable scale 5",
89
- "type": "bool",
90
- "default": false
91
- },
92
- {
93
- "key": "scale_5_sensitivity",
94
- "label": "Scale 5 sensitivity",
95
- "type": "float",
96
- "decimals": 1,
97
- "min": 1.0,
98
- "max": 100.0,
99
- "default": 100.0,
100
- "enabled_by": "scale_5_enabled"
101
- }
102
- ]
103
- }