vulus98 commited on
Commit
08fff05
·
1 Parent(s): 307b067

Trim demo to Space-essentials and refresh example panoramas

Browse files

- Drop unused code paths from app.py (isolated-cluster removal, logger
setup, MODEL_TO_REPO alias table) — the Space only ever runs the
unified prs-eth/PaGeR checkpoint.
- Strip src/utils/utils.py and src/utils/geometry_utils.py down to the
helpers app.py actually imports.
- Swap the example set for the eleven panoramas shipped in the main
PaGeR repo (church / eth / library / medieval kitchen / quattro canti
/ zurich street corner / zurich tree intersection / etc.); drop the
legacy alice / fish_eagle_hill / library.png / little_paris / etc.

app.py CHANGED
@@ -9,16 +9,15 @@ CLIP ViT-B/32 classifier on the cubemap or force one head explicitly.
9
 
10
  Run with::
11
 
12
- python app.py --checkpoint_path <hf_repo_or_local_dir>
13
 
14
- Requires ``pip install -e .[app]``.
15
  """
16
 
17
  from __future__ import annotations
18
 
19
  import argparse
20
  import gc
21
- import logging
22
  import sys
23
  from io import BytesIO
24
  from pathlib import Path
@@ -46,7 +45,6 @@ from src.utils.geometry_utils import (
46
  compute_edge_mask,
47
  erp_to_cubemap,
48
  erp_to_pointcloud,
49
- remove_isolated_clusters_3d,
50
  )
51
  from src.utils.scene_classifier import get_classifier
52
  from src.utils.utils import (
@@ -55,11 +53,6 @@ from src.utils.utils import (
55
  )
56
 
57
 
58
- # ``remove_isolated_clusters_3d`` is the dominant cost in point-cloud building
59
- # (~75 %) but removes <0.1 % of points on a 2k x 4k panorama; the bulk of
60
- # flying points is already caught by ``compute_edge_mask``. Default to off for
61
- # interactive UX; flip to True for the cleanest possible cloud.
62
- POINTCLOUD_REMOVE_ISOLATED_CLUSTERS = False
63
  POINTCLOUD_DOWNSAMPLE_FACTOR = 2
64
 
65
  EXAMPLES_DIR = Path(__file__).parent / "examples"
@@ -73,23 +66,14 @@ MODE_AUTO, MODE_INDOOR, MODE_OUTDOOR = "Auto", "Indoor", "Outdoor"
73
  MODE_CHOICES = [MODE_AUTO, MODE_INDOOR, MODE_OUTDOOR]
74
  FORMAT_MAP, FORMAT_POINTCLOUD = "Map", "Point Cloud"
75
 
76
- # Short aliases for the released checkpoints; the demo only validates against
77
- # ``pager`` (the unified checkpoint), but the resolver below accepts any HF
78
- # repo id or local directory passed through ``--checkpoint``.
79
- MODEL_TO_REPO = {
80
- "pager": "prs-eth/PaGeR",
81
- "pager-metric-depth": "prs-eth/PaGeR-metric-depth",
82
- "pager-normals": "prs-eth/PaGeR-normals",
83
- }
84
 
85
 
86
  def parse_args() -> argparse.Namespace:
87
  parser = argparse.ArgumentParser(description="PaGeR Gradio demo.")
88
- parser.add_argument("--checkpoint", type=str, default="pager",
89
- help="Which checkpoint to run. Short alias "
90
- "(pager / pager-metric-depth / pager-normals), a "
91
- "HuggingFace Hub repo id, or a local directory. "
92
- "Default: pager (the unified prs-eth/PaGeR checkpoint).")
93
  return parser.parse_args()
94
 
95
 
@@ -226,15 +210,8 @@ def _ensure_pointclouds(cache: dict) -> None:
226
  keep_2d = (depth > 0) & np.asarray(edge_mask, dtype=bool)
227
  points = xyz[keep_2d]
228
 
229
- if POINTCLOUD_REMOVE_ISOLATED_CLUSTERS and len(points) > 0:
230
- inlier = remove_isolated_clusters_3d(points)
231
- points = points[inlier]
232
- else:
233
- inlier = None
234
-
235
  def _colors_from(color_image):
236
- sub = (np.clip(color_image[keep_2d], 0.0, 1.0) * 255.0).astype(np.uint8)
237
- return sub if inlier is None else sub[inlier]
238
 
239
  cache["rgb_pc_path"] = _export_glb(points, _colors_from(rgb_color))
240
  cache["normals_pc_path"] = _export_glb(points, _colors_from(normals_color))
@@ -334,24 +311,15 @@ def on_image_or_scene_change():
334
 
335
  args = parse_args()
336
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
337
-
338
- logger = logging.getLogger("simple")
339
- handler = logging.StreamHandler(sys.stdout)
340
- handler.setFormatter(logging.Formatter("%(message)s"))
341
- logger.addHandler(handler)
342
- logger.setLevel(logging.INFO)
343
- logger.propagate = False
344
  cmap = plt.get_cmap("Spectral")
345
 
346
- checkpoint_arg = MODEL_TO_REPO.get(args.checkpoint, args.checkpoint)
347
-
348
  try:
349
  checkpoint_config_path = hf_hub_download(
350
- repo_id=checkpoint_arg, filename="config.yaml"
351
  )
352
  checkpoint_path = Path(checkpoint_config_path).parent
353
  except Exception:
354
- checkpoint_path = Path(checkpoint_arg)
355
  checkpoint_config_path = checkpoint_path / "config.yaml"
356
 
357
  cfg = OmegaConf.load(checkpoint_config_path)
 
9
 
10
  Run with::
11
 
12
+ python app.py --checkpoint <hf_repo_or_local_dir>
13
 
14
+ Requires ``pip install -r requirements.txt``.
15
  """
