Rhapso 0.1.99__py3-none-any.whl → 0.1.993__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import numpy as np
2
2
  import boto3
3
3
  import re
4
+ import copy
4
5
  from xml.etree import ElementTree as ET
5
6
 
6
7
  class SaveXML:
@@ -11,44 +12,357 @@ class SaveXML:
11
12
  self.xml_file = xml_file
12
13
  self.xml_output_path = xml_output_path
13
14
 
15
+ def save_tile_attributes_to_xml(self, xml):
16
+ """
17
+ Ensure the *last* <ViewSetups> (the outer split one) has:
18
+ - <Attributes name="illumination"> old_tile_0..N </Attributes>
19
+ - <Attributes name="channel"> ... </Attributes>
20
+ - <Attributes name="tile"> with locations from Image Splitting </Attributes>
21
+ - <Attributes name="angle"><Angle id=0 name=0/></Attributes>
22
+ """
23
+ root = ET.fromstring(xml)
24
+
25
+ def tagname(el):
26
+ return el.tag.split('}')[-1]
27
+
28
+ def find_one(tag):
29
+ el = root.find(f'.//{{*}}{tag}')
30
+ if el is None:
31
+ el = root.find(tag)
32
+ return el
33
+
34
+ def _norm_id(raw):
35
+ if isinstance(raw, (tuple, list)):
36
+ return int(raw[1] if len(raw) > 1 else raw[0])
37
+ return int(raw)
38
+
39
+ # --- find ALL ViewSetups blocks ---
40
+ view_setups_all = root.findall('.//{*}ViewSetups')
41
+ if not view_setups_all:
42
+ return xml # nothing to do
43
+
44
+ # Outer split ViewSetups = last, inner original = first non-outer
45
+ outer_vs = view_setups_all[-1]
46
+ inner_vs = None
47
+ for vs in view_setups_all:
48
+ if vs is not outer_vs:
49
+ inner_vs = vs
50
+ break
51
+
52
+ # --- collect existing Attributes on OUTER ---
53
+ children = list(outer_vs)
54
+ attr_by_name = {}
55
+ for ch in children:
56
+ if tagname(ch) == 'Attributes':
57
+ nm = ch.get('name')
58
+ if nm:
59
+ attr_by_name[nm] = ch
60
+
61
+ # --- ensure CHANNEL attributes (can still be cloned from inner) ---
62
+ if 'channel' not in attr_by_name and inner_vs is not None:
63
+ for ch in list(inner_vs):
64
+ if tagname(ch) != 'Attributes':
65
+ continue
66
+ nm = ch.get('name')
67
+ if nm == 'channel':
68
+ cloned = copy.deepcopy(ch)
69
+ outer_vs.append(cloned)
70
+ attr_by_name['channel'] = cloned
71
+ break
72
+
73
+ # --- build/overwrite ILLUMINATION attributes: old_tile_0..N ---
74
+ illum_attrs = attr_by_name.get('illumination')
75
+ if illum_attrs is None:
76
+ illum_attrs = ET.Element('Attributes', {'name': 'illumination'})
77
+ outer_vs.append(illum_attrs)
78
+ attr_by_name['illumination'] = illum_attrs
79
+ else:
80
+ # clear existing <Illumination> entries
81
+ for ch in list(illum_attrs):
82
+ illum_attrs.remove(ch)
83
+
84
+ # unique original tile ids from old_view
85
+ orig_tile_ids = sorted({_norm_id(v['old_view']) for v in self.self_definition})
86
+
87
+ for tid in orig_tile_ids:
88
+ illum_el = ET.SubElement(illum_attrs, 'Illumination')
89
+ ET.SubElement(illum_el, 'id').text = str(tid)
90
+ ET.SubElement(illum_el, 'name').text = f"old_tile_{tid}"
91
+
92
+ # --- ensure ANGLE attributes: a single Angle id=0/name=0 ---
93
+ angle_attrs = attr_by_name.get('angle')
94
+ if angle_attrs is None:
95
+ # try clone from inner if it exists
96
+ if inner_vs is not None:
97
+ for ch in list(inner_vs):
98
+ if tagname(ch) == 'Attributes' and ch.get('name') == 'angle':
99
+ angle_attrs = copy.deepcopy(ch)
100
+ outer_vs.append(angle_attrs)
101
+ break
102
+ # if no inner angle, synthesize default
103
+ if angle_attrs is None:
104
+ angle_attrs = ET.Element('Attributes', {'name': 'angle'})
105
+ angle_el = ET.SubElement(angle_attrs, 'Angle')
106
+ ET.SubElement(angle_el, 'id').text = "0"
107
+ ET.SubElement(angle_el, 'name').text = "0"
108
+ outer_vs.append(angle_attrs)
109
+ else:
110
+ # if it exists but has no <Angle>, make one
111
+ has_angle = any(tagname(ch) == 'Angle' for ch in angle_attrs)
112
+ if not has_angle:
113
+ angle_el = ET.SubElement(angle_attrs, 'Angle')
114
+ ET.SubElement(angle_el, 'id').text = "0"
115
+ ET.SubElement(angle_el, 'name').text = "0"
116
+
117
+ attr_by_name['angle'] = angle_attrs
118
+
119
+ # ---- find or create <Attributes name="tile"> under OUTER <ViewSetups> ----
120
+ children = list(outer_vs)
121
+ tile_attrs = None
122
+ insert_idx = len(children) # default: append at end
123
+
124
+ for i, ch in enumerate(children):
125
+ if tagname(ch) == 'Attributes':
126
+ name_attr = ch.get('name')
127
+ # remember existing tile attributes if present
128
+ if name_attr == 'tile':
129
+ tile_attrs = ch
130
+ # prefer to insert tile after channel attributes if we create it
131
+ if name_attr == 'channel':
132
+ insert_idx = i + 1
133
+
134
+ if tile_attrs is None:
135
+ tile_attrs = ET.Element('Attributes', {'name': 'tile'})
136
+ outer_vs.insert(insert_idx, tile_attrs)
137
+
138
+ # ---- figure out which tile ids (new_view ids) we care about ----
139
+ target_ids = {_norm_id(v['new_view']) for v in self.self_definition}
140
+
141
+ # Remove existing Tile entries for those ids (so we can rewrite cleanly)
142
+ for child in list(tile_attrs):
143
+ if tagname(child) != 'Tile':
144
+ continue
145
+ id_el = child.find('id') or child.find('{*}id')
146
+ if id_el is None or not id_el.text:
147
+ continue
148
+ try:
149
+ if int(id_el.text.strip()) in target_ids:
150
+ tile_attrs.remove(child)
151
+ except Exception:
152
+ pass
153
+
154
+ # ---- build a map: setup_id -> (tx, ty, tz) from 'Image Splitting' ----
155
+ view_regs = find_one('ViewRegistrations')
156
+ tile_locations = {}
157
+
158
+ if view_regs is not None:
159
+ # iterate over ViewRegistration elements, namespace-agnostic
160
+ for vr in view_regs.findall('.//{*}ViewRegistration'):
161
+ setup_attr = vr.get('setup')
162
+ if setup_attr is None:
163
+ continue
164
+ try:
165
+ setup_id = int(setup_attr)
166
+ except ValueError:
167
+ continue
168
+
169
+ if setup_id not in target_ids:
170
+ continue
171
+
172
+ # find the Image Splitting transform
173
+ for vt in vr.findall('./{*}ViewTransform'):
174
+ name_el = vt.find('Name') or vt.find('{*}Name')
175
+ if name_el is None:
176
+ continue
177
+ if (name_el.text or '').strip().lower() != 'image splitting':
178
+ continue
179
+
180
+ aff_el = vt.find('affine') or vt.find('{*}affine')
181
+ if aff_el is None or not aff_el.text:
182
+ continue
183
+
184
+ nums = re.findall(
185
+ r'[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?',
186
+ aff_el.text
187
+ )
188
+
189
+ # 3x4 affine: we expect at least 12 numbers
190
+ if len(nums) >= 12:
191
+ tx, ty, tz = map(float, (nums[3], nums[7], nums[11]))
192
+ elif len(nums) >= 3:
193
+ # fallback: last 3 numbers
194
+ tx, ty, tz = map(float, nums[-3:])
195
+ else:
196
+ tx = ty = tz = 0.0
197
+
198
+ tile_locations[setup_id] = (tx, ty, tz)
199
+ break # stop after first 'Image Splitting' for this VR
200
+
201
+ # ---- create Tile entries for each new_view ----
202
+ for view in self.self_definition:
203
+ new_id = _norm_id(view['new_view'])
204
+
205
+ if new_id in tile_locations:
206
+ loc = tile_locations[new_id]
207
+ else:
208
+ # Fallback: use min bound of interval if we didn't find an image splitting transform
209
+ mins = np.array(view['interval'][0], dtype=float)
210
+ loc = (float(mins[0]), float(mins[1]), float(mins[2]))
211
+
212
+ tile_el = ET.SubElement(tile_attrs, 'Tile')
213
+ ET.SubElement(tile_el, 'id').text = str(new_id)
214
+ ET.SubElement(tile_el, 'name').text = str(new_id)
215
+ ET.SubElement(tile_el, 'location').text = f"{loc[0]:.1f} {loc[1]:.1f} {loc[2]:.1f}"
216
+
217
+ # ---- reorder children in OUTER <ViewSetups>:
218
+ # all <ViewSetup> first, then <Attributes> in illumination, channel, tile, angle order ----
219
+ children = list(outer_vs)
220
+
221
+ viewsetup_children = [ch for ch in children if tagname(ch) == 'ViewSetup']
222
+ attr_children = [ch for ch in children if tagname(ch) == 'Attributes']
223
+ other_children = [ch for ch in children if tagname(ch) not in ('ViewSetup', 'Attributes')]
224
+
225
+ # desired attributes order
226
+ attr_order = {'illumination': 0, 'channel': 1, 'tile': 2, 'angle': 3}
227
+
228
+ def _attr_sort_key(el):
229
+ name = el.get('name', '')
230
+ return attr_order.get(name, 99)
231
+
232
+ attr_children.sort(key=_attr_sort_key)
233
+
234
+ # Clear existing children and re-append in desired order
235
+ for ch in children:
236
+ outer_vs.remove(ch)
237
+
238
+ for ch in viewsetup_children + attr_children + other_children:
239
+ outer_vs.append(ch)
240
+
241
+ try:
242
+ ET.indent(root, space=" ")
243
+ except Exception:
244
+ pass
245
+
246
+ return ET.tostring(root, encoding='unicode')
247
+
14
248
  def wrap_image_loader_for_split(self, xml: str) -> str:
