Skip to content

Commit 2de351b

Browse files
committed
feat(nice-lite): add abinitio3D volume viewer
1 parent 833ae6c commit 2de351b

14 files changed

Lines changed: 1373 additions & 4 deletions

File tree

nice/nice_lite/data_structures/batchjob.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@
2525
from .class_selection import ClassSelectionError, SIMPLEProjectFileReader
2626
from .simple import SIMPLEBatch, SIMPLEProjFile, SIMPLEProject
2727
from .job import Job
28-
from .mrc import read_mrc_stack_info, render_mrc_particle_png
28+
from .mrc import (
29+
read_mrc_stack_info,
30+
read_mrc_volume_info,
31+
render_mrc_particle_png,
32+
)
2933
from .movie import render_movie_webp
3034
from .workspace import Workspace
3135

@@ -660,6 +664,89 @@ def get_artifact_summary(self, max_previews=12):
660664
"images": images,
661665
}
662666

667+
def get_volume_outputs(self):
668+
"""Return project-declared, owned ab initio 3D density volumes."""
669+
if self.prog != "abinitio3D" or self.status != "finished":
670+
return []
671+
672+
job_dir = self.get_safe_job_dir()
673+
result_project = self.get_result_project_path()
674+
if job_dir is None or result_project is None:
675+
return []
676+
try:
677+
records = SIMPLEProjectFileReader(result_project).read_records("out")
678+
except (ClassSelectionError, OSError, OverflowError, struct.error):
679+
return []
680+
681+
outputs = []
682+
seen_paths = set()
683+
for record in records:
684+
if not isinstance(record, dict) or record.get("imgkind") != "vol":
685+
continue
686+
declared_path = record.get("vol")
687+
if not isinstance(declared_path, str) or not declared_path.strip():
688+
continue
689+
declared_path = declared_path.strip()
690+
if not os.path.isabs(declared_path):
691+
declared_path = os.path.join(
692+
os.path.dirname(result_project),
693+
declared_path,
694+
)
695+
resolved_path = os.path.realpath(declared_path)
696+
volume_name = os.path.basename(resolved_path)
697+
safe_path = self._safe_job_file(volume_name, job_dir)
698+
if (
699+
safe_path is None
700+
or safe_path != resolved_path
701+
or safe_path in seen_paths
702+
):
703+
continue
704+
705+
info = read_mrc_volume_info(safe_path)
706+
if info is None:
707+
continue
708+
state = record.get("state")
709+
if (
710+
isinstance(state, bool)
711+
or not isinstance(state, (int, float))
712+
or not math.isfinite(state)
713+
or state <= 0
714+
or not float(state).is_integer()
715+
):
716+
state = None
717+
else:
718+
state = int(state)
719+
population = record.get("pop")
720+
if (
721+
isinstance(population, bool)
722+
or not isinstance(population, (int, float))
723+
or not math.isfinite(population)
724+
or population < 0
725+
):
726+
population = None
727+
elif float(population).is_integer():
728+
population = int(population)
729+
730+
seen_paths.add(safe_path)
731+
outputs.append({
732+
"path": safe_path,
733+
"name": volume_name,
734+
"state": state,
735+
"population": population,
736+
"width": info.width,
737+
"height": info.height,
738+
"depth": info.depth,
739+
"voxel_size": info.voxel_size,
740+
"minimum": info.minimum,
741+
"maximum": info.maximum,
742+
})
743+
744+
return sorted(outputs, key=lambda output: (
745+
output["state"] is None,
746+
output["state"] or 0,
747+
output["name"],
748+
))
749+
663750
def get_particle_stack_page(self, page=1, page_size=40):
664751
"""Return one page of addressable images from owned output stacks.
665752

nice/nice_lite/data_structures/mrc.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,36 @@ class MRCStackInfo:
2929
data_offset: int
3030

3131

32+
@dataclass(frozen=True)
33+
class MRCVolumeInfo:
34+
"""Validated metadata for one three-dimensional MRC density map."""
35+
36+
width: int
37+
height: int
38+
depth: int
39+
mode: int
40+
data_offset: int
41+
voxel_size: tuple
42+
minimum: float
43+
maximum: float
44+
45+
46+
@dataclass(frozen=True)
47+
class MRCVolumePayload:
48+
"""A bounded, normalized volume ready for a browser 3D texture."""
49+
50+
data: bytes
51+
width: int
52+
height: int
53+
depth: int
54+
source_width: int
55+
source_height: int
56+
source_depth: int
57+
voxel_size: tuple
58+
minimum: float
59+
maximum: float
60+
61+
3262
def read_mrc_stack_info(path):
3363
"""Return validated MRC stack metadata without reading particle pixels."""
3464
try:
@@ -73,6 +103,127 @@ def read_mrc_stack_info(path):
73103
)
74104

75105

106+
def read_mrc_volume_info(path):
107+
"""Return validated 3D-map metadata without reading density voxels."""
108+
stack_info = read_mrc_stack_info(path)
109+
if stack_info is None or stack_info.count <= 1:
110+
return None
111+
112+
try:
113+
with warnings.catch_warnings():
114+
warnings.simplefilter("ignore", RuntimeWarning)
115+
with mrcfile.open(
116+
path,
117+
mode="r",
118+
permissive=True,
119+
header_only=True,
120+
) as volume:
121+
voxel_size = tuple(
122+
float(axis)
123+
for axis in (
124+
volume.voxel_size.x,
125+
volume.voxel_size.y,
126+
volume.voxel_size.z,
127+
)
128+
)
129+
minimum = float(volume.header.dmin)
130+
maximum = float(volume.header.dmax)
131+
except (OSError, OverflowError, TypeError, ValueError):
132+
return None
133+
134+
if not all(np.isfinite(axis) and axis > 0.0 for axis in voxel_size):
135+
voxel_size = (1.0, 1.0, 1.0)
136+
if not np.isfinite(minimum) or not np.isfinite(maximum):
137+
minimum = maximum = 0.0
138+
139+
return MRCVolumeInfo(
140+
width=stack_info.width,
141+
height=stack_info.height,
142+
depth=stack_info.count,
143+
mode=stack_info.mode,
144+
data_offset=stack_info.data_offset,
145+
voxel_size=voxel_size,
146+
minimum=minimum,
147+
maximum=maximum,
148+
)
149+
150+
151+
def _sample_volume_axis(length, max_dimension):
152+
output_length = min(length, max_dimension)
153+
return np.minimum(
154+
length - 1,
155+
((np.arange(output_length) + 0.5) * length / output_length).astype(np.intp),
156+
)
157+
158+
159+
def read_mrc_volume_payload(path, max_dimension=128):
160+
"""Read a bounded 3D texture, normalized to the source density range."""
161+
if (
162+
not isinstance(max_dimension, int)
163+
or isinstance(max_dimension, bool)
164+
or not 8 <= max_dimension <= 256
165+
):
166+
return None
167+
168+
info = read_mrc_volume_info(path)
169+
if info is None:
170+
return None
171+
172+
source_slices = _sample_volume_axis(info.depth, max_dimension)
173+
source_rows = _sample_volume_axis(info.height, max_dimension)
174+
source_columns = _sample_volume_axis(info.width, max_dimension)
175+
try:
176+
with warnings.catch_warnings():
177+
warnings.simplefilter("ignore", RuntimeWarning)
178+
with mrcfile.mmap(path, mode="r", permissive=True) as volume:
179+
values = volume.data
180+
if (
181+
values is None
182+
or values.ndim != 3
183+
or values.shape != (info.depth, info.height, info.width)
184+
):
185+
return None
186+
sampled = np.asarray(
187+
values[np.ix_(source_slices, source_rows, source_columns)],
188+
dtype=np.float32,
189+
)
190+
except (IndexError, OSError, OverflowError, TypeError, ValueError):
191+
return None
192+
193+
finite_mask = np.isfinite(sampled)
194+
if not finite_mask.any():
195+
return None
196+
197+
minimum = info.minimum
198+
maximum = info.maximum
199+
if maximum <= minimum:
200+
finite_values = sampled[finite_mask]
201+
minimum = float(np.min(finite_values))
202+
maximum = float(np.max(finite_values))
203+
204+
dynamic_range = maximum - minimum
205+
if dynamic_range <= 0.0:
206+
texture = np.zeros(sampled.shape, dtype=np.uint8)
207+
else:
208+
safe_values = np.where(finite_mask, sampled, minimum)
209+
texture = np.rint(
210+
np.clip(255.0 * (safe_values - minimum) / dynamic_range, 0.0, 255.0)
211+
).astype(np.uint8)
212+
213+
return MRCVolumePayload(
214+
data=np.ascontiguousarray(texture).tobytes(order="C"),
215+
width=texture.shape[2],
216+
height=texture.shape[1],
217+
depth=texture.shape[0],
218+
source_width=info.width,
219+
source_height=info.height,
220+
source_depth=info.depth,
221+
voxel_size=info.voxel_size,
222+
minimum=minimum,
223+
maximum=maximum,
224+
)
225+
226+
76227
def _encode_grayscale_image(pixels, image_format):
77228
"""Encode a two-dimensional uint8 array as an in-memory image."""
78229
if (

0 commit comments

Comments
 (0)