Spaces:
Running
Running
Download utils/units.py from Angione-Lab/Shape2Force: direct link, hf CLI and curl.
- Browser
- Download file 5.31 kB
-
https://huggingface.co/spaces/Angione-Lab/Shape2Force/resolve/main/utils/units.py
- Command line
-
hf download hf://spaces/Angione-Lab/Shape2Force/utils/units.py
-
curl -L -o units.py https://huggingface.co/spaces/Angione-Lab/Shape2Force/resolve/main/utils/units.py
5.31 kB
| """ | |
| Physical units for S2F predictions. | |
| The network output is a normalised map y_hat in [0, 1]. The ground-truth traction-stress maps used for | |
| training were exported by the TFM software on a fixed colour scale, so | |
| traction stress T [Pa] = y_hat * STRESS_MAX_PA[model_type] (500 Pa single cells, 50 Pa spheroids). | |
| Areas and forces additionally need the pixel size of the bright-field image (µm per pixel, entered by the | |
| user). The prediction is made on a MODEL_INPUT_SIZE x MODEL_INPUT_SIZE grid, so one heatmap pixel covers | |
| (image width * pixel size / MODEL_INPUT_SIZE) µm in x, and likewise in y. | |
| area [µm²] = number of pixels * heatmap pixel area | |
| force [nN] = sum(T [Pa] * heatmap pixel area [µm²]) * 1e-3 (1 Pa · µm² = 1e-12 N = 1e-3 nN) | |
| """ | |
| import numpy as np | |
| from config.constants import MODEL_INPUT_SIZE, STRESS_MAX_PA | |
| def stress_max_pa(model_type): | |
| return STRESS_MAX_PA["spheroid" if model_type == "spheroid" else "single_cell"] | |
| def make_scale(model_type, image_um_per_px=None, image_shape=None, heatmap_shape=(MODEL_INPUT_SIZE, MODEL_INPUT_SIZE)): | |
| """ | |
| Physical scale of one prediction. | |
| image_um_per_px: pixel size of the uploaded bright-field image (µm/px); None or <= 0 if unknown. | |
| image_shape: (height, width) of the uploaded image, before resizing to the model input. | |
| Returns a dict used by the display, report and measurement code. | |
| """ | |
| smax = stress_max_pa(model_type) | |
| s = {"stress_max_pa": smax, "image_um_per_px": None, "image_shape": None, | |
| "hm_um_per_px_x": None, "hm_um_per_px_y": None, "hm_px_area_um2": None} | |
| if image_shape is not None: | |
| s["image_shape"] = (int(image_shape[0]), int(image_shape[1])) | |
| if image_um_per_px and image_um_per_px > 0 and image_shape is not None: | |
| h, w = image_shape[:2] | |
| hh, hw = heatmap_shape[:2] | |
| sx = image_um_per_px * w / hw | |
| sy = image_um_per_px * h / hh | |
| s.update(image_um_per_px=float(image_um_per_px), hm_um_per_px_x=sx, hm_um_per_px_y=sy, hm_px_area_um2=sx * sy) | |
| return s | |
| def has_physical_size(scale): | |
| return scale is not None and scale.get("hm_px_area_um2") is not None | |
| def to_pa(heatmap, scale): | |
| """Normalised map -> traction stress in Pa.""" | |
| return np.clip(np.asarray(heatmap, dtype=np.float64), 0, 1) * scale["stress_max_pa"] | |
| def region_metrics(heatmap, mask, scale): | |
| """ | |
| Metrics over a region (mask = 1) or the whole map (mask = None). | |
| Keys: n_px, area_um2, mean_pa, max_pa, total_force_nN (area and force are None without a pixel size). | |
| """ | |
| t = to_pa(heatmap, scale) | |
| m = np.ones(t.shape, dtype=bool) if mask is None else (np.asarray(mask) > 0) | |
| n = int(m.sum()) | |
| if n == 0: | |
| return {"n_px": 0, "area_um2": None, "mean_pa": None, "max_pa": None, "total_force_nN": None} | |
| vals = t[m] | |
| out = {"n_px": n, "mean_pa": float(vals.mean()), "max_pa": float(vals.max()), "area_um2": None, "total_force_nN": None} | |
| if has_physical_size(scale): | |
| a = scale["hm_px_area_um2"] | |
| out["area_um2"] = n * a | |
| out["total_force_nN"] = float(vals.sum()) * a * 1e-3 | |
| return out | |
| def fmt(value, unit="", nd=1): | |
| """Format a metric value; '–' when not available.""" | |
| if value is None: | |
| return "–" | |
| return f"{value:.{nd}f}{(' ' + unit) if unit else ''}" | |
| def metric_rows(metrics): | |
| """(label, formatted value) pairs, in the order used everywhere in the app.""" | |
| return [ | |
| ("Mean traction stress", fmt(metrics.get("mean_pa"), "Pa")), | |
| ("Max traction stress", fmt(metrics.get("max_pa"), "Pa")), | |
| ("Area", fmt(metrics.get("area_um2"), "µm²", 0)), | |
| ("Total force", fmt(metrics.get("total_force_nN"), "nN", 2)), | |
| ] | |
| def nice_scale_bar_um(field_width_um, fraction=0.2): | |
| """Scale-bar length (µm) from {1, 2, 5} x 10^k closest to `fraction` of the field width.""" | |
| target = field_width_um * fraction | |
| if target <= 0: | |
| return None | |
| k = np.floor(np.log10(target)) | |
| candidates = [m * 10 ** k for m in (1, 2, 5, 10)] | |
| return float(min(candidates, key=lambda c: abs(c - target))) | |
| def draw_scale_bar(rgb, um_per_px, color=(255, 255, 255), margin_frac=0.04): | |
| """Draw a µm scale bar (bottom right) on an RGB uint8 image in place. Returns (rgb, length_um).""" | |
| import cv2 | |
| if not um_per_px or um_per_px <= 0: | |
| return rgb, None | |
| h, w = rgb.shape[:2] | |
| length_um = nice_scale_bar_um(w * um_per_px) | |
| if length_um is None: | |
| return rgb, None | |
| length_px = int(round(length_um / um_per_px)) | |
| thick = max(2, h // 120) | |
| m = int(margin_frac * w) | |
| x1, y = w - m, h - m | |
| x0 = x1 - length_px | |
| cv2.rectangle(rgb, (x0 - 1, y - thick - 1), (x1 + 1, y + 1), (0, 0, 0), -1) | |
| cv2.rectangle(rgb, (x0, y - thick), (x1, y), color, -1) | |
| label = f"{length_um:g} um" # OpenCV fonts have no µ glyph | |
| font_scale = max(0.4, w / 1100) | |
| (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, font_scale, max(1, thick // 2)) | |
| tx, ty = x0 + (length_px - tw) // 2, y - thick - 6 | |
| cv2.putText(rgb, label, (tx, ty), cv2.FONT_HERSHEY_SIMPLEX, font_scale, (0, 0, 0), max(2, thick), cv2.LINE_AA) | |
| cv2.putText(rgb, label, (tx, ty), cv2.FONT_HERSHEY_SIMPLEX, font_scale, color, max(1, thick // 2), cv2.LINE_AA) | |
| return rgb, length_um | |