249
+ """
250
+ Wrap the top-level ImageLoader in <ImageLoader format="split.viewerimgloader">
251
+ and move the ORIGINAL ViewSetups/Timepoints/MissingViews into an inner
252
+ <SequenceDescription> inside that wrapper.
253
+
254
+ Resulting structure:
255
+
256
+ <SpimData>
257
+ <BasePath/>
258
+ <SequenceDescription>
259
+ <ImageLoader format="split.viewerimgloader">
260
+ <ImageLoader format="bdv.multimg.zarr"> ... </ImageLoader>
261
+ <SequenceDescription>
262
+ <ViewSetups> (ORIGINAL) </ViewSetups>
263
+ <Timepoints> (ORIGINAL) </Timepoints>
264
+ <MissingViews/>
265
+ </SequenceDescription>
266
+ <!-- SetupIds (for split tiles) will be added later -->
267
+ </ImageLoader>
268
+ <!-- NEW ViewSetups/Timepoints/MissingViews for split views are added later -->
269
+ </SequenceDescription>
270
+ <ViewRegistrations> ... </ViewRegistrations>
271
+ </SpimData>
272
+ """
15
273
  root = ET.fromstring(xml)
16
274
 
17
- def tn(el): return el.tag.split('}')[-1]
275
+ def tn(el):
276
+ return el.tag.split('}')[-1]
277
+
18
278
  def find_one(tag):
