File size: 94,315 Bytes
a2130ce
 
7171bfb
 
 
 
a2130ce
 
 
 
7171bfb
 
a2130ce
7171bfb
a2130ce
 
 
 
 
 
7171bfb
a2130ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7171bfb
a2130ce
7171bfb
 
 
 
 
a2130ce
 
 
7171bfb
a2130ce
 
7171bfb
a2130ce
7171bfb
a2130ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7171bfb
a2130ce
 
 
7171bfb
 
 
 
 
 
 
 
a2130ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7171bfb
 
 
 
 
 
 
 
 
2df9947
 
 
 
 
a2130ce
7171bfb
 
 
 
fea4425
7171bfb
 
 
 
 
 
fea4425
7171bfb
 
a2130ce
 
fea4425
 
 
 
a2130ce
7171bfb
a2130ce
 
 
 
7171bfb
 
2df9947
7171bfb
a2130ce
7171bfb
2df9947
fea4425
a2130ce
2df9947
fea4425
 
 
a2130ce
 
fea4425
 
a2130ce
 
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fea4425
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2130ce
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2df9947
 
 
 
 
 
 
 
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2130ce
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2df9947
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2130ce
 
 
fea4425
7171bfb
 
fea4425
 
7171bfb
 
 
fea4425
 
 
 
 
 
7171bfb
 
 
 
a2130ce
 
fea4425
 
 
 
 
 
a2130ce
 
 
 
 
 
 
 
 
 
7171bfb
a2130ce
 
7171bfb
a2130ce
7171bfb
 
 
 
a2130ce
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fea4425
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7171bfb
a2130ce
 
 
 
 
7171bfb
 
 
 
a2130ce
 
7171bfb
 
 
 
a2130ce
7171bfb
 
 
 
a2130ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7171bfb
a2130ce
7171bfb
a2130ce
7171bfb
a2130ce
7171bfb
a2130ce
 
 
7171bfb
a2130ce
7171bfb
 
a2130ce
7171bfb
a2130ce
7171bfb
a2130ce
7171bfb
a2130ce
 
 
 
 
 
 
 
 
 
7171bfb
a2130ce
 
 
 
 
 
 
7171bfb
a2130ce
 
 
7171bfb
 
 
 
 
a2130ce
 
 
 
 
 
7171bfb
a2130ce
 
7171bfb
 
a2130ce
 
 
7171bfb
 
a2130ce
 
 
7171bfb
a2130ce
7171bfb
 
a2130ce
7171bfb
a2130ce
7171bfb
a2130ce
 
2df9947
7171bfb
 
2df9947
 
 
 
7171bfb
 
 
 
a2130ce
 
7171bfb
 
a2130ce
 
2df9947
7171bfb
 
2df9947
7171bfb
2df9947
 
 
a2130ce
 
 
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
fea4425
 
7171bfb
fea4425
 
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5136010
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2130ce
 
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2130ce
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fea4425
 
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2130ce
7171bfb
 
 
 
 
 
 
 
 
 
 
a2130ce
 
 
 
 
 
 
 
fea4425
a2130ce
fea4425
7171bfb
a2130ce
 
7171bfb
a2130ce
 
 
 
 
 
 
7171bfb
a2130ce
 
 
 
 
 
7171bfb
a2130ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
fea4425
7171bfb
 
 
 
a2130ce
7171bfb
a2130ce
 
7171bfb
 
a2130ce
 
 
fea4425
 
a2130ce
fea4425
 
a2130ce
 
 
 
 
 
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2130ce
 
 
 
 
 
 
 
 
 
7171bfb
a2130ce
 
 
 
 
 
7171bfb
 
a2130ce
 
 
 
 
 
 
 
 
 
7171bfb
 
a2130ce
7171bfb
 
 
 
 
 
5136010
 
 
7171bfb
 
 
5136010
7171bfb
 
 
 
 
 
 
 
 
 
5136010
 
 
 
 
 
 
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2130ce
 
5136010
 
a2130ce
5136010
 
a2130ce
 
 
 
edf6b63
 
 
 
7171bfb
edf6b63
 
 
 
 
 
7171bfb
edf6b63
 
7171bfb
edf6b63
 
 
 
 
 
7171bfb
edf6b63
 
 
 
0f224e2
 
 
edf6b63
 
 
 
 
 
 
 
 
a2130ce
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2130ce
 
 
 
 
 
 
 
7171bfb
 
 
 
a2130ce
 
 
 
7171bfb
 
a2130ce
 
 
 
 
7171bfb
 
a2130ce
 
7171bfb
a2130ce
 
 
 
 
7171bfb
 
 
 
 
 
a2130ce
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fea4425
 
 
7171bfb
 
 
 
 
 
fea4425
7171bfb
 
fea4425
7171bfb
 
 
a2130ce
 
fea4425
 
 
 
 
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2130ce
 
 
 
7171bfb
 
 
 
fea4425
7171bfb
 
fea4425
 
 
7171bfb
fea4425
 
 
 
7171bfb
 
fea4425
7171bfb
 
 
 
fea4425
 
 
 
7171bfb
 
 
fea4425
 
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fea4425
7171bfb
 
 
 
 
 
 
fea4425
7171bfb
 
 
a2130ce
fea4425
 
a2130ce
7171bfb
 
 
a2130ce
7171bfb
 
 
 
 
 
 
 
2df9947
 
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
fea4425
a2130ce
fea4425
7171bfb
a2130ce
7171bfb
 
fea4425
 
7171bfb
fea4425
 
2df9947
fea4425
7171bfb
a2130ce
 
 
 
 
 
7171bfb
 
 
 
 
5136010
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2130ce
 
 
7171bfb
 
5136010
7171bfb
 
5136010
 
 
7171bfb
 
 
 
a2130ce
 
 
 
 
7171bfb
a2130ce
 
 
 
 
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2130ce
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
a2130ce
 
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de62f63
 
 
7171bfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2130ce
 
7171bfb
 
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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
"""Scroll Lens: look inside a Herculaneum scroll scan with the Vesuvius Challenge's 3D models.

The web page and the API. Everything that is not the page (reading the scans, the models, clicks and scores) is in
lens.py, and the slice views are drawn in the visitor's browser by viewer.js. On a ZeroGPU Space the GPU is attached
only to `gpu_run`; when the visitor's GPU quota is used up, or no GPU is free, the same models run on the Space's CPU
on a smaller cube instead. Gallery cubes need no GPU at all.

  python3 app.py            serve the page (on a Space, or on any computer: it then runs on CUDA if present, else CPU)
"""

import base64
import collections
import functools
import gzip
import json
import os
import sys
import tempfile
import threading
import time
import uuid
from pathlib import Path

try:
    import spaces  # first, before torch: on a ZeroGPU Space it attaches the GPU to spaces.GPU functions
except ImportError:
    spaces = None

HERE = Path(__file__).resolve().parent
if str(HERE) not in sys.path:
    sys.path.insert(0, str(HERE))
import lens  # noqa: E402
from lens import LensError  # noqa: E402

ON_ZERO_GPU = bool(spaces) and os.environ.get("SPACES_ZERO_GPU", "").lower() in ("1", "true")
OUT = Path(tempfile.gettempdir()) / "scroll-lens-out"
VIEW_DIR = Path(tempfile.gettempdir()) / "scroll-lens-view"   # model layers for the browser viewer, served by Gradio
CPU_SIZE = 128                      # the cube side the CPU fallback runs
STATE_TTL_S = 6 * 3600              # a visitor's session state (a cube key and clicks) is dropped after this
MAX_CLICKS = 512                    # clicks a session keeps, More like this and Not this together
CACHE_CUBES = 12                    # cubes whose model outputs stay in memory, shared by every visitor
SIM_SCALE = 8192                    # similarity maps travel to the browser as int16 of similarity times this
TOP_FLOOR = 0.35                    # opacity of the least similar patch still inside Top %
ENGINE = None
ENGINE_ERROR = None
READY = threading.Event()
REFERENCE = None                    # the organizers' reference ink embedding, loaded before the models
GALLERY = []

OVERLAYS = ("Ink (3D ink model)", "Recto surface", "Your clicks", "Organizers' ink reference", "DINO colors")
VIEW_NAMES = ("Top view (z)", "Front view (y)", "Side view (x)")
COLORS = {"ink": (235, 64, 52), "surface": (64, 200, 96), "clicks": (40, 220, 235), "reference": (250, 180, 30)}
MARKS = {"pos": (255, 255, 255), "neg": (255, 70, 200)}   # crosses where you clicked: More like this, Not this

def log(message):
    lens.log(message)


# ---------------------------------------------------------------- start-up

def _load_engine():
    """Load the models in the background, so the page and the gallery work while they download."""
    global ENGINE, ENGINE_ERROR
    try:
        ENGINE = lens.Engine()
        log(f"models ready in {ENGINE.startup_s} s")
    except Exception as exc:  # noqa: BLE001  (shown to visitors who try to run a model)
        ENGINE_ERROR = f"{type(exc).__name__}: {exc}"
        log(f"models failed to load: {ENGINE_ERROR}")
    finally:
        READY.set()


def startup():
    global REFERENCE, GALLERY
    import shutil
    import numpy as np
    REFERENCE = lens.unit(np.load(lens.ensure_weights(names=("ink_reference",))["ink_reference"],
                                  allow_pickle=False).astype(np.float32))
    try:
        GALLERY = lens.gallery()
    except LensError as exc:                      # a broken index must not keep the page from starting
        log(f"gallery not loaded: {exc}")
        GALLERY = []
    for folder in (OUT, VIEW_DIR):                # outputs of an earlier run of this process
        shutil.rmtree(folder, ignore_errors=True)
    VIEW_DIR.mkdir(parents=True, exist_ok=True)
    log(f"{len(GALLERY)} gallery cubes; ZeroGPU {'on' if ON_ZERO_GPU else 'off'}")
    threading.Thread(target=_load_engine, name="load-models", daemon=True).start()


def engine(wait_s=5.0):
    if not READY.wait(wait_s):
        raise LensError("the models are still loading (about a minute after the Space wakes up); try again shortly. "
                        "The gallery works meanwhile.")
    if ENGINE is None:
        raise LensError(f"the models failed to load: {ENGINE_ERROR}")
    return ENGINE


# ---------------------------------------------------------------- the one GPU call

def _gpu_duration(cube, layers, tta):
    return lens.gpu_seconds(int(cube.shape[0]), layers, tta)


def _on_gpu(fn):
    return spaces.GPU(duration=_gpu_duration)(fn) if ON_ZERO_GPU else fn


@_on_gpu
def gpu_run(cube, layers, tta):
    """The chosen models on one cube, on the GPU ZeroGPU attaches for this call."""
    return ENGINE.run(cube, layers, device="cuda", tta=tta)


# ZeroGPU's refusals, by the titles the spaces package gives them (spaces 0.51.3, zero/client.py and wrappers.py):
# the quota, a duration it will not grant, no GPU free in time. "ZeroGPU worker error" is different: the models
# themselves failed inside the GPU task, so the CPU would fail the same way, except for "GPU task aborted" (the task
# ran past its duration).
GPU_REFUSALS = ("ZeroGPU quota exceeded", "ZeroGPU illegal duration", "ZeroGPU pending credits exceeded",
                "ZeroGPU queue timeout", "ZeroGPU client error")
CPU_SLOT = threading.Semaphore(1)       # one CPU fallback run at a time, so the page keeps answering


def _gpu_text(exc):
    """A ZeroGPU error's own message (gr.Error's str() is its repr, with quotes)."""
    return str(getattr(exc, "message", None) or exc).strip().strip("'\"").strip()


def _is_gpu_refusal(exc):
    return (str(getattr(exc, "title", "") or "") in GPU_REFUSALS or "GPU task aborted" in str(exc)
            or "No GPU was available" in str(exc))


def cpu_run(eng, cube, layers, focus=None):
    if not CPU_SLOT.acquire(blocking=False):
        raise LensError("the Space's CPU is busy with another fallback run; try again in a minute, or sign in to "
                        "Hugging Face for more ZeroGPU time")
    try:
        import torch
        torch.set_num_threads(max(1, (os.cpu_count() or 2) - 1))     # leave a core for the page
        return eng.run(_window(cube, CPU_SIZE, focus), layers, device="cpu", tta=False)
    finally:
        CPU_SLOT.release()


