33
44import numpy as np
55from numpy .linalg import lstsq
6+ from scipy .ndimage import gaussian_filter
67from scipy .special import i0
78
89
@@ -308,13 +309,13 @@ def segment_lengths(
308309 parent = edges [:, 1 ].astype (int ) - 1
309310
310311 density = np .zeros (nodes .shape [0 ], dtype = float )
311- mid = nodes .copy ()
312+ mid = nodes .copy ()
312313
313- vec = nodes [parent ] - nodes [child ]
314- seg_len = np .linalg .norm (vec , axis = 1 )
314+ vec = nodes [parent ] - nodes [child ]
315+ seg_len = np .linalg .norm (vec , axis = 1 )
315316
316- density [child ] = seg_len
317- mid [child ] = nodes [child ] + 0.5 * vec
317+ density [child ] = seg_len
318+ mid [child ] = nodes [child ] + 0.5 * vec
318319
319320 return density , mid
320321
@@ -336,27 +337,27 @@ def gridder1d(
336337 # Constants
337338 # ------------------------------------------------------------------
338339 alpha , W , err = 2 , 5 , 1e-3
339- S = int (np .ceil (0.91 / err / alpha ))
340+ S = int (np .ceil (0.91 / err / alpha ))
340341 beta = np .pi * np .sqrt ((W / alpha * (alpha - 0.5 ))** 2 - 0.8 )
341342
342343 # ------------------------------------------------------------------
343344 # Pre-computed Kaiser–Bessel lookup table (LUT)
344345 # ------------------------------------------------------------------
345- s = np .linspace (- 1 , 1 , 2 * S * W + 1 )
346- F_kbZ = i0 (beta * np .sqrt (1 - s ** 2 ))
346+ s = np .linspace (- 1 , 1 , 2 * S * W + 1 )
347+ F_kbZ = i0 (beta * np .sqrt (1 - s ** 2 ))
347348 F_kbZ /= F_kbZ .max ()
348349
349350 # ------------------------------------------------------------------
350351 # Fourier transform of the 1-D kernel
351352 # ------------------------------------------------------------------
352- Gz = alpha * n
353- z = np .arange (- Gz // 2 , Gz // 2 )
354- arg = (np .pi * W * z / Gz )** 2 - beta ** 2
353+ Gz = alpha * n
354+ z = np .arange (- Gz // 2 , Gz // 2 )
355+ arg = (np .pi * W * z / Gz )** 2 - beta ** 2
355356
356- kbZ = np .empty_like (arg , dtype = float )
357+ kbZ = np .empty_like (arg , dtype = float )
357358 pos , neg = arg > 1e-12 , arg < - 1e-12
358- kbZ [pos ] = np .sin (np .sqrt (arg [pos ])) / np .sqrt (arg [pos ])
359- kbZ [neg ] = np .sinh (np .sqrt (- arg [neg ])) / np .sqrt (- arg [neg ])
359+ kbZ [pos ] = np .sin (np .sqrt (arg [pos ])) / np .sqrt (arg [pos ])
360+ kbZ [neg ] = np .sinh (np .sqrt (- arg [neg ])) / np .sqrt (- arg [neg ])
360361 kbZ [~ (pos | neg )] = 1.0
361362 kbZ *= np .sqrt (Gz )
362363
@@ -367,13 +368,13 @@ def gridder1d(
367368 out = np .zeros (n_os , dtype = float )
368369
369370 centre = n_os / 2 + 1 # 1-based like MATLAB
370- nz = centre + n_os * z_samples # fractional indices
371+ nz = centre + n_os * z_samples # fractional indices
371372
372373 half_w = (W - 1 ) // 2
373374 for lz in range (- half_w , half_w + 1 ):
374375 nzt = np .round (nz + lz ).astype (int )
375376 zpos = S * ((nz - nzt ) + W / 2 )
376- kw = F_kbZ [np .round (zpos ).astype (int )]
377+ kw = F_kbZ [np .round (zpos ).astype (int )]
377378
378379 nzt = np .clip (nzt , 0 , n_os - 1 ) # clamp out-of-range
379380 np .add .at (out , nzt , density * kw )
@@ -384,12 +385,12 @@ def gridder1d(
384385 # myifft → de-apodise → abs(myfft3)
385386 # ------------------------------------------------------------------
386387 u = n
387- f = np .fft .ifftshift (np .fft .ifft (np .fft .ifftshift (out ))) * np .sqrt (u )
388- f = f [int (np .ceil ((f .size - u ) / 2 )) : int (np .ceil ((f .size + u ) / 2 ))]
388+ f = np .fft .ifftshift (np .fft .ifft (np .fft .ifftshift (out ))) * np .sqrt (u )
389+ f = f [int (np .ceil ((f .size - u ) / 2 )) : int (np .ceil ((f .size + u ) / 2 ))]
389390
390391 f /= kbZ [u // 2 : 3 * u // 2 ] # de-apodisation
391392
392- F = np .fft .fftshift (np .fft .fftn (np .fft .fftshift (f ))) / np .sqrt (f .size )
393+ F = np .fft .fftshift (np .fft .fftn (np .fft .fftshift (f ))) / np .sqrt (f .size )
393394 return np .abs (F )
394395
395396# =====================================================================
@@ -402,7 +403,7 @@ def get_zprofile(
402403 z_window : Optional [list [float ]] = None ,
403404 on_sac_pos : float = 0.0 ,
404405 off_sac_pos : float = 12.0 ,
405- grid_point_count : int = 120 ,
406+ nbins : int = 120 ,
406407) -> tuple [np .ndarray , np .ndarray , np .ndarray , dict ]:
407408 """
408409 Compute a 1-D depth profile (length per z-bin) from a warped arbor.
@@ -428,7 +429,7 @@ def get_zprofile(
428429 on_sac_pos, off_sac_pos
429430 Desired positions of the starburst layers in the *final* profile
430431 (µm). Defaults reproduce the numbers quoted in Sümbül et al. 2014.
431- grid_point_count
432+ nbins
432433 Number of evenly-spaced output bins along z.
433434
434435 Returns
@@ -445,7 +446,7 @@ def get_zprofile(
445446 """
446447
447448 # 0) decide the common span
448- dz_onoff = off_sac_pos - on_sac_pos # 12 µm by default
449+ dz_onoff = off_sac_pos - on_sac_pos # 12 µm by default
449450 if z_window is None :
450451 z_min , z_max = None , None # auto-span
451452 else :
@@ -456,36 +457,95 @@ def get_zprofile(
456457 warped_arbor ["edges" ])
457458
458459 vz_on , vz_off = warped_arbor ["medVZmin" ], warped_arbor ["medVZmax" ]
459- rel_depth = (nodes [:, 2 ] / z_res - vz_on ) / (vz_off - vz_on ) # 0→ON, 1→OFF
460- z_phys = on_sac_pos + rel_depth * dz_onoff # µm in global frame
460+ rel_depth = (nodes [:, 2 ] / z_res - vz_on ) / (vz_off - vz_on ) # 0→ON, 1→OFF
461+ z_phys = on_sac_pos + rel_depth * dz_onoff # µm in global frame
461462
462463
463464 # 2) decide bin edges *once*
464465 if z_min is None or z_max is None :
465466 # grow just enough to contain this cell, then round to one bin
466- z_min = np .floor (z_phys .min () / dz_onoff * grid_point_count ) * dz_onoff / grid_point_count
467- z_max = np .ceil (z_phys .max () / dz_onoff * grid_point_count ) * dz_onoff / grid_point_count
467+ z_min = np .floor (z_phys .min () / dz_onoff * nbins ) * dz_onoff / nbins
468+ z_max = np .ceil (z_phys .max () / dz_onoff * nbins ) * dz_onoff / nbins
468469
469- bin_edges = np .linspace (z_min , z_max , grid_point_count + 1 )
470+ bin_edges = np .linspace (z_min , z_max , nbins + 1 )
470471
471472 # 3) histogram-based z profile
472473 z_hist , _ = np .histogram (z_phys , bins = bin_edges , weights = density )
473- z_hist *= density .sum () / (z_hist .sum () * z_res )
474+ z_hist *= density .sum () / (z_hist .sum () * z_res )
474475
475476
476477 # 4) Kaiser–Bessel gridded version (needs centred –0.5…0.5 inputs)
477- centre = (z_min + z_max ) / 2
478+ centre = (z_min + z_max ) / 2
478479 halfspan = (z_max - z_min ) / 2
479480 z_samples = (z_phys - centre ) / halfspan # now in [-1, 1]
480481
481- z_dist = gridder1d (z_samples / 2 , density , grid_point_count ) # /2 → [-0.5, 0.5]
482- z_dist *= density .sum () / (z_dist .sum () * z_res )
482+ z_dist = gridder1d (z_samples / 2 , density , nbins ) # /2 → [-0.5, 0.5]
483+ z_dist *= density .sum () / (z_dist .sum () * z_res )
483484
484485 # 5) bin centres & rescaled arbor
485- x_um = 0.5 * (bin_edges [1 :] + bin_edges [:- 1 ]) # centre of each bin
486+ x_um = 0.5 * (bin_edges [1 :] + bin_edges [:- 1 ]) # centre of each bin
486487
487488 nodes_norm = warped_arbor ["nodes" ].copy ()
488489 nodes_norm [:, 2 ] = z_phys
489490 normed_arbor = {** warped_arbor , "nodes" : nodes_norm }
490491
491492 return x_um , z_dist , z_hist , normed_arbor
493+
494+ def get_xyprofile (
495+ warped_arbor : dict ,
496+ xy_window : Optional [list [float ]] = None ,
497+ nbins : int = 20 ,
498+ sigma_bins : float = 1.0 ,
499+ ) -> tuple [np .ndarray , np .ndarray , np .ndarray , np .ndarray ]:
500+ """
501+ 2-D dendritic-length density on a fixed XY grid (no per-cell rotation).
502+
503+ Parameters
504+ ----------
505+ warped_arbor
506+ output of ``warp_arbor()`` (nodes in µm).
507+ xy_window
508+ (xmin, xmax, ymin, ymax) in µm that *all* cells
509+ share. If ``None`` use this arbor's tight bounding box.
510+ nbins
511+ number of bins along X **and** Y (default 20).
512+
513+ Returns
514+ -------
515+ x_um: (nbins,) µm
516+ bin centres along X
517+ y_um: (nbins,) µm
518+ bin centres along Y
519+ xy_dist: (nbins, nbins) µm
520+ smoothed dendritic length per bin
521+ xy_hist: (nbins, nbins) µm
522+ histogram-based dendritic length per bin
523+ """
524+
525+ # 1) edge lengths and mid-points (same helper you already have)
526+ density , mid = segment_lengths (warped_arbor ["nodes" ],
527+ warped_arbor ["edges" ])
528+
529+ # 2) decide the common window
530+ if xy_window is None :
531+ xmin , xmax = mid [:, 0 ].min (), mid [:, 0 ].max ()
532+ ymin , ymax = mid [:, 1 ].min (), mid [:, 1 ].max ()
533+ else :
534+ xmin , xmax , ymin , ymax = xy_window
535+
536+ # 3) 2-D histogram weighted by edge length and density
537+ xy_hist , x_edges , y_edges = np .histogram2d (
538+ mid [:, 0 ], mid [:, 1 ],
539+ bins = [nbins , nbins ],
540+ range = [[xmin , xmax ], [ymin , ymax ]],
541+ weights = density
542+ )
543+
544+ xy_dist = gaussian_filter (xy_hist , sigma = sigma_bins , mode = 'nearest' )
545+ xy_dist *= density .sum () / xy_dist .sum () # keep Σ = total length
546+
547+ # 5) bin centres for plotting
548+ x = 0.5 * (x_edges [:- 1 ] + x_edges [1 :])
549+ y = 0.5 * (y_edges [:- 1 ] + y_edges [1 :])
550+
551+ return x , y , xy_dist , xy_hist
0 commit comments