19
279
  el = root.find(f'.//{{*}}{tag}')
20
280
  return el if el is not None else root.find(tag)
21
281
 
22
- seq = find_one('SequenceDescription')
282
+ seq = None
283
+ # Prefer the top-level SequenceDescription (direct child of root)
284
+ for ch in list(root):
285
+ if tn(ch) == 'SequenceDescription':
286
+ seq = ch
287
+ break
288
+ if seq is None:
289
+ seq = find_one('SequenceDescription')
23
290
  if seq is None:
24
- return xml
25
-
26
- # find the first immediate ImageLoader under SequenceDescription
27
- loaders = [ch for ch in list(seq) if tn(ch) == 'ImageLoader']
28
- if not loaders:
29
291
  return xml
30
292
 
31
- inner = loaders[0]
293
+ children = list(seq)
32
294
 
33
- fmt = (inner.get('format') or '').lower()
34
- if fmt == 'split.viewerimgloader':
295
+ # Find the first immediate ImageLoader under SequenceDescription
296
+ base_loader = None
297
+ base_loader_idx = None
298
+ for i, ch in enumerate(children):
299
+ if tn(ch) == 'ImageLoader':
300
+ base_loader = ch
301
+ base_loader_idx = i
302
+ break
303
+
304
+ if base_loader is None:
35
305
  return xml