16
 
17
  from __future__ import annotations
18
 
19
  import argparse
20
  import gc
 
21
  import sys
22
  from io import BytesIO
23
  from pathlib import Path
 
45
  compute_edge_mask,
46
  erp_to_cubemap,
47
  erp_to_pointcloud,
 
48
  )
49
  from src.utils.scene_classifier import get_classifier
50
  from src.utils.utils import (
 
53
  )
54
 
55
 
 
 
 
 
 
56
  POINTCLOUD_DOWNSAMPLE_FACTOR = 2
57
 
58
  EXAMPLES_DIR = Path(__file__).parent / "examples"
 
66
  MODE_CHOICES = [MODE_AUTO, MODE_INDOOR, MODE_OUTDOOR]
67
  FORMAT_MAP, FORMAT_POINTCLOUD = "Map", "Point Cloud"
68
 
69
+ DEFAULT_CHECKPOINT = "prs-eth/PaGeR"
 
 
 
 
 
 
 
70
 
71
 
72
  def parse_args() -> argparse.Namespace:
73
  parser = argparse.ArgumentParser(description="PaGeR Gradio demo.")
74
+ parser.add_argument("--checkpoint", type=str, default=DEFAULT_CHECKPOINT,
75
+ help="HuggingFace Hub repo id or local directory holding "
76
+ "config.yaml + model.safetensors. Default: prs-eth/PaGeR.")
 
 
77
  return parser.parse_args()
78
 
79
 
 
210
  keep_2d = (depth > 0) & np.asarray(edge_mask, dtype=bool)
211
  points = xyz[keep_2d]
212
 
 
 
 
 
 
 
213
  def _colors_from(color_image):
214
+ return (np.clip(color_image[keep_2d], 0.0, 1.0) * 255.0).astype(np.uint8)
 
215
 
216
  cache["rgb_pc_path"] = _export_glb(points, _colors_from(rgb_color))
217
  cache["normals_pc_path"] = _export_glb(points, _colors_from(normals_color))
 
311
 
312
  args = parse_args()
313
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
 
 
 
 
 
 
 
314
  cmap = plt.get_cmap("Spectral")
315
 
 
 
316
  try:
317
  checkpoint_config_path = hf_hub_download(
318
+ repo_id=args.checkpoint, filename="config.yaml"
319
  )
320
  checkpoint_path = Path(checkpoint_config_path).parent
321
  except Exception:
322
+ checkpoint_path = Path(args.checkpoint)
323
  checkpoint_config_path = checkpoint_path / "config.yaml"
324
 
325
  cfg = OmegaConf.load(checkpoint_config_path)
