OSCR

Motor cortex directly excites the substantia nigra pars reticulata, the basal ganglia output nucleus.

Code ↔ Paper

The paper beside its authors' code: matches between them have not been computed for this paper yet.

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Python · 1,518 lines · 50 KB · GPL-3.0

  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Created on Wed Jan 14 17:20:00 2026
  5. @author: wst
  6. visualisation tools for Snudda & Blender
  7. requires Blender 4.0 > with a functional python installation
  8. """
  9. import sys
  10. import os
  11. import bpy
  12. import bmesh
  13. import h5py
  14. import mathutils
  15. import math
  16. import random
  17. import numpy as np
  18. from snudda.utils.snudda_path import snudda_parse_path
  19. from snudda import SnuddaLoad
  20. # default_colours = [(0.168, 0.416, 0.8), (0.8, 0.294, 0.134)]
  21. # snr_colour = [52 / 255, 168 / 255, 224 / 255, 1]
  22. # midline = 5691.66
  23. ### Allen import convention: axis_forward = 'Y', axis_up = 'Z'
  24. ### coord_conv = {'Mouselight': {'X':4, 'Y':3, 'Z':2}, 'Peng': {'X':2, 'Y':3, 'Z':4}}
  25. def clear_scene(bg_colour=(1, 1, 1, 1), clipping=1e6, raw_colours=True):
  26. """
  27. clears all objects from active blender scene, and (optionallly) sets background colour.
  28. bg_colour (RGBA, optional): colour to set blender background to. Default: (1,1,1,1).
  29. clipping (float, opional): distance at which objects will be clipped in the default viewer. For Allen meshes a value of 1e6 is reasonable to keep all objects in view. Default: 1e6.
  30. raw_colours (bool, optional): If True, sets blender view transform to 'Raw', such that colours are not modified by blender (ie., RGB values will be preserved in render). Default: True.
  31. """
  32. if bpy.context.mode != "OBJECT":
  33. bpy.ops.object.mode_set(mode="OBJECT")
  34. bpy.ops.object.select_all(action="SELECT")
  35. bpy.ops.object.delete(use_global=False)
  36. if bg_colour:
  37. assert len(bg_colour) == 4, "colour must RGBA"
  38. bpy.data.worlds["World"].node_tree.nodes["Background"].inputs[
  39. 0
  40. ].default_value = bg_colour
  41. for area in bpy.context.screen.areas:
  42. if area.type == "VIEW_3D":
  43. for space in area.spaces:
  44. if space.type == "VIEW_3D":
  45. space.clip_end = clipping
  46. space.shading.type = 'RENDERED'
  47. if raw_colours:
  48. bpy.context.scene.view_settings.view_transform = "Raw"
  49. return
  50. def frame_selected():
  51. """
  52. frames selected objects in blender viewer.
  53. """
  54. for area in bpy.context.screen.areas:
  55. if area.type == "VIEW_3D":
  56. for region in area.regions:
  57. if region.type == "WINDOW":
  58. override = {
  59. "area": area,
  60. "region": region,
  61. "edit_object": bpy.context.edit_object,
  62. }
  63. with bpy.context.temp_override(**override):
  64. bpy.ops.view3d.view_selected(use_all_regions=False)
  65. break
  66. break
  67. return
  68. def frame_all():
  69. """
  70. frames all objects in blender viewer.
  71. """
  72. for area in bpy.context.screen.areas:
  73. if area.type == "VIEW_3D":
  74. for region in area.regions:
  75. if region.type == "WINDOW":
  76. override = {
  77. "area": area,
  78. "region": region,
  79. "edit_object": bpy.context.edit_object,
  80. }
  81. with bpy.context.temp_override(**override):
  82. bpy.ops.view3d.view_axis(type="TOP")
  83. bpy.ops.view3d.view_all()
  84. break
  85. break
  86. return
  87. def set_coronal_view():
  88. """
  89. sets 3D view to coronal (following Allen mesh conventions)
  90. """
  91. for area in bpy.context.screen.areas:
  92. if area.type == "VIEW_3D":
  93. space = area.spaces.active
  94. region_3d = space.region_3d
  95. base_quat = mathutils.Quaternion((0.5, 0.5, 0.5, 0.5))
  96. roll_quat = mathutils.Quaternion((1.0, 0.0, 0.0), math.radians(90))
  97. region_3d.view_rotation = roll_quat @ base_quat
  98. region_3d.view_perspective = "ORTHO"
  99. break
  100. return
  101. def bulletproof_name(name):
  102. """
  103. returns a new name if the name is already in use. This avoids conflicts with blender object operations.
  104. name (str): desired name.
  105. returns:
  106. name (str): desired name with a numeric suffix.
  107. """
  108. base = name
  109. i = 1
  110. while name in bpy.data.objects:
  111. name = f"{base}.{i:03d}"
  112. i += 1
  113. return name
  114. def collect_meshes(name):
  115. """
  116. returns a Blender collection by name, or creates a new one if necessary.
  117. name (str): name of collection.
  118. returns:
  119. col (bpy.types.Collection)
  120. """
  121. if name in bpy.data.collections:
  122. return bpy.data.collections[name]
  123. col = bpy.data.collections.new(name)
  124. bpy.context.scene.collection.children.link(col)
  125. return col
  126. def is_inside(p, obj):
  127. """
  128. checks if a point is inside a blender object.
  129. p (list of floats): 3D coordinate
  130. obj (bpy.types.Object): Blender mesh object
  131. returns:
  132. True if p is inside obj.
  133. """
  134. p = mathutils.Vector(p)
  135. result, closest, normal, _ = obj.closest_point_on_mesh(p)
  136. direction = p - closest
  137. return direction.dot(normal) < 0
  138. def distance_to_mesh(p, obj):
  139. """
  140. calculates distance from a given point to the surface of a blender object.
  141. p (list of floats): 3D coordinate.
  142. obj (bpy.types.Object): blender mesh object.
  143. returns:
  144. distance (float): distance to mesh surface.
  145. nearest_point (mathutils.Vector): point on mesh surface nearest to specified point.
  146. """
  147. p = mathutils.Vector(p)
  148. bm = bmesh.new()
  149. bm.from_mesh(obj.data)
  150. bm.transform(obj.matrix_world)
  151. bvh = mathutils.bvhtree.BVHTree.FromBMesh(bm)
  152. nearest_point, normal, index, distance = bvh.find_nearest(p)
  153. bm.free()
  154. return distance, nearest_point
  155. ##### not necessary but can provide significant speed ups for large arrays of coordinates
  156. # from numba import jit
  157. # @jit(nopython=True)
  158. # def distance_numba(point1, point2):
  159. # dx = point2[0] - point1[0]
  160. # dy = point2[1] - point1[1]
  161. # dz = point2[2] - point1[2]
  162. # return math.sqrt(dx*dx + dy*dy + dz*dz)
  163. def get_mesh_volume(obj, scale_f=1e-9):
  164. """
  165. calculates volume of blender object.
  166. obj (bpy.types.Object): blender mesh object
  167. scale_f (float): scaling factor for conversion to desired unit. Default: 1e-9 (converts Allen mesh volumes to mm^3).
  168. returns:
  169. volume (float): volume, mutlitplied by optional scaling factor.
  170. """
  171. me = obj.data
  172. bm = bmesh.new()
  173. bm.from_mesh(me)
  174. bm.transform(obj.matrix_world)
  175. bmesh.ops.triangulate(bm, faces=bm.faces)
  176. volume = 0
  177. for f in bm.faces:
  178. v1, v2, v3 = [v.co for v in f.verts[:3]]
  179. volume += v1.dot(v2.cross(v3)) / 6
  180. bm.free()
  181. return volume * scale_f
  182. def random_RGBA(low=50, high=255, alpha=1):
  183. """
  184. generates a random RGBA. Use low/high limits to control colour darkness for contrast with scene (i.e., 0-100 for dark objects on white backgrounds).
  185. """
  186. return tuple(np.random.randint(low, high, size=3) / 255) + (alpha,)
  187. def flip_mesh_across_midline(obj, midline=5691.66):
  188. """
  189. flip blender object across midline
  190. obj (bpy.types.Object): blender mesh object.
  191. midline: midline of brain. Default: 5691.66.
  192. """
  193. obj.scale.z *= -1
  194. if obj.parent:
  195. obj.parent.location = [
  196. obj.parent.location[0],
  197. obj.parent.location[1],
  198. 2 * midline - obj.parent.location[2],
  199. ]
  200. else:
  201. obj.location = [obj.location[0], obj.location[1], 2 * midline - obj.location[2]]
  202. return
  203. def bisect_object(obj, plane="sagittal", location=5691.66, inner = False, outer = True):
  204. """
  205. splits mesh objects at specified plane
  206. obj (bpy.types.Object): blender mesh object.
  207. plane (str): desired plane, accepts 'sagittal', 'coronal' or 'horizontal'. Follows conventions of Allen meshes.
  208. location (float): location along axis to bisect.
  209. inner (bool): keep inner side of obj.
  210. outer (bool): keep outer side of obj.
  211. """
  212. assert plane.lower() in [
  213. "sagittal",
  214. "coronal",
  215. "horizontal",
  216. ], f"Ensure plane is one of sagittal, coronal, or horizontal. Received: {plane}."
  217. bpy.ops.object.select_all(action="DESELECT")
  218. bpy.context.view_layer.objects.active = obj
  219. obj.select_set(True)
  220. bpy.ops.object.mode_set(mode='EDIT')
  221. bpy.ops.mesh.select_all(action="SELECT")
  222. plane_key = {"sagittal": 2, "coronal": 0, "horizontal": 1}
  223. plane_co = [0, 0, 0]
  224. plane_no = [0, 0, 0]
  225. plane_co[plane_key[plane.lower()]] = location
  226. plane_no[plane_key[plane.lower()]] = 1
  227. bpy.ops.mesh.bisect(
  228. plane_co=plane_co,
  229. plane_no=plane_no,
  230. use_fill=True,
  231. clear_inner=inner,
  232. clear_outer=outer,
  233. threshold=0.0001,
  234. )
  235. bpy.ops.object.mode_set(mode="OBJECT")
  236. return
  237. def set_origin_to_center(obj):
  238. """
  239. sets origin to center of volume of object
  240. obj (bpy.types.Object): blender mesh object.
  241. returns:
  242. obj.location: (XYZ)
  243. """
  244. if bpy.context.active_object and bpy.context.active_object.mode != "OBJECT":
  245. bpy.ops.object.mode_set(mode="OBJECT")
  246. bpy.ops.object.select_all(action="DESELECT")
  247. obj.select_set(True)
  248. bpy.context.view_layer.objects.active = obj
  249. bpy.ops.object.origin_set(type="ORIGIN_CENTER_OF_VOLUME")
  250. return obj.location
  251. def add_tracked_camera(
  252. target_name,
  253. rotate=True,
  254. rotation_time = 360,
  255. coronal=True,
  256. x_res=2160,
  257. y_res=2160,
  258. altitude=0,
  259. cam_clip_end=1e6,
  260. cam_type="ORTHO",
  261. cam_scale=2e4,
  262. ):
  263. """
  264. adds a camera to the scene, tracked to the center of a specified object.
  265. target_name (str): name of object to track to.
  266. rotate (bool): whether the camera should rotate about the object.
  267. rotation_time (int, optional): time (in keyframes) for a full rotation. Default: 360.
  268. coronal (bool): if True, will set a coronal view (Allen convention).
  269. x_res, y_res (int): resolution of x and y axes of camera (px).
  270. altitude (float): altitude of camera angle w.r.t. horizontal plane, in degrees. Default: 0.
  271. cam_clip_end (float): clipping threshold of camera. Default: 1e6.
  272. cam_type (str): camera type. Default: 'ORTHO'.
  273. cam_scale (float): camera scaling (i.e., zoom) for orthogonal cameras. Default: 2e4.
  274. """
  275. assert cam_type in ['PERSP', 'ORTHO', 'PANO', 'CUSTOM'], 'Check camera type!'
  276. x_res = round(x_res)
  277. y_res = round(y_res)
  278. target = bpy.data.objects[target_name]
  279. set_origin_to_center(target)
  280. frame_selected()
  281. if coronal:
  282. set_coronal_view()
  283. bpy.ops.object.empty_add(type="PLAIN_AXES", location=target.location)
  284. bpy.ops.object.camera_add()
  285. cam = bpy.data.objects["Camera"]
  286. bpy.context.scene.camera = cam
  287. for area in bpy.context.window.screen.areas:
  288. if area.type == "VIEW_3D":
  289. for region in area.regions:
  290. if region.type == "WINDOW":
  291. with bpy.context.temp_override(
  292. window=bpy.context.window,
  293. screen=bpy.context.window.screen,
  294. area=area,
  295. region=region,
  296. space_data=area.spaces.active,
  297. scene=bpy.context.scene,
  298. ):
  299. bpy.ops.view3d.camera_to_view()
  300. break
  301. break
  302. cam.data.clip_end = cam_clip_end
  303. cam.data.type = cam_type
  304. if cam_type == "ORTHO":
  305. cam.data.ortho_scale = cam_scale
  306. em = bpy.data.objects["Empty"]
  307. bpy.ops.object.mode_set(mode="OBJECT")
  308. bpy.ops.object.select_all(action="DESELECT")
  309. cam.select_set(True)
  310. em.select_set(True)
  311. bpy.context.view_layer.objects.active = em
  312. bpy.ops.object.parent_set(type="OBJECT", keep_transform=True)
  313. if rotate:
  314. em.rotation_euler = [0, 0, math.radians(altitude)]
  315. em.keyframe_insert(data_path="rotation_euler", frame=1)
  316. em.rotation_euler = [0, math.radians(360), math.radians(altitude)]
  317. em.keyframe_insert(data_path="rotation_euler", frame=rotation_time)
  318. bpy.data.scenes["Scene"].frame_end = rotation_time
  319. for fcurve in em.animation_data.action.fcurves:
  320. for keyframe in fcurve.keyframe_points:
  321. keyframe.interpolation = "LINEAR"
  322. bpy.data.scenes["Scene"].render.resolution_x = x_res
  323. bpy.data.scenes["Scene"].render.resolution_y = y_res
  324. return
  325. def animate_visibility(obj, frame, on=True):
  326. """
  327. animates the visibility of an object in both render and viewport.
  328. obj (bpy.types.Object): blender mesh object.
  329. frame (int): keyframe at which the object should change visibility
  330. on (bool): if True, the object will start invisibile and become visibile at frame. If False, the inverse will occur. Default: True.
  331. """
  332. bpy.context.scene.frame_set(1)
  333. obj.hide_render = on
  334. obj.keyframe_insert(data_path="hide_render", frame=1)
  335. obj.hide_viewport = on
  336. obj.keyframe_insert(data_path="hide_viewport", frame=1)
  337. bpy.context.scene.frame_set(frame)
  338. obj.hide_render = ~on
  339. obj.keyframe_insert(data_path="hide_render", frame=frame)
  340. obj.hide_viewport = ~on
  341. obj.keyframe_insert(data_path="hide_viewport", frame=frame)
  342. for fcurve in obj.animation_data.action.fcurves:
  343. for keyframe in fcurve.keyframe_points:
  344. keyframe.interpolation = "LINEAR"
  345. return
  346. def build_from_swc(
  347. filepath,
  348. name=None,
  349. lx=0,
  350. ly=0,
  351. lz=0,
  352. coord_space=None,
  353. flip=False,
  354. midline=5691.66,
  355. rotate=False,
  356. rotating=None,
  357. rotation_time = 360,
  358. draw_axon=False,
  359. fill_process_tips=False,
  360. merge_threshold=0.001,
  361. scale_f=1,
  362. colour=None,
  363. alpha=1,
  364. ):
  365. """
  366. renders a neuron in blender from an .swc file, using bezier curves.
  367. Inspired by https://github.com/Hjorthmedh/Snudda/blob/master/snudda/plotting/Blender/io_mesh_swc/operator_swc_import.py
  368. filepath (str): path to SWC file of neuron to be rendered
  369. name (str, optional): neuron name, will be passed to object name in Blender. Default: None.
  370. lx, ly, lz (float, optional): soma offsets in XYZ. Default: 0.
  371. coord_space (dict, optional): specified coordinate space if different from default. Default: {'X':2, 'Y':3, 'Z':4}.
  372. flip (bool, optional): if True, flips the neuron about the midline.
  373. midline (float, optional): midline of brain (Allen CCF). Default 5691.66.
  374. rotate (bool, optional): Applies random rotatation if true. Will be overriden by 'rotating'.
  375. rotating (str, optional): Animates rotation about specified axis. Will override 'rotate'. Default: None.
  376. rotation_time (int, optional): time (in keyframes) for a full rotation. Default: 360.
  377. draw_axon (bool, optional): If False, axons (ie., SWC type 2) will not be rendered.
  378. fill_process_tips (bool optional): If True, fills neurite tips with spheres for aesthetics. Warning: slow for large numbers of neurons.
  379. merge_threshold (float, optional): Distance within which vertices will be merged. Higher values reduce the complexity of the resulting object. Default: 0.001.
  380. scale_f (float, optional): Scaling factor for neuron size. Default: 1 (ie., 1:1).
  381. colour (RGBA, optional): Colour to render neuron in. If not specfied a random colour will be generated.
  382. alpha (float, optional): Alpha value (transparency) for neuron material. Must be in the interval [0,1]. Default: 1.
  383. """
  384. f = open(filepath)
  385. lines = f.readlines()
  386. f.close()
  387. x = 0
  388. while lines[x][0] == "#":
  389. x += 1
  390. if coord_space:
  391. coord_space = coord_space
  392. else:
  393. coord_space = {"X": 2, "Y": 3, "Z": 4}
  394. data = lines[x].strip().split()
  395. somaID = int(data[0])
  396. somaType = float(data[1])
  397. somaX = float(data[coord_space["X"]]) + lx
  398. somaY = float(data[coord_space["Y"]]) + ly
  399. somaZ = float(data[coord_space["Z"]]) + lz
  400. somaR = float(data[5])
  401. somaParent = int(data[6])
  402. neuron = {somaID: [somaType, somaX, somaY, somaZ, somaR, somaParent]}
  403. x += 1
  404. for l in lines[x:]:
  405. data = l.strip().split()
  406. compID = int(data[0])
  407. compType = float(data[1])
  408. compX = float(data[coord_space["X"]]) + lx
  409. compY = float(data[coord_space["Y"]]) + ly
  410. compZ = float(data[coord_space["Z"]]) + lz
  411. compR = float(data[5])
  412. compParent = int(data[6])
  413. neuron[compID] = [
  414. compType,
  415. compX - somaX,
  416. compY - somaY,
  417. compZ - somaZ,
  418. compR,
  419. compParent,
  420. ]
  421. if bpy.context.mode != "OBJECT":
  422. bpy.ops.object.mode_set(mode="OBJECT")
  423. bpy.ops.object.empty_add(
  424. type="ARROWS",
  425. location=(
  426. neuron[1][1] / scale_f,
  427. neuron[1][2] / scale_f,
  428. neuron[1][3] / scale_f,
  429. ),
  430. rotation=(0, 0, 0),
  431. )
  432. em = bpy.context.selected_objects[0]
  433. if not name:
  434. name = "neuron"
  435. em.name = bulletproof_name(name)
  436. if rotating:
  437. assert rotating.lower() in ['x', 'y', 'z'], f'{rotating} not an axis name'
  438. em.rotation_euler = [0, 0, 0]
  439. em.keyframe_insert(data_path="rotation_euler", frame=1)
  440. r_dict = {'x': 0, 'y':1, 'z': 2}
  441. em.rotation_euler[r_dict[rotating.lower()]] = math.radians(360)
  442. em.keyframe_insert(data_path="rotation_euler", frame=rotation_time)
  443. bpy.data.scenes["Scene"].frame_end = rotation_time
  444. for fcurve in em.animation_data.action.fcurves:
  445. for keyframe in fcurve.keyframe_points:
  446. keyframe.interpolation = "LINEAR"
  447. elif rotate:
  448. em.rotation_euler = [0, random.randrange(0, 30), random.randrange(0, 360)]
  449. if not colour:
  450. colour = random_RGBA()
  451. else:
  452. assert len(colour) == 4, "colour must RGBA"
  453. material = bpy.data.materials.new(name=str(name) + "_mat")
  454. material.use_nodes = True
  455. pbsdf_node = material.node_tree.nodes["Principled BSDF"]
  456. pbsdf_node.inputs["Base Color"].default_value = colour
  457. pbsdf_node.inputs["Metallic"].default_value = 0
  458. pbsdf_node.inputs["Roughness"].default_value = 1
  459. pbsdf_node.inputs["Specular IOR Level"].default_value = 0.05
  460. pbsdf_node.inputs["Sheen Weight"].default_value = 0
  461. pbsdf_node.inputs["Alpha"].default_value = alpha
  462. material.blend_method = "BLEND"
  463. last = 0
  464. for key, value in neuron.items():
  465. if value[0] == 1: # soma
  466. somaRadie = somaR
  467. bpy.ops.mesh.primitive_uv_sphere_add(
  468. location=(0 / scale_f, 0 / scale_f, 0 / scale_f),
  469. radius=somaRadie / scale_f,
  470. segments=64,
  471. ring_count=64,
  472. )
  473. somaObj = bpy.context.selected_objects[0]
  474. somaObj.name = "Soma"
  475. somaObj.parent = em
  476. somaObj.data.materials.append(material)
  477. last = -10
  478. if value[-1] == -1:
  479. continue
  480. if value[0] == 10:
  481. continue
  482. if value[0] == 5:
  483. continue
  484. if draw_axon == False:
  485. if value[0] == 2:
  486. continue
  487. if value[-1] != last:
  488. if fill_process_tips:
  489. if last != -10:
  490. bpy.ops.mesh.primitive_uv_sphere_add(
  491. radius=p.radius,
  492. location=p.co,
  493. scale=(1, 1, 1),
  494. segments=32,
  495. ring_count=16,
  496. )
  497. obj = bpy.context.selected_objects[0]
  498. obj.data.polygons.foreach_set(
  499. "use_smooth", [True] * len(obj.data.polygons)
  500. )
  501. obj.active_material = material
  502. obj.parent = em
  503. # trace the origins
  504. tracer = bpy.data.curves.new("tracer", "CURVE")
  505. tracer.dimensions = "3D"
  506. spline = tracer.splines.new("BEZIER")
  507. curve = bpy.data.objects.new("curve", tracer)
  508. curve.data.use_fill_caps = False
  509. curve.data.materials.append(material)
  510. bpy.context.scene.collection.objects.link(curve)
  511. # render ready curve
  512. tracer.resolution_u = 12
  513. tracer.bevel_resolution = 12
  514. tracer.fill_mode = "FULL"
  515. tracer.bevel_depth = 1.0
  516. # move nodes to objects
  517. p = spline.bezier_points[0]
  518. if neuron[value[-1]][1] == somaX:
  519. xco = 0
  520. else:
  521. xco = neuron[value[-1]][1]
  522. if neuron[value[-1]][2] == somaY:
  523. yco = 0
  524. else:
  525. yco = neuron[value[-1]][2]
  526. if neuron[value[-1]][3] == somaZ:
  527. zco = 0
  528. else:
  529. zco = neuron[value[-1]][3]
  530. p.co = [xco, yco, zco]
  531. p.radius = neuron[value[-1]][4]
  532. p.handle_right_type = "VECTOR"
  533. p.handle_left_type = "VECTOR"
  534. if last > 0:
  535. spline.bezier_points.add(1)
  536. p = spline.bezier_points[-1]
  537. p.co = [value[1] / scale_f, value[2] / scale_f, value[3] / scale_f]
  538. p.radius = value[4] / scale_f
  539. p.handle_right_type = "VECTOR"
  540. p.handle_left_type = "VECTOR"
  541. curve.parent = em
  542. # continue the last bezier curve
  543. if value[-1] == last:
  544. spline.bezier_points.add(1)
  545. p = spline.bezier_points[-1]
  546. p.co = [value[1] / scale_f, value[2] / scale_f, value[3] / scale_f]
  547. p.radius = value[4] / scale_f
  548. p.handle_right_type = "VECTOR"
  549. p.handle_left_type = "VECTOR"
  550. last = key
  551. ##fill in end of processes, can be slow
  552. if fill_process_tips:
  553. bpy.ops.mesh.primitive_uv_sphere_add(
  554. radius=p.radius, location=p.co, scale=(1, 1, 1), segments=32, ring_count=16
  555. )
  556. obj = bpy.context.selected_objects[0]
  557. obj.data.polygons.foreach_set("use_smooth", [True] * len(obj.data.polygons))
  558. obj.active_material = material
  559. obj.parent = em
  560. ##merge all objects into single object
  561. for obj in bpy.data.objects[name].children:
  562. if "curve" in obj.name:
  563. obj.select_set(True)
  564. bpy.context.view_layer.objects.active = obj
  565. bpy.ops.object.convert(target="MESH", keep_original=False)
  566. elif "Sphere" in obj.name:
  567. obj.select_set(True)
  568. bpy.context.view_layer.objects.active = obj
  569. elif "Soma" in obj.name:
  570. obj.select_set(True)
  571. bpy.context.view_layer.objects.active = obj
  572. soma = next(
  573. (obj for obj in bpy.data.objects[name].children if "Soma" in obj.name), None
  574. )
  575. bpy.ops.object.select_all(action="DESELECT")
  576. for obj in bpy.data.objects[name].children:
  577. obj.select_set(True)
  578. bpy.context.view_layer.objects.active = soma
  579. if bpy.context.active_object.mode != "OBJECT":
  580. bpy.ops.object.mode_set(mode="OBJECT")
  581. bpy.ops.object.join()
  582. bpy.ops.object.mode_set(mode="EDIT")
  583. bpy.ops.mesh.select_all(action="SELECT")
  584. bpy.ops.mesh.remove_doubles(
  585. threshold=merge_threshold,
  586. use_unselected=False,
  587. use_sharp_edge_from_normals=False,
  588. )
  589. bpy.ops.object.mode_set(mode="OBJECT")
  590. joined_obj = bpy.context.active_object
  591. joined_obj.name = name
  592. joined_obj.data.use_auto_smooth = True
  593. joined_obj.data.auto_smooth_angle = math.pi / 2
  594. if flip:
  595. flip_mesh_across_midline(joined_obj, midline)
  596. return joined_obj
  597. def import_allen_mesh(
  598. filepath, forward_axis="Y", up_axis="Z", colour=None, alpha=1, bf_cull=False
  599. ):
  600. """
  601. imports .obj files from Allen SDK to blender.
  602. filepath (str): path to .obj file
  603. forward_axis, up_axis (str, optional): 'X', 'Y', 'Z', axes for blender orientation
  604. colour (RGBA, optional): colour to render mesh in. If not specfied a random colour will be generated.
  605. alpha (float, optional): alpha value (transparency) for neuron material. Must be in the interval [0,1]. Default: 1.
  606. returns:
  607. obj (bpy.types.Object): blender mesh object of Allen region.
  608. """
  609. assert filepath.endswith(".obj"), "Expected .obj file."
  610. if not colour:
  611. colour = random_RGBA()
  612. else:
  613. assert len(colour) == 4, "colour must RGBA"
  614. bpy.ops.wm.obj_import(filepath=filepath, forward_axis=forward_axis, up_axis=up_axis)
  615. material = bpy.data.materials.new(name="mat")
  616. material.use_nodes = True
  617. pbsdf_node = material.node_tree.nodes["Principled BSDF"]
  618. pbsdf_node.inputs["Base Color"].default_value = colour
  619. pbsdf_node.inputs["Metallic"].default_value = 0
  620. pbsdf_node.inputs["Roughness"].default_value = 0
  621. pbsdf_node.inputs["Specular IOR Level"].default_value = 0
  622. pbsdf_node.inputs["Alpha"].default_value = alpha
  623. material.blend_method = "BLEND"
  624. material.use_backface_culling = bf_cull
  625. mesh = bpy.context.selected_objects[0]
  626. mesh.active_material = material
  627. return mesh
  628. def spherical_sample(n, ndim=3):
  629. """
  630. returns n randdom points on the surface of a unit sphere.
  631. n (int): number of points
  632. """
  633. vec = np.random.randn(ndim, n)
  634. vec /= np.linalg.norm(vec, axis=0)
  635. return vec.T
  636. def mesh_from_coordinates(coordinates, name=None, reference_object=None, scale_f=1):
  637. """
  638. creates a blender object from a set of coordinates, placing a reference object at each coordinate.
  639. Helpful for mapping out soma or synapse positions.
  640. coordinates (Nx3 array): coordinates of interest.
  641. reference_object (blender object, optional): specific blender object to place at each coordinate. Object should be centered at [0,0,0]. If None, a sphere will be generated.
  642. scale_f (float, optional): scaling factor for coordinates to blender space. Default: 1e6.
  643. returns:
  644. obj (bpy.types.Object): blender mesh object.
  645. """
  646. if not name:
  647. name = "coordinates"
  648. mesh = bpy.data.meshes.new("coordinates")
  649. mesh_object = bpy.data.objects.new("coordinates", mesh)
  650. mesh_object.name = f"{name}_temp"
  651. mesh.from_pydata(coordinates * scale_f, [], [])
  652. mesh.update(calc_edges=True)
  653. bpy.context.scene.cursor.location = [0, 0, 0]
  654. mesh_object.location = bpy.context.scene.cursor.location
  655. bpy.data.collections["Collection"].objects.link(mesh_object)
  656. if not reference_object:
  657. material = bpy.data.materials.new(name="mat")
  658. material.use_nodes = True
  659. pbsdf_node = material.node_tree.nodes["Principled BSDF"]
  660. pbsdf_node.inputs["Base Color"].default_value = (0, 0, 0, 1)
  661. bpy.ops.mesh.primitive_uv_sphere_add(
  662. location=[0, 0, 0], radius=10, segments=16, ring_count=16
  663. )
  664. reference_object = bpy.context.selected_objects[0]
  665. reference_object.data.materials.append(material)
  666. reference_object.parent = mesh_object
  667. mesh_object.instance_type = "VERTS"
  668. bpy.ops.object.select_all(action="DESELECT")
  669. mesh_object.select_set(True)
  670. before = set(bpy.data.objects)
  671. bpy.ops.object.duplicates_make_real()
  672. after = set(bpy.data.objects)
  673. realised = list(after - before)
  674. for r in realised:
  675. r.select_set(True)
  676. bpy.context.view_layer.objects.active = realised[0]
  677. bpy.ops.object.join()
  678. joined_obj = bpy.context.selected_objects[0]
  679. joined_obj.name = bulletproof_name(name)
  680. bpy.data.objects.remove(mesh_object, do_unlink=True)
  681. bpy.data.objects.remove(reference_object, do_unlink=True)
  682. frame_selected()
  683. return joined_obj
  684. def voxels_from_coordinates_GN(coordinates, values = None, colour = None, voxel_size = 50):
  685. """
  686. creates a blender object from a set of voxel coordinates, using blender geometry nodes for speed-ups with large numbers of points.
  687. coordinates (Nx3 array): coordinates of interest.
  688. values (array of length N, optional): list of values for each voxel. Mapped to alpha value.
  689. colour (RGBA, optional): colour to render mesh in. If not specfied a random colour will be generated.
  690. voxel_size (float, optional): voxel size in blender space. Default: 50.
  691. returns:
  692. obj (bpy.types.Object): blender mesh object.
  693. """
  694. if not colour:
  695. colour = random_RGBA()
  696. else:
  697. assert len(colour) == 4, "colour must RGBA"
  698. if len(values) != len(coordinates):
  699. values = np.ones(shape = [1, len(coordinates)])
  700. mesh = bpy.data.meshes.new("VoxelPointsMesh")
  701. mesh.from_pydata(coordinates, [], [])
  702. mesh.update()
  703. attr = mesh.attributes.new(
  704. name="value",
  705. type='FLOAT',
  706. domain='POINT'
  707. )
  708. for idx, v in enumerate(values):
  709. attr.data[idx].value = float(v)
  710. obj = bpy.data.objects.new("VoxelPoints", mesh)
  711. bpy.context.collection.objects.link(obj)
  712. mod = obj.modifiers.new(name="VoxelGN", type='NODES')
  713. ng = bpy.data.node_groups.new("VoxelNodeTree", 'GeometryNodeTree')
  714. mod.node_group = ng
  715. ng.interface.new_socket("Geometry", in_out='INPUT', socket_type='NodeSocketGeometry')
  716. ng.interface.new_socket("Geometry", in_out='OUTPUT', socket_type='NodeSocketGeometry')
  717. nodes = ng.nodes
  718. links = ng.links
  719. nodes.clear()
  720. group_in = nodes.new("NodeGroupInput")
  721. group_out = nodes.new("NodeGroupOutput")
  722. named_attr = nodes.new("GeometryNodeInputNamedAttribute")
  723. capture = nodes.new("GeometryNodeCaptureAttribute")
  724. inst = nodes.new("GeometryNodeInstanceOnPoints")
  725. cube = nodes.new("GeometryNodeMeshCube")
  726. store_color = nodes.new("GeometryNodeStoreNamedAttribute")
  727. realize = nodes.new("GeometryNodeRealizeInstances")
  728. combine_color = nodes.new("FunctionNodeCombineColor")
  729. cube.inputs["Size"].default_value = [voxel_size]*3
  730. named_attr.data_type = 'FLOAT'
  731. named_attr.inputs["Name"].default_value = "value"
  732. capture.data_type = 'FLOAT'
  733. capture.domain = 'POINT'
  734. store_color.data_type = 'BYTE_COLOR'
  735. store_color.domain = 'CORNER'
  736. store_color.inputs["Name"].default_value = "Color"
  737. combine_color.inputs["Red"].default_value = colour[0]
  738. combine_color.inputs["Green"].default_value = colour[1]
  739. combine_color.inputs["Blue"].default_value = colour[2]
  740. links.new(group_in.outputs["Geometry"], capture.inputs["Geometry"])
  741. links.new(named_attr.outputs["Attribute"], capture.inputs["Value"])
  742. links.new(capture.outputs["Geometry"], inst.inputs["Points"])
  743. links.new(cube.outputs["Mesh"], inst.inputs["Instance"])
  744. links.new(inst.outputs["Instances"], realize.inputs["Geometry"])
  745. links.new(realize.outputs["Geometry"], store_color.inputs["Geometry"])
  746. links.new(capture.outputs["Attribute"], combine_color.inputs["Alpha"])
  747. links.new(combine_color.outputs["Color"], store_color.inputs["Value"])
  748. links.new(store_color.outputs["Geometry"], group_out.inputs["Geometry"])
  749. # Apply the modifier
  750. bpy.context.view_layer.objects.active = obj
  751. bpy.ops.object.modifier_apply(modifier="VoxelGN")
  752. obj.data.materials.clear()
  753. material = bpy.data.materials.new(name="mat")
  754. material.use_nodes = True
  755. material.blend_method = 'BLEND'
  756. material.shadow_method = 'CLIP'
  757. mat_nodes = material.node_tree.nodes
  758. mat_links = material.node_tree.links
  759. color_attr_node = mat_nodes.new("ShaderNodeVertexColor")
  760. color_attr_node.layer_name = "Color"
  761. pbsdf_node = mat_nodes["Principled BSDF"]
  762. pbsdf_node.inputs["Metallic"].default_value = 0
  763. pbsdf_node.inputs["Roughness"].default_value = 0.5
  764. pbsdf_node.inputs["Base Color"].default_value = (colour[0], colour[1], colour[2], 1.0)
  765. mat_links.new(color_attr_node.outputs["Alpha"], pbsdf_node.inputs["Alpha"])
  766. obj.data.materials.append(material)
  767. for poly in obj.data.polygons:
  768. poly.material_index = 0
  769. bpy.ops.object.select_all(action='DESELECT')
  770. obj.select_set(True)
  771. bpy.context.view_layer.objects.active = obj
  772. bpy.ops.object.shade_smooth()
  773. return obj
  774. def draw_spheres(
  775. coordinates, name=None, colour=None, radius=7, res=1, alpha=1, scale_f=1e6
  776. ):
  777. """
  778. illustrate coordinates as spheres.
  779. coordinates (Nx3 list of floats): coordinates of locations.
  780. name (str, optional): name of group for blender.
  781. colour (RGBA, optional): colour to draw spheres in.
  782. radius (float, optional): radius of spheres. Default: 7.
  783. res (float, optional): resolution of spheres. Higher resolution can be slow. Default: 1.
  784. alpha (float, optional): alpha value (transparency) for material. Must be in the interval [0,1]. Default: 1.
  785. scale_f (float, optional): scaling factor for coordinates to blender space. Default: 1e6.
  786. returns:
  787. obj (bpy.types.Object): blender mesh object of spheres.
  788. """
  789. if not colour:
  790. colour = random_RGBA()
  791. else:
  792. assert len(colour) == 4, "colour must RGBA"
  793. material = bpy.data.materials.new(name="mat")
  794. material.use_nodes = True
  795. pbsdf_node = material.node_tree.nodes["Principled BSDF"]
  796. pbsdf_node.inputs["Base Color"].default_value = colour
  797. pbsdf_node.inputs["Alpha"].default_value = alpha
  798. bpy.ops.mesh.primitive_uv_sphere_add(
  799. location=[0, 0, 0],
  800. radius=radius,
  801. segments=int(8 * res),
  802. ring_count=int(8 * res),
  803. )
  804. reference_sphere = bpy.context.selected_objects[0]
  805. reference_sphere.data.materials.append(material)
  806. mesh_object = mesh_from_coordinates(
  807. coordinates, name=name, reference_object=reference_sphere, scale_f=scale_f
  808. )
  809. return mesh_object
  810. def draw_hypervoxels(
  811. hypervoxel_coords,
  812. hypervoxel_side_length=300.0,
  813. colour=(0, 0, 0, 1),
  814. alpha=1,
  815. thickness=30,
  816. scale_f=1e6,
  817. ):
  818. """
  819. draw hypervoxels as skeleton cubes.
  820. OBS: Snudda hypervoxel coordinates are available through SnuddaDetect, but, by default, are not written to disk.
  821. hypervoxel_coords (Nx3 list of floats): coordinates of hypervoxel centers.
  822. hypervoxel_side_length (float): size of hypervoxels.
  823. colour (RGBA, optional): Colour to render hypervoxels in. If not specfied a random colour will be generated.
  824. alpha (float, optional): alpha value (transparency) for material. Must be in the interval [0,1]. Default: 1.
  825. thickness (float, optional): Thickness of wireframe.
  826. scale_f (float, optional): scaling factor for coordinates to blender space. Default: 1e6.
  827. returns:
  828. obj (bpy.types.Object): blender mesh object of hypervoxels.
  829. """
  830. center = [hypervoxel_side_length / 2] * 3
  831. bpy.ops.mesh.primitive_cube_add(location=center, size=hypervoxel_side_length)
  832. material = bpy.data.materials.new(name="vox_mat")
  833. material.use_nodes = True
  834. pbsdf_node = material.node_tree.nodes["Principled BSDF"]
  835. pbsdf_node.inputs["Base Color"].default_value = colour
  836. pbsdf_node.inputs["Metallic"].default_value = 0
  837. pbsdf_node.inputs["Roughness"].default_value = 1
  838. pbsdf_node.inputs["Specular IOR Level"].default_value = 0
  839. pbsdf_node.inputs["Alpha"].default_value = alpha
  840. material.blend_method = "BLEND"
  841. reference_cube = bpy.context.selected_objects[0]
  842. reference_cube.data.materials.append(material)
  843. reference_cube.name = "hypervoxel"
  844. mod = reference_cube.modifiers.new(name="wf", type="WIREFRAME")
  845. mod.thickness = thickness
  846. mod.use_replace = True
  847. bpy.ops.object.modifier_apply(modifier="wf")
  848. hypervoxel_coords = np.array(hypervoxel_coords) * scale_f
  849. mesh = mesh_from_coordinates(
  850. coordinates=hypervoxel_coords,
  851. name="hypervoxels",
  852. reference_object=reference_cube,
  853. )
  854. return mesh
  855. ##################################
  856. #### Snudda specfic functions ####
  857. ##################################
  858. ### Note: tested with Snudda SNr branch only. For virtual synapses: synapse location must be saved in input hdf5 file.
  859. ### OBS: this is not default snudda behaviour.
  860. ### Snudda uses microns at some points, and meters at others. Adjust scale_f depending on use case.
  861. def draw_neuron_from_snudda(
  862. neurons, neuron_id, snudda_data, name=None, colour=None, scale_f=1e6
  863. ):
  864. """ "
  865. draws specified neuron from snudda network.
  866. neurons (list of dicts): data from snudda network: ie., sl.data['neurons'].
  867. neuron_id (int): id of neuron to draw.
  868. name (str, optional): name of neuron for blender.
  869. colour (RGBA, optional): Colour to render neuron in. If not specfied a random colour will be generated.
  870. scale_f (float, optional): Scaling factor for coordinates to blender space. Default: 1e6.
  871. returns:
  872. obj (bpy.types.Object): blender mesh object of neuron.
  873. """
  874. if not colour:
  875. colour = random_RGBA()
  876. else:
  877. assert len(colour) == 4, "colour must RGBA"
  878. position = neurons[neuron_id]["position"] * scale_f
  879. e_rot = mathutils.Matrix(neurons[neuron_id]["rotation"].reshape(3, 3)).to_euler()
  880. build_from_swc(
  881. snudda_parse_path(neurons[neuron_id]["morphology"], snudda_data),
  882. name=name,
  883. lx=position[0],
  884. ly=position[1],
  885. lz=position[2],
  886. colour=colour,
  887. )
  888. obj = bpy.context.selected_objects[0]
  889. obj.rotation_euler = e_rot
  890. return obj
  891. def draw_postsynaptic(
  892. network_path,
  893. post_id,
  894. snudda_data,
  895. draw_presynaptic_partners=True,
  896. draw_synapses=True,
  897. scale_f=1e6,
  898. post_colour=(239 / 255, 42 / 255, 126 / 255, 1.0),
  899. pre_colour=None,
  900. synapse_colour=None,
  901. synapse_radius=5,
  902. match_synapses=False,
  903. ):
  904. """
  905. draws postsynaptic neuron with (optionally) presynaptic partners and/or synapse locations.
  906. network_path (str): path to snudda network-synapses.hdf5 file.
  907. post_id (int): id of neuron to analyse and draw.
  908. snudda_data (str): path to snudda data.
  909. draw_presynaptic_partners (bool, optional): If True, presynaptic neurons will also be drawn.
  910. draw_synapses (bool, optional): If True, afferent synapses of post_id will be drawn.
  911. scale_f (float, optional): Scaling factor for coordinates to blender space. Default: 1e6.
  912. pre_colour (RGBA, optional): Colour to draw postsynaptic neuron in. Default: (239/255, 42/255, 126/255, 1.0) - kind of a red-pink colour. I am colourblind though, so don't trust that...
  913. post_colour (RGBA, optional): Colour to draw presynaptic neurons in.
  914. synapse_colour (RGBA, optional): Colour to draw synapses in.
  915. synapse_radius (float, optional): Radius of synapses. Default: 5.
  916. returns:
  917. pre_ids (list): list of presynaptic partner ids.
  918. post_synapse_coords (list): list of afferent synapse coordinates.
  919. """
  920. sl = SnuddaLoad(os.path.join(network_path, "network-synapses.hdf5"))
  921. neurons = sl.data["neurons"]
  922. synapses = sl.data["synapses"]
  923. synapse_coords = sl.data["synapse_coords"]
  924. post_synapses = (synapses[:, 1] == post_id).astype(bool)
  925. pre_ids = list(set(synapses[post_synapses, 0]))
  926. pre_synapse_coords = synapse_coords[post_synapses]
  927. draw_neuron_from_snudda(
  928. neurons,
  929. post_id,
  930. snudda_data,
  931. name="postsynaptic" + str(post_id),
  932. colour=post_colour,
  933. )
  934. if not match_synapses:
  935. if draw_presynaptic_partners:
  936. for pre_id in pre_ids:
  937. draw_neuron_from_snudda(
  938. neurons,
  939. pre_id,
  940. snudda_data,
  941. name="presynaptic" + str(pre_id),
  942. colour=pre_colour,
  943. )
  944. if draw_synapses:
  945. if not synapse_colour:
  946. synapse_colour = random_RGBA()
  947. else:
  948. assert len(synapse_colour) == 4, "colour must RGBA"
  949. material = bpy.data.materials.new(name="syn_mat")
  950. material.use_nodes = True
  951. pbsdf_node = material.node_tree.nodes["Principled BSDF"]
  952. pbsdf_node.inputs["Base Color"].default_value = synapse_colour
  953. bpy.ops.mesh.primitive_uv_sphere_add(
  954. location=[0, 0, 0], radius=synapse_radius, segments=16, ring_count=16
  955. )
  956. reference_sphere = bpy.context.selected_objects[0]
  957. reference_sphere.data.materials.append(material)
  958. mesh_from_coordinates(
  959. pre_synapse_coords,
  960. name="synapses",
  961. reference_object=reference_sphere,
  962. scale_f=scale_f,
  963. )
  964. else:
  965. for pre_id in pre_ids:
  966. matched_synapses = (
  967. (synapses[:, 0] == pre_id) & (synapses[:, 1] == post_id)
  968. ).astype(bool)
  969. colour = random_RGBA(low=0, high=255)
  970. draw_neuron_from_snudda(
  971. neurons,
  972. pre_id,
  973. snudda_data,
  974. name="presynaptic" + str(pre_id),
  975. colour=colour,
  976. )
  977. material = bpy.data.materials.new(name="syn_mat")
  978. material.use_nodes = True
  979. pbsdf_node = material.node_tree.nodes["Principled BSDF"]
  980. pbsdf_node.inputs["Base Color"].default_value = colour
  981. bpy.ops.mesh.primitive_uv_sphere_add(
  982. location=[0, 0, 0], radius=synapse_radius, segments=16, ring_count=16
  983. )
  984. reference_sphere = bpy.context.selected_objects[0]
  985. reference_sphere.data.materials.append(material)
  986. mesh_from_coordinates(
  987. synapse_coords[matched_synapses],
  988. name="synapses" + str(pre_id),
  989. reference_object=reference_sphere,
  990. scale_f=scale_f,
  991. )
  992. return pre_ids, pre_synapse_coords
  993. def draw_presynaptic(
  994. network_path,
  995. pre_id,
  996. snudda_data,
  997. draw_postsynaptic_partners=True,
  998. draw_synapses=True,
  999. scale_f=1e6,
  1000. pre_colour=(239 / 255, 42 / 255, 126 / 255, 1.0),
  1001. post_colour=None,
  1002. synapse_colour=None,
  1003. synapse_radius=5,
  1004. ):
  1005. """
  1006. draws presynaptic neuron with (optionally) postsynaptic partners and/or synapse locations.
  1007. network_path (str): path to snudda network-synapses.hdf5 file.
  1008. pre_id (int): id of neuron to draw.
  1009. snudda_data (str): path to snudda data.
  1010. draw_postsynaptic_partners (bool, optional): If True, postsynaptic neurons will also be drawn.
  1011. draw_synapses (bool, optional): If True, efferent synapses of pre_id will be drawn.
  1012. scale_f (float, optional): Scaling factor for coordinates to blender space. Default: 1e6.
  1013. pre_colour (RGBA, optional): Colour to draw presynaptic neuron in. Default: (239/255, 42/255, 126/255, 1.0) - kind of a red-pink colour. I am colourblind though, so don't trust that...
  1014. post_colour (RGBA, optional): Colour to draw postsynaptic neurons in.
  1015. synapse_colour (RGBA, optional): Colour to draw synapses in.
  1016. synapse_radius (float, optional): Radius of synapses. Default: 5.
  1017. returns:
  1018. post_ids (list): list of postsynaptic partner ids.
  1019. pre_synapse_coords (list): list of efferent synapse coordinates.
  1020. """
  1021. sl = SnuddaLoad(os.path.join(network_path, "network-synapses.hdf5"))
  1022. neurons = sl.data["neurons"]
  1023. synapses = sl.data["synapses"]
  1024. synapse_coords = sl.data["synapse_coords"]
  1025. post_synapses = (synapses[:, 0] == pre_id).astype(bool)
  1026. post_ids = list(set(synapses[post_synapses, 1]))
  1027. pre_synapse_coords = synapse_coords[post_synapses]
  1028. draw_neuron_from_snudda(
  1029. neurons,
  1030. pre_id,
  1031. snudda_data,
  1032. name="presynaptic" + str(pre_id),
  1033. colour=pre_colour,
  1034. )
  1035. if draw_postsynaptic_partners:
  1036. for post_id in post_ids:
  1037. draw_neuron_from_snudda(
  1038. neurons,
  1039. post_id,
  1040. snudda_data,
  1041. name="postynaptic" + str(post_id),
  1042. colour=post_colour,
  1043. )
  1044. if draw_synapses:
  1045. if not synapse_colour:
  1046. synapse_colour = pre_colour
  1047. else:
  1048. assert len(synapse_colour) == 4, "colour must RGBA"
  1049. material = bpy.data.materials.new(name="syn_mat")
  1050. material.use_nodes = True
  1051. pbsdf_node = material.node_tree.nodes["Principled BSDF"]
  1052. pbsdf_node.inputs["Base Color"].default_value = synapse_colour
  1053. bpy.ops.mesh.primitive_uv_sphere_add(
  1054. location=[0, 0, 0], radius=synapse_radius, segments=16, ring_count=16
  1055. )
  1056. reference_sphere = bpy.context.selected_objects[0]
  1057. reference_sphere.data.materials.append(material)
  1058. mesh_from_coordinates(
  1059. pre_synapse_coords,
  1060. name="synapses",
  1061. reference_object=reference_sphere,
  1062. scale_f=scale_f,
  1063. )
  1064. return post_ids, pre_synapse_coords
  1065. def add_virtual_input_locations(
  1066. coords,
  1067. neuron,
  1068. location=[0, 0, 0],
  1069. rotation=[0, 0, 0],
  1070. name=None,
  1071. scale_f=1,
  1072. soma_radius=10,
  1073. colour=None,
  1074. radius=4,
  1075. res=1,
  1076. jitter_amount=None,
  1077. timing=None,
  1078. ):
  1079. """
  1080. renders synapses at input locations defined in snudda input file (ie., virtual inputs).
  1081. coords (Nx3 array): input locations.
  1082. neuron (snudda neuron class, optional): postsynaptic neuron. Overrides location and rotation if passed.
  1083. location (1x3 array, optional): location to center coordinates.
  1084. rotation (1x3 array, optional): rotation of coordinates in Euler format (XYZ).
  1085. name (str, optional): name of synapse group for blender.
  1086. scale_f (float, optional): Scaling factor for coordinates to blender space. Default: 1 (ie., 1:1).
  1087. soma_radius (float, optional): Radius of soma. Synapses within this distance are randomly offset to the soma surface.
  1088. colour (RGBA, optional): Colour to render synapses in. If not specfied a random colour will be generated.
  1089. radius (float, optional): Radius for synapse representaiton.
  1090. res (float, optional): Resolution of synapse spheres. Higher resolution can be slow. Default: 1.
  1091. jitter_amount (float, optional): Jitter to be applied to synapse coordinates. Offsets synapses from dendrites for aesthetic purposes.
  1092. timing (int, optional): If specified, synapses will be animated to appear (ie., rain in) at the given time (keyframe). Can become very slow, as each synapse will be rendered individually.
  1093. """
  1094. coords = np.array(coords) * scale_f
  1095. distances = np.linalg.norm(coords, axis=1)
  1096. inside_soma = distances < soma_radius
  1097. coords[inside_soma] = soma_radius * spherical_sample(np.sum(inside_soma))
  1098. if jitter_amount:
  1099. jitter = np.random.uniform(-jitter_amount, jitter_amount, coords.shape)
  1100. coords += jitter
  1101. if not colour:
  1102. colour = random_RGBA()
  1103. else:
  1104. assert len(colour) == 4, "colour must RGBA"
  1105. material = bpy.data.materials.new(name=str(name) + "_mat")
  1106. material.use_nodes = True
  1107. pbsdf_node = material.node_tree.nodes["Principled BSDF"]
  1108. pbsdf_node.inputs["Metallic"].default_value = 0
  1109. pbsdf_node.inputs["Roughness"].default_value = 0.5
  1110. pbsdf_node.inputs["Base Color"].default_value = colour
  1111. if neuron:
  1112. rotation = mathutils.Matrix(neuron["rotation"].reshape(3, 3)).to_euler()
  1113. location = neuron["position"] * scale_f
  1114. if timing:
  1115. for c in coords:
  1116. bpy.ops.mesh.primitive_uv_sphere_add(
  1117. location=[0, 0, 0],
  1118. radius=radius,
  1119. segments=int(8 * res),
  1120. ring_count=int(8 * res),
  1121. )
  1122. sphere = bpy.context.selected_objects[0]
  1123. sphere.data.materials.append(material)
  1124. rotated_coord = rotation.to_matrix() @ mathutils.Vector(c)
  1125. sphere.location = [0, 0, 0]
  1126. sphere.keyframe_insert(
  1127. data_path="location", frame=1, options={"INSERTKEY_NEEDED"}
  1128. )
  1129. sphere.location = rotated_coord + mathutils.Vector(location)
  1130. sphere.keyframe_insert(
  1131. data_path="location",
  1132. frame=random.randint(timing - 5, timing + 5),
  1133. options={"INSERTKEY_NEEDED"},
  1134. )
  1135. if sphere.animation_data and sphere.animation_data.action:
  1136. for fcurve in sphere.animation_data.action.fcurves:
  1137. if fcurve.data_path == "location":
  1138. for keyframe in fcurve.keyframe_points:
  1139. keyframe.interpolation = "BEZIER"
  1140. keyframe.easing = "EASE_OUT"
  1141. else:
  1142. bpy.ops.mesh.primitive_uv_sphere_add(
  1143. location=[0, 0, 0],
  1144. radius=radius,
  1145. segments=int(8 * res),
  1146. ring_count=int(8 * res),
  1147. )
  1148. reference_sphere = bpy.context.selected_objects[0]
  1149. reference_sphere.data.materials.append(material)
  1150. mesh = mesh_from_coordinates(coords, reference_object=reference_sphere)
  1151. if neuron:
  1152. rotation = mathutils.Matrix(neuron["rotation"].reshape(3, 3)).to_euler()
  1153. location = neuron["position"] * scale_f
  1154. mesh.rotation_euler = rotation
  1155. mesh.location = location
  1156. return
  1157. def virtual_synapse_coordinates(input_file, neuron_id, input_prefix, snudda_data):
  1158. """
  1159. returns coordinates of synapses for specified postsynapic neuron_ids. OBS: make sure you are saving locations when you generate input, otherwise there will be no locations to find...
  1160. input_file (str): path to snudda input file (.hdf5).
  1161. neuron_id (int or list of ints): postsynaptic neuron id(s).
  1162. input_prefix (str or list of strs): prefix for input types of interest ('STN' for example).
  1163. snudda_data (str): path to snudda data folder.
  1164. returns:
  1165. syn_dict (dict): nested dictionary. Top level keys: neuron ids. Lower level keys: input_prefix. Values: lists of coordinates.
  1166. """
  1167. if isinstance(neuron_id, int):
  1168. neuron_id = [neuron_id]
  1169. if isinstance(input_prefix, str):
  1170. input_prefix = [input_prefix]
  1171. input_data = h5py.File(snudda_parse_path(input_file, snudda_data), "r")
  1172. syn_dict = {}
  1173. for n_id in neuron_id:
  1174. neuron_dict = {}
  1175. n_id = str(n_id)
  1176. if n_id in input_data["input"].keys():
  1177. for i_p in input_prefix:
  1178. syns = []
  1179. for k in input_data["input"][n_id].keys():
  1180. if i_p in k:
  1181. syns.append(input_data["input"][n_id][k]["location"][()])
  1182. neuron_dict[i_p] = syns
  1183. syn_dict[n_id] = neuron_dict
  1184. return syn_dict
  1185. ##########################################################################################################################################################################
  1186. if __name__ == "__main__":
  1187. print(
  1188. "Do not run this file directly. Call functions from python within blender please!"
  1189. )
  1190. sys.exit(-1)

bb_tools.py at commit 5226424, under GPL-3.0 · at the source

Overview

  1. Department of Neuroscience, Karolinska Institutet, Stockholm, Sweden
Institutions: Karolinska Institutet (Sweden)
Journal: Nature communications, volume 17, issue 1, article 5551
Dates: received 11 February 2025; accepted 8 June 2026; published online 23 June 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1038/s41467-026-74569-w · PMID 42336891 · PMCID PMC13291323 · OpenAlex W7165632366
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: intracellular / patch clamp (modality), mouse (organism), systems (subfield)
Methods: Smoothing, state filtering, decompositions, Machine learning, Spectral & time-frequency
Keywords: Basal ganglia, Motor cortex
MeSH: Basal Ganglia*, Motor Cortex*, Pars Reticulata*, Animals, GABAergic Neurons, Mice, Neurons, Optogenetics, Patch-Clamp Techniques, Substantia Nigra (* major topic)
Topic: Neural dynamics and brain function (Cognitive Neuroscience, Neuroscience), according to OpenAlex
Funding: Hjärnfonden (FO2023-0230); Knut och Alice Wallenbergs Stiftelse (Knut and Alice Wallenberg Foundation) (KAW 2017.0273); Vetenskapsrådet (VR-M-2019-01854, VR-M-2019-01254, VR-M-2023-02304)
Citations: cited by 3 papers (Europe PMC); 94 references in the paper

Abstract

Inhibitory neurons of the substantia nigra pars reticulata (SNr) serve as a primary output through which the basal ganglia regulate behavior. Using a virally targeted optogenetic approach, combined with whole cell patch-clamp recordings of SNr neurons, we show that, in mice, projection neurons of both primary and secondary motor cortices (M1 and M2) form monosynaptic excitatory connections onto different subpopulations of GABAergic SNr neurons. Furthermore, photostimulation of these cortical axon terminals markedly increases SNr neuron firing rate. To investigate the spatial organization of cortical input to the SNr, we employed a transsynaptic viral-labelling approach to identify SNr neurons receiving monosynaptic input from either M1 or M2. We found a topographical organization of the M1 and M2 projections in SNr. Chemogenetic inhibition of M1- and M2-targeted SNr neurons induced opposing changes in spontaneous behavior. These findings reveal functional pathways by which the motor cortex can directly modulate basal ganglia output to downstream targets.

Reproduced under the paper's license (CC BY), from the paper cited above.

Repository

Its files are read in the Code ↔ Paper reader above.

wstho/brainblenda

License: GPL-3.0
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: 52264246d149cba6983cdf7bf4d5d4ec1b520dbc, 18 March 2026
Languages: Jupyter (2), Python (1)
Size: 182 files, 3 scripts
Software Heritage: not checked
Found in: “Code availability”
Holds: README, license file, 1 notebook
Not found: CITATION.cff, environment file, tests, continuous integration, documentation
Tools: h5py (3 files), NumPy (3 files), pandas (2 files)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
5 files

Code availability

Visualization code is available at https://github.com/wstho/brainblenda. Additional code used in this study will be made available upon request.

Reproduced under the paper's license (CC BY), from the paper cited above.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 1 repository of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 3 scripts, each with its path and the digest of its content;
  • no match between paragraphs and code yet;
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

No dataset and no data link were found in the paper.

Data availability

A source data file supporting the findings of this study is provided with this paper. Further data will be made available upon request. Source data are provided with this paper.

Reproduced under the paper's license (CC BY), from the paper cited above.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 4 authors, 2 keywords, 10 MeSH terms, 3 funders, 93 references.

Cite

This paper

Thompson, W. S., Wekwejt, P., Grillner, S., & Silberberg, G. (2026). Motor cortex directly excites the substantia nigra pars reticulata, the basal ganglia output nucleus. Nature communications, 17(1), 5551. https://doi.org/10.1038/s41467-026-74569-w

BibTeX

@article{thompson2026motor,
author = {Thompson, William Scott and Wekwejt, Patryk and Grillner, Sten and Silberberg, Gilad},
title = {{Motor cortex directly excites the substantia nigra pars reticulata, the basal ganglia output nucleus}},
journal = {Nature communications},
year = {2026},
month = jun,
volume = {17},
number = {1},
pages = {5551},
publisher = {Nature Publishing Group},
issn = {2041-1723},
doi = {10.1038/s41467-026-74569-w},
url = {https://doi.org/10.1038/s41467-026-74569-w},
pmid = {42336891},
pmcid = {PMC13291323}
}

RIS

TY - JOUR
AU - Thompson, William Scott
AU - Wekwejt, Patryk
AU - Grillner, Sten
AU - Silberberg, Gilad
TI - Motor cortex directly excites the substantia nigra pars reticulata, the basal ganglia output nucleus
T2 - Nature communications
J2 - Nat Commun
PY - 2026
DA - 2026/06/23
VL - 17
IS - 1
SP - 5551
SN - 2041-1723
PB - Nature Publishing Group
DO - 10.1038/s41467-026-74569-w
UR - https://doi.org/10.1038/s41467-026-74569-w
LA - en
ER -

CSL-JSON

{
"id": "10.1038/s41467-026-74569-w",
"type": "article-journal",
"title": "Motor cortex directly excites the substantia nigra pars reticulata, the basal ganglia output nucleus",
"container-title": "Nature communications",
"author": [
{
"family": "Thompson",
"given": "William Scott"
},
{
"family": "Wekwejt",
"given": "Patryk"
},
{
"family": "Grillner",
"given": "Sten"
},
{
"family": "Silberberg",
"given": "Gilad"
}
],
"container-title-short": "Nat Commun",
"volume": "17",
"issue": "1",
"page": "5551",
"DOI": "10.1038/s41467-026-74569-w",
"PMID": "42336891",
"PMCID": "PMC13291323",
"ISSN": "2041-1723",
"publisher": "Nature Publishing Group",
"URL": "https://doi.org/10.1038/s41467-026-74569-w",
"language": "en",
"issued": {
"date-parts": [
[
2026,
6,
23
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.7554/elife.109240 [code]
Neural activity profiles reveal overlapping, intermingled subpopulations spanning area borders in mouse sensorimotor cortex.
Journal: eLife
In common: systems, mouse, 10 references
[2] doi:10.7554/elife.111876 [code]
Distinct sensorimotor encoding in tuft dendrites and somata associated with action, correction, and learning.
Journal: eLife
In common: pandas, NumPy, mouse, 7 references
[3] doi:10.1038/s41467-026-73476-4 [code]
Developmental molecular signatures define de novo cortico-brainstem circuit for skilled forelimb movement.
Journal: Nature communications
In common: pandas, NumPy, mouse, 7 references
[4] doi:10.1371/journal.pbio.3003749
Somatosensory input drives membrane potential dynamics in motor cortex during voluntary limb movement.
Journal: PLoS biology
In common: intracellular / patch clamp, systems, mouse, 6 references
[5] doi:10.1016/j.celrep.2026.117419 [code]
Conserved role of primary motor cortex in the control of prehension in mice and macaques.
Journal: Cell reports
In common: pandas, NumPy, systems, mouse, 6 references
[6] doi:10.1038/s41593-026-02253-9 [code]
Genoarchitecture and input-output organization of the mouse basal ganglia and thalamic parafascicular nucleus.
Journal: Nature neuroscience
In common: h5py, pandas, NumPy, mouse, 4 references
[7] doi:10.1016/j.stemcr.2026.103015 [code]
Brain injury reactivates a developmental program driving genesis and integration of transient LGE-class interneurons.
Journal: Stem cell reports
In common: h5py, pandas, NumPy, mouse, 3 references
[8] doi:10.1038/s41467-026-71426-8 [code]
Distinct modes of dopamine modulation on striatopallidal synaptic transmission.
Journal: Nature communications
In common: pandas, NumPy, intracellular / patch clamp, mouse, 3 references
[9] doi:10.1038/s41467-026-77168-x [code]
Cholinergic-dependent dopamine signals in mouse dorsomedial striatum are regulated by frontal but not sensory cortices.
Journal: Nature communications
In common: h5py, pandas, NumPy, systems, mouse, 2 references
[10] doi:10.1371/journal.pbio.3003687 [code]
Glutamatergic projections from the substantia nigra pars reticulata to the dorsal raphe nucleus regulate male social hierarchies.
Journal: PLoS biology
In common: systems, mouse, 4 references

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.