36
-
37
- # handle the case where the *outer* wrapper already exists
38
- if any(tn(ch) == 'ImageLoader' for ch in list(inner)) and fmt.startswith('bdv'):
306
+
307
+ fmt = (base_loader.get('format') or '').lower()
308
+ # Already wrapped; assume layout is correct and do nothing
309
+ if fmt == 'split.viewerimgloader':
39
310
  return xml
40
311
 
41
- # wrap the current loader
42
- idx = list(seq).index(inner)
43
- seq.remove(inner)
312
+ # Collect any other ImageLoader siblings (other sources)
313
+ other_imageloaders = []
314
+ # Collect ORIGINAL ViewSetups / Timepoints / MissingViews that are siblings
315
+ orig_viewsetups = None
316
+ orig_timepoints = None
317
+ orig_missingviews = None
318
+
319
+ for ch in children[base_loader_idx + 1:]:
320
+ name = tn(ch)
321
+ if name == 'ImageLoader':
322
+ other_imageloaders.append(ch)
323
+ elif name == 'ViewSetups':
324
+ orig_viewsetups = ch
325
+ elif name == 'Timepoints':
326
+ orig_timepoints = ch
327
+ elif name == 'MissingViews':
328
+ orig_missingviews = ch
329
+
330
+
331
+ # Remove them from the outer SequenceDescription
332
+ for node in (orig_viewsetups, orig_timepoints, orig_missingviews, *other_imageloaders):
333
+ if node is not None and node in seq:
334
+ seq.remove(node)
335
+
336
+ # Remove the original loader from seq
337
+ seq.remove(base_loader)
338
+
339
+ # Build wrapper <ImageLoader format="split.viewerimgloader">
44
340
  wrapper = ET.Element('ImageLoader', {'format': 'split.viewerimgloader'})
