diff --git a/Icons/hairstyle.png b/Icons/hairstyle.png new file mode 100644 index 0000000..d7f3148 Binary files /dev/null and b/Icons/hairstyle.png differ diff --git a/Icons/pose_body/body_front.ai b/Icons/pose_body/body_front.ai new file mode 100644 index 0000000..e69de29 diff --git a/Icons/pose_body/body_front.png b/Icons/pose_body/body_front.png new file mode 100644 index 0000000..db2576e Binary files /dev/null and b/Icons/pose_body/body_front.png differ diff --git a/Icons/pose_body/body_front.psd b/Icons/pose_body/body_front.psd new file mode 100644 index 0000000..1366075 Binary files /dev/null and b/Icons/pose_body/body_front.psd differ diff --git a/Icons/pose_body/body_front.svg b/Icons/pose_body/body_front.svg new file mode 100644 index 0000000..3138ca6 --- /dev/null +++ b/Icons/pose_body/body_front.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Icons/pose_body/koerper.ai b/Icons/pose_body/koerper.ai new file mode 100644 index 0000000..277033c --- /dev/null +++ b/Icons/pose_body/koerper.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (koerper.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 229.0000 1159.0000 m 238.8551 1156.7509 248.0536 1150.3586 255.0000 1145.0000 C 276.0932 1145.7522 285.1992 1150.1928 297.0000 1159.0000 C 297.6666 1158.6667 298.3334 1158.3333 299.0000 1158.0000 C 299.6666 1145.0013 300.3334 1131.9987 301.0000 1119.0000 C 318.4016 1088.6444 396.9466 1098.8351 411.0000 1065.0000 c 428.0913 1023.8507 410.3302 943.7519 418.0000 894.0000 c 423.2641 859.8535 434.2150 839.8268 441.0000 809.0000 C 445.6662 774.0035 450.3338 738.9965 455.0000 704.0000 C 464.0314 666.0110 521.1729 637.3541 529.0000 598.0000 C 527.6668 598.0000 526.3332 598.0000 525.0000 598.0000 C 519.0648 600.4933 516.5341 603.5037 512.0000 607.0000 C 508.9674 604.3088 510.4828 603.9896 505.0000 603.0000 C 503.6668 603.9999 502.3332 605.0001 501.0000 606.0000 C 498.7362 620.0683 483.9982 639.2759 468.0000 634.0000 C 473.3385 624.2162 482.6363 618.1433 484.0000 605.0000 C 473.3991 598.3708 468.8512 613.3308 464.0000 619.0000 c 454.3208 630.3114 437.8465 638.1430 434.0000 655.0000 C 433.6667 665.9989 433.3333 677.0011 433.0000 688.0000 C 418.0015 723.6631 402.9985 759.3369 388.0000 795.0000 C 373.7588 831.2642 368.1085 877.9416 368.0000 927.0000 C 366.3335 925.6668 364.6665 924.3332 363.0000 923.0000 C 356.2669 898.5021 342.3540 860.7613 349.0000 832.0000 c 362.4153 773.9440 408.0657 722.9859 394.0000 636.0000 c 380.9063 555.0249 358.2833 488.4140 345.0000 412.0000 c 337.2191 367.2397 354.2014 325.2037 347.0000 281.0000 c 339.2755 233.5856 330.6235 194.0853 321.0000 153.0000 c 315.3270 128.7807 311.0253 89.0604 313.0000 64.0000 c 314.4717 45.3228 337.2518 20.6528 327.0000 3.0000 C 313.5079 0.7795 293.8288 -1.7836 279.0000 1.0000 C 265.1707 12.3636 276.8742 28.8978 273.0000 50.0000 c 266.1604 87.2542 285.2973 116.0243 279.0000 157.0000 C 278.0001 178.9978 276.9999 201.0022 276.0000 223.0000 C 272.7229 244.0777 269.1402 275.3002 273.0000 300.0000 C 274.3332 313.6653 275.6668 327.3347 277.0000 341.0000 C 273.6670 355.6652 270.3330 370.3348 267.0000 385.0000 C 268.3332 416.6635 269.6668 448.3365 271.0000 480.0000 C 270.6667 505.3308 270.3333 530.6692 270.0000 556.0000 C 273.6050 590.3442 275.4010 624.3701 281.0000 652.0000 C 280.6667 652.0000 280.3333 652.0000 280.0000 652.0000 C 270.0010 651.6667 259.9990 651.3333 250.0000 651.0000 C 253.0283 632.1905 255.7119 611.6625 259.0000 590.0000 C 259.0000 549.3374 259.0000 508.6626 259.0000 468.0000 C 260.3332 441.3360 261.6668 414.6640 263.0000 388.0000 C 259.6670 372.3349 256.3330 356.6651 253.0000 341.0000 C 254.3332 326.6681 255.6668 312.3319 257.0000 298.0000 C 262.0870 264.9175 256.5385 215.0167 252.0000 185.0000 c 246.1474 146.2918 254.9968 107.9588 260.0000 79.0000 C 259.0001 66.6679 257.9999 54.3321 257.0000 42.0000 C 256.4172 23.3225 259.1067 11.4419 251.0000 0.0000 C 237.8957 0.0310 210.8185 0.9050 200.0000 8.0000 C 197.0934 27.3185 212.0449 42.8813 216.0000 60.0000 c 217.1107 64.8075 216.3444 108.1508 215.0000 116.0000 c 204.7898 175.6126 192.3903 223.3269 182.0000 286.0000 c 174.9628 328.4476 192.2578 374.2820 184.0000 419.0000 C 169.6681 477.6608 155.3319 536.3392 141.0000 595.0000 C 139.3335 610.6651 137.6665 626.3349 136.0000 642.0000 C 122.2425 725.2077 167.0489 774.8014 180.0000 832.0000 c 184.4924 851.8407 172.8562 917.3212 163.0000 925.0000 C 163.0000 924.3334 163.0000 923.6666 163.0000 923.0000 C 162.3334 911.3345 161.6666 899.6655 161.0000 888.0000 C 156.6990 862.0425 155.1218 836.2405 148.0000 813.0000 C 131.6683 773.0040 115.3317 732.9960 99.0000 693.0000 C 98.0001 679.6680 96.9999 666.3320 96.0000 653.0000 C 92.6468 642.8389 57.4000 603.5266 46.0000 603.0000 C 46.0000 603.6666 46.0000 604.3334 46.0000 605.0000 C 45.6667 605.3333 45.3333 605.6667 45.0000 606.0000 C 47.5041 618.5019 56.5937 625.0048 62.0000 635.0000 C 34.4675 631.6539 30.3755 602.6375 19.0000 603.0000 C 18.3334 603.9999 17.6666 605.0001 17.0000 606.0000 C 12.5414 601.0267 9.7760 598.0268 0.0000 598.0000 C 6.4498 622.7704 33.7879 649.6734 50.0000 667.0000 c 57.6522 675.1783 70.5049 682.0770 74.0000 694.0000 C 79.3328 734.6626 84.6672 775.3374 90.0000 816.0000 C 96.4836 843.5382 106.9161 860.9481 112.0000 892.0000 C 112.6666 931.9960 113.3334 972.0040 114.0000 1012.0000 C 113.9997 1030.1287 112.6499 1049.5642 118.0000 1063.0000 c 127.0910 1085.8303 141.1191 1081.8339 167.0000 1089.0000 c 182.3161 1093.2409 219.2943 1106.5248 226.0000 1120.0000 C 226.9999 1132.9987 228.0001 1146.0013 229.0000 1159.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/kopf.ai b/Icons/pose_body/kopf.ai new file mode 100644 index 0000000..f88a166 --- /dev/null +++ b/Icons/pose_body/kopf.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (kopf.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 251.0000 1385.0000 m 273.8161 1385.2928 300.7425 1372.3409 305.0000 1354.0000 c 306.8037 1346.2300 302.7450 1339.2870 306.0000 1335.0000 C 313.9992 1328.3340 322.0008 1321.6660 330.0000 1315.0000 C 354.6259 1280.1051 349.4749 1229.6209 324.0000 1201.0000 C 321.3336 1194.6673 318.6664 1188.3327 316.0000 1182.0000 C 311.0005 1176.3339 305.9995 1170.6661 301.0000 1165.0000 C 300.3334 1161.6670 299.6666 1158.3330 299.0000 1155.0000 C 261.9143 1115.4675 213.6948 1165.8901 207.0000 1201.0000 C 189.6939 1202.4396 192.8712 1218.8915 188.0000 1233.0000 c 178.8869 1259.3943 185.1491 1295.0442 200.0000 1315.0000 c 207.5650 1325.1654 225.7941 1328.9934 229.0000 1343.0000 C 218.3514 1368.6503 234.4272 1374.9075 251.0000 1385.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/linke_hand.ai b/Icons/pose_body/linke_hand.ai new file mode 100644 index 0000000..62808df --- /dev/null +++ b/Icons/pose_body/linke_hand.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (linke_hand.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 442.0000 690.0000 m 447.6661 689.6667 453.3339 689.3333 459.0000 689.0000 C 477.4999 677.2289 524.8600 621.0969 530.0000 598.0000 C 529.6667 597.6667 529.3333 597.3333 529.0000 597.0000 C 518.3576 598.0797 517.7607 602.9975 511.0000 607.0000 C 509.9733 601.5698 510.7002 602.6622 505.0000 602.0000 C 503.3335 603.3332 501.6665 604.6668 500.0000 606.0000 C 496.8390 624.6107 486.7534 629.4043 470.0000 634.0000 C 473.1414 621.2863 485.8802 617.7286 484.0000 603.0000 C 474.2854 600.7975 438.1487 641.2900 434.0000 651.0000 c 429.6165 661.2596 432.5661 675.8015 433.0000 687.0000 C 433.6666 687.0000 434.3334 687.0000 435.0000 687.0000 C 437.3331 687.9999 439.6669 689.0001 442.0000 690.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/linke_huefte.ai b/Icons/pose_body/linke_huefte.ai new file mode 100644 index 0000000..dd49923 --- /dev/null +++ b/Icons/pose_body/linke_huefte.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (linke_huefte.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 360.0000 769.0000 m 365.3328 768.3334 370.6672 767.6666 376.0000 767.0000 C 389.3379 724.2350 395.0696 686.0585 394.0000 630.0000 C 393.3334 629.6667 392.6666 629.3333 392.0000 629.0000 C 365.5890 623.4822 335.2978 653.7018 331.0000 674.0000 c 328.6137 685.2705 333.0399 703.5525 334.0000 714.0000 c 335.5943 731.3489 330.9011 745.4335 341.0000 759.0000 c 345.3688 764.8689 353.1042 765.5366 360.0000 769.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/linke_schulter.ai b/Icons/pose_body/linke_schulter.ai new file mode 100644 index 0000000..0d6b8a6 --- /dev/null +++ b/Icons/pose_body/linke_schulter.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (linke_schulter.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 365.0000 1089.0000 m 414.9642 1090.2220 424.6863 1043.1348 416.0000 1000.0000 C 396.7569 997.0739 384.2493 1002.9651 371.0000 1007.0000 c 363.1889 1009.3788 357.2608 1006.7751 348.0000 1010.0000 c 331.1677 1015.8616 320.8307 1035.9984 316.0000 1054.0000 c 314.6460 1059.0456 311.7446 1071.7944 315.0000 1078.0000 c 320.8208 1089.0959 355.4646 1081.0881 365.0000 1089.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/linker_elbogen.ai b/Icons/pose_body/linker_elbogen.ai new file mode 100644 index 0000000..217e9ca --- /dev/null +++ b/Icons/pose_body/linker_elbogen.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (linker_elbogen.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 385.0000 895.0000 m 396.3322 894.0001 407.6678 892.9999 419.0000 892.0000 C 420.8288 869.2737 430.9571 857.2419 432.0000 837.0000 C 431.3334 836.6667 430.6666 836.3333 430.0000 836.0000 C 413.0667 831.5774 395.9857 830.8379 377.0000 834.0000 C 373.0961 849.4181 366.2085 875.9756 371.0000 892.0000 C 375.6662 892.9999 380.3338 894.0001 385.0000 895.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/linker_fuss.ai b/Icons/pose_body/linker_fuss.ai new file mode 100644 index 0000000..7eea6c3 --- /dev/null +++ b/Icons/pose_body/linker_fuss.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (linker_fuss.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 277.0000 103.0000 m 291.1478 103.6525 309.8996 102.6367 315.0000 94.0000 C 315.3333 93.6667 315.6667 93.3333 316.0000 93.0000 C 315.6667 82.0011 315.3333 70.9989 315.0000 60.0000 C 317.9908 44.5742 334.5629 25.1506 331.0000 8.0000 C 330.6667 7.6667 330.3333 7.3333 330.0000 7.0000 C 328.3335 4.6669 326.6665 2.3331 325.0000 0.0000 C 313.8115 -0.3247 283.5760 -3.9736 277.0000 2.0000 C 265.8761 10.9713 267.9965 90.2803 277.0000 103.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/linkes_auge.ai b/Icons/pose_body/linkes_auge.ai new file mode 100644 index 0000000..024356a --- /dev/null +++ b/Icons/pose_body/linkes_auge.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (linkes_auge.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 277.0000 1226.0000 m 277.0069 1235.1036 279.3300 1237.1623 283.0000 1242.0000 C 296.7917 1245.9101 303.0520 1247.9246 317.0000 1242.0000 C 316.9406 1235.8168 317.0579 1236.4457 318.0000 1231.0000 C 304.4480 1222.8409 293.8836 1225.3946 277.0000 1226.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/linkes_knie.ai b/Icons/pose_body/linkes_knie.ai new file mode 100644 index 0000000..e0a3f1c --- /dev/null +++ b/Icons/pose_body/linkes_knie.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (linkes_knie.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 288.0000 412.0000 m 308.2769 412.4618 333.1308 414.0636 345.0000 406.0000 C 345.2706 386.2579 350.1005 351.4985 343.0000 336.0000 C 319.2309 335.8991 294.0563 328.0272 275.0000 333.0000 C 274.9537 358.2237 255.9923 384.2344 268.0000 410.0000 C 274.7488 409.6650 284.2934 409.1470 288.0000 412.0000 C n *U *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 273.0000 343.0000 m 273.3333 342.3334 273.6667 341.6666 274.0000 341.0000 C 273.6667 341.6666 273.3333 342.3334 273.0000 343.0000 C n *U *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 273.0000 340.0000 m 273.3333 339.3334 273.6667 338.6666 274.0000 338.0000 C 273.6667 338.6666 273.3333 339.3334 273.0000 340.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/linkes_ohr.ai b/Icons/pose_body/linkes_ohr.ai new file mode 100644 index 0000000..df0d57b --- /dev/null +++ b/Icons/pose_body/linkes_ohr.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (linkes_ohr.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 333.0000 1245.0000 m 339.5525 1242.9758 340.2438 1238.5036 343.0000 1233.0000 C 338.7459 1220.8678 335.5297 1211.3657 328.0000 1203.0000 C 323.4739 1203.8592 325.0245 1202.8840 323.0000 1206.0000 C 317.5339 1219.4039 329.6389 1235.0141 333.0000 1245.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/nase.ai b/Icons/pose_body/nase.ai new file mode 100644 index 0000000..ccef333 --- /dev/null +++ b/Icons/pose_body/nase.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (nase.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 256.0000 1244.0000 m 265.8410 1243.9900 273.8552 1240.8700 277.0000 1234.0000 C 280.8103 1224.9493 281.8250 1206.4314 280.0000 1197.0000 C 272.4634 1193.3288 259.8051 1193.6213 251.0000 1196.0000 C 250.6667 1196.9999 250.3333 1198.0001 250.0000 1199.0000 C 251.3332 1213.6652 252.6668 1228.3348 254.0000 1243.0000 C 254.6666 1243.3333 255.3334 1243.6667 256.0000 1244.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/rechte_hand.ai b/Icons/pose_body/rechte_hand.ai new file mode 100644 index 0000000..ec720bd --- /dev/null +++ b/Icons/pose_body/rechte_hand.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (rechte_hand.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 0.0000 597.0000 m 1.5180 616.6622 59.9494 684.6982 75.0000 694.0000 C 82.7011 697.4112 92.2200 692.9641 98.0000 691.0000 C 98.3333 690.3334 98.6667 689.6666 99.0000 689.0000 C 99.7940 645.7666 86.9573 641.7971 68.0000 621.0000 c 61.2553 613.6008 59.5154 607.0434 50.0000 602.0000 C 48.0002 602.9999 45.9998 604.0001 44.0000 605.0000 C 44.0000 605.3333 44.0000 605.6667 44.0000 606.0000 C 46.6189 618.8246 56.2958 624.0979 61.0000 635.0000 C 52.0009 630.3338 42.9991 625.6662 34.0000 621.0000 C 28.9112 614.2005 30.2923 606.7661 23.0000 602.0000 C 15.5871 604.6025 15.4987 605.8216 10.0000 598.0000 C 9.3334 597.6667 8.6666 597.3333 8.0000 597.0000 C 5.3336 597.0000 2.6664 597.0000 0.0000 597.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/rechte_huefte.ai b/Icons/pose_body/rechte_huefte.ai new file mode 100644 index 0000000..1261f32 --- /dev/null +++ b/Icons/pose_body/rechte_huefte.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (rechte_huefte.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 153.0000 764.0000 m 187.9691 764.8388 193.1110 742.5401 199.0000 715.0000 c 199.6438 711.9891 197.0000 705.0000 y 202.6496 697.3431 201.2605 691.1681 201.0000 681.0000 C 201.0000 673.0008 201.0000 664.9992 201.0000 657.0000 C 192.9741 634.7459 160.9609 618.7969 137.0000 624.0000 C 129.4179 677.5682 142.9846 724.8006 153.0000 764.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/rechte_schulter.ai b/Icons/pose_body/rechte_schulter.ai new file mode 100644 index 0000000..ce57274 --- /dev/null +++ b/Icons/pose_body/rechte_schulter.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (rechte_schulter.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 152.0000 1089.0000 m 167.5907 1089.1707 179.9606 1085.9658 195.0000 1083.0000 C 230.9463 1036.8340 167.6336 993.0098 121.0000 1008.0000 C 117.9419 1007.2572 116.4883 1007.7316 114.0000 1010.0000 C 104.3136 1058.0244 121.0049 1075.9437 152.0000 1089.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/rechter_elbogen.ai b/Icons/pose_body/rechter_elbogen.ai new file mode 100644 index 0000000..a9f01da --- /dev/null +++ b/Icons/pose_body/rechter_elbogen.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (rechter_elbogen.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 114.0000 895.0000 m 133.9809 895.2648 145.6805 893.5518 159.0000 890.0000 C 165.3996 871.1239 154.5425 841.5726 151.0000 824.0000 C 149.3335 824.0000 147.6665 824.0000 146.0000 824.0000 C 134.4574 822.0942 106.2651 838.4984 100.0000 847.0000 C 101.0041 853.6247 110.3012 890.2737 114.0000 895.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/rechter_fuss.ai b/Icons/pose_body/rechter_fuss.ai new file mode 100644 index 0000000..6f08be4 --- /dev/null +++ b/Icons/pose_body/rechter_fuss.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (rechter_fuss.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 218.0000 100.0000 m 229.6655 99.6667 241.3345 99.3333 253.0000 99.0000 C 265.7182 89.6342 260.6520 11.5479 252.0000 0.0000 C 231.8904 -0.4393 211.3800 0.0593 199.0000 8.0000 C 198.6667 8.6666 198.3333 9.3334 198.0000 10.0000 C 198.3239 31.9535 213.9041 44.1760 216.0000 64.0000 c 217.3001 76.2964 209.3088 89.4819 218.0000 100.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/rechtes_auge.ai b/Icons/pose_body/rechtes_auge.ai new file mode 100644 index 0000000..9d52c2f --- /dev/null +++ b/Icons/pose_body/rechtes_auge.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (rechtes_auge.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 228.0000 1246.0000 m 239.6454 1246.2905 243.5510 1243.2936 250.0000 1239.0000 C 250.9999 1235.3337 252.0001 1231.6663 253.0000 1228.0000 C 252.3334 1227.3334 251.6666 1226.6666 251.0000 1226.0000 C 229.4227 1227.8369 227.7179 1225.0791 213.0000 1233.0000 C 213.0000 1233.9999 213.0000 1235.0001 213.0000 1236.0000 C 218.1103 1241.0864 220.8971 1242.2729 228.0000 1246.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/rechtes_knie.ai b/Icons/pose_body/rechtes_knie.ai new file mode 100644 index 0000000..7b2e541 --- /dev/null +++ b/Icons/pose_body/rechtes_knie.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (rechtes_knie.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 184.0000 414.0000 m 206.0213 414.2704 245.4585 420.0122 263.0000 412.0000 C 264.2329 393.9503 259.5077 339.0758 253.0000 326.0000 C 229.6501 325.5124 197.6062 327.6801 184.0000 338.0000 C 183.8097 363.6520 178.2162 399.2486 184.0000 414.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/rechtes_ohr.ai b/Icons/pose_body/rechtes_ohr.ai new file mode 100644 index 0000000..d6b6ec7 --- /dev/null +++ b/Icons/pose_body/rechtes_ohr.ai @@ -0,0 +1 @@ +%!PS-Adobe-2.0 %%Creator: Adobe Photoshop(TM) Pen Path Export 7.0 %%Title: (rechtes_ohr.ai) %%DocumentNeededResources: procset Adobe_packedarray 2.0 0 %%+ procset Adobe_IllustratorA_AI3 1.0 1 %%ColorUsage: Black&White %%BoundingBox: 0 0 537 1386 %%HiResBoundingBox: 0 0 537 1386 %AI3_Cropmarks: 0 0 537 1386 %%DocumentPreview: None %%EndComments %%BeginProlog %%IncludeResource: procset Adobe_packedarray 2.0 0 Adobe_packedarray /initialize get exec %%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 1 %%EndProlog %%BeginSetup Adobe_IllustratorA_AI3 /initialize get exec n %%EndSetup 0.0 0.0 0.0 1.0 k 0 i 0 J 0 j 1 w 4 M []0 d %%Note: %Adobe_Photoshop_Path_Begin:<< /defaultFill false >> *u %AI3_Note:<< /operation /xor /defaultFill false >> 1 XR 193.0000 1243.0000 m 193.6666 1243.0000 194.3334 1243.0000 195.0000 1243.0000 C 204.1588 1234.2730 208.1833 1220.0521 208.0000 1202.0000 C 205.6669 1201.6667 203.3331 1201.3333 201.0000 1201.0000 C 191.9844 1209.6934 186.5244 1225.7040 190.0000 1241.0000 C 190.9999 1241.6666 192.0001 1242.3334 193.0000 1243.0000 C n *U %Adobe_Photoshop_Path_End %%Trailer %%EOF \ No newline at end of file diff --git a/Icons/pose_body/stock-vector-naked-woman-set-of-full-body-illustrations-1772426126.jpg b/Icons/pose_body/stock-vector-naked-woman-set-of-full-body-illustrations-1772426126.jpg new file mode 100644 index 0000000..8435d3a Binary files /dev/null and b/Icons/pose_body/stock-vector-naked-woman-set-of-full-body-illustrations-1772426126.jpg differ diff --git a/backend/__pycache__/ai_server.cpython-314.pyc b/backend/__pycache__/ai_server.cpython-314.pyc index 8556e63..a8e3c3b 100644 Binary files a/backend/__pycache__/ai_server.cpython-314.pyc and b/backend/__pycache__/ai_server.cpython-314.pyc differ diff --git a/backend/ai_server.py b/backend/ai_server.py index ffdce96..df06f7c 100644 --- a/backend/ai_server.py +++ b/backend/ai_server.py @@ -4,6 +4,7 @@ import json import math import os import secrets +import tempfile from pathlib import Path from typing import List, Optional @@ -164,6 +165,33 @@ def resolve_pose_model_path() -> Optional[Path]: return None +def base_pose_model_candidates() -> list[Path]: + return [ + BASE_DIR / "yolo26n-pose.pt", + Path.cwd() / "yolo26n-pose.pt", + TRAINING_ROOT / "yolo26n-pose.pt", + TRAINING_ROOT.parent / "yolo26n-pose.pt", + TRAINING_ROOT.parent.parent / "yolo26n-pose.pt", + Path(tempfile.gettempdir()) / "nsfwapp-ml" / "yolo26n-pose.pt", + ] + + +def resolve_base_pose_model_path() -> Optional[Path]: + env_path = os.environ.get("YOLO_BASE_POSE_MODEL", "").strip() + if env_path: + p = Path(env_path).expanduser().resolve() + if existing_file(p): + return p + raise RuntimeError(f"YOLO_BASE_POSE_MODEL not found: {p}") + + for candidate in base_pose_model_candidates(): + p = existing_file(candidate) + if p: + return p.resolve() + + return None + + def resolve_videomae_model_path() -> Optional[Path]: env_path = os.environ.get("VIDEOMAE_MODEL", "").strip() if env_path: @@ -203,6 +231,8 @@ _MODEL_PATH = "" _MODEL_ERROR = "" _POSE_MODEL_PATH = "" _POSE_MODEL_ERROR = "" +_BASE_POSE_MODEL_PATH = "" +_BASE_POSE_MODEL_ERROR = "" _VIDEOMAE_MODEL_PATH = "" _VIDEOMAE_MODEL_ERROR = "" _VIDEOMAE_DEVICE_ACTIVE = "" @@ -211,6 +241,7 @@ _LABEL_ERROR = "" _DEVICE = os.environ.get("YOLO_DEVICE", "") _CONF = float(os.environ.get("YOLO_CONF", "0.25")) _POSE_CONF = float(os.environ.get("YOLO_POSE_CONF", "0.30")) +_BASE_POSE_CONF = float(os.environ.get("YOLO_BASE_POSE_CONF", "0.10")) _POSE_RELIABLE_MIN_SCORE = float(os.environ.get("YOLO_POSE_RELIABLE_MIN_SCORE", "0.30")) _POSE_RELIABLE_MIN_KEYPOINTS = int(os.environ.get("YOLO_POSE_RELIABLE_MIN_KEYPOINTS", "6")) _POSE_RELIABLE_MIN_QUALITY = float(os.environ.get("YOLO_POSE_RELIABLE_MIN_QUALITY", "0.45")) @@ -226,6 +257,7 @@ _VIDEOMAE_NUM_FRAMES = int(os.environ.get("VIDEOMAE_NUM_FRAMES", "16")) model = None pose_model = None +base_pose_model = None videomae_model = None videomae_processor = None @@ -434,6 +466,37 @@ def get_pose_model(): return None +def get_base_pose_model(): + global base_pose_model + global _BASE_POSE_MODEL_PATH + global _BASE_POSE_MODEL_ERROR + + if base_pose_model is not None: + return base_pose_model + + try: + path = resolve_base_pose_model_path() + if path is None: + base_pose_model = None + _BASE_POSE_MODEL_PATH = "" + _BASE_POSE_MODEL_ERROR = "" + return None + + loaded = YOLO(str(path)) + + base_pose_model = loaded + _BASE_POSE_MODEL_PATH = str(path) + _BASE_POSE_MODEL_ERROR = "" + + return base_pose_model + + except Exception as exc: + base_pose_model = None + _BASE_POSE_MODEL_PATH = "" + _BASE_POSE_MODEL_ERROR = str(exc) + return None + + def get_videomae_components(): global videomae_model global videomae_processor @@ -1453,12 +1516,20 @@ def add_pose_scene_context_scores(scores: dict[str, float], prediction: dict, pe add("prone_bone", 0.07) -def best_pose_scene_position(prediction: dict, persons: list[dict]) -> tuple[str, float, bool, bool]: +def best_hybrid_pose_scene_position( + prediction: dict, + position_persons: list[dict], + context_persons: list[dict], +) -> tuple[str, float, bool, bool]: pose_scores: dict[str, float] = {} context_scores: dict[str, float] = {} - reliable_persons = reliable_pose_persons(persons) + reliable_position_persons = reliable_pose_persons(position_persons) + reliable_context_persons = reliable_pose_persons(context_persons) - for person in reliable_persons: + if not reliable_context_persons: + reliable_context_persons = reliable_position_persons + + for person in reliable_position_persons: label = normalize_sex_position_label(person.get("label")) score = float(person.get("score") or 0.0) @@ -1471,7 +1542,7 @@ def best_pose_scene_position(prediction: dict, persons: list[dict]) -> tuple[str if quality > 0: combine_position_score(pose_scores, label, 0.04 * quality) - add_pose_scene_context_scores(context_scores, prediction, reliable_persons) + add_pose_scene_context_scores(context_scores, prediction, reliable_context_persons) best_position, best_score, has_pose_signal, has_context_signal = fuse_hybrid_position_scores( pose_scores, @@ -1484,6 +1555,10 @@ def best_pose_scene_position(prediction: dict, persons: list[dict]) -> tuple[str return "", 0.0, False, has_context_signal +def best_pose_scene_position(prediction: dict, persons: list[dict]) -> tuple[str, float, bool, bool]: + return best_hybrid_pose_scene_position(prediction, persons, persons) + + def apply_pose_result_to_prediction(prediction: dict, result) -> dict: persons = pose_persons_from_result(result) if persons: @@ -1505,55 +1580,92 @@ def apply_pose_result_to_prediction(prediction: dict, result) -> dict: return prediction +def predict_pose_results(model, paths: list[str], imgsz: int, conf: float): + return model.predict( + source=paths, + imgsz=imgsz, + conf=conf, + batch=_BATCH, + device=_DEVICE or None, + half=_HALF, + verbose=False, + ) + + +def has_reliable_pose_persons(persons: list[dict]) -> bool: + return bool(reliable_pose_persons(persons)) + + +def apply_hybrid_pose_persons_to_prediction( + prediction: dict, + position_persons: list[dict], + context_persons: list[dict], +) -> None: + display_persons = context_persons or position_persons + if display_persons: + prediction["persons"] = display_persons + + best_position, best_score_value, has_pose_signal, has_context_signal = best_hybrid_pose_scene_position( + prediction, + position_persons, + context_persons, + ) + + if best_position: + prediction["sexPosition"] = best_position + prediction["sexPositionScore"] = best_score_value + + if position_persons or context_persons: + append_prediction_source(prediction, "yolo_pose") + if context_persons: + append_prediction_source(prediction, "base_pose") + if has_pose_signal: + append_prediction_source(prediction, "pose_position") + if has_context_signal: + append_prediction_source(prediction, "box_context") + + def apply_pose_batch_to_predictions(paths: list[str], predictions: list[dict], imgsz: int) -> None: global _POSE_MODEL_ERROR + global _BASE_POSE_MODEL_ERROR + + position_persons_by_index: dict[int, list[dict]] = {} + context_persons_by_index: dict[int, list[dict]] = {} current_pose_model = get_pose_model() - if current_pose_model is None: - for prediction in predictions: - best_position, best_score_value, _has_pose_signal, has_context_signal = best_pose_scene_position( - prediction, - [], - ) + if current_pose_model is not None: + try: + pose_results = predict_pose_results(current_pose_model, paths, imgsz, _POSE_CONF) + for index, pose_result in enumerate(pose_results): + position_persons_by_index[index] = pose_persons_from_result(pose_result) + except Exception as exc: + _POSE_MODEL_ERROR = str(exc) - if best_position: - prediction["sexPosition"] = best_position - prediction["sexPositionScore"] = best_score_value + needs_base_pose = [ + index + for index in range(len(paths)) + if not has_reliable_pose_persons(position_persons_by_index.get(index, [])) + ] - if has_context_signal: - append_prediction_source(prediction, "box_context") + current_base_pose_model = get_base_pose_model() + if current_base_pose_model is not None and needs_base_pose: + try: + base_paths = [paths[index] for index in needs_base_pose] + base_results = predict_pose_results(current_base_pose_model, base_paths, imgsz, _BASE_POSE_CONF) + for original_index, base_result in zip(needs_base_pose, base_results): + context_persons_by_index[original_index] = pose_persons_from_result(base_result) + except Exception as exc: + _BASE_POSE_MODEL_ERROR = str(exc) - return + for index, prediction in enumerate(predictions): + position_persons = position_persons_by_index.get(index, []) + context_persons = context_persons_by_index.get(index, []) - try: - pose_results = current_pose_model.predict( - source=paths, - imgsz=imgsz, - conf=_POSE_CONF, - batch=_BATCH, - device=_DEVICE or None, - half=_HALF, - verbose=False, + apply_hybrid_pose_persons_to_prediction( + prediction, + position_persons, + context_persons, ) - except Exception as exc: - _POSE_MODEL_ERROR = str(exc) - for prediction in predictions: - best_position, best_score_value, _has_pose_signal, has_context_signal = best_pose_scene_position( - prediction, - [], - ) - - if best_position: - prediction["sexPosition"] = best_position - prediction["sexPositionScore"] = best_score_value - - if has_context_signal: - append_prediction_source(prediction, "box_context") - - return - - for prediction, pose_result in zip(predictions, pose_results): - apply_pose_result_to_prediction(prediction, pose_result) def pose_model_status() -> dict: @@ -1576,6 +1688,26 @@ def pose_model_status() -> dict: } +def base_pose_model_status() -> dict: + try: + expected = resolve_base_pose_model_path() + expected_text = str(expected) if expected else "yolo26n-pose.pt" + exists = expected is not None + error = _BASE_POSE_MODEL_ERROR + except Exception as exc: + expected_text = "yolo26n-pose.pt" + exists = False + error = str(exc) + + return { + "basePoseModelAvailable": exists, + "basePoseModelLoaded": base_pose_model is not None, + "basePoseModel": _BASE_POSE_MODEL_PATH or (expected_text if exists else ""), + "basePoseModelError": error, + "expectedBasePoseModel": expected_text, + } + + def videomae_model_status() -> dict: try: expected = resolve_videomae_model_path() @@ -1744,6 +1876,7 @@ def health(): status_payload.update(detector_model_status(load=False)) status_payload.update(pose_model_status()) + status_payload.update(base_pose_model_status()) status_payload.update(videomae_model_status()) return status_payload @@ -1751,12 +1884,15 @@ def health(): def reload_model(): global model global pose_model + global base_pose_model global videomae_model global videomae_processor global _MODEL_PATH global _MODEL_ERROR global _POSE_MODEL_PATH global _POSE_MODEL_ERROR + global _BASE_POSE_MODEL_PATH + global _BASE_POSE_MODEL_ERROR global _VIDEOMAE_MODEL_PATH global _VIDEOMAE_MODEL_ERROR global _VIDEOMAE_DEVICE_ACTIVE @@ -1764,12 +1900,15 @@ def reload_model(): model = None pose_model = None + base_pose_model = None videomae_model = None videomae_processor = None _MODEL_PATH = "" _MODEL_ERROR = "" _POSE_MODEL_PATH = "" _POSE_MODEL_ERROR = "" + _BASE_POSE_MODEL_PATH = "" + _BASE_POSE_MODEL_ERROR = "" _VIDEOMAE_MODEL_PATH = "" _VIDEOMAE_MODEL_ERROR = "" _VIDEOMAE_DEVICE_ACTIVE = "" @@ -1785,5 +1924,6 @@ def reload_model(): status_payload.update(detector_model_status(load=True)) status_payload.update(pose_model_status()) + status_payload.update(base_pose_model_status()) status_payload.update(videomae_model_status()) return status_payload diff --git a/backend/data/pending-autostart/admin.json b/backend/data/pending-autostart/admin.json new file mode 100644 index 0000000..fc69ce2 --- /dev/null +++ b/backend/data/pending-autostart/admin.json @@ -0,0 +1,3 @@ +{ + "items": [] +} \ No newline at end of file diff --git a/backend/dist/nsfwapp-linux-amd64 b/backend/dist/nsfwapp-linux-amd64 index 3d840df..369768c 100644 Binary files a/backend/dist/nsfwapp-linux-amd64 and b/backend/dist/nsfwapp-linux-amd64 differ diff --git a/backend/dist/nsfwapp.exe b/backend/dist/nsfwapp.exe index 4d70ade..d912a11 100644 Binary files a/backend/dist/nsfwapp.exe and b/backend/dist/nsfwapp.exe differ diff --git a/backend/dist/nsfwapp_amd64.deb b/backend/dist/nsfwapp_amd64.deb index 1ffc11f..84cd729 100644 Binary files a/backend/dist/nsfwapp_amd64.deb and b/backend/dist/nsfwapp_amd64.deb differ diff --git a/backend/ml/__pycache__/predict_pose_model.cpython-314.pyc b/backend/ml/__pycache__/predict_pose_model.cpython-314.pyc new file mode 100644 index 0000000..fde7a21 Binary files /dev/null and b/backend/ml/__pycache__/predict_pose_model.cpython-314.pyc differ diff --git a/backend/ml/predict_pose_model.py b/backend/ml/predict_pose_model.py index a2f8d44..e90f26f 100644 --- a/backend/ml/predict_pose_model.py +++ b/backend/ml/predict_pose_model.py @@ -22,6 +22,7 @@ KEYPOINT_NAMES = [ ] BASE_MODEL_NAME = "yolo26n-pose.pt" +BASE_POSE_CONF = 0.10 POSE_KEYPOINT_MIN_CONFIDENCE = 0.20 POSE_RELIABLE_MIN_SCORE = 0.30 POSE_RELIABLE_MIN_KEYPOINTS = 6 @@ -106,6 +107,7 @@ def base_model_candidates(root): return [ script_dir / BASE_MODEL_NAME, + script_dir.parent / BASE_MODEL_NAME, Path.cwd() / BASE_MODEL_NAME, root / BASE_MODEL_NAME, root.parent / BASE_MODEL_NAME, @@ -133,6 +135,98 @@ def resolve_model_path(root, requested): return trained, "pose_missing" +def has_reliable_persons(persons): + return any(bool(person.get("reliable")) for person in persons or []) + + +def persons_from_results(results, img_w, img_h): + persons = [] + + if not results: + return persons + + r = results[0] + names = r.names or {} + + kpts_xyn = None + kpts_conf = None + + if r.keypoints is not None: + try: + kpts_xyn = r.keypoints.xyn.cpu().numpy() + except Exception: + kpts_xyn = None + try: + kpts_conf = r.keypoints.conf.cpu().numpy() + except Exception: + kpts_conf = None + + if r.boxes is None: + return persons + + for i, b in enumerate(r.boxes): + score = float(b.conf[0].item()) + label = "" + try: + cls_id = int(b.cls[0].item()) + label = str(names.get(cls_id, cls_id)).strip().lower() + except Exception: + label = "" + + x1, y1, x2, y2 = [float(v) for v in b.xyxy[0].tolist()] + x1 = max(0.0, min(float(img_w), x1)) + y1 = max(0.0, min(float(img_h), y1)) + x2 = max(0.0, min(float(img_w), x2)) + y2 = max(0.0, min(float(img_h), y2)) + + if x2 <= x1 or y2 <= y1: + continue + + box = { + "x": x1 / img_w, + "y": y1 / img_h, + "w": (x2 - x1) / img_w, + "h": (y2 - y1) / img_h, + } + + keypoints = [] + if kpts_xyn is not None and i < len(kpts_xyn): + person_kpts = kpts_xyn[i] + for ki, (kx, ky) in enumerate(person_kpts): + kconf = 0.0 + if kpts_conf is not None and i < len(kpts_conf) and ki < len(kpts_conf[i]): + kconf = float(kpts_conf[i][ki]) + + name = KEYPOINT_NAMES[ki] if ki < len(KEYPOINT_NAMES) else str(ki) + keypoints.append({ + "name": name, + "x": float(kx), + "y": float(ky), + "conf": kconf, + }) + + persons.append(annotate_pose_person_quality({ + "label": label, + "score": score, + "box": box, + "keypoints": keypoints, + })) + + return persons + + +def predict_persons(model_path, image_path, img_w, img_h, conf, imgsz, device): + model = YOLO(str(model_path)) + results = model.predict( + source=str(image_path), + conf=float(conf), + imgsz=int(imgsz), + verbose=False, + device=device, + ) + return persons_from_results(results, img_w, img_h) + + def main(): parser = argparse.ArgumentParser() parser.add_argument("--root", required=True) @@ -158,32 +252,18 @@ def main(): img = Image.open(image_path).convert("RGB") img_w, img_h = img.size - try: - model = YOLO(str(model_path)) - except Exception as e: - print(json.dumps({ - "available": False, - "source": "pose_load_failed", - "modelPath": str(model_path), - "error": repr(e), - "persons": [], - }, ensure_ascii=False)) - return - device = 0 if torch.cuda.is_available() else "cpu" + effective_model_path = model_path + effective_model_source = model_source + effective_conf = float(args.conf) + fallback_error = "" try: - results = model.predict( - source=str(image_path), - conf=float(args.conf), - imgsz=int(args.imgsz), - verbose=False, - device=device, - ) + persons = predict_persons(model_path, image_path, img_w, img_h, effective_conf, args.imgsz, device) except Exception as e: print(json.dumps({ "available": False, - "source": "pose_predict_failed", + "source": "pose_failed", "modelPath": str(model_path), "image": str(image_path), "error": repr(e), @@ -191,87 +271,50 @@ def main(): }, ensure_ascii=False)) return - persons = [] + if not has_reliable_persons(persons) and model_source != "yolo26_pose_base": + for candidate in base_model_candidates(root): + base_model_path = existing_file(candidate) + if not base_model_path or base_model_path == model_path: + continue - if results: - r = results[0] - names = r.names or {} - - kpts_xyn = None - kpts_conf = None - - if r.keypoints is not None: try: - kpts_xyn = r.keypoints.xyn.cpu().numpy() - except Exception: - kpts_xyn = None - try: - kpts_conf = r.keypoints.conf.cpu().numpy() - except Exception: - kpts_conf = None + base_conf = min(float(args.conf), BASE_POSE_CONF) + base_persons = predict_persons( + base_model_path, + image_path, + img_w, + img_h, + base_conf, + args.imgsz, + device, + ) + if base_persons: + persons = base_persons + effective_model_path = base_model_path + effective_model_source = "yolo26_pose_base_fallback" + effective_conf = base_conf + break + except Exception as e: + fallback_error = repr(e) - if r.boxes is not None: - for i, b in enumerate(r.boxes): - score = float(b.conf[0].item()) - label = "" - try: - cls_id = int(b.cls[0].item()) - label = str(names.get(cls_id, cls_id)).strip().lower() - except Exception: - label = "" - - x1, y1, x2, y2 = [float(v) for v in b.xyxy[0].tolist()] - x1 = max(0.0, min(float(img_w), x1)) - y1 = max(0.0, min(float(img_h), y1)) - x2 = max(0.0, min(float(img_w), x2)) - y2 = max(0.0, min(float(img_h), y2)) - - if x2 <= x1 or y2 <= y1: - continue - - box = { - "x": x1 / img_w, - "y": y1 / img_h, - "w": (x2 - x1) / img_w, - "h": (y2 - y1) / img_h, - } - - keypoints = [] - if kpts_xyn is not None and i < len(kpts_xyn): - person_kpts = kpts_xyn[i] - for ki, (kx, ky) in enumerate(person_kpts): - kconf = 0.0 - if kpts_conf is not None and i < len(kpts_conf) and ki < len(kpts_conf[i]): - kconf = float(kpts_conf[i][ki]) - - name = KEYPOINT_NAMES[ki] if ki < len(KEYPOINT_NAMES) else str(ki) - keypoints.append({ - "name": name, - "x": float(kx), - "y": float(ky), - "conf": kconf, - }) - - persons.append(annotate_pose_person_quality({ - "label": label, - "score": score, - "box": box, - "keypoints": keypoints, - })) - - print(json.dumps({ + payload = { "available": True, - "source": model_source, - "modelPath": str(model_path), + "source": effective_model_source, + "modelPath": str(effective_model_path), "image": str(image_path), - "conf": float(args.conf), + "conf": effective_conf, "imgsz": int(args.imgsz), "device": str(device), "imageWidth": img_w, "imageHeight": img_h, "personCount": len(persons), "persons": persons, - }, ensure_ascii=False)) + } + + if fallback_error: + payload["fallbackError"] = fallback_error + + print(json.dumps(payload, ensure_ascii=False)) if __name__ == "__main__": diff --git a/backend/ml/train_videomae_model.py b/backend/ml/train_videomae_model.py index fc14fe8..a9dc5d4 100644 --- a/backend/ml/train_videomae_model.py +++ b/backend/ml/train_videomae_model.py @@ -312,6 +312,12 @@ def main(): completed_epochs = 0 epochs_without_improvement = 0 + def training_progress(epoch: int, epoch_fraction: float) -> float: + safe_epoch = max(1, min(epochs, int(epoch))) + safe_fraction = max(0.0, min(1.0, float(epoch_fraction))) + completed = (safe_epoch - 1) + safe_fraction + return 0.04 + 0.90 * (completed / max(1, epochs)) + for epoch in range(1, epochs + 1): completed_epochs = epoch model.train() @@ -333,10 +339,9 @@ def main(): seen += batch_size_seen running_loss += float(loss.item()) * batch_size_seen - completed = (epoch - 1) + min(1.0, batch_idx / total_batches) emit_progress( "videomae", - 0.04 + 0.84 * (completed / max(1, epochs)), + training_progress(epoch, 0.85 * min(1.0, batch_idx / total_batches)), "VideoMAE trainiert...", epoch=epoch, epochs=epochs, @@ -364,7 +369,7 @@ def main(): emit_progress( "videomae", - 0.88 + 0.08 * (epoch / max(1, epochs)), + training_progress(epoch, 1.0), "VideoMAE wird validiert...", epoch=epoch, epochs=epochs, diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index a1e5ac2..bbe94fe 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -37,5 +37,8 @@ "trainingYoloBatchSize": 0, "trainingLowPriority": false, "trainingVideoMAEEnabled": true, + "trainingAutoPauseEnabled": false, + "trainingAutoPauseCpuPercent": 95, + "trainingAutoPauseTemperatureC": 85, "encryptedCookies": "p0aRfigWn3+TfypNAV7vPky13RW+gwhTlhNOGKKAED0FSMf0kdGV6LgIizzTeDMD5Ck+9SGGf6EfVg+RPnEr3ZjkkkjOTVGOJ9XNzpuczHqzi0ifmt0l9/lnNTaqh8oTzrk09uQC9YYe0CtsY6gjpYFAdVEPGimCPdULqn+8Jln1CSDiBGRGF0uZGxfmAZ1T/YQym/wjiCCdIRfcjTF4f+sz6PMBQOOnTA74if1OFNA6PxpGoXfHS7xvMeV6RX3J/qcTyMmFWr+uD2h1UKs7N9w4Y1sfw4heB10jzy/VL+9gWg2o4AKE5tGqOmlVYpv8KculMWfJHNxNpRonPNizPR4jO2jZYdr3AikuXTTeILyN7hb4MtO4fXblJ+9of1ABR6LvDe1dAXmbkNdqEqRo0k1WS6jaCdNkrleP8vGuH56H7jZMnEeJAcQxS99kVjFo0qMAEJ2ai8HPkdYURC3dWSu9uIbGmiqi6hgva2WM6jPG7TqZDyqUS7hjJwAmPa3aLPnsEWHqkcE=" } diff --git a/backend/routes.go b/backend/routes.go index b3d7d2e..3c1ebec 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -45,6 +45,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/perf/stream", perfStreamHandler) api.HandleFunc("/api/status/disk", diskStatusHandler) api.HandleFunc("/api/status/ai-server", aiServerStatusHandler) + api.HandleFunc("/api/status/ai-server/restart", aiServerRestartHandler) api.HandleFunc("/api/status/log", appLogHandler) api.HandleFunc("/api/autostart/state", autostartStateHandler) diff --git a/backend/server.go b/backend/server.go index 344d17b..b6a3bc4 100644 --- a/backend/server.go +++ b/backend/server.go @@ -119,6 +119,56 @@ var ( aiServerRoot string ) +type aiServerLifecycleSnapshot struct { + State string + Since time.Time + Message string +} + +var aiServerLifecycle = struct { + sync.Mutex + state string + since time.Time + message string +}{} + +func setAIServerLifecycle(state string, message string) { + state = strings.TrimSpace(strings.ToLower(state)) + message = strings.TrimSpace(message) + if state == "" { + state = "unknown" + } + + aiServerLifecycle.Lock() + defer aiServerLifecycle.Unlock() + + if aiServerLifecycle.state != state { + aiServerLifecycle.since = time.Now().UTC() + } else if aiServerLifecycle.since.IsZero() { + aiServerLifecycle.since = time.Now().UTC() + } + + aiServerLifecycle.state = state + aiServerLifecycle.message = message +} + +func getAIServerLifecycle() aiServerLifecycleSnapshot { + aiServerLifecycle.Lock() + defer aiServerLifecycle.Unlock() + + return aiServerLifecycleSnapshot{ + State: aiServerLifecycle.state, + Since: aiServerLifecycle.since, + Message: aiServerLifecycle.message, + } +} + +func aiServerHasCurrentProcess() bool { + aiServerMu.Lock() + defer aiServerMu.Unlock() + return aiServerCurrent != nil +} + func aiServerAutostartEnabled() bool { raw := strings.ToLower(strings.TrimSpace(os.Getenv("AI_SERVER_AUTOSTART"))) @@ -470,10 +520,34 @@ func aiServerStatusHandler(w http.ResponseWriter, r *http.Request) { Timeout: 900 * time.Millisecond, } + lifecycle := getAIServerLifecycle() + state := strings.TrimSpace(strings.ToLower(lifecycle.State)) + if state == "" || state == "unknown" { + if aiServerHasCurrentProcess() { + state = "starting" + } else { + state = "stopped" + } + } + status := map[string]any{ - "ok": false, - "running": false, - "url": url, + "ok": false, + "running": false, + "url": url, + "state": state, + "starting": state == "starting", + "restarting": state == "restarting", + } + if !lifecycle.Since.IsZero() { + status["stateSince"] = lifecycle.Since.Format(time.RFC3339) + } + if strings.TrimSpace(lifecycle.Message) != "" { + status["message"] = lifecycle.Message + } + + if state == "restarting" && !lifecycle.Since.IsZero() && time.Since(lifecycle.Since) < 2*time.Minute { + writeJSON(w, http.StatusOK, status) + return } req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, url+"/health", nil) @@ -498,27 +572,67 @@ func aiServerStatusHandler(w http.ResponseWriter, r *http.Request) { if res.StatusCode >= 200 && res.StatusCode < 300 { status["ok"] = true status["running"] = true + status["state"] = "running" + status["starting"] = false + status["restarting"] = false + status["message"] = "AI Server läuft." + setAIServerLifecycle("running", "AI Server läuft.") } writeJSON(w, http.StatusOK, status) } +func aiServerRestartHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) + return + } + + current := getAIServerLifecycle() + if strings.EqualFold(current.State, "restarting") && time.Since(current.Since) < 2*time.Minute { + writeJSON(w, http.StatusAccepted, map[string]any{ + "ok": true, + "state": "restarting", + "restarting": true, + "message": "AI Server Neustart läuft bereits.", + }) + return + } + + setAIServerLifecycle("restarting", "AI Server wird neu gestartet…") + appGo("ai-server-manual-restart", restartAIServer) + + writeJSON(w, http.StatusAccepted, map[string]any{ + "ok": true, + "state": "restarting", + "restarting": true, + "message": "AI Server wird neu gestartet.", + }) +} + func startAIServer(ctx context.Context) (*aiServerProcess, error) { if !aiServerAutostartEnabled() { appLogln("ℹ️ AI Server Autostart deaktiviert.") + setAIServerLifecycle("stopped", "AI Server Autostart ist deaktiviert.") return nil, nil } + if getAIServerLifecycle().State != "restarting" { + setAIServerLifecycle("starting", "AI Server wird gestartet…") + } + if err := stopStaleAIServerOnPort(aiServerPortFromURL()); err != nil { appLogln("⚠️ Alter AI Server konnte nicht bereinigt werden:", err) } if err := ensureMLPythonSetup(ctx); err != nil { + setAIServerLifecycle("error", "AI Server konnte nicht gestartet werden: "+err.Error()) return nil, err } scriptDir, err := findAIServerScriptDir() if err != nil { + setAIServerLifecycle("error", "AI Server Skript wurde nicht gefunden: "+err.Error()) return nil, err } @@ -588,6 +702,14 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { env = upsertEnv(env, "TRAINING_ROOT", trainingRoot) env = upsertEnv(env, "DETECTION_LABELS_PATH", detectionLabelsPath) + if strings.TrimSpace(os.Getenv("YOLO_BASE_POSE_MODEL")) == "" { + if poseBase, err := embeddedPoseModelPath(); err == nil && fileExistsNonEmpty(poseBase) { + env = upsertEnv(env, "YOLO_BASE_POSE_MODEL", poseBase) + } else if err != nil { + appLogln("⚠️ YOLO Base-Pose-Modell nicht gefunden:", err) + } + } + // Modell ebenfalls standardmäßig aus generated/training nehmen, // aber YOLO_MODEL darf weiterhin extern überschrieben werden. if strings.TrimSpace(os.Getenv("YOLO_MODEL")) == "" && defaultModel.EffectiveExists { @@ -629,10 +751,12 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { prepareAIServerCommandForLifecycle(cmd) if ctx.Err() != nil { + setAIServerLifecycle("stopped", "AI Server Start wurde abgebrochen.") return nil, ctx.Err() } if err := cmd.Start(); err != nil { + setAIServerLifecycle("error", "AI Server konnte nicht gestartet werden: "+err.Error()) return nil, err } @@ -643,6 +767,16 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { appGo("ai-server-wait", func() { err := cmd.Wait() + defer func() { + if err != nil && ctx.Err() == nil && !proc.isStopping() { + setAIServerLifecycle("error", "AI Server wurde beendet: "+err.Error()) + return + } + + if getAIServerLifecycle().State != "restarting" { + setAIServerLifecycle("stopped", "AI Server wurde beendet.") + } + }() if err != nil && ctx.Err() == nil && !proc.isStopping() { appLogf("⚠️ AI Server beendet: %v", err) @@ -656,10 +790,12 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { if err := waitForAIServer(ctx, aiServerURL(), 120*time.Second); err != nil { appLogln("⚠️ AI Server noch nicht bereit:", err) // Nicht hart abbrechen. Deine Analyse kann auf den Fallback gehen. + setAIServerLifecycle("starting", "AI Server startet noch: "+err.Error()) return proc, nil } appLogln("🧠 AI Server bereit:", aiServerURL()) + setAIServerLifecycle("running", "AI Server läuft.") return proc, nil } @@ -695,6 +831,7 @@ func restartAIServer() { } if ctx.Err() != nil { + setAIServerLifecycle("stopped", "AI Server Neustart übersprungen, App wird beendet.") appLogln("ℹ️ AI Server Neustart übersprungen (App wird beendet).") return } @@ -702,6 +839,8 @@ func restartAIServer() { appLogln("🔄 AI Server wird neu gestartet…") publishAppNotification("info", "AI Server", "AI Server wird neu gestartet…", 2200) + setAIServerLifecycle("restarting", "AI Server wird neu gestartet…") + if aiServerCurrent != nil { aiServerCurrent.Stop() aiServerCurrent = nil @@ -709,6 +848,7 @@ func restartAIServer() { proc, err := startAIServer(ctx) if err != nil { + setAIServerLifecycle("error", "AI Server Neustart fehlgeschlagen: "+err.Error()) appLogln("⚠️ AI Server Neustart fehlgeschlagen:", err) publishAppNotification("warning", "AI Server", "Neustart fehlgeschlagen: "+err.Error(), 5000) return @@ -717,6 +857,7 @@ func restartAIServer() { aiServerCurrent = proc if proc != nil { + setAIServerLifecycle("running", "AI Server wurde neu gestartet.") appLogln("✅ AI Server neu gestartet.") publishAppNotification("success", "AI Server", "AI Server wurde neu gestartet.", 2600) } diff --git a/backend/training.go b/backend/training.go index 50fde8c..8ed09c9 100644 --- a/backend/training.go +++ b/backend/training.go @@ -67,12 +67,13 @@ type TrainingPrediction struct { } type TrainingCorrection struct { - SexPosition string `json:"sexPosition"` - PeoplePresent []string `json:"peoplePresent"` - BodyPartsPresent []string `json:"bodyPartsPresent"` - ObjectsPresent []string `json:"objectsPresent"` - ClothingPresent []string `json:"clothingPresent"` - Boxes []TrainingBox `json:"boxes"` + SexPosition string `json:"sexPosition"` + PeoplePresent []string `json:"peoplePresent"` + BodyPartsPresent []string `json:"bodyPartsPresent"` + ObjectsPresent []string `json:"objectsPresent"` + ClothingPresent []string `json:"clothingPresent"` + Boxes []TrainingBox `json:"boxes"` + PosePersons []TrainingPosePerson `json:"posePersons,omitempty"` } type TrainingSample struct { @@ -278,12 +279,16 @@ type TrainingJobStatus struct { CPUPercent float64 `json:"cpuPercent,omitempty"` TemperatureC float64 `json:"temperatureC,omitempty"` - Stage string `json:"stage,omitempty"` - Epoch int `json:"epoch,omitempty"` - Epochs int `json:"epochs,omitempty"` + Stage string `json:"stage,omitempty"` + StageStartedAt string `json:"stageStartedAt,omitempty"` + StageProgress float64 `json:"stageProgress,omitempty"` + Epoch int `json:"epoch,omitempty"` + Epochs int `json:"epochs,omitempty"` - MAP50 float64 `json:"map50,omitempty"` - MAP5095 float64 `json:"map5095,omitempty"` + MAP50 float64 `json:"map50,omitempty"` + MAP5095 float64 `json:"map5095,omitempty"` + Accuracy float64 `json:"accuracy,omitempty"` + Loss float64 `json:"loss,omitempty"` } type TrainingConfidence struct { @@ -348,6 +353,8 @@ type trainingProgressEvent struct { SampleID string `json:"sampleId,omitempty"` MAP50 *float64 `json:"mAP50,omitempty"` MAP5095 *float64 `json:"mAP5095,omitempty"` + Accuracy *float64 `json:"accuracy,omitempty"` + Loss *float64 `json:"loss,omitempty"` } type TrainingFeedbackListResponse struct { @@ -801,6 +808,31 @@ func trainingScaleProgress(local float64, start int, end int) int { return start + int(math.Round(local*float64(end-start))) } +func trainingApplyStageProgress(s *TrainingJobStatus, stage string, localProgress float64) { + stage = strings.TrimSpace(stage) + if stage == "" { + return + } + + if math.IsNaN(localProgress) || math.IsInf(localProgress, 0) { + localProgress = 0 + } + localProgress = clamp01(localProgress) + + if strings.TrimSpace(s.Stage) != stage { + s.StageStartedAt = time.Now().UTC().Format(time.RFC3339) + s.Epoch = 0 + s.Epochs = 0 + s.MAP50 = 0 + s.MAP5095 = 0 + s.Accuracy = 0 + s.Loss = 0 + } + + s.Stage = stage + s.StageProgress = localProgress +} + func trainingHandleProgressLine(line string, start int, end int, defaultStep string) bool { line = strings.TrimSpace(line) if line == "" { @@ -829,11 +861,12 @@ func trainingHandleProgressLine(line string, start int, end int, defaultStep str s.Step = step - if strings.TrimSpace(ev.Stage) != "" { - s.Stage = strings.TrimSpace(ev.Stage) - } + trainingApplyStageProgress(s, ev.Stage, ev.Progress) if ev.Epoch > 0 { + if s.Epoch <= 0 { + s.StageStartedAt = time.Now().UTC().Format(time.RFC3339) + } s.Epoch = ev.Epoch } @@ -847,6 +880,12 @@ func trainingHandleProgressLine(line string, start int, end int, defaultStep str if ev.MAP5095 != nil && *ev.MAP5095 > 0 { s.MAP5095 = *ev.MAP5095 } + if ev.Accuracy != nil { + s.Accuracy = *ev.Accuracy + } + if ev.Loss != nil { + s.Loss = *ev.Loss + } sampleID := strings.TrimSpace(ev.SampleID) if sampleID != "" && @@ -1814,11 +1853,13 @@ func trainingGetJobStatus() TrainingJobStatus { func trainingStartJob(cancel context.CancelFunc) { trainingJob.mu.Lock() + startedAt := time.Now().UTC().Format(time.RFC3339) trainingJob.status = TrainingJobStatus{ - Running: true, - Progress: 5, - Step: "Training wird vorbereitet…", - StartedAt: time.Now().UTC().Format(time.RFC3339), + Running: true, + Progress: 5, + Step: "Training wird vorbereitet…", + StartedAt: startedAt, + StageStartedAt: startedAt, } trainingJob.cancel = cancel @@ -3368,6 +3409,7 @@ func trainingNegativeCorrection() *TrainingCorrection { ObjectsPresent: []string{}, ClothingPresent: []string{}, Boxes: []TrainingBox{}, + PosePersons: []TrainingPosePerson{}, } } @@ -3441,7 +3483,7 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) { trainingDeletePoseSample(root, req.SampleID) if sexPosition := trainingSexPositionForFeedback(sample, req); !isNoSexPositionLabel(sexPosition) { - if err := trainingWritePoseSample(root, sample, sexPosition, detectorBoxes); err != nil { + if err := trainingWritePoseSample(root, sample, sexPosition, detectorBoxes, req.Correction); err != nil { appLogln("pose sample write failed:", err) } } @@ -3575,7 +3617,7 @@ func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) { Correction: req.Correction, Notes: req.Notes, }); !isNoSexPositionLabel(sexPosition) { - if err := trainingWritePoseSample(root, sample, sexPosition, detectorBoxes); err != nil { + if err := trainingWritePoseSample(root, sample, sexPosition, detectorBoxes, req.Correction); err != nil { appLogln("pose sample update failed:", err) } } @@ -4064,6 +4106,7 @@ func trainingRunJob(ctx context.Context, root string, count int, targets trainin if !targets.Detector { trainingSetJobStatus(func(s *TrainingJobStatus) { + trainingApplyStageProgress(s, "detector", 1) if s.Progress < 58 { s.Progress = 58 } @@ -4079,6 +4122,8 @@ func trainingRunJob(ctx context.Context, root string, count int, targets trainin positiveValCount > 0 { detectorStartedAt := time.Now() trainingSetJobStatus(func(s *TrainingJobStatus) { + trainingApplyStageProgress(s, "detector", 0) + s.Epochs = trainingDetectorEpochs() s.Progress = 15 s.Step = "YOLO26 Detector wird trainiert…" }) @@ -4175,6 +4220,7 @@ func trainingRunJob(ctx context.Context, root string, count int, targets trainin if !targets.Pose { trainingSetJobStatus(func(s *TrainingJobStatus) { + trainingApplyStageProgress(s, "pose", 1) if s.Progress < 82 { s.Progress = 82 } @@ -4186,6 +4232,8 @@ func trainingRunJob(ctx context.Context, root string, count int, targets trainin } else { poseStartedAt = time.Now() trainingSetJobStatus(func(s *TrainingJobStatus) { + trainingApplyStageProgress(s, "pose", 0) + s.Epochs = trainingDetectorEpochs() if s.Progress < 60 { s.Progress = 60 } @@ -4221,6 +4269,8 @@ func trainingRunJob(ctx context.Context, root string, count int, targets trainin poseTrainCount >= minPoseTrainCount && poseValCount >= minPoseValCount { trainingSetJobStatus(func(s *TrainingJobStatus) { + trainingApplyStageProgress(s, "pose", 0.04) + s.Epochs = trainingDetectorEpochs() if s.Progress < 62 { s.Progress = 62 } @@ -4314,6 +4364,7 @@ func trainingRunJob(ctx context.Context, root string, count int, targets trainin if !targets.VideoMAE { trainingSetJobStatus(func(s *TrainingJobStatus) { + trainingApplyStageProgress(s, "videomae", 1) if s.Progress < 98 { s.Progress = 98 } @@ -4324,6 +4375,7 @@ func trainingRunJob(ctx context.Context, root string, count int, targets trainin appLogln(videoMAEOutput) } else if !runtimeOpts.VideoMAEEnabled { trainingSetJobStatus(func(s *TrainingJobStatus) { + trainingApplyStageProgress(s, "videomae", 1) if s.Progress < 84 { s.Progress = 84 } @@ -4335,6 +4387,8 @@ func trainingRunJob(ctx context.Context, root string, count int, targets trainin } else { videoMAEStartedAt = time.Now() trainingSetJobStatus(func(s *TrainingJobStatus) { + trainingApplyStageProgress(s, "videomae", 0) + s.Epochs = trainingVideoMAEEpochs() if s.Progress < 84 { s.Progress = 84 } @@ -4367,6 +4421,8 @@ func trainingRunJob(ctx context.Context, root string, count int, targets trainin videoMAETrainCount >= minVideoMAETrainCount && videoMAEValCount >= minVideoMAEValCount { trainingSetJobStatus(func(s *TrainingJobStatus) { + trainingApplyStageProgress(s, "videomae", 0.04) + s.Epochs = trainingVideoMAEEpochs() if s.Progress < 86 { s.Progress = 86 } @@ -4556,6 +4612,9 @@ func trainingRunJob(ctx context.Context, root string, count int, targets trainin s.Running = false s.Progress = 100 + if strings.TrimSpace(s.Stage) != "" { + s.StageProgress = 1 + } s.Step = "Training abgeschlossen." s.Message = message s.Error = errorText @@ -4769,6 +4828,7 @@ func trainingEffectiveCorrection(annotation TrainingAnnotation) TrainingCorrecti ObjectsPresent: trainingScoredLabelsToStrings(p.ObjectsPresent), ClothingPresent: trainingScoredLabelsToStrings(p.ClothingPresent), Boxes: p.Boxes, + PosePersons: p.Persons, } } @@ -7716,6 +7776,18 @@ func trainingPosePersonsForSample(root string, sample *TrainingSample) []Trainin return pose.Persons } +func trainingPosePersonsForCorrection(root string, sample *TrainingSample, correction *TrainingCorrection) []TrainingPosePerson { + if correction != nil && correction.PosePersons != nil { + persons := make([]TrainingPosePerson, 0, len(correction.PosePersons)) + for _, person := range correction.PosePersons { + persons = append(persons, trainingAnnotatePosePersonQuality(person)) + } + return persons + } + + return trainingPosePersonsForSample(root, sample) +} + func trainingPoseContextPersonBoxes(boxes []TrainingBox) []TrainingBox { out := []TrainingBox{} @@ -7844,6 +7916,7 @@ func trainingWritePoseSample( sample *TrainingSample, sexPosition string, contextBoxes []TrainingBox, + correction *TrainingCorrection, ) error { if sample == nil { return errors.New("sample missing") @@ -7864,7 +7937,7 @@ func trainingWritePoseSample( return fmt.Errorf("pose class not configured: %s", sexPosition) } - persons := trainingPosePersonsForSample(root, sample) + persons := trainingPosePersonsForCorrection(root, sample, correction) persons = trainingFilterPosePersonsByContext(persons, contextBoxes) labelContent, err := trainingPoseLabelContent(persons, classID) if err != nil { @@ -7972,7 +8045,7 @@ func trainingSyncPoseDataset(root string) (int, error) { continue } - if err := trainingWritePoseSample(root, sample, sexPosition, effective.Boxes); err != nil { + if err := trainingWritePoseSample(root, sample, sexPosition, effective.Boxes, &effective); err != nil { appLogln("pose sample sync skipped:", sampleID, err) continue } diff --git a/frontend/src/App.css b/frontend/src/App.css index d3b5a4c..8ea9c6d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -67,4 +67,4 @@ transform: scale(1.04); filter: drop-shadow(0 0 6px rgb(16 185 129 / 0.55)); } -} \ No newline at end of file +} diff --git a/frontend/src/assets/body_front.svg b/frontend/src/assets/body_front.svg new file mode 100644 index 0000000..3138ca6 --- /dev/null +++ b/frontend/src/assets/body_front.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index 792412f..17379fb 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -2313,6 +2313,7 @@ export default function FinishedDownloads({ const [deletedKeys, setDeletedKeys] = useState>(() => new Set()) const [deletingKeys, setDeletingKeys] = useState>(() => new Set()) const [deletedSuccessKeys, setDeletedSuccessKeys] = useState>(() => new Set()) + const [bulkDeletedSuccessKeys, setBulkDeletedSuccessKeys] = useState>(() => new Set()) const [keepingKeys, setKeepingKeys] = useState>(() => new Set()) const [keptSuccessKeys, setKeptSuccessKeys] = useState>(() => new Set()) const [removingKeys, setRemovingKeys] = useState>(() => new Set()) @@ -3152,10 +3153,20 @@ export default function FinishedDownloads({ return next }, [activeVisibleRows, keyFor, selectionStore, selectionVersion]) + const visibleDeletedSuccessKeys = useMemo(() => { + if (bulkDeletedSuccessKeys.size === 0) return deletedSuccessKeys + + const next = new Set(deletedSuccessKeys) + for (const key of bulkDeletedSuccessKeys) { + next.add(key) + } + return next + }, [bulkDeletedSuccessKeys, deletedSuccessKeys]) + const autoPageSizeSuspended = bulkBusy || deletingKeys.size > 0 || - deletedSuccessKeys.size > 0 || + visibleDeletedSuccessKeys.size > 0 || keepingKeys.size > 0 || keptSuccessKeys.size > 0 || removingKeys.size > 0 @@ -3463,6 +3474,15 @@ export default function FinishedDownloads({ }) }, []) + const markBulkDeletedSuccess = useCallback((key: string, value: boolean) => { + setBulkDeletedSuccessKeys((prev) => { + const next = new Set(prev) + if (value) next.add(key) + else next.delete(key) + return next + }) + }, []) + const markKeptSuccess = useCallback((key: string, value: boolean) => { setKeptSuccessKeys((prev) => { const next = new Set(prev) @@ -3623,6 +3643,7 @@ export default function FinishedDownloads({ }) markDeletedSuccess(key, false) + markBulkDeletedSuccess(key, false) markKeptSuccess(key, false) markDeleting(key, false) markKeeping(key, false) @@ -3636,6 +3657,7 @@ export default function FinishedDownloads({ [ cancelRemoveTimer, markDeletedSuccess, + markBulkDeletedSuccess, markKeptSuccess, markDeleting, markKeeping, @@ -3669,6 +3691,7 @@ export default function FinishedDownloads({ setRemovingKeys(removeKeyAliases) setDeletingKeys(removeKeyAliases) setDeletedSuccessKeys(removeKeyAliases) + setBulkDeletedSuccessKeys(removeKeyAliases) setKeepingKeys(removeKeyAliases) setKeptSuccessKeys(removeKeyAliases) @@ -4570,6 +4593,7 @@ export default function FinishedDownloads({ deletedKeys.add(key) deletedItems.push({ key, file }) markDeletedRowSuccess(key) + markBulkDeletedSuccess(key, true) } return } @@ -4685,6 +4709,7 @@ export default function FinishedDownloads({ clearSelection, emitCountHint, markDeletedRowSuccess, + markBulkDeletedSuccess, removeRowsTogether, notify, ]) @@ -6805,7 +6830,7 @@ export default function FinishedDownloads({ teaserState={teaserState} inlinePlay={inlinePlay} deletingKeys={deletingKeys} - deletedSuccessKeys={deletedSuccessKeys} + deletedSuccessKeys={visibleDeletedSuccessKeys} keptSuccessKeys={keptSuccessKeys} keepingKeys={keepingKeys} removingKeys={removingKeys} @@ -6888,7 +6913,7 @@ export default function FinishedDownloads({ assetNonce={assetNonce} assetNonceForJob={assetNonceForJob} deletingKeys={deletingKeys} - deletedSuccessKeys={deletedSuccessKeys} + deletedSuccessKeys={visibleDeletedSuccessKeys} keptSuccessKeys={keptSuccessKeys} keepingKeys={keepingKeys} removingKeys={removingKeys} @@ -6941,7 +6966,7 @@ export default function FinishedDownloads({ sizeBytesOf={sizeBytesOf} formatBytes={formatBytes} deletingKeys={deletingKeys} - deletedSuccessKeys={deletedSuccessKeys} + deletedSuccessKeys={visibleDeletedSuccessKeys} keptSuccessKeys={keptSuccessKeys} keepingKeys={keepingKeys} removingKeys={removingKeys} diff --git a/frontend/src/components/ui/Icons.tsx b/frontend/src/components/ui/Icons.tsx index 0817297..fad4771 100644 --- a/frontend/src/components/ui/Icons.tsx +++ b/frontend/src/components/ui/Icons.tsx @@ -47,6 +47,198 @@ function IconBase({ /* Einzelne Icons */ /* ---------------------------------- */ +export function PoseLeftEarIcon(props: IconProps) { + return ( + + + + + + + ) +} + +export function PoseRightEarIcon(props: IconProps) { + return ( + + + + + + + ) +} + +export function PoseEyeIcon(props: IconProps) { + return ( + + + + + + + ) +} + +export function PoseNoseIcon(props: IconProps) { + return ( + + + + + + + + ) +} + +export function PoseRightShoulderIcon(props: IconProps) { + return ( + + + + + + ) +} + +export function PoseLeftShoulderIcon(props: IconProps) { + return ( + + + + + + ) +} + +export function PoseLeftHandIcon(props: IconProps) { + return ( + + + + + + ) +} + +export function PoseRightHandIcon(props: IconProps) { + return ( + + + + + + ) +} + +export function PoseRightElbowIcon(props: IconProps) { + return ( + + + + + + ) +} + +export function PoseLeftElbowIcon(props: IconProps) { + return ( + + + + + + ) +} + +export function PoseLeftKneeIcon(props: IconProps) { + return ( + + + + + + ) +} + +export function PoseRightKneeIcon(props: IconProps) { + return ( + + + + + + ) +} + +export function PoseRightFootIcon(props: IconProps) { + return ( + + + + + + ) +} + +export function PoseLeftFootIcon(props: IconProps) { + return ( + + + + + + ) +} + +export function PoseRightHipIcon(props: IconProps) { + return ( + + + + + + + ) +} + +export function PoseLeftHipIcon(props: IconProps) { + return ( + + + + + + + ) +} + +export function PoseFaceMapIcon(props: IconProps) { + return ( + + + + + + + + + + ) +} + +export function PoseBodyMapIcon(props: IconProps) { + return ( + + + + + + + ) +} + export function FemaleBreastIcon(props: IconProps) { return ( (null) const [diskStatus, setDiskStatus] = useState(null) const [aiServerStatus, setAiServerStatus] = useState(null) + const [aiServerRestartModalOpen, setAiServerRestartModalOpen] = useState(false) + const [aiServerRestartBusy, setAiServerRestartBusy] = useState(false) const [appLog, setAppLog] = useState(null) const [appLogLoading, setAppLogLoading] = useState(false) const [appLogOpen, setAppLogOpen] = useState(false) @@ -831,6 +839,44 @@ export default function Settings({ onAssetsGenerated }: Props) { const trainingSettingsLocked = Boolean(trainingJobStatus?.running) const enrichPostworkTemporarilyDisabled = trainingSettingsLocked && !!value.enrichPostworkEnabled + const aiServerState = String(aiServerStatus?.state || '').toLowerCase() + const aiServerRestarting = + aiServerRestartBusy || aiServerStatus?.restarting || aiServerState === 'restarting' + const aiServerStarting = + !aiServerRestarting && (aiServerStatus?.starting || aiServerState === 'starting') + const aiServerBusy = aiServerRestarting || aiServerStarting + const aiServerRunning = Boolean(aiServerStatus?.running) && !aiServerBusy + const aiServerBadgeLabel = + aiServerStatus === null + ? 'AI prüft…' + : aiServerRestarting + ? 'AI startet neu…' + : aiServerStarting + ? 'AI startet…' + : aiServerRunning + ? 'AI online' + : 'AI offline' + const aiServerBadgeTitle = [ + aiServerStatus?.message || '', + aiServerStatus?.url ? `URL: ${aiServerStatus.url}` : '', + aiServerStatus?.error ? `Fehler: ${aiServerStatus.error}` : '', + ].filter(Boolean).join('\n') || 'AI Server Status' + const aiServerBadgeClass = + aiServerStatus === null + ? 'bg-gray-100 text-gray-700 ring-gray-200 hover:bg-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/15' + : aiServerBusy + ? 'bg-blue-50 text-blue-800 ring-blue-200 hover:bg-blue-100 dark:bg-blue-500/15 dark:text-blue-100 dark:ring-blue-400/30 dark:hover:bg-blue-500/20' + : aiServerRunning + ? 'bg-emerald-50 text-emerald-800 ring-emerald-200 hover:bg-emerald-100 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30 dark:hover:bg-emerald-500/20' + : 'bg-red-50 text-red-800 ring-red-200 hover:bg-red-100 dark:bg-red-500/15 dark:text-red-100 dark:ring-red-400/30 dark:hover:bg-red-500/20' + const aiServerDotClass = + aiServerStatus === null + ? 'bg-gray-400' + : aiServerBusy + ? 'bg-blue-500 animate-pulse' + : aiServerRunning + ? 'bg-emerald-500' + : 'bg-red-500' const [authStatus, setAuthStatus] = useState(null) const [securityBusy, setSecurityBusy] = useState(false) @@ -1319,52 +1365,110 @@ export default function Settings({ onAssetsGenerated }: Props) { } }, []) - useEffect(() => { - let alive = true + const loadAiServerStatus = useCallback(async (opts?: { signal?: AbortSignal }) => { + try { + const res = await fetch('/api/status/ai-server', { + cache: 'no-store', + signal: opts?.signal, + }) + const data = await res.json().catch(() => null) - const load = async () => { - try { - const res = await fetch('/api/status/ai-server', { cache: 'no-store' }) - const data = await res.json().catch(() => null) - - if (!alive) return - - if (!res.ok || !data) { - setAiServerStatus({ - ok: false, - running: false, - error: `HTTP ${res.status}`, - }) - return - } - - setAiServerStatus({ - ok: Boolean(data.ok), - running: Boolean(data.running), - url: String(data.url || ''), - statusCode: Number(data.statusCode || 0), - error: String(data.error || ''), - }) - } catch (e) { - if (!alive) return + if (opts?.signal?.aborted) return + if (!res.ok || !data) { setAiServerStatus({ ok: false, running: false, - error: e instanceof Error ? e.message : String(e), + state: 'error', + error: `HTTP ${res.status}`, }) + return } - } - void load() - const timer = window.setInterval(load, 5000) + setAiServerStatus({ + ok: Boolean(data.ok), + running: Boolean(data.running), + url: String(data.url || ''), + statusCode: Number(data.statusCode || 0), + error: String(data.error || ''), + state: String(data.state || ''), + starting: Boolean(data.starting), + restarting: Boolean(data.restarting), + message: String(data.message || ''), + stateSince: String(data.stateSince || ''), + }) + } catch (e) { + if (opts?.signal?.aborted) return - return () => { - alive = false - window.clearInterval(timer) + setAiServerStatus({ + ok: false, + running: false, + state: 'error', + error: e instanceof Error ? e.message : String(e), + }) } }, []) + useEffect(() => { + const controller = new AbortController() + + void loadAiServerStatus({ signal: controller.signal }) + const timer = window.setInterval(() => { + void loadAiServerStatus({ signal: controller.signal }) + }, 5000) + + return () => { + controller.abort() + window.clearInterval(timer) + } + }, [loadAiServerStatus]) + + async function restartAiServer() { + setAiServerRestartBusy(true) + setErr(null) + setMsg(null) + setAiServerStatus((prev) => ({ + ok: false, + running: false, + url: prev?.url, + statusCode: prev?.statusCode, + state: 'restarting', + starting: false, + restarting: true, + message: 'AI Server wird neu gestartet…', + })) + + try { + const res = await fetch('/api/status/ai-server/restart', { + method: 'POST', + cache: 'no-store', + }) + const data = await res.json().catch(() => null) + + if (!res.ok) { + const text = + typeof data?.error === 'string' + ? data.error + : typeof data?.message === 'string' + ? data.message + : `HTTP ${res.status}` + throw new Error(text) + } + + setAiServerRestartModalOpen(false) + setMsg('AI Server wird neu gestartet.') + + window.setTimeout(() => void loadAiServerStatus(), 500) + window.setTimeout(() => void loadAiServerStatus(), 1500) + window.setTimeout(() => void loadAiServerStatus(), 3500) + } catch (e: any) { + setErr(e?.message ?? String(e)) + await loadAiServerStatus() + } finally { + setAiServerRestartBusy(false) + } + } + async function loadAppLog() { setAppLogLoading(true) @@ -2178,75 +2282,59 @@ export default function Settings({ onAssetsGenerated }: Props) { } return ( - -
-
-
- Einstellungen -
+ <> + +
+
+
+ Einstellungen +
- - setAiServerRestartModalOpen(true)} className={[ - 'h-1.5 w-1.5 rounded-full', - aiServerStatus === null - ? 'bg-gray-400' - : aiServerStatus.running - ? 'bg-emerald-500' - : 'bg-red-500', + 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-bold ring-1 transition', + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950', + aiServerBadgeClass, ].join(' ')} - aria-hidden="true" - /> + title={`${aiServerBadgeTitle}\nKlicken zum Neustarten.`} + aria-label="AI Server Status anzeigen und Neustart öffnen" + > + + {aiServerBadgeLabel} + +
+
+ Recorder-Konfiguration, Automatisierung und Tasks. +
-
- Recorder-Konfiguration, Automatisierung und Tasks. + + {/* Rechts: Alerts + Button */} +
+
- - {/* Rechts: Alerts + Button */} -
- -
-
- } - grayBody - > + } + grayBody + >
- + + + { + if (!aiServerRestartBusy) setAiServerRestartModalOpen(false) + }} + title="AI Server neu starten" + width="max-w-lg" + footer={ +
+ + +
+ } + > +
+
+
+ +
+ +
+
+ AI Server Prozess neu starten +
+

+ Der laufende AI Server wird beendet und direkt neu gestartet. Laufende + Analysen können dabei kurz fehlschlagen und beim nächsten Versuch weiterlaufen. +

+
+
+ +
+
+
+
+ Aktueller Status +
+
+
+
+
+ +
+ {aiServerStatus?.url ? ( +
+
URL
+
+ {aiServerStatus.url} +
+
+ ) : null} + + {aiServerStatus?.message ? ( +
+
Meldung
+
+ {aiServerStatus.message} +
+
+ ) : null} + + {!aiServerStatus?.url && !aiServerStatus?.message && !aiServerStatus?.error ? ( +
+ Der Status wird gerade abgefragt. +
+ ) : null} + + {aiServerStatus?.error ? ( +
+
Fehler
+
{aiServerStatus.error}
+
+ ) : null} +
+
+ + {aiServerRestarting ? ( +
+ Der Neustart läuft bereits. Die Badge aktualisiert sich automatisch, sobald der AI Server wieder bereit ist. +
+ ) : null} +
+
+ ) } diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index 4e946b3..d9d2673 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import Button from './Button' -import LabeledSwitch from './LabeledSwitch' import LoadingSpinner from './LoadingSpinner' import { formatDuration } from './formatters' import { @@ -28,6 +27,8 @@ import { createPortal } from 'react-dom' import TrainingFeedbackHistoryModal from './TrainingFeedbackHistoryModal' import type { RecorderSettingsState } from '../../types' +const bodyFrontSvgUrl = new URL('../../assets/body_front.svg', import.meta.url).href + type DrawingTrainingBox = TrainingBox & { startX: number @@ -104,6 +105,8 @@ type TrainingJobStatus = { finishedAt?: string durationMs?: number stage?: string + stageStartedAt?: string + stageProgress?: number epoch?: number epochs?: number previewUrl?: string @@ -113,6 +116,8 @@ type TrainingJobStatus = { temperatureC?: number map50?: number map5095?: number + accuracy?: number + loss?: number } type TrainingPrediction = { @@ -156,6 +161,7 @@ type CorrectionState = { objectsPresent: string[] clothingPresent: string[] boxes: TrainingBox[] + posePersons?: TrainingPosePerson[] } type QueuedTrainingSample = { @@ -207,6 +213,17 @@ type BoxInteraction = original: TrainingBox } +type PoseInteraction = { + type: 'keypoint' + personIndex: number + keypointIndex: number +} + +type PoseKeypointPlacement = { + personIndex: number + keypointName: string +} + type MagnifierState = { visible: boolean clientX: number @@ -322,6 +339,7 @@ type TrainingFeedbackListResponse = { type TrainingSampleMode = 'random' | 'uncertain' type TrainingTargetKey = 'detector' | 'pose' | 'videomae' +type PoseKeypointMapView = 'body' | 'head' type TrainingStartMode = 'full' | 'custom' type TrainingStartOptions = { mode: TrainingStartMode @@ -388,6 +406,182 @@ const POSE_KEYPOINT_LABELS: Record = { right_ankle: 'R Fuss', } +const POSE_KEYPOINT_ORDER = [ + 'nose', + 'left_eye', + 'right_eye', + 'left_ear', + 'right_ear', + 'left_shoulder', + 'right_shoulder', + 'left_elbow', + 'right_elbow', + 'left_wrist', + 'right_wrist', + 'left_hip', + 'right_hip', + 'left_knee', + 'right_knee', + 'left_ankle', + 'right_ankle', +] as const + +const POSE_KEYPOINT_OPTIONS = POSE_KEYPOINT_ORDER.map((key) => [key, POSE_KEYPOINT_LABELS[key]] as const) + +type PoseKeypointShape = { + keypointName: keyof typeof POSE_KEYPOINT_LABELS + d: string + center: { + x: number + y: number + } +} + +const POSE_BODY_MAP_VIEW_BOX = '0 0 537 1386' +const POSE_BODY_MAP_ASPECT_RATIO = '537 / 1386' +const POSE_BODY_MAP_ASPECT_RATIO_VALUE = 537 / 1386 +const POSE_HEAD_MAP_VIEW_BOX = '172.5 0 185 477' +const POSE_HEAD_MAP_ASPECT_RATIO = POSE_BODY_MAP_ASPECT_RATIO +const POSE_KEYPOINT_MAP_ZOOM_DURATION_MS = 400 + +function poseKeypointMapViewBox(view: PoseKeypointMapView) { + return view === 'head' ? POSE_HEAD_MAP_VIEW_BOX : POSE_BODY_MAP_VIEW_BOX +} + +function parsePoseMapViewBox(viewBox: string): [number, number, number, number] { + const values = viewBox.split(/\s+/).map((value) => Number(value)) + + return [ + Number.isFinite(values[0]) ? values[0] : 0, + Number.isFinite(values[1]) ? values[1] : 0, + Number.isFinite(values[2]) ? values[2] : 1, + Number.isFinite(values[3]) ? values[3] : 1, + ] +} + +function formatPoseMapViewBox(values: [number, number, number, number]) { + return values.map((value) => Number(value.toFixed(3))).join(' ') +} + +function easePoseMapZoom(progress: number) { + return progress < 0.5 + ? 4 * progress * progress * progress + : 1 - Math.pow(-2 * progress + 2, 3) / 2 +} + +function interpolatePoseMapViewBox(from: string, to: string, progress: number) { + const start = parsePoseMapViewBox(from) + const end = parsePoseMapViewBox(to) + const eased = easePoseMapZoom(Math.max(0, Math.min(1, progress))) + + return formatPoseMapViewBox([ + start[0] + (end[0] - start[0]) * eased, + start[1] + (end[1] - start[1]) * eased, + start[2] + (end[2] - start[2]) * eased, + start[3] + (end[3] - start[3]) * eased, + ]) +} + +const POSE_HEAD_SHAPE = { + d: 'M252.24.38c22.82-.29,49.74,12.66,54,31,1.8,7.77-2.25,14.71,1,19l24,20c24.63,34.89,19.47,85.38-6,114l-8,19-15,17-2,10c-37.09,39.53-85.31-10.89-92-46-17.31-1.44-14.13-17.89-19-32-9.11-26.39-2.85-62.04,12-82,7.56-10.17,25.79-13.99,29-28-10.65-25.65,5.43-31.91,22-42Z', + center: { x: 260, y: 128 }, +} + +const POSE_BODY_SILHOUETTE_SHAPE = { + d: 'M228.17,226.46c9.86,2.25,19.05,8.64,26,14,21.09-.75,30.2-5.19,42-14l2,1,2,39c17.4,30.36,95.95,20.16,110,54,17.09,41.15-.67,121.25,7,171,5.26,34.15,16.21,54.17,23,85l14,105c9.03,37.99,66.17,66.65,74,106h-4c-5.94-2.49-8.47-5.5-13-9-3.03,2.69-1.52,3.01-7,4l-4-3c-2.26-14.07-17-33.28-33-28,5.34,9.78,14.64,15.86,16,29-10.6,6.63-15.15-8.33-20-14-9.68-11.31-26.15-19.14-30-36l-1-33-45-107c-14.24-36.26-19.89-82.94-20-132l-5,4c-6.73,24.5-20.65,62.24-14,91,13.42,58.06,59.07,109.01,45,196-13.09,80.98-35.72,147.59-49,224-7.78,44.76,9.2,86.8,2,131-7.72,47.41-16.38,86.91-26,128-5.67,24.22-9.97,63.94-8,89,1.47,18.68,24.25,43.35,14,61-13.49,2.22-33.17,4.78-48,2-13.83-11.36-2.13-27.9-6-49-6.84-37.25,12.3-66.02,6-107l-3-66c-3.28-21.08-6.86-52.3-3-77l4-41-10-44,4-95-1-76c3.6-34.34,5.4-68.37,11-96h-1l-30,1c3.03,18.81,5.71,39.34,9,61v122l4,80-10,47,4,43c5.09,33.08-.46,82.98-5,113-5.85,38.71,3,77.04,8,106l-3,37c-.58,18.68,2.11,30.56-6,42-13.1-.03-40.18-.91-51-8-2.91-19.32,12.04-34.88,16-52,1.11-4.81.34-48.15-1-56-10.21-59.61-22.61-107.33-33-170-7.04-42.45,10.26-88.28,2-133l-43-176-5-47c-13.76-83.21,31.05-132.8,44-190,4.49-19.84-7.14-85.32-17-93v2l-2,35c-4.3,25.96-5.88,51.76-13,75-16.33,40-32.67,80-49,120l-3,40c-3.35,10.16-38.6,49.47-50,50v-2l-1-1c2.5-12.5,11.59-19,17-29-27.53,3.35-31.62,32.36-43,32l-2-3c-4.46,4.97-7.22,7.97-17,8,6.45-24.77,33.79-51.67,50-69,7.65-8.18,20.5-15.08,24-27,5.33-40.66,10.67-81.34,16-122,6.48-27.54,16.92-44.95,22-76,.67-40,1.33-80,2-120,0-18.13-1.35-37.56,4-51,9.09-22.83,23.12-18.83,49-26,15.32-4.24,52.29-17.52,59-31l3-39Z', +} + +const POSE_FACE_KEYPOINT_SHAPES: PoseKeypointShape[] = [ + { + keypointName: 'left_ear', + d: 'M333.4,141.79c6.55,2.02,7.24,6.5,10,12-4.25,12.13-7.47,21.63-15,30-4.53-.86-2.98.12-5-3-5.47-13.4,6.64-29.01,10-39Z', + center: { x: 333, y: 162 }, + }, + { + keypointName: 'left_eye', + d: 'M275.17,161.23c0-9.1,2.33-11.16,6-16,13.79-3.91,20.05-5.92,34,0-.06,6.18.06,5.55,1,11-13.55,8.16-24.12,5.61-41,5Z', + center: { x: 296, y: 153 }, + }, + { + keypointName: 'nose', + d: 'M255.72,141.52c9.21.01,16.7,3.13,19.65,10,3.56,9.05,4.51,27.57,2.81,37-7.05,3.67-18.89,3.38-27.13,1l-.94-3,3.74-44,1.87-1Z', + center: { x: 265, y: 168 }, + }, + { + keypointName: 'right_eye', + d: 'M227.67,140.54c11.65-.29,15.55,2.71,22,7l3,11-2,2c-21.58-1.84-23.28.92-38-7v-3c5.11-5.09,7.9-6.27,15-10Z', + center: { x: 233, y: 151 }, + }, + { + keypointName: 'right_ear', + d: 'M192.31,141.85h2c9.16,8.73,13.18,22.95,13,41l-7,1c-9.02-8.69-14.48-24.7-11-40l3-2Z', + center: { x: 198, y: 163 }, + }, +] + +const POSE_BODY_KEYPOINT_SHAPES: PoseKeypointShape[] = [ + { + keypointName: 'left_shoulder', + d: 'M366.78,294.65c49.96-1.22,59.69,45.87,51,89-19.24,2.93-31.75-2.97-45-7-7.81-2.38-13.74.22-23-3-16.83-5.86-27.17-26-32-44-1.35-5.05-4.26-17.79-1-24,5.82-11.1,40.46-3.09,50-11Z', + center: { x: 367, y: 339 }, + }, + { + keypointName: 'right_shoulder', + d: 'M151.37,294.09c15.59-.17,27.96,3.03,43,6,35.95,46.17-27.37,89.99-74,75-3.06.74-4.51.27-7-2-9.69-48.02,7-65.94,38-79Z', + center: { x: 154, y: 335 }, + }, + { + keypointName: 'left_elbow', + d: 'M385.9,497.92l34,3c1.83,22.73,11.96,34.76,13,55l-2,1c-16.93,4.42-34.01,5.16-53,2-3.9-15.42-10.79-41.98-6-58l14-3Z', + center: { x: 402, y: 528 }, + }, + { + keypointName: 'right_elbow', + d: 'M112.08,499.2c19.98-.26,31.68,1.45,45,5,6.4,18.88-4.46,48.43-8,66h-5c-11.54,1.91-39.73-14.5-46-23,1-6.62,10.3-43.27,14-48Z', + center: { x: 128, y: 535 }, + }, + { + keypointName: 'left_wrist', + d: 'M442.19,698.5l17,1c18.5,11.77,65.86,67.9,71,91l-1,1c-10.64-1.08-11.24-6-18-10-1.03,5.43-.3,4.34-6,5l-5-4c-3.16-18.61-13.25-23.4-30-28,3.14,12.71,15.88,16.27,14,31-9.71,2.2-45.85-38.29-50-48-4.38-10.26-1.43-24.8-1-36h2l7-3Z', + center: { x: 482, y: 745 }, + }, + { + keypointName: 'right_wrist', + d: 'M-.9,790.21c1.52-19.66,59.95-87.7,75-97,7.7-3.41,17.22,1.04,23,3l1,2c.79,43.23-12.04,47.2-31,68-6.74,7.4-8.48,13.96-18,19l-6-3v-1c2.62-12.82,12.3-18.1,17-29l-27,14c-5.09,6.8-3.71,14.23-11,19-7.41-2.6-7.5-3.82-13,4l-2,1H-.9Z', + center: { x: 49, y: 742 }, + }, + { + keypointName: 'left_hip', + d: 'M361.83,617.76l16,2c13.34,42.76,19.07,80.94,18,137l-2,1c-26.41,5.52-56.7-24.7-61-45-2.39-11.27,2.04-29.55,3-40,1.59-17.35-3.1-31.43,7-45,4.37-5.87,12.1-6.54,19-10Z', + center: { x: 365, y: 690 }, + }, + { + keypointName: 'right_hip', + d: 'M149.97,622.82c34.97-.84,40.11,21.46,46,49,.64,3.01-2,10-2,10,5.65,7.66,4.26,13.83,4,24v24c-8.03,22.25-40.04,38.2-64,33-7.58-53.57,5.98-100.8,16-140Z', + center: { x: 166, y: 693 }, + }, + { + keypointName: 'left_knee', + d: 'M288.62,972.45c20.28-.46,45.13-2.06,57,6,.27,19.74,5.1,54.5-2,70-23.77.1-48.94,7.97-68,3-.05-25.22-19.01-51.23-7-77,6.75.33,16.29.85,20-2Z', + center: { x: 311, y: 1012 }, + }, + { + keypointName: 'right_knee', + d: 'M184.44,972.61c22.02-.27,61.46-6.01,79,2,1.23,18.05-3.49,72.92-10,86-23.35.49-55.39-1.68-69-12-.19-25.65-5.78-61.25,0-76Z', + center: { x: 223, y: 1017 }, + }, + { + keypointName: 'left_ankle', + d: 'M276.45,1281.69c14.15-.65,32.9.36,38,9l1,1-1,33c2.99,15.43,19.56,34.85,16,52l-1,1-5,7c-11.19.32-41.42,3.97-48-2-11.12-8.97-9-88.28,0-101Z', + center: { x: 301, y: 1335 }, + }, + { + keypointName: 'right_ankle', + d: 'M217.81,1285.3c11.67.33,23.33.67,35,1,12.72,9.37,7.65,87.45-1,99-20.11.44-40.62-.06-53-8l-1-2c.32-21.95,15.9-34.18,18-54,1.3-12.3-6.69-25.48,2-36Z', + center: { x: 233, y: 1337 }, + }, +] + function poseKeypointId(name?: string | null) { return String(name ?? '').trim().toLowerCase() } @@ -397,6 +591,18 @@ function poseKeypointLabel(name?: string | null) { return POSE_KEYPOINT_LABELS[key] || key.replace(/_/g, ' ') || 'Punkt' } +function poseKeypointMarkerClassName(present: boolean, pending: boolean) { + if (pending) { + return 'fill-amber-500 stroke-white/90 dark:stroke-gray-950/90' + } + + if (present) { + return 'fill-blue-500 stroke-white/90 dark:stroke-gray-950/90' + } + + return 'fill-rose-500 stroke-white/90 dark:stroke-gray-950/90' +} + function isPoseKeypointVisible(point?: TrainingKeypoint | null): point is TrainingKeypoint { if (!point) return false @@ -644,8 +850,45 @@ function combineTrainingEtaMs(stageRemainingMs: number, epochRemainingMs: number if (!hasStage) return epoch if (!hasEpoch) return stage - const boundedEpoch = Math.max(stage * 0.35, Math.min(stage * 1.65, epoch)) - return boundedEpoch * 0.7 + stage * 0.3 + const boundedStage = Math.max(epoch * 0.35, Math.min(epoch * 1.65, stage)) + return epoch * 0.75 + boundedStage * 0.25 +} + +function trainingLocalProgressFromJob( + job: TrainingJobStatus | null | undefined, + target: TrainingTargetKey | '' +) { + if (!job || !target) return null + + const stageTarget = trainingTargetFromStageText(job.stage, job.step) + if (stageTarget !== target) return null + + const value = Number(job.stageProgress) + if (!Number.isFinite(value)) return null + + return clamp01(value <= 1 ? value : value / 100) +} + +function trainingCompletedEpochUnits( + epoch: number, + epochs: number, + localProgress?: number | null +) { + if (!Number.isFinite(epoch) || !Number.isFinite(epochs) || epoch <= 0 || epochs <= 0) { + return 0 + } + + const safeEpoch = Math.max(1, Math.min(epochs, Math.floor(epoch))) + const progress = Number(localProgress) + + if (Number.isFinite(progress) && progress > 0) { + const inferred = ((clamp01(progress) - 0.04) / 0.90) * epochs + if (Number.isFinite(inferred) && inferred > 0) { + return clampTrainingEstimate(inferred, Math.max(0, safeEpoch - 1), epochs) + } + } + + return Math.max(0.15, Math.min(epochs, safeEpoch - 0.5)) } function normalizeTrainingEstimateMode(value?: string | null): TrainingEstimateMode { @@ -1411,6 +1654,93 @@ function peopleLabelsFromBoxes(boxes: TrainingBox[], labels: TrainingLabels) { ) } +function normalizePosePerson(person: TrainingPosePerson): TrainingPosePerson | null { + const box = normalizeBox(person.box) + if (!box) return null + + const keypoints = (person.keypoints ?? []) + .map((point) => ({ + name: poseKeypointId(point.name), + x: clamp01(Number(point.x)), + y: clamp01(Number(point.y)), + conf: clamp01(Number(point.conf ?? 1)), + })) + .filter((point) => point.name) + + return { + label: String(person.label || '').trim() || 'person', + score: clamp01(Number(person.score ?? 1)), + box, + keypoints, + quality: typeof person.quality === 'number' ? clamp01(Number(person.quality)) : undefined, + visibleKeypoints: + typeof person.visibleKeypoints === 'number' + ? Math.max(0, Math.round(Number(person.visibleKeypoints))) + : undefined, + reliable: typeof person.reliable === 'boolean' ? person.reliable : undefined, + } +} + +function normalizePosePersons(persons: TrainingPosePerson[] | undefined): TrainingPosePerson[] { + return (persons ?? []) + .map(normalizePosePerson) + .filter((person): person is TrainingPosePerson => Boolean(person)) +} + +function clonePosePersons(persons: TrainingPosePerson[] | undefined): TrainingPosePerson[] { + return normalizePosePersons(persons).map((person) => ({ + ...person, + box: { ...person.box }, + keypoints: person.keypoints.map((point) => ({ ...point })), + })) +} + +function poseBoxFromKeypoints(person: TrainingPosePerson): TrainingBox { + const points = (person.keypoints ?? []).filter((point) => + Number.isFinite(point.x) && + Number.isFinite(point.y) && + clamp01(Number(point.conf ?? 1)) >= POSE_KEYPOINT_MIN_CONFIDENCE + ) + + if (points.length === 0) { + return normalizeBox(person.box) ?? person.box + } + + const margin = 0.025 + const minX = Math.min(...points.map((point) => clamp01(Number(point.x)))) + const minY = Math.min(...points.map((point) => clamp01(Number(point.y)))) + const maxX = Math.max(...points.map((point) => clamp01(Number(point.x)))) + const maxY = Math.max(...points.map((point) => clamp01(Number(point.y)))) + const left = clamp01(minX - margin) + const top = clamp01(minY - margin) + const right = clamp01(maxX + margin) + const bottom = clamp01(maxY + margin) + + return normalizeBox({ + label: person.box?.label || person.label || 'person', + score: person.box?.score, + x: left, + y: top, + w: right - left, + h: bottom - top, + }) ?? person.box +} + +function updatePosePersonQuality(person: TrainingPosePerson): TrainingPosePerson { + const visibleKeypoints = posePersonVisibleKeypoints(person) + const quality = posePersonQuality(person) + + return { + ...person, + visibleKeypoints, + quality, + reliable: + clamp01(Number(person.score ?? 0)) >= POSE_RELIABLE_MIN_SCORE && + visibleKeypoints >= POSE_RELIABLE_MIN_KEYPOINTS && + quality >= POSE_RELIABLE_MIN_QUALITY, + } +} + function predictionToCorrection(sample: TrainingSample | null): CorrectionState { const p = sample?.prediction @@ -1432,6 +1762,7 @@ function predictionToCorrection(sample: TrainingSample | null): CorrectionState objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label), clothingPresent: (p?.clothingPresent ?? []).map((x) => x.label), boxes, + posePersons: clonePosePersons(p?.persons), } } @@ -1443,6 +1774,7 @@ function cloneCorrectionState(value: CorrectionState): CorrectionState { objectsPresent: [...value.objectsPresent], clothingPresent: [...value.clothingPresent], boxes: (value.boxes ?? []).map((box) => ({ ...box })), + posePersons: clonePosePersons(value.posePersons), } } @@ -3393,6 +3725,7 @@ function annotationToCorrectionState(item: TrainingAnnotation): CorrectionState objectsPresent: [], clothingPresent: [], boxes: [], + posePersons: [], } } @@ -3400,6 +3733,7 @@ function annotationToCorrectionState(item: TrainingAnnotation): CorrectionState return { ...item.correction, sexPosition: normalizeSexPositionValue(item.correction.sexPosition), + posePersons: clonePosePersons(item.correction.posePersons ?? item.prediction.persons), } } @@ -3488,6 +3822,7 @@ export default function TrainingTab(props: { const notify = useNotify() const activePointerIdRef = useRef(null) + const activePointerCaptureElementRef = useRef(null) const finishingGestureRef = useRef(false) const [frameImageLoaded, setFrameImageLoaded] = useState(false) @@ -3679,6 +4014,17 @@ export default function TrainingTab(props: { setTouchMagnifier(null) setBoxLabel('') setActiveBoxIndex(null) + setActivePosePersonIndex(null) + setPendingPoseKeypoint(null) + if (poseKeypointMapAnimationFrameRef.current !== null) { + cancelAnimationFrame(poseKeypointMapAnimationFrameRef.current) + poseKeypointMapAnimationFrameRef.current = null + } + poseKeypointMapViewRef.current = 'body' + poseKeypointMapViewBoxValueRef.current = POSE_BODY_MAP_VIEW_BOX + setPoseKeypointMapView('body') + setPoseKeypointMapViewBoxValue(POSE_BODY_MAP_VIEW_BOX) + setPoseKeypointMapAnimating(false) setFeedbackModalOpen(false) window.requestAnimationFrame(() => { @@ -3797,9 +4143,17 @@ export default function TrainingTab(props: { const [drawingBox, setDrawingBox] = useState(null) const [boxInteraction, setBoxInteraction] = useState(null) + const [poseInteraction, setPoseInteraction] = useState(null) + const [pendingPoseKeypoint, setPendingPoseKeypoint] = useState(null) + const [poseKeypointMapView, setPoseKeypointMapView] = useState('body') + const [poseKeypointMapViewBoxValue, setPoseKeypointMapViewBoxValue] = + useState(POSE_BODY_MAP_VIEW_BOX) + const [poseKeypointMapAnimating, setPoseKeypointMapAnimating] = useState(false) + const [poseMapSlotHeight, setPoseMapSlotHeight] = useState(0) const [touchMagnifier, setTouchMagnifier] = useState(null) const [boxLabel, setBoxLabel] = useState('') const [activeBoxIndex, setActiveBoxIndex] = useState(null) + const [activePosePersonIndex, setActivePosePersonIndex] = useState(null) const [imageReloadKey, setImageReloadKey] = useState(0) const [trainingSampleMode, setTrainingSampleMode] = useState('random') const trainingSampleModeRef = useRef('random') @@ -3833,6 +4187,11 @@ export default function TrainingTab(props: { const drawingBoxRef = useRef(null) const boxInteractionRef = useRef(null) + const poseInteractionRef = useRef(null) + const poseKeypointMapViewRef = useRef('body') + const poseKeypointMapViewBoxValueRef = useRef(POSE_BODY_MAP_VIEW_BOX) + const poseKeypointMapAnimationFrameRef = useRef(null) + const poseMapSlotRef = useRef(null) const correctionRef = useRef(correction) const hasManualCorrectionRef = useRef(hasManualCorrection) const latestGestureBoxRef = useRef(null) @@ -3887,6 +4246,45 @@ export default function TrainingTab(props: { [] ) + const switchPoseKeypointMapView = useCallback((nextView: PoseKeypointMapView) => { + const currentView = poseKeypointMapViewRef.current + + if (currentView === nextView) return + + if (poseKeypointMapAnimationFrameRef.current !== null) { + cancelAnimationFrame(poseKeypointMapAnimationFrameRef.current) + poseKeypointMapAnimationFrameRef.current = null + } + + const fromViewBox = poseKeypointMapViewBoxValueRef.current + const toViewBox = poseKeypointMapViewBox(nextView) + const startedAt = performance.now() + + poseKeypointMapViewRef.current = nextView + setPoseKeypointMapView(nextView) + setPoseKeypointMapAnimating(true) + + const animateZoom = (now: number) => { + const progress = Math.min(1, (now - startedAt) / POSE_KEYPOINT_MAP_ZOOM_DURATION_MS) + const nextViewBox = interpolatePoseMapViewBox(fromViewBox, toViewBox, progress) + + poseKeypointMapViewBoxValueRef.current = nextViewBox + setPoseKeypointMapViewBoxValue(nextViewBox) + + if (progress < 1) { + poseKeypointMapAnimationFrameRef.current = requestAnimationFrame(animateZoom) + return + } + + poseKeypointMapAnimationFrameRef.current = null + poseKeypointMapViewBoxValueRef.current = toViewBox + setPoseKeypointMapViewBoxValue(toViewBox) + setPoseKeypointMapAnimating(false) + } + + poseKeypointMapAnimationFrameRef.current = requestAnimationFrame(animateZoom) + }, []) + const labelsRef = useRef(emptyLabels) useEffect(() => { @@ -3925,6 +4323,23 @@ export default function TrainingTab(props: { boxInteractionRef.current = boxInteraction }, [boxInteraction]) + useEffect(() => { + poseInteractionRef.current = poseInteraction + }, [poseInteraction]) + + useEffect(() => { + poseKeypointMapViewRef.current = poseKeypointMapView + }, [poseKeypointMapView]) + + useEffect(() => { + return () => { + if (poseKeypointMapAnimationFrameRef.current !== null) { + cancelAnimationFrame(poseKeypointMapAnimationFrameRef.current) + poseKeypointMapAnimationFrameRef.current = null + } + } + }, []) + useEffect(() => { correctionRef.current = correction }, [correction]) @@ -3961,12 +4376,38 @@ export default function TrainingTab(props: { ] const posePersons = useMemo(() => { - return (sample?.prediction.persons ?? []).filter((person) => - hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible) - ) - }, [sample?.prediction.persons]) + return correction.posePersons ?? [] + }, [correction.posePersons]) - const hasPosePersons = posePersons.length > 0 + const hasPosePersons = posePersons.some((person) => + hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible) + ) + const visiblePosePersonCount = posePersons.filter((person) => + hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible) + ).length + const originalPosePersonCount = sample?.prediction.persons?.length ?? 0 + const firstVisiblePosePersonIndex = posePersons.findIndex((person) => + hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible) + ) + const activePosePerson = + activePosePersonIndex !== null ? posePersons[activePosePersonIndex] : undefined + const activePosePersonVisible = Boolean( + activePosePerson && + (hasVisiblePoseBox(activePosePerson) || + activePosePerson.keypoints?.some(isPoseKeypointVisible)) + ) + const poseCorrectionPersonIndex = + activePosePersonIndex !== null && activePosePersonVisible + ? activePosePersonIndex + : visiblePosePersonCount === 1 + ? firstVisiblePosePersonIndex + : null + + useEffect(() => { + setActivePosePersonIndex((current) => + current !== null && current >= posePersons.length ? null : current + ) + }, [posePersons.length]) const bodyPartScores = useMemo(() => { return Object.fromEntries( @@ -4449,6 +4890,12 @@ export default function TrainingTab(props: { const currentTrainingTargetProgress = useMemo(() => { if (!currentTrainingTarget) return 0 + const localProgress = trainingLocalProgressFromJob( + trainingStatus?.training, + currentTrainingTarget + ) + if (localProgress !== null) return localProgress + const window = trainingTargetProgressWindow(currentTrainingTarget) const span = Math.max(1, window.end - window.start) const progress = clampPercent(Number(serverTrainingProgress) || 0) @@ -4457,6 +4904,9 @@ export default function TrainingTab(props: { }, [ currentTrainingTarget, serverTrainingProgress, + trainingStatus?.training?.stage, + trainingStatus?.training?.stageProgress, + trainingStatus?.training?.step, ]) const estimatedTrainingProgress = useMemo(() => { @@ -4492,22 +4942,16 @@ export default function TrainingTab(props: { ]) const serverTrainingProgressPercent = clampPercent(Number(serverTrainingProgress) || 0) - const usesSelectedTargetProgress = - activeTrainingTargets.length > 0 && - activeTrainingTargets.length < ALL_TRAINING_TARGETS.length const shownTrainingProgress = trainingRunning - ? usesSelectedTargetProgress - ? Math.max( - Math.min(serverTrainingProgressPercent, 6), - estimatedTrainingProgress - ) - : Math.max(serverTrainingProgressPercent, estimatedTrainingProgress) + ? estimatedTrainingProgress > 0 + ? estimatedTrainingProgress + : serverTrainingProgressPercent : serverTrainingProgress const drawingCursorClass = boxInteraction?.type === 'move' ? '[@media_(hover:hover)_and_(pointer:fine)]:cursor-grabbing' - : drawingBox || (boxLabel && !uiLocked) + : drawingBox || pendingPoseKeypoint || (boxLabel && !uiLocked) ? '[@media_(hover:hover)_and_(pointer:fine)]:cursor-crosshair' : '' @@ -4655,6 +5099,11 @@ export default function TrainingTab(props: { finishedAt: job.finishedAt, durationMs: Number(job.durationMs ?? 0), stage: job.stage, + stageStartedAt: String(job.stageStartedAt ?? ''), + stageProgress: + job.stageProgress === undefined || job.stageProgress === null + ? undefined + : Number(job.stageProgress), epoch: Number(job.epoch ?? 0), epochs: Number(job.epochs ?? 0), previewUrl: String(job.previewUrl ?? ''), @@ -4664,6 +5113,8 @@ export default function TrainingTab(props: { temperatureC: Number(job.temperatureC ?? 0), map50: Number(job.map50 ?? 0), map5095: Number(job.map5095 ?? 0), + accuracy: Number(job.accuracy ?? 0), + loss: Number(job.loss ?? 0), } : prev?.training, })) @@ -4716,6 +5167,17 @@ export default function TrainingTab(props: { setTouchMagnifier(null) setBoxLabel('') setActiveBoxIndex(null) + setActivePosePersonIndex(null) + setPendingPoseKeypoint(null) + if (poseKeypointMapAnimationFrameRef.current !== null) { + cancelAnimationFrame(poseKeypointMapAnimationFrameRef.current) + poseKeypointMapAnimationFrameRef.current = null + } + poseKeypointMapViewRef.current = 'body' + poseKeypointMapViewBoxValueRef.current = POSE_BODY_MAP_VIEW_BOX + setPoseKeypointMapView('body') + setPoseKeypointMapViewBoxValue(POSE_BODY_MAP_VIEW_BOX) + setPoseKeypointMapAnimating(false) setMobilePanel(trainingRunningRef.current ? 'training' : 'labels') window.requestAnimationFrame(() => { @@ -5515,7 +5977,7 @@ export default function TrainingTab(props: { }, [applyTrainingAnalysisEvent]) useEffect(() => { - const draggingBox = Boolean(drawingBox || boxInteraction) + const draggingBox = Boolean(drawingBox || boxInteraction || poseInteraction) if (!draggingBox) return @@ -5547,7 +6009,7 @@ export default function TrainingTab(props: { clearSelection() } - }, [drawingBox, boxInteraction]) + }, [drawingBox, boxInteraction, poseInteraction]) useEffect(() => { if (!statsModalOpen) return @@ -5963,28 +6425,39 @@ export default function TrainingTab(props: { const previous = epochTimingRef.current const target = currentTrainingTarget || activeTrainingTarget || '' const targetChanged = previous.target !== target + const stageStarted = job?.stageStartedAt && Number.isFinite(Date.parse(job.stageStartedAt)) + ? Date.parse(job.stageStartedAt) + : 0 + const jobStarted = job?.startedAt && Number.isFinite(Date.parse(job.startedAt)) + ? Date.parse(job.startedAt) + : 0 if (targetChanged) { setEstimatedEpochMs(0) } const firstEpochAt = - targetChanged - ? now - : previous.firstEpochAt > 0 + stageStarted > 0 + ? stageStarted + : !targetChanged && previous.firstEpochAt > 0 ? previous.firstEpochAt - : job?.startedAt && Number.isFinite(Date.parse(job.startedAt)) - ? Date.parse(job.startedAt) + : jobStarted > 0 && (epoch > 1 || !targetChanged) + ? jobStarted : now const safeEpoch = Math.max(1, Math.min(epochs, Math.floor(epoch))) + const completedEpochUnits = trainingCompletedEpochUnits( + epoch, + epochs, + currentTrainingTargetProgress + ) const elapsedSinceStartMs = Math.max(0, now - firstEpochAt) // Direkt ab Epoche 1 eine erste Schätzung: // bisherige Laufzeit / aktuelle Epoche. const averageFromElapsed = - elapsedSinceStartMs > 0 && safeEpoch > 0 - ? elapsedSinceStartMs / safeEpoch + elapsedSinceStartMs > 0 && completedEpochUnits > 0 + ? elapsedSinceStartMs / completedEpochUnits : 0 if (Number.isFinite(averageFromElapsed) && averageFromElapsed > 0) { @@ -6012,9 +6485,11 @@ export default function TrainingTab(props: { trainingRunning, trainingStatus?.training?.epoch, trainingStatus?.training?.epochs, + trainingStatus?.training?.stageStartedAt, trainingStatus?.training?.startedAt, activeTrainingTarget, currentTrainingTarget, + currentTrainingTargetProgress, trainingNowMs, ]) @@ -6040,6 +6515,7 @@ export default function TrainingTab(props: { const feedbackCorrection = { ...correction, boxes: normalizedBoxes, + posePersons: clonePosePersons(correction.posePersons), } const negative = options?.negative ?? @@ -6052,11 +6528,13 @@ export default function TrainingTab(props: { objectsPresent: [], clothingPresent: [], boxes: [], + posePersons: [], } : { ...correction, peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current), boxes: normalizedBoxes, + posePersons: clonePosePersons(correction.posePersons), } const effectiveAccepted = negative ? false : accepted setSavingOverlayText( @@ -6478,15 +6956,18 @@ export default function TrainingTab(props: { if (typeof id !== 'number') { activePointerIdRef.current = null + activePointerCaptureElementRef.current = null return } try { - imageBoxRef.current?.releasePointerCapture(id) + const captureElement = activePointerCaptureElementRef.current ?? imageBoxRef.current + captureElement?.releasePointerCapture(id) } catch { // Pointer-Capture war evtl. schon weg. } + activePointerCaptureElementRef.current = null activePointerIdRef.current = null }, []) @@ -6511,6 +6992,7 @@ export default function TrainingTab(props: { activeImageContentRectRef.current = null drawingBoxRef.current = null boxInteractionRef.current = null + poseInteractionRef.current = null latestGestureBoxRef.current = null }, [cancelPointerMoveFrame]) @@ -6520,8 +7002,9 @@ export default function TrainingTab(props: { }) => { const drawing = drawingBoxRef.current const interaction = boxInteractionRef.current + const poseInteraction = poseInteractionRef.current - if (!drawing && !interaction) return + if (!drawing && !interaction && !poseInteraction) return const clampedPos = getPointerPosInImage(snapshot.clientX, snapshot.clientY) if (!clampedPos) return @@ -6553,6 +7036,52 @@ export default function TrainingTab(props: { return nextMagnifier }) + if (poseInteraction) { + markManualCorrection() + + setCorrection((prev) => { + const persons = clonePosePersons(prev.posePersons) + const person = persons[poseInteraction.personIndex] + const point = person?.keypoints?.[poseInteraction.keypointIndex] + + if (!person || !point) { + return prev + } + + const nextPoint = { + ...point, + x: snap01(clampedPos.x), + y: snap01(clampedPos.y), + conf: Math.max(clamp01(Number(point.conf ?? 1)), POSE_RELIABLE_KEYPOINT_MIN_CONFIDENCE), + } + + const nextPerson = updatePosePersonQuality({ + ...person, + box: poseBoxFromKeypoints({ + ...person, + keypoints: person.keypoints.map((candidate, index) => + index === poseInteraction.keypointIndex ? nextPoint : candidate + ), + }), + keypoints: person.keypoints.map((candidate, index) => + index === poseInteraction.keypointIndex ? nextPoint : candidate + ), + }) + + persons[poseInteraction.personIndex] = nextPerson + + const next: CorrectionState = { + ...prev, + posePersons: persons, + } + + correctionRef.current = next + return next + }) + + return + } + if (interaction) { const dx = pos.x - interaction.startX const dy = pos.y - interaction.startY @@ -6690,17 +7219,121 @@ export default function TrainingTab(props: { } }, [applyPointerMoveSnapshot]) + const setPoseKeypointOnPerson = useCallback(( + personIndex: number, + keypointName: string, + position: { x: number; y: number } + ) => { + const cleanKeypointName = poseKeypointId(keypointName) + if (!cleanKeypointName) return false + + const currentPerson = correctionRef.current.posePersons?.[personIndex] + if (!currentPerson) return false + + markManualCorrection() + + setCorrection((prev) => { + const persons = clonePosePersons(prev.posePersons) + const person = persons[personIndex] + + if (!person) { + return prev + } + + const nextPoint: TrainingKeypoint = { + name: cleanKeypointName, + x: snap01(position.x), + y: snap01(position.y), + conf: 1, + } + + const existingIndex = person.keypoints.findIndex( + (point) => poseKeypointId(point.name) === cleanKeypointName + ) + const keypoints = + existingIndex >= 0 + ? person.keypoints.map((point, index) => + index === existingIndex ? nextPoint : point + ) + : [...person.keypoints, nextPoint] + + const nextPerson = updatePosePersonQuality({ + ...person, + keypoints, + box: poseBoxFromKeypoints({ + ...person, + keypoints, + }), + }) + + persons[personIndex] = nextPerson + + const next: CorrectionState = { + ...prev, + posePersons: persons, + } + + correctionRef.current = next + return next + }) + + setShowPoseSkeleton(true) + setActivePosePersonIndex(personIndex) + setPendingPoseKeypoint(null) + + return true + }, [markManualCorrection]) + useEffect(() => { return () => cancelPointerMoveFrame() }, [cancelPointerMoveFrame]) const startDrawBox = useCallback((e: React.PointerEvent) => { + if (pendingPoseKeypoint) { + const isFrameBusy = loading || (!!imageSrc && !frameImageLoaded) + if (uiLocked || isFrameBusy || trainingRunning || saving) return + if (drawingBoxRef.current || boxInteractionRef.current || poseInteractionRef.current) return + + const target = e.target as HTMLElement | null + if (target?.closest('[data-box-control="true"]')) return + if (target?.closest('[data-pose-control="true"]')) return + + const contentRect = getImageContentRect() + if (!contentRect) return + + const rawPos = getPointerPosFromRect(contentRect, e.clientX, e.clientY, { clamp: false }) + + if ( + rawPos.x < 0 || + rawPos.x > 1 || + rawPos.y < 0 || + rawPos.y > 1 + ) { + return + } + + e.preventDefault() + e.stopPropagation() + window.getSelection()?.removeAllRanges() + + setPoseKeypointOnPerson( + pendingPoseKeypoint.personIndex, + pendingPoseKeypoint.keypointName, + { + x: clamp01(rawPos.x), + y: clamp01(rawPos.y), + } + ) + return + } + if (!boxLabel) return if (uiLocked) return - if (drawingBoxRef.current || boxInteractionRef.current) return + if (drawingBoxRef.current || boxInteractionRef.current || poseInteractionRef.current) return const target = e.target as HTMLElement | null if (target?.closest('[data-box-control="true"]')) return + if (target?.closest('[data-pose-control="true"]')) return const contentRect = getImageContentRect() if (!contentRect) return @@ -6732,9 +7365,11 @@ export default function TrainingTab(props: { activePointerIdRef.current = e.pointerId try { + activePointerCaptureElementRef.current = e.currentTarget e.currentTarget.setPointerCapture(e.pointerId) } catch { activePointerIdRef.current = null + activePointerCaptureElementRef.current = null } const nextMagnifier: MagnifierState = { @@ -6760,10 +7395,24 @@ export default function TrainingTab(props: { setTouchMagnifier(nextMagnifier) setDrawingBox(nextDrawingBox) - }, [boxLabel, getImageContentRect, getPointerPosFromRect, uiLocked]) + }, [ + boxLabel, + frameImageLoaded, + getImageContentRect, + getPointerPosFromRect, + imageSrc, + loading, + pendingPoseKeypoint, + saving, + setPoseKeypointOnPerson, + trainingRunning, + uiLocked, + ]) const moveDrawBox = useCallback((e: React.PointerEvent) => { - const hasActiveGesture = Boolean(drawingBoxRef.current || boxInteractionRef.current) + const hasActiveGesture = Boolean( + drawingBoxRef.current || boxInteractionRef.current || poseInteractionRef.current + ) if (hasActiveGesture) { e.preventDefault() @@ -6781,6 +7430,7 @@ export default function TrainingTab(props: { const activeDrawingBox = drawingBoxRef.current const activeInteraction = boxInteractionRef.current + const activePoseInteraction = poseInteractionRef.current releaseActivePointerCapture( typeof e?.pointerId === 'number' ? e.pointerId : null @@ -6791,17 +7441,24 @@ export default function TrainingTab(props: { if (finishingGestureRef.current) { setDrawingBox(null) setBoxInteraction(null) + setPoseInteraction(null) clearBoxGestureRefs() return } finishingGestureRef.current = true - if (activeDrawingBox || activeInteraction) { + if (activeDrawingBox || activeInteraction || activePoseInteraction) { e?.preventDefault() e?.stopPropagation() } + if (activePoseInteraction) { + setPoseInteraction(null) + clearBoxGestureRefs() + return + } + if (activeInteraction) { const finalBox = latestGestureBoxRef.current @@ -6873,7 +7530,18 @@ export default function TrainingTab(props: { ]) useEffect(() => { - if (!drawingBox && !boxInteraction) return + if (!drawingBox && !boxInteraction && !poseInteraction) return + + const movePointer = (event: PointerEvent) => { + if (!drawingBoxRef.current && !boxInteractionRef.current && !poseInteractionRef.current) return + + event.preventDefault() + event.stopPropagation() + schedulePointerMove({ + clientX: event.clientX, + clientY: event.clientY, + }) + } const finishPointer = (event: PointerEvent) => { finishDrawBox(event) @@ -6889,18 +7557,20 @@ export default function TrainingTab(props: { } } + window.addEventListener('pointermove', movePointer, true) window.addEventListener('pointerup', finishPointer, true) window.addEventListener('pointercancel', finishPointer, true) window.addEventListener('blur', finishWithoutPointer) document.addEventListener('visibilitychange', finishOnHidden) return () => { + window.removeEventListener('pointermove', movePointer, true) window.removeEventListener('pointerup', finishPointer, true) window.removeEventListener('pointercancel', finishPointer, true) window.removeEventListener('blur', finishWithoutPointer) document.removeEventListener('visibilitychange', finishOnHidden) } - }, [drawingBox, boxInteraction, finishDrawBox]) + }, [drawingBox, boxInteraction, poseInteraction, finishDrawBox, schedulePointerMove]) const removeBox = useCallback((index: number) => { setHasManualCorrection(true) @@ -6935,6 +7605,158 @@ export default function TrainingTab(props: { }) }, []) + const removePosePerson = useCallback((personIndex: number) => { + markManualCorrection() + + setCorrection((prev) => { + const persons = clonePosePersons(prev.posePersons) + if (personIndex < 0 || personIndex >= persons.length) return prev + + const next: CorrectionState = { + ...prev, + posePersons: persons.filter((_, index) => index !== personIndex), + } + + correctionRef.current = next + return next + }) + + setActivePosePersonIndex((current) => { + if (current === null) return null + if (current === personIndex) return null + if (current > personIndex) return current - 1 + return current + }) + + setPendingPoseKeypoint((current) => { + if (!current) return null + if (current.personIndex === personIndex) return null + if (current.personIndex > personIndex) { + return { + ...current, + personIndex: current.personIndex - 1, + } + } + return current + }) + }, [markManualCorrection]) + + const removePoseKeypoint = useCallback((personIndex: number, keypointIndex: number) => { + markManualCorrection() + + setCorrection((prev) => { + const persons = clonePosePersons(prev.posePersons) + const person = persons[personIndex] + const point = person?.keypoints?.[keypointIndex] + + if (!person || !point) return prev + + const keypoints = person.keypoints.map((candidate, index) => + index === keypointIndex + ? { + ...candidate, + x: 0, + y: 0, + conf: 0, + } + : candidate + ) + + const nextPerson = updatePosePersonQuality({ + ...person, + keypoints, + box: poseBoxFromKeypoints({ + ...person, + keypoints, + }), + }) + + persons[personIndex] = nextPerson + + const next: CorrectionState = { + ...prev, + posePersons: persons, + } + + correctionRef.current = next + return next + }) + }, [markManualCorrection]) + + const resetPosePersons = useCallback(() => { + markManualCorrection() + const persons = clonePosePersons(sampleRef.current?.prediction.persons) + + setCorrection((prev) => { + const next: CorrectionState = { + ...prev, + posePersons: persons, + } + + correctionRef.current = next + return next + }) + + setActivePosePersonIndex(null) + if (poseKeypointMapAnimationFrameRef.current !== null) { + cancelAnimationFrame(poseKeypointMapAnimationFrameRef.current) + poseKeypointMapAnimationFrameRef.current = null + } + setPendingPoseKeypoint(null) + poseKeypointMapViewRef.current = 'body' + poseKeypointMapViewBoxValueRef.current = POSE_BODY_MAP_VIEW_BOX + setPoseKeypointMapView('body') + setPoseKeypointMapViewBoxValue(POSE_BODY_MAP_VIEW_BOX) + setPoseKeypointMapAnimating(false) + }, [markManualCorrection]) + + const startPoseKeypointDrag = useCallback(( + e: React.PointerEvent, + personIndex: number, + keypointIndex: number + ) => { + if (uiLocked) return + if (drawingBoxRef.current || boxInteractionRef.current || poseInteractionRef.current) return + + const contentRect = getImageContentRect() + if (!contentRect) return + + const pos = getPointerPosFromRect(contentRect, e.clientX, e.clientY) + + e.preventDefault() + e.stopPropagation() + window.getSelection()?.removeAllRanges() + + activeImageContentRectRef.current = contentRect + finishingGestureRef.current = false + activePointerIdRef.current = e.pointerId + + try { + activePointerCaptureElementRef.current = e.currentTarget + e.currentTarget.setPointerCapture(e.pointerId) + } catch { + activePointerIdRef.current = null + activePointerCaptureElementRef.current = null + } + + const nextInteraction: PoseInteraction = { + type: 'keypoint', + personIndex, + keypointIndex, + } + + poseInteractionRef.current = nextInteraction + setPoseInteraction(nextInteraction) + setActivePosePersonIndex(personIndex) + setTouchMagnifier({ + visible: true, + clientX: e.clientX, + clientY: e.clientY, + imageX: pos.x, + imageY: pos.y, + }) + }, [getImageContentRect, getPointerPosFromRect, uiLocked]) + const changeBoxLabel = useCallback((index: number, nextLabel: string) => { const currentLabel = String(correction.boxes?.[index]?.label || '').trim() const cleanNextLabel = String(nextLabel || '').trim() @@ -6988,7 +7810,107 @@ export default function TrainingTab(props: { setLoadingPreviewFailed(false) }, [loading, frameBusy, loadingPreviewFallbackUrl, loadingPreviewUrl]) - const showImageBoxes = !frameBusy && !trainingRunning && !saving + const poseModeAvailable = hasPosePersons || originalPosePersonCount > 0 + const poseModeActive = showPoseSkeleton && poseModeAvailable + const annotationLayerHidden = frameBusy || trainingRunning || saving + const poseSkeletonVisible = poseModeActive && hasPosePersons && !annotationLayerHidden + const showImageBoxes = !annotationLayerHidden && !poseModeActive + const showAnnotationMagnifier = showImageBoxes || poseSkeletonVisible + + useEffect(() => { + if (!poseModeActive) { + setPoseMapSlotHeight(0) + return + } + + const mediaQuery = window.matchMedia('(min-width: 1024px)') + + const updateHeight = () => { + const element = poseMapSlotRef.current + + if (!mediaQuery.matches || !element) { + setPoseMapSlotHeight(0) + return + } + + const nextHeight = Math.floor(element.getBoundingClientRect().height) + + setPoseMapSlotHeight((current) => + current === nextHeight ? current : nextHeight + ) + } + + updateHeight() + + const resizeObserver = + typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(() => updateHeight()) + : null + + if (resizeObserver && poseMapSlotRef.current) { + resizeObserver.observe(poseMapSlotRef.current) + } + + mediaQuery.addEventListener('change', updateHeight) + window.addEventListener('resize', updateHeight) + + return () => { + resizeObserver?.disconnect() + mediaQuery.removeEventListener('change', updateHeight) + window.removeEventListener('resize', updateHeight) + } + }, [poseModeActive]) + + const annotationModeTabs = () => { + if (!poseModeAvailable) return null + + const modeButtonClass = (active: boolean) => [ + 'inline-flex h-9 min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 text-xs font-bold transition', + 'focus:outline-none focus:ring-2 focus:ring-indigo-500/35', + active + ? 'bg-white text-indigo-700 shadow-sm ring-1 ring-indigo-200 dark:bg-indigo-500 dark:text-white dark:ring-indigo-300/30' + : 'text-gray-600 hover:bg-white/80 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-white/10 dark:hover:text-white', + ].join(' ') + + return ( +
+ + + +
+ ) + } const shownTrainingDurationMs = useMemo(() => { const job = trainingStatus?.training @@ -7017,12 +7939,12 @@ export default function TrainingTab(props: { Number.isFinite(epochs) && epoch > 0 && epochs > 0 - const completedEpochs = hasEpochInfo - ? Math.max(0, Math.min(epochs, Math.floor(epoch))) + const completedEpochUnits = hasEpochInfo + ? trainingCompletedEpochUnits(epoch, epochs, currentTrainingTargetProgress) : 0 const remainingEpochs = Number.isFinite(epochs) && epochs > 0 - ? Math.max(0, epochs - completedEpochs) + ? Math.max(0, epochs - completedEpochUnits) : 0 const epochEtaMs = Number.isFinite(estimatedEpochMs) && estimatedEpochMs > 0 @@ -7130,6 +8052,13 @@ export default function TrainingTab(props: { trainingStatus?.training?.epochs, ]) + const trainingMetricLabel = + currentTrainingTarget === 'videomae' ? 'Accuracy' : 'mAP50' + const trainingMetricText = + currentTrainingTarget === 'videomae' + ? formatMapPercent(trainingStatus?.training?.accuracy) || '—' + : formatMapPercent(trainingStatus?.training?.map50) || '—' + useEffect(() => { const toastImage = imageSrc @@ -7720,6 +8649,15 @@ export default function TrainingTab(props: { const compact = Boolean(opts?.compact) const stretch = Boolean(opts?.stretch) const hasBoxes = correctionBoxes.length > 0 + const poseItems = posePersons + .map((person, index) => ({ person, index })) + .filter(({ person }) => + hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible) + ) + const hasPoseItems = poseItems.length > 0 + const activeAnnotationTitle = poseModeActive ? 'Pose' : 'Detector-Boxen' + const activeAnnotationCount = poseModeActive ? poseItems.length : correctionBoxes.length + const activeAnnotationHasItems = poseModeActive ? hasPoseItems : hasBoxes return (
- Detector-Boxen + {activeAnnotationTitle}
- {correctionBoxes.length} + {activeAnnotationCount}
{!compact ? (
- Prüfen, Label ändern oder einzeln löschen. + {poseModeActive + ? 'Personen und Punkte prüfen oder einzeln löschen.' + : 'Prüfen, Label ändern oder einzeln löschen.'}
) : null}
- {hasBoxes ? ( + {poseModeActive && originalPosePersonCount > 0 ? ( + + ) : !poseModeActive && hasBoxes ? (
+ + {poseModeAvailable ? ( +
+ {annotationModeTabs()} +
+ ) : null}
- {!hasBoxes ? ( + {poseModeActive ? ( + !hasPoseItems ? ( +
+
+
+ Keine Pose vorhanden +
+ +
+ Reset stellt die zuletzt erkannte Pose wieder her. +
+
+
+ ) : ( + poseItems.map(({ person, index: personIndex }) => { + const reliable = isPosePersonReliable(person) + const isActive = activePosePersonIndex === personIndex + const color = reliable + ? POSE_PERSON_COLORS[personIndex % POSE_PERSON_COLORS.length] + : POSE_UNRELIABLE_COLOR + const scoreText = typeof person.score === 'number' ? percent(person.score) : '' + const quality = Math.round(posePersonQuality(person) * 100) + const visibleKeypoints = posePersonVisibleKeypoints(person) + const keypoints = (person.keypoints ?? []) + .map((point, pointIndex) => ({ point, pointIndex })) + .filter(({ point }) => isPoseKeypointVisible(point)) + + return ( +
{ + if (uiLocked) return + setShowPoseSkeleton(true) + setPendingPoseKeypoint(null) + setActivePosePersonIndex((current) => + current === personIndex ? null : personIndex + ) + }} + className={[ + 'group relative overflow-hidden rounded-2xl border transition-all duration-200', + 'bg-white shadow-sm dark:border-white/10 dark:bg-gray-950/55', + uiLocked ? 'cursor-not-allowed opacity-60' : 'cursor-pointer', + isActive + ? 'border-blue-200 bg-blue-50/40 ring-1 ring-blue-100 dark:border-blue-400/30 dark:bg-blue-500/10 dark:ring-blue-400/20' + : 'border-gray-200 hover:bg-gray-50/80 hover:shadow-md dark:hover:bg-white/[0.04]', + ].join(' ')} + > + + ) + }) + ) + ) : !hasBoxes ? (
@@ -7821,7 +8996,7 @@ export default function TrainingTab(props: { aria-disabled={uiLocked} onClick={() => { if (uiLocked) return - setActiveBoxIndex(index) + setActiveBoxIndex((current) => current === index ? null : index) }} className={[ 'group relative overflow-hidden rounded-2xl border transition-all duration-200', @@ -7861,7 +9036,7 @@ export default function TrainingTab(props: { onClick={(e) => { e.stopPropagation() if (uiLocked) return - setActiveBoxIndex(index) + setActiveBoxIndex((current) => current === index ? null : index) }} className={[ 'flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1 transition', @@ -7978,8 +9153,278 @@ export default function TrainingTab(props: { ) } + const poseKeypointCorrectionPanel = () => { + const targetIndex = poseCorrectionPersonIndex + const targetPerson = + targetIndex !== null && targetIndex >= 0 ? posePersons[targetIndex] : undefined + const targetPoseLabel = + targetIndex !== null && targetIndex >= 0 ? `Pose #${targetIndex + 1}` : 'Keine Pose ausgewählt' + const visibleKeypointIds = new Set( + (targetPerson?.keypoints ?? []) + .filter(isPoseKeypointVisible) + .map((point) => poseKeypointId(point.name)) + ) + const disabled = uiLocked || frameBusy || trainingRunning || saving || !targetPerson + const faceVisibleCount = POSE_FACE_KEYPOINT_SHAPES.filter((shape) => + visibleKeypointIds.has(shape.keypointName) + ).length + const mapShapes = + poseKeypointMapView === 'head' ? POSE_FACE_KEYPOINT_SHAPES : POSE_BODY_KEYPOINT_SHAPES + const mapAspectRatio = + poseKeypointMapView === 'head' ? POSE_HEAD_MAP_ASPECT_RATIO : POSE_BODY_MAP_ASPECT_RATIO + const mapViewBox = poseKeypointMapViewBoxValue + const mapMarkerRadius = poseKeypointMapView === 'head' ? 6 : 7 + const mapWidthLimitPx = + poseMapSlotHeight > 0 + ? Math.floor(poseMapSlotHeight * POSE_BODY_MAP_ASPECT_RATIO_VALUE) + : 0 + const mapContainerStyle = { + aspectRatio: mapAspectRatio, + ...(mapWidthLimitPx > 0 + ? { + maxHeight: `${poseMapSlotHeight}px`, + width: `min(14.5rem, 86%, ${mapWidthLimitPx}px)`, + } + : {}), + } as CSSProperties + const headPending = + pendingPoseKeypoint?.personIndex === targetIndex && + POSE_FACE_KEYPOINT_SHAPES.some( + (shape) => shape.keypointName === pendingPoseKeypoint.keypointName + ) + const headPresent = faceVisibleCount > 0 + const headActive = headPending || headPresent + const showMapParts = !poseKeypointMapAnimating + + const selectPoseKeypoint = (keypointName: string) => { + if (disabled || targetIndex === null) return + + const pending = + pendingPoseKeypoint?.personIndex === targetIndex && + pendingPoseKeypoint.keypointName === keypointName + + if (pending) { + setPendingPoseKeypoint(null) + return + } + + setShowPoseSkeleton(true) + setActivePosePersonIndex(targetIndex) + setPendingPoseKeypoint({ + personIndex: targetIndex, + keypointName, + }) + } + + return ( +
+
+
+
+
+ Pose-Körperteile +
+ +
+ {targetPerson + ? `${targetPoseLabel}: Punkt wählen und im Bild setzen.` + : visiblePosePersonCount > 1 + ? 'Links erst eine Pose auswählen.' + : 'Keine Pose vorhanden.'} +
+
+ + {targetPerson ? ( + + {visibleKeypointIds.size}/{POSE_KEYPOINT_OPTIONS.length} + + ) : null} +
+ +
+
+
+ + + + {showMapParts && poseKeypointMapView === 'head' ? ( + { + event.preventDefault() + event.stopPropagation() + switchPoseKeypointMapView('body') + }} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + switchPoseKeypointMapView('body') + }} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + switchPoseKeypointMapView('body') + }} + > + Koerperansicht oeffnen + + ) : null} + + {showMapParts && poseKeypointMapView === 'body' ? ( + { + event.preventDefault() + event.stopPropagation() + if (disabled) return + switchPoseKeypointMapView('head') + }} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + if (disabled) return + switchPoseKeypointMapView('head') + }} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + if (disabled) return + switchPoseKeypointMapView('head') + }} + > + Kopf vergrößern + + ) : null} + + {showMapParts && poseKeypointMapView === 'body' ? ( + + ) : null} + + {showMapParts ? mapShapes.map((shape) => { + const present = visibleKeypointIds.has(shape.keypointName) + const pending = + pendingPoseKeypoint?.personIndex === targetIndex && + pendingPoseKeypoint.keypointName === shape.keypointName + + return ( + + ) + }) : null} + + {showMapParts ? mapShapes.map((shape) => { + const keypointName = shape.keypointName + const label = poseKeypointLabel(keypointName) + const present = visibleKeypointIds.has(keypointName) + const pending = + pendingPoseKeypoint?.personIndex === targetIndex && + pendingPoseKeypoint.keypointName === keypointName + const active = present || pending + + return ( + selectPoseKeypoint(keypointName)} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + selectPoseKeypoint(keypointName) + }} + > + {label} + + ) + }) : null} + +
+
+
+
+
+ ) + } + const imageTouchClass = - boxLabel || drawingBox || boxInteraction + boxLabel || drawingBox || boxInteraction || poseInteraction || pendingPoseKeypoint ? 'touch-none' : 'touch-pan-y' @@ -8259,10 +9704,6 @@ export default function TrainingTab(props: {
-
- Bestätige oder korrigiere die Analyse. Jede Antwort wird als Trainingsdatenpunkt gespeichert. -
-
{detectorBoxesPanel({ @@ -8297,18 +9738,6 @@ export default function TrainingTab(props: { ].join(' ')} style={imageStageStyle} > - {hasPosePersons ? ( -
- -
- ) : null} - {imageSrc ? (
- {showPoseSkeleton && imageLayerStyle && hasPosePersons ? (() => { + {poseSkeletonVisible && imageLayerStyle ? (() => { const layerWidth = Number(imageLayerStyle.width) const layerHeight = Number(imageLayerStyle.height) @@ -8379,12 +9808,17 @@ export default function TrainingTab(props: { return ( ) })} @@ -8472,8 +9971,9 @@ export default function TrainingTab(props: { : POSE_UNRELIABLE_COLOR return person.keypoints - .filter(isPoseKeypointVisible) .map((point, pointIndex) => { + if (!isPoseKeypointVisible(point)) return null + const left = poseCoordPx(point.x, layerWidth) const top = poseCoordPx(point.y, layerHeight) const labelToLeft = left > layerWidth * 0.72 @@ -8487,23 +9987,24 @@ export default function TrainingTab(props: { /> ) + const labelToneClass = reliable + ? 'bg-white/95 text-gray-950 ring-black/10 hover:bg-gray-50 dark:bg-white/95 dark:text-gray-950 dark:ring-black/10 dark:hover:bg-gray-50' + : 'bg-white/90 text-slate-700 ring-slate-400/30 hover:bg-gray-50 dark:bg-white/90 dark:text-slate-700 dark:ring-black/10' + const text = ( - + {label} ) return (
- {labelToLeft ? ( - <> - {text} - {dot} - - ) : ( - <> - {dot} - {text} - - )} +
+ {labelToLeft ? ( + <> + + + + ) : ( + <> + + + + )} +
) }) @@ -8640,9 +10218,11 @@ export default function TrainingTab(props: { activePointerIdRef.current = e.pointerId try { + activePointerCaptureElementRef.current = imageBoxRef.current imageBoxRef.current?.setPointerCapture(e.pointerId) } catch { activePointerIdRef.current = null + activePointerCaptureElementRef.current = null } const nextMagnifier: MagnifierState = { @@ -8776,9 +10356,11 @@ export default function TrainingTab(props: { activePointerIdRef.current = e.pointerId try { + activePointerCaptureElementRef.current = imageBoxRef.current imageBoxRef.current?.setPointerCapture(e.pointerId) } catch { activePointerIdRef.current = null + activePointerCaptureElementRef.current = null } const nextMagnifier: MagnifierState = { @@ -8821,7 +10403,7 @@ export default function TrainingTab(props: {
) : null} - {touchMagnifier?.visible && imageSrc && showImageBoxes ? (() => { + {touchMagnifier?.visible && imageSrc && showAnnotationMagnifier ? (() => { const rect = activeImageContentRectRef.current ?? getImageContentRect() if (!rect || rect.width <= 0 || rect.height <= 0) return null @@ -8956,6 +10538,8 @@ export default function TrainingTab(props: { const boxTop = hasUsableBox ? imageTop + activeBox.y * imageHeight : 0 const boxWidth = hasUsableBox ? activeBox.w * imageWidth : 0 const boxHeight = hasUsableBox ? activeBox.h * imageHeight : 0 + const poseMagnifierInteraction = poseInteraction + const showPoseMagnifierSkeleton = poseSkeletonVisible && Boolean(poseMagnifierInteraction) return typeof document !== 'undefined' ? createPortal( @@ -8981,6 +10565,87 @@ export default function TrainingTab(props: { }} /> + {showPoseMagnifierSkeleton ? ( + + {posePersons.map((person, personIndex) => { + if (!person.keypoints?.some(isPoseKeypointVisible)) { + return null + } + + const reliable = isPosePersonReliable(person) + const isActivePose = poseMagnifierInteraction?.personIndex === personIndex + const color = reliable + ? POSE_PERSON_COLORS[personIndex % POSE_PERSON_COLORS.length] + : POSE_UNRELIABLE_COLOR + const keypointsByName = new Map( + person.keypoints.map((point) => [poseKeypointId(point.name), point]) + ) + + return ( + + {POSE_SKELETON_EDGES.map(([from, to]) => { + const fromPoint = keypointsByName.get(from) + const toPoint = keypointsByName.get(to) + + if ( + !isPoseKeypointVisible(fromPoint) || + !isPoseKeypointVisible(toPoint) + ) { + return null + } + + return ( + + ) + })} + + {person.keypoints.map((point, pointIndex) => { + if (!isPoseKeypointVisible(point)) return null + + const isActivePoint = + poseMagnifierInteraction?.personIndex === personIndex && + poseMagnifierInteraction.keypointIndex === pointIndex + + return ( + + ) + })} + + ) + })} + + ) : null} + {hasUsableBox ? (
- mAP50 + {trainingMetricLabel}
- {formatMapPercent(trainingStatus?.training?.map50) || '—'} + {trainingMetricText}
@@ -9310,154 +10975,160 @@ export default function TrainingTab(props: {
-
{ - mobileSectionRefs.current.sexPosition = el - }} - > - - toggleMobileCorrectionSection('sexPosition', expanded) - } - onChange={(value) => - setCorrection((p) => { - if (p.sexPosition !== value) { - setHasManualCorrection(true) + {poseModeActive ? ( + poseKeypointCorrectionPanel() + ) : ( + <> +
{ + mobileSectionRefs.current.sexPosition = el + }} + > + + toggleMobileCorrectionSection('sexPosition', expanded) } + onChange={(value) => + setCorrection((p) => { + if (p.sexPosition !== value) { + setHasManualCorrection(true) + } - return { - ...p, - sexPosition: value, + return { + ...p, + sexPosition: value, + } + }) } - }) - } - disabled={uiLocked} - gridClassName="grid grid-cols-3 gap-2" - /> -
+ disabled={uiLocked} + gridClassName="grid grid-cols-3 gap-2" + /> +
-
{ - mobileSectionRefs.current.people = el - }} - > - - toggleMobileCorrectionSection('people', expanded) - } - onToggle={() => {}} - drawLabel={drawLabelForSection(labels.people)} - onDrawLabelChange={setBoxLabel} - disabled={uiLocked} - singleDrawMode - gridClassName="grid grid-cols-2 gap-2" - /> -
- -
{ - mobileSectionRefs.current.bodyParts = el - }} - > - - toggleMobileCorrectionSection('bodyParts', expanded) - } - onToggle={(value) => - setCorrection((p) => { - setHasManualCorrection(true) - - return { - ...p, - bodyPartsPresent: toggleArrayValue(p.bodyPartsPresent, value), +
{ + mobileSectionRefs.current.people = el + }} + > + + toggleMobileCorrectionSection('people', expanded) } - }) - } - drawLabel={drawLabelForSection(labels.bodyParts)} - onDrawLabelChange={setBoxLabel} - disabled={uiLocked} - /> -
+ onToggle={() => {}} + drawLabel={drawLabelForSection(labels.people)} + onDrawLabelChange={setBoxLabel} + disabled={uiLocked} + singleDrawMode + gridClassName="grid grid-cols-2 gap-2" + /> +
-
{ - mobileSectionRefs.current.objects = el - }} - > - - toggleMobileCorrectionSection('objects', expanded) - } - onToggle={(value) => - setCorrection((p) => { - setHasManualCorrection(true) - - return { - ...p, - objectsPresent: toggleArrayValue(p.objectsPresent, value), +
{ + mobileSectionRefs.current.bodyParts = el + }} + > + + toggleMobileCorrectionSection('bodyParts', expanded) } - }) - } - drawLabel={drawLabelForSection(labels.objects)} - onDrawLabelChange={setBoxLabel} - disabled={uiLocked} - /> -
+ onToggle={(value) => + setCorrection((p) => { + setHasManualCorrection(true) -
{ - mobileSectionRefs.current.clothing = el - }} - > - - toggleMobileCorrectionSection('clothing', expanded) - } - onToggle={(value) => - setCorrection((p) => { - setHasManualCorrection(true) - - return { - ...p, - clothingPresent: toggleArrayValue(p.clothingPresent, value), + return { + ...p, + bodyPartsPresent: toggleArrayValue(p.bodyPartsPresent, value), + } + }) } - }) - } - drawLabel={drawLabelForSection(labels.clothing)} - onDrawLabelChange={setBoxLabel} - disabled={uiLocked} - /> -
+ drawLabel={drawLabelForSection(labels.bodyParts)} + onDrawLabelChange={setBoxLabel} + disabled={uiLocked} + /> +
+ +
{ + mobileSectionRefs.current.objects = el + }} + > + + toggleMobileCorrectionSection('objects', expanded) + } + onToggle={(value) => + setCorrection((p) => { + setHasManualCorrection(true) + + return { + ...p, + objectsPresent: toggleArrayValue(p.objectsPresent, value), + } + }) + } + drawLabel={drawLabelForSection(labels.objects)} + onDrawLabelChange={setBoxLabel} + disabled={uiLocked} + /> +
+ +
{ + mobileSectionRefs.current.clothing = el + }} + > + + toggleMobileCorrectionSection('clothing', expanded) + } + onToggle={(value) => + setCorrection((p) => { + setHasManualCorrection(true) + + return { + ...p, + clothingPresent: toggleArrayValue(p.clothingPresent, value), + } + }) + } + drawLabel={drawLabelForSection(labels.clothing)} + onDrawLabelChange={setBoxLabel} + disabled={uiLocked} + /> +
+ + )}
) : null} @@ -9480,20 +11151,19 @@ export default function TrainingTab(props: { {/* Rechte Details/Korrektur */}