examples/apartment_synth.jpg CHANGED

Git LFS Details

  • SHA256: adc135d54b67aa529f03fd723229084ed9c4c5bfe49bf8f35fed0a55e9c9521f
  • Pointer size: 131 Bytes
  • Size of remote file: 280 kB

Git LFS Details

  • SHA256: 28227ac5e9d5c04d5a48da9588c541a80763838317c77de6304a99e3d35daf10
  • Pointer size: 131 Bytes
  • Size of remote file: 314 kB
examples/blue_photo_studio.jpg CHANGED

Git LFS Details

  • SHA256: b6df95336f4f961fcb4d77b2d01a8e61b59d0495ba82972ded49977f4e868044
  • Pointer size: 131 Bytes
  • Size of remote file: 554 kB

Git LFS Details

  • SHA256: 4c513319bd5990b4b9339219d415418a3ca178ce97bf9e90da2d04e93bee8857
  • Pointer size: 131 Bytes
  • Size of remote file: 654 kB
examples/{fish_eagle_hill.jpg → church_meeting_room.jpg} RENAMED
File without changes
examples/{library.png → eth_campus_plaza.jpg} RENAMED
File without changes
examples/{alice.jpg → library.jpg} RENAMED
File without changes
examples/little_paris_under_tower.jpg DELETED

Git LFS Details

  • SHA256: 4b42a09e7df8d79ecdbd4952e15d993766a445dd248539b74960377c40bbaf55
  • Pointer size: 133 Bytes
  • Size of remote file: 51.8 MB
examples/{the_lost_city.jpg → medieval_kitchen.jpg} RENAMED
File without changes
examples/peppermint_powerplant.jpg DELETED

Git LFS Details

  • SHA256: 76d9469e8979e316b8bb23084b44ddf33246617cff342f23fc6a687fbab996fd
  • Pointer size: 133 Bytes
  • Size of remote file: 71.6 MB
examples/quattro_canti.jpg ADDED

Git LFS Details

  • SHA256: 186d45269d9d3a4227bfb91029e74b870ad4cdc202bddcae3ca76334c97d3128
  • Pointer size: 132 Bytes
  • Size of remote file: 1.91 MB
examples/zurich_street_corner.jpg ADDED

Git LFS Details

  • SHA256: 149f28b94bc6cb22fdadfdafd32b5618998467bd1b5c20254a95297e475ac9aa
  • Pointer size: 132 Bytes
  • Size of remote file: 1.11 MB
examples/zurich_tree_intersection.jpg ADDED

Git LFS Details

  • SHA256: 4ce91adb4ad7444cf220c85ddaed25286a808ab64343a6d3d633cc4df8deaa02
  • Pointer size: 132 Bytes
  • Size of remote file: 1.37 MB
src/utils/geometry_utils.py CHANGED
@@ -1,7 +1,6 @@
1
  import math
2
  import torch
3
  import numpy as np
4
- import trimesh
5
  from pytorch360convert import e2c, c2e, e2p
6
 
7
 
@@ -120,44 +119,6 @@ def cubemap_to_erp(cube_tensor, erp_h=1024, erp_w=2048, fov=90.0, cube_format="s
120
  f"Unsupported cube_tensor shape {tuple(cube_tensor.shape)}. Expected (6, C, h, w) or (B, 6, C, h, w)."
121
  )
122
 