45
- wrapper.append(inner)
46
- seq.insert(idx, wrapper)
341
+ # First child: original loader
342
+ wrapper.append(base_loader)
343
+ for other_loader in other_imageloaders:
344
+ wrapper.append(other_loader)
345
+
346
+ # Inner <SequenceDescription> that holds the original ViewSetups/Timepoints/MissingViews
347
+ inner_seq = ET.Element('SequenceDescription')
348
+
349
+ if orig_viewsetups is not None:
350
+ inner_seq.append(orig_viewsetups)
351
+ if orig_timepoints is not None:
352
+ inner_seq.append(orig_timepoints)
353
+ if orig_missingviews is not None:
354
+ inner_seq.append(orig_missingviews)
355
+
356
+ wrapper.append(inner_seq)
357
+
358
+ # Insert wrapper where the original loader was
359
+ seq.insert(base_loader_idx, wrapper)
47
360
 
48
361
  try:
49
362
  ET.indent(root, space=" ")
50
363
  except Exception:
51
364
  pass
365
+
52
366
  return ET.tostring(root, encoding='unicode')
53
367
 
54
368
  def save_view_interest_points(self, xml):
@@ -202,46 +516,66 @@ class SaveXML:
202
516
  return ET.tostring(root, encoding='unicode')
203
517
 
204
518
  def save_setup_id_to_xml(self, xml):
519
+ """
520
+ Create/overwrite the OUTER <ViewSetups> (split tiles) and ensure outer
521
+ <Timepoints> and <MissingViews> exist under the top-level SequenceDescription.
522
+
523
+ Outer layout target:
524
+
525
+ <SpimData>
526
+ <BasePath/>
527
+ <SequenceDescription>
528
+ <ImageLoader format="split.viewerimgloader">
529
+ ...
530
+ <SequenceDescription> (original) </SequenceDescription>
531
+ <SetupIds> ... </SetupIds> (from save_setup_id_definition_to_xml)
532
+ </ImageLoader>
533
+ <ViewSetups> <-- created here (ids 0..499)
534
+ <ViewSetup>...</ViewSetup>
535
+ ...
536
+ <Attributes ...>...</Attributes>
537
+ </ViewSetups>
538
+ <Timepoints type="pattern">
539
+ <integerpattern>0</integerpattern>
540
+ </Timepoints>
541
+ <MissingViews/>
542
+ </SequenceDescription>
543
+ <ViewRegistrations>...</ViewRegistrations>
544
+ </SpimData>
545
+ """
205
546
  root = ET.fromstring(xml)
206
547
 
207
- def tagname(el):
548
+ def tn(el):
208
549
  return el.tag.split('}')[-1]
209
550
 
210
- def find_one(tag):
211
- el = root.find(f'.//{{*}}{tag}')
212
- if el is None:
213
- el = root.find(tag)
214
- return el
215
-
216
- seq = find_one('SequenceDescription')
217
- regs = find_one('ViewRegistrations')
218
- setup_ids = find_one('SetupIds')
219
- if setup_ids is None:
220
- setup_ids = ET.Element('SetupIds')
221
- kids = list(root)
222
- insert_idx = len(kids)
223
- if regs is not None and regs in kids:
224
- insert_idx = kids.index(regs)
225
- elif seq is not None and seq in kids:
226
- insert_idx = kids.index(seq) + 1
227
- root.insert(insert_idx, setup_ids)
551
+ # Find top-level SequenceDescription
552
+ outer_seq = None
553
+ for ch in list(root):
554
+ if tn(ch) == 'SequenceDescription':
555
+ outer_seq = ch
556
+ break
557
+ if outer_seq is None:
558
+ outer_seq = root.find('.//{*}SequenceDescription')
559
+ if outer_seq is None:
560
+ return xml
228
561
 
562
+ # Find or create OUTER <ViewSetups> under SequenceDescription
229
563
  view_setups = None
230
- for ch in list(root):
231
- if tagname(ch) == 'ViewSetups':
564
+ for ch in list(outer_seq):
565
+ if tn(ch) == 'ViewSetups':
232
566
  view_setups = ch
233
567
  break
