@@ -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+
3262def 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+
76227def _encode_grayscale_image (pixels , image_format ):
77228 """Encode a two-dimensional uint8 array as an in-memory image."""
78229 if (
0 commit comments