def run_models(cube, layers, tta, allow_cpu=True, focus=None):
    """Run on the GPU when there is one; on ZeroGPU fall back to the CPU when the GPU is refused, on the CPU_SIZE
    window around `focus` (the point, in cube voxels; see window_offset). Returns the result and a note for the
    visitor (empty when it ran as asked)."""
    eng = engine()
    import torch
    if ON_ZERO_GPU:
        try:
            return gpu_run(cube, tuple(layers), bool(tta)), ""
        except Exception as exc:  # noqa: BLE001
            if not _is_gpu_refusal(exc):
                if str(getattr(exc, "title", "")) == "ZeroGPU worker error":
                    raise LensError(f"the models failed on the GPU ({_gpu_text(exc)[:200]}); try again, or try "
                                    "another point") from None
                raise
            if not allow_cpu:
                raise LensError(f"ZeroGPU refused this call: {_gpu_text(exc)[:300]} Pass allow_cpu=True to run "
                                f"the {CPU_SIZE}³ around the point on the CPU instead.") from None
            log(f"GPU refused ({exc}); running on the CPU")
            note = (f"ZeroGPU refused the run ({_gpu_text(exc).rstrip('. ')[:200]}), so the models ran on the "
                    f"Space's CPU, on the {CPU_SIZE}³ voxels around your point. ZeroGPU time comes back each day; "
                    "signing in to Hugging Face gives more.")
            return cpu_run(eng, cube, layers, focus), note
    device = "cuda" if torch.cuda.is_available() else "cpu"
    if device == "cpu" and cube.shape[0] > CPU_SIZE:
        return (cpu_run(eng, cube, layers, focus),
                f"No GPU here, so the models ran on the CPU on the {CPU_SIZE}³ voxels around your point.")
    return eng.run(cube, layers, device=device, tta=tta), ""


# One model run at a time per visitor or caller, so one script cannot hold the GPU and the queue for everyone.
RUNNING = collections.Counter()
RUN_LOCK = threading.Lock()


def client_of(request):
    if request is None:
        return None
    user = getattr(request, "username", None)
    if user:
        return f"user:{user}"
    headers = getattr(request, "headers", None) or {}
    # the last X-Forwarded-For entry is the one the Space's own proxy adds; earlier ones are what the caller sent
    forwarded = (headers.get("x-forwarded-for") or "").split(",")[-1].strip()
    host = getattr(getattr(request, "client", None), "host", "") or ""
    return forwarded or host or getattr(request, "session_hash", None)


class one_run:
    def __init__(self, request):
        self.key = client_of(request)

    def __enter__(self):
        with RUN_LOCK:
            if self.key and RUNNING[self.key] >= 1:
                raise LensError("you already have a model run going; wait for it to finish (one at a time per "
                                "visitor)")
            RUNNING[self.key] += 1

    def __exit__(self, *exc):
        with RUN_LOCK:
            RUNNING[self.key] -= 1
            if RUNNING[self.key] <= 0:
                del RUNNING[self.key]
        return False