234
- if view_setups is None:
235
- view_setups = find_one('ViewSetups')
568
+
236
569
  if view_setups is None:
237
570
  view_setups = ET.Element('ViewSetups')
238
- kids = list(root)
239
- after_idx = -1
240
- for i, ch in enumerate(kids):
241
- if tagname(ch) in ('ImageLoader', 'SequenceDescription'):
242
- after_idx = i
243
- root.insert(after_idx + 1 if after_idx >= 0 else len(kids), view_setups)
244
-
571
+ children = list(outer_seq)
572
+ insert_idx = len(children)
573
+ for i, ch in enumerate(children):
574
+ if tn(ch) == 'ImageLoader':
575
+ insert_idx = i + 1
576
+ outer_seq.insert(insert_idx, view_setups)
577
+
578
+ # Helper to normalize ids
245
579
  def _norm_id(raw):
246
580
  if isinstance(raw, (tuple, list)):
247
581
  if len(raw) >= 2:
@@ -249,11 +583,11 @@ class SaveXML:
249
583
  return int(raw[0])
250
584
  return int(raw)
251
585
 
252
- target_ids = set(_norm_id(v['new_view']) for v in self.self_definition)
586
+ target_ids = {_norm_id(v['new_view']) for v in self.self_definition}
253
587
 
254
- # ViewSetup cleanup
588
+ # Remove any existing ViewSetup with those ids (outer only)
255
589
  for child in list(view_setups):
256
- if tagname(child) != 'ViewSetup':
590
+ if tn(child) != 'ViewSetup':
257
591
  continue
258
592
  id_el = child.find('id') or child.find('{*}id')
259
593
  if id_el is not None and id_el.text:
@@ -263,40 +597,22 @@ class SaveXML:
263
597
  except Exception:
264
598
  pass
265
599
 
266
- # SetupIdDefinition cleanup
267
- for sid in list(setup_ids):
268
- if tagname(sid) != 'SetupIdDefinition':
269
- continue
270
- nid_el = sid.find('NewId') or sid.find('{*}NewId')
271
- if nid_el is not None and nid_el.text:
272
- try:
273
- if int(nid_el.text.strip()) in target_ids:
274
- setup_ids.remove(sid)
275
- except Exception:
276
- pass
277
-
600
+ # (Re)build ViewSetups for each new split view
278
601
  for view in self.self_definition:
279
602
  new_id = _norm_id(view['new_view'])
280
- old_id = _norm_id(view['old_view'])
281
- angle = view['angle']
282
- channel = view['channel']
603
+ # old_id = _norm_id(view['old_view']) # not strictly needed here
604
+
605
+ angle = view['angle']
606
+ channel = view['channel']
283
607
  illumination = view['illumination']
284
- tile = new_id
285
- voxel_unit = view['voxel_unit']
286
- voxel_size = view['voxel_dim']
608
+ tile = new_id
609
+ voxel_unit = view['voxel_unit']
610
+ voxel_size = view['voxel_dim']
287
611
 
288
612
  mins = np.array(view["interval"][0], dtype=np.int64)
289
613
  maxs = np.array(view["interval"][1], dtype=np.int64)
290
614
  size = (maxs - mins + 1).tolist()
291
615
 
292
- # <SetupIds>/<SetupIdDefinition>
293
- def_el = ET.SubElement(setup_ids, 'SetupIdDefinition')
294
- ET.SubElement(def_el, 'NewId').text = str(new_id)
295
- ET.SubElement(def_el, 'OldId').text = str(old_id)
296
- ET.SubElement(def_el, 'min').text = f"{int(mins[0])} {int(mins[1])} {int(mins[2])}"
297
- ET.SubElement(def_el, 'max').text = f"{int(maxs[0])} {int(maxs[1])} {int(maxs[2])}"
298
-
299
- # <ViewSetups>/<ViewSetup>
300
616
  vs = ET.SubElement(view_setups, 'ViewSetup')