123
- def compute_scale_and_shift(pred_g, targ_g, mask_g=None, weights=None, eps=0.0, fit_shift=True):
124
- if mask_g is None:
125
- mask_g = torch.ones_like(pred_g, dtype=torch.bool)
126
-
127
- # Flatten to (B, N): works for (B, 1, H, W) and (B, 6, 1, H, W).
128
- # Use contiguous() before reshape to avoid returning a view of the caller's
129
- # tensor storage — an in-place op on the original after this call would
130
- # otherwise corrupt the autograd graph (AsStridedBackward version mismatch).
131
- B = pred_g.shape[0]
132
- pred_g = pred_g.contiguous().reshape(B, -1)
133
- targ_g = targ_g.contiguous().reshape(B, -1)
134
- mask_g = mask_g.contiguous().reshape(B, -1).to(dtype=pred_g.dtype)
135
- if weights is not None:
136
- weights = weights.contiguous().reshape(B, -1)
137
-
138
- # Apply weights to the mask
139
- mask_w = mask_g * weights if weights is not None else mask_g
140
-
141
- # Compute weighted summations → shape (B,)
142
- a_00 = torch.sum(mask_w * pred_g * pred_g, dim=1)
143
- a_01 = torch.sum(mask_w * pred_g, dim=1)
144
- a_11 = torch.sum(mask_w, dim=1)
145
- b_0 = torch.sum(mask_w * pred_g * targ_g, dim=1)
146
- b_1 = torch.sum(mask_w * targ_g, dim=1)
147
-
148
- if fit_shift:
149
- det = a_00 * a_11 - a_01 * a_01 + eps
150
- scale = torch.zeros_like(b_0)
151
- shift = torch.zeros_like(b_1)
152
- valid = det > 0
153
- scale[valid] = (a_11[valid] * b_0[valid] - a_01[valid] * b_1[valid]) / det[valid]
154
- shift[valid] = (-a_01[valid] * b_0[valid] + a_00[valid] * b_1[valid]) / det[valid]
155
- return scale, shift
156
- else:
157
- scale = b_0 / (a_00 + eps)
158
- return scale, torch.zeros_like(scale)
159
-
160
-
161
  def unit_normals(n, eps = 1e-6):
162
  assert n.dim() >= 3 and n.size(-3) == 3, "normals must have channel=3 at dim -3"
163
  denom = torch.clamp(torch.linalg.norm(n, dim=-3, keepdim=True), min=eps)
@@ -207,103 +168,6 @@ def z_depth_to_euclidean(z_depth, intrinsics):
207
  return out.squeeze(0) if squeeze_batch else out
208
 
209
 
210
- def remove_isolated_clusters_3d(points: np.ndarray,
211
- max_cluster_size: int = 500,
212
- connect_factor: float = 0.05,
213
- isolation_factor: float = 0.2,
214
- far_percentile: float = 90.0) -> np.ndarray:
215
- """Remove small clusters of far points that are isolated from the rest of the cloud.
216
-
217
- All distance thresholds are derived from the point cloud's own distance
218
- distribution, making the filter scale-invariant across different depth
219
- ranges (indoor, outdoor, different depth units, etc.).
220
-
221
- The reference scale is ``r_far = percentile(‖p‖, far_percentile)``. Both
222
- radii are expressed as fractions of it:
223
-
224
- * ``connect_radius = connect_factor * r_far``
225
- * ``isolation_radius = isolation_factor * r_far``
226
-
227
- Only points beyond ``far_percentile`` of the distance-from-origin
228
- distribution are considered candidates — the filter never touches nearby
229
- geometry. Among those far points, spatial connected components are formed
230
- using ``connect_radius``. Any component with ≤ ``max_cluster_size`` points
231
- whose closest distance to the *near* part of the cloud exceeds
232
- ``isolation_radius`` is removed.
233
-
234
- A cluster close to the main cloud (within ``isolation_radius``) is kept
235
- regardless of its size — it is likely valid distant geometry.
236
-
237
- Args:
238
- points: (N, 3) float32 XYZ array.
239
- max_cluster_size: clusters larger than this are always kept.
240
- connect_factor: cluster connection radius as a fraction of r_far.
241
- E.g. 0.05 → points within 5 % of r_far are connected.
242
- isolation_factor: isolation threshold as a fraction of r_far.
243
- E.g. 0.2 → cluster must be >20 % of r_far from the
244
- near cloud to be removed.
245
- far_percentile: distance percentile that separates "near" from "far".
246
-
247
- Returns:
248
- (N,) bool — True for points to keep.
249
- """
250
- from scipy.spatial import cKDTree
251
- from scipy.sparse import csr_matrix
252
- from scipy.sparse.csgraph import connected_components
253
-
254
- N = len(points)
255
- inlier = np.ones(N, dtype=bool)
256
-
257
- r = np.linalg.norm(points, axis=1)
258
- r_far = np.percentile(r, far_percentile)
259
-
260
- connect_radius = connect_factor * r_far
261
- isolation_radius = isolation_factor * r_far
262
-
263
- far_mask = r > r_far
264
- near_mask = ~far_mask
265
-
266
- far_points = points[far_mask]
267
- near_points = points[near_mask]
268
- far_idx = np.where(far_mask)[0]
269
-
270
- if len(far_points) < 2 or len(near_points) == 0:
271
- return inlier
272
-
273
- # --- Connected components among far points --------------------------
274
- far_tree = cKDTree(far_points)
275
- pairs = far_tree.query_pairs(connect_radius, output_type='ndarray')
276
-
277
- N_far = len(far_points)
278
- if len(pairs) > 0:
279
- rows = np.concatenate([pairs[:, 0], pairs[:, 1]])
280
- cols = np.concatenate([pairs[:, 1], pairs[:, 0]])
281
- adj = csr_matrix((np.ones(len(rows), dtype=np.float32), (rows, cols)),
282
- shape=(N_far, N_far))
283
- else:
284
- adj = csr_matrix((N_far, N_far))
285
-
286
- _, labels = connected_components(adj, directed=False)
287
- comp_sizes = np.bincount(labels)
288
- small_comp_ids = np.where(comp_sizes <= max_cluster_size)[0]
289
-
290
- if len(small_comp_ids) == 0:
291
- return inlier
292
-
293
- # --- Isolation check against the near cloud -------------------------
294
- near_tree = cKDTree(near_points)
295
-
296
- for comp_id in small_comp_ids:
297
- comp_local = labels == comp_id # index into far_points
298
- comp_pts = far_points[comp_local]
299
- # Distance from each cluster point to the nearest near-cloud point
300
- dists, _ = near_tree.query(comp_pts, k=1, workers=-1)
301
- if dists.min() > isolation_radius:
302
- inlier[far_idx[comp_local]] = False
303
-
304
- return inlier
305
-
306
-
307
  def compute_edge_mask(depth, abs_thresh = 0.1, rel_thresh = 0.1):
