Repository navigation
Expand file tree
/
Copy pathftwindow_slots.cpp
More file actions
8320 lines (7512 loc) · 328 KB
/
Copy pathftwindow_slots.cpp
File metadata and controls
8320 lines (7512 loc) · 328 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
#include "ftwindow_common.h"
#include <cstdlib> // std::malloc / std::free (allocation preflight probe)
#include <QFileInfo>
#include <QImageReader>
#include <QImageWriter>
#include <QComboBox>
#include <QCheckBox>
#include <QLabel>
#include <QLineEdit>
#include <QHBoxLayout>
#include <QPdfWriter>
#include <QPageSize>
#include <QPageLayout>
#ifndef __EMSCRIPTEN__
#include <QStorageInfo> // network-volume check for the startup history restore
#else
#include <QJsonDocument> // WASM session history is persisted as JSON in localStorage
#include <QJsonObject>
#include <QJsonArray>
#endif
// Probe whether `bytes` can currently be allocated. The WASM build links with
// ABORTING_MALLOC=0, so a failed malloc returns null instead of aborting the
// app, making this a safe test. A single contiguous probe is deliberately
// conservative: it is stricter than the many smaller allocations the real work
// performs, so passing it is a strong signal the operation will fit. On desktop
// memory is effectively unlimited, so the probe is skipped.
static bool probeAlloc(qint64 bytes)
{
#ifdef __EMSCRIPTEN__
if (bytes <= 0) return true;
void *p = std::malloc(static_cast<size_t>(bytes));
if (!p) return false;
std::free(p);
return true;
#else
Q_UNUSED(bytes);
return true;
#endif
}
// Run body(i) for i in [begin, end) across the available worker threads (the
// same pool used by the FFT). Falls back to a serial loop when threads are
// unavailable or the range is small. body must be safe to call concurrently —
// callers here only ever write to disjoint indices.
template <typename F>
static void parallelFor(int begin, int end, F &&body)
{
int n = end - begin;
if (n <= 0) return;
#if FT_HAVE_THREADS
int nThreads = (int)std::thread::hardware_concurrency();
if (nThreads < 1) nThreads = 1;
if (nThreads > 1 && n >= 4096) {
std::vector<std::thread> threads;
int per = (n + nThreads - 1) / nThreads;
for (int t = 0; t < nThreads; t++) {
int a = begin + t * per;
int b = std::min(a + per, end);
if (a < b)
threads.emplace_back([a, b, &body]() {
for (int i = a; i < b; i++) body(i);
});
}
for (auto &th : threads) th.join();
return;
}
#endif
for (int i = begin; i < end; i++) body(i);
}
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#include <emscripten/html5.h>
// Browser fullscreen state changes are picked up through Emscripten's own event
// API rather than a hand-written JS listener calling back in. A listener in JS
// has no reliable way to reach us: it would need the module object to call an
// exported function, and Qt's qtloader.js keeps the instance to itself — it
// never publishes it as window.Module — so such a listener silently does
// nothing and the button keeps its old label. Registering here hands Emscripten
// a plain C function pointer and takes JS out of the path. Emscripten attaches
// both "fullscreenchange" and the "webkitfullscreenchange" that older Safari
// needs, so every route in and out of fullscreen is caught: our own button, F11,
// and ESC.
static EM_BOOL ftFullscreenChanged(int, const EmscriptenFullscreenChangeEvent *e,
void *userData)
{
if (auto *w = static_cast<FtWindow *>(userData))
w->handleBrowserFullscreenChange(e->isFullscreen != 0);
return EM_FALSE; // observe only; leave the event to the browser
}
// ---- WASM session-history persistence (localStorage) ----------------------
// The browser has no filesystem, so the last session's buffers are remembered
// by their re-fetchable identity (the example-image path) rather than by data.
// On the next launch small images are re-fetched automatically; larger ones
// become "click to load" placeholders. See save/restoreHistory() below.
// localStorage key holding the session history JSON. The "v1" suffix lets a
// future format change be recognised and ignored rather than mis-parsed.
static const char *const kWasmHistoryKey = "ftHistoryV1";
// Square side (px) up to which a remembered image is re-fetched automatically at
// launch; anything larger is left as a deferred "click to load" placeholder.
static constexpr int kWasmAutoRestoreMaxSide = 1024;
// Only paths that look like an actual image file can be re-fetched from the
// server. Generated results (math/align/phase-ramp) carry a human label as
// their "path" and must never be persisted as if they were fetchable.
static bool wasmIsRefetchablePath(const QString &path)
{
if (path.isEmpty()) return false;
static const char *const exts[] =
{ ".png", ".tif", ".tiff", ".jpg", ".jpeg", ".mrc" };
for (const char *e : exts)
if (path.endsWith(QLatin1String(e), Qt::CaseInsensitive)) return true;
return false;
}
static void wasmLocalStorageSet(const char *key, const QByteArray &value)
{
EM_ASM({
try { localStorage.setItem(UTF8ToString($0), UTF8ToString($1)); }
catch (e) { console.warn('ft: localStorage write failed', e); }
}, key, value.constData());
}
static QByteArray wasmLocalStorageGet(const char *key)
{
// Copy the stored value into a caller-owned buffer, avoiding any dependency
// on _malloc being reachable from JS. The history JSON is ~1 KB, so 64 KB is
// ample; a value that somehow exceeds it is treated as absent.
QByteArray buf(64 * 1024, '\0');
const int n = EM_ASM_INT({
var v;
try { v = localStorage.getItem(UTF8ToString($0)); } catch (e) { v = null; }
if (v === null || v === undefined) return -1;
stringToUTF8(v, $1, $2); // truncates to $2 bytes (incl. null)
return lengthBytesUTF8(v);
}, key, buf.data(), buf.size());
if (n < 0 || n >= buf.size()) return QByteArray();
buf.resize(n);
return buf;
}
// Largest source-image dimension permitted in the 32-bit WASM build. Bigger
// inputs are downsampled on load so one image cannot, on its own, consume an
// outsized fraction of the capped linear heap (see CMakeLists.txt). A 4096
// image already costs ~128 MB of raw pixels plus ~256 MB of FFT working set.
static constexpr int kMaxLoadDim = 4096;
// Cap on the total memory held by the undo + redo history. Each undo snapshot
// deep-copies every buffer, so the history can dwarf the live data; this bound
// keeps headroom free for the calculation itself in the capped WASM heap.
static constexpr qint64 kUndoBudgetBytes = 768LL * 1024 * 1024;
#else
// Effectively unlimited on desktop: behaviour is unchanged there.
static constexpr int kMaxLoadDim = 1 << 20;
static constexpr qint64 kUndoBudgetBytes = 8LL * 1024 * 1024 * 1024;
#endif
// ---------------------------------------------------------------------------
// Slots
// ---------------------------------------------------------------------------
void FtWindow::onLoadImage()
{
#ifdef __EMSCRIPTEN__
// Build a list of example images served alongside the WASM app.
QStringList items;
items << "Exercise_01-Photos/band_1995.png"
<< "Exercise_01-Photos/brain.png"
<< "Exercise_01-Photos/church.png"
<< "Exercise_01-Photos/jacques_andreas_2017.png"
<< "Exercise_01-Photos/jacques_titan.png"
<< "Exercise_01-Photos/lorenz_1999.png"
<< "Exercise_01-Photos/party_1995.png"
<< "Exercise_01-Photos/plants.png"
<< "Exercise_02-Restauration/restauration_512.png"
<< "Exercise_03-Single_particle_EM/apoferritin_1024.png"
<< "Exercise_03-Single_particle_EM/apoferritin_1024_anisotropic.png"
<< "Exercise_03-Single_particle_EM/apoferritin_2048.png"
<< "Exercise_03-Single_particle_EM/apoferritin_2048_anisotropic.png"
<< "Exercise_04-2D-Crystal/2d_protein_crystal_1024.png"
<< "Exercise_04-2D-Crystal/Biozentrum-Basel-Eyes_1024.png"
<< "Exercise_04-2D-Crystal/Polyhead_virus_512.png"
<< "Exercise_05-Apple-Reconstruction/APPL0000000100.mrc"
<< "Exercise_05-Apple-Reconstruction/APPL0012345604.mrc"
<< "Exercise_05-Apple-Reconstruction/apple_clean.png"
<< "Exercise_05-Apple-Reconstruction/apple_corners_labeled.png"
<< "Exercise_05-Apple-Reconstruction/apple_noisy.png"
<< "Exercise_05-Apple-Reconstruction/apple_very_noisy.png"
<< "Exercise_06-Photo-Multiplication/andreas_1024.png"
<< "Exercise_07-PhaseRamp/jacques_titan.png"
<< "Exercise_08-TextRecognition-by-Correlation/Dot_black_center_1024.png"
<< "Exercise_08-TextRecognition-by-Correlation/Fourier_text_black_1024.png"
<< "Exercise_08-TextRecognition-by-Correlation/Fourier_text_white_1024.png"
<< "Exercise_08-TextRecognition-by-Correlation/Fourier_word_black_1024.png"
<< "Exercise_08-TextRecognition-by-Correlation/Letter_f_black_1024.png"
<< "Exercise_08-TextRecognition-by-Correlation/Letter_o_white_center_1024.png"
<< "Exercise_08-TextRecognition-by-Correlation/Letter_o_white_corner_1024.png"
<< "Exercise_08-TextRecognition-by-Correlation/Ring_black_1024.png"
<< "Exercise_08-TextRecognition-by-Correlation/Smiley_black_1024.png"
<< "Exercise_08-TextRecognition-by-Correlation/Smiley_small_black_1024.png"
<< "Exercise_09-Artemin-Protein/size_1024_rescaled_cropped/apoartcns_006.png"
<< "Exercise_09-Artemin-Protein/size_1024_rescaled_cropped/apoartcns_010.png"
<< "Exercise_09-Artemin-Protein/size_1024_rescaled_cropped/apoartcns_011.png"
<< "Exercise_09-Artemin-Protein/size_1024_rescaled_cropped/apoartcns_012.png"
<< "Exercise_09-Artemin-Protein/size_1024_rescaled_cropped/apoartcns_013.png"
<< "Exercise_09-Artemin-Protein/size_1024_rescaled_cropped/apoartcns_014.png"
<< "Exercise_09-Artemin-Protein/size_1024_rescaled_cropped/apoartcns_015.png"
<< "Exercise_09-Artemin-Protein/size_1024_rescaled_cropped/apoartcns_016.png"
<< "Exercise_09-Artemin-Protein/size_1024_rescaled_cropped/reference1_1024_rescaled_cropped.png"
<< "Exercise_09-Artemin-Protein/size_1024_rescaled_cropped/reference2_1024_rescaled_cropped.png"
<< "Exercise_09-Artemin-Protein/size_2048_cropped/apoartcns_006.png"
<< "Exercise_09-Artemin-Protein/size_2048_cropped/apoartcns_010.png"
<< "Exercise_09-Artemin-Protein/size_2048_cropped/apoartcns_011.png"
<< "Exercise_09-Artemin-Protein/size_2048_cropped/apoartcns_012.png"
<< "Exercise_09-Artemin-Protein/size_2048_cropped/apoartcns_013.png"
<< "Exercise_09-Artemin-Protein/size_2048_cropped/apoartcns_014.png"
<< "Exercise_09-Artemin-Protein/size_2048_cropped/apoartcns_015.png"
<< "Exercise_09-Artemin-Protein/size_2048_cropped/apoartcns_016.png"
<< "Exercise_09-Artemin-Protein/size_2048_cropped/reference1_2048_cropped.png"
<< "Exercise_09-Artemin-Protein/size_2048_cropped/reference2_2048_cropped.png"
<< "Exercise_09-Artemin-Protein/size_2048_rescaled/apoartcns_006.png"
<< "Exercise_09-Artemin-Protein/size_2048_rescaled/apoartcns_010.png"
<< "Exercise_09-Artemin-Protein/size_2048_rescaled/apoartcns_011.png"
<< "Exercise_09-Artemin-Protein/size_2048_rescaled/apoartcns_012.png"
<< "Exercise_09-Artemin-Protein/size_2048_rescaled/apoartcns_013.png"
<< "Exercise_09-Artemin-Protein/size_2048_rescaled/apoartcns_014.png"
<< "Exercise_09-Artemin-Protein/size_2048_rescaled/apoartcns_015.png"
<< "Exercise_09-Artemin-Protein/size_2048_rescaled/apoartcns_016.png"
<< "Exercise_09-Artemin-Protein/size_2048_rescaled/reference1_2048_rescaled.png"
<< "Exercise_09-Artemin-Protein/size_2048_rescaled/reference2_2048_rescaled.png"
<< "Exercise_10-Fibrils/aSyn_PFF1_c2_1024.mrc"
<< "Exercise_10-Fibrils/aSyn_PFF1_c2_BGzero_1024.mrc"
<< "Exercise_10-Fibrils/aSyn_PFF2_c2_1024.mrc"
<< "Exercise_10-Fibrils/aSyn_PFF2_c2_BGzero_1024.mrc"
<< "Exercise_10-Fibrils/aSyn_cryoEM_image_1024.mrc"
<< "Exercise_10-Fibrils/aSyn_cryoEM_image_1024mask.png"
<< "Exercise_10-Fibrils/aSyn_cryoEM_image_2048.mrc"
<< "Exercise_10-Fibrils/aSyn_dragon_c1_BGzero_1024.mrc"
<< "Exercise_10-Fibrils/aSyn_dragon_c2_BGzero_1024.mrc"
<< "Exercise_11-Alignment/Bacteriophages.png"
<< "Exercise_11-Alignment/Phage1.png"
<< "Exercise_11-Alignment/Phage1b.png"
<< "Exercise_11-Alignment/Phage1c.png"
<< "Exercise_11-Alignment/Phage2.png"
<< "Exercise_11-Alignment/Phage2b.png"
<< "Exercise_11-Alignment/Phage2c.png"
<< "Exercise_11-Alignment/Phage3b.png"
<< "Exercise_11-Alignment/Phage3c.png"
<< "Exercise_12-CTF/Example_200nm_0nm.mrc"
<< "Exercise_12-CTF/Example_325nm_50nm.mrc"
<< "Exercise_12-CTF/Example_400nm_0nm.mrc"
<< "Exercise_12-CTF/Example_300nm_200nm.mrc"
<< "Exercise_12-CTF/Example_975nm_50nm.mrc"
<< "Exercise_12-CTF/Example_1500nm_200nm.mrc"
<< "Exercise_12-CTF/Example_5000nm_0nm.mrc"
<< "Exercise_12-CTF/Example_4150nm_1700nm.mrc"
<< "Exercise_12-CTF/Example_0nm_1000nm.mrc"
<< "Exercise_12-CTF/Example_50nm_500nm.mrc"
<< "Exercise_12-CTF/Example_550nm_500nm.mrc"
<< "Exercise_12-CTF/Example_8500nm_3000nm.mrc";
// Use a QListWidget inside a QDialog so the list scrolls within the
// dialog instead of spilling off the page like a combo-box popup.
auto *dlg = new QDialog(this);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->setWindowTitle("Load Example Image");
dlg->setModal(true);
auto *layout = new QVBoxLayout(dlg);
auto *list = new QListWidget(dlg);
list->addItems(items);
if (!items.isEmpty())
list->setCurrentRow(0);
layout->addWidget(list);
// Let the user upload an image straight from their own computer via the
// browser's native file picker, as an alternative to the example list.
auto *uploadBtn = new QPushButton("Upload from my computer…", dlg);
layout->addWidget(uploadBtn);
connect(uploadBtn, &QPushButton::clicked, this, [this, dlg]() {
dlg->reject(); // close the example chooser without loading a list item
QFileDialog::getOpenFileContent(
"Images (*.tif *.tiff *.jpg *.jpeg *.png *.mrc *.MRC)",
[this](const QString &fileName, const QByteArray &fileContent) {
if (fileName.isEmpty()) // user cancelled the picker
return;
loadImageData(fileName, fileContent);
});
});
auto *buttons = new QDialogButtonBox(
QDialogButtonBox::Ok | QDialogButtonBox::Cancel, dlg);
layout->addWidget(buttons);
connect(buttons, &QDialogButtonBox::accepted, dlg, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, dlg, &QDialog::reject);
connect(list, &QListWidget::itemDoubleClicked, dlg, &QDialog::accept);
// Constrain dialog so it fits inside the browser viewport.
if (auto *scr = QGuiApplication::primaryScreen()) {
QSize avail = scr->availableSize();
dlg->resize(qMin(500, avail.width() - 40),
qMin(500, avail.height() - 80));
} else {
dlg->resize(500, 500);
}
connect(dlg, &QDialog::accepted, this, [this, list]() {
if (auto *it = list->currentItem()) {
QString chosen = it->text();
if (!chosen.isEmpty())
fetchAndLoadImage(chosen);
}
});
dlg->open();
#else
// Prefer an embedder-supplied override (see FtWindow::setExampleImagesDir),
// fall back to the standalone layout, then to the app directory.
QString startDir = FtWindow::exampleImagesDir();
if (startDir.isEmpty() || !QDir(startDir).exists())
startDir = QCoreApplication::applicationDirPath() + "/../EXAMPLE_IMAGES";
if (!QDir(startDir).exists())
startDir = QCoreApplication::applicationDirPath();
QString path = QFileDialog::getOpenFileName(
this, "Load image", startDir,
"Images (*.tif *.tiff *.jpg *.jpeg *.png *.mrc *.MRC)");
if (path.isEmpty()) return;
loadImageFile(path);
#endif
}
QImage FtWindow::renderSaveImage(bool withScaleBar) const
{
if (m_image.isNull()) return QImage();
QImage img = m_image.convertToFormat(QImage::Format_RGB32);
if (!withScaleBar || !(m_pixelSize > 0.0)) return img;
const int W = img.width(), H = img.height();
// Physical length one image pixel spans, reported in nm (pixel size is Å).
const double nmPerPx = m_pixelSize / 10.0;
const double targetPx = W / 8.0; // aim for ~1/8 of the width
const double rawNm = nmPerPx * targetPx;
if (!(rawNm > 0.0) || !std::isfinite(rawNm)) return img;
// Round to the nearest 1 / 2 / 5 × 10^k.
const double e = std::floor(std::log10(rawNm));
const double base = std::pow(10.0, e);
const double f = rawNm / base;
const double nf = (f < 1.5) ? 1.0 : (f < 3.5) ? 2.0 : (f < 7.5) ? 5.0 : 10.0;
const double niceNm = nf * base;
const double barPx = niceNm / nmPerPx;
if (!(barPx > 2.0) || barPx > W) return img;
QPainter p(&img);
const int iBar = (int)std::lround(barPx);
const int barH = std::max(3, H / 120);
const int pad = std::max(8, W / 40);
const int bx = W - pad - iBar;
const int by = H - pad - barH;
const QString label = QString("%1 nm").arg(QString::number(niceNm, 'g', 3));
QFont sf; sf.setPixelSize(std::max(11, H / 32)); sf.setBold(true);
p.setFont(sf);
QFontMetrics sfm(sf);
const int tw = sfm.horizontalAdvance(label);
const int tx = bx + iBar - tw;
const int ty = by - 6;
p.setRenderHint(QPainter::Antialiasing, false);
p.setPen(Qt::NoPen);
p.setBrush(QColor(0, 0, 0, 140)); // faint shadow behind the bar
p.drawRect(bx - 2, by - 2, iBar + 4, barH + 4);
p.setRenderHint(QPainter::Antialiasing, true);
p.setPen(QColor(0, 0, 0, 160)); // text shadow
for (const QPoint &d : { QPoint(1, 1), QPoint(-1, 1), QPoint(1, -1), QPoint(-1, -1) })
p.drawText(tx + d.x(), ty + d.y(), label);
p.setRenderHint(QPainter::Antialiasing, false);
p.fillRect(QRect(bx, by, iBar, barH), Qt::white); // the bar
p.setRenderHint(QPainter::Antialiasing, true);
p.setPen(Qt::white);
p.drawText(tx, ty, label);
if (m_pixelSizeAssumed) {
const QString note = QString::fromUtf8("(if 1px = 1Å)");
QFont nfo; nfo.setPixelSize(std::max(9, H / 48));
p.setFont(nfo);
QFontMetrics nfm(nfo);
const int ntw = nfm.horizontalAdvance(note);
const int ntx = bx + iBar - ntw;
const int nty = by + barH + 2 + nfm.ascent();
p.setPen(QColor(0, 0, 0, 160));
for (const QPoint &d : { QPoint(1, 1), QPoint(-1, 1), QPoint(1, -1), QPoint(-1, -1) })
p.drawText(ntx + d.x(), nty + d.y(), note);
p.setPen(Qt::white);
p.drawText(ntx, nty, note);
}
p.end();
return img;
}
// One entry per offered export format. `ext` is the file suffix; `qtFormat` is
// the QImageWriter format (null for the MRC and PDF special cases).
namespace {
struct SaveFormat {
const char *label;
const char *ext;
const char *qtFormat;
int quality; // JPEG quality, else -1
bool isMrc;
bool isPdf;
};
const SaveFormat kSaveFormats[] = {
{ "MRC (raw float data)", "mrc", nullptr, -1, true, false },
{ "PNG", "png", "PNG", -1, false, false },
{ "JPEG (90% quality)", "jpg", "JPEG", 90, false, false },
{ "JPEG (80% quality)", "jpg", "JPEG", 80, false, false },
{ "JPEG (50% quality)", "jpg", "JPEG", 50, false, false },
{ "PDF", "pdf", nullptr, -1, false, true },
{ "TIFF", "tif", "TIFF", -1, false, false },
};
const int kNumSaveFormats = (int)(sizeof(kSaveFormats) / sizeof(kSaveFormats[0]));
}
// Encode the current image in the chosen format. Returns empty on failure.
static QByteArray encodeSaveBytes(const SaveFormat &fmt, const QImage &rgb,
const std::vector<double> &rawPixels,
int w, int h, double pixelSize)
{
if (fmt.isMrc) {
// Raw data export: the scale bar does not apply. Use the float pixels
// when they match the image, else fall back to the 8-bit greys.
if ((qint64)rawPixels.size() == (qint64)w * h)
return saveMrcToData(rawPixels, w, h, pixelSize);
std::vector<double> px((size_t)w * h);
for (int y = 0; y < h; y++) {
const uchar *row = rgb.constScanLine(y);
for (int x = 0; x < w; x++)
px[(size_t)y * w + x] = qGray(reinterpret_cast<const QRgb *>(row)[x]);
}
return saveMrcToData(px, w, h, pixelSize);
}
if (fmt.isPdf) {
QByteArray out;
QBuffer buf(&out);
buf.open(QIODevice::WriteOnly);
QPdfWriter writer(&buf);
writer.setPageSize(QPageSize(QPageSize::A4));
writer.setPageMargins(QMarginsF(0, 0, 0, 0));
writer.setResolution(150);
QPainter pp(&writer);
QRect page = pp.viewport();
QSize sz = rgb.size();
sz.scale(page.size(), Qt::KeepAspectRatio); // fit, preserve aspect
QRect target((page.width() - sz.width()) / 2,
(page.height() - sz.height()) / 2,
sz.width(), sz.height());
pp.drawImage(target, rgb);
pp.end();
buf.close();
return out;
}
QByteArray out;
QBuffer buf(&out);
buf.open(QIODevice::WriteOnly);
if (!rgb.save(&buf, fmt.qtFormat, fmt.quality))
out.clear();
buf.close();
return out;
}
// Give `name` the format's canonical extension, first stripping any recognised
// trailing extension for that format so it is never doubled — e.g. a JPEG named
// "photo.jpeg" becomes "photo.jpg", not "photo.jpeg.jpg", and a TIFF named
// "img.tiff" becomes "img.tif", not "img.tiff.tif".
static QString applySaveExtension(QString name, const SaveFormat &fmt)
{
const QString canon = QString::fromLatin1(fmt.ext);
QStringList exts;
exts << canon;
if (canon == "jpg") exts << "jpeg";
if (canon == "tif") exts << "tiff";
for (const QString &e : exts) {
if (name.endsWith("." + e, Qt::CaseInsensitive)) {
name.chop(e.size() + 1);
break;
}
}
return name + "." + canon;
}
void FtWindow::onSaveImage()
{
if (m_image.isNull()) return;
// ---- format / scale-bar chooser --------------------------------------
auto *dlg = new QDialog(this);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->setModal(true);
dlg->setWindowTitle(tr("Save image"));
dlg->setMinimumWidth(420);
dlg->setStyleSheet("QDialog { background:#333; } QLabel { color:#eee; font-size:14px; }");
auto *layout = new QVBoxLayout(dlg);
layout->setContentsMargins(18, 18, 18, 18);
layout->setSpacing(10);
#ifdef __EMSCRIPTEN__
// The browser cannot pick a save location, only a file name.
auto *nameLabel = new QLabel(tr("File name:"), dlg);
layout->addWidget(nameLabel);
auto *nameEdit = new QLineEdit("image", dlg);
nameEdit->setStyleSheet("background:#222; color:white; border:1px solid #888;"
" padding:6px; font-size:14px;");
layout->addWidget(nameEdit);
#endif
auto *fmtLabel = new QLabel(tr("File format:"), dlg);
layout->addWidget(fmtLabel);
auto *fmtCombo = new QComboBox(dlg);
fmtCombo->setMinimumWidth(320);
fmtCombo->setMinimumHeight(30);
fmtCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents);
fmtCombo->setStyleSheet(
"QComboBox { background:white; color:black; border:1px solid #888;"
" padding:6px 10px; font-size:14px; }"
"QComboBox::drop-down { width:24px; }"
"QComboBox QAbstractItemView { background:white; color:black; font-size:14px;"
" padding:4px; outline:0; selection-background-color:#cce4ff;"
" selection-color:black; }");
// Only offer QImage formats this Qt build can actually write (TIFF, for
// instance, needs a plugin that is not always shipped). MRC and PDF are
// produced by our own code, so they are always available. Each item carries
// its index into kSaveFormats as user data.
const QList<QByteArray> writable = QImageWriter::supportedImageFormats();
for (int i = 0; i < kNumSaveFormats; i++) {
const SaveFormat &f = kSaveFormats[i];
bool ok = f.isMrc || f.isPdf;
if (!ok && f.qtFormat)
ok = writable.contains(QByteArray(f.qtFormat).toLower());
if (ok)
fmtCombo->addItem(QString::fromLatin1(f.label), i);
}
// Default to PNG when present.
for (int i = 0; i < fmtCombo->count(); i++)
if (fmtCombo->itemData(i).toInt() == 1) { fmtCombo->setCurrentIndex(i); break; }
layout->addWidget(fmtCombo);
auto *scaleBarCheck = new QCheckBox(tr("Include scale bar"), dlg);
scaleBarCheck->setStyleSheet("color:#eee; font-size:14px; padding:4px 0;");
scaleBarCheck->setChecked(true);
layout->addWidget(scaleBarCheck);
// The scale bar is meaningless for the raw-data MRC export.
auto syncScaleBar = [scaleBarCheck, fmtCombo](int comboIdx) {
const int fi = fmtCombo->itemData(comboIdx).toInt();
const bool isMrc = (fi >= 0 && fi < kNumSaveFormats && kSaveFormats[fi].isMrc);
scaleBarCheck->setEnabled(!isMrc);
};
connect(fmtCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), dlg, syncScaleBar);
syncScaleBar(fmtCombo->currentIndex());
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, dlg);
buttons->setStyleSheet(
"QPushButton { background-color:#888; border:2px outset #aaa; color:#eee; padding:2px 12px; }");
layout->addWidget(buttons);
connect(buttons, &QDialogButtonBox::accepted, dlg, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, dlg, &QDialog::reject);
connect(dlg, &QDialog::accepted, this, [=]() {
const int idx = fmtCombo->currentData().toInt();
if (idx < 0 || idx >= kNumSaveFormats) return;
const SaveFormat &fmt = kSaveFormats[idx];
const bool withBar = scaleBarCheck->isChecked() && scaleBarCheck->isEnabled();
const QImage rgb = renderSaveImage(withBar);
const int w = m_image.width(), h = m_image.height();
const QByteArray bytes =
encodeSaveBytes(fmt, rgb, m_imageRawPixels, w, h, m_pixelSize);
if (bytes.isEmpty()) {
QMessageBox::warning(this, tr("Save image"),
tr("Could not encode the image as %1.").arg(QString::fromLatin1(fmt.label)));
return;
}
#ifdef __EMSCRIPTEN__
QString name = nameEdit->text().trimmed();
if (name.isEmpty()) name = "image";
name = applySaveExtension(name, fmt);
QFileDialog::saveFileContent(bytes, name);
#else
const QString filter =
QString("%1 (*.%2)").arg(QString::fromLatin1(fmt.label), fmt.ext);
QString path = QFileDialog::getSaveFileName(this, tr("Save image"), QString(), filter);
if (path.isEmpty()) return;
path = applySaveExtension(path, fmt);
QFile f(path);
if (!f.open(QIODevice::WriteOnly) || f.write(bytes) != bytes.size()) {
QMessageBox::warning(this, tr("Save image"),
tr("Could not write %1.").arg(path));
}
f.close();
#endif
});
dlg->show();
}
void FtWindow::onCreateImage()
{
onNewImageOpen();
}
void FtWindow::onNewImageOpen()
{
// Dismiss any currently-open math calculation overlays so they do not
// linger behind the Create-or-Copy popup.
if (m_mathActive) onMathCancel();
if (m_ftMathActive) onFtMathCancel();
// Source default: always "New 1024".
m_newImgSrcCombo->setCurrentIndex(1); // "New 1024"
// Target default: active slot if any, else first empty slot, else 0.
int defaultTgt = -1;
if (m_activeSlot >= 0 && m_activeSlot < HISTORY_SLOTS) {
defaultTgt = m_activeSlot;
} else {
for (int i = 0; i < HISTORY_SLOTS; i++) {
if (!m_history[i].occupied) { defaultTgt = i; break; }
}
if (defaultTgt < 0) defaultTgt = 0;
}
m_newImgTgtCombo->setCurrentIndex(defaultTgt);
m_newImageActive = true;
m_newImgSrcCombo->show();
m_newImgTgtCombo->show();
m_newImgCreateBtn->show();
m_newImgCancelBtn->show();
update();
}
void FtWindow::onNewImageCancel()
{
m_newImageActive = false;
m_newImgSrcCombo->hide();
m_newImgTgtCombo->hide();
m_newImgCreateBtn->hide();
m_newImgCancelBtn->hide();
update();
}
void FtWindow::onNewImageCreate()
{
if (!ensureCalcHeadroom(tr("create the image"))) return;
onNewImageCreateImpl();
}
void FtWindow::onNewImageCreateImpl()
{
int srcIdx = m_newImgSrcCombo->currentIndex();
int tgtIdx = m_newImgTgtCombo->currentIndex();
if (tgtIdx < 0 || tgtIdx >= HISTORY_SLOTS) { onNewImageCancel(); return; }
if (srcIdx < 4) {
// Create a new blank image of the selected size in the target slot.
static const int sizes[4] = { 512, 1024, 2048, 4096 };
int sz = sizes[srcIdx];
// Save any currently-active buffer back to its slot before switching.
if (m_activeSlot >= 0 && m_activeSlot != tgtIdx && !m_image.isNull()) {
m_history[m_activeSlot].image = m_image;
m_history[m_activeSlot].path = m_imagePath;
m_history[m_activeSlot].rawPixels = m_imageRawPixels;
m_history[m_activeSlot].minVal = m_imageMinVal;
m_history[m_activeSlot].maxVal = m_imageMaxVal;
m_history[m_activeSlot].pixelSize = m_pixelSize;
m_history[m_activeSlot].powerSpecImg = computePowerSpecMasked(m_image);
m_history[m_activeSlot].occupied = true;
}
m_activeSlot = tgtIdx;
onNewImageCancel();
onCreateImageSized(sz);
return;
}
// Source is a history slot a..p
int slotIdx = srcIdx - 4;
if (slotIdx < 0 || slotIdx >= HISTORY_SLOTS) { onNewImageCancel(); return; }
if (slotIdx == tgtIdx) { onNewImageCancel(); return; }
// Resolve source snapshot (use live buffer if it is the active slot).
HistoryEntry src;
if (slotIdx == m_activeSlot && !m_image.isNull()) {
src.image = m_image;
src.path = m_imagePath;
src.rawPixels = m_imageRawPixels;
src.minVal = m_imageMinVal;
src.maxVal = m_imageMaxVal;
src.pixelSize = m_pixelSize;
src.powerSpecImg = computePowerSpecMasked(m_image);
src.occupied = true;
} else if (m_history[slotIdx].occupied) {
src = m_history[slotIdx];
} else {
onNewImageCancel();
return; // nothing to copy
}
storeUndoSnapshot();
m_history[tgtIdx] = src;
m_history[tgtIdx].occupied = true;
m_activeSlot = tgtIdx;
m_image = m_history[tgtIdx].image;
m_imagePath = m_history[tgtIdx].path;
m_imageRawPixels = m_history[tgtIdx].rawPixels;
m_imageMinVal = m_history[tgtIdx].minVal;
m_imageMaxVal = m_history[tgtIdx].maxVal;
m_imageDispMin = m_imageMinVal;
m_imageDispMax = m_imageMaxVal;
m_pixelSize = m_history[tgtIdx].pixelSize;
m_pixelSizeAssumed = m_history[tgtIdx].pixelSizeAssumed;
m_lastOperation = m_history[tgtIdx].lastOperation;
m_zoom[0].reset(m_image.width(), m_image.height());
m_ftComputed = false;
m_modeBtn->setText(modeLabel());
m_modeBtn->hide();
m_maskBtnVisible = false;
computeFFT();
m_history[tgtIdx].powerSpecImg = computePowerSpecMasked(m_image);
#ifndef __EMSCRIPTEN__
QSettings settings("ft", "ft");
settings.setValue("activeSlot", m_activeSlot);
#endif
saveHistory();
onNewImageCancel();
}
void FtWindow::onCreateImageSized(int sz)
{
// If no slot is active, pick the first empty one (or last slot as fallback)
if (m_activeSlot < 0) {
m_activeSlot = HISTORY_SLOTS - 1;
for (int i = 0; i < HISTORY_SLOTS; i++) {
if (!m_history[i].occupied) { m_activeSlot = i; break; }
}
}
if (sz <= 0) sz = 1024;
m_image = QImage(sz, sz, QImage::Format_Grayscale8);
m_image.fill(0);
m_imageRawPixels.assign((size_t)sz * sz, 0.0);
m_imageMinVal = 0;
m_imageMaxVal = 0;
m_imageDispMin = 0;
m_imageDispMax = 0;
m_pixelSize = 1.0;
m_pixelSizeAssumed = true; // synthetic pattern has no physical scale
m_lastOperation.clear();
m_imagePath.clear();
m_history[m_activeSlot].image = m_image;
m_history[m_activeSlot].path.clear();
m_history[m_activeSlot].rawPixels = m_imageRawPixels;
m_history[m_activeSlot].minVal = 0;
m_history[m_activeSlot].maxVal = 0;
m_history[m_activeSlot].pixelSize = 1.0;
m_history[m_activeSlot].pixelSizeAssumed = true;
m_history[m_activeSlot].lastOperation.clear();
m_history[m_activeSlot].occupied = true;
m_ftComputed = false;
m_modeBtn->setText(modeLabel());
m_modeBtn->hide();
m_maskBtnVisible = false;
m_zoom[0].reset(sz, sz);
computeFFT();
m_history[m_activeSlot].powerSpecImg = computePowerSpecMasked(m_image);
#ifndef __EMSCRIPTEN__
QSettings settings("ft", "ft");
settings.setValue("activeSlot", m_activeSlot);
#endif
saveHistory();
update();
}
void FtWindow::onReloadImage()
{
#ifdef __EMSCRIPTEN__
if (m_imagePath.isEmpty()) return;
// Start reload-button progress animation
m_reloadProgress = 0.0;
if (!m_reloadAnimTimer) {
m_reloadAnimTimer = new QTimer(this);
m_reloadAnimTimer->setInterval(80);
connect(m_reloadAnimTimer, &QTimer::timeout, this, [this]() {
m_reloadProgress = std::min(m_reloadProgress + 0.03, 0.95);
int pct = static_cast<int>(m_reloadProgress * 100);
m_reloadBtn->setStyleSheet(
QString("QPushButton { background: qlineargradient("
"x1:0,y1:0,x2:1,y2:0,"
"stop:0 rgb(180,210,255), stop:%1 rgb(180,210,255),"
"stop:%2 white, stop:1 white); }")
.arg(pct / 100.0, 0, 'f', 3)
.arg((pct + 1) / 100.0, 0, 'f', 3));
});
}
m_reloadAnimTimer->start();
fetchAndLoadImage(m_imagePath);
#else
if (m_imagePath.isEmpty() || !QFile::exists(m_imagePath)) return;
qDebug() << "Reloading image:" << m_imagePath;
if (m_imagePath.endsWith(".mrc", Qt::CaseInsensitive)) {
MrcResult r = loadMrc(m_imagePath);
m_image = r.image;
m_imageRawPixels = std::move(r.rawPixels);
m_imageMinVal = r.minVal;
m_imageMaxVal = r.maxVal;
m_imageDispMin = r.minVal;
m_imageDispMax = r.maxVal;
m_pixelSize = r.pixelSize;
m_pixelSizeAssumed = !r.pixelSizeKnown;
if (!m_image.isNull())
padImageToSquare();
} else {
m_image = QImage(m_imagePath);
m_pixelSize = 1.0;
m_pixelSizeAssumed = true; // no scale in a PNG/TIFF/JPG — assume 1 px = 1 Å
if (!m_image.isNull()) {
padImageToSquare();
extractImageData();
}
}
m_lastOperation.clear();
m_ftComputed = false;
m_modeBtn->setText(modeLabel());
m_modeBtn->hide();
m_maskBtnVisible = false;
if (!m_image.isNull()) {
m_zoom[0].reset(m_image.width(), m_image.height());
computeFFT();
}
update();
#endif // !__EMSCRIPTEN__
}
// Clear the active buffer (image, raw pixels, FFT, power spectrum) after the
// user confirms. The file on disk is never touched — only the slot is emptied,
// and an undo snapshot is taken first so the buffer can be brought back.
void FtWindow::onDeleteImage()
{
if (m_activeSlot < 0 || m_activeSlot >= HISTORY_SLOTS) return;
const HistoryEntry &entry = m_history[m_activeSlot];
// A buffer that is still being read counts as deletable: the whole point of
// loading it in the background is that the user can throw it away without
// waiting for it.
if (!entry.occupied && !entry.deferred && !entry.loading && m_image.isNull())
return; // nothing in this buffer to delete
const int slot = m_activeSlot;
const QString label = QString(QChar('a' + slot));
auto *box = new QMessageBox(this);
box->setAttribute(Qt::WA_DeleteOnClose);
box->setIcon(QMessageBox::Warning);
box->setWindowTitle(QStringLiteral("Empty buffer"));
box->setText(QStringLiteral("Empty the buffer %1?").arg(label));
box->setInformativeText(
QStringLiteral("The file on disk is not deleted, "
"and Undo brings the buffer back."));
QPushButton *cancelBtn = box->addButton(QStringLiteral("Cancel"), QMessageBox::RejectRole);
QPushButton *deleteBtn = box->addButton(QStringLiteral("Empty buffer"), QMessageBox::DestructiveRole);
box->setDefaultButton(cancelBtn);
connect(box, &QMessageBox::finished, this, [this, box, deleteBtn, slot]() {
if (box->clickedButton() != deleteBtn) return;
if (slot != m_activeSlot) return; // buffer switched while the dialog was open
storeUndoSnapshot();
// Throw away the result of a read that is still running for this slot,
// so it cannot repopulate the buffer we are about to empty.
cancelSlotLoad(slot);
m_history[slot] = HistoryEntry();
m_image = QImage();
m_imagePath.clear();
m_imageRawPixels.clear();
m_imageRawPixels.shrink_to_fit();
m_imageMinVal = m_imageMaxVal = 0;
m_imageDispMin = m_imageDispMax = 0;
m_pixelSize = 1.0;
m_pixelSizeAssumed = false;
m_lastOperation.clear();
m_fftData.clear();
m_fftData.shrink_to_fit();
m_ftComputed = false;
m_modeBtn->hide();
m_maskBtnVisible = false;
saveHistory();
updateUndoRedoButtons();
update();
});
box->open();
}
void FtWindow::onCycleMode()
{
m_displayMode = (m_displayMode + 1) % 4;
m_modeBtn->setText(modeLabel());
#ifndef __EMSCRIPTEN__
QSettings settings("ft", "ft");
settings.setValue("displayMode", m_displayMode);
#endif
// Sync zoom/pan between both FT panels
m_zoom[2] = m_zoom[1];
if (m_brushActive && m_ftComputed) {
double bv = brushValue();
m_brushValueEdit->setText(bv > 0 ? QString::number(bv, 'g', 5) : "1");
}
update();
}
void FtWindow::onToggleFullscreen()
{
#ifdef __EMSCRIPTEN__
// iPad/iPhone Safari accepts requestFullscreen on non-video elements
// but cancels it within a frame, with no fullscreenerror to detect.
// Detect iOS and show "Add to Home Screen" instructions instead.
// (Standalone/home-screen launches already run without Safari chrome.)
int iosState = EM_ASM_INT({
var ua = navigator.userAgent || "";
var isIPad = /iPad|iPhone|iPod/.test(ua) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
if (!isIPad) return 0;
var standalone = window.navigator.standalone === true ||
(window.matchMedia && window.matchMedia("(display-mode: standalone)").matches);
return standalone ? 2 : 1;
});
if (iosState == 1) {
auto *msg = new QMessageBox(this);
msg->setAttribute(Qt::WA_DeleteOnClose);
msg->setWindowTitle("Fullscreen on iPad / iPhone");
msg->setTextFormat(Qt::RichText);
msg->setText(
"<p>Safari on iPad and iPhone does not support a stable in-browser "
"fullscreen mode for web apps.</p>"
"<p><b>To use Fourier Analyzer fullscreen:</b></p>"
"<ol>"
"<li>Tap the <b>Share</b> button in Safari (the square with an upward arrow).</li>"
"<li>Scroll down and tap <b>Add to Home Screen</b>.</li>"
"<li>Tap <b>Add</b>.</li>"
"<li>Launch Fourier Analyzer from its home-screen icon — it opens "
"fullscreen automatically.</li>"
"</ol>");
msg->setStandardButtons(QMessageBox::Ok);
msg->open();
return;
}
if (iosState == 2) {
// Already running standalone — fullscreen toggle is a no-op.
return;
}
// Drive the browser Fullscreen API from JS. Target Qt's #screen
// container (falls back to its canvas) instead of documentElement,
// which iPad Safari exits immediately. The actual button label is
// synced from JS via fullscreenchange listeners installed once.
installFullscreenSync();
EM_ASM({
// Target the #screen container, NOT its inner <canvas>. Qt resizes
// the canvas (DPR + pixel dims) the moment fullscreen begins, and
// iPad Safari exits fullscreen when the fullscreen element itself
// changes size. The container stays stable while the canvas inside
// resizes freely.
var target = document.getElementById('screen') || document.documentElement;