301
617
  ET.SubElement(vs, 'id').text = str(new_id)
302
618
  ET.SubElement(vs, 'size').text = f"{int(size[0])} {int(size[1])} {int(size[2])}"
@@ -314,6 +630,31 @@ class SaveXML:
314
630
  ET.SubElement(attrs, 'tile').text = str(int(tile))
315
631
  ET.SubElement(attrs, 'angle').text = str(int(angle))
316
632
 
633
+ # Ensure outer <Timepoints> exists
634
+ outer_timepoints = None
635
+ for ch in list(outer_seq):
636
+ if tn(ch) == 'Timepoints':
637
+ outer_timepoints = ch
638
+ break
639
+ if outer_timepoints is None:
640
+ outer_timepoints = ET.Element('Timepoints', {'type': 'pattern'})
641
+ ip = ET.SubElement(outer_timepoints, 'integerpattern')
642
+ ip.text = "0"
643
+ # place right after ViewSetups
644
+ children = list(outer_seq)
645
+ insert_idx = children.index(view_setups) + 1 if view_setups in children else len(children)
646
+ outer_seq.insert(insert_idx, outer_timepoints)
647
+
648
+ # Ensure outer <MissingViews> exists
649
+ outer_missing = None
650
+ for ch in list(outer_seq):
651
+ if tn(ch) == 'MissingViews':
652
+ outer_missing = ch
653
+ break
654
+ if outer_missing is None:
655
+ outer_missing = ET.Element('MissingViews')
656
+ outer_seq.append(outer_missing)
657
+
317
658
  try:
318
659
  ET.indent(root, space=" ")
319
660
  except Exception:
@@ -322,38 +663,92 @@ class SaveXML:
322
663
  return ET.tostring(root, encoding='unicode')
323
664
 
324
665
  def save_setup_id_definition_to_xml(self, xml):
666
+ """
667
+ Create/overwrite <SetupIds> for the split views.
668
+
669
+ In the desired final layout, SetupIds lives inside:
670
+ <SequenceDescription>
671
+ <ImageLoader format="split.viewerimgloader">
672
+ ...
673
+ <SequenceDescription> ... </SequenceDescription>
674
+ <SetupIds> ... </SetupIds> <-- here
675
+ </ImageLoader>
676
+ ...
677
+ </SequenceDescription>
678
+ """
325
679
  root = ET.fromstring(xml)
326
680
 
327
- # find existing nodes (namespace-agnostic)
328
- def tagname(el): return el.tag.split('}')[-1]
329
- children = list(root)
330
- regs_idx = next((i for i, ch in enumerate(children) if tagname(ch) == 'ViewRegistrations'), None)
331
- seq_idx = next((i for i, ch in enumerate(children) if tagname(ch) == 'SequenceDescription'), None)
332
- setup_ids = next((ch for ch in children if tagname(ch) == 'SetupIds'), None)
681
+ def tn(el):
682
+ return el.tag.split('}')[-1]
683
+
684
+ # Find top-level SequenceDescription
685
+ outer_seq = None
686
+ for ch in list(root):
687
+ if tn(ch) == 'SequenceDescription':
688
+ outer_seq = ch
689
+ break
690
+ if outer_seq is None:
691
+ outer_seq = root.find('.//{*}SequenceDescription')
692
+ if outer_seq is None:
693
+ return xml
694
+
695
+ # Find the wrapper ImageLoader format="split.viewerimgloader"
696
+ wrapper = None
697
+ for ch in list(outer_seq):
698
+ if tn(ch) == 'ImageLoader' and (ch.get('format') or '').lower() == 'split.viewerimgloader':
699
+ wrapper = ch
700
+ break
701
+
702
+ # If wrapper not found, fall back to old behavior (root-level SetupIds)
703
+ parent_for_setupids = wrapper if wrapper is not None else root
704
+ children = list(parent_for_setupids)
705
+
706
+ # Locate existing <SetupIds> under the chosen parent
707
+ setup_ids = None
708
+ for ch in children:
709
+ if tn(ch) == 'SetupIds':
710
+ setup_ids = ch
711
+ break
333
712
 
