Skip to content

Commit 79c195f

Browse files
committed
add CORESI configuration management utilities, refactor coresi_helpers.py, and update test097 for multi-camera setup
1 parent fefbd15 commit 79c195f

File tree

2 files changed

+246
-45
lines changed

2 files changed

+246
-45
lines changed
Lines changed: 199 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,208 @@
11
#!/usr/bin/env python3
22
# -*- coding: utf-8 -*-
33

4-
from box import Box
4+
import opengate_core as g4
5+
from opengate.geometry.utility import vec_g4_as_np, rot_g4_as_np
6+
from opengate.exception import fatal
7+
from opengate.utility import g4_units
8+
import yaml
9+
import uproot
10+
11+
12+
# --- Custom List for Inline YAML Formatting ---
13+
class FlowList(list):
14+
"""A custom list that will be dumped as [x, y, z] in YAML."""
15+
16+
pass
17+
18+
19+
def flow_list_representer(dumper, data):
20+
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=True)
21+
22+
23+
yaml.add_representer(FlowList, flow_list_representer)
24+
25+
26+
def convert_to_flowlist(data):
27+
"""
28+
Recursively traverse the dictionary.
29+
Convert any list containing only simple scalars (int, float, str) to FlowList
30+
so they appear as [a, b, c] in the YAML output.
31+
"""
32+
if isinstance(data, dict):
33+
return {k: convert_to_flowlist(v) for k, v in data.items()}
34+
elif isinstance(data, list):
35+
# Check if list is "simple" (contains only primitives, no dicts or nested lists)
36+
is_simple = all(
37+
isinstance(i, (int, float, str, bool)) or i is None for i in data
38+
)
39+
40+
if is_simple:
41+
return FlowList(data)
42+
else:
43+
# If list contains complex objects, process them recursively but keep the list as is
44+
return [convert_to_flowlist(i) for i in data]
45+
else:
46+
return data
547

648