308
  assert depth.ndim == 2
309
  depth = depth.astype(np.float32, copy=False)
@@ -460,54 +324,3 @@ def get_cubemap_intrinsics_extrinsics(image_size=512, fov=90.0):
460
  extrinsics[:, 3, 3] = 1.0
461
 
462
  return extrinsics, intrinsics
463
-
464
-
465
- def erp_to_point_cloud_glb(rgb, depth, mask=None, export_path=None,
466
- remove_isolated_clusters: bool = True,
467
- cluster_max_size: int = 500,
468
- cluster_connect_factor: float = 0.05,
469
- cluster_isolation_factor: float = 0.2,
470
- cluster_far_percentile: float = 90.0):
471
- """Build and optionally export a trimesh GLB from ERP rgb + depth.
472
-
473
- rgb: (H, W, 3) float32 in [0, 1] (numpy or torch)
474
- depth: (H, W) float32 (numpy or torch)
475
- mask: (H, W) bool-like (numpy or torch), optional
476
- remove_isolated_clusters: if True, apply :func:`remove_isolated_clusters_3d`
477
- after projection to suppress flying/stranded far clusters. The
478
- ``cluster_*`` kwargs are forwarded to that function.
479
- """
480
- if isinstance(depth, torch.Tensor):
481
- depth = depth.detach().cpu().float().numpy()
482
- if isinstance(rgb, torch.Tensor):
483
- rgb = rgb.detach().cpu().float().numpy()
484
- if isinstance(mask, torch.Tensor):
485
- mask = mask.detach().cpu().numpy()
486
-
487
- depth = depth.astype(np.float32, copy=False)
488
- H, W = depth.shape
489
-
490
- xyz_np = erp_to_pointcloud(torch.from_numpy(depth)).permute(1, 2, 0).numpy() # (H, W, 3)
491
-
492
- keep = depth > 0
493
- if mask is not None:
494
- keep = keep & np.asarray(mask, dtype=bool)
495
-
496
- points = xyz_np[keep]
497
- colors = (np.clip(rgb, 0.0, 1.0) * 255.0).astype(np.uint8)[keep]
498
-
499
- if remove_isolated_clusters and len(points) > 0:
500
- inlier = remove_isolated_clusters_3d(
501
- points,
502
- max_cluster_size=cluster_max_size,
503
- connect_factor=cluster_connect_factor,
504
- isolation_factor=cluster_isolation_factor,
505
- far_percentile=cluster_far_percentile,
506
- )
507
- points = points[inlier]
508
- colors = colors[inlier]
509
-
510
- scene = trimesh.Scene()
511
- scene.add_geometry(trimesh.PointCloud(vertices=points, colors=colors))
512
- scene.export(export_path)
513
- return scene
 