def window_offset(size, n, focus=None):
    """Where the CPU fallback's n³ window sits in a size³ cube: around `focus` (the point, in cube voxels), on the
    8-voxel patch grid and inside the cube, so the point is always in it (near a scan's edge the cube is not centered
    on the point); the middle when no point is given."""
    if n >= size:
        return (0, 0, 0)
    if focus is None:
        return ((size - n) // 2,) * 3
    return tuple(int(min(max((int(f) - n // 2) // lens.PATCH * lens.PATCH, 0), size - n)) for f in focus)


def _window(cube, n, focus=None):
    o = window_offset(cube.shape[0], n, focus)
    return cube[o[0]:o[0] + n, o[1]:o[1] + n, o[2]:o[2] + n]


def _local(point, origin):
    return tuple(int(p) - int(o) for p, o in zip(point, origin))


# ---------------------------------------------------------------- cubes in memory
#
# A cube's model outputs (tens of MB) live in one cache shared by every visitor; a visitor's session keeps only the
# cube's key and its clicks, so memory stays bounded however many people use the page. Gallery cubes reload from
# disk when they drop out; a cube the models ran on now does not, and asks to be run again.

class Cube:
    """One cube's model outputs and what is derived from them."""

    def __init__(self, key, meta, result):
        import numpy as np
        self.key, self.meta, self.result = key, meta, result
        self.size = int(meta["cube_size"])
        self.n = self.size // lens.PATCH
        self.lock = threading.Lock()
        self.has_dino = "grid" in result or "grid_reduced" in result
        self.norms = None
        if "grid_reduced" in result:
            coef = np.asarray(result["grid_reduced"], np.float32)
            self.norms = np.maximum(np.linalg.norm(coef, axis=-1), 1e-12)
        self.ref_sim = self.similarity(REFERENCE) if self.has_dino and REFERENCE is not None else None
        self.data_path = None

    def similarity(self, positive, negative=None):
        """Per patch: cosine to the positive concept, minus that to the negative one. A compact (gallery) cube is
        scored in its reduced basis, the same numbers as with the embeddings lens.grid_of rebuilds."""
        import numpy as np
        res = self.result
        if "grid" in res:
            return lens.similarity(np.asarray(res["grid"]), positive, negative)
        if "grid_reduced" not in res:
            raise LensError("this cube has no DINO embeddings; run it with the 3D DINO model to search it")
        basis = np.asarray(res["grid_basis"], np.float32)
        coef = np.asarray(res["grid_reduced"], np.float32)

        def cos(vec):                 # the projection's dot product, as lens.grid_of rebuilds it (no rescaling)
            return coef @ (basis.T @ np.asarray(vec, np.float32))
        s = cos(positive)
        if negative is not None:
            s = s - cos(negative)
        return s.astype(np.float32)

    def reference_agreement(self):
        """The organizers' reference against the 3D ink model for this cube, with the control range (lens.agreement),
        worked out once from the arrays held here."""
        if self.ref_sim is None or "ink" not in self.result:
            return None
        if getattr(self, "_ref_agreement", None) is None:
            self._ref_agreement = lens.agreement(self.ref_sim, self.result["ink"])
        return self._ref_agreement

    def vector(self, voxel):
        """The unit 864-number embedding of the patch holding a cube voxel (z, y, x)."""
        import numpy as np
        t = lens.token_of(voxel, self.size)
        res = self.result
        if "grid" in res:
            return np.asarray(res["grid"][t], np.float32)
        if "grid_reduced" not in res:
            raise LensError("this cube has no DINO embeddings; run it with the 3D DINO model to search by clicking")
        return lens.unit(np.asarray(res["grid_basis"], np.float32) @ np.asarray(res["grid_reduced"][t], np.float32))

    def pca(self):
        import numpy as np
        res = self.result
        if "grid" in res:
            return lens.pca_colors(np.asarray(res["grid"], np.float32))
        if "grid_reduced" in res:
            # the basis is orthonormal, so unit coefficients are the unit embeddings in a rotated frame: same colors
            coef = np.asarray(res["grid_reduced"], np.float32) / self.norms[..., None]
            return lens.pca_colors(coef)
        return None

    def layers_file(self, path):
        """Write the model layers the browser viewer draws, once: ink and surface (uint8, Z Y X), DINO colors
        (uint8, Z/8 Y/8 X/8 3) and the organizers' reference similarity (float32, Z/8 Y/8 X/8), gzipped behind a
        JSON header (see viewer.js loadLayers)."""
        import numpy as np
        with self.lock:
            if self.data_path and Path(self.data_path).is_file():
                return self.data_path
            arrays = [(k, np.ascontiguousarray(self.result[k], np.uint8)) for k in ("ink", "surface") if k in self.result]
            if self.has_dino:
                arrays.append(("pca", np.ascontiguousarray(self.pca(), np.uint8)))
                if self.ref_sim is not None:
                    arrays.append(("ref", np.ascontiguousarray(self.ref_sim, "<f4")))
            header, blobs, offset = {"arrays": []}, [], 0
            for name, a in arrays:
                raw = a.tobytes()
                header["arrays"].append({"name": name, "dtype": str(a.dtype).replace("<", ""), "shape": list(a.shape),
                                         "offset": offset, "nbytes": len(raw)})
                pad = (-len(raw)) % 4
                blobs.append(raw + b"\0" * pad)
                offset += len(raw) + pad
            hjson = json.dumps(header).encode()
            body = len(hjson).to_bytes(4, "little") + hjson
            body += b"\0" * ((-len(body)) % 4)
            Path(path).parent.mkdir(parents=True, exist_ok=True)
            tmp = Path(f"{path}.{uuid.uuid4().hex[:8]}.part")      # concurrent first opens never share it
            tmp.write_bytes(gzip.compress(body + b"".join(blobs), compresslevel=3, mtime=0))
            os.replace(tmp, path)
            self.data_path = str(path)
            return self.data_path


class CubeCache:
    """Two least-recently-used pools: gallery cubes (reloaded from disk when they drop out) and cubes the models ran
    on now (lost when they drop out), so opening gallery cubes never pushes out a visitor's run."""

    def __init__(self, size=CACHE_CUBES):
        self.size = size
        self.pools = {"gallery": collections.OrderedDict(), "run": collections.OrderedDict()}
        self.lock = threading.Lock()

    def _pool(self, key):
        return self.pools["gallery" if key.startswith("gallery:") else "run"]

    @property
    def items(self):
        return {**self.pools["gallery"], **self.pools["run"]}

    def get(self, key):
        with self.lock:
            pool = self._pool(key)
            if key in pool:
                pool.move_to_end(key)
                return pool[key]
        if key.startswith("gallery:"):
            return self.put(gallery_cube(key.split(":", 1)[1]))
        raise LensError("this cube is no longer in the Space's memory (it keeps the last few); run it again")

    def put(self, cube):
        with self.lock:
            pool = self._pool(cube.key)
            pool[cube.key] = cube
            pool.move_to_end(cube.key)
            while len(pool) > self.size:
                key, old = pool.popitem(last=False)
                if key.startswith("run:") and old.data_path:
                    Path(old.data_path).unlink(missing_ok=True)
        return cube


CUBES = CubeCache()


def gallery_item(cube_id):
    for it in GALLERY:
        if it["id"] == cube_id:
            return it
    raise LensError(f"no gallery cube {cube_id!r}")


def gallery_cube(cube_id):
    it = gallery_item(cube_id)
    meta, arrays = lens.load_npz(HERE / "gallery" / it["file"])
    result = dict(arrays, seconds=meta.get("seconds", {}), device=meta.get("device"),
                  device_name=meta.get("device_name"), tta=meta.get("tta", False))
    return Cube(f"gallery:{cube_id}", meta, result)


def cube_of(view):
    return CUBES.get(view["cache"])


def layers_url(cube):
    if cube.key.startswith("gallery:"):
        path = VIEW_DIR / "gallery" / f"{cube.key.split(':', 1)[1]}-{lens.VERSION}.bin"
    else:
        path = VIEW_DIR / "runs" / f"{cube.key.split(':', 1)[1]}.bin"
    return f"gradio_api/file={cube.layers_file(path)}"


# ---------------------------------------------------------------- one cube on screen

def new_view(cube, point, source, note="", title=None, request_size=None, cube_id=None):
    """What a visitor's session keeps about the cube on screen: small values only; the arrays stay in CUBES."""
    meta = cube.meta
    vid = meta["volume"]
    origin = tuple(int(v) for v in meta["cube_origin_zyx"])
    receipt = dict(meta)
    receipt["source"] = source
    if cube_id:
        receipt["gallery_id"] = cube_id
    return {"cache": cube.key, "volume": vid, "origin": origin, "size": cube.size,
            "point": tuple(int(v) for v in point), "source": source, "note": note, "cube_id": cube_id,
            "title": title or f"{lens.volume(vid)['scroll']}, cube at z {origin[0]}, y {origin[1]}, x {origin[2]}",
            "request_size": request_size or cube.size, "foreground": meta.get("foreground"), "receipt": receipt}


def empty_state():
    return {"view": None, "pos": [], "neg": [], "acked": [], "keys": [], "view_ticket": 0, "rev": 0}


_REV_LOCK = threading.Lock()
_LAST_REV = [0]


def next_rev():
    """A number larger than every one before, also across restarts of the Space (microseconds since 1970, below
    2**53 so the browser reads it exactly). Every answer to the viewer carries one, and the viewer shows only the
    newest; opens take one as their ticket (show_view)."""
    with _REV_LOCK:
        _LAST_REV[0] = max(_LAST_REV[0] + 1, time.time_ns() // 1000)
        return _LAST_REV[0]


def cube_key(view):
    return f"{view['volume']}:{','.join(map(str, view['origin']))}:{view['size']}"


def place_of(cube):
    """cube_key for a cube in CUBES (its scan, origin and size), for clicks on a cube no longer on screen."""
    origin = [int(v) for v in cube.meta["cube_origin_zyx"]]
    return f"{cube.meta['volume']}:{','.join(map(str, origin))}:{cube.size}"


def click_concept(state):
    """The visitor's concept: unit mean of the positive clicks' embeddings, and of the negative ones (or None)."""
    import numpy as np
    pos = [c["vec"] for c in state["pos"]]
    neg = [c["vec"] for c in state["neg"]]
    p = lens.unit(np.mean(pos, 0)) if pos else None
    n = lens.unit(np.mean(neg, 0)) if neg else None
    return p, n


def click_sim(state, cube=None):
    view = state["view"]
    p, n = click_concept(state)
    if view is None or p is None:
        return None
    cube = cube or cube_of(view)
    if not cube.has_dino:
        return None
    return cube.similarity(p, n)


def encode_sim(sim):
    import numpy as np
    if sim is None:
        return None
    q = np.clip(np.rint(np.asarray(sim, np.float32) * SIM_SCALE), -32768, 32767).astype("<i2")
    return base64.b64encode(q.tobytes()).decode()


def viewer_value(state):
    """The JSON the browser viewer reads (viewer.js onValue): where to fetch the scan and the model layers, the
    cube's place, and the visitor's clicks with their similarity map."""
    view = state.get("view")
    if not view:
        return ""
    cube = cube_of(view)
    key = cube_key(view)
    marks = {kind: [c["voxel"] for c in state[kind] if c.get("cube") == key and c.get("voxel")]
             for kind in ("pos", "neg")}
    return json.dumps({
        "key": view["cache"], "rev": state.get("rev", 0), "volume": view["volume"],
        "scroll": lens.volume(view["volume"])["scroll"], "title": view["title"], "cube_id": view.get("cube_id"),
        "origin": list(view["origin"]), "size": view["size"], "point": list(view["point"]),
        "request_size": view.get("request_size"),
        "ct": {"base": lens.volume_url(view["volume"]), "chunk": lens.CHUNK},
        "data": layers_url(cube),
        "has": {"dino": cube.has_dino, "ink": "ink" in cube.result, "surface": "surface" in cube.result},
        "tau": lens.TAU,
        "clicks": {"sim": encode_sim(click_sim(state, cube)), "pos": marks["pos"], "neg": marks["neg"],
                   "acked": state.get("acked", [])},
    })


def ack_only(key, clicks):
    """A viewer answer that only acknowledges click ids, for clicks the server cannot use (viewer.js onValue)."""
    return json.dumps({"key": key, "rev": next_rev(), "ack_only": True,
                       "clicks": {"acked": [str(c.get("id")) for c in clicks]}})


# ---------------------------------------------------------------- a visitor's page state, changed in place
#
# Gradio hands every event of one visitor the same state dict, and keeps whatever the event returns. An event that
# rebuilt the dict from what it read when it started would put that old copy back when it finished late: clicks made
# during a 20-second model run would be lost, and a click answered after the visitor opened another cube would bring
# the old cube back. So the page events change that one dict in place, under STATE_LOCK, only once their slow part
# (reading a cube, the models) is done, and the viewer orders their answers by revision.

STATE_LOCK = threading.RLock()
KEEP_KEYS = 16                      # the cubes a page has shown that its clicks may still name
KEEP_ACKS = 256


def snapshot(state, rev=True):
    """A copy of the visitor's state to draw from (viewer_value, numbers, save_files), with a new revision."""
    with STATE_LOCK:
        if rev:
            state["rev"] = next_rev()
        return dict(state, pos=list(state["pos"]), neg=list(state["neg"]), acked=list(state.get("acked", [])),
                    keys=list(state.get("keys", [])))


def show_view(state, view, ticket, force=False):
    """Put `view` on screen unless the visitor has since opened another cube (a later ticket, from next_rev when
    the open began). A finished model run always shows (`force`): it spent the visitor's GPU time. Returns whether
    `view` is on screen now."""
    with STATE_LOCK:
        if not force and ticket < state.get("view_ticket", 0):
            return False
        state["view"] = view
        state["view_ticket"] = max(ticket, state.get("view_ticket", 0))
        state["keys"] = ([k for k in state.get("keys", []) if k != view["cache"]] + [view["cache"]])[-KEEP_KEYS:]
        return True


def _ack(state, ids):
    acked = list(state.get("acked", []))
    seen = set(acked)
    acked += [i for i in dict.fromkeys(str(i) for i in ids) if i not in seen]
    state["acked"] = acked[-KEEP_ACKS:]                 # in arrival order: the newest ids stay


def take_clicks(state, key, clicks):
    """Add the viewer's clicks on the cube `key` (a CUBES key) to the visitor's concept. The viewer resends every
    click until it is acknowledged, so the ids stop a click counting twice; a click on a cube this page has moved on
    from still counts (a concept spans cubes) but never brings that cube back. Returns (taken, problem): whether the
    clicks could be used, and why some were refused."""
    with STATE_LOCK:
        view = state["view"]
        known = key in state.get("keys", []) or (view is not None and key == view["cache"])
    if not known:
        with STATE_LOCK:
            _ack(state, [c.get("id") for c in clicks])
        return False, "This cube is no longer in this page's session; open it again to search it."
    try:
        cube = CUBES.get(key)               # a gallery cube reloads from disk; a run cube may have dropped out
    except LensError as exc:
        with STATE_LOCK:
            _ack(state, [c.get("id") for c in clicks])
        return False, str(exc)
    place, problem = place_of(cube), None
    with STATE_LOCK:
        seen = set(state.get("acked", []))
        for click in clicks:
            cid = str(click.get("id"))
            if cid in seen:
                continue
            seen.add(cid)
            _ack(state, [cid])              # acknowledged even when refused, so the viewer does not resend it
            if len(state["pos"]) + len(state["neg"]) >= MAX_CLICKS:
                problem = f"a page keeps at most {MAX_CLICKS} clicks; clear some to add more"
                continue
            try:
                vox = [int(v) for v in click.get("index")]
                if len(vox) != 3 or not all(0 <= v < cube.size for v in vox):
                    raise LensError("that click is outside the cube")
                vec = cube.vector(vox)
            except (TypeError, ValueError, LensError) as exc:
                problem = str(exc) if isinstance(exc, LensError) else "a click needs a voxel z, y, x"
                continue
            kind = "neg" if click.get("value") == "neg" else "pos"
            origin = [int(v) for v in cube.meta["cube_origin_zyx"]]
            state["tick"] = state.get("tick", 0) + 1
            state[kind] = state[kind] + [{"id": cid, "volume": cube.meta["volume"], "cube": place, "voxel": vox,
                                          "t": state["tick"], "voxel_zyx": [v + o for v, o in zip(vox, origin)],
                                          "vec": vec}]
    return True, problem


def undo_click(state, cid=None):
    """Forget the click with id `cid` (one still on its way is dropped when it arrives), or else the most recent
    click (More like this or Not this) on any cube."""
    with STATE_LOCK:
        if cid:
            cid = str(cid)
            for kind in ("pos", "neg"):
                if any(c.get("id") == cid for c in state[kind]):
                    state[kind] = [c for c in state[kind] if c.get("id") != cid]
                    return
            _ack(state, [cid])
            return
        clicks = [(c.get("t", 0), kind, i) for kind in ("pos", "neg") for i, c in enumerate(state[kind])
                  if c["cube"] != "file"]
        if not clicks:
            raise LensError("no click to undo")
        _, kind, i = max(clicks)
        state[kind] = state[kind][:i] + state[kind][i + 1:]


def clear_clicks(state, ids=()):
    """Forget every click and loaded concept; `ids` are clicks the viewer sent but has no answer for yet, dropped
    when they arrive."""
    with STATE_LOCK:
        state["pos"], state["neg"] = [], []
        _ack(state, list(ids)[:KEEP_ACKS])


def set_concept(state, pos, neg):
    """Replace the visitor's clicks with a loaded concept.npy (and concept_not.npy), or with `pos` and `neg` None
    drop the loaded files and keep the clicks."""
    with STATE_LOCK:
        if pos is None:
            state["pos"] = [c for c in state["pos"] if c["cube"] != "file"]
            state["neg"] = [c for c in state["neg"] if c["cube"] != "file"]
        else:
            state["pos"], state["neg"] = list(pos), list(neg)


# ---------------------------------------------------------------- drawing on the server (the agent tools)

def _take(a, axis, i):
    return a[i] if axis == 0 else (a[:, i] if axis == 1 else a[:, :, i])


def _top_alpha(sim2d, sim3d, top_pct, floor=None):
    """Opacity of each pixel of a similarity slice: 0 below the cube's top `top_pct` percent (and below `floor`, if
    given), then from 0.35 at the threshold to 1 at the cube's best patch, so every selected patch shows (viewer.js
    draws it the same way)."""
    import numpy as np
    thr = float(np.percentile(sim3d, 100 - top_pct))
    if floor is not None:
        thr = max(thr, float(floor))
        if float(sim3d.max()) < thr:
            return np.zeros_like(sim2d, dtype=np.float32)
    best = float(sim3d.max())
    if best - thr < 1e-6 and float(sim3d.min()) == best:
        return np.zeros_like(sim2d, dtype=np.float32)          # a flat map (the concept cancels out): show nothing
    ramp = np.clip((sim2d - thr) / max(best - thr, 1e-6), 0, 1)
    return (TOP_FLOOR + (1 - TOP_FLOOR) * ramp) * (sim2d >= thr)


def draw_slice(state, axis, overlays, top_pct, opacity):
    """One slice through the cube as an RGB image, scaled up so a 128 or 256 cube shows at 512 px."""
    import numpy as np
    view = state["view"]
    if view is None:
        return None
    size, cube, idx = view["size"], view["cube"], view["slices"][axis]
    ct = _take(cube, axis, idx).astype(np.float32)
    g = np.clip((ct - view["lo"]) / (view["hi"] - view["lo"]), 0, 1)
    rgb = np.repeat(g[..., None], 3, axis=2)
    res = view["result"]
    tok_i = idx // lens.PATCH

    def up(t2):
        return np.repeat(np.repeat(t2, lens.PATCH, 0), lens.PATCH, 1)

    def blend(color, alpha):
        a = (np.asarray(alpha, np.float32) * opacity)[..., None]
        rgb[:] = rgb * (1 - a) + (np.asarray(color, np.float32) / 255.0) * a

    if "DINO colors" in overlays and "grid" in view:
        if view["pca"] is None:
            view["pca"] = lens.pca_colors(view["grid"].astype(np.float32))
        pc = up(_take(view["pca"], axis, tok_i)).astype(np.float32) / 255.0
        rgb[:] = rgb * (1 - 0.6 * opacity) + pc * (0.6 * opacity)
    if "Recto surface" in overlays and "surface" in res:
        blend(COLORS["surface"], _take(res["surface"], axis, idx) / 255.0)
    if "Ink (3D ink model)" in overlays and "ink" in res:
        blend(COLORS["ink"], _take(res["ink"], axis, idx) / 255.0)
    if "Organizers' ink reference" in overlays and view.get("ref_sim") is not None:
        s3 = view["ref_sim"]
        blend(COLORS["reference"], up(_top_alpha(_take(s3, axis, tok_i), s3, top_pct, floor=lens.TAU)))
    if "Your clicks" in overlays:
        s3 = tool_click_sim(state)
        if s3 is not None:
            blend(COLORS["clicks"], up(_top_alpha(_take(s3, axis, tok_i), s3, top_pct)))
    img = (np.clip(rgb, 0, 1) * 255).astype(np.uint8)
    # where the other two slices cut this one: thin guide lines
    others = [a for a in range(3) if a != axis]
    r, c = view["slices"][others[0]], view["slices"][others[1]]
    f = scale_of(size)
    img = np.repeat(np.repeat(img, f, 0), f, 1)
    guide = np.array([255, 255, 255], np.uint8)
    img[r * f + f // 2, :: 3] = guide
    img[:: 3, c * f + f // 2] = guide
    # the clicks made in this cube that lie in this slice
    for kind, color in (("pos", MARKS["pos"]), ("neg", MARKS["neg"])):
        for ck in state[kind]:
            if ck.get("cube") != cube_key(view):
                continue
            v = ck["voxel"]
            if v[axis] // lens.PATCH != tok_i:
                continue
            rr, cc = v[others[0]] * f, v[others[1]] * f
            _mark(img, rr + f // 2, cc + f // 2, color, 5 * f // 2 + 3)
    return img


def scale_of(size):
    return max(1, 512 // int(size))


def _mark(img, r, c, color, half):
    """A cross two pixels thick, drawn in place."""
    h, w = img.shape[:2]
    for d in range(-half, half + 1):
        for t in (0, 1):
            for (y, x) in ((r + d, c + t), (r + t, c + d)):
                if 0 <= y < h and 0 <= x < w:
                    img[y, x] = color


def numbers(state):
    """The text beside the views: what this cube is, what ran, and the checks."""
    view = state["view"]
    if view is None:
        return ("Pick a ready-made cube from the gallery, or a point in a scan, then press **Run the models**. "
                "Click the views to find similar places.")
    cube = cube_of(view)
    v = lens.volume(view["volume"])
    r = view["receipt"]
    um = v["voxel_um"] * view["size"]
    fg = view.get("foreground")
    lines = [f"**{v['scroll']}**, cube of {view['size']}³ voxels ({um / 1000:.2f} mm a side) at "
             f"z {view['origin'][0]}, y {view['origin'][1]}, x {view['origin'][2]}."
             + (f" {fg:.0%} of it lies inside the scan's scroll mask." if fg is not None else "")]
    src = {"gallery": "Gallery cube: computed earlier, no GPU used now.", "gpu": "Ran now on the GPU.",
           "cpu": "" if view["note"] else "Ran now on the CPU.", "file": "Loaded from your file."}[view["source"]]
    secs = ", ".join(f"{k} {s:.1f} s" for k, s in r.get("seconds", {}).items())
    lines.append(f"{src} {('Model time: ' + secs + '.') if secs else ''} {view['note']}".strip())
    lines.append("**What each model saw of this scroll in training.** " + " ".join(
        f"{lens.LAYER_TITLES[k]}: {v['role'][k]}" for k in lens.LAYERS))
    ag = cube.reference_agreement()
    if ag and ag.get("auc") is not None:
        lines.append(f"**Check.** The organizers' ink reference picks out the patches the 3D ink model marks as ink "
                     f"with AUC {ag['auc']:.2f}; controls (the ink mask moved away from its place) give "
                     f"{ag['auc_control_p05']:.2f} to {ag['auc_control_p95']:.2f}, and the AUC means something only "
                     f"above that range. Ink covers {ag['ink_patch_fraction']:.1%} of patches. Agreement between two "
                     "models, not ground truth.")
    if cube.ref_sim is not None:
        lines.append(f"Patches at or above the organizers' threshold (cosine {lens.TAU}) to their reference: "
                     f"{float((cube.ref_sim >= lens.TAU).mean()):.1%}.")
    s = click_sim(state, cube)
    if s is not None:
        extra = ""
        if "ink" in cube.result:
            a = lens.agreement(s, cube.result["ink"])
            if a["auc"] is not None:
                extra = (f" Against the 3D ink model your concept scores AUC {a['auc']:.2f} "
                         f"(controls {a.get('auc_control_p05', 0):.2f} to {a.get('auc_control_p95', 0):.2f}).")
        clicked = {c["cube"] for c in state["pos"] + state["neg"] if c["cube"] != "file"}
        loaded = any(c["cube"] == "file" for c in state["pos"])
        loaded_not = any(c["cube"] == "file" for c in state["neg"])
        lines.append(f"**Your concept:** {len([c for c in state['pos'] if c['cube'] != 'file'])} positive and "
                     f"{len([c for c in state['neg'] if c['cube'] != 'file'])} negative clicks from {len(clicked)} "
                     f"cube(s){', plus your concept.npy' if loaded else ''}"
                     f"{' and concept_not.npy' if loaded_not else ''}.{extra}")
    lines.append("_Model output, not a reading. A cube is smaller than one letter._")
    return "\n\n".join(lines)


# ---------------------------------------------------------------- loading cubes

def run_cube(vid, z, y, x, size, layers, tta, allow_cpu=True):
    """Read the cube around a point, run the chosen models on it and keep it in CUBES. Returns (cube, note)."""
    import numpy as np
    vid = lens.volume_id(vid)
    size = check_size_arg(size)
    layers = lens.check_layers(layers)
    point = check_point(z, y, x)
    eng = engine()                                 # refuse before reading the bucket while the models load
    origin = lens.cube_origin(vid, point, size)
    ct = lens.read_cube(vid, origin, size)
    fg = lens.check_cube(ct)
    focus = _local(point, origin)
    result, note = run_models(ct, layers, bool(tta), allow_cpu=allow_cpu, focus=focus)
    n = ran_size(result)
    if n != size:                                  # the CPU fallback ran the window around the point
        origin = tuple(int(o + d) for o, d in zip(origin, window_offset(size, n, focus)))
        fg = lens.foreground(_window(ct, n, focus))
    src = "gpu" if str(result.get("device", "")).startswith("cuda") else "cpu"
    meta = lens.receipt(vid, origin, n, point, result, eng.describe(),
                        {"source": src, "note_run": note, "foreground": round(fg, 4), "requested_size": size})
    if "grid" in result and "ink" in result:
        meta["agreement_with_ink_model"] = lens.agreement(
            lens.similarity(np.asarray(result["grid"], np.float32), REFERENCE), result["ink"])
    return CUBES.put(Cube(f"run:{uuid.uuid4().hex}", meta, result)), note


def check_point(z, y, x):
    try:
        point = tuple(int(v) for v in (z, y, x))
    except (TypeError, ValueError, OverflowError):
        raise LensError("give the point as three whole numbers, z y x, in level-0 voxels") from None
    return point


def check_size_arg(size):
    try:
        return lens.check_size(int(size))
    except (TypeError, ValueError, OverflowError):
        raise LensError(f"cube side must be one of {lens.SIZES} voxels, not {size!r}") from None


def parse_layers(layers):
    """Models to run from a comma-separated string or a list, in any case, by key or by page title."""
    titles = {v.lower(): k for k, v in lens.LAYER_TITLES.items()}
    items = layers if isinstance(layers, (list, tuple)) else str(layers or "").split(",")
    return lens.check_layers([titles.get(str(i).strip().lower(), str(i).strip().lower()) for i in items
                              if str(i).strip()])


def prune(folder, age_s=3600):
    """Delete what a folder of per-request outputs holds that is older than `age_s` (Gradio serves copies)."""
    import shutil
    now = time.time()
    for p in Path(folder).glob("*"):
        try:
            if now - p.stat().st_mtime > age_s:
                shutil.rmtree(p) if p.is_dir() else p.unlink()
        except OSError:
            pass


def load_point(vid, z, y, x, size, layers, tta):
    cube, note = run_cube(vid, z, y, x, size, parse_layers(list(layers or [])), tta)
    return new_view(cube, check_point(z, y, x), cube.meta["source"], note, request_size=int(size))


def ran_size(result):
    """The side of the cube a result covers, from whichever layers it holds."""
    for k in ("ink", "surface"):
        if k in result:
            return int(result[k].shape[0])
    return int(lens.grid_of(result).shape[0] * lens.PATCH)


def load_gallery(cube_id):
    it = gallery_item(cube_id)
    cube = CUBES.get(f"gallery:{cube_id}")
    return new_view(cube, tuple(it["point_zyx"]), "gallery", it.get("note", ""), title=it["title"],
                    cube_id=cube_id)


def gallery_label(it):
    """The dropdown shows the title alone, on one line; the point and size are in the readout below the views."""
    return it["title"]


def next_gallery_id(cube_id, step=1):
    ids = [it["id"] for it in GALLERY]
    if not ids:
        raise LensError("the gallery is empty")
    i = ids.index(cube_id) if cube_id in ids else -step
    return ids[(i + step) % len(ids)]


def surprise(vid, seed=None):
    import numpy as np
    rng = np.random.default_rng(seed)
    if vid == "PHercParis4":
        return lens.PARIS4_TRAINING_POINTS[int(rng.integers(0, len(lens.PARIS4_TRAINING_POINTS)))]
    return lens.random_point(vid, rng)


# ---------------------------------------------------------------- files to take away

def save_files(state):
    import numpy as np
    view = state["view"]
    if view is None:
        raise LensError("nothing to save yet")
    cube = cube_of(view)
    OUT.mkdir(parents=True, exist_ok=True)
    prune(OUT, 900)
    d = Path(tempfile.mkdtemp(dir=OUT))
    name = f"scroll-lens-{view['volume']}-{'-'.join(map(str, view['origin']))}-{view['size']}"
    files = [lens.save_npz(d / f"{name}.npz", view["receipt"], cube.result,
                           compact="grid_reduced" in cube.result)]
    rec = d / f"{name}.json"
    rec.write_text(json.dumps(view["receipt"], indent=1) + "\n")
    files.append(rec)
    p, n = click_concept(state)
    if p is not None:
        np.save(d / "concept.npy", p.astype(np.float32))
        files.append(d / "concept.npy")
        if n is not None:
            np.save(d / "concept_not.npy", n.astype(np.float32))
            files.append(d / "concept_not.npy")
        clicks = {"tool": "Scroll Lens", "version": lens.VERSION, "embedding": lens.MODELS["dino"]["repo"],
                  "positive": [{k: c[k] for k in ("volume", "cube", "voxel_zyx")} for c in state["pos"]],
                  "negative": [{k: c[k] for k in ("volume", "cube", "voxel_zyx")} for c in state["neg"]],
                  "note": "concept.npy is the unit-length mean of the positive clicks' 864-number dinovol embeddings; "
                          "concept_not.npy, when present, that of the Not this clicks. The page scores each patch "
                          "as its cosine to concept.npy minus its cosine to concept_not.npy. Load both files "
                          "together to get the same map back."}
        (d / "clicks.json").write_text(json.dumps(clicks, indent=1) + "\n")
        files.append(d / "clicks.json")
    return [str(f) for f in files]


def as_bool(value, name):
    """True or False from a boolean, 0 or 1, or the strings true, false, yes, no, 1, 0 (a JSON caller may send
    "false", which bool() would read as True)."""
    if isinstance(value, bool):
        return value
    if isinstance(value, (int, float)) and value in (0, 1):
        return bool(value)
    text = str(value).strip().lower()
    if text in ("true", "yes", "1", "on"):
        return True
    if text in ("false", "no", "0", "off", "", "none"):
        return False
    raise LensError(f"{name} must be true or false, not {value!r}")


# ---------------------------------------------------------------- the agent tools' cubes
#
# The agent tools below (PR #114) keep their own view of a cube, with every array in it; the page uses CUBES.

def tool_new_view(vid, origin, size, point, cube, result, source, note=""):
    """Everything the agent tools need about one cube: the scan cube and all arrays (kept in VIEW_CACHE, three at a
    time). The page keeps its cubes in CUBES instead."""
    import numpy as np
    if cube.shape[0] != size:                      # the CPU fallback ran a smaller cube around the point
        off = window_offset(size, cube.shape[0], _local(point, origin))
        origin = tuple(int(v + d) for v, d in zip(origin, off))
        size = int(cube.shape[0])
    nz = cube[cube > 0]
    lo, hi = (np.percentile(nz, (0.5, 99.5)) if nz.size else (0, 255))
    view = {"volume": vid, "origin": tuple(int(v) for v in origin), "size": int(size),
            "point": tuple(int(v) for v in point), "cube": cube, "lo": float(lo), "hi": float(max(hi, lo + 1)),
            "source": source, "note": note, "result": result, "pca": None, "ref_sim": None,
            "foreground": lens.foreground(cube)}
    if "grid" in result or "grid_reduced" in result:
        view["grid"] = lens.grid_of(result).astype(np.float16)
        view["ref_sim"] = lens.similarity(view["grid"].astype(np.float32), REFERENCE)
    view["slices"] = [int(min(max(p - o, 0), size - 1)) for p, o in zip(point, view["origin"])]
    view["receipt"] = lens.receipt(vid, view["origin"], size, point, result,
                                   ENGINE.describe() if ENGINE else None,
                                   {"source": source, "foreground": round(view["foreground"], 4)})
    if "ink" in result and view.get("ref_sim") is not None:
        view["receipt"]["agreement_with_ink_model"] = lens.agreement(view["ref_sim"], result["ink"])
    return view


def tool_click_sim(state):
    view = state["view"]
    p, n = click_concept(state)
    if view is None or p is None or "grid" not in view:
        return None
    import numpy as np
    return lens.similarity(view["grid"].astype(np.float32), p, n)


def tool_load_point(vid, z, y, x, size, layers, tta):
    import numpy as np
    size = lens.check_size(size)
    layers = lens.check_layers([{"3D DINO embeddings": "dino", "3D ink model": "ink",
                                 "Recto surface model": "surface"}.get(k, k) for k in layers])
    point = (int(z), int(y), int(x))
    origin = lens.cube_origin(vid, point, size)
    cube = lens.read_cube(vid, origin, size)
    lens.check_cube(cube)
    result, note = run_models(cube, layers, tta, focus=_local(point, origin))
    src = "gpu" if result.get("device", "").startswith("cuda") else "cpu"
    ran = _window(cube, ran_size(result), _local(point, origin))      # the CPU fallback runs around the point
    return tool_new_view(vid, origin, size, point, np.ascontiguousarray(ran), result, src, note)


def tool_load_gallery(cube_id):
    for it in GALLERY:
        if it["id"] == cube_id:
            meta, arrays = lens.load_npz(HERE / "gallery" / it["file"])
            origin, size = tuple(meta["cube_origin_zyx"]), int(meta["cube_size"])
            cube = lens.read_cube(meta["volume"], origin, size)
            result = dict(arrays, seconds=meta.get("seconds", {}), device=meta.get("device"),
                          device_name=meta.get("device_name"), tta=meta.get("tta", False))
            view = tool_new_view(meta["volume"], origin, size, tuple(meta["point_zyx"]), cube, result, "gallery",
                            it.get("note", ""))
            view["receipt"]["gallery_id"] = cube_id
            return view
    raise LensError(f"no gallery cube {cube_id!r}")


def tool_save_files(state):
    import numpy as np
    view = state["view"]
    if view is None:
        raise LensError("nothing to save yet")
    OUT.mkdir(parents=True, exist_ok=True)
    d = Path(tempfile.mkdtemp(dir=OUT))
    name = f"scroll-lens-{view['volume']}-{'-'.join(map(str, view['origin']))}-{view['size']}"
    files = [lens.save_npz(d / f"{name}.npz", view["receipt"], view["result"])]
    rec = d / f"{name}.json"
    rec.write_text(json.dumps(view["receipt"], indent=1) + "\n")
    files.append(rec)
    p, n = click_concept(state)
    if p is not None:
        np.save(d / "concept.npy", p.astype(np.float32))
        files.append(d / "concept.npy")
        clicks = {"tool": "Scroll Lens", "version": lens.VERSION, "embedding": lens.MODELS["dino"]["repo"],
                  "positive": [{k: c[k] for k in ("volume", "cube", "voxel_zyx")} for c in state["pos"]],
                  "negative": [{k: c[k] for k in ("volume", "cube", "voxel_zyx")} for c in state["neg"]],
                  "note": "concept.npy is the unit-length mean of the positive clicks' 864-number dinovol embeddings"}
        (d / "clicks.json").write_text(json.dumps(clicks, indent=1) + "\n")
        files.append(d / "clicks.json")
    return [str(f) for f in files]


def look(vid: str, z: int, y: int, x: int, size: int = 256, layers: str = "dino,ink,surface", tta: bool = False,
         compact: bool = False, allow_cpu: bool = False):
    """For batch scripts: run the models on the cube around a point and return its .npz (see lens.save_npz). Uses the
    caller's ZeroGPU time; tools/build_gallery.py builds the gallery with it.

    Args:
        vid: a scan id from list_scans.
        z: the point's z in voxels.
        y: the point's y in voxels.
        x: the point's x in voxels.
        size: the cube's side in voxels, 128 or 256.
        layers: comma-separated models to run: dino, ink, surface.
        tta: average the eight mirrorings for ink and surface (8 times their GPU time).
        compact: keep 128 components of each embedding and 16 levels of each probability (about a quarter the size).
        allow_cpu: when ZeroGPU refuses, run the 128 cube around the point on the Space's CPU instead of failing.

    Returns:
        The .npz file.
    """
    import numpy as np
    tta, compact, allow_cpu = as_bool(tta, "tta"), as_bool(compact, "compact"), as_bool(allow_cpu, "allow_cpu")
    size = lens.check_size(size)
    layers = lens.check_layers([s.strip() for s in str(layers).split(",") if s.strip()])
    point = check_point(z, y, x)
    engine()                                       # refuse before reading the bucket while the models load
    origin = lens.cube_origin(vid, point, size)
    cube = lens.read_cube(vid, origin, size)
    lens.check_cube(cube)
    focus = _local(point, origin)
    result, note = run_models(cube, layers, tta, allow_cpu=allow_cpu, focus=focus)
    n = ran_size(result)
    if n != size:                                  # the CPU fallback ran the window around the point
        origin = tuple(int(o + d) for o, d in zip(origin, window_offset(size, n, focus)))
    meta = lens.receipt(vid, origin, n, point, result, ENGINE.describe(), {"source": "api", "note_run": note,
                                                                          "foreground": lens.foreground(cube)})
    if "grid" in result and "ink" in result:
        meta["agreement_with_ink_model"] = lens.agreement(
            lens.similarity(np.asarray(result["grid"], np.float32), REFERENCE), result["ink"])
    OUT.mkdir(parents=True, exist_ok=True)
    d = Path(tempfile.mkdtemp(dir=OUT))
    return str(lens.save_npz(d / f"{vid}-{'-'.join(map(str, origin))}-{n}.npz", meta, result, compact=compact))


# ---------------------------------------------------------------- tools for agents (also the API and MCP server)
#
# Each function below is one API endpoint and one MCP tool. They keep no session: a cube is named by a gallery id, or
# by a scan and a point, and every answer carries a link that opens the same cube on the page for a person.

VIEW_CACHE = {}                     # recent cubes, so a search after an inspect does not run the models again
VIEW_CACHE_N = 3
VIEW_LOCK = threading.Lock()
AGENT_OVERLAYS = {"ink": OVERLAYS[0], "surface": OVERLAYS[1], "clicks": OVERLAYS[2], "reference": OVERLAYS[3],
                  "dino": OVERLAYS[4]}


def page_url(query=""):
    host = os.environ.get("SPACE_HOST")
    base = f"https://{host}/" if host else "/"
    return base + (f"?{query}" if query else "")


def view_link(view):
    gid = view["receipt"].get("gallery_id")
    if gid:
        return page_url(f"cube={gid}")
    z, y, x = view["point"]
    return page_url(f"v={view['volume']}&z={z}&y={y}&x={x}&size={view['size']}")


def agent_view(cube_id="", scan="", z=0, y=0, x=0, size=256):
    """A gallery cube (no GPU), or the cube around a point with all three models run on it (GPU, else CPU)."""
    cube_id = str(cube_id or "").strip()
    if cube_id:
        key = ("gallery", cube_id)
    else:
        if not scan:
            raise LensError("give a gallery cube_id, or a scan and a point (z, y, x)")
        lens.volume(scan)
        size = lens.check_size(int(size))
        point = (int(z), int(y), int(x))
        key = ("point", scan, lens.cube_origin(scan, point, size), size)
    with VIEW_LOCK:
        hit = VIEW_CACHE.pop(key, None)
        if hit is not None:
            VIEW_CACHE[key] = hit
            if cube_id:
                return hit
            hit = dict(hit, point=point)    # same cube, another point in it
            hit["receipt"] = dict(hit["receipt"], point_zyx=list(point))
            hit["slices"] = [int(min(max(p - o, 0), hit["size"] - 1)) for p, o in zip(point, hit["origin"])]
            return hit
    view = tool_load_gallery(cube_id) if cube_id else tool_load_point(scan, point[0], point[1], point[2], size,
                                                            list(lens.LAYERS), False)
    with VIEW_LOCK:
        VIEW_CACHE[key] = view
        while len(VIEW_CACHE) > VIEW_CACHE_N:
            VIEW_CACHE.pop(next(iter(VIEW_CACHE)))
    return view


def agent_overlays(text):
    names = [s.strip().lower() for s in str(text or "").split(",") if s.strip()]
    bad = [n for n in names if n not in AGENT_OVERLAYS]
    if bad:
        raise LensError(f"unknown overlays {bad}; choose from {', '.join(AGENT_OVERLAYS)}")
    return [AGENT_OVERLAYS[n] for n in names]


def three_views(state, overlays, top_pct):
    """The three slice views through the current slices, side by side, as one RGB image."""
    import numpy as np
    imgs = [draw_slice(state, a, overlays, top_pct, 0.7) for a in range(3)]
    gap = np.full((imgs[0].shape[0], 8, 3), 255, np.uint8)
    return np.concatenate([imgs[0], gap, imgs[1], gap, imgs[2]], axis=1)


def summary(view):
    """The receipt plus the numbers a person sees beside the images."""
    import numpy as np
    r = dict(view["receipt"])
    r["source"] = view["source"]
    r["slices_zyx"] = [int(s + o) for s, o in zip(view["slices"], view["origin"])]
    r["foreground"] = round(float(view["foreground"]), 4)
    if view.get("ref_sim") is not None:
        r["reference_fraction_at_tau"] = round(float((view["ref_sim"] >= lens.TAU).mean()), 4)
    for k in ("ink", "surface"):
        if k in view["result"]:
            r[f"{k}_voxel_fraction"] = round(float((np.asarray(view["result"][k]) >= 128).mean()), 4)
    if view["note"]:
        r["run_note"] = view["note"]
    r["open_in_browser"] = view_link(view)
    return r


def list_scans():
    """List the twelve scans Scroll Lens can read, with their size and what each model saw of them in training.

    Points are voxel coordinates (z, y, x) at full resolution. On PHerc. Paris 4, prefer the organizers' eight training
    points (`paris4_training_points`), since its text is an open prize.

    Returns:
        A JSON object: `scans` (id, scroll, voxel_um, shape_zyx, note, models_trained_on_this_scroll),
        `paris4_training_points`, `sizes`, `layers`, `version`.
    """
    scans = [{"id": vid, "scroll": v["scroll"], "voxel_um": v["voxel_um"], "shape_zyx": list(v["shape"]),
              "note": v["note"], "models_trained_on_this_scroll": v["role"]} for vid, v in lens.VOLUMES.items()]
    return {"scans": scans, "paris4_training_points": [list(p) for p in lens.PARIS4_TRAINING_POINTS],
            "sizes": list(lens.SIZES), "layers": list(lens.LAYERS), "version": lens.VERSION,
            "note": "Model output, not a reading. A cube is smaller than one letter."}


def list_gallery():
    """List the ready-made gallery cubes. Inspecting or searching a gallery cube needs no GPU.

    Returns:
        A JSON object: `cubes` (id, scan, title, note, point_zyx, size, open_in_browser).
    """
    return {"cubes": [{"id": it["id"], "scan": it["volume"], "title": it["title"], "note": it["note"],
                       "point_zyx": it["point_zyx"], "size": it["size"],
                       "open_in_browser": page_url(f"cube={it['id']}")} for it in GALLERY]}


def random_point(scan: str, seed: int = 0):
    """Pick a point on the papyrus of a scan (a window around it is at least 90% inside the scroll mask).

    Args:
        scan: a scan id from list_scans, such as PHerc0139.
        seed: any whole number; the same seed gives the same point. On PHercParis4 the point is one of the
            organizers' eight training points.

    Returns:
        A JSON object with z, y, x (voxels) and the scan.
    """
    lens.volume(scan)
    z, y, x = surprise(scan, int(seed))
    return {"scan": scan, "z": int(z), "y": int(y), "x": int(x)}


def inspect_cube(cube_id: str = "", scan: str = "", z: int = 0, y: int = 0, x: int = 0, size: int = 256,
                  overlays: str = "ink,reference"):
    """Look at one cube: three slice views through a point, with model overlays, and the cube's numbers.

    Name the cube either by a gallery `cube_id` (no GPU) or by `scan` and a point (z, y, x), which runs the three
    models on the cube around it (a few seconds of the caller's ZeroGPU time; the Space's CPU on a 128 cube when
    that is used up). The slices cut through the point, or the gallery cube's own point.

    Args:
        cube_id: a gallery cube id from list_gallery; leave empty to use scan and point.
        scan: a scan id from list_scans.
        z: the point's z in voxels.
        y: the point's y in voxels.
        x: the point's x in voxels.
        size: the cube's side in voxels, 128 or 256.
        overlays: comma-separated layers to draw: ink (red), surface (green), reference (amber, patches like the
            organizers' ink reference), dino (DINO colors). Empty for the bare scan.

    Returns:
        An image (top, front and side views left to right, a white dotted line where the others cut) and a JSON
        summary: the receipt, the ink reference agreement AUC with its control, layer fractions, and a link
        that opens the same cube on the page.
    """
    view = agent_view(cube_id, scan, z, y, x, size)
    state = {"view": view, "pos": [], "neg": []}
    return three_views(state, agent_overlays(overlays), 8), summary(view)


def search_cube(cube_id: str = "", scan: str = "", z: int = 0, y: int = 0, x: int = 0, size: int = 256,
                 positive: str = "", negative: str = "", concept: str = None, top_k: int = 20):
    """Search a cube for places like some examples: the organizers' recipe, done by clicking on the page.

    Give examples as voxel points (z, y, x) in scan coordinates inside the cube: `positive` for "more like this",
    `negative` for "not this". Or give a concept file (concept.npy, 864 numbers, from the page's Save files or an
    earlier search), which carries a concept from one cube or scan to another. With neither, the organizers' ink
    reference is the concept. Name the cube as in inspect_cube.

    Args:
        cube_id: a gallery cube id from list_gallery; leave empty to use scan and point.
        scan: a scan id from list_scans.
        z: the point's z in voxels, which picks the cube.
        y: the point's y in voxels.
        x: the point's x in voxels.
        size: the cube's side in voxels, 128 or 256.
        positive: example points like "37600,8800,22100; 37610,8790,22120" (z,y,x; separated by semicolons).
        negative: points to steer away from, written the same way.
        concept: a concept.npy file (path or URL) to use instead of positive points.
        top_k: how many best-matching patches to list (1 to 200).

    Returns:
        An image (the three views through the best match, the matching patches in cyan), a JSON summary (the
        best patches with their scan coordinates and scores, the concept's AUC against the 3D ink model with its
        control, and a link to the page), and the concept as concept.npy.
    """
    import numpy as np
    view = agent_view(cube_id, scan, z, y, x, size)
    if "grid" not in view:
        raise LensError("this cube has no DINO embeddings, so it cannot be searched")
    grid = view["grid"].astype(np.float32)
    pos = lens.parse_points(positive)
    neg = lens.parse_points(negative)
    state = {"view": dict(view), "pos": [], "neg": []}
    for kind, pts in (("pos", pos), ("neg", neg)):
        for p in pts:
            vox = lens.voxel_in_cube(p, view["origin"], view["size"])
            state[kind].append({"volume": view["volume"], "cube": cube_key(view), "voxel": vox,
                                "voxel_zyx": list(p), "vec": grid[lens.token_of(vox, view["size"])]})
    if concept:
        if pos:
            raise LensError("give positive points or a concept file, not both")
        state["pos"] = [{"volume": "file", "cube": "file", "voxel": [-1, -1, -1], "voxel_zyx": None,
                         "vec": lens.load_concept(concept)}]
    used = "clicks" if state["pos"] else "reference"
    if used == "reference":
        if neg:
            raise LensError("negative points need positive points or a concept file")
        sim = view["ref_sim"]
        vec = REFERENCE
    else:
        p_vec, n_vec = click_concept(state)
        sim = lens.similarity(grid, p_vec, n_vec)
        vec = p_vec
    matches = lens.top_matches(sim, view["origin"], int(top_k))
    best = matches[0]["center_zyx"]
    state["view"]["slices"] = lens.voxel_in_cube(best, view["origin"], view["size"])
    overlays = [AGENT_OVERLAYS["clicks" if used == "clicks" else "reference"]]
    img = three_views(state, overlays, 8)
    out = summary(state["view"])
    out.update({"concept": "your examples" if used == "clicks" else "the organizers' ink reference",
                "positive_zyx": [list(p) for p in pos], "negative_zyx": [list(p) for p in neg], "matches": matches,
                "score": "cosine to the concept, minus cosine to the negative examples when given"})
    if "ink" in view["result"]:
        out["agreement_with_ink_model"] = lens.agreement(sim, view["result"]["ink"])
    OUT.mkdir(parents=True, exist_ok=True)
    path = Path(tempfile.mkdtemp(dir=OUT)) / "concept.npy"
    np.save(path, np.asarray(vec, np.float32))
    return img, out, str(path)


def download_cube(cube_id: str = "", scan: str = "", z: int = 0, y: int = 0, x: int = 0, size: int = 256):
    """Get a cube's model outputs as arrays: a .npz with the 3D DINO embeddings (float16, one 864-number unit vector
    per 8³ patch), the 3D ink and recto surface probabilities (uint8 per voxel) and the receipt (JSON). Name the cube
    as in inspect_cube. Read it with numpy.load(allow_pickle=False), or lens.load_npz from the Space's files.

    Args:
        cube_id: a gallery cube id from list_gallery; leave empty to use scan and point.
        scan: a scan id from list_scans.
        z: the point's z in voxels.
        y: the point's y in voxels.
        x: the point's x in voxels.
        size: the cube's side in voxels, 128 or 256.

    Returns:
        The .npz file and its receipt as JSON.
    """
    view = agent_view(cube_id, scan, z, y, x, size)
    paths = tool_save_files({"view": view, "pos": [], "neg": []})
    return paths[0], summary(view)


# ---------------------------------------------------------------- the page

INTRO = """\
**Scroll Lens** shows what three of the Vesuvius Challenge's newest 3D models see inside a carbonized Herculaneum scroll.
It reads a small cube of a public X-ray CT scan straight from the open-data bucket and runs, on that cube:

- **3D DINO embeddings** ([dinovol](https://huggingface.co/scrollprize/dinovol_v2_ps8_with_paris4_352500)): a
  self-supervised model that turns every 8 × 8 × 8 voxel patch into 864 numbers. Patches the model finds alike get
  alike numbers. *DINO colors* paints the three main directions as red, green and blue.
- **3D ink model** ([ink_3d_dino_guided](https://huggingface.co/scrollprize/ink_3d_dino_guided)): marks ink voxels
  directly in the scan, with no flattened surface.
- **Recto surface model** ([surface_recto_3dunet](https://huggingface.co/scrollprize/surface_recto_3dunet)): marks
  the written side of each papyrus sheet.

**Click to search.** With *More like this*, a click takes the embedding of the patch you clicked; every patch with a
similar embedding lights up in cyan. *Not this* subtracts a patch you do not want. This is how the organizers built
their ink reference (*Organizers' ink reference*, in amber): experts clicked ink 256 times and averaged the
embeddings. Your concept follows you to other cubes and scans, so you can click ink in one scroll and look for its
like in another, and you can download it as `concept.npy`.

All three models come from the PHerc. Paris 4 work of the Vesuvius Challenge team
([Angelotti et al. 2026](https://arxiv.org/abs/2606.29085)). Scroll Lens reads only the twelve 2.2 to 2.4 µm scans
those models were made for, none of them eligible for the First Letters or Grand Prize. **Everything shown is model
output, not a reading:** a cube is at most 0.6 mm across, smaller than one letter.
"""

HOW_TO = """\
1. **Gallery** (no GPU needed): pick a ready-made cube; it opens at once. *Previous*, *Next* and *Random* step
   through the list, and you can type in the list to search it.
2. **Your own point**: pick a scan, type a point in voxels (z, y, x) or press **Surprise me**, then **Run the models**.
   A 256³ cube is the models' own working size and uses about 5 seconds of ZeroGPU time (with ZeroGPU's own
   overhead, a call is about 10 seconds). When your daily ZeroGPU time runs out, Scroll Lens runs a 128³ cube on the
   CPU instead (about 10 seconds).
3. **Look**: drag a slider under a view, or drag inside a view to move the crosshair; the other views follow as you
   move. The wheel or the arrow keys step one slice (Shift: 8). Ctrl or Cmd with the wheel, or a pinch, zooms;
   right-drag or Shift-drag pans; double-click resets. The line under the views reads out what is under the pointer.
4. **Search**: a click only moves the crosshair until you choose *More like this*; then click a patch.
   *Not this* subtracts one; *Undo click* (or Ctrl or Cmd with Z) forgets the last one.
   *Top % shown* sets how many of the most similar patches light up.
5. **3D view**: turns the three views into one 3D space: the three slices as planes through the cube (they move
   as you drag the sliders), ink and surface as points, and the patches like your concept or the organizers'
   reference glowing. Drag to rotate, wheel to zoom, right-drag to pan, *Spin* to turn it slowly; click a slice to
   move there (or search there, with *More like this* chosen). *Flat views* goes back.
6. **Take it away**: **Save files** gives the model outputs (.npz), the receipt (.json) and, after clicks, your
   `concept.npy`. *Copy link* gives a link that opens this cube at this crosshair.

Scripts and agents: see *For scripts and agents* below, or the README.
"""

AGENTS_TEXT = """\
Everything on this page is also an API endpoint and an MCP tool, so an AI agent or a script can use Scroll Lens
the way a person does. Every answer includes `open_in_browser`, a link that opens the same cube here for a person.

- `list_scans`: the twelve scans, their sizes, and what each model saw of them in training. No GPU.
- `list_gallery`: the ready-made cubes. No GPU.
- `random_point`: a point on the papyrus of a scan. No GPU.
- `inspect_cube`: three slice views with overlays, and the cube's numbers and checks. No GPU for a gallery cube.
- `search_cube`: places like your example points or concept file (or the organizers' ink reference). No GPU for a
  gallery cube.
- `download_cube`: the models' outputs as arrays (.npz). No GPU for a gallery cube.
- `look`: the .npz for any point, for batch scripts. Uses the GPU.

**MCP** (Claude, Cursor and other MCP clients): add the server `{url}/gradio_api/mcp/`.
Hugging Face's own MCP settings (huggingface.co/settings/mcp) can add this Space too. Runs on a new point use the
caller's ZeroGPU minutes, so sign the client in with a Hugging Face token.

**Python**:

```python
from gradio_client import Client
lens = Client("SharpMeow/scroll-lens")                     # Client(..., token=...) to use your own GPU minutes
cubes = lens.predict(api_name="/list_gallery")["cubes"]
image, info = lens.predict(cubes[0]["id"], "", 0, 0, 0, 256, "ink,reference", api_name="/inspect_cube")
image, hits, concept = lens.predict(cubes[0]["id"], "", 0, 0, 0, 256, "", "", None, 10, api_name="/search_cube")
```

Model output, not a reading. Scroll Lens reads only the twelve listed scans, none eligible for the First Letters or
Grand Prize.
"""

CONNECT_HTML = Path(__file__).with_name("connect.html")

CSS = """
#sl-gpick input { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
#sl-gpick [role=option] { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.sl-head { display:flex; gap:14px; align-items:baseline; flex-wrap:wrap; margin: 4px 0 2px; }
.sl-head h1 { margin:0; font-size: 1.6rem; letter-spacing: .01em; }
.sl-head span { color: var(--body-text-color-subdued); }
footer.sl-credit { text-align:center; color: var(--body-text-color-subdued); font-size:.85rem; margin-top: 8px; }
#sl-viewer-html .slv { display:flex; flex-direction:column; gap:8px; }
#sl-viewer-html .slv-bar { display:flex; flex-direction:column; gap:6px; font-size:.86rem; }
#sl-viewer-html .slv-group { display:flex; flex-wrap:wrap; gap:4px 8px; align-items:center; }
#sl-viewer-html .slv-chip { display:inline-flex; align-items:center; gap:5px; padding:2px 9px; border:1px solid var(--border-color-primary);
  border-radius:999px; cursor:pointer; user-select:none; background: var(--block-background-fill); }
#sl-viewer-html .slv-chip input { margin:0; }
#sl-viewer-html .slv-chip i { width:10px; height:10px; border-radius:2px; display:inline-block; }
#sl-viewer-html .slv-label { color: var(--body-text-color-subdued); margin-right: 2px; }
#sl-viewer-html .slv-sliders label { display:inline-flex; align-items:center; gap:5px; margin-right: 8px; }
#sl-viewer-html .slv-sliders input[type=range] { width:96px; }
#sl-viewer-html .slv-buttons button { font-size:.8rem; padding:3px 10px; border:1px solid var(--border-color-primary); border-radius:6px;
  background: var(--button-secondary-background-fill); color: var(--button-secondary-text-color); cursor:pointer; }
#sl-viewer-html .slv-buttons button:hover { background: var(--button-secondary-background-fill-hover); }
#sl-viewer-html .slv-status { display:flex; gap:10px; align-items:center; font-size:.86rem; color: var(--body-text-color-subdued);
  min-height:1.4em; word-break: break-all; }
#sl-viewer-html .slv-status progress { width:160px; flex: none; }
#sl-viewer-html .slv-views { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap:8px; }
@media (max-width: 900px) { #sl-viewer-html .slv-views { grid-template-columns: 1fr; } }
#sl-viewer-html .slv-view { display:flex; flex-direction:column; gap:4px; min-width:0; }
#sl-viewer-html .slv-title { font-size:.85rem; display:flex; justify-content:space-between; color: var(--body-text-color-subdued); }
#sl-viewer-html .slv-view canvas { display:block; background:#0b0f14; border-radius:6px; touch-action:none; cursor:crosshair;
  outline:none; }
#sl-viewer-html .slv-view canvas:focus-visible { box-shadow: 0 0 0 2px var(--color-accent); }
#sl-viewer-html .slv-view input[type=range] { width:100%; }
#sl-viewer-html .slv-more { display:flex; flex-direction:column; gap:6px; }
#sl-viewer-html .slv-more > summary { cursor:pointer; color: var(--body-text-color-subdued); font-size:.84rem; }
/* Wide screens hide the summary only while the controls are open: a page opened narrow (closed) and then widened
   keeps its way back to them. */
@media (min-width: 601px) { #sl-viewer-html .slv-more[open] > summary { display:none !important; } }
#sl-viewer-html .slv-views[hidden], #sl-viewer-html .slv-3d[hidden] { display:none !important; }
#sl-viewer-html .slv-3d { display:flex; flex-direction:column; gap:6px; }
#sl-viewer-html .slv-3d-canvas { display:block; width:100%; border-radius:6px; background:#0b0f14; touch-action:none; cursor:grab; }
#sl-viewer-html .slv-3d-canvas:active { cursor:grabbing; }
#sl-viewer-html .slv-3d-sliders label { display:inline-flex; align-items:center; gap:6px; flex:1 1 30%; min-width:150px; }
#sl-viewer-html .slv-3d-sliders input[type=range] { flex:1; }
#sl-viewer-html .slv-3d-toggle { font-weight:600; }
#sl-viewer-html .slv-readout { font-family: ui-monospace, monospace; font-size:.8rem; min-height:1.3em; }
#sl-viewer-html .slv-help { font-size:.78rem; color: var(--body-text-color-subdued); }
"""


def fix_mcp_multiple_outputs():
    """Gradio 6.28's MCP server joins a queued tool's several outputs into one text, so inspect_cube's image never
    reached an agent as an image. Hand them over one by one, as its unqueued path already does."""
    try:
        from gradio.mcp import GradioMCPServer
        from anyio.to_thread import run_sync
    except ImportError:
        return

    async def without_progress(self, job):
        result = await run_sync(job.result)
        return list(result) if isinstance(result, tuple) else [result]

    GradioMCPServer._execute_tool_without_progress = without_progress


def theme():
    import gradio as gr
    return gr.themes.Base(primary_hue="amber", neutral_hue="slate", radius_size="sm",
                          font=("system-ui", "sans-serif"), font_mono=("ui-monospace", "monospace"))


def viewer_js():
    return (HERE / "viewer.js").read_text(encoding="utf-8")


def build_demo():
    import gradio as gr

    vol_choices = [(f"{v['scroll']} ({vid})", vid) for vid, v in lens.VOLUMES.items()]
    gal_choices = [(gallery_label(it), it["id"]) for it in GALLERY]
    gal_ids = [it["id"] for it in GALLERY]
    layer_names = [lens.LAYER_TITLES[k] for k in lens.LAYERS]

    def volume_text(vid):
        v = lens.volume(vid)
        z, y, x = v["shape"]
        return (f"{v['note']} Scan: {v['voxel_um']} µm voxels, {z:,} × {y:,} × {x:,} (z, y, x), open data at "
                f"`{lens.BUCKET}/{vid}/volumes/{v['scan']}` ([metadata]({lens.volume_url(vid)}/.zarray))").strip()

    def guard(fn):
        @functools.wraps(fn)          # keeps fn's signature, so gradio still passes EventData and Request
        def wrapped(*args, **kwargs):
            try:
                return fn(*args, **kwargs)
            except LensError as exc:
                raise gr.Error(str(exc)) from None
            except gr.Error:
                raise
            except Exception as exc:  # noqa: BLE001  (anything else still reaches the visitor with its reason)
                import traceback
                log(f"{fn.__name__} failed: {traceback.format_exc()}")
                raise gr.Error(f"{type(exc).__name__}: {str(exc)[:400]}") from None
        return wrapped

    def soft(n):
        """For the page's own events: a refusal shows as a warning and leaves the page as it was (an error would
        cover the views with error panels). The API endpoints use guard, so callers get a real error."""
        def deco(fn):
            @functools.wraps(fn)
            def wrapped(*args, **kwargs):
                try:
                    return fn(*args, **kwargs)
                except LensError as exc:
                    gr.Warning(str(exc))
                except gr.Error as exc:
                    gr.Warning(str(exc))
                except Exception as exc:  # noqa: BLE001
                    import traceback
                    log(f"{fn.__name__} failed: {traceback.format_exc()}")
                    gr.Warning(f"{type(exc).__name__}: {str(exc)[:300]}")
                return gr.skip() if n == 1 else [gr.skip()] * n
            return wrapped
        return deco

    def shown(state):                 # the visitor's own state dict, and what the viewer and the text show of it
        snap = snapshot(state)
        return [state, viewer_value(snap), numbers(snap)]

    def fresh():                      # a new cube: clear the old saved files and receipt
        return [None, None]

    @soft(5)
    def on_run(vid, z, y, x, size, layers, tta, state, request: gr.Request):
        ticket = next_rev()
        with one_run(request):
            view = load_point(vid, z, y, x, size, layers, tta)
        show_view(state, view, ticket, force=True)
        return shown(state) + fresh()

    def open_gallery(cube_id, state):
        if not cube_id:
            raise LensError("pick a gallery cube")
        ticket = next_rev()
        view = load_gallery(cube_id)
        if not show_view(state, view, ticket):
            return [gr.skip()] * 11           # the visitor opened another cube meanwhile, and that one stays
        p = view["point"]
        return shown(state) + fresh() + [view["volume"], p[0], p[1], p[2], view["size"], cube_id]

    @soft(11)
    def on_gallery(cube_id, state):
        return open_gallery(cube_id, state)

    @soft(11)
    def on_gallery_step(cube_id, state, *, step):
        if not gal_ids:
            raise LensError("the gallery is empty on this copy; pick a point instead")
        if step == "random":
            import numpy as np
            nxt = gal_ids[int(np.random.default_rng().integers(0, len(gal_ids)))]
        else:
            i = gal_ids.index(cube_id) if cube_id in gal_ids else -1
            nxt = gal_ids[(i + (1 if step == "next" else -1)) % len(gal_ids)]
        return open_gallery(nxt, state)

    @soft(3)
    def on_surprise(vid):
        z, y, x = surprise(vid)
        return z, y, x

    @soft(3)
    def on_viewer_select(state, evt: gr.EventData):
        """Clicks from the viewer: {"key": cube, "clicks": [{"id", "index": [z, y, x], "value": "pos" or "neg"}]}.
        The viewer sends every click the server has not yet acknowledged, so none is lost when two arrive while one
        is being handled (take_clicks)."""
        data = getattr(evt, "_data", None) or {}
        key = str(data.get("key") or "")
        clicks = [c for c in (data.get("clicks") or [])[:64] if isinstance(c, dict)]
        if state["view"] is None and key.startswith("gallery:"):
            ticket = next_rev()             # the session expired while the browser kept showing this gallery cube
            try:
                show_view(state, load_gallery(key.split(":", 1)[1]), ticket)
            except LensError:
                pass
        taken, problem = take_clicks(state, key, clicks)
        if problem:
            gr.Warning(problem)
        if not taken:
            return [state, ack_only(key, clicks), gr.skip()]
        return shown(state)

    @soft(3)
    def on_undo(state, evt: gr.EventData):
        """Forget a click: the one the viewer names (it may still be on its way), else the most recent on any cube."""
        data = getattr(evt, "_data", None) or {}
        undo_click(state, data.get("id"))
        return shown(state)

    @soft(3)
    def on_clear(state, evt: gr.EventData):
        data = getattr(evt, "_data", None) or {}
        clear_clicks(state, [str(i) for i in (data.get("ids") or []) if isinstance(i, (str, int))])
        return shown(state)

    @soft(5)
    def on_viewer_use(evt: gr.EventData):
        data = getattr(evt, "_data", None) or {}
        vid = data.get("volume")
        lens.volume(vid)
        z, y, x = check_point(*(data.get("point") or [None] * 3))
        return vid, z, y, x, gr.Tabs(selected="point")

    @soft(3)
    def on_concept(paths, state):
        """Load concept.npy, and concept_not.npy with it if you have one: they replace your clicks."""
        paths = [p for p in (paths if isinstance(paths, list) else [paths]) if p]
        if not paths:                         # the file was removed: drop the loaded concept, keep the clicks
            set_concept(state, None, None)
            return shown(state)
        pos, neg = [], []
        for p in paths[:2]:
            item = {"volume": "file", "cube": "file", "voxel": None, "voxel_zyx": None, "vec": lens.load_concept(p)}
            (neg if "_not" in Path(p).name.lower() else pos).append(item)
        if not pos:
            raise LensError("load concept.npy too: concept_not.npy alone has nothing to look for")
        set_concept(state, pos, neg)
        return shown(state)

    @soft(2)
    def on_save(state):
        snap = snapshot(state, rev=False)
        return save_files(snap), (snap["view"] or {}).get("receipt")

    def on_load(state, request: gr.Request):
        """Open the page from its link: ?cube=ID opens a gallery cube; ?v=SCAN&z=&y=&x=&size= fills Your own point
        (press Run the models); anything else opens the first gallery cube. A link that cannot be followed says why."""
        q = dict(request.query_params) if request else {}
        fields, tab, problems = [gr.skip()] * 4, gr.skip(), []
        size = gr.update(value=int(q["size"])) if q.get("size") in ("128", "256") else gr.skip()
        if q.get("size") and q.get("size") not in ("128", "256"):
            problems.append(f"size must be 128 or 256, not {q['size']!r}")
        cube_id = q.get("cube")
        if q.get("v"):
            try:
                vid = lens.volume_id(q["v"])
                ok = {k: str(q.get(k, "")).isascii() and str(q.get(k, "")).isdigit() for k in "zyx"}
                fields = [vid] + [int(q[k]) if ok[k] else gr.skip() for k in "zyx"]
                bad = [k for k in "zyx" if not ok[k]]
                if bad:
                    problems.append(f"{', '.join(bad)} must be whole numbers of voxels")
                tab = gr.Tabs(selected="point")
            except LensError as exc:
                problems.append(str(exc))
        if cube_id and cube_id not in gal_ids:
            problems.append(f"no gallery cube {cube_id!r}")
            cube_id = None
        if not cube_id and gal_ids:
            cube_id = gal_ids[0]
        if problems:
            gr.Warning(f"This link could not be followed in full: {'; '.join(problems)}.")
        if not cube_id:
            return shown(state) + fields + [size, gr.skip(), tab]
        ticket = next_rev()
        try:
            view = load_gallery(cube_id)
        except (LensError, OSError) as exc:
            log(f"link not loaded: {exc}")
            gr.Warning(f"The gallery cube could not be opened: {exc}")
            return shown(state) + fields + [size, gr.skip(), tab]
        if not show_view(state, view, ticket):       # the visitor already opened another cube
            return [gr.skip()] * 3 + fields + [size, gr.skip(), tab]
        if not q.get("v"):
            p = view["point"]
            fields = [view["volume"], p[0], p[1], p[2]]
        if not q.get("size"):
            size = gr.update(value=view["size"])
        return shown(state) + fields + [size, cube_id, tab]

    with gr.Blocks(title="Scroll Lens", delete_cache=(1800, 3600)) as demo:
        state = gr.State(empty_state(), time_to_live=STATE_TTL_S)
        gr.HTML('<div class="sl-head"><h1>Scroll Lens</h1><span>Look inside a Herculaneum scroll with the '
                'Vesuvius Challenge’s 3D models: embeddings, ink and surfaces, and click to search.</span></div>')
        with gr.Row(equal_height=False):
            with gr.Column(scale=1, min_width=290, elem_id="sl-controls"):
                with gr.Tabs() as tabs:
                    with gr.Tab("Gallery", id="gallery"):
                        gpick = gr.Dropdown(gal_choices, value=gal_choices[0][1] if gal_choices else None,
                                            label=f"Ready-made cube, {len(gal_choices)} in all (no GPU needed)",
                                            filterable=True, elem_id="sl-gpick")
                        with gr.Row(equal_height=True):
                            gprev = gr.Button("Previous", size="sm", min_width=60)
                            grand = gr.Button("Random", size="sm", min_width=60)
                            gnext = gr.Button("Next", size="sm", min_width=60)
                        if not gal_choices:
                            gr.Markdown("The gallery is empty on this copy. Pick a point instead.")
                    with gr.Tab("Your own point", id="point"):
                        vid = gr.Dropdown(vol_choices, value="PHercParis4", label="Scan", filterable=False)
                        vinfo = gr.Markdown(volume_text("PHercParis4"))
                        with gr.Row():
                            z = gr.Number(37628, label="z", precision=0, minimum=0)
                            y = gr.Number(8793, label="y", precision=0, minimum=0)
                            x = gr.Number(22167, label="x", precision=0, minimum=0)
                        size = gr.Radio([128, 256], value=256, label="Cube side, voxels")
                        layers = gr.CheckboxGroup(layer_names, value=layer_names, label="Models to run")
                        tta = gr.Checkbox(False, label="Average eight mirrorings for ink and surface (slower, smoother)")
                        with gr.Row():
                            sbtn = gr.Button("Surprise me")
                            rbtn = gr.Button("Run the models", variant="primary")
                with gr.Accordion("Your concept and files", open=False):
                    concept_up = gr.UploadButton("Load concept.npy (and concept_not.npy): replaces your clicks",
                                                 file_types=[".npy"], file_count="multiple", type="filepath", size="sm")
                    save = gr.Button("Save files", size="sm")
                    files = gr.File(label="Model outputs, receipt and your concept", file_count="multiple")
                    receipt = gr.JSON(label="Receipt")
                with gr.Accordion("How to use it", open=False):
                    gr.Markdown(HOW_TO)
                with gr.Accordion("For agents and scripts", open=False, elem_id="sl-agents"):
                    gr.Markdown(AGENTS_TEXT.format(url=page_url().rstrip("/") if page_url() != "/" else
                                                   "https://sharpmeow-scroll-lens.hf.space"))
                with gr.Accordion("Connect your tool", open=False, elem_id="sl-connect"):
                    # The data's URLs and ready snippets, made by python -m kit spaceconnect render from connect.json
                    gr.HTML(CONNECT_HTML.read_text() if CONNECT_HTML.exists() else "")
            with gr.Column(scale=3, min_width=320, elem_id="sl-viewer"):
                viewer = gr.HTML("", html_template='<div class="slv-mount"></div>', js_on_load=viewer_js(),
                                 apply_default_css=False, elem_id="sl-viewer-html")
                info = gr.Markdown(numbers(empty_state()))
                with gr.Accordion("About", open=False):
                    gr.Markdown(INTRO)
        gr.HTML('<footer class="sl-credit">Built by Chase Hendrick · models and data by the Vesuvius Challenge · '
                f'Scroll Lens {lens.VERSION}</footer>')

        # hidden: the API endpoints and MCP tools for scripts and agents
        with gr.Row(visible=False):
            a_vid, a_layers = gr.Textbox(), gr.Textbox(value="dino,ink,surface")
            a_z, a_y, a_x, a_size = gr.Number(precision=0), gr.Number(precision=0), gr.Number(precision=0), \
                gr.Number(256, precision=0)
            a_tta, a_compact, a_cpu = gr.Checkbox(False), gr.Checkbox(False), gr.Checkbox(False)
            a_out, a_btn = gr.File(), gr.Button()
            t_id, t_scan = gr.Textbox("", label="cube_id"), gr.Textbox("", label="scan")
            t_z, t_y, t_x = (gr.Number(0, precision=0, label=k) for k in "zyx")
            t_size, t_seed = gr.Number(256, precision=0, label="size"), gr.Number(0, precision=0, label="seed")
            t_over = gr.Textbox("ink,reference", label="overlays")
            t_pos, t_neg = gr.Textbox("", label="positive"), gr.Textbox("", label="negative")
            t_concept = gr.File(label="concept", file_types=[".npy"], type="filepath")
            t_k = gr.Number(20, precision=0, label="top_k")
            t_img, t_json, t_file = gr.Image(type="numpy", format="png"), gr.JSON(), gr.File()
            t_btns = [gr.Button() for _ in range(6)]

        out = [state, viewer, info]
        stale = [files, receipt]
        hidden = dict(api_visibility="private")
        vid.change(volume_text, vid, vinfo, **hidden)
        rbtn.click(on_run, [vid, z, y, x, size, layers, tta, state], out + stale, concurrency_id="models",
                   concurrency_limit=2, **hidden)
        gal_out = out + stale + [vid, z, y, x, size, gpick]
        gpick.input(on_gallery, [gpick, state], gal_out, concurrency_limit=4, **hidden)
        for btn, step in ((gprev, "previous"), (gnext, "next"), (grand, "random")):
            btn.click(functools.partial(on_gallery_step, step=step), [gpick, state], gal_out, concurrency_limit=4,
                      **hidden)
        sbtn.click(on_surprise, vid, [z, y, x], **hidden)
        viewer.select(on_viewer_select, state, out, show_progress="hidden", trigger_mode="multiple", **hidden)
        viewer.clear(on_clear, state, out, show_progress="hidden", **hidden)
        viewer.undo(on_undo, state, out, show_progress="hidden", **hidden)
        viewer.apply(on_viewer_use, None, [vid, z, y, x, tabs], show_progress="hidden", **hidden)
        concept_up.upload(on_concept, [concept_up, state], out, **hidden)
        save.click(on_save, state, [files, receipt], **hidden)
        a_btn.click(guard(look), [a_vid, a_z, a_y, a_x, a_size, a_layers, a_tta, a_compact, a_cpu], a_out,
                    api_name="look", concurrency_id="models", concurrency_limit=2)
        cube_in = [t_id, t_scan, t_z, t_y, t_x, t_size]
        for btn, fn, ins, outs, name in (
                (t_btns[0], list_scans, [], t_json, "list_scans"),
                (t_btns[1], list_gallery, [], t_json, "list_gallery"),
                (t_btns[2], random_point, [t_scan, t_seed], t_json, "random_point"),
                (t_btns[3], inspect_cube, cube_in + [t_over], [t_img, t_json], "inspect_cube"),
                (t_btns[4], search_cube, cube_in + [t_pos, t_neg, t_concept, t_k], [t_img, t_json, t_file],
                 "search_cube"),
                (t_btns[5], download_cube, cube_in, [t_file, t_json], "download_cube")):
            models = name in ("inspect_cube", "search_cube", "download_cube")
            btn.click(guard(fn), ins, outs, api_name=name, api_description=fn.__doc__.split("\n\n")[0],
                      concurrency_id="models" if models else None, concurrency_limit=2 if models else 8)
        demo.load(on_load, state, out + [vid, z, y, x, size, gpick, tabs], **hidden)
    return demo


class NoUrlFiles:
    """ASGI middleware, a second guard beside close_url_fetching: refuse Gradio's file routes when they name a URL
    (/file=URL, /file/URL), and uploads of more than MAX_UPLOAD_BYTES in one request (max_file_size counts each file
    of a request on its own)."""

    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        if scope.get("type") == "http":
            from urllib.parse import unquote
            path = unquote(unquote(scope.get("path", ""))).lower()
            if any(t in path for t in ("/file=http:", "/file=https:", "/file=//", "/file/http:", "/file/https:",
                                       "/file//")):
                return await self.refuse(send, 403, b"Scroll Lens does not fetch URLs for callers.")
            if scope.get("method") == "POST" and path.rstrip("/").endswith("/upload"):
                length = dict(scope.get("headers") or []).get(b"content-length")
                if length is not None and length.isdigit() and int(length) > MAX_UPLOAD_BYTES:
                    return await self.refuse(send, 413, b"An upload to Scroll Lens is at most 12 MB.")
        await self.app(scope, receive, send)

    @staticmethod
    async def refuse(send, status, body):
        await send({"type": "http.response.start", "status": status,
                    "headers": [(b"content-type", b"text/plain; charset=utf-8")]})
        await send({"type": "http.response.body", "body": body})


MAX_UPLOAD_BYTES = 12 << 20


HEAD = """
<meta name="description" content="Scroll Lens: look inside Herculaneum scroll CT scans with the Vesuvius Challenge's
3D DINO, 3D ink and recto surface models; click to search. API and MCP server for agents.">
<meta property="og:title" content="Scroll Lens">
<meta property="og:description" content="Look inside a Herculaneum scroll with the Vesuvius Challenge's 3D models,
and click to search. Model output, not a reading.">
"""
# One page scroll bar: fits every same-origin iframe to its content so only the page scrolls (kit spaceconnect render).
ONESCROLL_HTML = HERE / "onescroll.html"
HEAD = HEAD + (ONESCROLL_HTML.read_text() if ONESCROLL_HTML.exists() else "")


def close_url_fetching():
    """Gradio streams any public URL through its file routes (/file=URL and the older /file/URL) and downloads any URL
    a caller puts in a file input, with no size limit. This Space serves its own files only, and downloads a file input
    only from itself or the Hugging Face Hub (an agent may pass back a concept.npy this Space gave it)."""
    import gradio.processing_utils as pu
    import gradio.route_utils as ru
    import gradio.routes as rt
    from urllib.parse import urlparse
    from fastapi import HTTPException

    async def refuse_stream(url, request):
        raise HTTPException(status_code=403, detail="Scroll Lens does not fetch URLs for callers.")
    rt.secure_url_stream_response = ru.secure_url_stream_response = refuse_stream
    try:
        import gradio.static_server as ss
        ss.secure_url_stream_response = refuse_stream
    except ImportError:
        pass
    allowed = {h for h in (os.environ.get("SPACE_HOST"), "huggingface.co", "hf.co") if h}
    original = getattr(pu.async_ssrf_protected_download, "__wrapped_original__", pu.async_ssrf_protected_download)

    async def download(url, cache_dir):
        if (urlparse(url).hostname or "").lower() not in allowed:
            raise PermissionError("Scroll Lens downloads files only from this Space or the Hugging Face Hub")
        return await original(url, cache_dir)
    download.__wrapped_original__ = original
    pu.async_ssrf_protected_download = download


def launch_kwargs():
    from starlette.middleware import Middleware
    close_url_fetching()
    VIEW_DIR.mkdir(parents=True, exist_ok=True)
    return dict(theme=theme(), css=CSS, head=HEAD, footer_links=["api", "gradio"], allowed_paths=[str(VIEW_DIR)],
                mcp_server=True, max_file_size="5mb", app_kwargs={"middleware": [Middleware(NoUrlFiles)]})


if __name__ == "__main__":
    startup()
    fix_mcp_multiple_outputs()
    build_demo().queue(max_size=256, default_concurrency_limit=8).launch(**launch_kwargs())