Repository navigation
Add OptogenSIMLight: Monte Carlo–based light model #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 32 commits
Commits
Show all changes
42 commits
Select commit
Hold shift + click to select a range
14aa1b8
Add OptogenSIMLight prototype and compressed 4D dataset
akhilsnarra-ui 57bfebe
Store dataset as gzipped NetCDF3 (scipy backend, no new dependency)
akhilsnarra-ui 3d89dd8
Load packaged data via importlib.resources; include data file in Poet…
akhilsnarra-ui 3abb054
Add OptogenSIMLight to light visualization notebook
akhilsnarra-ui 3db06a3
Add dedicated OptogenSIMLight transmittance test and viz_params cover…
akhilsnarra-ui 9317a61
Add OptogenSIMLight demonstration to multi-opto tutorial
akhilsnarra-ui bed38a7
Expose real backscatter behind source in OptogenSIMLight
akhilsnarra-ui df44ed5
Regenerate dataset with per-wavelength tissue fix
akhilsnarra-ui 4629a3f
Re-run light_viz notebook with regenerated dataset
akhilsnarra-ui fa6aeed
Replace OpticFiber with OptogenSIMLight in multi-opto tutorial
akhilsnarra-ui e684943
Rename optogensim_light.py to optogensim.py per review
akhilsnarra-ui 80fb94e
Clean notebooks: remove change markers, strip execution counts, fresh…
akhilsnarra-ui f2a58d6
Restructure OptogenSIM: data as field, derived properties, add defocu…
akhilsnarra-ui 2329245
Re-run notebooks against renamed OptogenSIM class
akhilsnarra-ui e26da6c
Update OptogenSIM reference in profile explorer notebook
akhilsnarra-ui 06efe53
Add OptogenSIM to transmittance test; add beam_radius and wavelength …
akhilsnarra-ui 62c6ea9
Tune multi_opto light amplitude to 0.6 mW/mm2 for OptogenSIM
akhilsnarra-ui aa6acab
Reduce docstring duplication in defocused_gaussian_beam
akhilsnarra-ui 069a4fb
Remove unused enphr3 opsin from multi_opto tutorial
akhilsnarra-ui f88c835
Move wavelength from Light to LightModel
akhilsnarra-ui 56d081b
Fix two_photon call site missed in wavelength refactor
akhilsnarra-ui 3cf2aec
Address review on optogensim.py + fix light_viz wavelength calls
akhilsnarra-ui d280664
Extend beam-width transmittance test to fiber and GaussianEllipsoid
akhilsnarra-ui 8b2fc0e
Apply ruff formatting to optogensim.py
akhilsnarra-ui 81dd5e5
Tune multi_opto amplitude to 0.45 and use np.ptp for trace spacing
akhilsnarra-ui 47a6540
Use OptogenSIM LightModel in profile explorer; fix heatmap aspect ratio
akhilsnarra-ui 838a676
Add OptogenSIM fork link to data docstring
akhilsnarra-ui d6319a0
Silence brian2 warning for discarded light-source objects
akhilsnarra-ui 9949c01
tweak visualization
kjohnsen 5e10b0e
format tests
kjohnsen b42b63b
Merge remote-tracking branch 'origin/master' into light-model-improve…
akhilsnarra-ui 3b60daf
Address final review comments
akhilsnarra-ui 9b0e017
Warn when OptogenSIM targets fall outside the data range
akhilsnarra-ui 24e74f1
Zero OptogenSIM transmittance outside data range
kjohnsen 5bdc7b4
Fix CI: drop setup-python, let setup-uv provide Python
kjohnsen 8ff3df5
Test OptogenSIM packaged data is peak-normalized to 1
kjohnsen e51e7ff
Use setup-uv@v7 (no v10 major tag exists)
kjohnsen 3213f9e
CI: prefer uv-managed Python (runner Pythons are PEP 668 locked)
kjohnsen 4fc4657
CI: setup-uv v7 cancels on all runners, use known-good v5
kjohnsen 193d00e
CI: install into uv venv instead of system python
kjohnsen 69c46e4
CI: drop empty env block (invalid workflow)
kjohnsen 6e8b96b
CI: setup-uv already provides .venv, drop manual uv venv
kjohnsen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -153,4 +153,6 @@ dmypy.json | |
| .pytype/ | ||
|
|
||
| # Cython debug symbols | ||
| cython_debug/ | ||
| cython_debug/ | ||
| .DS_Store | ||
| **/.DS_Store | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
Binary file not shown.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
|
akhilsnarra-ui marked this conversation as resolved.
kjohnsen marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| from functools import cached_property | ||
| from importlib.resources import files | ||
|
|
||
| import numpy as np | ||
| import xarray as xr | ||
| from attrs import define, field | ||
| from brian2.units import Quantity, cm, nmeter, um | ||
|
|
||
| from cleo.coords import coords_from_xyz | ||
| from cleo.light.light import LightModel | ||
| from cleo.utilities import uniform_cylinder_rθz, xyz_from_rθz | ||
|
|
||
|
|
||
| @define | ||
| class OptogenSIM(LightModel): | ||
| """Light model from OptogenSIM Monte Carlo simulations.""" | ||
|
|
||
| beam_radius: Quantity = 100 * um | ||
| """Beam (1/e^2) radius. Must be within the simulated grid range.""" | ||
| data: xr.DataArray = field(default=None, repr=False) | ||
| """The 4D transmittance dataset. Defaults to the dataset packaged with Cleo. | ||
|
|
||
| To supply your own, pass an :class:`xarray.DataArray` with dimensions | ||
| ``(wavelength, beam_size, r, z)`` in that order, with coordinate values in | ||
| these units: ``wavelength`` in nm, ``beam_size`` (the beam radius) in µm, | ||
| and ``r`` and ``z`` in cm, where ``z`` is measured from the top of the | ||
| simulated tissue volume. Values are transmittance in [0, 1]. The packaged | ||
| dataset spans wavelengths 380-740 nm (30 nm steps) and beam radii of 10, 20, | ||
| 100, 200, 400, and 800 µm; it was generated with a modified version of the | ||
| OptogenSIM Monte Carlo model (https://github.com/akhilsnarra-ui/optogensim-cleo) sweeping over | ||
| wavelength and beam radius.""" | ||
|
|
||
| def __attrs_post_init__(self): | ||
| if self.data is None: | ||
| path = str(files("cleo.light.data") / "light_model_4d.nc.gz") | ||
| self.data = xr.open_dataarray(path, engine="scipy") | ||
|
|
||
| @cached_property | ||
| def _rz_slice(self): | ||
| rz = self.data.interp( | ||
| wavelength=self.wavelength / nmeter, beam_size=self.beam_radius / um | ||
|
akhilsnarra-ui marked this conversation as resolved.
|
||
| ) | ||
| # data measures z from the atlas top; re-zero to the source | ||
| z_source = float(rz.isel(r=0).z[rz.isel(r=0).argmax("z")]) | ||
|
akhilsnarra-ui marked this conversation as resolved.
|
||
| return rz.assign_coords(z=rz.z - z_source) | ||
|
|
||
| @cached_property | ||
| def _r_range(self): | ||
| return (float(self._rz_slice.r.min()), float(self._rz_slice.r.max())) | ||
|
|
||
| @cached_property | ||
| def _z_range(self): | ||
| return (float(self._rz_slice.z.min()), float(self._rz_slice.z.max())) | ||
|
|
||
| def transmittance(self, source_coords, source_dir_uvec, target_coords): | ||
| assert np.allclose(np.linalg.norm(source_dir_uvec, axis=-1), 1) | ||
| r, z = self._get_rz_for_xyz(source_coords, source_dir_uvec, target_coords) | ||
| r_cm = np.asarray(r / cm) | ||
| z_cm = np.asarray(z / cm) | ||
| T = self._rz_slice.interp( | ||
| r=xr.DataArray(np.clip(r_cm, *self._r_range)), | ||
| z=xr.DataArray(np.clip(z_cm, *self._z_range)), | ||
| ).values | ||
| T = np.nan_to_num(T, nan=0.0) | ||
| T[z_cm < self._z_range[0]] = ( | ||
| 0 # zero only beyond data range; keep real backscatter within it | ||
| ) | ||
| return T | ||
|
|
||
| @property | ||
| def area0(self): | ||
| return np.pi * self.beam_radius**2 | ||
|
|
||
| def viz_params( | ||
| self, coords, direction, T_threshold, n_points_per_source=1.5e5, **kwargs | ||
| ): | ||
| r_thresh, zc_thresh, zc_back = self._find_rz_thresholds(T_threshold) | ||
| # cylinder spans from behind the source (zc_back < 0) through zc_thresh | ||
| total_length = zc_thresh - zc_back | ||
| r, theta, zc = uniform_cylinder_rθz(n_points_per_source, r_thresh, total_length) | ||
| start = coords + zc_back * direction | ||
| end = coords + zc_thresh * direction | ||
| x, y, z = xyz_from_rθz(r, theta, zc, start, end) | ||
| density_factor = 3 | ||
| cyl_vol = np.pi * r_thresh**2 * total_length | ||
| markersize = (cyl_vol / n_points_per_source * density_factor) ** (1 / 3) | ||
| intensity_scale = 1.5 * (4e3 / n_points_per_source) ** (1 / 3) | ||
| return coords_from_xyz(x, y, z), markersize, intensity_scale | ||
|
|
||
| def _find_rz_thresholds(self, thresh): | ||
| """Find r and z extents for visualization at the given transmittance threshold.""" | ||
| # On-axis (r[0]) profile along z (re-zeroed so source at z=0). | ||
| on_axis = self._rz_slice.isel(r=0) | ||
| z_vals = on_axis.z.values # cm, source at 0 | ||
| T_z = on_axis.values | ||
|
|
||
| # Forward extent (z >= 0): first z where T drops below threshold. | ||
| fwd = z_vals >= 0 | ||
| z_pos, T_pos = z_vals[fwd], T_z[fwd] | ||
| below = np.where(T_pos < thresh)[0] | ||
| zc_thresh = (z_pos[below[0]] if len(below) > 0 else z_pos[-1]) * cm | ||
|
|
||
| # Backward extent (z < 0): most-negative z still above threshold, | ||
| # so backscatter behind the source is included in the visualization. | ||
| back = z_vals < 0 | ||
| z_neg, T_neg = z_vals[back], T_z[back] | ||
| above_back = z_neg[T_neg >= thresh] | ||
| zc_back = (float(above_back.min()) if len(above_back) > 0 else 0.0) * cm | ||
|
|
||
| # r threshold at the forward midpoint. | ||
| z_mid = float(zc_thresh / cm) / 2 | ||
| z_mid = np.clip( | ||
| z_mid, float(self._rz_slice.z.min()), float(self._rz_slice.z.max()) | ||
| ) | ||
| r_vals = self._rz_slice.r.values | ||
| T_r = self._rz_slice.interp(z=float(z_mid)).values | ||
| below_r = np.where(T_r < thresh)[0] | ||
| r_thresh = (r_vals[below_r[0]] if len(below_r) > 0 else r_vals[-1]) * cm | ||
|
|
||
| return r_thresh * 1.2, zc_thresh, zc_back | ||
|
|
||
|
|
||
| def defocused_gaussian_beam( | ||
| wavelength: Quantity = 473 * nmeter, | ||
| beam_radius: Quantity = 100 * um, | ||
| ) -> OptogenSIM: | ||
| """Construct an :class:`OptogenSIM` model for a defocused Gaussian beam. | ||
|
|
||
| This is the default OptogenSIM profile: light emitted from a defocused | ||
| Gaussian beam source, propagating through gray matter, using the Monte | ||
| Carlo dataset packaged with Cleo. ``wavelength`` and ``beam_radius`` are | ||
| as documented on :class:`OptogenSIM`. To use a custom dataset, construct | ||
| :class:`OptogenSIM` directly and pass your own ``data`` array. | ||
| """ | ||
| return OptogenSIM(wavelength=wavelength, beam_radius=beam_radius) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
|
akhilsnarra-ui marked this conversation as resolved.
kjohnsen marked this conversation as resolved.
kjohnsen marked this conversation as resolved.
kjohnsen marked this conversation as resolved.
|
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
|
kjohnsen marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| import marimo | ||
|
|
||
| __generated_with = "0.23.14" | ||
| app = marimo.App(width="medium") | ||
|
|
||
|
|
||
| @app.cell | ||
| def _(): | ||
| return | ||
|
|
||
|
|
||
| @app.cell | ||
| def _(mo): | ||
| mo.md(""" | ||
| # OptogenSIM profile explorer | ||
|
|
||
| Interactive 2D cross-sections of the OptogenSIM Monte Carlo light model. | ||
| Drag the sliders to set a wavelength and beam radius and see how light | ||
| propagates through gray matter. The heatmap shows log₁₀ transmittance in | ||
| the (r, z) plane, mirrored across the beam axis. z is depth from the | ||
| source; r is radial distance. | ||
|
|
||
| *Note: values between simulated grid points (wavelengths every 30 nm; | ||
| beam radii 10, 20, 100, 200, 400, 800 µm) are linearly interpolated. | ||
| """) | ||
| return | ||
|
|
||
|
|
||
| @app.cell | ||
| def _(): | ||
| import numpy as np | ||
| import plotly.graph_objects as go | ||
|
kjohnsen marked this conversation as resolved.
|
||
| from cleo.light import OptogenSIM | ||
| from brian2 import mm, nmeter, um | ||
|
|
||
| return OptogenSIM, go, mm, nmeter, np, um | ||
|
|
||
|
|
||
| @app.cell | ||
| def _(OptogenSIM): | ||
| _m = OptogenSIM() # default instance, just to read the packaged data grid | ||
| wl_vals = _m.data.wavelength.values | ||
| bs_vals = _m.data.beam_size.values | ||
| return bs_vals, wl_vals | ||
|
|
||
|
|
||
| @app.cell | ||
| def _(bs_vals, mo, wl_vals): | ||
| wavelength = mo.ui.slider( | ||
| start=float(wl_vals.min()), | ||
| stop=float(wl_vals.max()), | ||
| step=5, | ||
| value=470.0, | ||
| label="Wavelength (nm)", | ||
| show_value=True, | ||
| ) | ||
| beam = mo.ui.slider( | ||
| start=float(bs_vals.min()), | ||
| stop=float(bs_vals.max()), | ||
| step=10, | ||
| value=200.0, | ||
| label="Beam radius (µm)", | ||
| show_value=True, | ||
| ) | ||
| mo.hstack([wavelength, beam], justify="start") | ||
| return beam, wavelength | ||
|
|
||
|
|
||
| @app.cell | ||
| def _(OptogenSIM, beam, go, mm, nmeter, np, um, wavelength): | ||
| model = OptogenSIM( | ||
| wavelength=wavelength.value * nmeter, | ||
| beam_radius=beam.value * um, | ||
| ) | ||
|
|
||
| # build an (r, z) grid of target points, as a user would: real coordinates | ||
| # with brian2 units. Source at origin pointing in +z. | ||
| r_mm = np.linspace(0, 3, 120) | ||
| z_mm = np.linspace(-1, 6, 200) | ||
| R, Z = np.meshgrid(r_mm, z_mm, indexing="ij") | ||
| grid_coords = np.stack([R.ravel(), np.zeros(R.size), Z.ravel()], axis=-1) * mm | ||
|
|
||
| source = np.array([0, 0, 0]) * mm | ||
| direction = np.array([0, 0, 1.0]) | ||
|
|
||
| T = model.transmittance(source, direction, grid_coords).reshape(R.shape) | ||
|
|
||
| # normalize to peak for display (colorbar is log10 T/peak) | ||
|
|
||
| logT = np.log10(np.clip(T, 1e-4, None)) | ||
|
|
||
| # mirror across the beam axis for a symmetric view | ||
| r_full = np.concatenate([-r_mm[::-1], r_mm]) | ||
| logT_full = np.vstack([logT[::-1, :], logT]) | ||
|
|
||
| fig = go.Figure( | ||
| data=go.Heatmap( | ||
| x=z_mm, | ||
| y=r_full, | ||
| z=logT_full, | ||
| colorscale="Viridis", | ||
| zmin=-4, zmax=0, | ||
| colorbar=dict(title="log₁₀ T"), | ||
| ) | ||
| ) | ||
| fig.update_layout( | ||
| title=f"{wavelength.value:.0f} nm, {beam.value:.0f} µm beam radius", | ||
| xaxis_title="z, depth from source (mm)", | ||
| yaxis_title="r, radial distance (mm)", | ||
| width=750, height=450, | ||
|
kjohnsen marked this conversation as resolved.
Outdated
|
||
| yaxis=dict(scaleanchor="x", scaleratio=1), | ||
| ) | ||
| fig.update_xaxes(range=[float(z_mm.min()), min(float(z_mm.max()), 3)]) | ||
| fig | ||
| return | ||
|
|
||
|
|
||
| @app.cell | ||
| def _(): | ||
| import marimo as mo | ||
|
|
||
| return (mo,) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| app.run() | ||
|
kjohnsen marked this conversation as resolved.
|
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.