334
- # create/position <SetupIds>
335
713
  if setup_ids is None:
336
714
  setup_ids = ET.Element('SetupIds')
337
- insert_idx = regs_idx if regs_idx is not None else ((seq_idx + 1) if seq_idx is not None else len(children))
338
- root.insert(insert_idx, setup_ids)
715
+ if wrapper is not None:
716
+ # Under wrapper: insert after inner SequenceDescription if present
717
+ inner_children = list(wrapper)
718
+ inner_seq = None
719
+ for ich in inner_children:
720
+ if tn(ich) == 'SequenceDescription':
721
+ inner_seq = ich
722
+ break
723
+ insert_idx = inner_children.index(inner_seq) + 1 if inner_seq is not None else len(inner_children)
724
+ wrapper.insert(insert_idx, setup_ids)
725
+ else:
726
+ # Root-level fallback: put before <ViewRegistrations> if present
727
+ root_children = list(root)
728
+ regs_idx = next((i for i, ch in enumerate(root_children) if tn(ch) == 'ViewRegistrations'), None)
729
+ insert_idx = regs_idx if regs_idx is not None else len(root_children)
730
+ root.insert(insert_idx, setup_ids)
339
731
  else:
340
- setup_ids.clear()
732
+ # Clear existing definitions so we can rewrite
733
+ setup_ids.clear()
341
734
 
735
+ # Now populate SetupIdDefinition from self.self_definition
342
736
  for view in self.self_definition:
343
737
  new_id = view['new_view']
344
738
  old_id = view['old_view']
345
739
  min_bound = view['interval'][0]
346
740
  max_bound = view['interval'][1]
347
-
741
+
742
+ # Normalize IDs (can be int or (tp, setup))
348
743
  nid = int(new_id[1] if isinstance(new_id, (tuple, list)) else new_id)
349
744
  oid = int(old_id[1] if isinstance(old_id, (tuple, list)) else old_id)
350
745
 
351
746
  def_el = ET.SubElement(setup_ids, 'SetupIdDefinition')
352
747
  ET.SubElement(def_el, 'NewId').text = str(nid)
353
748
  ET.SubElement(def_el, 'OldId').text = str(oid)
354
- ET.SubElement(def_el, 'min').text = f"{int(min_bound[0])} {int(min_bound[1])} {int(min_bound[2])}"
355
- ET.SubElement(def_el, 'max').text = f"{int(max_bound[0])} {int(max_bound[1])} {int(max_bound[2])}"
356
-
749
+ ET.SubElement(def_el, 'min').text = f"{int(min_bound[0])} {int(min_bound[1])} {int(min_bound[2])}"
750
+ ET.SubElement(def_el, 'max').text = f"{int(max_bound[0])} {int(max_bound[1])} {int(max_bound[2])}"
751
+
357
752
  try:
358
753
  ET.indent(root, space=" ")
359
754
  except Exception:
@@ -362,11 +757,13 @@ class SaveXML:
362
757
  return ET.tostring(root, encoding='unicode')
363
758
 
364
759
  def run(self):
365
- xml = self.save_setup_id_definition_to_xml(self.xml_file)
760
+ xml = self.xml_file
761
+ xml = self.wrap_image_loader_for_split(xml)
762
+ xml = self.save_setup_id_definition_to_xml(xml)
366
763
  xml = self.save_setup_id_to_xml(xml)
367
764
  xml = self.save_view_registrations_to_xml(xml)
765
+ xml = self.save_tile_attributes_to_xml(xml)
368
766
  xml = self.save_view_interest_points(xml)
369
- xml = self.wrap_image_loader_for_split(xml)
370
767
 
371
768
  if self.xml_output_path:
372
769
  if self.xml_output_path.startswith("s3://"):