Skip to content

Commit df73975

Browse files
authored
Merge pull request #139 from DCC-Lab/jlb/export
Add raw data export
2 parents e20da2b + b2cc3b3 commit df73975

File tree

8 files changed

+203
-3
lines changed

8 files changed

+203
-3
lines changed

pytissueoptics/rayscattering/energyLogging/energyLogger.py

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import json
12
import os
23
import pickle
3-
from typing import List, Union, Optional
4+
from typing import List, Optional, TextIO, Union
45

56
import numpy as np
67

@@ -10,12 +11,15 @@
1011
from pytissueoptics.rayscattering.scatteringScene import ScatteringScene
1112
from pytissueoptics.scene.geometry import Vector
1213
from pytissueoptics.scene.logger.listArrayContainer import ListArrayContainer
13-
from pytissueoptics.scene.logger.logger import DataType, InteractionKey, Logger
14+
from pytissueoptics.scene.logger.logger import DataType, InteractionData, InteractionKey, Logger
1415

16+
from ..opencl.CLScene import NO_SOLID_LABEL
1517
from .energyType import EnergyType
1618

1719

1820
class EnergyLogger(Logger):
21+
_data: dict[InteractionKey, InteractionData]
22+
1923
def __init__(
2024
self,
2125
scene: ScatteringScene,
@@ -307,3 +311,78 @@ def _fluenceTransform(self, key: InteractionKey, data: Optional[np.ndarray]) ->
307311

308312
data[:, 0] = data[:, 0] / self._scene.getMaterial(key.solidLabel).mu_a
309313
return data
314+
315+
def export(self, exportPath: str):
316+
"""
317+
Export the raw 3D data points to a CSV file, along with the scene information to a JSON file.
318+
319+
The data file <exportPath>.csv will be comma-delimited and will contain the following columns:
320+
- energy, x, y, z, solid_index, surface_index
321+
322+
Two types of interactions are logged: scattering and surface crossings. In the first case, the energy will be
323+
the delta energy deposited at the point and the surface index will be -1. In the second case, the energy
324+
will be the total photon energy when crossing the surface, either as positive if leaving the surface
325+
(along the normal) or as negative if entering the surface.
326+
327+
The scene information will be saved in a JSON file named <exportPath>.json, which includes details for each solid
328+
index and surface index, such as their labels, materials, and geometry. The world information is also exported
329+
as solid index -1.
330+
"""
331+
if not self.has3D:
332+
utils.warn("Cannot export data when keep3D is False. No 3D data available.")
333+
return
334+
335+
solidLabels = []
336+
for solid in self._scene.solids:
337+
if solid.isStack():
338+
solidLabels.extend(solid.getLayerLabels())
339+
else:
340+
solidLabels.append(solid.getLabel())
341+
solidLabels.sort()
342+
343+
print("Exporting raw data to file...")
344+
filepath = f"{exportPath}.csv"
345+
with open(filepath, "w") as file:
346+
file.write("energy,x,y,z,solid_index,surface_index\n")
347+
self._writeKeyData(file, InteractionKey(NO_SOLID_LABEL), -1, -1)
348+
for i, solidLabel in enumerate(solidLabels):
349+
self._writeKeyData(file, InteractionKey(solidLabel), i, -1)
350+
for j, surfaceLabel in enumerate(self._scene.getSurfaceLabels(solidLabel)):
351+
self._writeKeyData(file, InteractionKey(solidLabel, surfaceLabel), i, j)
352+
print(f"Exported data points to {filepath}")
353+
354+
sceneInfo = {}
355+
material = self._scene.getWorldEnvironment().material
356+
sceneInfo["-1"] = {"label": "world", "material": material.__dict__ if material else None}
357+
for i, solidLabel in enumerate(solidLabels):
358+
material = self._scene.getMaterial(solidLabel)
359+
solid = self._scene.getSolid(solidLabel)
360+
surfaces = {}
361+
for j, surfaceLabel in enumerate(solid.surfaceLabels):
362+
normals = [s.normal for s in solid.getPolygons(surfaceLabel)[:2]]
363+
if len(normals) == 1 or normals[0] == normals[1]:
364+
normal = normals[0].array
365+
else:
366+
normal = None
367+
surfaces[j] = {"label": surfaceLabel, "normal": normal}
368+
369+
sceneInfo[str(i)] = {
370+
"label": solidLabel,
371+
"type": solid.__class__.__name__,
372+
"material": material.__dict__ if material else None,
373+
"geometry": solid.geometryExport(),
374+
"surfaces": surfaces,
375+
}
376+
377+
sceneFilepath = f"{exportPath}.json"
378+
with open(sceneFilepath, "w") as file:
379+
json.dump(sceneInfo, file, indent=4)
380+
print(f"Exported scene information to {sceneFilepath}")
381+
382+
def _writeKeyData(self, file: TextIO, key: InteractionKey, solidIndex: int, surfaceIndex: int):
383+
if key not in self._data or self._data[key].dataPoints is None:
384+
return
385+
dataArray = self._data[key].dataPoints.getData().astype(str)
386+
dataArray = np.hstack((dataArray, np.full((dataArray.shape[0], 2), str(solidIndex))))
387+
dataArray[:, 5] = str(surfaceIndex)
388+
file.write("\n".join([",".join(row) for row in dataArray]) + "\n")

pytissueoptics/rayscattering/tests/energyLogging/testEnergyLogger.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import io
2+
import json
23
import os
34
import tempfile
45
import unittest
56
from unittest.mock import MagicMock, patch
67

78
import numpy as np
89

10+
from pytissueoptics import Sphere
911
from pytissueoptics.rayscattering.display.utils import Direction
1012
from pytissueoptics.rayscattering.display.views import (
1113
View2DProjection,
@@ -16,6 +18,8 @@
1618
)
1719
from pytissueoptics.rayscattering.energyLogging import EnergyLogger
1820
from pytissueoptics.rayscattering.materials import ScatteringMaterial
21+
from pytissueoptics.rayscattering.opencl.CLScene import NO_SOLID_LABEL
22+
from pytissueoptics.rayscattering.samples import PhantomTissue
1923
from pytissueoptics.rayscattering.scatteringScene import ScatteringScene
2024
from pytissueoptics.scene.geometry import Vector
2125
from pytissueoptics.scene.logger import InteractionKey
@@ -284,3 +288,64 @@ def testWhenLogSegmentArray_shouldRaiseError(self):
284288
def testWhenLogSegment_shouldRaiseError(self):
285289
with self.assertRaises(NotImplementedError):
286290
self.logger.logSegment(Vector(0, 0, 0), Vector(0, 0, 0), self.INTERACTION_KEY)
291+
292+
def testWhenExport_shouldExport3DDataPointsToFile(self):
293+
# Use a scene that contains a stack, a sphere and a world material.
294+
scene = PhantomTissue(worldMaterial=ScatteringMaterial(0.1, 0.1, 0.99))
295+
scene.add(Sphere(position=Vector(0, 5, 0), material=ScatteringMaterial(0.4, 0.2, 0.9)))
296+
self.logger = EnergyLogger(scene)
297+
298+
# Log entering surface event, world scattering event and scattering event in both solids.
299+
self.logger.logDataPoint(0.1, Vector(0.7, 0.8, 0.8), InteractionKey("middleLayer"))
300+
self.logger.logDataPoint(-0.9, Vector(0.5, 1.0, 0.75), InteractionKey("frontLayer", "interface1"))
301+
self.logger.logDataPoint(0.4, Vector(0, 5, 0), InteractionKey("sphere"))
302+
self.logger.logDataPoint(0.2, Vector(0, 0, 0), InteractionKey(NO_SOLID_LABEL))
303+
304+
with tempfile.TemporaryDirectory() as tempDir:
305+
filePath = os.path.join(tempDir, "test_sim")
306+
self.logger.export(filePath)
307+
self.assertTrue(os.path.exists(filePath + ".csv"))
308+
309+
with open(filePath + ".csv", "r") as f:
310+
lines = f.readlines()
311+
312+
self.assertEqual(5, len(lines))
313+
self.assertEqual("energy,x,y,z,solid_index,surface_index\n", lines[0])
314+
self.assertEqual("0.2,0.0,0.0,0.0,-1,-1\n", lines[1])
315+
self.assertEqual("-0.9,0.5,1.0,0.75,1,5\n", lines[2])
316+
self.assertEqual("0.1,0.7,0.8,0.8,2,-1\n", lines[3])
317+
self.assertEqual("0.4,0.0,5.0,0.0,3,-1\n", lines[4])
318+
319+
def testWhenExport_shouldExportMetadataToFile(self):
320+
scene = PhantomTissue(worldMaterial=ScatteringMaterial(0.1, 0.1, 0.99))
321+
scene.add(Sphere(position=Vector(0, 5, 0), material=ScatteringMaterial(0.4, 0.2, 0.9)))
322+
self.logger = EnergyLogger(scene)
323+
324+
with tempfile.TemporaryDirectory() as tempDir:
325+
filePath = os.path.join(tempDir, "test_sim")
326+
self.logger.export(filePath)
327+
self.assertTrue(os.path.exists(filePath + ".json"))
328+
sceneInfo = json.loads(open(filePath + ".json", "r").read())
329+
330+
self.assertEqual(["-1", "0", "1", "2", "3"], list(sceneInfo.keys()))
331+
332+
expectedWorldInfo = {
333+
"label": "world",
334+
"material": {
335+
"mu_s": 0.1,
336+
"mu_a": 0.1,
337+
"mu_t": 0.2,
338+
"_albedo": 0.5,
339+
"g": 0.99,
340+
"n": 1.0,
341+
},
342+
}
343+
self.assertEqual(expectedWorldInfo, sceneInfo["-1"])
344+
345+
self.assertEqual(["label", "type", "material", "geometry", "surfaces"], list(sceneInfo["0"].keys()))
346+
self.assertEqual("backLayer", sceneInfo["0"]["label"])
347+
self.assertEqual("Cuboid", sceneInfo["0"]["type"])
348+
expectedLayerGeometry = {"shape": [3, 3, 2], "position": [0, 0, 1], "bbox": [[-1.5, 1.5], [-1.5, 1.5], [0, 2]]}
349+
self.assertEqual(expectedLayerGeometry, sceneInfo["0"]["geometry"])
350+
self.assertEqual(16, len(sceneInfo["0"]["surfaces"]))
351+
self.assertEqual({"label": "interface0", "normal": [0.0, 0.0, -1.0]}, sceneInfo["0"]["surfaces"]["14"])

pytissueoptics/scene/solids/cuboid.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,6 @@ def _getLayerPolygons(self, layerLabel: str) -> List[Polygon]:
151151
for surfaceLabel in layerSurfaceLabels:
152152
polygons.extend(self._surfaces.getPolygons(surfaceLabel))
153153
return polygons
154+
155+
def _geometryParams(self) -> dict:
156+
return {"shape": self.shape}

pytissueoptics/scene/solids/cylinder.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,12 @@ def __hash__(self):
164164
materialHash = hash(self._material) if self._material else 0
165165
propertyHash = hash((self._radius, self._length, self._frontCenter, self._backCenter))
166166
return hash((materialHash, propertyHash))
167+
168+
def _geometryParams(self) -> dict:
169+
return {
170+
"radius": self._radius,
171+
"length": self._length,
172+
"u": self._u,
173+
"v": self._v,
174+
"s": self._s,
175+
}

pytissueoptics/scene/solids/ellipsoid.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,11 @@ def _getRadiusError(self) -> float:
212212

213213
def _getMinimumRadiusTowards(self, vertex) -> float:
214214
return (1 - self._getRadiusError()) * self._radiusTowards(vertex)
215+
216+
def _geometryParams(self) -> dict:
217+
return {
218+
"radius_a": self._a,
219+
"radius_b": self._b,
220+
"radius_c": self._c,
221+
"order": self._order,
222+
}

pytissueoptics/scene/solids/lens.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def __init__(
5050
self._diameter = diameter
5151
self._frontRadius = frontRadius
5252
self._backRadius = backRadius
53+
self._thickness = thickness
5354

5455
length = self._computeEdgeThickness(thickness)
5556
super().__init__(
@@ -156,6 +157,18 @@ def smooth(self, surfaceLabel: str = None, reset: bool = True):
156157
for surfaceLabel in ["front", "back"]:
157158
self.smooth(surfaceLabel, reset=False)
158159

160+
def _geometryParams(self) -> dict:
161+
return {
162+
"diameter": self._diameter,
163+
"frontRadius": self._frontRadius,
164+
"backRadius": self._backRadius,
165+
"thickness": self._thickness,
166+
"length": self._length,
167+
"u": self._u,
168+
"v": self._v,
169+
"s": self._s,
170+
}
171+
159172

160173
class SymmetricLens(ThickLens):
161174
"""A symmetrical thick lens of focal length `f` in air."""

pytissueoptics/scene/solids/solid.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import warnings
22
from functools import partial
3-
from typing import Callable, Dict, List
3+
from typing import Any, Callable, Dict, List
44

55
import numpy as np
66

@@ -332,3 +332,20 @@ def __hash__(self):
332332
verticesHash = hash(tuple(sorted([hash(v) for v in self._vertices])))
333333
materialHash = hash(self._material) if self._material else 0
334334
return hash((verticesHash, materialHash))
335+
336+
def geometryExport(self) -> dict[str, Any]:
337+
"""Used to describe geometry during data export."""
338+
params = {
339+
**self._geometryParams(),
340+
"position": self.position.array,
341+
"bbox": self.getBoundingBox().xyzLimits,
342+
}
343+
if self._orientation != INITIAL_SOLID_ORIENTATION:
344+
params["orientation"] = self._orientation.array
345+
if self._rotation:
346+
params["rotation"] = [self._rotation.xTheta, self._rotation.yTheta, self._rotation.zTheta]
347+
return params
348+
349+
def _geometryParams(self) -> dict:
350+
"""To be implemented by Solid subclasses to detail other geometry parameters."""
351+
return {}

pytissueoptics/scene/solids/sphere.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,9 @@ def _getMinimumRadius(self) -> float:
5959

6060
def _radiusTowards(self, vertex) -> float:
6161
return self.radius
62+
63+
def _geometryParams(self) -> dict:
64+
return {
65+
"radius": self._radius,
66+
"order": self._order,
67+
}

0 commit comments

Comments
 (0)