-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCollageBuilder.py
More file actions
3160 lines (2614 loc) · 136 KB
/
Copy pathCollageBuilder.py
File metadata and controls
3160 lines (2614 loc) · 136 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import os
import logging
from PIL import Image, ImageDraw, ImageFont
import math
from typing import List, Tuple, Optional
logger = logging.getLogger(__name__)
class CollageBuilder:
def __init__(self, grid_width: int = 3, grid_height: int = 3,
cell_size: Tuple[int, int] = (300, 300),
spacing: int = 10, background_color: str = "white",
show_labels: bool = True, label_position: str = "Bottom Center"):
"""
Initialize the CollageBuilder.
Args:
grid_width: Number of columns in the grid
grid_height: Number of rows in the grid
cell_size: Size of each cell (width, height) in pixels
spacing: Spacing between images in pixels
background_color: Background color of the collage
show_labels: Whether to show DSO name labels on images
label_position: Position of labels (Bottom Center, Top Center, etc.)
"""
self.grid_width = grid_width
self.grid_height = grid_height
self.cell_size = cell_size
self.spacing = spacing
self.background_color = background_color
self.show_labels = show_labels
self.label_position = label_position
self.images = []
self.image_paths = []
self.dso_names = []
def add_image(self, image_path: str, dso_name: str = "Unknown DSO") -> bool:
"""
Add an image to the collage.
Args:
image_path: Path to the image file
dso_name: Name of the DSO (Deep Sky Object) associated with this image
Returns:
True if image was successfully added, False otherwise
"""
if not os.path.exists(image_path):
print(f"Error: Image file not found: {image_path}")
return False
if len(self.images) >= self.grid_width * self.grid_height:
print(f"Error: Grid is full. Maximum {self.grid_width * self.grid_height} images allowed.")
return False
try:
image = Image.open(image_path)
# Resize image to fit cell while maintaining aspect ratio
image = self._resize_image_to_fit(image, self.cell_size)
self.images.append(image)
self.image_paths.append(image_path)
self.dso_names.append(dso_name)
print(f"Added image: {os.path.basename(image_path)} ({dso_name})")
return True
except Exception as e:
print(f"Error loading image {image_path}: {str(e)}")
return False
def add_images_from_folder(self, folder_path: str, extensions: List[str] = None) -> int:
"""
Add all images from a folder.
Args:
folder_path: Path to the folder containing images
extensions: List of file extensions to include (default: common image formats)
Returns:
Number of images successfully added
"""
if extensions is None:
extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff']
if not os.path.exists(folder_path):
print(f"Error: Folder not found: {folder_path}")
return 0
added_count = 0
for filename in os.listdir(folder_path):
if any(filename.lower().endswith(ext) for ext in extensions):
if self.add_image(os.path.join(folder_path, filename)):
added_count += 1
return added_count
def add_images_from_collage_data(self, collage_images_list) -> int:
"""
Add images from the collage images data structure.
Args:
collage_images_list: List of image data dictionaries from CollageBuilder UI
Returns:
Number of images successfully added
"""
added_count = 0
for image_data in collage_images_list:
image_path = image_data.get('image_path', '')
dso_name = image_data.get('dso_name', 'Unknown DSO')
# Use integration_time as part of DSO identifier if available
integration_time = image_data.get('integration_time', '')
equipment = image_data.get('equipment', '')
# Create a more descriptive name if we have additional info
if integration_time or equipment:
details = []
if equipment:
details.append(equipment)
if integration_time:
details.append(f"{integration_time}")
if details:
dso_name = f"{dso_name} ({', '.join(details)})"
if self.add_image(image_path, dso_name):
added_count += 1
return added_count
def remove_image(self, index: int) -> bool:
"""
Remove an image from the collage by index.
Args:
index: Index of the image to remove
Returns:
True if image was successfully removed, False otherwise
"""
if 0 <= index < len(self.images):
removed_path = self.image_paths.pop(index)
removed_name = self.dso_names.pop(index) if index < len(self.dso_names) else "Unknown"
self.images.pop(index)
print(f"Removed image: {os.path.basename(removed_path)}")
return True
else:
print(f"Error: Invalid index {index}. Valid range: 0-{len(self.images)-1}")
return False
def clear_images(self):
"""Clear all images from the collage."""
self.images.clear()
self.image_paths.clear()
self.dso_names.clear()
print("All images cleared from collage")
def _group_images_by_path(self) -> dict:
"""
Group DSO names by their image paths to identify shared images.
Returns:
Dictionary where keys are image paths and values are lists of DSO names
"""
image_groups = {}
for i, image_path in enumerate(self.image_paths):
if i < len(self.dso_names):
dso_name = self.dso_names[i]
if image_path not in image_groups:
image_groups[image_path] = []
image_groups[image_path].append(dso_name)
return image_groups
def _calculate_merged_layout(self) -> list:
"""
Calculate layout with merged cells for shared images.
Returns:
List of dictionaries containing layout information for each cell.
Each dict contains: 'image_path', 'dso_names', 'start_col', 'start_row', 'span_cols', 'span_rows'
"""
image_groups = self._group_images_by_path()
layout = []
processed_paths = set()
current_row = 0
current_col = 0
for i, image_path in enumerate(self.image_paths):
if image_path in processed_paths:
continue
dso_names = image_groups[image_path]
num_dsos = len(dso_names)
# Determine cell span based on number of DSOs sharing the image
if num_dsos == 1:
span_cols = 1
span_rows = 1
elif num_dsos == 2:
span_cols = 2
span_rows = 1
elif num_dsos <= 4:
span_cols = 2
span_rows = 2
elif num_dsos <= 6:
span_cols = 3
span_rows = 2
else:
span_cols = 3
span_rows = 3
# Check if the span fits in current row
if current_col + span_cols > self.grid_width:
current_row += 1
current_col = 0
# Check if we have enough vertical space
if current_row + span_rows > self.grid_height:
# If we can't fit, use smaller span
available_rows = self.grid_height - current_row
available_cols = self.grid_width - current_col
span_rows = min(span_rows, available_rows)
span_cols = min(span_cols, available_cols)
layout.append({
'image_path': image_path,
'dso_names': dso_names,
'start_col': current_col,
'start_row': current_row,
'span_cols': span_cols,
'span_rows': span_rows
})
processed_paths.add(image_path)
# Move to next position
current_col += span_cols
if current_col >= self.grid_width:
current_row += 1
current_col = 0
return layout
def _resize_image_to_fit(self, image: Image.Image, target_size: Tuple[int, int]) -> Image.Image:
"""
Resize image to fit within target size while maintaining aspect ratio.
Args:
image: PIL Image object
target_size: Target size (width, height)
Returns:
Resized PIL Image object
"""
# Calculate scaling factor to fit image within target size
scale_x = target_size[0] / image.width
scale_y = target_size[1] / image.height
scale = min(scale_x, scale_y)
# Calculate new size
new_width = int(image.width * scale)
new_height = int(image.height * scale)
# Resize image
return image.resize((new_width, new_height), Image.Resampling.LANCZOS)
def create_collage(self, output_path: str = "collage.jpg", quality: int = 95) -> bool:
"""
Create the collage and save it to a file.
Args:
output_path: Path where the collage will be saved
quality: JPEG quality (1-100, only applies to JPEG format)
Returns:
True if collage was successfully created, False otherwise
"""
if not self.images:
print("Error: No images added to the collage")
return False
# Calculate canvas size
canvas_width = (self.cell_size[0] * self.grid_width) + (self.spacing * (self.grid_width + 1))
canvas_height = (self.cell_size[1] * self.grid_height) + (self.spacing * (self.grid_height + 1))
# Create canvas
canvas = Image.new('RGB', (canvas_width, canvas_height), self.background_color)
draw = ImageDraw.Draw(canvas)
# Try to load a font for labels
try:
if os.name == 'nt': # Windows
font = ImageFont.truetype("arial.ttf", 16)
else: # Linux/Mac
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 16)
except:
font = ImageFont.load_default()
# Place images on canvas
for i, image in enumerate(self.images):
if i >= self.grid_width * self.grid_height:
break
# Calculate grid position
col = i % self.grid_width
row = i // self.grid_width
# Calculate position on canvas (centered within cell)
x = self.spacing + (col * (self.cell_size[0] + self.spacing))
y = self.spacing + (row * (self.cell_size[1] + self.spacing))
# Center image within cell
x_offset = (self.cell_size[0] - image.width) // 2
y_offset = (self.cell_size[1] - image.height) // 2
canvas.paste(image, (x + x_offset, y + y_offset))
# Add DSO name label if enabled
if self.show_labels and i < len(self.dso_names):
dso_name = self.dso_names[i]
# Get text bounding box for positioning calculations
bbox = draw.textbbox((0, 0), dso_name, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# Calculate text position based on label_position setting
if self.label_position == "Bottom Center":
text_x = x + x_offset + (image.width // 2) - (text_width // 2)
text_y = y + y_offset + image.height + 5
elif self.label_position == "Top Center":
text_x = x + x_offset + (image.width // 2) - (text_width // 2)
text_y = y + y_offset - text_height - 5
elif self.label_position == "Bottom Left":
text_x = x + x_offset + 5
text_y = y + y_offset + image.height + 5
elif self.label_position == "Bottom Right":
text_x = x + x_offset + image.width - text_width - 5
text_y = y + y_offset + image.height + 5
elif self.label_position == "Top Left":
text_x = x + x_offset + 5
text_y = y + y_offset - text_height - 5
elif self.label_position == "Top Right":
text_x = x + x_offset + image.width - text_width - 5
text_y = y + y_offset - text_height - 5
elif self.label_position == "Center Overlay":
text_x = x + x_offset + (image.width // 2) - (text_width // 2)
text_y = y + y_offset + (image.height // 2) - (text_height // 2)
else:
# Default to bottom center
text_x = x + x_offset + (image.width // 2) - (text_width // 2)
text_y = y + y_offset + image.height + 5
# Ensure text stays within cell bounds
min_x = x + 2
max_x = x + self.cell_size[0] - text_width - 2
text_x = max(min_x, min(text_x, max_x))
min_y = y + 2
max_y = y + self.cell_size[1] - text_height - 2
text_y = max(min_y, min(text_y, max_y))
# Draw semi-transparent background box for better text visibility
padding = 4
box_x1 = text_x - padding
box_y1 = text_y - padding
box_x2 = text_x + text_width + padding
box_y2 = text_y + text_height + padding
# Create semi-transparent background
box_overlay = Image.new('RGBA', (box_x2 - box_x1, box_y2 - box_y1), (0, 0, 0, 128))
canvas.paste(box_overlay, (box_x1, box_y1), box_overlay)
# Use white text with black outline for maximum visibility
text_color = "white"
outline_color = "black"
# Draw text with outline for better visibility
for dx in [-1, 0, 1]:
for dy in [-1, 0, 1]:
if dx != 0 or dy != 0:
draw.text((text_x + dx, text_y + dy), dso_name, font=font, fill=outline_color)
# Draw main text
draw.text((text_x, text_y), dso_name, font=font, fill=text_color)
# Save collage with appropriate format
try:
file_ext = output_path.lower().split('.')[-1]
if file_ext in ['jpg', 'jpeg']:
canvas.save(output_path, 'JPEG', quality=quality)
elif file_ext == 'png':
canvas.save(output_path, 'PNG')
elif file_ext in ['tif', 'tiff']:
canvas.save(output_path, 'TIFF')
else:
# Fallback to format detection
canvas.save(output_path)
print(f"Collage saved to: {output_path}")
return True
except Exception as e:
print(f"Error saving collage: {str(e)}")
return False
def create_merged_collage(self, output_path: str = "collage.jpg", quality: int = 95) -> bool:
"""
Create a collage with merged cells for DSOs sharing the same image.
Args:
output_path: Path where the collage will be saved
quality: JPEG quality (1-100, only applies to JPEG format)
Returns:
True if collage was successfully created, False otherwise
"""
if not self.images:
print("Error: No images added to the collage")
return False
# Calculate merged layout
layout = self._calculate_merged_layout()
if not layout:
print("Error: No valid layout calculated")
return False
# Calculate canvas size
canvas_width = (self.cell_size[0] * self.grid_width) + (self.spacing * (self.grid_width + 1))
canvas_height = (self.cell_size[1] * self.grid_height) + (self.spacing * (self.grid_height + 1))
# Create canvas
canvas = Image.new('RGB', (canvas_width, canvas_height), self.background_color)
draw = ImageDraw.Draw(canvas)
# Try to load fonts for labels (base and larger sizes)
try:
if os.name == 'nt': # Windows
base_font = ImageFont.truetype("arial.ttf", 16)
large_font = ImageFont.truetype("arial.ttf", 20)
else: # Linux/Mac
base_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 16)
large_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
except:
base_font = ImageFont.load_default()
large_font = ImageFont.load_default()
# Place images on canvas with merged cells
for layout_item in layout:
image_path = layout_item['image_path']
dso_names = layout_item['dso_names']
start_col = layout_item['start_col']
start_row = layout_item['start_row']
span_cols = layout_item['span_cols']
span_rows = layout_item['span_rows']
# Load and process the image
try:
image = Image.open(image_path)
# Calculate merged cell size
merged_width = (self.cell_size[0] * span_cols) + (self.spacing * (span_cols - 1))
merged_height = (self.cell_size[1] * span_rows) + (self.spacing * (span_rows - 1))
# Resize image to fit the merged cell
image = self._resize_image_to_fit(image, (merged_width, merged_height))
# Calculate position on canvas
x = self.spacing + (start_col * (self.cell_size[0] + self.spacing))
y = self.spacing + (start_row * (self.cell_size[1] + self.spacing))
# Center image within merged cell
x_offset = (merged_width - image.width) // 2
y_offset = (merged_height - image.height) // 2
canvas.paste(image, (x + x_offset, y + y_offset))
# Add DSO name labels if enabled
if self.show_labels and dso_names:
# Choose font size based on merged cell size
total_cells = span_cols * span_rows
if total_cells >= 4: # Large merged cells get larger font
font = large_font
else:
font = base_font
# Create combined label for multiple DSOs
if len(dso_names) == 1:
label_text = dso_names[0]
else:
# For multiple DSOs, display all names for better visibility
if len(dso_names) == 2:
# For 2 DSOs, join with " & " for better readability
label_text = " & ".join(dso_names)
elif len(dso_names) <= 4:
# For 3-4 DSOs, use multi-line format
label_text = "\n".join(dso_names)
else:
# For 5+ DSOs, show first few and count
first_names = dso_names[:3]
remaining_count = len(dso_names) - 3
label_text = "\n".join(first_names) + f"\n+ {remaining_count} more"
# Get text bounding box for positioning calculations
bbox = draw.textbbox((0, 0), label_text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# Calculate text position based on label_position setting
# For merged cells with multiple DSOs, adjust positioning for better visibility
cell_width = span_cols * self.cell_size[0] + (span_cols - 1) * self.spacing
cell_height = span_rows * self.cell_size[1] + (span_rows - 1) * self.spacing
if self.label_position == "Bottom Center":
text_x = x + (cell_width // 2) - (text_width // 2)
text_y = y + cell_height + 5
elif self.label_position == "Top Center":
text_x = x + (cell_width // 2) - (text_width // 2)
text_y = y - text_height - 5
elif self.label_position == "Bottom Left":
text_x = x + 5
text_y = y + cell_height + 5
elif self.label_position == "Bottom Right":
text_x = x + cell_width - text_width - 5
text_y = y + cell_height + 5
elif self.label_position == "Top Left":
text_x = x + 5
text_y = y - text_height - 5
elif self.label_position == "Top Right":
text_x = x + cell_width - text_width - 5
text_y = y - text_height - 5
elif self.label_position == "Center Overlay":
text_x = x + (cell_width // 2) - (text_width // 2)
text_y = y + (cell_height // 2) - (text_height // 2)
else:
# Default to bottom center
text_x = x + (cell_width // 2) - (text_width // 2)
text_y = y + cell_height + 5
# Ensure text stays within merged cell bounds
min_x = x + 2
max_x = x + cell_width - text_width - 2
text_x = max(min_x, min(text_x, max_x))
min_y = y + 2
max_y = y + cell_height - text_height - 2
text_y = max(min_y, min(text_y, max_y))
# Draw semi-transparent background box for better text visibility
padding = 4
box_x1 = text_x - padding
box_y1 = text_y - padding
box_x2 = text_x + text_width + padding
box_y2 = text_y + text_height + padding
# Create semi-transparent background
box_overlay = Image.new('RGBA', (box_x2 - box_x1, box_y2 - box_y1), (0, 0, 0, 128))
canvas.paste(box_overlay, (box_x1, box_y1), box_overlay)
# Use white text with black outline for maximum visibility
text_color = "white"
outline_color = "black"
# Add text outline for better visibility
for dx in [-1, 0, 1]:
for dy in [-1, 0, 1]:
if dx != 0 or dy != 0:
draw.text((text_x + dx, text_y + dy), label_text, font=font, fill=outline_color)
# Draw main text
draw.text((text_x, text_y), label_text, font=font, fill=text_color)
except Exception as e:
print(f"Error processing image {image_path}: {str(e)}")
continue
# Save collage with appropriate format
try:
file_ext = output_path.lower().split('.')[-1]
if file_ext in ['jpg', 'jpeg']:
canvas.save(output_path, 'JPEG', quality=quality)
elif file_ext == 'png':
canvas.save(output_path, 'PNG')
elif file_ext in ['tif', 'tiff']:
canvas.save(output_path, 'TIFF')
else:
canvas.save(output_path)
print(f"Merged collage saved to: {output_path}")
return True
except Exception as e:
print(f"Error saving merged collage: {str(e)}")
return False
def preview_layout(self):
"""Print a preview of the current layout."""
print(f"\nCollage Layout ({self.grid_width}x{self.grid_height}):")
print(f"Cell size: {self.cell_size[0]}x{self.cell_size[1]} pixels")
print(f"Spacing: {self.spacing} pixels")
print(f"Images added: {len(self.images)}/{self.grid_width * self.grid_height}")
if self.images:
print("\nImages:")
for i, path in enumerate(self.image_paths):
row = i // self.grid_width
col = i % self.grid_width
print(f" [{row},{col}] {os.path.basename(path)}")
else:
print("No images added yet")
def preview_merged_layout(self):
"""Print a preview of the merged cell layout."""
print(f"\nMerged Collage Layout ({self.grid_width}x{self.grid_height}):")
print(f"Cell size: {self.cell_size[0]}x{self.cell_size[1]} pixels")
print(f"Spacing: {self.spacing} pixels")
print(f"Images added: {len(self.images)}")
if not self.images:
print("No images added yet")
return
# Show image groupings
image_groups = self._group_images_by_path()
print(f"\nImage groups: {len(image_groups)}")
for image_path, dso_names in image_groups.items():
print(f" {os.path.basename(image_path)}: {len(dso_names)} DSO(s) - {', '.join(dso_names)}")
# Show calculated layout
layout = self._calculate_merged_layout()
print(f"\nMerged layout: {len(layout)} cells")
for i, layout_item in enumerate(layout):
print(f" Cell {i+1}: {os.path.basename(layout_item['image_path'])}")
print(f" Position: ({layout_item['start_col']}, {layout_item['start_row']})")
print(f" Span: {layout_item['span_cols']}x{layout_item['span_rows']}")
print(f" DSOs: {', '.join(layout_item['dso_names'])}")
# Import required Qt classes
try:
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QListWidget, QSpinBox, QGroupBox,
QCheckBox, QSplitter, QWidget, QScrollArea,
QColorDialog, QMessageBox, QInputDialog, QFileDialog,
QProgressDialog, QGridLayout, QLineEdit, QComboBox)
from PySide6.QtCore import Qt, QThread, Signal, QTimer
from PySide6.QtGui import QColor, QDrag, QPixmap
from PySide6.QtCore import QMimeData
from PySide6.QtWidgets import QApplication
from WindowPositionManager import WindowPositionMixin
# Import DatabaseManager (assuming it's available in the main application)
try:
from DatabaseManager import DatabaseManager
except ImportError:
# Fallback if DatabaseManager is not available as a separate module
try:
from Main import DatabaseManager
except ImportError:
DatabaseManager = None
# Import Theme for colors
from Theme import COLORS
except ImportError as e:
print(f"Warning: Could not import required Qt modules: {e}")
# --- Thumbnail Worker Thread ---
class ThumbnailWorker(QThread):
"""Worker thread for generating thumbnails in background"""
# Signal emitted when a thumbnail is ready
thumbnail_ready = Signal(int, int, QPixmap) # row, col, pixmap
thumbnail_error = Signal(int, int, str) # row, col, error_message
def __init__(self, image_requests, cache=None):
super().__init__()
self.image_requests = image_requests # List of (row, col, image_path) tuples
self.cache = cache # ThumbnailCache instance
self.cancelled = False
def cancel(self):
"""Cancel the thumbnail generation"""
self.cancelled = True
def _load_fits_thumbnail(self, fits_path, colormap='gray'):
"""Load a FITS file and convert to QPixmap thumbnail with RGB color mapping - same as DSODetailWindow"""
try:
# Import required libraries
from astropy.io import fits
from astropy.visualization import simple_norm
import numpy as np
from PySide6.QtGui import QImage
# Open FITS file
with fits.open(fits_path) as hdul:
# Get the primary image data (usually the first HDU with data)
image_data = None
for hdu in hdul:
if hdu.data is not None and len(hdu.data.shape) >= 2:
image_data = hdu.data
break
if image_data is None:
return None
# Handle different dimensionalities
is_rgb = False
if len(image_data.shape) > 2:
# Check if this is an RGB image (3 color planes)
if len(image_data.shape) == 3 and image_data.shape[2] == 3:
# This is an RGB FITS image
is_rgb = True
elif len(image_data.shape) == 3 and image_data.shape[0] == 3:
# RGB planes are in first dimension, transpose
image_data = np.transpose(image_data, (1, 2, 0))
is_rgb = True
elif len(image_data.shape) == 3:
# Take the first 2D slice if it's a cube
image_data = image_data[0]
elif len(image_data.shape) == 4:
image_data = image_data[0, 0]
else:
return None
# Normalize the data for display (handle NaN values)
image_data = np.nan_to_num(image_data, nan=0.0, posinf=0.0, neginf=0.0)
if is_rgb:
# Handle RGB FITS data - normalize each channel separately
normalized_data = np.zeros_like(image_data)
for channel in range(3):
channel_data = image_data[:, :, channel]
# Apply normalization to each color channel
try:
norm = simple_norm(channel_data, stretch='linear', percent=99.5)
normalized_data[:, :, channel] = norm(channel_data)
except Exception:
# Fallback to simple min-max normalization per channel
data_min, data_max = np.percentile(channel_data, [0.5, 99.5])
if data_max > data_min:
normalized_data[:, :, channel] = (channel_data - data_min) / (data_max - data_min)
else:
normalized_data[:, :, channel] = channel_data
# Clip to valid range
normalized_data = np.clip(normalized_data, 0, 1)
# Convert directly to 8-bit RGB (no false color mapping needed)
rgb_data = (normalized_data * 255).astype(np.uint8)
# Ensure the array is C-contiguous for QImage
if not rgb_data.flags['C_CONTIGUOUS']:
rgb_data = np.ascontiguousarray(rgb_data)
# Create QImage from RGB array
height, width, channels = rgb_data.shape
bytes_per_line = width * channels
qimage = QImage(rgb_data.data, width, height, bytes_per_line, QImage.Format_RGB888)
else:
# Handle grayscale FITS data
# Apply simple normalization (linear stretch between percentiles)
try:
norm = simple_norm(image_data, stretch='linear', percent=99.5)
normalized_data = norm(image_data)
except Exception:
# Fallback to simple min-max normalization
data_min, data_max = np.percentile(image_data, [0.5, 99.5])
if data_max > data_min:
normalized_data = (image_data - data_min) / (data_max - data_min)
else:
normalized_data = image_data
normalized_data = np.clip(normalized_data, 0, 1)
# For grayscale data, apply color mapping if specified
if colormap == 'gray' or colormap == 'grey':
# Display as grayscale
image_8bit = (normalized_data * 255).astype(np.uint8)
# Ensure the array is C-contiguous for QImage
if not image_8bit.flags['C_CONTIGUOUS']:
image_8bit = np.ascontiguousarray(image_8bit)
height, width = image_8bit.shape
bytes_per_line = width
qimage = QImage(image_8bit.data, width, height, bytes_per_line, QImage.Format_Grayscale8)
else:
# Apply color mapping for better visualization
try:
import matplotlib.pyplot as plt
import matplotlib.cm as cm
# Apply a color map for better astronomical visualization
try:
cmap = cm.get_cmap(colormap)
except ValueError:
cmap = cm.get_cmap('viridis')
colored_data = cmap(normalized_data)
# Convert to 8-bit RGB
rgb_data = (colored_data[:, :, :3] * 255).astype(np.uint8)
# Ensure the array is C-contiguous for QImage
if not rgb_data.flags['C_CONTIGUOUS']:
rgb_data = np.ascontiguousarray(rgb_data)
# Create QImage from RGB array
height, width, channels = rgb_data.shape
bytes_per_line = width * channels
qimage = QImage(rgb_data.data, width, height, bytes_per_line, QImage.Format_RGB888)
except ImportError:
# Matplotlib not available, fallback to grayscale
image_8bit = (normalized_data * 255).astype(np.uint8)
# Ensure the array is C-contiguous for QImage
if not image_8bit.flags['C_CONTIGUOUS']:
image_8bit = np.ascontiguousarray(image_8bit)
height, width = image_8bit.shape
bytes_per_line = width
qimage = QImage(image_8bit.data, width, height, bytes_per_line, QImage.Format_Grayscale8)
except Exception:
# Color mapping failed, fallback to grayscale
image_8bit = (normalized_data * 255).astype(np.uint8)
# Ensure the array is C-contiguous for QImage
if not image_8bit.flags['C_CONTIGUOUS']:
image_8bit = np.ascontiguousarray(image_8bit)
height, width = image_8bit.shape
bytes_per_line = width
qimage = QImage(image_8bit.data, width, height, bytes_per_line, QImage.Format_Grayscale8)
# Convert to QPixmap
return QPixmap.fromImage(qimage)
except ImportError:
# astropy not available
return None
except Exception:
# Other FITS loading error
return None
def run(self):
"""Generate thumbnails in background"""
for row, col, image_path in self.image_requests:
if self.cancelled:
break
try:
# Check cache first
if self.cache:
cached_pixmap = self.cache.get(image_path)
if cached_pixmap:
self.thumbnail_ready.emit(row, col, cached_pixmap)
continue
if os.path.exists(image_path):
# Check file size first
file_size = os.path.getsize(image_path)
if file_size == 0:
self.thumbnail_error.emit(row, col, "Empty File")
continue
# Get file extension
_, ext = os.path.splitext(image_path.lower())
# Try different loading methods based on file type
pixmap = None
# For FITS files, use the same method as DSODetailWindow
if ext in ['.fits', '.fit', '.fts']:
pixmap = self._load_fits_thumbnail(image_path)
if pixmap is None:
self.thumbnail_error.emit(row, col, "FITS Load\nError")
continue
else:
# Load regular image formats with better error handling - same approach as DSODetailWindow
from PySide6.QtGui import QImageReader
# Increase maximum allocation limit for large images (in MB) - same as DSODetailWindow
QImageReader.setAllocationLimit(512)
# First try standard QPixmap loading
pixmap = QPixmap(image_path)
# If Qt fails to load, try with QImageReader for better error reporting
if pixmap.isNull():
try:
reader = QImageReader(image_path)
# Check if reader can read the file first
if reader.canRead():
# Try setting explicit format
if ext in ['.jpg', '.jpeg']:
reader.setFormat(b"JPEG")
elif ext == '.png':
reader.setFormat(b"PNG")
elif ext == '.bmp':
reader.setFormat(b"BMP")
elif ext in ['.tiff', '.tif']:
reader.setFormat(b"TIFF")
image = reader.read()
if not image.isNull():
pixmap = QPixmap.fromImage(image)
else:
# Log the specific error from QImageReader
logger.warning(f"QImageReader cannot read file {image_path}, error: {reader.errorString()}")
except Exception as e:
logger.warning(f"Error with QImageReader for {image_path}: {e}")
pass # Fall through to error handling below
if pixmap and not pixmap.isNull():
# Scale the image to fit while maintaining aspect ratio
scaled_pixmap = pixmap.scaled(120, 100, Qt.KeepAspectRatio, Qt.SmoothTransformation)
# Cache the generated thumbnail
if self.cache:
self.cache.put(image_path, scaled_pixmap)
self.thumbnail_ready.emit(row, col, scaled_pixmap)
else:
# Log more detailed error info
logger.warning(f"Failed to load image thumbnail: {image_path} (extension: {ext}, size: {file_size} bytes)")
error_msg = f"Invalid {ext.upper()}\nFormat"
self.thumbnail_error.emit(row, col, error_msg)
else:
self.thumbnail_error.emit(row, col, "File Not\nFound")
except Exception as e:
# More detailed error reporting
error_msg = f"Error:\n{str(e)[:20]}..."
self.thumbnail_error.emit(row, col, error_msg)
# --- Thumbnail Cache ---
class ThumbnailCache:
"""Cache for storing generated thumbnails to avoid regeneration"""
def __init__(self, max_size=100):
self._cache = {} # image_path -> QPixmap
self._max_size = max_size
self._access_order = [] # Track access order for LRU eviction
def get(self, image_path):
"""Get cached thumbnail for image path"""
if image_path in self._cache:
# Move to end (most recently used)
if image_path in self._access_order:
self._access_order.remove(image_path)
self._access_order.append(image_path)
return self._cache[image_path]
return None
def put(self, image_path, pixmap):
"""Store thumbnail in cache"""
if image_path in self._cache:
# Update existing entry
if image_path in self._access_order:
self._access_order.remove(image_path)
elif len(self._cache) >= self._max_size:
# Remove least recently used item
if self._access_order:
lru_path = self._access_order.pop(0)
if lru_path in self._cache:
del self._cache[lru_path]
self._cache[image_path] = pixmap
self._access_order.append(image_path)
def clear(self):
"""Clear all cached thumbnails"""
self._cache.clear()
self._access_order.clear()
def size(self):
"""Return current cache size"""
return len(self._cache)
# --- Draggable Cell Widget ---
class DraggableCell(QWidget):
"""A draggable cell widget for grid reordering"""
def __init__(self, row, col, index, parent_window):
super().__init__()
self.row = row
self.col = col
self.index = index
self.parent_window = parent_window
self.image_data = None
self.has_image = False
self.setAcceptDrops(True)