749
def coresi_new_config():
8-
config = Box(
9-
{
10-
"data_file": "coinc.dat",
11-
"data_type": "GATE",
12-
"n_events": 0,
13-
"starts_at": 0,
14-
"E0": [],
15-
"remove_out_of_range_energies": False,
16-
"energy_range": [120, 150],
17-
"energy_threshold": 5,
18-
"log_dir": None,
19-
"cameras": {"n_cameras": 0},
20-
"volume": {
21-
"volume_dimensions": [10, 10, 10], # in cm
22-
"n_voxels": [50, 50, 1],
23-
"volume_centre": [0, 0, 0],
50+
config = {
51+
"data_file": "coinc.dat",
52+
"data_type": "GATE",
53+
"n_events": 0,
54+
"starts_at": 0,
55+
"E0": [],
56+
"remove_out_of_range_energies": False,
57+
"energy_range": [120, 150],
58+
"energy_threshold": 5,
59+
"log_dir": None,
60+
"cameras": {
61+
"n_cameras": 0,
62+
"common_attributes": {
63+
"n_sca_layers": 0,
64+
"sca_material": "Si",
65+
"abs_material": "Si",
66+
"n_absorbers": 0,
2467
},
25-
"lm_mlem": {
26-
"cone_thickness": "angular",
27-
"model": "cos1rho2",
28-
"last_iter": 0,
29-
"first_iter": 0,
30-
"n_sigma": 2,
31-
"width_factor": 1,
32-
"checkpoint_dir": "checkpoints",
33-
"save_every": 76,
34-
"sensitivity": False,
35-
"sensitivity_model": "like_system_matrix",
36-
"sensitivity_point_samples": 1,
68+
"position_0": {
69+
"frame_origin": [0, 0, 0],
70+
"Ox": [1, 0, 0], # parallel to scatterer edge
71+
"Oy": [0, 1, 0], # parallel to scatterer edge
72+
"Oz": [0, 0, 1], # orthogonal to the camera, tw the source"
3773
},
38-
}
39-
)
74+
},
75+
"volume": {
76+
"volume_dimensions": [10, 10, 10], # in cm?
77+
"n_voxels": [50, 50, 1], # in voxels
78+
"volume_centre": [0, 0, 0], # in cm?
79+
},
80+
"lm_mlem": {
81+
"cone_thickness": "angular",
82+
"model": "cos1rho2",
83+
"last_iter": 0,
84+
"first_iter": 0,
85+
"n_sigma": 2,
86+
"width_factor": 1,
87+
"checkpoint_dir": "checkpoints",
88+
"save_every": 76,
89+
"sensitivity": False,
90+
"sensitivity_model": "like_system_matrix",
91+
"sensitivity_point_samples": 1,
92+
},
93+
}
94+
4095
return config
96+
97+
98+
def set_hook_coresi_config(sim, cameras, filename):
99+
"""
100+
Prepare everything to create the coresi config file at the init of the simulation.
101+
The param structure allows retrieving the coresi config at the end of the simulation.
102+
"""
103+
# create the param structure
104+
param = {
105+
"cameras": cameras,
106+
"filename": filename,
107+
"coresi_config": coresi_new_config(),
108+
}
109+
sim.user_hook_after_init = create_coresi_config
110+
sim.user_hook_after_init_arg = param
111+
return param
112+
113+
114+
def create_coresi_config(simulation_engine, param):
115+
# (note: simulation_engine is not used here but must be the first param)
116+
coresi_config = param["coresi_config"]
117+
cameras = param["cameras"]
118+
119+
for camera in cameras.values():
120+
c = coresi_config["cameras"]
121+
c["n_cameras"] += 1
122+
scatter_layer_names = camera["scatter_layer_names"]
123+
absorber_layer_names = camera["absorber_layer_names"]
124+
125+
for layer_name in scatter_layer_names:
126+
coresi_add_scatterer(coresi_config, layer_name)
127+
for layer_name in absorber_layer_names:
128+
coresi_add_absorber(coresi_config, layer_name)
129+
130+
131+
def coresi_add_scatterer(coresi_config, layer_name):
132+
# find all volumes ('touchable' in Geant4 terminology)
133+
touchables = g4.FindAllTouchables(layer_name)
134+
if len(touchables) != 1:
135+
fatal(f"Cannot find unique volume for layer {layer_name}: {touchables}")
136+
touchable = touchables[0]
137+
138+
# current nb of scatterers
139+
id = coresi_config["cameras"]["common_attributes"]["n_sca_layers"]
140+
coresi_config["cameras"]["common_attributes"]["n_sca_layers"] += 1
141+
layer = {
142+
"center": [0, 0, 0],
143+
"size": [0, 0, 0],
144+
}
145+
coresi_config["cameras"]["common_attributes"][f"sca_layer_{id}"] = layer
146+
147+
# Get the information: WARNING in cm!
148+
cm = g4_units.cm
149+
translation = vec_g4_as_np(touchable.GetTranslation(0)) / cm
150+
solid = touchable.GetSolid(0)
151+
pMin_local = g4.G4ThreeVector()
152+
pMax_local = g4.G4ThreeVector()
153+
solid.BoundingLimits(pMin_local, pMax_local)
154+
size = [
155+
(pMax_local.x - pMin_local.x) / cm,
156+
(pMax_local.y - pMin_local.y) / cm,
157+
(pMax_local.z - pMin_local.z) / cm,
158+
]
159+
layer["center"] = translation.tolist()
160+
layer["size"] = size
161+
162+
163+
def coresi_add_absorber(coresi_config, layer_name):
164+
# find all volumes ('touchable' in Geant4 terminology)
165+
touchables = g4.FindAllTouchables(layer_name)
166+
if len(touchables) != 1:
167+
fatal(f"Cannot find unique volume for layer {layer_name}: {touchables}")
168+
touchable = touchables[0]
169+
170+
# current nb of scatterers
171+
id = coresi_config["cameras"]["common_attributes"]["n_absorbers"]
172+
coresi_config["cameras"]["common_attributes"]["n_absorbers"] += 1
173+
layer = {
174+
"center": [0, 0, 0],
175+
"size": [0, 0, 0],
176+
}
177+
coresi_config["cameras"]["common_attributes"][f"abs_layer_{id}"] = layer
178+
179+
# Get the information: WARNING in cm!
180+
cm = g4_units.cm
181+
translation = vec_g4_as_np(touchable.GetTranslation(0)) / cm
182+
solid = touchable.GetSolid(0)
183+
pMin_local = g4.G4ThreeVector()
184+
pMax_local = g4.G4ThreeVector()
185+
solid.BoundingLimits(pMin_local, pMax_local)
186+
size = [
187+
(pMax_local.x - pMin_local.x) / cm,
188+
(pMax_local.y - pMin_local.y) / cm,
189+
(pMax_local.z - pMin_local.z) / cm,
190+
]
191+
layer["center"] = translation.tolist()
192+
layer["size"] = size
193+
194+
195+
def coresi_write_config(coresi_config, filename):
196+
# Convert vectors to FlowList just before writing
197+
formatted_config = convert_to_flowlist(coresi_config)
198+
199+
with open(filename, "w") as f:
200+
yaml.dump(
201+
formatted_config, f, default_flow_style=False, sort_keys=False, indent=2
202+
)
203+
204+
205+
def coresi_convert_root_data(root_filename, branch_name, output_filename):
206+
root_file = uproot.open(root_filename)
207+
tree = root_file[branch_name]
208+
print("todo")

opengate/tests/src/test097_coresi_ccmod.py

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
import opengate as gate
55
from opengate.utility import g4_units
66
import opengate.tests.utility as utility
7-
import opengate.contrib.compton_camera.macaco as macaco
87
from opengate.actors.coincidences import *
8+
import opengate.contrib.compton_camera.macaco as macaco
9+
from scipy.spatial.transform import Rotation
10+
import opengate.contrib.compton_camera.coresi_helpers as coresi
911

1012
if __name__ == "__main__":
1113

@@ -38,16 +40,25 @@
3840
world.size = [1 * m, 1 * m, 2 * m]
3941
sim.world.material = "G4_AIR"
4042

41-
# add the camera
42-
camera = macaco.add_macaco1_camera(sim, "macaco1")
43+
# add two cameras
44+
name1 = "macaco1"
45+
camera1 = macaco.add_macaco1_camera(sim, name1)
46+
camera1.translation = [0, 0, 10 * cm]
47+
48+
"""
49+
name2 = "macaco2"
50+
camera2 = macaco.add_macaco1_camera(sim, name2)
51+
camera2.rotation = Rotation.from_euler("x", -90, degrees=True).as_matrix()
52+
camera2.translation = [0, 10 * cm, 0 * cm]
53+
"""
4354

4455
# stats
4556
stats = sim.add_actor("SimulationStatisticsActor", "Stats")
4657
stats.output_filename = "stats.txt"
4758

4859
# PhaseSpace Actor
4960
phsp = sim.add_actor("PhaseSpaceActor", "PhaseSpace")
50-
phsp.attached_to = camera
61+
phsp.attached_to = camera1 # [camera1, camera2]
5162
phsp.attributes = [
5263
"TotalEnergyDeposit",
5364
"PreKineticEnergy",
@@ -60,14 +71,17 @@
6071
"PDGCode",
6172
"TrackVertexKineticEnergy",
6273
"GlobalTime",
74+
"PreStepUniqueVolumeID",
75+
"PreStepUniqueVolumeIDAsInt",
6376
]
6477
phsp.output_filename = "phsp.root"
6578
phsp.steps_to_store = "allsteps"
6679

6780
# physics
6881
sim.physics_manager.physics_list_name = "G4EmStandardPhysics_option2"
6982
sim.physics_manager.set_production_cut("world", "all", 1 * mm)
70-
sim.physics_manager.set_production_cut(camera.name, "all", 0.1 * mm)
83+
sim.physics_manager.set_production_cut(camera1.name, "all", 0.1 * mm)
84+
# sim.physics_manager.set_production_cut(camera2.name, "all", 0.1 * mm)
7185

7286
# source
7387
source = sim.add_source("GenericSource", "src")
@@ -79,11 +93,23 @@
7993
source.direction.type = "iso"
8094
source.activity = 0.847 * MBq / sim.number_of_threads
8195

96+
# acquisition time
8297
if sim.visu:
8398
source.activity = 1 * Bq
84-
8599
sim.run_timing_intervals = [[0 * sec, 5 * sec]]
86100

101+
# special hook to prepare coresi config file.
102+
# For each camera, we must find the names of all layers (scatterer and absorber).
103+
# In this simple macaco1 case, there is only one of each.
104+
cameras = {
105+
name1: {
106+
"scatter_layer_names": [f"{name1}_scatterer"],
107+
"absorber_layer_names": [f"{name1}_absorber"],
108+
}
109+
}
110+
yaml_filename = paths.output / "coresi_config.yaml"
111+
param = coresi.set_hook_coresi_config(sim, cameras, yaml_filename)
112+
87113
# go
88114
sim.run()
89115

@@ -92,25 +118,32 @@
92118

93119
# open the root file and create coincidences
94120
print()
95-
print(f"Output file: {phsp.get_output_path()}")
96121
root_file = uproot.open(phsp.get_output_path())
97122
tree = root_file["PhaseSpace"]
98123
hits = tree.arrays(library="pd")
99-
print(f"Number of hits: {len(hits)} ")
100124
singles = ccmod_ideal_singles(hits)
101-
print(f"Found: {len(singles)} singles")
102125
coinc = ccmod_ideal_coincidences(singles)
126+
print(f"Output file: {phsp.get_output_path()}")
127+
print(f"Number of hits: {len(hits)} ")
128+
print(f"Found: {len(singles)} singles")
103129
print(f"Found: {len(coinc)} coincidences")
104130

105131
# write the new root with coinc
106-
filename = str(phsp.get_output_path()).replace(".root", "_coinc.root")
132+
coinc_filename = str(phsp.get_output_path()).replace(".root", "_coinc.root")
107133
root_write_trees(
108-
filename, ["hits", "singles", "coincidences"], [hits, singles, coinc]
134+
coinc_filename, ["hits", "singles", "coincidences"], [hits, singles, coinc]
109135
)
110-
print(f"Output file: {filename}")
136+
print(f"Output file: {coinc_filename}")
111137
print()
112138

113-
# CORESI stage1: create the configuration file
114-
# coresi_config =
139+
# CORESI stage1: we retrieve the coresi config built during the hook
140+
coresi_config = param["coresi_config"]
141+
# optional: change the volume information
142+
# TODO LATER : set parameters from an image
143+
coresi_config["volume"]["volume_dimensions"] = [20, 20, 0.5]
144+
coresi.coresi_write_config(coresi_config, yaml_filename)
145+
print(f"Coresi config file: {coinc_filename}")
115146

116147
# CORESI stage2: convert the root file
148+
data_filename = output_folder / "coincidences.dat"
149+
coresi.coresi_convert_root_data(coinc_filename, "coincidences", data_filename)

0 commit comments

Comments
 (0)