diff --git a/dataset/genuine/original_42_6.png b/dataset/genuine/original_42_6.png new file mode 100644 index 0000000000000000000000000000000000000000..3d0047877ddfee7015d20c2ba809d284e0973111 --- /dev/null +++ b/dataset/genuine/original_42_6.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c339d774a4d79131fe8c61a242250706725eacb7008600452f86fc15ed1bbc7 +size 91299 diff --git a/dataset/genuine/original_42_7.png b/dataset/genuine/original_42_7.png new file mode 100644 index 0000000000000000000000000000000000000000..1acccd22276d1d65518382f09e3fcd07a4abaaf2 --- /dev/null +++ b/dataset/genuine/original_42_7.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1af23f88985a98caa9a25065f4bcf998e638c8b31a14bdd7f59599c1479e1a2 +size 76251 diff --git a/dataset/genuine/original_42_8.png b/dataset/genuine/original_42_8.png new file mode 100644 index 0000000000000000000000000000000000000000..afed7fc482845ac4dd23c2cc8704be5b1abb4af6 --- /dev/null +++ b/dataset/genuine/original_42_8.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8944f54c8e773b84b814b352661c029ef2a8ad89f31470029e543769c7081ec1 +size 105515 diff --git a/dataset/genuine/original_42_9.png b/dataset/genuine/original_42_9.png new file mode 100644 index 0000000000000000000000000000000000000000..4678b5bd11907f761b38a24673ab618bc5d8ff10 --- /dev/null +++ b/dataset/genuine/original_42_9.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:730f4c7bafef9d369cf09e65485e92debd83f499d34cd791ae5dd1868e28f304 +size 78282 diff --git a/dataset/genuine/original_43_1.png b/dataset/genuine/original_43_1.png new file mode 100644 index 0000000000000000000000000000000000000000..20987d84478ef167466dde59a599ac3c157a3a82 --- /dev/null +++ b/dataset/genuine/original_43_1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6be95468b77fc516fc600797bbdcdf14dffbf2b9a77f3fe56ba21de8cfceb4b0 +size 85646 diff --git a/dataset/genuine/original_43_10.png b/dataset/genuine/original_43_10.png new file mode 100644 index 0000000000000000000000000000000000000000..735b48492ae98f5fde4e07690ce542bebf8f3ea5 --- /dev/null +++ b/dataset/genuine/original_43_10.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:687a1287f156b505b61deac61be26ba1777596df56262521a6bf6844254a935f +size 91250 diff --git a/dataset/genuine/original_43_11.png b/dataset/genuine/original_43_11.png new file mode 100644 index 0000000000000000000000000000000000000000..d18d42ec6fc46b78c170757a13f521869205aa28 --- /dev/null +++ b/dataset/genuine/original_43_11.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fda47e5eea0eb2c9d12c669a64ff8389ffb1041702849996a9935f044534bee0 +size 94100 diff --git a/dataset/genuine/original_43_12.png b/dataset/genuine/original_43_12.png new file mode 100644 index 0000000000000000000000000000000000000000..5ffb61fd351ff50f0d0cae754ffb835867cc64b4 --- /dev/null +++ b/dataset/genuine/original_43_12.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f355c157cb0dbd7ece5a4f85a52e184a35457b3368c41c3bcd0381b2efd06c5 +size 79865 diff --git a/dataset/genuine/original_43_13.png b/dataset/genuine/original_43_13.png new file mode 100644 index 0000000000000000000000000000000000000000..39ac7592eba4ea8d3824ef6fb1d18ffe1428639d --- /dev/null +++ b/dataset/genuine/original_43_13.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:822c6451724666e2f4cecb288eefbf93fd12dd3a20d097c724537ef0d2c94bb5 +size 94851 diff --git a/dataset/genuine/original_43_14.png b/dataset/genuine/original_43_14.png new file mode 100644 index 0000000000000000000000000000000000000000..b89ac7a72878bb3650c64cdd47aa6338037a86c4 --- /dev/null +++ b/dataset/genuine/original_43_14.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15e1782b0f46c6ab5221a4df5730ff89beb0cbee127e3ab938c96ccf5f9c3ac9 +size 103283 diff --git a/dataset/genuine/original_43_15.png b/dataset/genuine/original_43_15.png new file mode 100644 index 0000000000000000000000000000000000000000..c3afc85c65ee80d14cab7646dd55c6a1f2f62165 --- /dev/null +++ b/dataset/genuine/original_43_15.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc8ff31795df68abf986b3c722f40c6caf5f49cecd7b274337f557e3743743ee +size 75127 diff --git a/dataset/genuine/original_43_16.png b/dataset/genuine/original_43_16.png new file mode 100644 index 0000000000000000000000000000000000000000..0f7542d9c3fe8ae7c150beded8860df3ba8f1ee0 --- /dev/null +++ b/dataset/genuine/original_43_16.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c45b1f13ef5ffc5746f12dd0137158326489f5de2bc31f8de7bf6257d2348b32 +size 81329 diff --git a/dataset/genuine/original_43_17.png b/dataset/genuine/original_43_17.png new file mode 100644 index 0000000000000000000000000000000000000000..a90a5ebe2d90da6091ef0b69f3f673c8f57e5dfc --- /dev/null +++ b/dataset/genuine/original_43_17.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1eba68ca30c4326f08f1b7e9db3f3c48b4b4db14aa8b86c0d4a4b8ce1eff6a59 +size 78871 diff --git a/dataset/genuine/original_43_18.png b/dataset/genuine/original_43_18.png new file mode 100644 index 0000000000000000000000000000000000000000..689ba99cf879500045abab984ee98aaf37e744a8 --- /dev/null +++ b/dataset/genuine/original_43_18.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:235cc3b65c08c6fe2db69a22f5174f3e80ac80dc1cf9ab28e0f36c269554be82 +size 102219 diff --git a/dataset/genuine/original_43_19.png b/dataset/genuine/original_43_19.png new file mode 100644 index 0000000000000000000000000000000000000000..6405f9d95abc746542ba73d63d47e270b1a938a7 --- /dev/null +++ b/dataset/genuine/original_43_19.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b92695b127f19cf27303c5426900395e6b63f6f8bdda9f171f953b14578bc4d3 +size 77446 diff --git a/dataset/genuine/original_43_2.png b/dataset/genuine/original_43_2.png new file mode 100644 index 0000000000000000000000000000000000000000..7b4149398e6aa79b4bd829a1e76682b230bf1bc2 --- /dev/null +++ b/dataset/genuine/original_43_2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4e00df7a276d270e54fed709601a9c6ac605d898a53c6dab5b3e3fecbf6217e +size 82657 diff --git a/dataset/genuine/original_43_20.png b/dataset/genuine/original_43_20.png new file mode 100644 index 0000000000000000000000000000000000000000..250f36a2c327f5b16f8f82f08c7fe4f01f786964 --- /dev/null +++ b/dataset/genuine/original_43_20.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c8fbee3df1cf6055ad1a819b6f20b6424b38343f1aa0f9bd24da3bbce86528d8 +size 71385 diff --git a/dataset/genuine/original_43_21.png b/dataset/genuine/original_43_21.png new file mode 100644 index 0000000000000000000000000000000000000000..d00b6fc712104d18c083d7e1499ff3edc7b0b461 --- /dev/null +++ b/dataset/genuine/original_43_21.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5ed3dc9445788b39fc571fe56fcd2d3f0a76b7b5061dd7d8f518ed7ed8dc8783 +size 84837 diff --git a/dataset/genuine/original_43_22.png b/dataset/genuine/original_43_22.png new file mode 100644 index 0000000000000000000000000000000000000000..6f3fb2a044f00ca5fc8bbda6fd50915fc39638f4 --- /dev/null +++ b/dataset/genuine/original_43_22.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0160275c3fd8ebb4480b19e6023c6c93234dde53c8142b875d899bc8cd1acc24 +size 89699 diff --git a/dataset/genuine/original_43_23.png b/dataset/genuine/original_43_23.png new file mode 100644 index 0000000000000000000000000000000000000000..165269a5febf1fff6b0e2520b0f12bc538b00445 --- /dev/null +++ b/dataset/genuine/original_43_23.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba36d5d45bc989b23fad613b037631e0c46f7e9e3996f704889557997a860ffe +size 82057 diff --git a/dataset/genuine/original_43_24.png b/dataset/genuine/original_43_24.png new file mode 100644 index 0000000000000000000000000000000000000000..b76b3af57fa408b8011e508c416086c0ac40344e --- /dev/null +++ b/dataset/genuine/original_43_24.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80d19b5e5c536496557ad272ddcaff0ac08ad202ef15d9671ab4c789e8de2f3e +size 97963 diff --git a/dataset/genuine/original_43_3.png b/dataset/genuine/original_43_3.png new file mode 100644 index 0000000000000000000000000000000000000000..68336672f6bf453d41d27beac7d92897d84e0312 --- /dev/null +++ b/dataset/genuine/original_43_3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1162c6ceef3fd809a4c386c2f8e3e4a5d8354397b6728d711358c6a40b94ca1a +size 75445 diff --git a/dataset/genuine/original_43_4.png b/dataset/genuine/original_43_4.png new file mode 100644 index 0000000000000000000000000000000000000000..4a5508d44fb08f00ba033a66538c76b8f555d0e6 --- /dev/null +++ b/dataset/genuine/original_43_4.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4d4a13ca2323f005499b581f921a38ed871655f07ed3b5171852305b1fc9ade1 +size 85275 diff --git a/dataset/genuine/original_43_5.png b/dataset/genuine/original_43_5.png new file mode 100644 index 0000000000000000000000000000000000000000..a590af290e0d8e06e92ce4af85de134e7e9bde59 --- /dev/null +++ b/dataset/genuine/original_43_5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98e03dc1d0e56650725d3179e11d7882e9971409c002df7ac9cd213b5b454c22 +size 81961 diff --git a/dataset/genuine/original_43_6.png b/dataset/genuine/original_43_6.png new file mode 100644 index 0000000000000000000000000000000000000000..8aeb1c6ec6239a9ae60e62f2b494363f665900a9 --- /dev/null +++ b/dataset/genuine/original_43_6.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7a6100535dfc3ea7f3c4107b934dc3cba108517ca0c8c00f0c860dcff5c2ea32 +size 82984 diff --git a/dataset/genuine/original_43_7.png b/dataset/genuine/original_43_7.png new file mode 100644 index 0000000000000000000000000000000000000000..f58cc1a2b8769bbbd9a068028edfa792eaf9ee9d --- /dev/null +++ b/dataset/genuine/original_43_7.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:954416dacea4257581ac2d1e584eedf26c94bab3349e6f9ea60a70f58c911b4d +size 66382 diff --git a/dataset/genuine/original_43_8.png b/dataset/genuine/original_43_8.png new file mode 100644 index 0000000000000000000000000000000000000000..7b2e946034a446f827600e64a5693d1fb07bc003 --- /dev/null +++ b/dataset/genuine/original_43_8.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c0331fc3c1fe85ecf09c64004c89dccfbde6ca183d0abc3c792f5da5e79babc +size 74503 diff --git a/dataset/genuine/original_43_9.png b/dataset/genuine/original_43_9.png new file mode 100644 index 0000000000000000000000000000000000000000..e27da6726b15a18a3426e21f465e5ed071a9ebe6 --- /dev/null +++ b/dataset/genuine/original_43_9.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:37e35bd9f4a9bb2da17f5968bbe363c9eda79ae5ba622f439649f9f673764d7b +size 77898 diff --git a/dataset/genuine/original_44_1.png b/dataset/genuine/original_44_1.png new file mode 100644 index 0000000000000000000000000000000000000000..61dcdd6df02c2422ab5a3964e630614e6e89f04e --- /dev/null +++ b/dataset/genuine/original_44_1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0235a5e7677b2883a6a2bf890ff83ad01a73f7f08b841181fff099455545e769 +size 65369 diff --git a/dataset/genuine/original_44_10.png b/dataset/genuine/original_44_10.png new file mode 100644 index 0000000000000000000000000000000000000000..de64b9cb97b83e1db780ecc69fff2d9dbdf00cac --- /dev/null +++ b/dataset/genuine/original_44_10.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7fdce9d9fadbb09e79411962ec8c2a36ef110def0014ad7cbe2ad8b6ec642550 +size 57016 diff --git a/dataset/genuine/original_44_11.png b/dataset/genuine/original_44_11.png new file mode 100644 index 0000000000000000000000000000000000000000..38e42c56766e7fafc972ac5f1c141c04545130c1 --- /dev/null +++ b/dataset/genuine/original_44_11.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4bc466a8f633b2880e8b6c0a432e7cb4946dc67c23edaed50688cf8f85294a9 +size 60891 diff --git a/dataset/genuine/original_44_12.png b/dataset/genuine/original_44_12.png new file mode 100644 index 0000000000000000000000000000000000000000..8aab3419f7902a7dd44306ae36a217755ab73450 --- /dev/null +++ b/dataset/genuine/original_44_12.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d2e256c2523135f87e04d63f63b2655fbd746ec89eb9b82b7516a594bc5aa563 +size 67146 diff --git a/dataset/genuine/original_44_13.png b/dataset/genuine/original_44_13.png new file mode 100644 index 0000000000000000000000000000000000000000..9a1f5c1cce79f1dc8c3783b9ea5aa02364c41cb9 --- /dev/null +++ b/dataset/genuine/original_44_13.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:816572178541f84ce4b78d2e7e097eb1eec9466601efff0b8e84c2c7496adb6d +size 52646 diff --git a/dataset/genuine/original_44_14.png b/dataset/genuine/original_44_14.png new file mode 100644 index 0000000000000000000000000000000000000000..1ca7afd6795b63e69dfdf1502eeaecd2c994d448 --- /dev/null +++ b/dataset/genuine/original_44_14.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c305feba125c25dd994570f3baefed1dcb02dfcc9cb273c946fafb6b0a794aad +size 43929 diff --git a/dataset/genuine/original_44_15.png b/dataset/genuine/original_44_15.png new file mode 100644 index 0000000000000000000000000000000000000000..81191b30b4e132c2c58063cfded58a9438e2239f --- /dev/null +++ b/dataset/genuine/original_44_15.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d0b365076cd0e990471b896ec88793944aae0852027ba2cf8a91a038f9501fa +size 55263 diff --git a/dataset/genuine/original_44_16.png b/dataset/genuine/original_44_16.png new file mode 100644 index 0000000000000000000000000000000000000000..f2213b9ce638d910d94bb779d5392fd4f0df0977 --- /dev/null +++ b/dataset/genuine/original_44_16.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:792f991421ffabe12f60d8b4b61611b41a7f3925d0f48e565e21f69b3178c85a +size 48341 diff --git a/dataset/genuine/original_44_17.png b/dataset/genuine/original_44_17.png new file mode 100644 index 0000000000000000000000000000000000000000..8d758cbd33203a74b835b22d2d9367c5b1231597 --- /dev/null +++ b/dataset/genuine/original_44_17.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee485688d3d4a728feea82ecf38bfa348d81870e48154da501e076ab8c877cab +size 58716 diff --git a/dataset/genuine/original_44_18.png b/dataset/genuine/original_44_18.png new file mode 100644 index 0000000000000000000000000000000000000000..2bc283921f024bf042001a6182838eac4d1d365e --- /dev/null +++ b/dataset/genuine/original_44_18.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63c8056e3ec6c71430a92ad4c87f7da89df9f124a4a33ddf73040f6c0f4acd3a +size 65007 diff --git a/dataset/genuine/original_44_19.png b/dataset/genuine/original_44_19.png new file mode 100644 index 0000000000000000000000000000000000000000..24dd32c5fada4b866942e0f7110c56024d34adff --- /dev/null +++ b/dataset/genuine/original_44_19.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:490ee8dbe178536ec87940891cfccaf2555ea6232a9db06995eb09388a7d7267 +size 63832 diff --git a/dataset/genuine/original_44_2.png b/dataset/genuine/original_44_2.png new file mode 100644 index 0000000000000000000000000000000000000000..2ebecdb173a3a2e1b01137371377c79024286d0d --- /dev/null +++ b/dataset/genuine/original_44_2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f76edd19202caae86fc0869357162bb6924072945964cb8396c68dfbc7b220a +size 72897 diff --git a/dataset/genuine/original_44_20.png b/dataset/genuine/original_44_20.png new file mode 100644 index 0000000000000000000000000000000000000000..4c6300d197b916cacdde3a78bfb31c535f88c98e --- /dev/null +++ b/dataset/genuine/original_44_20.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0b1b4664c83ddac0945fb97b43624d79fb29cf62dd8a512a554e328b913df299 +size 48879 diff --git a/dataset/genuine/original_44_21.png b/dataset/genuine/original_44_21.png new file mode 100644 index 0000000000000000000000000000000000000000..2f84207551c3824f2e61c2bfc1d7a0fe7e633583 --- /dev/null +++ b/dataset/genuine/original_44_21.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1366e2f07576a0af3c69b4c956146d4043be8682405f3b152186b6c6e23c0113 +size 60579 diff --git a/dataset/genuine/original_44_22.png b/dataset/genuine/original_44_22.png new file mode 100644 index 0000000000000000000000000000000000000000..0574773950fc15aa3a4b7fc997c02abb7ff775f0 --- /dev/null +++ b/dataset/genuine/original_44_22.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8dc22282b288de504e057db373ff68c74d06f31af81bd1303cabe2d660fcd4e2 +size 65667 diff --git a/dataset/genuine/original_44_23.png b/dataset/genuine/original_44_23.png new file mode 100644 index 0000000000000000000000000000000000000000..e669e9fc09b83ff9c1974367982f65c156400ee8 --- /dev/null +++ b/dataset/genuine/original_44_23.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe9a370f4b1fc48f1135e4a5661ccb02b3c9cee513e6dde804c8bb16b31a1f01 +size 69292 diff --git a/dataset/genuine/original_44_24.png b/dataset/genuine/original_44_24.png new file mode 100644 index 0000000000000000000000000000000000000000..62d3599686f4a6e9b1fa10518d31e8a387978a28 --- /dev/null +++ b/dataset/genuine/original_44_24.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ab6d09f72bc935b30fd5f6547c5101439d457ae3aed9f5ba37eba0cb487cbac +size 49349 diff --git a/scripts/build_degradation_slides.py b/scripts/build_degradation_slides.py new file mode 100644 index 0000000000000000000000000000000000000000..f61ccc35e52ce9616badf3ea3c8814fdcb67593d --- /dev/null +++ b/scripts/build_degradation_slides.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python +"""Append a before/after slide per augmentation effect, plus normal- and +extreme-case multi-degradation slides, to Metrics_Thresholds_Formulas.pptx. + +Reuses the assets written by generate_before_after_assets.py and +generate_case_assets.py (run those first). New slides replicate the deck's +existing visual language exactly (navy header bar, orange accent line, teal +section label, Calibri/Consolas body text, bottom-right page number) so they +read as part of the same deck rather than a bolt-on. + +The new slides are inserted as a new section 8 ("Degradation gallery") and +section 9 ("Multi-degradation cases"), placed right before the closing "Key +takeaways" slide, and the Agenda slide (slide 2) is extended with the two new +entries. A one-time backup of the original file is kept alongside it. + +Usage: + python scripts/build_degradation_slides.py +""" + +from __future__ import annotations + +import copy +import json +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_SHAPE +from pptx.enum.text import MSO_ANCHOR, PP_ALIGN +from pptx.util import Emu, Pt +from PIL import Image + +from scripts.generate_before_after_assets import ( + ALL_EFFECTS, DISPLAY_NAME, ZOOM_EFFECTS, +) +from scripts.generate_multiple_degradation_extreme_gallery import _fmt_effect + +ROOT = Path(__file__).resolve().parents[1] +PPTX_PATH = ROOT / "Metrics_Thresholds_Formulas.pptx" +BACKUP_PATH = ROOT / "Metrics_Thresholds_Formulas.backup.pptx" +DEG_ROOT = ROOT / "degradation" + +# ---------------------------------------------------------------- palette -- +NAVY, TEAL, ORANGE = "0F2A43", "1B7A7A", "E07A2C" +LIGHT_TEAL, GRAY, LIGHT_BG, TEXT_DARK, WHITE = "9BC5C5", "6B767C", "F4F7F8", "222A30", "FFFFFF" + + +def rgb(hexstr: str) -> RGBColor: + return RGBColor.from_string(hexstr) + + +# ------------------------------------------------------------- geometry --- +SLIDE_W, SLIDE_H = Emu(12191695), Emu(6858000) +HEADER_H, ACCENT_H = Emu(1051560), Emu(38100) +TITLE_X = Emu(502920) +CONTENT_X, CONTENT_W = Emu(640080), Emu(10881360) +FOOTER_X, FOOTER_Y = Emu(11475720), Emu(6510528) +FOOTER_W, FOOTER_H = Emu(640080), Emu(274320) + +LEFT_X, RIGHT_X = Emu(640080), Emu(6263640) +COL_W = Emu(5257800) +LABEL_Y, LABEL_H = Emu(1554480), Emu(365760) +IMG_Y = Emu(LABEL_Y + LABEL_H + Emu(91440)) + +CAPTION_Y = Emu(1150000) +CAPTION_H = Emu(320040) + +# ------------------------------------------------------ formula captions -- +FORMULAS = { + "rotation": "extra_deg = U(-1,1) × strength × 8.0", + "stroke_dilation": "ksize = round(1 + strength×5) [odd]", + "ink_fading": "mult = clip(1 − strength×(1−field)×0.9, .05, 1)", + "ink_bleed": "ksize = round(1+strength×4); halo floor = .35+.35×strength", + "motion_blur": "length = round(3 + strength×16)", + "gaussian_blur": "sigma = 0.2 + strength×2.2", + "perspective": "scale = 0.01 + strength×0.09", + "contrast": "contrast_limit = ±(strength×0.45)", + "gamma": "delta = round(strength×45); gamma_limit = (100−delta, 100+delta), lo≥40", + "coffee_stain": "n_blobs = 1+randint(0,1+round(strength×3)); opacity = .10+.30×strength", + "stain": "radius = (.06+.16×strength)×min(w,h); opacity scales with strength", + "shadow": "darkness = 0.15+0.55×strength; band/radius/fold-width scale with strength", + "crumple": "amplitude = strength×min(w,h)×0.015", + "discoloration": "tint strength scales with strength per tone", + "page_tilt": "angle = U(-1,1) × (1.5 + strength×6.5)", + "dust": "n_specks = (h×w)×0.00003×(1+strength×6)", + "scanner_streaks": "n_streaks = 1+int(strength×6)", + "scanner_noise": "GaussNoise std = 0.01 + strength×0.12", + "salt_pepper": "amount = 0.002 + strength×0.09", + "jpeg_compression": "quality = randint(min_quality, max_quality) — NOT driven by strength", +} + + +def short_list(names: list[str], limit: int = 8) -> str: + if len(names) <= limit: + return ", ".join(names) if names else "none" + return ", ".join(names[:limit]) + f", +{len(names) - limit} more" + + +# ----------------------------------------------------------- primitives --- +def add_rect(slide, x, y, w, h, color: str): + shp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h) + shp.fill.solid() + shp.fill.fore_color.rgb = rgb(color) + shp.line.fill.background() + return shp + + +def add_text( + slide, x, y, w, h, text, size, color, *, bold=False, font="Calibri", + align=PP_ALIGN.LEFT, anchor=None, word_wrap=True, +): + tb = slide.shapes.add_textbox(x, y, w, h) + tf = tb.text_frame + tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0 + tf.word_wrap = word_wrap + if anchor is not None: + tf.vertical_anchor = anchor + lines = text.split("\n") + for i, line in enumerate(lines): + p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() + p.alignment = align + run = p.add_run() + run.text = line + run.font.size = Pt(size) + run.font.bold = bold + run.font.name = font + run.font.color.rgb = rgb(color) + return tb + + +def add_bg(slide): + add_rect(slide, Emu(0), Emu(0), SLIDE_W, SLIDE_H, WHITE) + + +def add_header(slide, section_label: str, title: str, page_num: int): + add_rect(slide, Emu(0), Emu(0), SLIDE_W, HEADER_H, NAVY) + add_rect(slide, Emu(0), HEADER_H, SLIDE_W, ACCENT_H, ORANGE) + add_text(slide, TITLE_X, Emu(128016), Emu(9144000), Emu(274320), section_label, 12, LIGHT_TEAL, bold=True) + add_text(slide, TITLE_X, Emu(384048), Emu(10515600), Emu(594360), title, 28, WHITE, bold=True) + add_text( + slide, FOOTER_X, FOOTER_Y, FOOTER_W, FOOTER_H, str(page_num), 11, GRAY, + align=PP_ALIGN.RIGHT, + ) + + +def fit_picture(slide, img_path: Path, box_x, box_y, box_w, box_h): + iw, ih = Image.open(img_path).size + scale = min(box_w / iw, box_h / ih) + disp_w, disp_h = int(iw * scale), int(ih * scale) + left = int(box_x + (box_w - disp_w) / 2) + top = int(box_y + (box_h - disp_h) / 2) + slide.shapes.add_picture(str(img_path), left, top, width=disp_w, height=disp_h) + + +def add_panel_label(slide, x, color, text): + add_rect(slide, x, LABEL_Y, COL_W, LABEL_H, color) + add_text( + slide, x, LABEL_Y, COL_W, LABEL_H, text, 14, WHITE, bold=True, + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, word_wrap=False, + ) + + +# -------------------------------------------------------------- slides ---- +def build_before_after_slide(prs, layout, effect: str, page_num: int, manifest: dict): + slide = prs.slides.add_slide(layout) + add_bg(slide) + add_header(slide, "8. DEGRADATION GALLERY", DISPLAY_NAME[effect], page_num) + + rec = manifest[effect] + resolved = _fmt_effect(effect, {"strength": rec["strength"], "params": rec["params"] or {}}) + caption = ( + f"{FORMULAS[effect]} | resolved: {resolved} | " + f"difficulty after = {rec['difficulty_score']:.3f} ({rec['difficulty_tier']})" + ) + add_text(slide, CONTENT_X, CAPTION_Y, CONTENT_W, CAPTION_H, caption, 12, GRAY, font="Consolas") + + add_panel_label(slide, LEFT_X, TEAL, "BEFORE - clean render") + add_panel_label(slide, RIGHT_X, ORANGE, f"AFTER - {DISPLAY_NAME[effect]} (strength={rec['strength']:.2f})") + + img_bottom = Emu(6100000) + img_h = Emu(img_bottom - IMG_Y) + before_name = "before_zoom.png" if effect in ZOOM_EFFECTS else "before.png" + after_name = "after_zoom.png" if effect in ZOOM_EFFECTS else "after.png" + fit_picture(slide, DEG_ROOT / effect / before_name, LEFT_X, IMG_Y, COL_W, img_h) + fit_picture(slide, DEG_ROOT / effect / after_name, RIGHT_X, IMG_Y, COL_W, img_h) + return slide + + +def build_case_slide(prs, layout, kind: str, page_num: int): + slide = prs.slides.add_slide(layout) + add_bg(slide) + title = "Normal-severity combinations" if kind == "normal" else "Extreme-severity combinations" + add_header(slide, "9. MULTI-DEGRADATION CASES", title, page_num) + + if kind == "normal": + folder = ROOT / "multiple_degradation" / "case_assets" + subtitle = ( + "configs/generate.yaml -- every effect independently rolls its own realistic " + "probability per sample (mixed, moderate combinations)" + ) + color = TEAL + else: + folder = ROOT / "multiple_degradation_extreme" / "case_assets" + subtitle = ( + "worst case -- every effect probability=0.85, strength 0.7-1.0, plus heavy " + "misplacement / faint+blurred ink (see scripts/generate_multiple_degradation_extreme_gallery.py)" + ) + color = ORANGE + + manifest = json.loads((folder / "case_manifest.json").read_text()) + add_text(slide, CONTENT_X, CAPTION_Y, CONTENT_W, CAPTION_H, subtitle, 12, GRAY, font="Consolas") + + img_bottom = Emu(5850000) + img_h = Emu(img_bottom - IMG_Y) + cap_y = Emu(img_bottom + Emu(45720)) + cap_h = Emu(600000) + + for i, x in enumerate((LEFT_X, RIGHT_X)): + key = f"case_{i + 1}" + rec = manifest[key] + add_panel_label(slide, x, color, f"Sample {i + 1} - {rec['n_applied']}/{rec['n_total']} effects applied") + fit_picture(slide, folder / f"{key}.png", x, IMG_Y, COL_W, img_h) + cap = ( + f"difficulty = {rec['difficulty_score']:.3f} ({rec['difficulty_tier']}) | " + f"placement: rot={rec['placement']['rotation_deg']:.1f} deg " + f"scale={rec['placement']['scale_factor']:.2f} misplaced={rec['placement']['misplaced']}\n" + f"applied: {short_list(rec['applied_effects'])}" + ) + add_text(slide, x, cap_y, COL_W, cap_h, cap, 10, TEXT_DARK, font="Consolas") + return slide + + +# ---------------------------------------------------------- agenda edit --- +def extend_agenda(prs): + slide = prs.slides[1] + target = None + for shape in slide.shapes: + if shape.name == "TextBox 7": + target = shape + break + if target is None: + print("WARNING: could not find agenda TextBox 7 -- skipping agenda update") + return + + tf = target.text_frame + n_original = len(tf.paragraphs) + last_p = tf.paragraphs[-1]._p + new_texts = ( + "8. Degradation gallery — before & after, all 20 effects", + "9. Multi-degradation cases — normal vs. extreme severity", + ) + for text in new_texts: + new_p = copy.deepcopy(last_p) + run_elm = new_p.findall(".//{http://schemas.openxmlformats.org/drawingml/2006/main}t") + run_elm[0].text = text + last_p.addnext(new_p) + last_p = new_p + + # Shape height doesn't clip a top-anchored textbox -- text simply overflows past + # it -- so what actually matters is total rendered text height staying inside the + # slide. 9 lines at the original 22pt ran ~24pt past the bottom edge, so shrink + # every run down to 20pt (measured to comfortably clear the slide bottom). + for para in tf.paragraphs: + for run in para.runs: + run.font.size = Pt(20) + + +# ---------------------------------------------------------------- main ---- +def main(): + if not BACKUP_PATH.exists(): + shutil.copy2(PPTX_PATH, BACKUP_PATH) + print(f"Backed up original deck -> {BACKUP_PATH}") + + manifest = json.loads((DEG_ROOT / "before_after_manifest.json").read_text()) + + prs = Presentation(str(PPTX_PATH)) + layout = prs.slides[2].slide_layout # "Blank" layout used by all content slides + + sld_id_lst = prs.slides._sldIdLst + closing_sld_id = sld_id_lst[-1] # "Key takeaways" slide, must stay last + + extend_agenda(prs) + + page_num = 19 + for effect in ALL_EFFECTS: + build_before_after_slide(prs, layout, effect, page_num, manifest) + page_num += 1 + + build_case_slide(prs, layout, "normal", page_num) + page_num += 1 + build_case_slide(prs, layout, "extreme", page_num) + page_num += 1 + + sld_id_lst.remove(closing_sld_id) + sld_id_lst.append(closing_sld_id) + + prs.save(str(PPTX_PATH)) + print(f"Saved {PPTX_PATH} -- {len(prs.slides.__iter__.__self__._sldIdLst)} slides total") + + +if __name__ == "__main__": + main() diff --git a/scripts/degrade_cli.py b/scripts/degrade_cli.py new file mode 100644 index 0000000000000000000000000000000000000000..9535a6aed3b2d4de930af5fa53a4d4dadfaa9636 --- /dev/null +++ b/scripts/degrade_cli.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python +"""Interactive terminal wizard for generating synthetic signed documents with +hand-picked degradation effects. + +A friendlier front end over main.py's --set override system: arrow-key menus +and checkboxes instead of memorizing config dot-paths. Same pipeline underneath +(GeneratorContext), so output is identical to what main.py would produce with +the equivalent --set flags. + +Usage: + python scripts/degrade_cli.py +""" + +from __future__ import annotations + +import json +import os +import sys +from datetime import datetime +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import questionary +from questionary import Choice, Separator +from rich.console import Console +from rich.panel import Panel +from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn +from rich.table import Table + +from src.config.loader import load_config +from src.generator import GeneratorContext +from src.templates.template_loader import TemplateLoader +from src.utils.image_io import save_image_rgb +from src.visualization.visualizer import save_visualization +from scripts.generate_before_after_assets import ALL_EFFECTS, DISPLAY_NAME, DOCUMENT_EFFECTS, SIGNATURE_EFFECTS +from scripts.generate_multiple_degradation_extreme_gallery import build_overrides as extreme_overrides + +ROOT = Path(__file__).resolve().parents[1] +console = Console() + +# ---- palette, matched to Metrics_Thresholds_Formulas.pptx ----------------- +NAVY, TEAL, ORANGE, GRAY = "#0F2A43", "#1B7A7A", "#E07A2C", "#6B767C" + +QSTYLE = questionary.Style([ + ("qmark", f"fg:{ORANGE} bold"), + ("question", "bold"), + ("answer", f"fg:{TEAL} bold"), + ("pointer", f"fg:{ORANGE} bold"), + ("highlighted", f"fg:{TEAL} bold"), + ("selected", f"fg:{TEAL}"), + ("instruction", f"fg:{GRAY}"), + ("separator", f"fg:{GRAY}"), +]) + + +def banner() -> None: + console.print(Panel.fit( + "[bold white]Synthetic Signed Document Generator[/bold white]\n" + f"[{TEAL}]pick degradations -> generate -> inspect[/{TEAL}]", + border_style=TEAL, padding=(1, 4), + )) + + +def discover_templates() -> list[tuple[str, str]]: + cfg = load_config() + loader = TemplateLoader(cfg.paths.image_dir, cfg.paths.annotations_dir) + loader.discover() + return sorted((tid, t.annotation.document_type) for tid, t in loader._templates.items()) + + +def valid_float01(v: str) -> "bool | str": + try: + f = float(v) + except ValueError: + return "Enter a number between 0.0 and 1.0" + return True if 0.0 <= f <= 1.0 else "Must be between 0.0 and 1.0" + + +def valid_posint(v: str) -> "bool | str": + return True if v.isdigit() and int(v) > 0 else "Enter a positive whole number" + + +# ------------------------------------------------------------- questions --- +def ask_mode() -> str: + return questionary.select( + "How should degradations be applied?", + choices=[ + Choice(title="Pick effects - isolated (ONLY the chosen effects fire, nothing else)", value="isolate"), + Choice(title="Pick effects - mixed (chosen effects forced on, others may also roll)", value="mixed"), + Choice(title="Preset - normal severity (realistic random mix, configs/generate.yaml)", value="normal"), + Choice(title="Preset - extreme severity (worst case, everything cranked)", value="extreme"), + ], + style=QSTYLE, + ).ask() + + +def ask_template() -> "str | None": + templates = discover_templates() + choices = [Choice(title="Random each sample", value=None)] + choices += [Choice(title=f"{tid} ({doc_type})", value=tid) for tid, doc_type in templates] + return questionary.select("Which document template?", choices=choices, style=QSTYLE).ask() + + +def ask_effects() -> list[str]: + choices = [Separator("-- signature-level --")] + choices += [Choice(title=DISPLAY_NAME[e], value=e) for e in SIGNATURE_EFFECTS] + choices += [Separator("-- document-level --")] + choices += [Choice(title=DISPLAY_NAME[e], value=e) for e in DOCUMENT_EFFECTS] + selected = questionary.checkbox( + "Pick degradations (space to toggle, enter to confirm)", choices=choices, style=QSTYLE, + ).ask() + if selected is None: + raise KeyboardInterrupt + if not selected: + console.print(f"[{ORANGE}]Pick at least one effect.[/{ORANGE}]") + return ask_effects() + return selected + + +def ask_strengths(effects: list[str]) -> dict[str, float]: + global_raw = questionary.text( + "Strength for all selected effects (0.0-1.0)", default="0.75", + validate=valid_float01, style=QSTYLE, + ).ask() + global_strength = float(global_raw) + strengths = {e: global_strength for e in effects} + if questionary.confirm("Customize strength per effect?", default=False, style=QSTYLE).ask(): + for e in effects: + raw = questionary.text( + f" {DISPLAY_NAME[e]} strength", default=str(global_strength), + validate=valid_float01, style=QSTYLE, + ).ask() + strengths[e] = float(raw) + return strengths + + +def ask_count() -> int: + raw = questionary.text("How many samples?", default="3", validate=valid_posint, style=QSTYLE).ask() + return int(raw) + + +def ask_save_viz() -> bool: + return questionary.confirm( + "Also save the debug-panel visualization (crop / mask / metrics / difficulty)?", + default=True, style=QSTYLE, + ).ask() + + +def ask_output_dir() -> Path: + default = f"output/interactive_{datetime.now():%Y%m%d_%H%M%S}" + raw = questionary.text("Output folder", default=default, style=QSTYLE).ask() + return ROOT / raw + + +# ------------------------------------------------------------ overrides ---- +def build_pick_overrides(mode: str, template: "str | None", effects: list[str], strengths: dict[str, float]) -> list[str]: + overrides = ["generation.run_validation=false"] + if template: + overrides.append(f"document.template={template}") + + if mode == "isolate": + for name in ALL_EFFECTS: + on = name in effects + overrides.append(f"augmentation.{name}.enabled={'true' if on else 'false'}") + overrides.append(f"augmentation.{name}.probability={'1.0' if on else '0.0'}") + for name in effects: + overrides.append(f"augmentation.{name}.strength={strengths[name]}") + else: # mixed + for name in effects: + overrides.append(f"augmentation.{name}.enabled=true") + overrides.append(f"augmentation.{name}.probability=1.0") + overrides.append(f"augmentation.{name}.strength={strengths[name]}") + return overrides + + +# ---------------------------------------------------------------- report --- +def show_plan(mode: str, template, effects, strengths, count: int, out_dir: Path) -> None: + table = Table(title="Plan", title_style=f"bold {NAVY}", show_header=False, border_style=GRAY) + table.add_row("Mode", mode) + table.add_row("Template", template or "random per sample") + if effects: + table.add_row("Effects", ", ".join(f"{DISPLAY_NAME[e]} ({strengths[e]:.2f})" for e in effects)) + table.add_row("Samples", str(count)) + table.add_row("Output", str(out_dir)) + console.print(table) + + +def show_results(results: list[tuple[str, float, str, list[str]]], out_dir: Path) -> None: + table = Table(title="Results", border_style=GRAY, header_style=f"bold white on {NAVY}") + table.add_column("Sample") + table.add_column("Difficulty") + table.add_column("Tier") + table.add_column("Effects applied") + tier_color = {"easy": "green", "medium": ORANGE, "hard": "red"} + for sid, score, tier, applied in results: + table.add_row( + sid, f"{score:.3f}", f"[{tier_color.get(tier, 'white')}]{tier}[/]", + ", ".join(applied) if applied else "[dim]none[/dim]", + ) + console.print(table) + console.print(f"\n[{TEAL}]Wrote {len(results)} sample(s) -> {out_dir}[/{TEAL}]") + + +# ------------------------------------------------------------------ main --- +def main() -> int: + banner() + try: + mode = ask_mode() + if mode is None: + raise KeyboardInterrupt + + template, effects, strengths = None, [], {} + if mode in ("isolate", "mixed"): + template = ask_template() + effects = ask_effects() + strengths = ask_strengths(effects) + + count = ask_count() + save_viz = ask_save_viz() + out_dir = ask_output_dir() + + show_plan(mode, template, effects, strengths, count, out_dir) + if not questionary.confirm("Generate now?", default=True, style=QSTYLE).ask(): + console.print("Cancelled.") + return 0 + + if mode == "normal": + config = load_config( + config_path=ROOT / "configs" / "generate.yaml", + overrides=["document.template=bank_doc_05", "generation.run_validation=false"], + ) + elif mode == "extreme": + config = load_config(overrides=extreme_overrides()) + else: + config = load_config(overrides=build_pick_overrides(mode, template, effects, strengths)) + + out_dir.mkdir(parents=True, exist_ok=True) + ctx = GeneratorContext(config) + prefix, digits = config.generation.filename_prefix, config.generation.filename_digits + + results = [] + with Progress( + SpinnerColumn(), TextColumn("[progress.description]{task.description}"), + BarColumn(complete_style=TEAL), TextColumn("{task.completed}/{task.total}"), + TimeElapsedColumn(), console=console, + ) as progress: + task = progress.add_task("Generating", total=count) + for i in range(1, count + 1): + metadata, final_image, _ = ctx.run_sample(i) + sample_id = f"{prefix}_{i:0{digits}d}" + + save_image_rgb(out_dir / f"{sample_id}.png", final_image) + ctx.metadata_exporter.export(metadata, out_dir / f"{sample_id}.json") + if save_viz: + save_visualization(out_dir / f"{sample_id}_viz.png", final_image, metadata) + + applied = [n for n, r in metadata["augmentation"]["effects"].items() if r["applied"]] + results.append((sample_id, metadata["difficulty"]["score"], metadata["difficulty"]["tier"], applied)) + progress.advance(task) + + console.print() + show_results(results, out_dir) + + if questionary.confirm("Open the output folder now?", default=True, style=QSTYLE).ask(): + if os.name == "nt": + os.startfile(out_dir) # noqa: S606 + else: + console.print(str(out_dir)) + + return 0 + + except KeyboardInterrupt: + console.print("\n[dim]Cancelled.[/dim]") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/generate_before_after_assets.py b/scripts/generate_before_after_assets.py new file mode 100644 index 0000000000000000000000000000000000000000..b06f43b7759ee8be8b8b1d568599eeba37bfc591 --- /dev/null +++ b/scripts/generate_before_after_assets.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python +"""Generate paired before/after images for every augmentation effect, for slides. + +For each of the 25 configurable effects, renders two samples that are +identical in every respect (same template, signature, placement, box) except +that one has every augmentation disabled ("before") and the other has just +that one effect forced on at a fixed strength ("after"). Because placement is +frozen and both runs share the same (seed, sample_index), the underlying +document/signature/box are pixel-identical up to the effect itself. + +Signature-level effects are saved as a tight crop around the signature box +(where the effect is visible at slide scale); document-level effects are +saved as the full page. Also writes a JSON manifest with the resolved +strength/params/formula description used per effect, for slide captions. + +Usage: + python scripts/generate_before_after_assets.py +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from PIL import Image + +from src.config.loader import load_config +from src.generator import GeneratorContext +from src.visualization.visualizer import _crop_region + +SIGNATURE_EFFECTS = [ + "rotation", "stroke_dilation", + "ink_fading", "ink_bleed", "motion_blur", "gaussian_blur", +] +DOCUMENT_EFFECTS = [ + "perspective", "contrast", "gamma", "coffee_stain", "stain", + "shadow", "crumple", "discoloration", "page_tilt", "dust", "scanner_streaks", + "scanner_noise", "salt_pepper", "jpeg_compression", +] +ALL_EFFECTS = SIGNATURE_EFFECTS + DOCUMENT_EFFECTS + +OUT_ROOT = Path(__file__).resolve().parents[1] / "degradation" +TEMPLATE_ID = "bank_doc_05" +FORCED_STRENGTH = 1.0 # max strength: matches the "range at strength=1.0" figures already on the formula slides +SAMPLE_INDEX = 2 # picked for a bolder/higher-ink-fill signature than index 1, more visible at slide scale +CROP_PAD = 45 +CROP_UPSCALE = 2 + +# Purely tonal/textural document effects are nearly invisible at full-page slide +# scale, so also cut a fixed, upscaled body-text zoom for these (kept identical +# between before/after so the comparison is apples-to-apples). Structural effects +# (crumple, page_tilt, perspective, shadow, scanner_streaks) already read clearly +# on the full page and don't need one. +ZOOM_EFFECTS = { + "contrast", "gamma", "coffee_stain", "stain", + "discoloration", "dust", "scanner_noise", "salt_pepper", "jpeg_compression", +} +ZOOM_BOX = (40, 110, 460, 340) # (x1, y1, x2, y2) in full-page pixel space + +DISPLAY_NAME = { + "rotation": "Rotation jitter", + "stroke_dilation": "Stroke dilation", + "ink_fading": "Ink fading", + "ink_bleed": "Ink bleed", + "motion_blur": "Motion blur", + "gaussian_blur": "Gaussian blur", + "perspective": "Perspective warp", + "contrast": "Contrast shift", + "gamma": "Gamma shift", + "coffee_stain": "Coffee stain", + "stain": "Water / paper stain", + "shadow": "Shadow", + "crumple": "Crumple / wrinkle", + "discoloration": "Discoloration", + "page_tilt": "Page tilt", + "dust": "Dust specks", + "scanner_streaks": "Scanner streaks", + "scanner_noise": "Scanner noise", + "salt_pepper": "Salt & pepper noise", + "jpeg_compression": "JPEG compression", +} + + +def base_overrides() -> list[str]: + return [ + f"document.template={TEMPLATE_ID}", + "placement.rotation_deg.min=0", "placement.rotation_deg.max=0", + "placement.scale_factor.min=0.85", "placement.scale_factor.max=0.85", + "placement.x_offset=0", "placement.y_offset=0", + "placement.misplacement_probability=0.0", + "generation.run_validation=false", + ] + + +def all_disabled_overrides() -> list[str]: + overrides = base_overrides() + for name in ALL_EFFECTS: + overrides.append(f"augmentation.{name}.enabled=false") + overrides.append(f"augmentation.{name}.probability=0.0") + return overrides + + +def effect_overrides(target_effect: str) -> list[str]: + overrides = base_overrides() + for name in ALL_EFFECTS: + is_target = name == target_effect + overrides.append(f"augmentation.{name}.enabled={'true' if is_target else 'false'}") + overrides.append(f"augmentation.{name}.probability={'1.0' if is_target else '0.0'}") + overrides.append(f"augmentation.{target_effect}.strength={FORCED_STRENGTH}") + return overrides + + +def main() -> None: + OUT_ROOT.mkdir(parents=True, exist_ok=True) + manifest: dict[str, dict] = {} + + baseline_config = load_config(overrides=all_disabled_overrides()) + baseline_ctx = GeneratorContext(baseline_config) + baseline_meta, baseline_img, _ = baseline_ctx.run_sample(SAMPLE_INDEX) + box = baseline_meta["geometry"]["target_box"] + + for effect in ALL_EFFECTS: + out_dir = OUT_ROOT / effect + out_dir.mkdir(parents=True, exist_ok=True) + + config = load_config(overrides=effect_overrides(effect)) + ctx = GeneratorContext(config) + meta, after_img, _ = ctx.run_sample(SAMPLE_INDEX) + + before_img, after_img_final = baseline_img, after_img + if effect in SIGNATURE_EFFECTS: + before_out = _crop_region(before_img, box, pad=CROP_PAD) + after_out = _crop_region(after_img_final, box, pad=CROP_PAD) + before_pil = Image.fromarray(before_out) + after_pil = Image.fromarray(after_out) + before_pil = before_pil.resize( + (before_pil.width * CROP_UPSCALE, before_pil.height * CROP_UPSCALE), Image.LANCZOS, + ) + after_pil = after_pil.resize( + (after_pil.width * CROP_UPSCALE, after_pil.height * CROP_UPSCALE), Image.LANCZOS, + ) + else: + before_pil, after_pil = Image.fromarray(before_img), Image.fromarray(after_img_final) + + before_pil.save(out_dir / "before.png") + after_pil.save(out_dir / "after.png") + + if effect in ZOOM_EFFECTS: + x1, y1, x2, y2 = ZOOM_BOX + zb = Image.fromarray(before_img[y1:y2, x1:x2]) + za = Image.fromarray(after_img_final[y1:y2, x1:x2]) + zb = zb.resize((zb.width * CROP_UPSCALE, zb.height * CROP_UPSCALE), Image.LANCZOS) + za = za.resize((za.width * CROP_UPSCALE, za.height * CROP_UPSCALE), Image.LANCZOS) + zb.save(out_dir / "before_zoom.png") + za.save(out_dir / "after_zoom.png") + + rec = meta["augmentation"]["effects"].get(effect, {}) + manifest[effect] = { + "display_name": DISPLAY_NAME[effect], + "category": "signature" if effect in SIGNATURE_EFFECTS else "document", + "strength": rec.get("strength"), + "params": rec.get("params"), + "difficulty_score": meta["difficulty"]["score"], + "difficulty_tier": meta["difficulty"]["tier"], + } + print(f"[{effect}] before/after -> {out_dir} (strength={rec.get('strength')}, params={rec.get('params')})") + + manifest_path = OUT_ROOT / "before_after_manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + print(f"\nWrote manifest -> {manifest_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_case_assets.py b/scripts/generate_case_assets.py new file mode 100644 index 0000000000000000000000000000000000000000..5950898a01ac383cae07eaa0a45265a93fd1d724 --- /dev/null +++ b/scripts/generate_case_assets.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python +"""Generate plain full-page images (no dashboard overlay) for normal vs. extreme +multi-degradation cases, for slides. + +Reuses the same two configurations as scripts/generate_multiple_degradation_gallery.py +("normal": configs/generate.yaml's realistic per-effect probabilities, unmodified) +and scripts/generate_multiple_degradation_extreme_gallery.py ("extreme": every +effect pushed to high probability/strength plus worst-case placement/rendering), +but writes the bare rendered page instead of the multi-panel debug figure, since +slides just need the document image plus a short caption. + +Usage: + python scripts/generate_case_assets.py +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from PIL import Image + +from src.config.loader import load_config +from src.generator import GeneratorContext +from scripts.generate_multiple_degradation_extreme_gallery import build_overrides as extreme_overrides + +ROOT = Path(__file__).resolve().parents[1] +TEMPLATE_ID = "bank_doc_05" +N_SAMPLES = 2 + + +def _run_case(out_dir: Path, config_path, overrides: list[str]) -> None: + out_dir.mkdir(parents=True, exist_ok=True) + config = load_config(config_path=config_path, overrides=overrides) + ctx = GeneratorContext(config) + + manifest = {} + for i in range(1, N_SAMPLES + 1): + meta, final_image, _ = ctx.run_sample(i) + Image.fromarray(final_image).save(out_dir / f"case_{i}.png") + + effects = meta["augmentation"]["effects"] + applied = [n for n, r in effects.items() if r["applied"]] + manifest[f"case_{i}"] = { + "applied_effects": applied, + "n_applied": len(applied), + "n_total": len(effects), + "difficulty_score": meta["difficulty"]["score"], + "difficulty_tier": meta["difficulty"]["tier"], + "placement": { + "rotation_deg": meta["placement"]["rotation_deg"], + "scale_factor": meta["placement"]["scale_factor"], + "misplaced": meta["placement"]["misplaced"], + }, + } + print(f"[{out_dir.parent.name}/case_{i}] {len(applied)}/{len(effects)} effects, " + f"difficulty={meta['difficulty']['score']:.3f} ({meta['difficulty']['tier']})") + + (out_dir / "case_manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + + +def main() -> None: + # Normal: configs/generate.yaml's realistic "varied batch" probabilities, unmodified. + _run_case( + ROOT / "multiple_degradation" / "case_assets", + ROOT / "configs" / "generate.yaml", + [f"document.template={TEMPLATE_ID}", "generation.run_validation=false"], + ) + # Extreme: every effect pushed high, worst-case placement/rendering. + _run_case( + ROOT / "multiple_degradation_extreme" / "case_assets", + None, + extreme_overrides(), + ) + print("\nDone.") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_degradation_gallery.py b/scripts/generate_degradation_gallery.py new file mode 100644 index 0000000000000000000000000000000000000000..d4f499597fbc25680b786f5c31953f2cc54f65db --- /dev/null +++ b/scripts/generate_degradation_gallery.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python +"""Generate a labeled sample gallery of every augmentation effect in isolation. + +For each of the 25 configurable augmentation families, forces that one effect +on (probability=1.0) and disables every other effect, so the visual result is +attributable to that effect alone. Placement is frozen (no rotation/scale/ +misplacement jitter, fixed template) so samples across effects are directly +comparable. Writes 5 sample images per effect to degradation//, each +a multi-panel figure (full page, signature crop, ink mask, coverage metrics, +difficulty breakdown) via the existing visualizer. + +Usage: + python scripts/generate_degradation_gallery.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from src.config.loader import load_config +from src.generator import GeneratorContext +from src.visualization.visualizer import save_visualization + +SIGNATURE_EFFECTS = [ + "rotation", "stroke_dilation", + "ink_fading", "ink_bleed", "motion_blur", "gaussian_blur", +] +DOCUMENT_EFFECTS = [ + "perspective", "contrast", "gamma", "coffee_stain", "stain", + "shadow", "crumple", "discoloration", "page_tilt", "dust", "scanner_streaks", + "scanner_noise", "salt_pepper", "jpeg_compression", +] +ALL_EFFECTS = SIGNATURE_EFFECTS + DOCUMENT_EFFECTS + +OUT_ROOT = Path(__file__).resolve().parents[1] / "degradation" +SAMPLES_PER_EFFECT = 5 +TEMPLATE_ID = "bank_doc_05" # decent-sized signature box (255x35px) +FORCED_STRENGTH = 0.75 + + +def build_overrides(target_effect: str) -> list[str]: + overrides = [ + f"document.template={TEMPLATE_ID}", + "placement.rotation_deg.min=0", "placement.rotation_deg.max=0", + "placement.scale_factor.min=0.85", "placement.scale_factor.max=0.85", + "placement.x_offset=0", "placement.y_offset=0", + "placement.misplacement_probability=0.0", + "generation.run_validation=false", + ] + for name in ALL_EFFECTS: + is_target = name == target_effect + overrides.append(f"augmentation.{name}.enabled={'true' if is_target else 'false'}") + overrides.append(f"augmentation.{name}.probability={'1.0' if is_target else '0.0'}") + overrides.append(f"augmentation.{target_effect}.strength={FORCED_STRENGTH}") + return overrides + + +def main() -> None: + OUT_ROOT.mkdir(parents=True, exist_ok=True) + for effect in ALL_EFFECTS: + out_dir = OUT_ROOT / effect + out_dir.mkdir(parents=True, exist_ok=True) + + config = load_config(overrides=build_overrides(effect)) + ctx = GeneratorContext(config) + + for i in range(1, SAMPLES_PER_EFFECT + 1): + metadata, final_image, _ = ctx.run_sample(i) + save_visualization(out_dir / f"sample_{i}.png", final_image, metadata) + + print(f"[{effect}] wrote {SAMPLES_PER_EFFECT} samples -> {out_dir}") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_multiple_degradation_extreme_gallery.py b/scripts/generate_multiple_degradation_extreme_gallery.py new file mode 100644 index 0000000000000000000000000000000000000000..ffeff39243a5720d051e4c1dbd638f8a06744571 --- /dev/null +++ b/scripts/generate_multiple_degradation_extreme_gallery.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python +"""Generate WORST-CASE samples: many degradations stacked at high strength on one template. + +Like scripts/generate_multiple_degradation_gallery.py, but every augmentation +family's probability and strength range are pushed high (instead of using +configs/generate.yaml's mild "realistic batch" defaults), and placement/ +rendering are also pushed toward their hardest settings (heavy misplacement, +small/faint signature). Most effects fire on most samples, so each sample +ends up with a large, clearly-visible stack of simultaneous degradations. + +The caption band at the top of each image lists not just which effects +fired but their resolved *extent*: strength, plus any effect-specific +parameter (blob count, shadow type, JPEG quality, wrinkle count/amplitude, +etc.) alongside the resolved placement/rendering severity for that sample. + +Usage: + python scripts/generate_multiple_degradation_extreme_gallery.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from PIL import Image, ImageDraw, ImageFont + +from src.config.loader import load_config +from src.generator import GeneratorContext +from src.visualization.visualizer import save_visualization +from scripts.generate_degradation_gallery import ALL_EFFECTS + +ROOT = Path(__file__).resolve().parents[1] +OUT_DIR = ROOT / "multiple_degradation_extreme" +N_SAMPLES = 10 +TEMPLATE_ID = "bank_doc_05" + +EFFECT_PROBABILITY = 0.85 # near-certain per effect, but still leaves sample-to-sample variety +STRENGTH_MIN, STRENGTH_MAX = 0.7, 1.0 + + +def _load_font(size: int): + for candidate in (r"C:\Windows\Fonts\consola.ttf", r"C:\Windows\Fonts\arial.ttf"): + if Path(candidate).exists(): + return ImageFont.truetype(candidate, size) + return ImageFont.load_default() + + +def build_overrides() -> list[str]: + overrides = [ + f"document.template={TEMPLATE_ID}", + "generation.run_validation=false", + # Worst-case placement: large rotation, small scale, frequent/strong misplacement. + "placement.rotation_deg.min=-15", "placement.rotation_deg.max=15", + "placement.scale_factor.min=0.3", "placement.scale_factor.max=0.6", + "placement.margin=0.03", + "placement.misplacement_probability=0.5", + "placement.misplacement_strength.min=0.5", "placement.misplacement_strength.max=1.0", + "placement.allow_partial_outside=true", + # Worst-case rendering: faint, blurred, heavily feathered ink. + "rendering.ink_opacity.min=0.3", "rendering.ink_opacity.max=0.55", + "rendering.blur_sigma.min=0.6", "rendering.blur_sigma.max=1.4", + "rendering.feather_px.min=1.5", "rendering.feather_px.max=3.0", + "rendering.scanner_noise=0.05", + ] + for name in ALL_EFFECTS: + overrides.append(f"augmentation.{name}.enabled=true") + overrides.append(f"augmentation.{name}.probability={EFFECT_PROBABILITY}") + if name == "jpeg_compression": + overrides += [ + "augmentation.jpeg_compression.strength=0.9", + "augmentation.jpeg_compression.min_quality=10", + "augmentation.jpeg_compression.max_quality=30", + ] + else: + overrides.append(f"augmentation.{name}.strength.min={STRENGTH_MIN}") + overrides.append(f"augmentation.{name}.strength.max={STRENGTH_MAX}") + return overrides + + +def _fmt_effect(name: str, rec: dict) -> str: + params = rec.get("params") or {} + if name == "jpeg_compression" and "quality" in params: + return f"{name}=q{params['quality']}" + tag = f"{name}={rec['strength']:.2f}" + if "resolved_type" in params: + tag += f"({params['resolved_type']})" + elif "resolved_tone" in params: + tag += f"({params['resolved_tone']})" + elif name == "crumple" and "wrinkle_lines" in params: + tag += f"({params['wrinkle_lines']}L,{params['displacement_amplitude_px']:.1f}px)" + elif name == "coffee_stain" and "blob_count" in params: + tag += f"({params['blob_count']}blobs)" + elif name == "page_tilt" and "tilt_deg" in params: + tag += f"({params['tilt_deg']:.1f}deg)" + elif name == "dust" and "speck_count" in params: + tag += f"({params['speck_count']}px)" + elif name == "scanner_streaks" and "streak_count" in params: + tag += f"({params['streak_count']}streaks)" + return tag + + +def _annotate(path: Path, metadata: dict) -> list[str]: + effects = metadata["augmentation"]["effects"] + applied_names = [n for n, r in effects.items() if r["applied"]] + applied_tags = [_fmt_effect(n, effects[n]) for n in applied_names] + + placement, rendering, difficulty = metadata["placement"], metadata["rendering"], metadata["difficulty"] + header = ( + f"{metadata['document_id']} | difficulty={difficulty['score']:.3f} ({difficulty['tier']}) | " + f"placement: rot={placement['rotation_deg']:.1f}deg scale={placement['scale_factor']:.2f} " + f"misplaced={placement['misplaced']} | " + f"render: ink_opacity={rendering['ink_opacity']:.2f} blur_sigma={rendering['blur_sigma']:.2f}" + ) + body = f"Applied ({len(applied_tags)}/{len(effects)}): " + (", ".join(applied_tags) if applied_tags else "none") + + img = Image.open(path).convert("RGB") + font = _load_font(15) + draw_probe = ImageDraw.Draw(img) + max_w = img.width - 28 + + # Pixel-accurate wrap of the (potentially long) applied-effects line. + words = body.replace(", ", " ,SPLIT_ME ").split(" ,SPLIT_ME ") + lines, current = [], "" + for tok in words: + candidate = (current + ", " + tok) if current else tok + if draw_probe.textlength(candidate, font=font) <= max_w: + current = candidate + else: + if current: + lines.append(current) + current = tok + if current: + lines.append(current) + + all_lines = [header] + lines + line_h = 22 + band_h = 14 + line_h * len(all_lines) + + canvas = Image.new("RGB", (img.width, img.height + band_h), "white") + canvas.paste(img, (0, band_h)) + draw = ImageDraw.Draw(canvas) + draw.rectangle([0, 0, canvas.width, band_h], fill=(60, 18, 18)) + y = 8 + for i, line in enumerate(all_lines): + draw.text((14, y), line, fill="white" if i == 0 else (255, 214, 194), font=font) + y += line_h + canvas.save(path) + return applied_names + + +def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + config = load_config(overrides=build_overrides()) + ctx = GeneratorContext(config) + + for i in range(1, N_SAMPLES + 1): + metadata, final_image, _ = ctx.run_sample(i) + out_path = OUT_DIR / f"sample_{i}.png" + save_visualization(out_path, final_image, metadata) + applied = _annotate(out_path, metadata) + print(f"[sample_{i}] {len(applied)}/{len(metadata['augmentation']['effects'])} effects -> {', '.join(applied)}") + + print(f"\nWrote {N_SAMPLES} extreme samples -> {OUT_DIR}") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_multiple_degradation_gallery.py b/scripts/generate_multiple_degradation_gallery.py new file mode 100644 index 0000000000000000000000000000000000000000..c2a84d8a21908b1e0d5db78d6875a4ceab546ee5 --- /dev/null +++ b/scripts/generate_multiple_degradation_gallery.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python +"""Generate samples with multiple random degradations stacked on one template. + +Unlike scripts/generate_degradation_gallery.py (which isolates exactly one +effect per sample for comparison), this uses the realistic "varied batch" +probabilities from configs/generate.yaml unmodified -- every enabled effect +independently rolls its own probability per sample, so different samples end +up with different *combinations* of simultaneously-applied degradations, all +on the same template/box for a fair before/after comparison. + +Each output image is the standard metrics visualization (full page, crop, +ink mask, coverage metrics, difficulty breakdown) plus a caption band listing +exactly which effects fired for that sample. + +Usage: + python scripts/generate_multiple_degradation_gallery.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from PIL import Image, ImageDraw, ImageFont + +from src.config.loader import load_config +from src.generator import GeneratorContext +from src.visualization.visualizer import save_visualization + +ROOT = Path(__file__).resolve().parents[1] +OUT_DIR = ROOT / "multiple_degradation" +N_SAMPLES = 10 +TEMPLATE_ID = "bank_doc_05" # same template used in the single-effect gallery + + +def _load_font(size: int) -> "ImageFont.FreeTypeFont | ImageFont.ImageFont": + for candidate in (r"C:\Windows\Fonts\consola.ttf", r"C:\Windows\Fonts\arial.ttf"): + if Path(candidate).exists(): + return ImageFont.truetype(candidate, size) + return ImageFont.load_default() + + +def _annotate_applied_effects(path: Path, metadata: dict) -> list[str]: + effects = metadata["augmentation"]["effects"] + applied = [name for name, rec in effects.items() if rec["applied"]] + difficulty = metadata["difficulty"] + + caption = ( + f"{metadata['document_id']} | applied degradations " + f"({len(applied)}/{len(effects)}): {', '.join(applied) if applied else 'none'}" + f" | difficulty = {difficulty['score']:.3f} ({difficulty['tier']})" + ) + + img = Image.open(path).convert("RGB") + band_h = 40 + canvas = Image.new("RGB", (img.width, img.height + band_h), "white") + canvas.paste(img, (0, band_h)) + draw = ImageDraw.Draw(canvas) + font = _load_font(16) + draw.rectangle([0, 0, canvas.width, band_h], fill=(15, 42, 67)) + draw.text((14, 11), caption, fill="white", font=font) + canvas.save(path) + return applied + + +def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + + overrides = [ + f"document.template={TEMPLATE_ID}", + "generation.run_validation=false", + ] + config = load_config(config_path=ROOT / "configs" / "generate.yaml", overrides=overrides) + ctx = GeneratorContext(config) + + for i in range(1, N_SAMPLES + 1): + metadata, final_image, _ = ctx.run_sample(i) + out_path = OUT_DIR / f"sample_{i}.png" + save_visualization(out_path, final_image, metadata) + applied = _annotate_applied_effects(out_path, metadata) + print(f"[sample_{i}] {len(applied)} effects applied -> {', '.join(applied) or 'none'}") + + print(f"\nWrote {N_SAMPLES} samples -> {OUT_DIR}") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_verification_suite.py b/scripts/generate_verification_suite.py new file mode 100644 index 0000000000000000000000000000000000000000..4ada9c90d1c140d332730a591faa4a3c3c61bf03 --- /dev/null +++ b/scripts/generate_verification_suite.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python +"""Generate a labeled sample batch covering all 12 verification test-case types. + +For each ``case_type`` in ``src.verification.case_types.CASE_TABLE`` (1a +through 5b), generates a couple of samples via ``GeneratorContext`` directly +(bypassing ``DatasetGenerator`` since each case needs its own config +override), writes the rendered PNGs + metadata JSONs to +``output/verification_suite/``, and prints a verdict/reject_code summary +table. + +Usage: + python scripts/generate_verification_suite.py [--per-case N] +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from src.config.loader import load_config +from src.generator import GeneratorContext +from src.metadata.metadata_exporter import MetadataExporter +from src.utils.image_io import save_image_rgb +from src.verification.case_types import CASE_TABLE + +ROOT = Path(__file__).resolve().parents[1] +OUT_DIR = ROOT / "output" / "verification_suite" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--per-case", type=int, default=2, help="Samples to generate per case type.") + parser.add_argument("--seed", type=int, default=4242, help="Master seed.") + args = parser.parse_args() + + OUT_DIR.mkdir(parents=True, exist_ok=True) + exporter = MetadataExporter() + summary_rows = [] + + sample_index = 1 + for code in CASE_TABLE: + config = load_config( + config_path=str(ROOT / "configs" / "examples" / "verification_cases.yaml"), + overrides=[f"quality.case_type={code}", f"seed={args.seed}"], + ) + ctx = GeneratorContext(config) + + for _ in range(args.per_case): + sample_id = f"case_{sample_index:03d}" + metadata, final_image, _ = ctx.run_sample(sample_index) + metadata["document_id"] = sample_id + + save_image_rgb(OUT_DIR / f"{sample_id}.png", final_image) + exporter.export(metadata, OUT_DIR / f"{sample_id}.json") + + v = metadata["verification"] + summary_rows.append({ + "sample_id": sample_id, "case_type": code, "template": metadata["template"], + "box_id": v["target_box_id"], "verdict": v["verdict"], "reject_code": v["reject_code"], + }) + print(f"[{sample_id}] case_type={code:<3} template={metadata['template']:<12} " + f"box={v['target_box_id']:<5} -> {v['verdict']:<7} {v['reject_code'] or '-'}") + sample_index += 1 + + (OUT_DIR / "summary.json").write_text(json.dumps(summary_rows, indent=2), encoding="utf-8") + print(f"\nWrote {len(summary_rows)} samples to {OUT_DIR}") + + +if __name__ == "__main__": + main() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c27f9df9007d143f1d631a0bcb4b8fadcfb87b96 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,3 @@ +"""Synthetic signed bank document generation pipeline.""" + +__version__ = "1.0.0" diff --git a/src/analysis/__init__.py b/src/analysis/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6fa0bc73e3dc29a8d207279194fa89505c9b25c7 --- /dev/null +++ b/src/analysis/__init__.py @@ -0,0 +1 @@ +"""Post-render geometry detection, difficulty scoring, and validation.""" diff --git a/src/analysis/difficulty_analyzer.py b/src/analysis/difficulty_analyzer.py new file mode 100644 index 0000000000000000000000000000000000000000..d60d7acb2dda4381a35f0276dcfd73de247a2f5f --- /dev/null +++ b/src/analysis/difficulty_analyzer.py @@ -0,0 +1,79 @@ +"""Computes the deterministic difficulty score from post-render geometry metrics. + +Pure function of :class:`~src.analysis.geometry_analyzer.GeometryResult` and +the configured weights -- no randomness is involved, so the score is +trivially reproducible given identical geometry inputs. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from src.analysis.geometry_analyzer import GeometryResult +from src.config.schema import DifficultyWeights +from src.utils.geometry_utils import clamp01 + + +@dataclass +class DifficultyResult: + """The five difficulty components, the weighted score, and its tier.""" + + misplacement: float + whitespace: float + faintness: float + smallness: float + low_texture: float + score: float + tier: str + + +class DifficultyAnalyzer: + """Computes the exact spec-defined difficulty score and tier. + + ``score = 0.35*misplacement + 0.20*whitespace + 0.20*faintness + 0.15*smallness + 0.10*low_texture`` + + Example: + >>> analyzer = DifficultyAnalyzer(cfg.difficulty) + >>> result = analyzer.analyze(geometry_result) + """ + + def __init__(self, weights: DifficultyWeights) -> None: + self.weights = weights + + def analyze(self, geometry: GeometryResult) -> DifficultyResult: + """Compute the difficulty components, weighted score, and tier for one sample.""" + w = self.weights + + misplacement = clamp01(1.0 - geometry.polygon_overlap_proxy) + whitespace = clamp01(geometry.whitespace_ratio) + faintness = clamp01((geometry.mean_dark_intensity - 90.0) / 120.0) + smallness = clamp01(1.0 - geometry.signature_area_ratio / 0.04) + low_texture = clamp01(1.0 - geometry.gray_stddev / 60.0) + + score = ( + w.misplacement * misplacement + + w.whitespace * whitespace + + w.faintness * faintness + + w.smallness * smallness + + w.low_texture * low_texture + ) + score = float(np.clip(score, 0.0, 1.0)) + + if score < w.easy_threshold: + tier = "easy" + elif score < w.hard_threshold: + tier = "medium" + else: + tier = "hard" + + return DifficultyResult( + misplacement=round(misplacement, 4), + whitespace=round(whitespace, 4), + faintness=round(faintness, 4), + smallness=round(smallness, 4), + low_texture=round(low_texture, 4), + score=round(score, 4), + tier=tier, + ) diff --git a/src/analysis/geometry_analyzer.py b/src/analysis/geometry_analyzer.py new file mode 100644 index 0000000000000000000000000000000000000000..c1e8d8a17ec5774aeff11dc18d1bf82dcce6ad59 --- /dev/null +++ b/src/analysis/geometry_analyzer.py @@ -0,0 +1,259 @@ +"""Post-render signature detection and geometry/coverage metric computation. + +Rather than searching the entire page (which would pick up printed field +labels like "By:" or "Print Name:"), detection is scoped to the known +signature field location -- exactly as a production pipeline would use the +document's layout annotation to know where to look. A tight pass is tried +first; if no ink is found (e.g. the signature was intentionally misplaced +far from its box) the search window progressively expands, bounded by the +page, before giving up. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import cv2 +import numpy as np + +from src.utils.geometry_utils import ( + Box, box_center, box_to_polygon, centroid_of, containment_pct, + coverage_pct, distance_between, iou, margin_utilization, points_to_polygon, safe_area, +) +from src.utils.logging_utils import get_logger + +logger = get_logger(__name__) + +MIN_INK_PIXELS = 12 + + +@dataclass +class GeometryResult: + """Full geometric + photometric characterization of the detected signature.""" + + found: bool + signature_bbox_px: Box + min_rotated_rect: dict + signature_polygon_px: list[list[float]] + convex_hull_px: list[list[float]] + centroid_px: tuple[float, float] + orientation_deg: float + ink_pixels: int + target_box: Box + actual_box: Box + search_box: Box + + box_coverage_pct: float + containment_pct: float + ink_fill_ratio: float + polygon_overlap_proxy: float + distance_from_box_center_px: float + margin_utilization: float + + mean_dark_intensity: float + gray_stddev: float + signature_area_ratio: float + whitespace_ratio: float + + +def _expand_box(box: Box, canvas_size: tuple[int, int], factor: float, min_pad: float) -> Box: + x1, y1, x2, y2 = box + w, h = canvas_size + bw, bh = (x2 - x1), (y2 - y1) + pad_x = max(bw * (factor - 1.0) / 2.0, min_pad) + pad_y = max(bh * (factor - 1.0) / 2.0, min_pad) + return ( + float(max(x1 - pad_x, 0)), float(max(y1 - pad_y, 0)), + float(min(x2 + pad_x, w)), float(min(y2 + pad_y, h)), + ) + + +def _union_box(a: Box, b: Box) -> Box: + return (min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3])) + + +def _is_printed_rule_line(width: int, height: int, area: int) -> bool: + """Heuristic filter for printed "sign here" guideline rules vs. handwritten ink. + + Printed rules are thin (<=3px tall), very wide relative to their height, + and nearly solid (high fill ratio) -- unlike handwriting, which varies in + stroke height and leaves gaps even in a single connected component. + """ + if height > 3: + return False + if width < 8 * max(height, 1): + return False + fill_ratio = area / max(width * height, 1) + return fill_ratio > 0.6 + + +def _ink_mask(gray_crop: np.ndarray) -> "np.ndarray | None": + if gray_crop.size == 0: + return None + background = float(np.percentile(gray_crop, 90)) + threshold = background - max(18.0, background * 0.15) + binary = (gray_crop.astype(np.float32) < threshold).astype(np.uint8) * 255 + if binary.sum() == 0: + return binary + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2, 2)) + opened = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) + n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(opened, connectivity=8) + cleaned = np.zeros_like(opened) + for label in range(1, n_labels): + area = stats[label, cv2.CC_STAT_AREA] + w = stats[label, cv2.CC_STAT_WIDTH] + h = stats[label, cv2.CC_STAT_HEIGHT] + if area < 2: + continue + if _is_printed_rule_line(w, h, area): + continue + cleaned[labels == label] = 255 + return cleaned + + +class GeometryAnalyzer: + """Detects the rendered signature within its known field and computes all geometry metrics. + + Example: + >>> analyzer = GeometryAnalyzer() + >>> result = analyzer.analyze(final_image, target_box=(100, 100, 300, 160), + ... canvas_size=(1024, 559)) + """ + + def analyze( + self, + image_rgb: np.ndarray, + target_box: Box, + canvas_size: tuple[int, int], + placement_hint_bbox: "Box | None" = None, + ) -> GeometryResult: + """Detect the rendered signature and compute every geometry/coverage metric. + + Args: + image_rgb: The fully rendered (and augmented) document, ``(H, W, 3)`` uint8. + target_box: The annotated ``(x1, y1, x2, y2)`` signature field. + canvas_size: ``(width, height)`` of the document. + placement_hint_bbox: Optional pre-augmentation placement bounding + box, used to widen the search if a tight pass finds nothing + (e.g. after a large intentional misplacement). + + Returns: + A :class:`GeometryResult` with detection status and every metric. + """ + gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY) + + search_box = _expand_box(target_box, canvas_size, factor=1.15, min_pad=6) + mask, offset = self._try_detect(gray, search_box) + + if mask is None or int((mask > 0).sum()) < MIN_INK_PIXELS: + union = _union_box(target_box, placement_hint_bbox) if placement_hint_bbox else target_box + search_box = _expand_box(union, canvas_size, factor=2.2, min_pad=50) + mask, offset = self._try_detect(gray, search_box) + + if mask is None or int((mask > 0).sum()) < MIN_INK_PIXELS: + logger.warning("No signature ink detected near target box %s.", target_box) + return self._empty_result(target_box, search_box, canvas_size) + + return self._build_result(mask, offset, gray, target_box, search_box, canvas_size) + + @staticmethod + def _try_detect(gray: np.ndarray, box: Box) -> tuple["np.ndarray | None", tuple[int, int]]: + x1, y1, x2, y2 = (int(round(v)) for v in box) + crop = gray[y1:y2, x1:x2] + mask = _ink_mask(crop) + return mask, (x1, y1) + + def _build_result( + self, mask: np.ndarray, offset: tuple[int, int], full_gray: np.ndarray, + target_box: Box, search_box: Box, canvas_size: tuple[int, int], + ) -> GeometryResult: + ox, oy = offset + ys, xs = np.where(mask > 0) + points = np.column_stack([xs + ox, ys + oy]).astype(np.float32) + ink_pixels = int(len(points)) + + x1, y1 = float(points[:, 0].min()), float(points[:, 1].min()) + x2, y2 = float(points[:, 0].max()) + 1.0, float(points[:, 1].max()) + 1.0 + bbox: Box = (x1, y1, x2, y2) + + rect = cv2.minAreaRect(points) + (rcx, rcy), (rw, rh), rangle = rect + rotated_rect = { + "center": [round(float(rcx), 2), round(float(rcy), 2)], + "size": [round(float(rw), 2), round(float(rh), 2)], + "angle_deg": round(float(rangle), 2), + } + + contour_points = points.astype(np.int32) + hull = cv2.convexHull(contour_points) + hull_xy = [[int(p[0][0]), int(p[0][1])] for p in hull] + + mean_pt, eigvecs, eigvals = cv2.PCACompute2(points, mean=None) + orientation_deg = float(np.degrees(np.arctan2(eigvecs[0][1], eigvecs[0][0]))) + orientation_deg = ((orientation_deg + 90) % 180) - 90 + + centroid = (float(points[:, 0].mean()), float(points[:, 1].mean())) + + bbox_crop = full_gray[int(y1):int(y2), int(x1):int(x2)] + mean_dark_intensity = float(bbox_crop[mask.astype(bool)].mean()) if bbox_crop.shape == mask.shape else float( + full_gray[oy:oy + mask.shape[0], ox:ox + mask.shape[1]][mask.astype(bool)].mean() + ) + gray_stddev = float(bbox_crop.std()) if bbox_crop.size > 0 else 0.0 + + target_poly = box_to_polygon(target_box) + signature_poly = points_to_polygon(hull_xy) + + bbox_area = max((x2 - x1) * (y2 - y1), 1.0) + ink_fill_ratio = float(np.clip(ink_pixels / bbox_area, 0.0, 1.0)) + canvas_area = max(canvas_size[0] * canvas_size[1], 1) + signature_area_ratio = float(bbox_area / canvas_area) + + return GeometryResult( + found=True, + signature_bbox_px=bbox, + min_rotated_rect=rotated_rect, + signature_polygon_px=hull_xy, + convex_hull_px=hull_xy, + centroid_px=(round(centroid[0], 2), round(centroid[1], 2)), + orientation_deg=round(orientation_deg, 2), + ink_pixels=ink_pixels, + target_box=target_box, + actual_box=bbox, + search_box=search_box, + box_coverage_pct=round(coverage_pct(target_poly, signature_poly), 3), + containment_pct=round(containment_pct(target_poly, signature_poly), 3), + ink_fill_ratio=round(ink_fill_ratio, 4), + polygon_overlap_proxy=round(iou(target_poly, signature_poly), 4), + distance_from_box_center_px=round(distance_between(box_center(target_box), centroid), 2), + margin_utilization=round(margin_utilization(target_box, signature_poly), 4), + mean_dark_intensity=round(mean_dark_intensity, 2), + gray_stddev=round(gray_stddev, 2), + signature_area_ratio=round(signature_area_ratio, 5), + whitespace_ratio=round(float(np.clip(1.0 - ink_fill_ratio, 0.0, 1.0)), 4), + ) + + @staticmethod + def _empty_result(target_box: Box, search_box: Box, canvas_size: tuple[int, int]) -> GeometryResult: + return GeometryResult( + found=False, + signature_bbox_px=target_box, + min_rotated_rect={"center": [0.0, 0.0], "size": [0.0, 0.0], "angle_deg": 0.0}, + signature_polygon_px=[], + convex_hull_px=[], + centroid_px=(float("nan"), float("nan")), + orientation_deg=0.0, + ink_pixels=0, + target_box=target_box, + actual_box=target_box, + search_box=search_box, + box_coverage_pct=0.0, + containment_pct=0.0, + ink_fill_ratio=0.0, + polygon_overlap_proxy=0.0, + distance_from_box_center_px=float("nan"), + margin_utilization=0.0, + mean_dark_intensity=255.0, + gray_stddev=0.0, + signature_area_ratio=0.0, + whitespace_ratio=1.0, + ) diff --git a/src/analysis/validation_engine.py b/src/analysis/validation_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..6a035bc3bf98c71df35b9c24c7662ff3e5d6399c --- /dev/null +++ b/src/analysis/validation_engine.py @@ -0,0 +1,274 @@ +"""Post-generation validation of exported metadata. + +Checks that every sample's metadata is internally consistent and safe for +downstream ML consumption: the signature was actually detected, its polygon +is a valid non-degenerate geometry, every declared bounding box is +well-formed and lies on the page, no NaN/Inf values leaked into the JSON, +metadata is schema-complete, and the difficulty score is exactly +reproducible from its own recorded components and weights. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Any + +from shapely.geometry import Polygon + +from src.config.schema import DifficultyWeights, ValidationConfig +from src.utils.logging_utils import get_logger +from src.verification.case_types import CASE_TABLE + +logger = get_logger(__name__) + +REQUIRED_TOP_LEVEL_KEYS = ( + "document_id", "template", "page", "page_size", "signer", "signature_source", + "placement", "rendering", "difficulty", "quality", "verification", "augmentation", "geometry", "metrics", +) + +# Case types whose ground truth is an intentionally blank target box -- a +# real signature is not expected to be detected there. +_BLANK_CASE_TYPES = frozenset({"2a", "5a"}) + + +@dataclass +class ValidationIssue: + field: str + message: str + severity: str # "error" | "warning" + + +@dataclass +class ValidationReport: + sample_id: str + passed: bool + issues: list[ValidationIssue] = field(default_factory=list) + + @property + def errors(self) -> list[ValidationIssue]: + return [i for i in self.issues if i.severity == "error"] + + @property + def warnings(self) -> list[ValidationIssue]: + return [i for i in self.issues if i.severity == "warning"] + + +class ValidationEngine: + """Validates one sample's exported metadata for correctness and completeness. + + Example: + >>> engine = ValidationEngine(cfg.validation, cfg.difficulty) + >>> report = engine.validate(metadata) + >>> assert report.passed + """ + + def __init__(self, config: ValidationConfig, difficulty_weights: DifficultyWeights) -> None: + self.config = config + self.weights = difficulty_weights + + def validate(self, metadata: dict[str, Any]) -> ValidationReport: + """Run every validation check against one sample's metadata dict.""" + sample_id = str(metadata.get("document_id", "unknown")) + issues: list[ValidationIssue] = [] + + issues += self._check_completeness(metadata) + issues += self._check_no_nan(metadata) + issues += self._check_signature_exists(metadata) + issues += self._check_polygon_valid(metadata) + issues += self._check_boxes(metadata) + issues += self._check_difficulty_reproducible(metadata) + issues += self._check_verification_consistency(metadata) + + has_errors = any(i.severity == "error" for i in issues) + has_warnings = any(i.severity == "warning" for i in issues) + passed = not has_errors and not (self.config.strict and has_warnings) + + return ValidationReport(sample_id=sample_id, passed=passed, issues=issues) + + def _check_completeness(self, metadata: dict[str, Any]) -> list[ValidationIssue]: + issues = [] + for key in REQUIRED_TOP_LEVEL_KEYS: + if key not in metadata: + issues.append(ValidationIssue(key, f"Missing required top-level key '{key}'", "error")) + signer = metadata.get("signer", {}) + for key in ("name", "role"): + if not signer.get(key): + issues.append(ValidationIssue(f"signer.{key}", f"Missing signer.{key}", "error")) + return issues + + def _check_no_nan(self, metadata: dict[str, Any], path: str = "") -> list[ValidationIssue]: + issues: list[ValidationIssue] = [] + + # geometry.centroid_px / distance_from_box_center_px are deliberate + # NaN sentinels (see GeometryAnalyzer._empty_result) when nothing was + # detected -- not a data-quality bug, so they're excluded from the walk. + skip_paths: set = set() + if not metadata.get("geometry", {}).get("detected", True): + skip_paths = {"geometry.centroid_px[0]", "geometry.centroid_px[1]", "geometry.distance_from_box_center_px"} + + def walk(value: Any, cur_path: str) -> None: + if cur_path in skip_paths: + return + if isinstance(value, dict): + for k, v in value.items(): + walk(v, f"{cur_path}.{k}" if cur_path else str(k)) + elif isinstance(value, (list, tuple)): + for i, v in enumerate(value): + walk(v, f"{cur_path}[{i}]") + elif isinstance(value, float): + if math.isnan(value) or math.isinf(value): + issues.append(ValidationIssue(cur_path, f"Non-finite value ({value}) at {cur_path}", "error")) + + walk(metadata, path) + return issues + + @staticmethod + def _expects_blank_target(metadata: dict[str, Any]) -> bool: + verification = metadata.get("verification") or {} + return verification.get("case_type") in _BLANK_CASE_TYPES + + def _check_signature_exists(self, metadata: dict[str, Any]) -> list[ValidationIssue]: + geometry = metadata.get("geometry", {}) + if self._expects_blank_target(metadata): + return [] + if not geometry.get("detected", False): + return [ValidationIssue("geometry.detected", "Signature was not detected in the rendered document", "error")] + if geometry.get("ink_pixels", 0) <= 0: + return [ValidationIssue("geometry.ink_pixels", "Signature detected but has zero ink pixels", "error")] + return [] + + def _check_polygon_valid(self, metadata: dict[str, Any]) -> list[ValidationIssue]: + issues: list[ValidationIssue] = [] + if not metadata.get("geometry", {}).get("detected", False) and self._expects_blank_target(metadata): + return [] + poly_pts = metadata.get("geometry", {}).get("signature_polygon_px", []) + if len(poly_pts) < 3: + issues.append(ValidationIssue("geometry.signature_polygon_px", "Polygon has fewer than 3 points", "error")) + return issues + try: + poly = Polygon(poly_pts) + if not poly.is_valid: + issues.append(ValidationIssue("geometry.signature_polygon_px", "Polygon geometry is invalid", "error")) + elif poly.area <= 0: + issues.append(ValidationIssue("geometry.signature_polygon_px", "Polygon has zero area", "error")) + except Exception as exc: # pragma: no cover - defensive + issues.append(ValidationIssue("geometry.signature_polygon_px", f"Polygon construction failed: {exc}", "error")) + return issues + + def _check_boxes(self, metadata: dict[str, Any]) -> list[ValidationIssue]: + issues: list[ValidationIssue] = [] + geometry = metadata.get("geometry", {}) + page_w, page_h = metadata.get("page_size", [None, None]) + eps = self.config.max_coordinate_epsilon + + for field_name in ("target_box", "actual_box", "signature_bbox_px", "box_px"): + box = geometry.get(field_name) + if not box or len(box) != 4: + issues.append(ValidationIssue(f"geometry.{field_name}", "Box missing or malformed", "error")) + continue + x1, y1, x2, y2 = box + if x2 <= x1 or y2 <= y1: + issues.append(ValidationIssue(f"geometry.{field_name}", f"Box has non-positive extent: {box}", "error")) + if page_w is not None and page_h is not None: + if x1 < -eps or y1 < -eps or x2 > page_w + eps or y2 > page_h + eps: + issues.append(ValidationIssue( + f"geometry.{field_name}", + f"Box {box} lies outside page bounds (0,0,{page_w},{page_h})", + "warning", + )) + return issues + + def _check_difficulty_reproducible(self, metadata: dict[str, Any]) -> list[ValidationIssue]: + difficulty = metadata.get("difficulty", {}) + required = ("misplacement", "whitespace", "faintness", "smallness", "low_texture", "score", "tier") + if not all(k in difficulty for k in required): + return [ValidationIssue("difficulty", "Difficulty block incomplete", "error")] + + w = self.weights + recomputed = ( + w.misplacement * difficulty["misplacement"] + + w.whitespace * difficulty["whitespace"] + + w.faintness * difficulty["faintness"] + + w.smallness * difficulty["smallness"] + + w.low_texture * difficulty["low_texture"] + ) + if abs(recomputed - difficulty["score"]) > 1e-3: + return [ValidationIssue( + "difficulty.score", + f"Score {difficulty['score']} does not match recomputed weighted sum {recomputed:.4f}", + "error", + )] + + if recomputed < w.easy_threshold: + expected_tier = "easy" + elif recomputed < w.hard_threshold: + expected_tier = "medium" + else: + expected_tier = "hard" + if expected_tier != difficulty["tier"]: + return [ValidationIssue( + "difficulty.tier", f"Tier '{difficulty['tier']}' does not match expected '{expected_tier}'", "error", + )] + return [] + + def _check_verification_consistency(self, metadata: dict[str, Any]) -> list[ValidationIssue]: + verification = metadata.get("verification") or {} + case_type = verification.get("case_type") + if case_type is None: + return [] + + spec = CASE_TABLE.get(case_type) + if spec is None: + return [ValidationIssue("verification.case_type", f"Unknown case_type '{case_type}'", "error")] + + issues: list[ValidationIssue] = [] + if verification.get("verdict") != spec.verdict: + issues.append(ValidationIssue( + "verification.verdict", + f"verdict '{verification.get('verdict')}' does not match case_type '{case_type}' expected '{spec.verdict}'", + "error", + )) + if verification.get("reject_code") != spec.reject_code: + issues.append(ValidationIssue( + "verification.reject_code", + f"reject_code '{verification.get('reject_code')}' does not match case_type '{case_type}' expected '{spec.reject_code}'", + "error", + )) + return issues + + +@dataclass +class ValidationSummary: + """Aggregate statistics over a batch of per-sample validation reports.""" + + total: int + passed: int + failed: int + total_errors: int + total_warnings: int + failed_sample_ids: list[str] + + def as_dict(self) -> dict[str, Any]: + return { + "total": self.total, + "passed": self.passed, + "failed": self.failed, + "pass_rate": round(self.passed / self.total, 4) if self.total else 0.0, + "total_errors": self.total_errors, + "total_warnings": self.total_warnings, + "failed_sample_ids": self.failed_sample_ids, + } + + +def summarize(reports: list[ValidationReport]) -> ValidationSummary: + """Aggregate a list of per-sample validation reports into a batch summary.""" + passed = sum(1 for r in reports if r.passed) + return ValidationSummary( + total=len(reports), + passed=passed, + failed=len(reports) - passed, + total_errors=sum(len(r.errors) for r in reports), + total_warnings=sum(len(r.warnings) for r in reports), + failed_sample_ids=[r.sample_id for r in reports if not r.passed], + ) diff --git a/src/augmentation/__init__.py b/src/augmentation/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e927ec98c1762c54d98d8ce7f902589ed628c6f1 --- /dev/null +++ b/src/augmentation/__init__.py @@ -0,0 +1 @@ +"""Configurable augmentation effects for signature layers and full documents.""" diff --git a/src/augmentation/albumentations_effects.py b/src/augmentation/albumentations_effects.py new file mode 100644 index 0000000000000000000000000000000000000000..e61f598f0e9fcb08393c9fa71fdc7594a44edbbd --- /dev/null +++ b/src/augmentation/albumentations_effects.py @@ -0,0 +1,76 @@ +"""Thin, deterministic wrappers around Albumentations transforms. + +Every wrapper collapses the transform's own randomized magnitude range down +to values derived from our ``strength in [0, 1]`` convention, and executes +via a freshly seeded ``A.Compose`` so results are fully reproducible under +:mod:`src.utils.random_utils` seed derivation rather than Albumentations' +global random state. +""" + +from __future__ import annotations + +import albumentations as A +import numpy as np + + +def _run(transform: "A.BasicTransform", image: np.ndarray, seed: int) -> np.ndarray: + pipeline = A.Compose([transform], seed=seed, p=1.0) + return pipeline(image=image)["image"] + + +def motion_blur(image: np.ndarray, strength: float, seed: int) -> np.ndarray: + """Directional motion blur; ``strength`` controls kernel size (3..21 px).""" + k = int(round(3 + strength * 18)) + k = k + 1 if k % 2 == 0 else k + k = max(3, min(k, 21)) + return _run(A.MotionBlur(blur_limit=(k, k), p=1.0), image, seed) + + +def gaussian_blur(image: np.ndarray, strength: float, seed: int) -> tuple[np.ndarray, float]: + """Isotropic Gaussian blur; ``strength`` controls sigma (0.2..4.0 px).""" + sigma = round(0.2 + strength * 3.8, 3) + out = _run(A.GaussianBlur(blur_limit=0, sigma_limit=(sigma, sigma), p=1.0), image, seed) + return out, sigma + + +def perspective_warp(image: np.ndarray, strength: float, seed: int) -> np.ndarray: + """Subtle perspective distortion; ``strength`` controls corner-jitter scale.""" + scale = round(0.01 + strength * 0.09, 4) + return _run(A.Perspective(scale=(scale, scale), keep_size=True, fit_output=False, p=1.0), image, seed) + + +def contrast_shift(image: np.ndarray, strength: float, seed: int) -> np.ndarray: + """Global contrast shift; ``strength`` controls the max +/- magnitude.""" + mag = round(strength * 0.45, 4) + t = A.RandomBrightnessContrast(brightness_limit=(0.0, 0.0), contrast_limit=(-mag, mag), p=1.0) + return _run(t, image, seed) + + +def gamma_shift(image: np.ndarray, strength: float, seed: int) -> tuple[np.ndarray, int]: + """Gamma correction; ``strength`` controls deviation from gamma=100.""" + delta = int(round(strength * 45)) + lo, hi = max(100 - delta, 40), 100 + delta + out = _run(A.RandomGamma(gamma_limit=(lo, hi), p=1.0), image, seed) + return out, lo if lo != hi else lo + + +def jpeg_compression(image: np.ndarray, min_quality: int, max_quality: int, seed: int) -> tuple[np.ndarray, int]: + """JPEG re-compression artifact simulation over ``[min_quality, max_quality]``.""" + rng = np.random.default_rng(seed) + quality = int(rng.integers(min_quality, max_quality + 1)) + out = _run(A.ImageCompression(compression_type="jpeg", quality_range=(quality, quality), p=1.0), image, seed) + return out, quality + + +def salt_and_pepper(image: np.ndarray, strength: float, seed: int) -> np.ndarray: + """Salt-and-pepper impulse noise; ``strength`` controls the affected pixel fraction.""" + amount = round(0.002 + strength * 0.09, 5) + t = A.SaltAndPepper(amount=(amount, amount), salt_vs_pepper=(0.45, 0.55), p=1.0) + return _run(t, image, seed) + + +def sensor_noise(image: np.ndarray, strength: float, seed: int) -> np.ndarray: + """Gaussian sensor/scanner noise; ``strength`` controls the noise std as a fraction of range.""" + std = round(0.01 + strength * 0.12, 5) + t = A.GaussNoise(std_range=(std, std), mean_range=(0.0, 0.0), per_channel=False, p=1.0) + return _run(t, image, seed) diff --git a/src/augmentation/augmentation_engine.py b/src/augmentation/augmentation_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..da253fd93b02eea8d3e6300a4a2c4964bfb9804f --- /dev/null +++ b/src/augmentation/augmentation_engine.py @@ -0,0 +1,267 @@ +"""Orchestrates every configurable augmentation effect for one sample. + +Two passes are performed: + +1. :meth:`AugmentationEngine.apply_to_signature` -- effects that only make + sense applied to the ink itself (stroke geometry, ink density, localized + blur/jitter), run on the RGBA signature layer *before* blending. +2. :meth:`AugmentationEngine.apply_to_document` -- effects that simulate + physical document/scanner degradation, run on the fully blended page. + +Every effect is gated independently by its own ``enabled`` flag, a +probability roll, and a strength value -- each resolved from a seed derived +deterministically from ``(sample_seed, augmentation_name)`` (or an explicit +per-effect ``seed`` override in config), guaranteeing reproducibility. +Every effect's resolved parameters are collected into an +:class:`AugmentationRecord` for full metadata traceability, even when the +effect was not applied. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import numpy as np + +from src.augmentation import albumentations_effects as AE +from src.augmentation import document_effects as DE +from src.augmentation import signature_effects as SE +from src.config.schema import AugmentationParam, AugmentationsConfig +from src.utils.logging_utils import get_logger +from src.utils.random_utils import SeedManager, resolve_scalar_or_range + +logger = get_logger(__name__) + + +@dataclass +class AugmentationRecord: + """The resolved, applied-or-not state of a single augmentation effect.""" + + name: str + enabled: bool + applied: bool + strength: float + probability: float + seed: int + params: dict[str, Any] = field(default_factory=dict) + + +def _prepare(name: str, cfg: AugmentationParam, seed_manager: SeedManager) -> tuple[bool, np.random.Generator, int]: + seed = cfg.seed if cfg.seed is not None else seed_manager.seed_for(f"augmentation:{name}") + rng = np.random.default_rng(seed) + roll = float(rng.uniform(0.0, 1.0)) + applied = bool(cfg.enabled and roll < cfg.probability) + return applied, rng, seed + + +def _record(name: str, cfg: AugmentationParam, applied: bool, seed: int, + strength: float = 0.0, params: "dict | None" = None) -> AugmentationRecord: + return AugmentationRecord( + name=name, enabled=cfg.enabled, applied=applied, strength=round(strength, 4), + probability=cfg.probability, seed=seed, params=params or {}, + ) + + +class AugmentationEngine: + """Applies the full configurable augmentation suite to a signature layer and a document. + + Example: + >>> engine = AugmentationEngine(cfg.augmentation) + >>> rgba, sig_records = engine.apply_to_signature(placed_rgba, seed_manager) + >>> doc, doc_records = engine.apply_to_document(blended_doc, seed_manager) + """ + + def __init__(self, config: AugmentationsConfig) -> None: + self.config = config + + # ------------------------------------------------------------------ # + # Signature-layer effects + # ------------------------------------------------------------------ # + def apply_to_signature( + self, rgba: np.ndarray, seed_manager: SeedManager, + ) -> tuple[np.ndarray, dict[str, AugmentationRecord]]: + cfg = self.config + records: dict[str, AugmentationRecord] = {} + out = rgba + + applied, rng, seed = _prepare("rotation", cfg.rotation, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.rotation.strength, rng) + extra_deg = float(rng.uniform(-1, 1)) * strength * 8.0 + out = SE.jitter_rotation(out, extra_deg) + records["rotation"] = _record("rotation", cfg.rotation, True, seed, strength, {"extra_deg": round(extra_deg, 3)}) + else: + records["rotation"] = _record("rotation", cfg.rotation, False, seed) + + applied, rng, seed = _prepare("stroke_dilation", cfg.stroke_dilation, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.stroke_dilation.strength, rng) + out = SE.stroke_morphology(out, strength, thicken=True) + records["stroke_dilation"] = _record("stroke_dilation", cfg.stroke_dilation, True, seed, strength, {"thicken": True}) + else: + records["stroke_dilation"] = _record("stroke_dilation", cfg.stroke_dilation, False, seed) + + applied, rng, seed = _prepare("ink_fading", cfg.ink_fading, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.ink_fading.strength, rng) + out = SE.ink_fading(out, strength, rng) + records["ink_fading"] = _record("ink_fading", cfg.ink_fading, True, seed, strength) + else: + records["ink_fading"] = _record("ink_fading", cfg.ink_fading, False, seed) + + applied, rng, seed = _prepare("ink_bleed", cfg.ink_bleed, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.ink_bleed.strength, rng) + out = SE.ink_bleed(out, strength) + records["ink_bleed"] = _record("ink_bleed", cfg.ink_bleed, True, seed, strength) + else: + records["ink_bleed"] = _record("ink_bleed", cfg.ink_bleed, False, seed) + + applied, rng, seed = _prepare("motion_blur", cfg.motion_blur, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.motion_blur.strength, rng) + angle = float(rng.uniform(0, 180)) + out = SE.motion_blur_signature(out, strength, angle) + records["motion_blur"] = _record("motion_blur", cfg.motion_blur, True, seed, strength, {"angle_deg": round(angle, 2)}) + else: + records["motion_blur"] = _record("motion_blur", cfg.motion_blur, False, seed) + + applied, rng, seed = _prepare("gaussian_blur", cfg.gaussian_blur, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.gaussian_blur.strength, rng) + sigma = 0.2 + strength * 2.2 + out = SE.gaussian_blur_signature(out, sigma) + records["gaussian_blur"] = _record("gaussian_blur", cfg.gaussian_blur, True, seed, strength, {"sigma": round(sigma, 3)}) + else: + records["gaussian_blur"] = _record("gaussian_blur", cfg.gaussian_blur, False, seed) + + return out, records + + # ------------------------------------------------------------------ # + # Document-level effects + # ------------------------------------------------------------------ # + def apply_to_document( + self, image: np.ndarray, seed_manager: SeedManager, + ) -> tuple[np.ndarray, dict[str, AugmentationRecord]]: + cfg = self.config + records: dict[str, AugmentationRecord] = {} + out = image + + applied, rng, seed = _prepare("perspective", cfg.perspective, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.perspective.strength, rng) + out = AE.perspective_warp(out, strength, seed) + records["perspective"] = _record("perspective", cfg.perspective, True, seed, strength) + else: + records["perspective"] = _record("perspective", cfg.perspective, False, seed) + + applied, rng, seed = _prepare("contrast", cfg.contrast, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.contrast.strength, rng) + out = AE.contrast_shift(out, strength, seed) + records["contrast"] = _record("contrast", cfg.contrast, True, seed, strength) + else: + records["contrast"] = _record("contrast", cfg.contrast, False, seed) + + applied, rng, seed = _prepare("gamma", cfg.gamma, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.gamma.strength, rng) + out, gamma_value = AE.gamma_shift(out, strength, seed) + records["gamma"] = _record("gamma", cfg.gamma, True, seed, strength, {"gamma": gamma_value}) + else: + records["gamma"] = _record("gamma", cfg.gamma, False, seed) + + applied, rng, seed = _prepare("coffee_stain", cfg.coffee_stain, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.coffee_stain.strength, rng) + out, params = DE.coffee_stain(out, strength, rng) + records["coffee_stain"] = _record("coffee_stain", cfg.coffee_stain, True, seed, strength, params) + else: + records["coffee_stain"] = _record("coffee_stain", cfg.coffee_stain, False, seed) + + applied, rng, seed = _prepare("stain", cfg.stain, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.stain.strength, rng) + out, params = DE.stain(out, strength, cfg.stain.type, rng) + records["stain"] = _record("stain", cfg.stain, True, seed, strength, params) + else: + records["stain"] = _record("stain", cfg.stain, False, seed) + + applied, rng, seed = _prepare("shadow", cfg.shadow, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.shadow.strength, rng) + out, params = DE.shadow(out, strength, cfg.shadow.type, rng) + records["shadow"] = _record("shadow", cfg.shadow, True, seed, strength, params) + else: + records["shadow"] = _record("shadow", cfg.shadow, False, seed) + + applied, rng, seed = _prepare("crumple", cfg.crumple, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.crumple.strength, rng) + density = resolve_scalar_or_range(cfg.crumple.wrinkle_density, rng) + out, params = DE.crumple(out, strength, density, rng) + records["crumple"] = _record("crumple", cfg.crumple, True, seed, strength, {**params, "wrinkle_density": round(density, 3)}) + else: + records["crumple"] = _record("crumple", cfg.crumple, False, seed) + + applied, rng, seed = _prepare("discoloration", cfg.discoloration, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.discoloration.strength, rng) + out, params = DE.discoloration(out, strength, cfg.discoloration.tone, rng) + records["discoloration"] = _record("discoloration", cfg.discoloration, True, seed, strength, params) + else: + records["discoloration"] = _record("discoloration", cfg.discoloration, False, seed) + + applied, rng, seed = _prepare("page_tilt", cfg.page_tilt, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.page_tilt.strength, rng) + out, params = DE.page_tilt(out, strength, rng) + records["page_tilt"] = _record("page_tilt", cfg.page_tilt, True, seed, strength, params) + else: + records["page_tilt"] = _record("page_tilt", cfg.page_tilt, False, seed) + + applied, rng, seed = _prepare("dust", cfg.dust, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.dust.strength, rng) + out, params = DE.dust(out, strength, rng) + records["dust"] = _record("dust", cfg.dust, True, seed, strength, params) + else: + records["dust"] = _record("dust", cfg.dust, False, seed) + + applied, rng, seed = _prepare("scanner_streaks", cfg.scanner_streaks, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.scanner_streaks.strength, rng) + out, params = DE.scanner_streaks(out, strength, rng) + records["scanner_streaks"] = _record("scanner_streaks", cfg.scanner_streaks, True, seed, strength, params) + else: + records["scanner_streaks"] = _record("scanner_streaks", cfg.scanner_streaks, False, seed) + + applied, rng, seed = _prepare("scanner_noise", cfg.scanner_noise, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.scanner_noise.strength, rng) + out = AE.sensor_noise(out, strength, seed) + records["scanner_noise"] = _record("scanner_noise", cfg.scanner_noise, True, seed, strength) + else: + records["scanner_noise"] = _record("scanner_noise", cfg.scanner_noise, False, seed) + + applied, rng, seed = _prepare("salt_pepper", cfg.salt_pepper, seed_manager) + if applied: + strength = resolve_scalar_or_range(cfg.salt_pepper.strength, rng) + out = AE.salt_and_pepper(out, strength, seed) + records["salt_pepper"] = _record("salt_pepper", cfg.salt_pepper, True, seed, strength) + else: + records["salt_pepper"] = _record("salt_pepper", cfg.salt_pepper, False, seed) + + # JPEG compression must run last: it is a lossy re-encode and every + # subsequent pixel-level effect would otherwise mask its artifacts. + jpeg_cfg = cfg.jpeg_compression + applied, rng, seed = _prepare("jpeg_compression", jpeg_cfg, seed_manager) + if applied: + strength = resolve_scalar_or_range(jpeg_cfg.strength, rng) + out, quality = AE.jpeg_compression(out, jpeg_cfg.min_quality, jpeg_cfg.max_quality, seed) + records["jpeg_compression"] = _record("jpeg_compression", jpeg_cfg, True, seed, strength, {"quality": quality}) + else: + records["jpeg_compression"] = _record("jpeg_compression", jpeg_cfg, False, seed) + + return out, records diff --git a/src/augmentation/document_effects.py b/src/augmentation/document_effects.py new file mode 100644 index 0000000000000000000000000000000000000000..3d45931e8bb854e9215578343c6a5a3f0c7c08c6 --- /dev/null +++ b/src/augmentation/document_effects.py @@ -0,0 +1,235 @@ +"""Custom augmentation effects operating on the full, already-blended document image. + +These simulate physical document degradation and scanning artifacts that +Albumentations has no domain-specific transform for: coffee/water/paper +stains, directional shadows, page creasing, discoloration, dust, and +scanner streaks. Each function returns ``(image, actual_params)`` so the +exact resolved parameters (e.g. which corner a shadow fell in) can be +recorded in metadata. +""" + +from __future__ import annotations + +import cv2 +import numpy as np + +from src.utils.transform_utils import smooth_noise_field + + +def _blob_mask(shape: tuple[int, int], center: tuple[float, float], radii: tuple[float, float], + angle_deg: float, blur_sigma: float) -> np.ndarray: + """Build a soft-edged elliptical blob mask in ``[0, 1]``.""" + h, w = shape + mask = np.zeros((h, w), dtype=np.uint8) + cv2.ellipse(mask, (int(center[0]), int(center[1])), (int(radii[0]), int(radii[1])), angle_deg, 0, 360, 255, -1) + mask_f = cv2.GaussianBlur(mask.astype(np.float32), (0, 0), sigmaX=blur_sigma) / 255.0 + return np.clip(mask_f, 0, 1) + + +def coffee_stain(image: np.ndarray, strength: float, rng: np.random.Generator) -> tuple[np.ndarray, dict]: + """Overlay 1-3 translucent brown ring-edged blotches, like a coffee cup mark.""" + h, w = image.shape[:2] + out = image.astype(np.float32) + n_blobs = 1 + int(rng.integers(0, 1 + int(round(strength * 3)))) + centers = [] + for _ in range(max(n_blobs, 1)): + cx, cy = rng.uniform(0.1, 0.9) * w, rng.uniform(0.1, 0.9) * h + base_r = (0.03 + strength * 0.10) * min(w, h) + rx, ry = base_r * rng.uniform(0.8, 1.3), base_r * rng.uniform(0.7, 1.2) + angle = float(rng.uniform(0, 180)) + fill = _blob_mask((h, w), (cx, cy), (rx, ry), angle, blur_sigma=base_r * 0.35) + rim = fill - _blob_mask((h, w), (cx, cy), (rx * 0.82, ry * 0.82), angle, blur_sigma=base_r * 0.2) + color = np.array([139, 98, 60], dtype=np.float32) + opacity = 0.10 + strength * 0.30 + for c in range(3): + out[:, :, c] = out[:, :, c] * (1 - fill * opacity * 0.5) + color[c] * fill * opacity * 0.5 + out[:, :, c] = out[:, :, c] * (1 - rim * opacity * 0.9) + color[c] * 0.7 * rim * opacity * 0.9 + centers.append((round(float(cx), 1), round(float(cy), 1))) + return np.clip(out, 0, 255).astype(np.uint8), {"blob_count": len(centers), "centers": centers} + + +def stain(image: np.ndarray, strength: float, stain_type: str, rng: np.random.Generator) -> tuple[np.ndarray, dict]: + """Overlay a paper or water stain blotch. + + ``paper`` stains are mottled brownish-grey discolorations; ``water`` + stains are pale, soft-edged, faintly blue-grey tide marks. + """ + h, w = image.shape[:2] + out = image.astype(np.float32) + resolved_type = stain_type if stain_type != "random" else rng.choice(["paper", "water"]) + cx, cy = rng.uniform(0.15, 0.85) * w, rng.uniform(0.15, 0.85) * h + base_r = (0.06 + strength * 0.16) * min(w, h) + + if resolved_type == "water": + color = np.array([170, 178, 182], dtype=np.float32) + opacity = 0.06 + strength * 0.18 + rx, ry = base_r * rng.uniform(1.0, 1.6), base_r * rng.uniform(0.8, 1.3) + fill = _blob_mask((h, w), (cx, cy), (rx, ry), float(rng.uniform(0, 180)), blur_sigma=base_r * 0.6) + edge = fill - _blob_mask((h, w), (cx, cy), (rx * 0.9, ry * 0.9), 0, blur_sigma=base_r * 0.3) + for c in range(3): + out[:, :, c] = out[:, :, c] * (1 - fill * opacity * 0.4) + color[c] * fill * opacity * 0.4 + out[:, :, c] = out[:, :, c] * (1 - edge * opacity) + color[c] * edge * opacity + else: + color = np.array([120, 106, 84], dtype=np.float32) + opacity = 0.08 + strength * 0.22 + for _ in range(3): + rr = base_r * rng.uniform(0.4, 1.0) + ox, oy = cx + rng.uniform(-base_r, base_r) * 0.5, cy + rng.uniform(-base_r, base_r) * 0.5 + fill = _blob_mask((h, w), (ox, oy), (rr, rr * rng.uniform(0.7, 1.1)), float(rng.uniform(0, 180)), + blur_sigma=rr * 0.5) + for c in range(3): + out[:, :, c] = out[:, :, c] * (1 - fill * opacity * 0.5) + color[c] * fill * opacity * 0.5 + + return np.clip(out, 0, 255).astype(np.uint8), { + "resolved_type": resolved_type, "center": (round(float(cx), 1), round(float(cy), 1)), + } + + +def shadow(image: np.ndarray, strength: float, shadow_type: str, rng: np.random.Generator) -> tuple[np.ndarray, dict]: + """Apply an edge, corner, or fold shadow gradient.""" + h, w = image.shape[:2] + resolved_type = shadow_type if shadow_type != "random" else rng.choice(["edge", "corner", "fold"]) + out = image.astype(np.float32) + darkness = 0.15 + strength * 0.55 + + if resolved_type == "edge": + side = rng.choice(["top", "bottom", "left", "right"]) + band_frac = 0.12 + strength * 0.18 + yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) + if side == "top": + grad = np.clip(1 - yy / (h * band_frac), 0, 1) + elif side == "bottom": + grad = np.clip(1 - (h - 1 - yy) / (h * band_frac), 0, 1) + elif side == "left": + grad = np.clip(1 - xx / (w * band_frac), 0, 1) + else: + grad = np.clip(1 - (w - 1 - xx) / (w * band_frac), 0, 1) + gain = 1 - grad * darkness + out *= gain[:, :, None] + extra = {"side": side} + elif resolved_type == "corner": + corner = rng.choice(["top_left", "top_right", "bottom_left", "bottom_right"]) + cx = 0 if "left" in corner else w + cy = 0 if "top" in corner else h + yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) + dist = np.hypot(xx - cx, yy - cy) + radius = 0.55 * min(w, h) * (0.7 + strength * 0.6) + grad = np.clip(1 - dist / radius, 0, 1) + gain = 1 - grad * darkness + out *= gain[:, :, None] + extra = {"corner": corner} + else: # fold + n_folds = 1 + int(strength * 2) + extra_lines = [] + for _ in range(n_folds): + vertical = bool(rng.integers(0, 2)) + pos = rng.uniform(0.25, 0.75) + width = (0.015 + strength * 0.03) * (w if vertical else h) + yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) + axis = xx if vertical else yy + center = pos * (w if vertical else h) + dist = np.abs(axis - center) + grad = np.clip(1 - dist / max(width, 1.0), 0, 1) + gain = 1 - grad * darkness * 0.7 + out *= gain[:, :, None] + extra_lines.append({"vertical": vertical, "position_frac": round(float(pos), 3)}) + extra = {"folds": extra_lines} + + return np.clip(out, 0, 255).astype(np.uint8), {"resolved_type": resolved_type, **extra} + + +def crumple(image: np.ndarray, strength: float, wrinkle_density: float, rng: np.random.Generator) -> tuple[np.ndarray, dict]: + """Simulate paper crumpling: subtle geometric displacement plus faint wrinkle-line shading.""" + h, w = image.shape[:2] + field_x = smooth_noise_field((h, w), rng, blur_frac=0.06) - 0.5 + field_y = smooth_noise_field((h, w), rng, blur_frac=0.06) - 0.5 + amplitude = strength * min(w, h) * 0.015 + + yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) + map_x = xx + field_x * amplitude + map_y = yy + field_y * amplitude + warped = cv2.remap(image, map_x, map_y, interpolation=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE) + + shade_field = smooth_noise_field((h, w), rng, blur_frac=0.03) + n_lines = int(3 + wrinkle_density * 12) + line_mask = np.zeros((h, w), dtype=np.float32) + for _ in range(n_lines): + x1, y1 = rng.uniform(0, w), rng.uniform(0, h) + angle = rng.uniform(0, 2 * np.pi) + length = rng.uniform(0.15, 0.5) * min(w, h) + x2, y2 = x1 + np.cos(angle) * length, y1 + np.sin(angle) * length + cv2.line(line_mask, (int(x1), int(y1)), (int(x2), int(y2)), 1.0, thickness=1) + line_mask = cv2.GaussianBlur(line_mask, (0, 0), sigmaX=1.0) + + shading = (shade_field - 0.5) * strength * 0.25 - line_mask * strength * 0.18 + out = warped.astype(np.float32) * (1 + shading[:, :, None]) + return np.clip(out, 0, 255).astype(np.uint8), {"wrinkle_lines": n_lines, "displacement_amplitude_px": round(float(amplitude), 3)} + + +_SEPIA_MATRIX = np.array([ + [0.393, 0.769, 0.189], + [0.349, 0.686, 0.168], + [0.272, 0.534, 0.131], +], dtype=np.float32) + + +def discoloration(image: np.ndarray, strength: float, tone: str, rng: np.random.Generator) -> tuple[np.ndarray, dict]: + """Apply an overall color-cast discoloration: yellowing, greying, or sepia toning.""" + resolved_tone = tone if tone != "random" else rng.choice(["yellow", "grey", "sepia"]) + img_f = image.astype(np.float32) + + if resolved_tone == "yellow": + tint = np.array([1.0 + strength * 0.12, 1.0 + strength * 0.04, 1.0 - strength * 0.28], dtype=np.float32) + out = img_f * tint[None, None, :] + elif resolved_tone == "grey": + gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY).astype(np.float32) + gray3 = np.repeat(gray[:, :, None], 3, axis=2) + out = img_f * (1 - strength * 0.6) + gray3 * (strength * 0.6) + else: # sepia + sepia = img_f @ _SEPIA_MATRIX.T + out = img_f * (1 - strength * 0.7) + sepia * (strength * 0.7) + + unevenness = smooth_noise_field(image.shape[:2], rng, blur_frac=0.05) + gain = 1 - (1 - unevenness) * strength * 0.08 + out *= gain[:, :, None] + + return np.clip(out, 0, 255).astype(np.uint8), {"resolved_tone": resolved_tone} + + +def page_tilt(image: np.ndarray, strength: float, rng: np.random.Generator) -> tuple[np.ndarray, dict]: + """Rotate the whole page by a small angle, simulating a crooked scanner feed.""" + h, w = image.shape[:2] + angle = float(rng.uniform(-1.0, 1.0) * (1.5 + strength * 6.5)) + matrix = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 1.0) + rotated = cv2.warpAffine(image, matrix, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE) + return rotated, {"tilt_deg": round(angle, 3)} + + +def dust(image: np.ndarray, strength: float, rng: np.random.Generator) -> tuple[np.ndarray, dict]: + """Scatter small light/dark specks simulating dust and sensor debris.""" + h, w = image.shape[:2] + out = image.astype(np.float32) + n_specks = int((h * w) * 0.00003 * (1 + strength * 6)) + xs = rng.integers(0, w, size=n_specks) + ys = rng.integers(0, h, size=n_specks) + for x, y in zip(xs, ys): + r = int(rng.integers(1, 3)) + val = float(rng.choice([255.0, 40.0])) + cv2.circle(out, (int(x), int(y)), r, (val, val, val), -1) + return np.clip(out, 0, 255).astype(np.uint8), {"speck_count": int(n_specks)} + + +def scanner_streaks(image: np.ndarray, strength: float, rng: np.random.Generator) -> tuple[np.ndarray, dict]: + """Add faint vertical scan-line streaks, simulating a dirty scanner sensor/glass.""" + h, w = image.shape[:2] + out = image.astype(np.float32) + n_streaks = 1 + int(strength * 6) + xs = sorted(int(v) for v in rng.integers(0, w, size=n_streaks)) + for x in xs: + width = int(rng.integers(1, 3)) + delta = float(rng.uniform(-1, 1)) * (10 + strength * 35) + x1, x2 = max(x - width, 0), min(x + width, w) + out[:, x1:x2, :] += delta + return np.clip(out, 0, 255).astype(np.uint8), {"streak_count": n_streaks, "positions_x": xs} + + diff --git a/src/augmentation/signature_effects.py b/src/augmentation/signature_effects.py new file mode 100644 index 0000000000000000000000000000000000000000..dc0ee4d9d0736bb3ac0549d0849e9927a31fdac3 --- /dev/null +++ b/src/augmentation/signature_effects.py @@ -0,0 +1,106 @@ +"""Custom augmentation effects operating on the RGBA signature layer. + +These act on the placed signature *before* it is blended onto the document, +so they affect only the ink itself (stroke geometry, ink density) rather +than the surrounding paper. +""" + +from __future__ import annotations + +import cv2 +import numpy as np + +from src.utils.transform_utils import rotate_and_scale_rgba, smooth_noise_field + + +def stroke_morphology(rgba: np.ndarray, strength: float, thicken: bool) -> np.ndarray: + """Thicken or thin ink strokes via morphological dilation/erosion of the alpha channel.""" + ksize = max(1, int(round(1 + strength * 5))) + ksize = ksize + 1 if ksize % 2 == 0 else ksize + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ksize, ksize)) + alpha = rgba[:, :, 3] + op = cv2.dilate if thicken else cv2.erode + new_alpha = op(alpha, kernel, iterations=1) + out = rgba.copy() + out[:, :, 3] = new_alpha + return out + + +def motion_blur_signature(rgba: np.ndarray, strength: float, angle_deg: float) -> np.ndarray: + """Directional (hand-shake style) motion blur applied to the signature stamp.""" + length = max(3, int(round(3 + strength * 16))) + kernel = np.zeros((length, length), dtype=np.float32) + kernel[length // 2, :] = 1.0 + center = (length / 2 - 0.5, length / 2 - 0.5) + rot = cv2.getRotationMatrix2D(center, angle_deg, 1.0) + kernel = cv2.warpAffine(kernel, rot, (length, length)) + kernel_sum = kernel.sum() + if kernel_sum > 1e-6: + kernel /= kernel_sum + + out = rgba.astype(np.float32) + for c in range(4): + out[:, :, c] = cv2.filter2D(out[:, :, c], -1, kernel, borderType=cv2.BORDER_CONSTANT) + return np.clip(out, 0, 255).astype(np.uint8) + + +def gaussian_blur_signature(rgba: np.ndarray, sigma: float) -> np.ndarray: + """Isotropic blur of the signature stamp (softens edges, simulates scan blur/feathering).""" + if sigma <= 0: + return rgba + blurred = cv2.GaussianBlur(rgba.astype(np.float32), (0, 0), sigmaX=sigma) + return np.clip(blurred, 0, 255).astype(np.uint8) + + +def ink_fading(rgba: np.ndarray, strength: float, rng: np.random.Generator) -> np.ndarray: + """Fade ink unevenly, simulating a pen running low on ink mid-stroke.""" + h, w = rgba.shape[:2] + if h < 2 or w < 2: + return rgba + field = smooth_noise_field((h, w), rng, blur_frac=0.15) + multiplier = 1.0 - strength * (1.0 - field) * 0.9 + multiplier = np.clip(multiplier, 0.05, 1.0) + out = rgba.copy() + out[:, :, 3] = np.clip(out[:, :, 3].astype(np.float32) * multiplier, 0, 255).astype(np.uint8) + return out + + +def ink_bleed(rgba: np.ndarray, strength: float) -> np.ndarray: + """Simulate ink bleeding/feathering into paper fibers around stroke edges.""" + ksize = max(1, int(round(1 + strength * 4))) + ksize = ksize + 1 if ksize % 2 == 0 else ksize + alpha = rgba[:, :, 3].astype(np.float32) + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ksize, ksize)) + dilated = cv2.dilate(alpha, kernel, iterations=1) + halo = cv2.GaussianBlur(dilated, (0, 0), sigmaX=max(ksize * 0.6, 0.8)) + bled = np.maximum(alpha, halo * (0.35 + 0.35 * strength)) + out = rgba.copy() + out[:, :, 3] = np.clip(bled, 0, 255).astype(np.uint8) + return out + + +def _fit_to_canvas(rgba: np.ndarray, canvas_h: int, canvas_w: int) -> np.ndarray: + """Center-crop/pad ``rgba`` back to ``(canvas_h, canvas_w)``. + + ``rotate_and_scale_rgba`` auto-expands its output to avoid clipping, + which is correct for a standalone stamp but not here: these jitter + effects run on the full-canvas placement layer, and every downstream + stage (blending, geometry detection) assumes that layer stays exactly + canvas-sized. + """ + h, w = rgba.shape[:2] + if h == canvas_h and w == canvas_w: + return rgba + out = np.zeros((canvas_h, canvas_w, 4), dtype=rgba.dtype) + src_x1, src_y1 = max((w - canvas_w) // 2, 0), max((h - canvas_h) // 2, 0) + src = rgba[src_y1:src_y1 + min(h, canvas_h), src_x1:src_x1 + min(w, canvas_w)] + sh, sw = src.shape[:2] + dst_x1, dst_y1 = max((canvas_w - w) // 2, 0), max((canvas_h - h) // 2, 0) + out[dst_y1:dst_y1 + sh, dst_x1:dst_x1 + sw] = src + return out + + +def jitter_rotation(rgba: np.ndarray, extra_deg: float) -> np.ndarray: + """Apply a small extra rotation on top of the base placement rotation.""" + h, w = rgba.shape[:2] + return _fit_to_canvas(rotate_and_scale_rgba(rgba, extra_deg, 1.0), h, w) diff --git a/src/config/loader.py b/src/config/loader.py new file mode 100644 index 0000000000000000000000000000000000000000..abbdf01e33af3d8f8d56176bb4f859a401f75641 --- /dev/null +++ b/src/config/loader.py @@ -0,0 +1,70 @@ +"""OmegaConf-based configuration loading, merging, and Pydantic validation. + +Supports: + +* A base YAML file (typically ``configs/default.yaml``). +* An optional user YAML file merged on top of the base. +* Dot-list CLI overrides (``key.subkey=value``), e.g. as produced by + ``OmegaConf.from_dotlist(["placement.rotation_deg.min=-5"])``. + +The merged structure is converted to a plain Python container and validated +against :class:`~src.config.schema.PipelineConfig`, so any typo or +out-of-range value fails fast with a clear error instead of silently being +ignored. +""" + +from __future__ import annotations + +from pathlib import Path + +from omegaconf import OmegaConf + +from src.config.schema import PipelineConfig + + +def _default_config_path() -> Path: + return Path(__file__).resolve().parents[2] / "configs" / "default.yaml" + + +def load_config( + config_path: "str | Path | None" = None, + overrides: "list[str] | None" = None, +) -> PipelineConfig: + """Load, merge, and validate the pipeline configuration. + + Args: + config_path: Path to a user YAML config. If ``None``, only the + built-in ``configs/default.yaml`` is used. + overrides: Optional list of ``"dotted.key=value"`` CLI-style + overrides, applied last (highest precedence). + + Returns: + A fully validated :class:`~src.config.schema.PipelineConfig`. + + Raises: + pydantic.ValidationError: If the merged configuration is invalid. + FileNotFoundError: If ``config_path`` is given but does not exist. + """ + base = OmegaConf.load(_default_config_path()) + + merged = base + if config_path is not None: + config_path = Path(config_path) + if not config_path.exists(): + raise FileNotFoundError(f"Config file not found: {config_path}") + user_conf = OmegaConf.load(config_path) + merged = OmegaConf.merge(base, user_conf) + + if overrides: + cli_conf = OmegaConf.from_dotlist(overrides) + merged = OmegaConf.merge(merged, cli_conf) + + resolved = OmegaConf.to_container(merged, resolve=True) + return PipelineConfig.model_validate(resolved) + + +def save_resolved_config(config: PipelineConfig, path: "str | Path") -> None: + """Write a fully-resolved configuration (as actually used) to YAML for provenance.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + OmegaConf.save(OmegaConf.create(config.model_dump()), path) diff --git a/src/generator.py b/src/generator.py new file mode 100644 index 0000000000000000000000000000000000000000..181ac39a6fffea6fb6daf3d905f44e1ecf0e4447 --- /dev/null +++ b/src/generator.py @@ -0,0 +1,554 @@ +"""Top-level orchestration: DatasetGenerator drives the full per-sample pipeline. + +For every sample this wires together, in order: + +1. :class:`~src.templates.template_loader.TemplateLoader` -- pick a template + signature box. +2. :class:`~src.signatures.signature_dataset.SignatureDataset` -- pick a signature image. +3. :class:`~src.rendering.signature_preprocessor.SignaturePreprocessor` -- clean it into an RGBA stamp. +4. :class:`~src.placement.placement_engine.PlacementEngine` -- fit/rotate/position it. +5. :class:`~src.augmentation.augmentation_engine.AugmentationEngine` -- signature-layer effects. +6. :class:`~src.rendering.renderer.Renderer` -- blend onto the document. +7. :class:`~src.augmentation.augmentation_engine.AugmentationEngine` -- document-level effects. +8. :class:`~src.analysis.geometry_analyzer.GeometryAnalyzer` -- detect + measure. +9. :class:`~src.analysis.difficulty_analyzer.DifficultyAnalyzer` -- score. +10. :class:`~src.metadata.metadata_exporter.MetadataExporter` -- assemble + write JSON. +11. :class:`~src.analysis.validation_engine.ValidationEngine` -- verify. + +A single :class:`GeneratorContext` holds all stateless-between-calls engine +instances for one process; :class:`DatasetGenerator` either drives it +directly (``workers=1``) or fans it out across a +:class:`concurrent.futures.ProcessPoolExecutor` pool, where each worker +process builds exactly one context via ``_init_worker`` and reuses it for +every sample it is assigned. Because every random draw is derived purely +from ``(master_seed, sample_index, stage)``, results are identical +regardless of worker count or scheduling order. +""" + +from __future__ import annotations + +from concurrent.futures import ProcessPoolExecutor, as_completed +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np +from tqdm import tqdm + +from src.analysis.difficulty_analyzer import DifficultyAnalyzer +from src.analysis.geometry_analyzer import GeometryAnalyzer +from src.analysis.validation_engine import ValidationEngine, ValidationReport, summarize +from src.augmentation.augmentation_engine import AugmentationEngine +from src.config.schema import PipelineConfig +from src.metadata.metadata_exporter import MetadataExporter +from src.placement.placement_engine import PlacementEngine, PlacementResult +from src.rendering.printed_text import render_printed_name_stamp +from src.rendering.renderer import Renderer +from src.rendering.signature_preprocessor import SignaturePreprocessor +from src.signatures.signature_dataset import SignatureDataset, SignatureRecord +from src.templates.annotation_schema import SignatureBoxAnnotation +from src.templates.template_loader import Template, TemplateLoader +from src.utils.image_io import load_image_any, load_image_rgb, save_image_rgb +from src.utils.logging_utils import get_logger +from src.utils.names import generate_name, generate_role +from src.utils.random_utils import SeedManager, resolve_scalar_or_range +from src.verification.case_types import CASE_TABLE, CaseSpec +from src.verification.registry import SignerIdentityRegistry +from src.visualization.visualizer import save_visualization + +logger = get_logger(__name__) + +INK_COLOR_POOL: tuple[tuple[int, int, int], ...] = ( + (15, 15, 20), # black + (18, 22, 58), # blue-black + (24, 34, 96), # ballpoint blue + (12, 12, 14), # deep black +) + + +@dataclass +class SampleOutcome: + """Lightweight, picklable result of generating one sample (used across process boundaries).""" + + sample_id: str + success: bool + error: "str | None" = None + difficulty_tier: "str | None" = None + difficulty_score: "float | None" = None + validation_passed: "bool | None" = None + validation_errors: int = 0 + validation_warnings: int = 0 + + +class GeneratorContext: + """Holds every stateless-between-calls engine instance needed to generate a sample.""" + + def __init__(self, config: PipelineConfig) -> None: + self.config = config + self.template_loader = TemplateLoader(config.paths.image_dir, config.paths.annotations_dir) + self.signature_dataset = SignatureDataset(config.paths.dataset_dir) + self.preprocessor = SignaturePreprocessor() + self.placement_engine = PlacementEngine(config.placement) + self.augmentation_engine = AugmentationEngine(config.augmentation) + self.renderer = Renderer(config.rendering) + self.geometry_analyzer = GeometryAnalyzer() + self.difficulty_analyzer = DifficultyAnalyzer(config.difficulty) + self.metadata_exporter = MetadataExporter() + self.validation_engine = ValidationEngine(config.validation, config.difficulty) + + # Trigger discovery eagerly so the first sample doesn't pay (and doesn't + # race another worker on) the filesystem-scan + annotation-cache cost. + self.template_loader.discover() + self.signature_dataset.discover() + self.identity_registry = SignerIdentityRegistry(self.template_loader, self.signature_dataset, config.seed) + + def run_sample(self, sample_index: int) -> tuple[dict[str, Any], "np.ndarray", "ValidationReport | None"]: + cfg = self.config + sm = SeedManager(cfg.seed, sample_index) + prefix = cfg.generation.filename_prefix + digits = cfg.generation.filename_digits + sample_id = f"{prefix}_{sample_index:0{digits}d}" + + case_spec = CASE_TABLE.get(cfg.quality.case_type) + if case_spec is not None: + return self._run_case_sample(sample_id, sm, case_spec) + return self._run_generic_sample(sample_id, sm) + + def _run_generic_sample(self, sample_id: str, sm: SeedManager) -> tuple[dict[str, Any], "np.ndarray", "ValidationReport | None"]: + cfg = self.config + template = self._select_template(sm) + page_annotation = self._select_page(template) + box = self._select_box(page_annotation, sm) + + document_image = load_image_rgb(template.image_path) + canvas_h, canvas_w = document_image.shape[:2] + target_box = box.as_tuple() + + signer_name = cfg.signer.name or generate_name(sm.python_rng("signer_name")) + signer_role = cfg.signer.role or box.role or generate_role(sm.python_rng("signer_role")) + + signature_record = self._select_signature(sm) + raw_signature = load_image_any(signature_record.path) + ink_color = INK_COLOR_POOL[int(sm.numpy_rng("ink_color").integers(0, len(INK_COLOR_POOL)))] + signature_rgba = self.preprocessor.process(raw_signature, ink_color=ink_color) + + placement = self.placement_engine.place( + signature_rgba, target_box=target_box, canvas_size=(canvas_w, canvas_h), seed_manager=sm, + ) + signature_layer, signature_aug_records = self.augmentation_engine.apply_to_signature(placement.layer, sm) + render_result = self.renderer.blend(document_image, signature_layer, sm) + final_image, document_aug_records = self.augmentation_engine.apply_to_document(render_result.image, sm) + + geometry = self.geometry_analyzer.analyze( + final_image, target_box=target_box, canvas_size=(canvas_w, canvas_h), + placement_hint_bbox=placement.intended_bbox, + ) + difficulty = self.difficulty_analyzer.analyze(geometry) + + quality_rng = sm.numpy_rng("quality") + quality_values = { + "scanner_quality": round(resolve_scalar_or_range(cfg.quality.scanner_quality, quality_rng), 4), + "paper_quality": round(resolve_scalar_or_range(cfg.quality.paper_quality, quality_rng), 4), + "render_quality": round(resolve_scalar_or_range(cfg.quality.render_quality, quality_rng), 4), + } + + metadata = self.metadata_exporter.build( + document_id=sample_id, + template_id=template.template_id, + document_type=template.annotation.document_type, + page=page_annotation.page, + page_size=(canvas_w, canvas_h), + dpi=page_annotation.dpi, + signer_name=signer_name, + signer_role=signer_role, + signature_source=signature_record, + placement=placement, + render=render_result, + signature_aug=signature_aug_records, + document_aug=document_aug_records, + geometry=geometry, + difficulty=difficulty, + quality_cfg=cfg.quality, + quality_values=quality_values, + seed=cfg.seed, + sample_index=sm.sample_index, + verification=None, + ) + + validation_report = None + if cfg.generation.run_validation: + validation_report = self.validation_engine.validate(metadata) + + return metadata, final_image, validation_report + + # ------------------------------------------------------------------ # + # Verification test-case generation (case_type-driven) + # ------------------------------------------------------------------ # + + def _run_case_sample( + self, sample_id: str, sm: SeedManager, case_spec: CaseSpec, + ) -> tuple[dict[str, Any], "np.ndarray", "ValidationReport | None"]: + cfg = self.config + template = self._select_template_for_case(sm, case_spec) + page_annotation = self._select_page(template) + boxes = page_annotation.signature_boxes + if not boxes: + raise ValueError(f"Page {page_annotation.page} has no signature boxes defined.") + target_box, companion_box = self._select_case_boxes(boxes, sm) + + document_image = load_image_rgb(template.image_path) + canvas_h, canvas_w = document_image.shape[:2] + target_box_tuple = target_box.as_tuple() + canvas_size = (canvas_w, canvas_h) + + expected_group = self.identity_registry.authorized_signer_group(template.template_id, target_box.id) + signer_name = cfg.signer.name or self.identity_registry.display_name(expected_group) + signer_role = cfg.signer.role or target_box.role + + ink_color = INK_COLOR_POOL[int(sm.numpy_rng("ink_color").integers(0, len(INK_COLOR_POOL)))] + + document_after_companion = document_image + companion_info: "dict[str, Any] | None" = None + if case_spec.fill_companion: + if companion_box is None: + logger.warning( + "Case %s wants a companion signer but template %s has only one signature box; skipping.", + case_spec.code, template.template_id, + ) + else: + companion_group = self.identity_registry.authorized_signer_group(template.template_id, companion_box.id) + sm_companion = SeedManager(cfg.seed, f"{sm.sample_index}_companion") + companion_record = self.signature_dataset.sample_from_group( + sm_companion.numpy_rng("case:companion_signature"), companion_group, source_filter="genuine", + ) + companion_raw = load_image_any(companion_record.path) + companion_rgba = self.preprocessor.process(companion_raw, ink_color=ink_color) + companion_placement = self.placement_engine.place( + companion_rgba, target_box=companion_box.as_tuple(), canvas_size=canvas_size, seed_manager=sm_companion, + ) + companion_layer, _ = self.augmentation_engine.apply_to_signature(companion_placement.layer, sm_companion) + companion_render = self.renderer.blend(document_after_companion, companion_layer, sm_companion) + document_after_companion = companion_render.image + companion_info = { + "box_id": companion_box.id, "filled": True, + "signer_group": companion_group, "script": self.identity_registry.script_for(companion_group), + "matches_expected": True, + } + + actual_record, actual_group, actual_source_type, target_rgba = self._resolve_case_signature( + case_spec, template, target_box, companion_box, expected_group, signer_name, ink_color, sm, + ) + + if target_rgba is None: + placement = PlacementResult( + layer=np.zeros((canvas_h, canvas_w, 4), dtype=np.uint8), + target_box=target_box_tuple, intended_bbox=target_box_tuple, + x_offset=0.0, y_offset=0.0, rotation_deg=0.0, scale_factor=0.0, + anchor_point=cfg.placement.anchor_point, misplaced=False, + misplacement_dx=0.0, misplacement_dy=0.0, + ) + else: + placement = self.placement_engine.place( + target_rgba, target_box=target_box_tuple, canvas_size=canvas_size, seed_manager=sm, + ) + signature_layer, signature_aug_records = self.augmentation_engine.apply_to_signature(placement.layer, sm) + render_result = self.renderer.blend(document_after_companion, signature_layer, sm) + final_image, document_aug_records = self.augmentation_engine.apply_to_document(render_result.image, sm) + + geometry = self.geometry_analyzer.analyze( + final_image, target_box=target_box_tuple, canvas_size=canvas_size, + placement_hint_bbox=placement.intended_bbox, + ) + difficulty = self.difficulty_analyzer.analyze(geometry) + + quality_rng = sm.numpy_rng("quality") + quality_values = { + "scanner_quality": round(resolve_scalar_or_range(cfg.quality.scanner_quality, quality_rng), 4), + "paper_quality": round(resolve_scalar_or_range(cfg.quality.paper_quality, quality_rng), 4), + "render_quality": round(resolve_scalar_or_range(cfg.quality.render_quality, quality_rng), 4), + } + + verification_record = { + "case_type": case_spec.code, + "verdict": case_spec.verdict, + "reject_code": case_spec.reject_code, + "target_box_id": target_box.id, + "expected_signer_group": expected_group, + "expected_signer_script": self.identity_registry.script_for(expected_group), + "actual_signer_group": actual_group, + "actual_source_type": actual_source_type, + "actual_signer_script": self.identity_registry.script_for(actual_group) if actual_group else None, + "companion": companion_info, + } + + metadata = self.metadata_exporter.build( + document_id=sample_id, + template_id=template.template_id, + document_type=template.annotation.document_type, + page=page_annotation.page, + page_size=(canvas_w, canvas_h), + dpi=page_annotation.dpi, + signer_name=signer_name, + signer_role=signer_role, + signature_source=actual_record, + placement=placement, + render=render_result, + signature_aug=signature_aug_records, + document_aug=document_aug_records, + geometry=geometry, + difficulty=difficulty, + quality_cfg=cfg.quality, + quality_values=quality_values, + seed=cfg.seed, + sample_index=sm.sample_index, + verification=verification_record, + ) + + validation_report = None + if cfg.generation.run_validation: + validation_report = self.validation_engine.validate(metadata) + + return metadata, final_image, validation_report + + def _select_template_for_case(self, sm: SeedManager, case_spec: CaseSpec) -> Template: + cfg = self.config + if cfg.document.template: + return self.template_loader.get(cfg.document.template) + + templates = self.template_loader.discover() + if case_spec.fill_companion: + candidates = sorted( + tid for tid, t in templates.items() if len(t.annotation.pages[0].signature_boxes) >= 2 + ) + if not candidates: + logger.warning( + "Case %s needs a multi-box template but none were found; using the full template pool.", + case_spec.code, + ) + candidates = sorted(templates.keys()) + else: + candidates = sorted(templates.keys()) + + idx = int(sm.numpy_rng("template_selection").integers(0, len(candidates))) + return self.template_loader.get(candidates[idx]) + + def _select_case_boxes( + self, boxes: "list[SignatureBoxAnnotation]", sm: SeedManager, + ) -> "tuple[SignatureBoxAnnotation, SignatureBoxAnnotation | None]": + idx = int(sm.numpy_rng("box_selection").integers(0, len(boxes))) + target = boxes[idx] + others = [b for i, b in enumerate(boxes) if i != idx] + companion = others[0] if others else None + return target, companion + + def _resolve_case_signature( + self, case_spec: CaseSpec, template: Template, target_box: "SignatureBoxAnnotation", + companion_box: "SignatureBoxAnnotation | None", expected_group: str, expected_name: str, + ink_color: tuple[int, int, int], sm: SeedManager, + ) -> "tuple[SignatureRecord | None, str | None, str, np.ndarray | None]": + """Resolve what actually goes in the target box for one case type. + + Returns ``(signature_record_or_none, actual_signer_group_or_none, + actual_source_type, rgba_layer_or_none)``. + """ + mode = case_spec.signature_mode + + if mode == "blank": + return None, None, "none", None + + if mode == "printed_text": + layer_rgba = render_printed_name_stamp(expected_name, ink_color=ink_color) + return None, None, "printed", layer_rgba + + if mode == "genuine_correct": + group, source_filter, stage = expected_group, "genuine", "case:target_signature" + elif mode == "genuine_variation": + group, source_filter, stage = expected_group, "genuine", "case:target_signature_variant" + elif mode == "forged_skilled": + group, source_filter, stage = expected_group, "forged", "case:forged_signature" + elif mode == "wrong_signer_genuine": + wrong_group = self.identity_registry.random_wrong_signer_group( + sm.python_rng("case:wrong_signer"), expected_group, + ) + group, source_filter, stage = wrong_group, "genuine", "case:wrong_signature" + elif mode == "role_swap": + if companion_box is None: + raise ValueError(f"Case {case_spec.code} requires a companion box but none is available.") + group = self.identity_registry.authorized_signer_group(template.template_id, companion_box.id) + source_filter, stage = "genuine", "case:role_swap_signature" + elif mode == "foreign_template_signer": + group, _foreign_template_id, _foreign_box_id = self.identity_registry.foreign_authorized(template.template_id) + source_filter, stage = "genuine", "case:foreign_signature" + else: + raise ValueError(f"Unknown signature_mode: {mode!r}") + + record = self.signature_dataset.sample_from_group(sm.numpy_rng(stage), group, source_filter) + raw = load_image_any(record.path) + layer_rgba = self.preprocessor.process(raw, ink_color=ink_color) + return record, group, record.source_type, layer_rgba + + def _select_template(self, sm: SeedManager): + cfg = self.config + if cfg.document.template: + return self.template_loader.get(cfg.document.template) + return self.template_loader.random_template(sm.numpy_rng("template_selection")) + + def _select_page(self, template): + cfg = self.config + if cfg.document.page is not None: + return template.annotation.page_by_number(cfg.document.page) + return template.annotation.pages[0] + + def _select_box(self, page_annotation, sm: SeedManager): + boxes = page_annotation.signature_boxes + if not boxes: + raise ValueError(f"Page {page_annotation.page} has no signature boxes defined.") + idx = int(sm.numpy_rng("box_selection").integers(0, len(boxes))) + return boxes[idx] + + def _select_signature(self, sm: SeedManager): + cfg = self.config + if cfg.signature_selection.signature_id: + return self.signature_dataset.get(cfg.signature_selection.signature_id) + return self.signature_dataset.sample( + sm.numpy_rng("signature_selection"), source_filter=cfg.signature_selection.source_filter, + ) + + +_WORKER_CONTEXT: "GeneratorContext | None" = None +_WORKER_CONFIG: "PipelineConfig | None" = None + + +def _init_worker(config: PipelineConfig) -> None: + global _WORKER_CONTEXT, _WORKER_CONFIG + _WORKER_CONFIG = config + _WORKER_CONTEXT = GeneratorContext(config) + + +def _generate_and_export(sample_index: int) -> SampleOutcome: + """Module-level worker entry point: generate one sample and write it to disk.""" + assert _WORKER_CONTEXT is not None and _WORKER_CONFIG is not None, "Worker not initialized" + cfg = _WORKER_CONFIG + ctx = _WORKER_CONTEXT + prefix = cfg.generation.filename_prefix + digits = cfg.generation.filename_digits + sample_id = f"{prefix}_{sample_index:0{digits}d}" + output_dir = Path(cfg.paths.output_dir) + png_path = output_dir / f"{sample_id}.png" + json_path = output_dir / f"{sample_id}.json" + + if not cfg.generation.overwrite and png_path.exists() and json_path.exists(): + return SampleOutcome(sample_id=sample_id, success=True, error="skipped_existing") + + try: + metadata, final_image, validation_report = ctx.run_sample(sample_index) + save_image_rgb(png_path, final_image) + ctx.metadata_exporter.export(metadata, json_path) + + if cfg.generation.save_visualization: + viz_path = output_dir / "visualizations" / f"{sample_id}_viz.png" + save_visualization(viz_path, final_image, metadata) + + outcome = SampleOutcome( + sample_id=sample_id, success=True, + difficulty_tier=metadata["difficulty"]["tier"], + difficulty_score=metadata["difficulty"]["score"], + ) + if validation_report is not None: + outcome.validation_passed = validation_report.passed + outcome.validation_errors = len(validation_report.errors) + outcome.validation_warnings = len(validation_report.warnings) + if not validation_report.passed: + logger.warning( + "Sample %s failed validation: %s", sample_id, + "; ".join(i.message for i in validation_report.errors), + ) + return outcome + except Exception as exc: # noqa: BLE001 - report and continue the batch + logger.exception("Failed to generate sample %s", sample_id) + return SampleOutcome(sample_id=sample_id, success=False, error=str(exc)) + + +@dataclass +class GenerationSummary: + """Batch-level summary returned by :meth:`DatasetGenerator.generate`.""" + + total: int + succeeded: int + failed: int + skipped_existing: int + validation_passed: int + validation_failed: int + tier_counts: dict[str, int] + failed_samples: list[str] + + def as_dict(self) -> dict[str, Any]: + return { + "total": self.total, + "succeeded": self.succeeded, + "failed": self.failed, + "skipped_existing": self.skipped_existing, + "validation_passed": self.validation_passed, + "validation_failed": self.validation_failed, + "tier_counts": self.tier_counts, + "failed_samples": self.failed_samples, + } + + +class DatasetGenerator: + """Drives batch generation of a synthetic signed-document dataset. + + Example: + >>> generator = DatasetGenerator(config) + >>> summary = generator.generate() + """ + + def __init__(self, config: PipelineConfig) -> None: + self.config = config + Path(config.paths.output_dir).mkdir(parents=True, exist_ok=True) + + def generate(self) -> GenerationSummary: + """Generate ``config.generation.count`` samples, sequentially or via a worker pool.""" + cfg = self.config + indices = list(range(cfg.generation.start_index, cfg.generation.start_index + cfg.generation.count)) + outcomes: list[SampleOutcome] = [] + + if cfg.generation.workers <= 1: + _init_worker(cfg) + for idx in tqdm(indices, desc="Generating samples", unit="doc"): + outcomes.append(_generate_and_export(idx)) + else: + with ProcessPoolExecutor( + max_workers=cfg.generation.workers, initializer=_init_worker, initargs=(cfg,), + ) as pool: + futures = {pool.submit(_generate_and_export, idx): idx for idx in indices} + for future in tqdm(as_completed(futures), total=len(futures), desc="Generating samples", unit="doc"): + outcomes.append(future.result()) + + return self._summarize(outcomes) + + @staticmethod + def _summarize(outcomes: list[SampleOutcome]) -> GenerationSummary: + succeeded = [o for o in outcomes if o.success] + failed = [o for o in outcomes if not o.success] + skipped = [o for o in succeeded if o.error == "skipped_existing"] + validated = [o for o in succeeded if o.validation_passed is not None] + validation_passed = sum(1 for o in validated if o.validation_passed) + validation_failed = sum(1 for o in validated if not o.validation_passed) + + tier_counts: dict[str, int] = {} + for o in succeeded: + if o.difficulty_tier: + tier_counts[o.difficulty_tier] = tier_counts.get(o.difficulty_tier, 0) + 1 + + summary = GenerationSummary( + total=len(outcomes), + succeeded=len(succeeded), + failed=len(failed), + skipped_existing=len(skipped), + validation_passed=validation_passed, + validation_failed=validation_failed, + tier_counts=tier_counts, + failed_samples=[o.sample_id for o in failed], + ) + logger.info("Generation summary: %s", summary.as_dict()) + return summary diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..c069d8905448ac92d76e5079717032d9dcbb458b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,29 @@ +"""Shared pytest fixtures pointing at the project's real bundled assets.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture(scope="session") +def project_root() -> Path: + return PROJECT_ROOT + + +@pytest.fixture(scope="session") +def image_dir(project_root: Path) -> Path: + return project_root / "image" + + +@pytest.fixture(scope="session") +def dataset_dir(project_root: Path) -> Path: + return project_root / "dataset" + + +@pytest.fixture(scope="session") +def annotations_dir(project_root: Path) -> Path: + return project_root / "annotations" diff --git a/tests/test_difficulty_analyzer.py b/tests/test_difficulty_analyzer.py new file mode 100644 index 0000000000000000000000000000000000000000..ae14cb3df6a990bc5acf354c54d23b32869a809b --- /dev/null +++ b/tests/test_difficulty_analyzer.py @@ -0,0 +1,109 @@ +"""Unit tests for src.analysis.difficulty_analyzer -- verifies the exact spec formula.""" + +from __future__ import annotations + +import math + +from src.analysis.difficulty_analyzer import DifficultyAnalyzer +from src.analysis.geometry_analyzer import GeometryResult +from src.config.schema import DifficultyWeights + + +def _geometry( + polygon_overlap_proxy: float, whitespace_ratio: float, mean_dark_intensity: float, + signature_area_ratio: float, gray_stddev: float, +) -> GeometryResult: + box = (0.0, 0.0, 100.0, 20.0) + return GeometryResult( + found=True, signature_bbox_px=box, min_rotated_rect={}, signature_polygon_px=[[0, 0], [1, 0], [1, 1]], + convex_hull_px=[], centroid_px=(50.0, 10.0), orientation_deg=0.0, ink_pixels=100, + target_box=box, actual_box=box, search_box=box, + box_coverage_pct=0.0, containment_pct=0.0, ink_fill_ratio=1 - whitespace_ratio, + polygon_overlap_proxy=polygon_overlap_proxy, distance_from_box_center_px=0.0, margin_utilization=0.0, + mean_dark_intensity=mean_dark_intensity, gray_stddev=gray_stddev, + signature_area_ratio=signature_area_ratio, whitespace_ratio=whitespace_ratio, + ) + + +def test_difficulty_score_matches_exact_spec_formula(): + weights = DifficultyWeights() + analyzer = DifficultyAnalyzer(weights) + + geom = _geometry( + polygon_overlap_proxy=0.4, # misplacement = 0.6 + whitespace_ratio=0.5, # whitespace = 0.5 + mean_dark_intensity=150.0, # faintness = clamp((150-90)/120) = 0.5 + signature_area_ratio=0.02, # smallness = clamp(1 - 0.02/0.04) = 0.5 + gray_stddev=30.0, # low_texture = clamp(1 - 30/60) = 0.5 + ) + result = analyzer.analyze(geom) + + assert math.isclose(result.misplacement, 0.6, abs_tol=1e-6) + assert math.isclose(result.whitespace, 0.5, abs_tol=1e-6) + assert math.isclose(result.faintness, 0.5, abs_tol=1e-6) + assert math.isclose(result.smallness, 0.5, abs_tol=1e-6) + assert math.isclose(result.low_texture, 0.5, abs_tol=1e-6) + + expected = 0.35 * 0.6 + 0.20 * 0.5 + 0.20 * 0.5 + 0.15 * 0.5 + 0.10 * 0.5 + assert math.isclose(result.score, expected, abs_tol=1e-6) + + +def test_difficulty_components_are_clamped_to_unit_interval(): + weights = DifficultyWeights() + analyzer = DifficultyAnalyzer(weights) + + geom = _geometry( + polygon_overlap_proxy=-5.0, # misplacement would exceed 1 -> clamp to 1 + whitespace_ratio=2.0, # clamp to 1 + mean_dark_intensity=1000.0, # faintness clamp to 1 + signature_area_ratio=-1.0, # smallness clamp to 1 + gray_stddev=-10.0, # low_texture clamp to 1 + ) + result = analyzer.analyze(geom) + + assert result.misplacement == 1.0 + assert result.whitespace == 1.0 + assert result.faintness == 1.0 + assert result.smallness == 1.0 + assert result.low_texture == 1.0 + assert result.score == 1.0 + assert result.tier == "hard" + + +def test_difficulty_tier_boundaries(): + weights = DifficultyWeights() + analyzer = DifficultyAnalyzer(weights) + + # All-zero components -> score 0 -> easy. + geom_easy = _geometry( + polygon_overlap_proxy=1.0, whitespace_ratio=0.0, mean_dark_intensity=90.0, + signature_area_ratio=0.04, gray_stddev=60.0, + ) + assert analyzer.analyze(geom_easy).tier == "easy" + assert analyzer.analyze(geom_easy).score == 0.0 + + # All components at 1.0 -> score 1.0 -> hard. + geom_hard = _geometry( + polygon_overlap_proxy=0.0, whitespace_ratio=1.0, mean_dark_intensity=300.0, + signature_area_ratio=0.0, gray_stddev=0.0, + ) + assert analyzer.analyze(geom_hard).tier == "hard" + + +def test_difficulty_score_is_reproducible_given_same_geometry(): + weights = DifficultyWeights() + analyzer = DifficultyAnalyzer(weights) + geom = _geometry(0.3, 0.4, 140.0, 0.01, 25.0) + + r1 = analyzer.analyze(geom) + r2 = analyzer.analyze(geom) + + assert r1 == r2 + + +def test_difficulty_weights_must_sum_to_one(): + import pytest + from pydantic import ValidationError + + with pytest.raises(ValidationError): + DifficultyWeights(misplacement=0.9, whitespace=0.2, faintness=0.2, smallness=0.15, low_texture=0.10) diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000000000000000000000000000000000000..790dd73668ab4ac249807370a42cd5282a5ed9b7 --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,83 @@ +"""Integration tests for template and signature dataset discovery against the real bundled assets.""" + +from __future__ import annotations + +from src.signatures.signature_dataset import SignatureDataset +from src.templates.template_loader import TemplateLoader +import numpy as np + + +def test_template_loader_discovers_all_nine_templates(image_dir, annotations_dir): + loader = TemplateLoader(image_dir, annotations_dir) + templates = loader.discover() + assert len(templates) == 9 + assert set(templates.keys()) == {f"bank_doc_{i:02d}" for i in range(1, 10)} + + +def test_every_template_has_at_least_one_valid_signature_box(image_dir, annotations_dir): + loader = TemplateLoader(image_dir, annotations_dir) + for template_id in loader.list_ids(): + template = loader.get(template_id) + page = template.annotation.pages[0] + assert len(page.signature_boxes) >= 1 + for box in page.signature_boxes: + assert box.x2 > box.x1 + assert box.y2 > box.y1 + # Box must lie within the annotated page bounds. + assert 0 <= box.x1 and box.x2 <= page.page_size[0] + assert 0 <= box.y1 and box.y2 <= page.page_size[1] + + +def test_template_loader_random_template_is_deterministic(image_dir, annotations_dir): + loader = TemplateLoader(image_dir, annotations_dir) + t1 = loader.random_template(np.random.default_rng(123)) + t2 = loader.random_template(np.random.default_rng(123)) + assert t1.template_id == t2.template_id + + +def test_signature_dataset_discovers_bundled_dataset(dataset_dir): + ds = SignatureDataset(dataset_dir) + records = ds.discover() + assert len(records) > 0 + assert any(r.source_type == "genuine" for r in records) + assert any(r.source_type == "forged" for r in records) + + +def test_signature_dataset_source_filter(dataset_dir): + ds = SignatureDataset(dataset_dir) + rng = np.random.default_rng(0) + for _ in range(20): + record = ds.sample(rng, source_filter="genuine") + assert record.source_type == "genuine" + + +def test_signature_dataset_sample_is_deterministic(dataset_dir): + ds = SignatureDataset(dataset_dir) + r1 = ds.sample(np.random.default_rng(55), source_filter="any") + r2 = ds.sample(np.random.default_rng(55), source_filter="any") + assert r1.signature_id == r2.signature_id + + +def test_signature_dataset_discovers_multi_script_named_signers(dataset_dir): + """dataset/metadata.csv associates dataset/hindi_bengali/*.png with real named, + scripted identities (see dataset/hindi_bengali/) alongside the anonymous CEDAR pool.""" + ds = SignatureDataset(dataset_dir) + by_group = {} + for r in ds.discover(): + if "hindi_bengali" in r.path.as_posix(): + by_group.setdefault(r.signer_group, set()).add(r.script) + + expected = { + "Priya Sharma": {"hindi"}, "Raj Malhotra": {"hindi"}, "Vikram Singh": {"hindi"}, + "Suman Das": {"bengali"}, "Anika Roy": {"bengali"}, + "Carol Wong": {"latin"}, "Henry Ooi": {"latin"}, + } + assert by_group == expected + for name in expected: + assert ds.script_for_group(name) == next(iter(expected[name])) + + +def test_default_script_for_unlabeled_records_is_latin(dataset_dir): + ds = SignatureDataset(dataset_dir) + cedar_records = [r for r in ds.discover() if "hindi_bengali" not in r.path.as_posix()] + assert all(r.script == "latin" for r in cedar_records) diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py new file mode 100644 index 0000000000000000000000000000000000000000..ca9a36fe7aec1ff35209d92cafb1ec0aa90fed1b --- /dev/null +++ b/tests/test_end_to_end.py @@ -0,0 +1,60 @@ +"""End-to-end integration tests running the full pipeline against the real bundled assets.""" + +from __future__ import annotations + +import json + +import numpy as np + +from src.config.loader import load_config +from src.generator import GeneratorContext + + +def _make_context(tmp_path, seed: int = 123): + overrides = [ + f"paths.output_dir={tmp_path.as_posix()}", + "generation.count=1", + ] + config = load_config(overrides=[f"seed={seed}"] + overrides) + return config, GeneratorContext(config) + + +def test_run_sample_produces_complete_metadata(tmp_path): + config, ctx = _make_context(tmp_path) + metadata, final_image, validation_report = ctx.run_sample(1) + + assert isinstance(final_image, np.ndarray) + assert final_image.ndim == 3 and final_image.shape[2] == 3 + assert final_image.dtype == np.uint8 + + for key in ("document_id", "template", "signer", "difficulty", "quality", "augmentation", "geometry", "metrics"): + assert key in metadata + + assert validation_report is not None + assert validation_report.passed, [i.message for i in validation_report.issues] + + +def test_run_sample_is_fully_reproducible(tmp_path): + config, ctx = _make_context(tmp_path, seed=555) + metadata1, image1, _ = ctx.run_sample(42) + metadata2, image2, _ = ctx.run_sample(42) + + assert np.array_equal(image1, image2) + metadata1.pop("provenance", None) + metadata2.pop("provenance", None) + assert json.dumps(metadata1, sort_keys=True, default=str) == json.dumps(metadata2, sort_keys=True, default=str) + + +def test_different_sample_indices_produce_different_documents(tmp_path): + config, ctx = _make_context(tmp_path, seed=555) + metadata1, image1, _ = ctx.run_sample(1) + metadata2, image2, _ = ctx.run_sample(2) + + assert not np.array_equal(image1, image2) or metadata1["signer"] != metadata2["signer"] + + +def test_difficulty_tier_is_one_of_expected_values(tmp_path): + config, ctx = _make_context(tmp_path, seed=7) + metadata, _, _ = ctx.run_sample(1) + assert metadata["difficulty"]["tier"] in ("easy", "medium", "hard") + assert 0.0 <= metadata["difficulty"]["score"] <= 1.0 diff --git a/tests/test_geometry_analyzer.py b/tests/test_geometry_analyzer.py new file mode 100644 index 0000000000000000000000000000000000000000..640df006bfbe1b3e8ce73d64ef933622f029a1aa --- /dev/null +++ b/tests/test_geometry_analyzer.py @@ -0,0 +1,94 @@ +"""Unit tests for src.analysis.geometry_analyzer.""" + +from __future__ import annotations + +import numpy as np + +from src.analysis.geometry_analyzer import GeometryAnalyzer + + +def _blank_page(w: int = 400, h: int = 300) -> np.ndarray: + return np.full((h, w, 3), 250, dtype=np.uint8) + + +def _draw_ink_blob(image: np.ndarray, box) -> np.ndarray: + out = image.copy() + x1, y1, x2, y2 = box + out[y1:y2, x1:x2] = 30 # dark ink blob + return out + + +def test_geometry_analyzer_detects_ink_inside_target_box(): + page = _blank_page() + target_box = (100, 100, 200, 150) + page = _draw_ink_blob(page, (120, 115, 180, 135)) + + analyzer = GeometryAnalyzer() + result = analyzer.analyze(page, target_box=target_box, canvas_size=(400, 300)) + + assert result.found is True + assert result.ink_pixels > 0 + assert result.box_coverage_pct > 0 + assert result.containment_pct > 0 + + +def test_geometry_analyzer_reports_not_found_on_blank_box(): + page = _blank_page() + target_box = (100, 100, 200, 150) + + analyzer = GeometryAnalyzer() + result = analyzer.analyze(page, target_box=target_box, canvas_size=(400, 300)) + + assert result.found is False + assert result.ink_pixels == 0 + assert result.box_coverage_pct == 0.0 + assert result.polygon_overlap_proxy == 0.0 + + +def test_geometry_analyzer_ignores_printed_rule_line(): + """A thin, wide, solid horizontal line (a printed field rule) should not register as ink.""" + page = _blank_page() + target_box = (50, 50, 350, 70) + page[64:66, 60:340] = 40 # printed underline: 2px tall, very wide, solid + + analyzer = GeometryAnalyzer() + result = analyzer.analyze(page, target_box=target_box, canvas_size=(400, 300)) + + assert result.found is False + + +def test_geometry_analyzer_full_coverage_signature(): + page = _blank_page() + target_box = (100, 100, 200, 150) + page = _draw_ink_blob(page, (100, 100, 200, 150)) # fills the entire box + + analyzer = GeometryAnalyzer() + result = analyzer.analyze(page, target_box=target_box, canvas_size=(400, 300)) + + assert result.found is True + assert result.box_coverage_pct > 95.0 + + +def test_geometry_analyzer_widens_search_with_placement_hint(): + page = _blank_page() + target_box = (100, 100, 150, 120) + # Ink is well outside the tight target box (simulating a misplaced signature) + # but within the placement hint bbox used to widen the search. + hint_bbox = (250, 100, 320, 150) + page = _draw_ink_blob(page, (260, 110, 300, 140)) + + analyzer = GeometryAnalyzer() + result = analyzer.analyze(page, target_box=target_box, canvas_size=(400, 300), placement_hint_bbox=hint_bbox) + + assert result.found is True + # Detected ink should be near the hint location, not the empty target box. + assert result.signature_bbox_px[0] > 200 + + +def test_geometry_result_no_nan_when_not_found(): + import math + page = _blank_page() + analyzer = GeometryAnalyzer() + result = analyzer.analyze(page, target_box=(10, 10, 50, 30), canvas_size=(400, 300)) + assert not math.isnan(result.mean_dark_intensity) + assert not math.isnan(result.gray_stddev) diff --git a/tests/test_geometry_utils.py b/tests/test_geometry_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..45cf0ee0638bd7014eae2daaee709a60975a33ba --- /dev/null +++ b/tests/test_geometry_utils.py @@ -0,0 +1,95 @@ +"""Unit tests for src.utils.geometry_utils.""" + +from __future__ import annotations + +import math + +from src.utils.geometry_utils import ( + box_center, box_to_polygon, clamp01, containment_pct, coverage_pct, + distance_between, iou, margin_utilization, points_to_polygon, safe_area, +) + + +def test_box_to_polygon_area(): + poly = box_to_polygon((0, 0, 10, 20)) + assert safe_area(poly) == 200.0 + + +def test_box_to_polygon_normalizes_reversed_coords(): + poly = box_to_polygon((10, 20, 0, 0)) + assert safe_area(poly) == 200.0 + + +def test_coverage_pct_full_overlap(): + target = box_to_polygon((0, 0, 100, 100)) + sig = box_to_polygon((0, 0, 100, 100)) + assert coverage_pct(target, sig) == 100.0 + assert containment_pct(target, sig) == 100.0 + assert iou(target, sig) == 1.0 + + +def test_coverage_pct_partial_overlap(): + target = box_to_polygon((0, 0, 100, 100)) + sig = box_to_polygon((50, 0, 150, 100)) + # Half of the target box is covered by the signature. + assert math.isclose(coverage_pct(target, sig), 50.0, abs_tol=1e-6) + # Half of the signature lies within the target box. + assert math.isclose(containment_pct(target, sig), 50.0, abs_tol=1e-6) + + +def test_coverage_pct_no_overlap(): + target = box_to_polygon((0, 0, 10, 10)) + sig = box_to_polygon((100, 100, 110, 110)) + assert coverage_pct(target, sig) == 0.0 + assert containment_pct(target, sig) == 0.0 + assert iou(target, sig) == 0.0 + + +def test_iou_partial(): + a = box_to_polygon((0, 0, 10, 10)) + b = box_to_polygon((5, 0, 15, 10)) + # Intersection = 50, union = 100+100-50 = 150 + assert math.isclose(iou(a, b), 50.0 / 150.0, rel_tol=1e-6) + + +def test_points_to_polygon_handles_degenerate_input(): + poly = points_to_polygon([]) + assert poly.is_empty + poly_single = points_to_polygon([[5, 5]]) + assert safe_area(poly_single) > 0 + + +def test_points_to_polygon_valid_shape(): + poly = points_to_polygon([[0, 0], [10, 0], [10, 10], [0, 10]]) + assert poly.is_valid + assert safe_area(poly) == 100.0 + + +def test_box_center_and_distance(): + assert box_center((0, 0, 10, 20)) == (5.0, 10.0) + assert distance_between((0, 0), (3, 4)) == 5.0 + + +def test_distance_between_nan_safe(): + assert math.isnan(distance_between((float("nan"), 0), (1, 1))) + + +def test_clamp01(): + assert clamp01(-1) == 0.0 + assert clamp01(2) == 1.0 + assert clamp01(0.5) == 0.5 + assert clamp01(float("nan")) == 0.0 + + +def test_margin_utilization_centered_small_signature(): + box = (0, 0, 100, 100) + sig = box_to_polygon((45, 45, 55, 55)) + # A small centered signature uses very little of the available margin. + assert margin_utilization(box, sig) < 0.2 + + +def test_margin_utilization_full_box_signature(): + box = (0, 0, 100, 100) + sig = box_to_polygon((0, 0, 100, 100)) + # A signature filling the whole box reaches ~1.0 (corner at half-diagonal). + assert math.isclose(margin_utilization(box, sig), 1.0, rel_tol=1e-6) diff --git a/tests/test_placement_engine.py b/tests/test_placement_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..fde4a68f645c0c0a39bf824d7403d6256b76143c --- /dev/null +++ b/tests/test_placement_engine.py @@ -0,0 +1,92 @@ +"""Unit tests for src.placement.placement_engine.""" + +from __future__ import annotations + +import numpy as np + +from src.config.schema import PlacementConfig +from src.placement.placement_engine import MIN_SIGNATURE_HEIGHT_PX, PlacementEngine +from src.utils.random_utils import SeedManager + + +def _synthetic_signature(w: int = 300, h: int = 150) -> np.ndarray: + """A simple synthetic RGBA "signature": a diagonal opaque stroke on transparency.""" + rgba = np.zeros((h, w, 4), dtype=np.uint8) + for x in range(w): + y = int((x / w) * (h - 1)) + rgba[max(y - 3, 0):y + 3, x, :3] = (20, 20, 25) + rgba[max(y - 3, 0):y + 3, x, 3] = 255 + return rgba + + +def test_placement_is_deterministic_for_same_seed(): + cfg = PlacementConfig(rotation_deg={"min": -5, "max": 5}, scale_factor={"min": 0.7, "max": 1.0}) + engine = PlacementEngine(cfg) + sig = _synthetic_signature() + + r1 = engine.place(sig, target_box=(100, 100, 300, 200), canvas_size=(800, 600), seed_manager=SeedManager(1, 5)) + r2 = engine.place(sig, target_box=(100, 100, 300, 200), canvas_size=(800, 600), seed_manager=SeedManager(1, 5)) + + assert r1.rotation_deg == r2.rotation_deg + assert r1.scale_factor == r2.scale_factor + assert r1.intended_bbox == r2.intended_bbox + assert np.array_equal(r1.layer, r2.layer) + + +def test_placement_differs_for_different_sample_index(): + cfg = PlacementConfig(rotation_deg={"min": -5, "max": 5}, scale_factor={"min": 0.7, "max": 1.0}) + engine = PlacementEngine(cfg) + sig = _synthetic_signature() + + r1 = engine.place(sig, target_box=(100, 100, 300, 200), canvas_size=(800, 600), seed_manager=SeedManager(1, 1)) + r2 = engine.place(sig, target_box=(100, 100, 300, 200), canvas_size=(800, 600), seed_manager=SeedManager(1, 2)) + + assert r1.rotation_deg != r2.rotation_deg or r1.scale_factor != r2.scale_factor + + +def test_placement_respects_aspect_ratio_when_not_rotated(): + cfg = PlacementConfig(rotation_deg=0.0, scale_factor=0.5, margin=0.0) + engine = PlacementEngine(cfg) + sig = _synthetic_signature(w=300, h=150) # aspect ratio 2:1 + + result = engine.place(sig, target_box=(0, 0, 400, 400), canvas_size=(800, 800), seed_manager=SeedManager(1, 1)) + x1, y1, x2, y2 = result.intended_bbox + placed_w, placed_h = x2 - x1, y2 - y1 + assert abs((placed_w / placed_h) - 2.0) < 0.05 + + +def test_placement_output_layer_matches_canvas_size(): + cfg = PlacementConfig() + engine = PlacementEngine(cfg) + sig = _synthetic_signature() + result = engine.place(sig, target_box=(10, 10, 200, 100), canvas_size=(640, 480), seed_manager=SeedManager(2, 0)) + assert result.layer.shape == (480, 640, 4) + + +def test_tiny_box_still_produces_a_legible_minimum_height_signature(): + cfg = PlacementConfig(rotation_deg=0.0, scale_factor=1.0, margin=0.05) + engine = PlacementEngine(cfg) + sig = _synthetic_signature(w=300, h=150) + + # A box far shorter than the natural signature height. + result = engine.place(sig, target_box=(0, 0, 300, 6), canvas_size=(800, 800), seed_manager=SeedManager(1, 1)) + x1, y1, x2, y2 = result.intended_bbox + assert (y2 - y1) >= MIN_SIGNATURE_HEIGHT_PX - 1 # allow rounding + + +def test_misplacement_never_triggers_when_probability_zero(): + cfg = PlacementConfig(misplacement_probability=0.0) + engine = PlacementEngine(cfg) + sig = _synthetic_signature() + for i in range(20): + result = engine.place(sig, target_box=(50, 50, 250, 150), canvas_size=(800, 600), seed_manager=SeedManager(1, i)) + assert result.misplaced is False + + +def test_misplacement_always_triggers_when_probability_one(): + cfg = PlacementConfig(misplacement_probability=1.0, misplacement_strength=0.5) + engine = PlacementEngine(cfg) + sig = _synthetic_signature() + for i in range(10): + result = engine.place(sig, target_box=(50, 50, 250, 150), canvas_size=(800, 600), seed_manager=SeedManager(1, i)) + assert result.misplaced is True diff --git a/tests/test_random_utils.py b/tests/test_random_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..b8053622bedcd5b9bf2fd97b42b41d0909a8bb71 --- /dev/null +++ b/tests/test_random_utils.py @@ -0,0 +1,70 @@ +"""Unit tests for src.utils.random_utils -- the determinism backbone of the whole pipeline.""" + +from __future__ import annotations + +import numpy as np + +from src.config.schema import Range +from src.utils.random_utils import SeedManager, derive_seed, resolve_scalar_or_range + + +def test_derive_seed_is_deterministic(): + assert derive_seed(42, 1, "placement") == derive_seed(42, 1, "placement") + + +def test_derive_seed_varies_with_each_part(): + base = derive_seed(42, 1, "placement") + assert derive_seed(42, 2, "placement") != base + assert derive_seed(42, 1, "rendering") != base + assert derive_seed(43, 1, "placement") != base + + +def test_derive_seed_in_valid_range(): + seed = derive_seed(0, 999999, "x") + assert 0 <= seed < 2**32 + + +def test_seed_manager_same_sample_same_stage_is_deterministic(): + sm1 = SeedManager(master_seed=7, sample_index=3) + sm2 = SeedManager(master_seed=7, sample_index=3) + draws1 = sm1.numpy_rng("placement").uniform(0, 1, size=10) + draws2 = sm2.numpy_rng("placement").uniform(0, 1, size=10) + assert np.allclose(draws1, draws2) + + +def test_seed_manager_different_stages_are_independent(): + sm = SeedManager(master_seed=7, sample_index=3) + a = sm.numpy_rng("placement").uniform(0, 1, size=5) + b = sm.numpy_rng("rendering").uniform(0, 1, size=5) + assert not np.allclose(a, b) + + +def test_seed_manager_different_samples_are_independent(): + a = SeedManager(master_seed=7, sample_index=1).numpy_rng("placement").uniform(0, 1, size=5) + b = SeedManager(master_seed=7, sample_index=2).numpy_rng("placement").uniform(0, 1, size=5) + assert not np.allclose(a, b) + + +def test_resolve_scalar_or_range_with_scalar(): + rng = np.random.default_rng(0) + assert resolve_scalar_or_range(0.5, rng) == 0.5 + + +def test_resolve_scalar_or_range_with_range_stays_in_bounds(): + rng = np.random.default_rng(0) + r = Range(min=-3.0, max=5.0) + for _ in range(200): + v = resolve_scalar_or_range(r, rng) + assert -3.0 <= v <= 5.0 + + +def test_resolve_scalar_or_range_with_dict_range(): + rng = np.random.default_rng(0) + v = resolve_scalar_or_range({"min": 1.0, "max": 1.0}, rng) + assert v == 1.0 + + +def test_resolve_scalar_or_range_degenerate_range_returns_bound(): + rng = np.random.default_rng(0) + r = Range(min=2.0, max=2.0) + assert resolve_scalar_or_range(r, rng) == 2.0 diff --git a/tests/test_validation_engine.py b/tests/test_validation_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..1cf1ece404620f100f120fca46307935530ce8a9 --- /dev/null +++ b/tests/test_validation_engine.py @@ -0,0 +1,126 @@ +"""Unit tests for src.analysis.validation_engine.""" + +from __future__ import annotations + +import copy + +from src.analysis.validation_engine import ValidationEngine, summarize +from src.config.schema import DifficultyWeights, ValidationConfig + +VALID_METADATA = { + "document_id": "sample_000001", + "template": "bank_doc_01", + "page": 1, + "page_size": [1000, 800], + "signer": {"name": "Jane Doe", "role": "Account Holder"}, + "signature_source": {"signature_id": "sig_0001", "path": "dataset/genuine/x.png"}, + "placement": {"rotation_deg": 0.0}, + "rendering": {"blend_mode": "multiply"}, + "difficulty": { + "misplacement": 0.6, "whitespace": 0.5, "faintness": 0.5, "smallness": 0.5, "low_texture": 0.5, + "score": round(0.35 * 0.6 + 0.20 * 0.5 + 0.20 * 0.5 + 0.15 * 0.5 + 0.10 * 0.5, 4), + "tier": "medium", + }, + "quality": {"degraded": False}, + "verification": { + "case_type": None, "verdict": None, "reject_code": None, + "target_box_id": None, "expected_signer_group": None, "expected_signer_script": None, + "actual_signer_group": None, "actual_source_type": None, "actual_signer_script": None, + "companion": None, + }, + "augmentation": {}, + "geometry": { + "detected": True, + "ink_pixels": 120, + "target_box": [100, 100, 200, 150], + "actual_box": [110, 105, 190, 145], + "signature_bbox_px": [110, 105, 190, 145], + "box_px": [100, 100, 200, 150], + "signature_polygon_px": [[110, 105], [190, 105], [190, 145], [110, 145]], + }, + "metrics": {"box_coverage_pct": 80.0}, +} + + +def _engine(strict: bool = False) -> ValidationEngine: + return ValidationEngine(ValidationConfig(strict=strict), DifficultyWeights()) + + +def test_valid_metadata_passes(): + report = _engine().validate(copy.deepcopy(VALID_METADATA)) + assert report.passed, [i.message for i in report.issues] + assert not report.errors + + +def test_missing_top_level_key_fails(): + bad = copy.deepcopy(VALID_METADATA) + del bad["geometry"] + report = _engine().validate(bad) + assert not report.passed + assert any("geometry" in i.field for i in report.errors) + + +def test_signature_not_detected_fails(): + bad = copy.deepcopy(VALID_METADATA) + bad["geometry"]["detected"] = False + report = _engine().validate(bad) + assert not report.passed + + +def test_degenerate_polygon_fails(): + bad = copy.deepcopy(VALID_METADATA) + bad["geometry"]["signature_polygon_px"] = [[0, 0], [1, 1]] + report = _engine().validate(bad) + assert not report.passed + + +def test_invalid_box_ordering_fails(): + bad = copy.deepcopy(VALID_METADATA) + bad["geometry"]["target_box"] = [200, 100, 100, 150] # x2 < x1 + report = _engine().validate(bad) + assert not report.passed + + +def test_box_outside_page_is_warning_not_error_by_default(): + bad = copy.deepcopy(VALID_METADATA) + bad["geometry"]["target_box"] = [900, 700, 1200, 900] # exceeds page_size [1000, 800] + report = _engine(strict=False).validate(bad) + assert report.passed # warning only, not strict + assert report.warnings + + report_strict = _engine(strict=True).validate(bad) + assert not report_strict.passed + + +def test_nan_value_fails(): + bad = copy.deepcopy(VALID_METADATA) + bad["metrics"]["box_coverage_pct"] = float("nan") + report = _engine().validate(bad) + assert not report.passed + + +def test_difficulty_score_mismatch_fails(): + bad = copy.deepcopy(VALID_METADATA) + bad["difficulty"]["score"] = 0.999 # inconsistent with components + report = _engine().validate(bad) + assert not report.passed + + +def test_difficulty_tier_mismatch_fails(): + bad = copy.deepcopy(VALID_METADATA) + bad["difficulty"]["tier"] = "hard" # inconsistent with score ~0.49 -> medium + report = _engine().validate(bad) + assert not report.passed + + +def test_summarize_batch(): + good = _engine().validate(copy.deepcopy(VALID_METADATA)) + bad_meta = copy.deepcopy(VALID_METADATA) + bad_meta["geometry"]["detected"] = False + bad = _engine().validate(bad_meta) + + summary = summarize([good, bad]) + assert summary.total == 2 + assert summary.passed == 1 + assert summary.failed == 1 + assert "sample_000001" in summary.failed_sample_ids diff --git a/tests/test_verification_cases.py b/tests/test_verification_cases.py new file mode 100644 index 0000000000000000000000000000000000000000..cbffc6e5f092644317eafda43ae30706030b39cc --- /dev/null +++ b/tests/test_verification_cases.py @@ -0,0 +1,213 @@ +"""Tests for the case_type-driven verification test-case generation feature. + +Covers: registry determinism, correct rendering/ground-truth for every one +of the 12 case types, companion-box behavior, and backward compatibility +with the existing (non-case-type) generation path. +""" + +from __future__ import annotations + +from src.config.loader import load_config +from src.generator import GeneratorContext +from src.verification.case_types import CASE_TABLE +from src.verification.registry import SignerIdentityRegistry + + +def _make_context(tmp_path, case_type: "str | None" = None, seed: int = 4242): + overrides = [f"seed={seed}", f"paths.output_dir={tmp_path.as_posix()}", "generation.count=1"] + if case_type is not None: + overrides.append(f"quality.case_type={case_type}") + config = load_config(overrides=overrides) + return GeneratorContext(config) + + +# --------------------------------------------------------------------- # +# Registry +# --------------------------------------------------------------------- # + +def test_registry_authorized_signer_is_deterministic(tmp_path): + ctx1 = _make_context(tmp_path) + ctx2 = _make_context(tmp_path) + + for template_id, template in ctx1.template_loader.discover().items(): + for box in template.annotation.pages[0].signature_boxes: + a = ctx1.identity_registry.authorized_signer_group(template_id, box.id) + b = ctx2.identity_registry.authorized_signer_group(template_id, box.id) + assert a == b + + +def test_registry_display_name_is_stable_across_instances(tmp_path): + ctx1 = _make_context(tmp_path) + ctx2 = _make_context(tmp_path) + group = ctx1.identity_registry._signer_groups[0] + assert ctx1.identity_registry.display_name(group) == ctx2.identity_registry.display_name(group) + + +def test_registry_foreign_authorized_picks_a_different_template(tmp_path): + ctx = _make_context(tmp_path) + group, foreign_template_id, foreign_box_id = ctx.identity_registry.foreign_authorized("bank_doc_01") + assert foreign_template_id != "bank_doc_01" + assert group == ctx.identity_registry.authorized_signer_group(foreign_template_id, foreign_box_id) + + +# --------------------------------------------------------------------- # +# Per-case-type ground truth +# --------------------------------------------------------------------- # + +def test_all_case_types_produce_matching_verdict_and_reject_code(tmp_path): + for code, spec in CASE_TABLE.items(): + ctx = _make_context(tmp_path, case_type=code, seed=99) + metadata, _, report = ctx.run_sample(1) + v = metadata["verification"] + + assert v["case_type"] == code + assert v["verdict"] == spec.verdict, f"{code}: verdict mismatch" + assert v["reject_code"] == spec.reject_code, f"{code}: reject_code mismatch" + assert report is not None and report.passed, ( + f"{code}: validation failed: {[i.message for i in report.errors]}" + ) + + +def test_pass_cases_use_the_authorized_signer(tmp_path): + for code in ("1a", "1b", "1d"): + ctx = _make_context(tmp_path, case_type=code, seed=17) + metadata, _, _ = ctx.run_sample(1) + v = metadata["verification"] + assert v["actual_signer_group"] == v["expected_signer_group"] + assert v["actual_source_type"] == "genuine" + assert v["companion"] is None + + +def test_multi_signer_pass_case_fills_companion_correctly(tmp_path): + ctx = _make_context(tmp_path, case_type="1c", seed=17) + metadata, _, _ = ctx.run_sample(1) + v = metadata["verification"] + assert v["actual_signer_group"] == v["expected_signer_group"] + assert v["companion"] is not None + assert v["companion"]["filled"] is True + assert v["companion"]["matches_expected"] is True + + +def test_blank_cases_have_no_actual_signer(tmp_path): + for code in ("2a", "5a"): + ctx = _make_context(tmp_path, case_type=code, seed=5) + metadata, _, _ = ctx.run_sample(1) + v = metadata["verification"] + assert v["actual_signer_group"] is None + assert v["actual_source_type"] == "none" + assert metadata["signature_source"]["signature_id"] is None + + ctx_5a = _make_context(tmp_path, case_type="5a", seed=5) + metadata_5a, _, _ = ctx_5a.run_sample(1) + assert metadata_5a["verification"]["companion"]["filled"] is True + + ctx_2a = _make_context(tmp_path, case_type="2a", seed=5) + metadata_2a, _, _ = ctx_2a.run_sample(1) + assert metadata_2a["verification"]["companion"] is None + + +def test_identity_mismatch_cases_use_a_different_signer(tmp_path): + for code in ("3a", "5b"): + ctx = _make_context(tmp_path, case_type=code, seed=23) + metadata, _, _ = ctx.run_sample(1) + v = metadata["verification"] + assert v["actual_signer_group"] != v["expected_signer_group"] + assert v["actual_source_type"] == "genuine" + + +def test_skilled_forgery_case_uses_forged_sample_of_the_authorized_signer(tmp_path): + ctx = _make_context(tmp_path, case_type="3b", seed=23) + metadata, _, _ = ctx.run_sample(1) + v = metadata["verification"] + assert v["actual_signer_group"] == v["expected_signer_group"] + assert v["actual_source_type"] == "forged" + + +def test_role_mismatch_cases_use_a_legitimately_registered_signer(tmp_path): + ctx = _make_context(tmp_path, case_type="4a", seed=31) + metadata, _, _ = ctx.run_sample(1) + v = metadata["verification"] + # The target box got the *companion* box's own authorized signer. + assert v["actual_signer_group"] == v["companion"]["signer_group"] + assert v["actual_signer_group"] != v["expected_signer_group"] + + ctx_4b = _make_context(tmp_path, case_type="4b", seed=31) + metadata_4b, _, _ = ctx_4b.run_sample(1) + v4b = metadata_4b["verification"] + assert v4b["actual_signer_group"] != v4b["expected_signer_group"] + + +def test_printed_text_case_has_no_biometric_identity(tmp_path): + ctx = _make_context(tmp_path, case_type="2b", seed=11) + metadata, _, _ = ctx.run_sample(1) + v = metadata["verification"] + assert v["actual_signer_group"] is None + assert v["actual_source_type"] == "printed" + + +# --------------------------------------------------------------------- # +# Backward compatibility +# --------------------------------------------------------------------- # + +def test_unset_case_type_is_unaffected(tmp_path): + ctx = _make_context(tmp_path, case_type=None, seed=1) + metadata, _, report = ctx.run_sample(1) + v = metadata["verification"] + assert v == { + "case_type": None, "verdict": None, "reject_code": None, + "target_box_id": None, "expected_signer_group": None, "expected_signer_script": None, + "actual_signer_group": None, "actual_source_type": None, "actual_signer_script": None, + "companion": None, + } + assert report is not None and report.passed + + +# --------------------------------------------------------------------- # +# Multi-script signer pool (Hindi/Bengali/Latin named identities) +# --------------------------------------------------------------------- # + +def test_display_name_passes_through_real_names_but_generates_for_anonymous_groups(tmp_path): + ctx = _make_context(tmp_path) + assert ctx.identity_registry.display_name("Priya Sharma") == "Priya Sharma" + generated = ctx.identity_registry.display_name("signer_0014") + assert generated != "signer_0014" + assert " " in generated # looks like "First Last" + + +def test_named_signers_are_part_of_the_authorized_pool(tmp_path): + ctx = _make_context(tmp_path) + groups = set(ctx.signature_dataset.signer_groups()) + assert {"Priya Sharma", "Raj Malhotra", "Vikram Singh", "Suman Das", "Anika Roy", "Carol Wong", "Henry Ooi"} <= groups + + +def test_named_signer_flows_through_to_metadata_with_correct_script(tmp_path): + """Find a sample that lands on a named, scripted signer and check it renders cleanly.""" + ctx = _make_context(tmp_path, case_type="1a", seed=1) + for i in range(1, 200): + metadata, _, report = ctx.run_sample(i) + v = metadata["verification"] + if v["expected_signer_script"] != "latin": + assert metadata["signer"]["name"] == v["expected_signer_group"] + assert v["actual_signer_script"] == v["expected_signer_script"] + assert report is not None and report.passed + return + raise AssertionError("No sample in range landed on a non-latin-script signer; widen the search range.") + + +def test_forged_mode_degrades_gracefully_for_signers_without_forgeries(tmp_path): + """Named signers have no CEDAR-style forged counterpart; sampling 'forged' for them + should fall back to their genuine records rather than crash (SignatureDataset already + logs a warning for this -- this just confirms the caller-facing behavior).""" + ctx = _make_context(tmp_path) + records = ctx.signature_dataset.records_for_group("Priya Sharma", source_filter="forged") + assert len(records) > 0 + assert all(r.signer_group == "Priya Sharma" for r in records) + + +def test_unrecognized_case_type_label_falls_back_to_generic_path(tmp_path): + """A free-text case_type not in CASE_TABLE behaves exactly like today's plain-label usage.""" + ctx = _make_context(tmp_path, case_type="my-custom-label", seed=1) + metadata, _, report = ctx.run_sample(1) + assert metadata["verification"]["case_type"] is None + assert metadata["quality"]["case_type"] == "my-custom-label" + assert report is not None and report.passed