1
  import math
2
  import torch
3
  import numpy as np
 
4
  from pytorch360convert import e2c, c2e, e2p
5
 
6
 
 
119
  f"Unsupported cube_tensor shape {tuple(cube_tensor.shape)}. Expected (6, C, h, w) or (B, 6, C, h, w)."
120
  )
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  def unit_normals(n, eps = 1e-6):
123
  assert n.dim() >= 3 and n.size(-3) == 3, "normals must have channel=3 at dim -3"
124
  denom = torch.clamp(torch.linalg.norm(n, dim=-3, keepdim=True), min=eps)
 
168
  return out.squeeze(0) if squeeze_batch else out
169
 
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  def compute_edge_mask(depth, abs_thresh = 0.1, rel_thresh = 0.1):
172
  assert depth.ndim == 2
173
  depth = depth.astype(np.float32, copy=False)
 
324
  extrinsics[:, 3, 3] = 1.0
325
 
326
  return extrinsics, intrinsics
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/utils/utils.py CHANGED
@@ -1,7 +1,5 @@
1
- """Lightweight logging helpers shared by ``inference.py``, ``app.py``, and the
2
- evaluation scripts. The training utilities (param-group builders, EMA, loss
3
- calculators, ...) live in the development repository and are not part of the
4
- release distribution.
5
  """
6
 
7
  from __future__ import annotations
@@ -9,51 +7,9 @@ from __future__ import annotations
9
  from typing import Tuple
10
 
11
  import numpy as np
12
- import torch
13
- from omegaconf import DictConfig, OmegaConf
14
- from pathlib import Path
15
  from scipy.ndimage import median_filter
16
 
17
 
18
- def args_to_omegaconf(args, base_cfg):
19
- """Overlay non-``None`` argparse values onto ``base_cfg`` in place.
20
-
21
- Keys are matched recursively by name across every nesting level of the
22
- config, so ``args.results_path`` will overwrite ``cfg.results_path`` no
23
- matter where it lives in the tree.
24
- """
25
- cfg = OmegaConf.create(base_cfg)
26
-
27
- def _override(container, key):
28
- if hasattr(args, key):
29
- value = getattr(args, key)
30
- if value is not None:
31
- container[key] = value
32
-
33
- def _walk(container):
34
- if not isinstance(container, DictConfig):
35
- return
36
- for key in container.keys():
37
- node = container[key]
38
- if isinstance(node, DictConfig):
39
- _walk(node)
40
- else:
41
- _override(container, key)
42
-
43
- _walk(cfg)
44
- return cfg
45
-
46
-
47
- def convert_paths_to_pathlib(cfg):
48
- """Cast any leaf key whose name contains ``"path"`` to ``pathlib.Path``."""
49
- for key, value in cfg.items():
50
- if isinstance(value, DictConfig):
51
- cfg[key] = convert_paths_to_pathlib(value)
52
- elif "path" in key.lower():
53
- cfg[key] = Path(value) if value is not None else None
54
- return cfg
55
-
56
-
57
  def prepare_image_for_logging(image: np.ndarray, apply_median: bool = False) -> np.ndarray:
58
  """Per-sample min/max stretch to ``uint8``, optionally median-filtered."""
59
  if apply_median:
 
1
+ """Preview helpers consumed by ``app.py`` to turn raw pager outputs into
2
+ uint8 images for the Gradio UI.
 
 
3
  """
4
 
5
  from __future__ import annotations
 
7
  from typing import Tuple
8
 
9
  import numpy as np
 
 
 
10
  from scipy.ndimage import median_filter
11
 
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  def prepare_image_for_logging(image: np.ndarray, apply_median: bool = False) -> np.ndarray:
14
  """Per-sample min/max stretch to ``uint8``, optionally median-filtered."""
15
  if apply_median: