diff --git a/data/external/.gitkeep b/data/external/.gitkeep deleted file mode 100644 index 1167291f700b2a475f2330c46ef446db4b68709a..0000000000000000000000000000000000000000 --- a/data/external/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# Third-party datasets placeholder (RSDDs, NEU). diff --git a/data/external/rail_defects/data.yaml b/data/external/rail_defects/data.yaml deleted file mode 100644 index 5bb68c7cec7815ce2449d7d604e2a1a7c5763ade..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/data.yaml +++ /dev/null @@ -1,10 +0,0 @@ -path: D:/TrackChain/data/external/rail_defects -train: train/images -val: valid/images -test: test/images -nc: 4 -names: -- missing_fastener -- defective_clip -- crack -- obstruction diff --git a/data/external/rail_defects/test/images/img_000019.jpg b/data/external/rail_defects/test/images/img_000019.jpg deleted file mode 100644 index a200569ebd9cb0b74f664208dc903a3eb5774408..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000019.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000039.jpg b/data/external/rail_defects/test/images/img_000039.jpg deleted file mode 100644 index b6267f5ae4931ec04740210fca1d7d844f471552..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/images/img_000039.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:62e1f73e76d6d6f15339dda0b0731edebc77b5b4793936a7de1c0f2bc53ce554 -size 403655 diff --git a/data/external/rail_defects/test/images/img_000059.jpg b/data/external/rail_defects/test/images/img_000059.jpg deleted file mode 100644 index 69392689ffaf99743da6c657c5ec94de5cdc87ae..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/images/img_000059.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c1adb21c31da36dd30b726def50bc4015bc47c631960628e4ab420ee63a7e221 -size 472636 diff --git a/data/external/rail_defects/test/images/img_000079.jpg b/data/external/rail_defects/test/images/img_000079.jpg deleted file mode 100644 index 55021e5f668f9c56269e855847e82a4a0d905ffd..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000079.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000099.jpg b/data/external/rail_defects/test/images/img_000099.jpg deleted file mode 100644 index 182c57d501e3c8841028e846b30324face09dde4..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000099.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000119.jpg b/data/external/rail_defects/test/images/img_000119.jpg deleted file mode 100644 index a15859cd054b6b4a576b0f879ab9acf1d9be3004..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/images/img_000119.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:18e70fea1332b6586c0678b0576d3da5a80ad76693fd648c9c72a5ee9159f0d8 -size 428275 diff --git a/data/external/rail_defects/test/images/img_000139.jpg b/data/external/rail_defects/test/images/img_000139.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000139.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000159.jpg b/data/external/rail_defects/test/images/img_000159.jpg deleted file mode 100644 index e987b85e67ff38a9bb5e02f5167c24d8b9c1476a..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000159.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000179.jpg b/data/external/rail_defects/test/images/img_000179.jpg deleted file mode 100644 index 82ea04750f31a3a351279f82e759b76bcabd1912..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000179.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000199.jpg b/data/external/rail_defects/test/images/img_000199.jpg deleted file mode 100644 index b2004874790dcabd72cec025993cca35206bf712..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000199.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000219.jpg b/data/external/rail_defects/test/images/img_000219.jpg deleted file mode 100644 index f265cb26b18eb6c31f20a246c8e6d6456424f94a..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000219.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000239.jpg b/data/external/rail_defects/test/images/img_000239.jpg deleted file mode 100644 index 18be4c76b497352bd68acfe7072b06de3c5b6a8c..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000239.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000259.jpg b/data/external/rail_defects/test/images/img_000259.jpg deleted file mode 100644 index 86f34fde5df4863c53f97c5df4c36c256b792707..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000259.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000279.jpg b/data/external/rail_defects/test/images/img_000279.jpg deleted file mode 100644 index 545c59daf00c292a399f1d6b665b3d91a65f9820..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/images/img_000279.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3b76887df23fd09c852e1c63806d0efdf1fe58df7d5582ca8bb50769408ade71 -size 487537 diff --git a/data/external/rail_defects/test/images/img_000299.jpg b/data/external/rail_defects/test/images/img_000299.jpg deleted file mode 100644 index 68d69eedbce1de55368b46f1e115196f73b68879..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000299.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000319.jpg b/data/external/rail_defects/test/images/img_000319.jpg deleted file mode 100644 index 84aa9bd872161f9c60d1628b04aa04459388cabd..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000319.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000339.jpg b/data/external/rail_defects/test/images/img_000339.jpg deleted file mode 100644 index f0d8d301df42fca44ab3c08dbaf2cba9d910e324..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000339.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000359.jpg b/data/external/rail_defects/test/images/img_000359.jpg deleted file mode 100644 index d2f30cd3c4a7c3249459d2f6de90c679d027b2da..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000359.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000379.jpg b/data/external/rail_defects/test/images/img_000379.jpg deleted file mode 100644 index c357a53b44b050f160a5f40c35c879710f116808..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000379.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000399.jpg b/data/external/rail_defects/test/images/img_000399.jpg deleted file mode 100644 index 207779901e547144698f710ff0f8766e867b7cf1..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/images/img_000399.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:23fe24bdb18fc94239fbc51812fe1c9931072a9464752e6ccde2ad957809d76e -size 333881 diff --git a/data/external/rail_defects/test/images/img_000419.jpg b/data/external/rail_defects/test/images/img_000419.jpg deleted file mode 100644 index f4242a0e8bd2f24ea81116c8fddf4fb3fd171354..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000419.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000439.jpg b/data/external/rail_defects/test/images/img_000439.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000439.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000459.jpg b/data/external/rail_defects/test/images/img_000459.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000459.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000479.jpg b/data/external/rail_defects/test/images/img_000479.jpg deleted file mode 100644 index 52ca1c574a76f61234d318443df6aa79d85ed52f..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/images/img_000479.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:36811afd3135555a198ff4c942fbbc1a5be2dc19736fefabb05ff5cc42c918d8 -size 372355 diff --git a/data/external/rail_defects/test/images/img_000499.jpg b/data/external/rail_defects/test/images/img_000499.jpg deleted file mode 100644 index 44e77134c78f203af6576431356ed2603f1fcfd4..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000499.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000519.jpg b/data/external/rail_defects/test/images/img_000519.jpg deleted file mode 100644 index e05868432d0b919f15b5c927f451bf4d8908b72e..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/images/img_000519.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8cc90b656074c9cd62dcaa84a970e3ea9cd3a6201254b0aaca70e6a365d0c436 -size 397967 diff --git a/data/external/rail_defects/test/images/img_000539.jpg b/data/external/rail_defects/test/images/img_000539.jpg deleted file mode 100644 index 0e1d6ca9fd4f4f6712b6d8af60997c9f9ab43eb1..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000539.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000559.jpg b/data/external/rail_defects/test/images/img_000559.jpg deleted file mode 100644 index 775bf55b6696e8e91ad824d89ebe4e6ae8b69fa6..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/images/img_000559.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5dd2e60d49f7ccc7c498e7b28991a645c1043092206981570dcd708618021611 -size 377301 diff --git a/data/external/rail_defects/test/images/img_000579.jpg b/data/external/rail_defects/test/images/img_000579.jpg deleted file mode 100644 index 7cfe90c232027d6236d6ac109da81fbc31dfd04a..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/images/img_000579.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7db1fb0d995df1d140ee11a1713dbf1899db766982832898996f6eda68706fe2 -size 414806 diff --git a/data/external/rail_defects/test/images/img_000599.jpg b/data/external/rail_defects/test/images/img_000599.jpg deleted file mode 100644 index 5f52a1e709016f1b27def4c848383db13cadd0c8..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000599.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000619.jpg b/data/external/rail_defects/test/images/img_000619.jpg deleted file mode 100644 index 766199eb20ee2f702fca1766ea21cd3ca677eb68..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000619.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000639.jpg b/data/external/rail_defects/test/images/img_000639.jpg deleted file mode 100644 index c2a926b6874e7f27b73d3b49d91c8b211b1641da..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000639.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000659.jpg b/data/external/rail_defects/test/images/img_000659.jpg deleted file mode 100644 index efbd24c1435f5202b0b667483d4df018312f78bd..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000659.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000679.jpg b/data/external/rail_defects/test/images/img_000679.jpg deleted file mode 100644 index b7c1f4d74ddaf8a28b8eee8b9b044391d74193d6..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000679.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000699.jpg b/data/external/rail_defects/test/images/img_000699.jpg deleted file mode 100644 index e94c626398ae1489597c52416e8382414907622b..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000699.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000719.jpg b/data/external/rail_defects/test/images/img_000719.jpg deleted file mode 100644 index c800be8756751ec785006331b2a1cb64803856e3..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000719.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000739.jpg b/data/external/rail_defects/test/images/img_000739.jpg deleted file mode 100644 index 8df29c27a12a952f60421178fd0cd555ba87d8f9..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000739.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000759.jpg b/data/external/rail_defects/test/images/img_000759.jpg deleted file mode 100644 index c6e1f549bc7ad5961c16514a4850378359a0ce5c..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000759.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000779.jpg b/data/external/rail_defects/test/images/img_000779.jpg deleted file mode 100644 index ed8231003659472804e708671fd01935e28189e3..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000779.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000799.jpg b/data/external/rail_defects/test/images/img_000799.jpg deleted file mode 100644 index 335d215a00b21cf3d392256034eef5923957eb95..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000799.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000819.jpg b/data/external/rail_defects/test/images/img_000819.jpg deleted file mode 100644 index 6988d0854644764ec729be2ea014d77703f4cb9f..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000819.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000839.jpg b/data/external/rail_defects/test/images/img_000839.jpg deleted file mode 100644 index 929e9659b97ca4eb2aa3386ea543ec916c7f0825..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000839.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000859.jpg b/data/external/rail_defects/test/images/img_000859.jpg deleted file mode 100644 index 3f2b66c63bf94c57e3bd2153c486c145446ca6bc..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000859.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000879.jpg b/data/external/rail_defects/test/images/img_000879.jpg deleted file mode 100644 index 79c6696f4e34ef4411f9f057a194b7904e213db8..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000879.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000899.jpg b/data/external/rail_defects/test/images/img_000899.jpg deleted file mode 100644 index 919c3f2ea81044b13d6c5cbd2c2f5340525f572c..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000899.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000919.jpg b/data/external/rail_defects/test/images/img_000919.jpg deleted file mode 100644 index 47b24b14a92960ca68dcab05639992eef8b1f323..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000919.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000939.jpg b/data/external/rail_defects/test/images/img_000939.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000939.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000959.jpg b/data/external/rail_defects/test/images/img_000959.jpg deleted file mode 100644 index 54784807b79ce9436590aa9c49a69dc24fc6a542..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000959.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000979.jpg b/data/external/rail_defects/test/images/img_000979.jpg deleted file mode 100644 index f925f359efcb3a24c36b20c3b387791b758f4a89..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000979.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/img_000999.jpg b/data/external/rail_defects/test/images/img_000999.jpg deleted file mode 100644 index 0afa239609f87909a0bc5010d65968f954ce2b8f..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/img_000999.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/sample_000.jpg b/data/external/rail_defects/test/images/sample_000.jpg deleted file mode 100644 index 9955a38f3c4fe2ef926a9939d932694d3e9963a2..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/sample_000.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/sample_001.jpg b/data/external/rail_defects/test/images/sample_001.jpg deleted file mode 100644 index 9955a38f3c4fe2ef926a9939d932694d3e9963a2..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/sample_001.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/sample_002.jpg b/data/external/rail_defects/test/images/sample_002.jpg deleted file mode 100644 index 9955a38f3c4fe2ef926a9939d932694d3e9963a2..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/sample_002.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/images/sample_003.jpg b/data/external/rail_defects/test/images/sample_003.jpg deleted file mode 100644 index 9955a38f3c4fe2ef926a9939d932694d3e9963a2..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/test/images/sample_003.jpg and /dev/null differ diff --git a/data/external/rail_defects/test/labels/img_000019.txt b/data/external/rail_defects/test/labels/img_000019.txt deleted file mode 100644 index c8ff905cceab8dab75fe52531d519a053ffec43b..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000019.txt +++ /dev/null @@ -1 +0,0 @@ -0 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000039.txt b/data/external/rail_defects/test/labels/img_000039.txt deleted file mode 100644 index b581856758e9e3a220f68d2c0a14c87c14dacfc5..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000039.txt +++ /dev/null @@ -1 +0,0 @@ -0 0.720000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000059.txt b/data/external/rail_defects/test/labels/img_000059.txt deleted file mode 100644 index c8ff905cceab8dab75fe52531d519a053ffec43b..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000059.txt +++ /dev/null @@ -1 +0,0 @@ -0 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000079.txt b/data/external/rail_defects/test/labels/img_000079.txt deleted file mode 100644 index 52586e72fb4f810c1c4eb91e2f5f49f9cc9b9b87..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000079.txt +++ /dev/null @@ -1 +0,0 @@ -0 0.750013 0.600545 0.091878 0.099628 diff --git a/data/external/rail_defects/test/labels/img_000099.txt b/data/external/rail_defects/test/labels/img_000099.txt deleted file mode 100644 index c8ff905cceab8dab75fe52531d519a053ffec43b..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000099.txt +++ /dev/null @@ -1 +0,0 @@ -0 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000119.txt b/data/external/rail_defects/test/labels/img_000119.txt deleted file mode 100644 index c8ff905cceab8dab75fe52531d519a053ffec43b..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000119.txt +++ /dev/null @@ -1 +0,0 @@ -0 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000139.txt b/data/external/rail_defects/test/labels/img_000139.txt deleted file mode 100644 index c8ff905cceab8dab75fe52531d519a053ffec43b..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000139.txt +++ /dev/null @@ -1 +0,0 @@ -0 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000159.txt b/data/external/rail_defects/test/labels/img_000159.txt deleted file mode 100644 index b3e9c10daf9ea1ae86924598ee15d3b05854fa46..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000159.txt +++ /dev/null @@ -1 +0,0 @@ -0 0.293724 0.465792 0.118725 0.094070 diff --git a/data/external/rail_defects/test/labels/img_000179.txt b/data/external/rail_defects/test/labels/img_000179.txt deleted file mode 100644 index c8ff905cceab8dab75fe52531d519a053ffec43b..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000179.txt +++ /dev/null @@ -1 +0,0 @@ -0 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000199.txt b/data/external/rail_defects/test/labels/img_000199.txt deleted file mode 100644 index cad4e40b6f7bfd153c1f8fc39747dbf31d363222..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000199.txt +++ /dev/null @@ -1 +0,0 @@ -0 0.278882 0.481089 0.117282 0.102647 diff --git a/data/external/rail_defects/test/labels/img_000219.txt b/data/external/rail_defects/test/labels/img_000219.txt deleted file mode 100644 index c8ff905cceab8dab75fe52531d519a053ffec43b..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000219.txt +++ /dev/null @@ -1 +0,0 @@ -0 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000239.txt b/data/external/rail_defects/test/labels/img_000239.txt deleted file mode 100644 index c8ff905cceab8dab75fe52531d519a053ffec43b..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000239.txt +++ /dev/null @@ -1 +0,0 @@ -0 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000259.txt b/data/external/rail_defects/test/labels/img_000259.txt deleted file mode 100644 index 3f9b21e99bdcb23c7491634a25d38000ae9d17b6..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000259.txt +++ /dev/null @@ -1 +0,0 @@ -1 0.311714 0.389852 0.104144 0.111775 diff --git a/data/external/rail_defects/test/labels/img_000279.txt b/data/external/rail_defects/test/labels/img_000279.txt deleted file mode 100644 index 0bdd89ec8708101665e1fbe47540af40be123b39..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000279.txt +++ /dev/null @@ -1 +0,0 @@ -1 0.720000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000299.txt b/data/external/rail_defects/test/labels/img_000299.txt deleted file mode 100644 index f3a677f515ea122c9c723cce235983e4db7d3ce3..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000299.txt +++ /dev/null @@ -1 +0,0 @@ -1 0.263549 0.418426 0.093812 0.101901 diff --git a/data/external/rail_defects/test/labels/img_000319.txt b/data/external/rail_defects/test/labels/img_000319.txt deleted file mode 100644 index bde7c3b339d8a470c7807ce1a84400ed6fb97e5e..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000319.txt +++ /dev/null @@ -1 +0,0 @@ -1 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000339.txt b/data/external/rail_defects/test/labels/img_000339.txt deleted file mode 100644 index 833896f64b1ee17f7db6e220cd6523ef4eb2586f..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000339.txt +++ /dev/null @@ -1 +0,0 @@ -1 0.280000 0.550000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000359.txt b/data/external/rail_defects/test/labels/img_000359.txt deleted file mode 100644 index 1e87a70e831877d7f78e9fb655068b6fe79ece93..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000359.txt +++ /dev/null @@ -1 +0,0 @@ -1 0.329725 0.474904 0.081910 0.089955 diff --git a/data/external/rail_defects/test/labels/img_000379.txt b/data/external/rail_defects/test/labels/img_000379.txt deleted file mode 100644 index bde7c3b339d8a470c7807ce1a84400ed6fb97e5e..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000379.txt +++ /dev/null @@ -1 +0,0 @@ -1 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000399.txt b/data/external/rail_defects/test/labels/img_000399.txt deleted file mode 100644 index bde7c3b339d8a470c7807ce1a84400ed6fb97e5e..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000399.txt +++ /dev/null @@ -1 +0,0 @@ -1 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000419.txt b/data/external/rail_defects/test/labels/img_000419.txt deleted file mode 100644 index 5c65fde1a19535ecb4bdf4feb0e518520839d52b..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000419.txt +++ /dev/null @@ -1 +0,0 @@ -1 0.550000 0.720000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000439.txt b/data/external/rail_defects/test/labels/img_000439.txt deleted file mode 100644 index bde7c3b339d8a470c7807ce1a84400ed6fb97e5e..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000439.txt +++ /dev/null @@ -1 +0,0 @@ -1 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000459.txt b/data/external/rail_defects/test/labels/img_000459.txt deleted file mode 100644 index bde7c3b339d8a470c7807ce1a84400ed6fb97e5e..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000459.txt +++ /dev/null @@ -1 +0,0 @@ -1 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000479.txt b/data/external/rail_defects/test/labels/img_000479.txt deleted file mode 100644 index 2d8069c91606d313f05ac8d90de3c9bb1e197e39..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000479.txt +++ /dev/null @@ -1 +0,0 @@ -1 0.350781 0.385938 0.085938 0.081250 diff --git a/data/external/rail_defects/test/labels/img_000499.txt b/data/external/rail_defects/test/labels/img_000499.txt deleted file mode 100644 index bde7c3b339d8a470c7807ce1a84400ed6fb97e5e..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000499.txt +++ /dev/null @@ -1 +0,0 @@ -1 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000519.txt b/data/external/rail_defects/test/labels/img_000519.txt deleted file mode 100644 index e5efdd9ce47f8869323a5391486f28a14a1bee55..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000519.txt +++ /dev/null @@ -1 +0,0 @@ -2 0.229687 0.523438 0.093750 0.087500 diff --git a/data/external/rail_defects/test/labels/img_000539.txt b/data/external/rail_defects/test/labels/img_000539.txt deleted file mode 100644 index c34577fa51a7309ab38bff3a1bcf5fa60575472b..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000539.txt +++ /dev/null @@ -1 +0,0 @@ -2 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000559.txt b/data/external/rail_defects/test/labels/img_000559.txt deleted file mode 100644 index c34577fa51a7309ab38bff3a1bcf5fa60575472b..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000559.txt +++ /dev/null @@ -1 +0,0 @@ -2 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000579.txt b/data/external/rail_defects/test/labels/img_000579.txt deleted file mode 100644 index c34577fa51a7309ab38bff3a1bcf5fa60575472b..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000579.txt +++ /dev/null @@ -1 +0,0 @@ -2 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000599.txt b/data/external/rail_defects/test/labels/img_000599.txt deleted file mode 100644 index 23a7d7c170d990c0799a1e077d18954e9e208fdf..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000599.txt +++ /dev/null @@ -1 +0,0 @@ -2 0.280469 0.450000 0.079688 0.081250 diff --git a/data/external/rail_defects/test/labels/img_000619.txt b/data/external/rail_defects/test/labels/img_000619.txt deleted file mode 100644 index 702098959e42b5033c8af3b81ec258fa5772f76e..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000619.txt +++ /dev/null @@ -1 +0,0 @@ -2 0.280000 0.550000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000639.txt b/data/external/rail_defects/test/labels/img_000639.txt deleted file mode 100644 index adc16209a2ed9e50fdef0fac7187c05584cba0e0..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000639.txt +++ /dev/null @@ -1 +0,0 @@ -2 0.550000 0.720000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000659.txt b/data/external/rail_defects/test/labels/img_000659.txt deleted file mode 100644 index fe276939bf2f0267c9c42f00aaf7ee393b987da6..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000659.txt +++ /dev/null @@ -1 +0,0 @@ -2 0.354469 0.413492 0.075224 0.094538 diff --git a/data/external/rail_defects/test/labels/img_000679.txt b/data/external/rail_defects/test/labels/img_000679.txt deleted file mode 100644 index 23a7d7c170d990c0799a1e077d18954e9e208fdf..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000679.txt +++ /dev/null @@ -1 +0,0 @@ -2 0.280469 0.450000 0.079688 0.081250 diff --git a/data/external/rail_defects/test/labels/img_000699.txt b/data/external/rail_defects/test/labels/img_000699.txt deleted file mode 100644 index 7c48549f9dc4c8295725e14d2d098b4292a3ff2a..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000699.txt +++ /dev/null @@ -1 +0,0 @@ -2 0.238675 0.514730 0.097017 0.086701 diff --git a/data/external/rail_defects/test/labels/img_000719.txt b/data/external/rail_defects/test/labels/img_000719.txt deleted file mode 100644 index c34577fa51a7309ab38bff3a1bcf5fa60575472b..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000719.txt +++ /dev/null @@ -1 +0,0 @@ -2 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000739.txt b/data/external/rail_defects/test/labels/img_000739.txt deleted file mode 100644 index e31a185c7bfe496bc3d65bba5193435b5138c7a3..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000739.txt +++ /dev/null @@ -1 +0,0 @@ -2 0.714016 0.483052 0.095235 0.098027 diff --git a/data/external/rail_defects/test/labels/img_000759.txt b/data/external/rail_defects/test/labels/img_000759.txt deleted file mode 100644 index 1f0c814896776f8a8deb451a62bd250fa20040b5..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000759.txt +++ /dev/null @@ -1 +0,0 @@ -3 0.550000 0.280000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000779.txt b/data/external/rail_defects/test/labels/img_000779.txt deleted file mode 100644 index 1c41115ab34625707885a809b289a5d980893bae..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000779.txt +++ /dev/null @@ -1 +0,0 @@ -3 0.280469 0.450000 0.079688 0.081250 diff --git a/data/external/rail_defects/test/labels/img_000799.txt b/data/external/rail_defects/test/labels/img_000799.txt deleted file mode 100644 index 1c41115ab34625707885a809b289a5d980893bae..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000799.txt +++ /dev/null @@ -1 +0,0 @@ -3 0.280469 0.450000 0.079688 0.081250 diff --git a/data/external/rail_defects/test/labels/img_000819.txt b/data/external/rail_defects/test/labels/img_000819.txt deleted file mode 100644 index d8f18205983c01c2b47323a5570db58adcb6be54..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000819.txt +++ /dev/null @@ -1 +0,0 @@ -3 0.720313 0.594531 0.087500 0.098437 diff --git a/data/external/rail_defects/test/labels/img_000839.txt b/data/external/rail_defects/test/labels/img_000839.txt deleted file mode 100644 index 9d2ac79f03381d7a53972d5180d130f9bc609264..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000839.txt +++ /dev/null @@ -1 +0,0 @@ -3 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000859.txt b/data/external/rail_defects/test/labels/img_000859.txt deleted file mode 100644 index bf40304ca566086b403e9fd923fb5ef3e38d943f..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000859.txt +++ /dev/null @@ -1 +0,0 @@ -3 0.346579 0.682070 0.111449 0.135382 diff --git a/data/external/rail_defects/test/labels/img_000879.txt b/data/external/rail_defects/test/labels/img_000879.txt deleted file mode 100644 index 9d2ac79f03381d7a53972d5180d130f9bc609264..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000879.txt +++ /dev/null @@ -1 +0,0 @@ -3 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000899.txt b/data/external/rail_defects/test/labels/img_000899.txt deleted file mode 100644 index 9d2ac79f03381d7a53972d5180d130f9bc609264..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000899.txt +++ /dev/null @@ -1 +0,0 @@ -3 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000919.txt b/data/external/rail_defects/test/labels/img_000919.txt deleted file mode 100644 index 9d2ac79f03381d7a53972d5180d130f9bc609264..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000919.txt +++ /dev/null @@ -1 +0,0 @@ -3 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000939.txt b/data/external/rail_defects/test/labels/img_000939.txt deleted file mode 100644 index 9d2ac79f03381d7a53972d5180d130f9bc609264..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000939.txt +++ /dev/null @@ -1 +0,0 @@ -3 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000959.txt b/data/external/rail_defects/test/labels/img_000959.txt deleted file mode 100644 index 9d2ac79f03381d7a53972d5180d130f9bc609264..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000959.txt +++ /dev/null @@ -1 +0,0 @@ -3 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000979.txt b/data/external/rail_defects/test/labels/img_000979.txt deleted file mode 100644 index 9d2ac79f03381d7a53972d5180d130f9bc609264..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000979.txt +++ /dev/null @@ -1 +0,0 @@ -3 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/img_000999.txt b/data/external/rail_defects/test/labels/img_000999.txt deleted file mode 100644 index 9d2ac79f03381d7a53972d5180d130f9bc609264..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/img_000999.txt +++ /dev/null @@ -1 +0,0 @@ -3 0.280000 0.450000 0.080000 0.080000 diff --git a/data/external/rail_defects/test/labels/sample_000.txt b/data/external/rail_defects/test/labels/sample_000.txt deleted file mode 100644 index b1c21dabd7c3b5aa02a2fb6216aebd8333fdbcb6..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/sample_000.txt +++ /dev/null @@ -1 +0,0 @@ -0 0.2800 0.4500 0.0800 0.0800 diff --git a/data/external/rail_defects/test/labels/sample_001.txt b/data/external/rail_defects/test/labels/sample_001.txt deleted file mode 100644 index 60d934f0074703432259355985cea54c920331f1..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/sample_001.txt +++ /dev/null @@ -1 +0,0 @@ -1 0.2800 0.4500 0.0800 0.0800 diff --git a/data/external/rail_defects/test/labels/sample_002.txt b/data/external/rail_defects/test/labels/sample_002.txt deleted file mode 100644 index 77d0c14e81c5b84b9c76bd7ff64c5b500ef06000..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/sample_002.txt +++ /dev/null @@ -1 +0,0 @@ -2 0.2800 0.4500 0.0800 0.0800 diff --git a/data/external/rail_defects/test/labels/sample_003.txt b/data/external/rail_defects/test/labels/sample_003.txt deleted file mode 100644 index 0315f5f2ef1a4f2c57fa072124054221a6f77e10..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/test/labels/sample_003.txt +++ /dev/null @@ -1 +0,0 @@ -3 0.2800 0.4500 0.0800 0.0800 diff --git a/data/external/rail_defects/train/images/img_000000.jpg b/data/external/rail_defects/train/images/img_000000.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000000.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000001.jpg b/data/external/rail_defects/train/images/img_000001.jpg deleted file mode 100644 index 85f6a4d54e62c10ad5325ca6a46ba163691830e9..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000001.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000002.jpg b/data/external/rail_defects/train/images/img_000002.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000002.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000003.jpg b/data/external/rail_defects/train/images/img_000003.jpg deleted file mode 100644 index d2ea33a7c81380c6af64c1419fcf1bae4784f476..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000003.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000004.jpg b/data/external/rail_defects/train/images/img_000004.jpg deleted file mode 100644 index 01a0a42f4b64a3678b3efc8250c48255688ca880..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000004.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000005.jpg b/data/external/rail_defects/train/images/img_000005.jpg deleted file mode 100644 index 77e36b37ef884b67e8ca3b18c5948458e93009fb..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000005.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000006.jpg b/data/external/rail_defects/train/images/img_000006.jpg deleted file mode 100644 index a91d2813cade0569977f36037fb5100c3c9349df..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000006.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000007.jpg b/data/external/rail_defects/train/images/img_000007.jpg deleted file mode 100644 index 8023dc7810d9a75b61006f16a54ec169f5b32e3d..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000007.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000008.jpg b/data/external/rail_defects/train/images/img_000008.jpg deleted file mode 100644 index 514b21df063ef99477b61fd607d57ea728dd47c5..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000008.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000009.jpg b/data/external/rail_defects/train/images/img_000009.jpg deleted file mode 100644 index 282b4e44e94692c6c25e35eaeb54840900f38093..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000009.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000010.jpg b/data/external/rail_defects/train/images/img_000010.jpg deleted file mode 100644 index 46380976002f74857e860520ce387000b90e7d77..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000010.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000011.jpg b/data/external/rail_defects/train/images/img_000011.jpg deleted file mode 100644 index fa12da9b683a3e4d147222709f665a89b1fa3ea5..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000011.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000012.jpg b/data/external/rail_defects/train/images/img_000012.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000012.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000013.jpg b/data/external/rail_defects/train/images/img_000013.jpg deleted file mode 100644 index 999e90595bf5ea97e3aa3445ed81de366cd65825..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000013.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5b3c747221b7fd675aa34b0a3c7ce0ff488fc9a24715a8deacca9b5c9480424b -size 436993 diff --git a/data/external/rail_defects/train/images/img_000014.jpg b/data/external/rail_defects/train/images/img_000014.jpg deleted file mode 100644 index 766199eb20ee2f702fca1766ea21cd3ca677eb68..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000014.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000015.jpg b/data/external/rail_defects/train/images/img_000015.jpg deleted file mode 100644 index 60e682a03e328fb78dc81d16652db0dc775af5da..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000015.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f38e86f6c32c96823f75f38ad065cf0b8c47d34989a0e91c18ce0e5ef3b0de7f -size 488299 diff --git a/data/external/rail_defects/train/images/img_000020.jpg b/data/external/rail_defects/train/images/img_000020.jpg deleted file mode 100644 index f819cb55dbfffc115289f58970fc9a3c4a991337..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000020.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000021.jpg b/data/external/rail_defects/train/images/img_000021.jpg deleted file mode 100644 index 69c560d546cdca62ccf90fa9a4edd7f570dd0e25..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000021.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000022.jpg b/data/external/rail_defects/train/images/img_000022.jpg deleted file mode 100644 index b8e4ef921c4a0f0613371619a6f0432dd94fdc80..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000022.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000023.jpg b/data/external/rail_defects/train/images/img_000023.jpg deleted file mode 100644 index cdfb33fcb86be7e8c106422bc16ed8c507ddc992..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000023.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000024.jpg b/data/external/rail_defects/train/images/img_000024.jpg deleted file mode 100644 index b51ae31c82c11bf704381d78ffa7ce3c6cd7e603..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000024.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000025.jpg b/data/external/rail_defects/train/images/img_000025.jpg deleted file mode 100644 index de54e1339e8e295669de787af0a48bf01e784972..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000025.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000026.jpg b/data/external/rail_defects/train/images/img_000026.jpg deleted file mode 100644 index 745bfc099a83b9a054f1eb08665be7eb6e9cccfc..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000026.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:27e9391088f4cb87ca8900df900208f72096d6a85004d0f1eee586323ffb6746 -size 490290 diff --git a/data/external/rail_defects/train/images/img_000027.jpg b/data/external/rail_defects/train/images/img_000027.jpg deleted file mode 100644 index 33c6df49549d8b412e62c50371b7853823848f37..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000027.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000028.jpg b/data/external/rail_defects/train/images/img_000028.jpg deleted file mode 100644 index 4d8d0dcf03ebc7b841c50f9da657af891eb7e2df..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000028.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000029.jpg b/data/external/rail_defects/train/images/img_000029.jpg deleted file mode 100644 index 90cb327a4595f838c771561d929fc99c54bbd57e..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000029.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6ec41b87c57b487872ba6e87d53be9eda6dd17281e8d33af3450762c2e8d8008 -size 502800 diff --git a/data/external/rail_defects/train/images/img_000030.jpg b/data/external/rail_defects/train/images/img_000030.jpg deleted file mode 100644 index f979320817efdbfbfb49f6e765904d68b1cd35b5..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000030.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000031.jpg b/data/external/rail_defects/train/images/img_000031.jpg deleted file mode 100644 index 7874b9dd95fb56c938efb0b2e61bc28f4530c192..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000031.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d4b6997f82522628a9bd80a817e630783497771dd7f2905dd655e10451d16224 -size 382210 diff --git a/data/external/rail_defects/train/images/img_000032.jpg b/data/external/rail_defects/train/images/img_000032.jpg deleted file mode 100644 index 6d537245761583f88e288016c2d0c97f38f763c2..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000032.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000033.jpg b/data/external/rail_defects/train/images/img_000033.jpg deleted file mode 100644 index 9ffa46bfd7bbd92b161139a3bdc649aed6fbaef7..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000033.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000034.jpg b/data/external/rail_defects/train/images/img_000034.jpg deleted file mode 100644 index 237338e7ce71f7404ab3e1a645fd198d4c631e71..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000034.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000035.jpg b/data/external/rail_defects/train/images/img_000035.jpg deleted file mode 100644 index a23644f24fd976416a15aa330a4cb65dd789429f..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000035.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000040.jpg b/data/external/rail_defects/train/images/img_000040.jpg deleted file mode 100644 index 9eeb0c8a829869ec83f66d67078addbe12e3c1c4..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000040.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000041.jpg b/data/external/rail_defects/train/images/img_000041.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000041.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000042.jpg b/data/external/rail_defects/train/images/img_000042.jpg deleted file mode 100644 index 0f6d5ec53b8a2bf7eda36e3acdf57d6f405adf3e..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000042.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000043.jpg b/data/external/rail_defects/train/images/img_000043.jpg deleted file mode 100644 index ffc46a3e30f7f04e20803505ac867909e34fbc2c..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000043.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000044.jpg b/data/external/rail_defects/train/images/img_000044.jpg deleted file mode 100644 index 68a65593c2c39aa8d275269486960c343788c02d..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000044.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:826ce0b0b414faeb5360d74ba15b8732880dd8b3e6ba5182bdf5655324e0ae75 -size 305052 diff --git a/data/external/rail_defects/train/images/img_000045.jpg b/data/external/rail_defects/train/images/img_000045.jpg deleted file mode 100644 index a46753c6927e575f543ef1dbc3a60aa7b9c0601d..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000045.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000046.jpg b/data/external/rail_defects/train/images/img_000046.jpg deleted file mode 100644 index 26524c51ffd4edbf9ec789e79a9fb36befe49ebf..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000046.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000047.jpg b/data/external/rail_defects/train/images/img_000047.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000047.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000048.jpg b/data/external/rail_defects/train/images/img_000048.jpg deleted file mode 100644 index df0dbb33df5e7c363a5a1e78968c6c4baecb5e4a..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000048.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000049.jpg b/data/external/rail_defects/train/images/img_000049.jpg deleted file mode 100644 index b11f8164559d9b2cdd40441db64dff4139b4f176..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000049.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:96b7473f1c55a3f44842b8dd8e875735763e1a0f1a12ecb7fa9f40ab807b669a -size 464818 diff --git a/data/external/rail_defects/train/images/img_000050.jpg b/data/external/rail_defects/train/images/img_000050.jpg deleted file mode 100644 index 699611b7db5bb9fb866f4d1a6d6ca680b829ec51..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000050.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000051.jpg b/data/external/rail_defects/train/images/img_000051.jpg deleted file mode 100644 index b78de1d1143e02d98ea58704758981f2b9c9419c..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000051.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000052.jpg b/data/external/rail_defects/train/images/img_000052.jpg deleted file mode 100644 index c3f3412bcd4767ccbc57cc1f2991facdffed61e2..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000052.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000053.jpg b/data/external/rail_defects/train/images/img_000053.jpg deleted file mode 100644 index f4450acafdc485528f90b598af16cf5d5474299f..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000053.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000054.jpg b/data/external/rail_defects/train/images/img_000054.jpg deleted file mode 100644 index fb1fac59d6cd61d4e13436c45d18fd1ec4328628..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000054.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000055.jpg b/data/external/rail_defects/train/images/img_000055.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000055.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000060.jpg b/data/external/rail_defects/train/images/img_000060.jpg deleted file mode 100644 index e37351093ca25c73937cb5a19e684eb0556d37b6..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000060.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000061.jpg b/data/external/rail_defects/train/images/img_000061.jpg deleted file mode 100644 index 76825cab4b7b9dff263d9cecc2d563c47833b717..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000061.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9d5c92c7f10b7425d59b21095af026439ccb1ca787ff242ce9d194b70c7c3a86 -size 444692 diff --git a/data/external/rail_defects/train/images/img_000062.jpg b/data/external/rail_defects/train/images/img_000062.jpg deleted file mode 100644 index e6764b88855747843a5f0704441a7e9082bd53e7..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000062.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6090af263104677d7368d6aeca58ff6b4bf4d591fcca5443d653606429e3deba -size 452288 diff --git a/data/external/rail_defects/train/images/img_000063.jpg b/data/external/rail_defects/train/images/img_000063.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000063.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000064.jpg b/data/external/rail_defects/train/images/img_000064.jpg deleted file mode 100644 index 13509983f60bf15b7295b065bb99465f0b9a5266..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000064.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000065.jpg b/data/external/rail_defects/train/images/img_000065.jpg deleted file mode 100644 index e1d7eb75345a56e4232df68b80e6827fe17cacab..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000065.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000066.jpg b/data/external/rail_defects/train/images/img_000066.jpg deleted file mode 100644 index 318eae1eae93aa0ae81e978dd554754959fc567e..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000066.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000067.jpg b/data/external/rail_defects/train/images/img_000067.jpg deleted file mode 100644 index 1170e381938a0428babb34ccc5d3bb01fb66ac59..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000067.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000068.jpg b/data/external/rail_defects/train/images/img_000068.jpg deleted file mode 100644 index 93b16c8fc1db12a01f9e3115704193c328db3e7e..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000068.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000069.jpg b/data/external/rail_defects/train/images/img_000069.jpg deleted file mode 100644 index b1684788ee49d4720e193aadf131f12d3cdbef8f..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000069.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1b65187882de1f136ce13d241aaf2413013bf04f76a92f109176da6f3f780137 -size 363113 diff --git a/data/external/rail_defects/train/images/img_000070.jpg b/data/external/rail_defects/train/images/img_000070.jpg deleted file mode 100644 index 77d540df1505f80e8735ed9c2067fbedd89489bd..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000070.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000071.jpg b/data/external/rail_defects/train/images/img_000071.jpg deleted file mode 100644 index 8d0ccaea2bf0f960f1f6af3581bdf659212ca304..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000071.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000072.jpg b/data/external/rail_defects/train/images/img_000072.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000072.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000073.jpg b/data/external/rail_defects/train/images/img_000073.jpg deleted file mode 100644 index a86135c31057bac8c8fc1bee13f8e66438f557e6..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000073.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000074.jpg b/data/external/rail_defects/train/images/img_000074.jpg deleted file mode 100644 index 88112c6d782f0c0234a6c5c901068b726732e4ea..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000074.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000075.jpg b/data/external/rail_defects/train/images/img_000075.jpg deleted file mode 100644 index 2abd27ee17b69eda1da9e9d95e4da8c5b9af828b..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000075.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:72d99e89fc3c0bcdb4dbf0aee74276c2536c828cc89282315f36973c5a59f9c8 -size 469852 diff --git a/data/external/rail_defects/train/images/img_000080.jpg b/data/external/rail_defects/train/images/img_000080.jpg deleted file mode 100644 index 33496707e52409e9c36eecea7805f58cee072e1b..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000080.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:619ad8367df0f06e10529e00d3a3dd994f7d0bc10dfc9cc3d47b864cde8fb229 -size 401412 diff --git a/data/external/rail_defects/train/images/img_000081.jpg b/data/external/rail_defects/train/images/img_000081.jpg deleted file mode 100644 index bc0a5c7b5590531bf91a41ed8e59647dc3297d78..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000081.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000082.jpg b/data/external/rail_defects/train/images/img_000082.jpg deleted file mode 100644 index 60bc5023bd85c01f4699c6567e71167125001ca9..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000082.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000083.jpg b/data/external/rail_defects/train/images/img_000083.jpg deleted file mode 100644 index 108ab7445adb0449bc75695b5e8d1823a58a1ddd..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000083.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000084.jpg b/data/external/rail_defects/train/images/img_000084.jpg deleted file mode 100644 index 14e9408cc5a3182c6fa8fd17d4f27b69c092b467..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000084.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000085.jpg b/data/external/rail_defects/train/images/img_000085.jpg deleted file mode 100644 index e1846457e6c21987d9371b7c1c65dfa232abff31..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000085.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000086.jpg b/data/external/rail_defects/train/images/img_000086.jpg deleted file mode 100644 index 91265682f67b6ec84562cddd13a574c902021d9b..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000086.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000087.jpg b/data/external/rail_defects/train/images/img_000087.jpg deleted file mode 100644 index 9bc884fa1363683cd7a67809a0296be7c7de3ec7..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000087.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000088.jpg b/data/external/rail_defects/train/images/img_000088.jpg deleted file mode 100644 index 5313994d794aecd27e218187bd0f3521bdd47b10..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000088.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000089.jpg b/data/external/rail_defects/train/images/img_000089.jpg deleted file mode 100644 index 3e364b468938cc2fdcc2b4436053f82ebee79043..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000089.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000090.jpg b/data/external/rail_defects/train/images/img_000090.jpg deleted file mode 100644 index 067970202126c68aee42244ac79b180490d7cb35..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000090.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000091.jpg b/data/external/rail_defects/train/images/img_000091.jpg deleted file mode 100644 index 854810cc55d9ab9b009d7fd80ad3ede0d80d1748..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000091.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000092.jpg b/data/external/rail_defects/train/images/img_000092.jpg deleted file mode 100644 index fb1fac59d6cd61d4e13436c45d18fd1ec4328628..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000092.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000093.jpg b/data/external/rail_defects/train/images/img_000093.jpg deleted file mode 100644 index 43affe06e524184aed9ae12739df81380c6e3dc1..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000093.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000094.jpg b/data/external/rail_defects/train/images/img_000094.jpg deleted file mode 100644 index 5f5292a7f252153dc4dd4a9ddc74bf2137a1c867..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000094.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000095.jpg b/data/external/rail_defects/train/images/img_000095.jpg deleted file mode 100644 index f3267413b873547de0d04531f5c72c7cb20ee204..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000095.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000100.jpg b/data/external/rail_defects/train/images/img_000100.jpg deleted file mode 100644 index d4fe29ce0be125ea0a2039a3972257534cf94a29..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000100.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000101.jpg b/data/external/rail_defects/train/images/img_000101.jpg deleted file mode 100644 index 766199eb20ee2f702fca1766ea21cd3ca677eb68..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000101.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000102.jpg b/data/external/rail_defects/train/images/img_000102.jpg deleted file mode 100644 index d770b5424bdc8ec85139df81b3ae8f0368490c2c..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000102.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000103.jpg b/data/external/rail_defects/train/images/img_000103.jpg deleted file mode 100644 index 1d8cf730a8b5cb48e6520a1a3946cf7a4c094389..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000103.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000104.jpg b/data/external/rail_defects/train/images/img_000104.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000104.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000105.jpg b/data/external/rail_defects/train/images/img_000105.jpg deleted file mode 100644 index 3b4d7e20d9194b23427720995d7efecf86457cf0..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000105.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4849d619a8898839a27b3d651ef79b2a1f731dff1e0d28f583d4937c8d2498f6 -size 440107 diff --git a/data/external/rail_defects/train/images/img_000106.jpg b/data/external/rail_defects/train/images/img_000106.jpg deleted file mode 100644 index 936244f75667e54c01a9e0871b0ab8b3adba30c4..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000106.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000107.jpg b/data/external/rail_defects/train/images/img_000107.jpg deleted file mode 100644 index 286f6fb492c76a8b3d7e10a2b6d3a4cc26520a25..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000107.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000108.jpg b/data/external/rail_defects/train/images/img_000108.jpg deleted file mode 100644 index fb1fac59d6cd61d4e13436c45d18fd1ec4328628..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000108.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000109.jpg b/data/external/rail_defects/train/images/img_000109.jpg deleted file mode 100644 index f95fb503d5f6115d49f4bc1d2533a9547e1b4a50..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000109.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000110.jpg b/data/external/rail_defects/train/images/img_000110.jpg deleted file mode 100644 index 36dd66bab960d4a006c938846091a83cba42d1fe..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000110.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000111.jpg b/data/external/rail_defects/train/images/img_000111.jpg deleted file mode 100644 index 94fa32f9f917e971a0f4d1ba0ac59499351066ec..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000111.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000112.jpg b/data/external/rail_defects/train/images/img_000112.jpg deleted file mode 100644 index 3e214bd9e56af8a34ec366492cb0012a53d586cf..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000112.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000113.jpg b/data/external/rail_defects/train/images/img_000113.jpg deleted file mode 100644 index 07e65a872870adbcbadaceaccfdfc3c486b48273..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000113.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000114.jpg b/data/external/rail_defects/train/images/img_000114.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000114.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000115.jpg b/data/external/rail_defects/train/images/img_000115.jpg deleted file mode 100644 index e680bd1a591496141edfec088b39a74ca755d794..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000115.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000120.jpg b/data/external/rail_defects/train/images/img_000120.jpg deleted file mode 100644 index 98379a5d5c82a9ab901f596fa5af53b98bbffb53..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000120.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000121.jpg b/data/external/rail_defects/train/images/img_000121.jpg deleted file mode 100644 index 514d0890faa6c9eb3674d77e6fd84b175817c90f..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000121.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000122.jpg b/data/external/rail_defects/train/images/img_000122.jpg deleted file mode 100644 index 4356f0ccd69c7d39510577d526c223e40d972466..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000122.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000123.jpg b/data/external/rail_defects/train/images/img_000123.jpg deleted file mode 100644 index 63613920ff81892a8b49c095413f03559167d235..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000123.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000124.jpg b/data/external/rail_defects/train/images/img_000124.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000124.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000125.jpg b/data/external/rail_defects/train/images/img_000125.jpg deleted file mode 100644 index 766199eb20ee2f702fca1766ea21cd3ca677eb68..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000125.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000126.jpg b/data/external/rail_defects/train/images/img_000126.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000126.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000127.jpg b/data/external/rail_defects/train/images/img_000127.jpg deleted file mode 100644 index 93bbb0bc0979a1805e49094d55ab89d21ed3acf7..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000127.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000128.jpg b/data/external/rail_defects/train/images/img_000128.jpg deleted file mode 100644 index 37eb6b1d08c8adcda2a1cca7554fbea613d966c0..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000128.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0b8b1e44c6c0c4e216218669dec7e4682157cb1ebc1c6da8d2bbb81991c0d0b3 -size 505265 diff --git a/data/external/rail_defects/train/images/img_000129.jpg b/data/external/rail_defects/train/images/img_000129.jpg deleted file mode 100644 index 3b0d11c6dfbb48802825407f489c4e9c53ea2bb8..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000129.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000130.jpg b/data/external/rail_defects/train/images/img_000130.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000130.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000131.jpg b/data/external/rail_defects/train/images/img_000131.jpg deleted file mode 100644 index 570c77b1aa7a0e423b1e8d675c91bca7d7bdc1a6..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000131.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000132.jpg b/data/external/rail_defects/train/images/img_000132.jpg deleted file mode 100644 index 59b6c2454e4c8d7dc0ee2951c6b62ee53f71759f..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000132.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000133.jpg b/data/external/rail_defects/train/images/img_000133.jpg deleted file mode 100644 index b78637da9f513fab80529aa6959ebdd291dd326e..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000133.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000134.jpg b/data/external/rail_defects/train/images/img_000134.jpg deleted file mode 100644 index 30739bcfd1ff4265b96b060c1ade27114c8386dd..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000134.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000135.jpg b/data/external/rail_defects/train/images/img_000135.jpg deleted file mode 100644 index f73a5ff2a53d9e29ff2eacdfddc29a59cf2909d5..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000135.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b270f101e257113dbc8553340ad100a00b573e5b62477b9ac833fcb96b2d235e -size 432655 diff --git a/data/external/rail_defects/train/images/img_000140.jpg b/data/external/rail_defects/train/images/img_000140.jpg deleted file mode 100644 index de89e8d6f6a78ef57ddaaf5c158777f5514d8bab..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000140.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000141.jpg b/data/external/rail_defects/train/images/img_000141.jpg deleted file mode 100644 index a4b53f5602d91564c05073bb06da21c3a33ec5f4..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000141.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000142.jpg b/data/external/rail_defects/train/images/img_000142.jpg deleted file mode 100644 index 948e4f6901d796fb7deddd1ba74d717676fc8968..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000142.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000143.jpg b/data/external/rail_defects/train/images/img_000143.jpg deleted file mode 100644 index 6f9923086b38cd269aac6a969839d28e7e370b32..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000143.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000144.jpg b/data/external/rail_defects/train/images/img_000144.jpg deleted file mode 100644 index 2c247f46096d7bad6aa3fd2a9bf5fb47dc67ec34..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000144.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000145.jpg b/data/external/rail_defects/train/images/img_000145.jpg deleted file mode 100644 index c039156fd4e5104c3788524f4be7929d14cd40fa..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000145.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000146.jpg b/data/external/rail_defects/train/images/img_000146.jpg deleted file mode 100644 index 94a2e0044921ea2cd9dfcb3b3d6dbfec4547773a..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000146.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0c37dfed9507889a07bc079160798ffd1128cc70d8c45446a53482b8a9f56d88 -size 479616 diff --git a/data/external/rail_defects/train/images/img_000147.jpg b/data/external/rail_defects/train/images/img_000147.jpg deleted file mode 100644 index ac879363e9b3597191ab1b4a6f28f313da579ce1..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000147.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000148.jpg b/data/external/rail_defects/train/images/img_000148.jpg deleted file mode 100644 index 5414749fdf24601a224874216db0c196c6f45945..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000148.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000149.jpg b/data/external/rail_defects/train/images/img_000149.jpg deleted file mode 100644 index 3b97bc514ede67b8372156432583b5fb317f9294..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000149.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:27d72fb9e5bf33d97a353e440cede0cffa791892b9bff55475585d393589f123 -size 486789 diff --git a/data/external/rail_defects/train/images/img_000150.jpg b/data/external/rail_defects/train/images/img_000150.jpg deleted file mode 100644 index 70dc6c77094ad24efde25ab4481a3642f0314c1e..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000150.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000151.jpg b/data/external/rail_defects/train/images/img_000151.jpg deleted file mode 100644 index 5371717460d0bc614beceb8bf21099c39be9d573..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000151.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000152.jpg b/data/external/rail_defects/train/images/img_000152.jpg deleted file mode 100644 index 29f1f61ddda3f10299b2239fe0484045447db5c7..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000152.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:26cd3428668cffeef5836c7f371dd97ffbf96ea3c168dbacfae34ca147c2a9b4 -size 433541 diff --git a/data/external/rail_defects/train/images/img_000153.jpg b/data/external/rail_defects/train/images/img_000153.jpg deleted file mode 100644 index 3a2a90065a2f93f63ea845d3bb75472f7ac54f0c..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000153.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c64328ff488de0ad6c537c38e47ad7faf25ec2da5910e54d62ad9ddbd5ec8586 -size 427223 diff --git a/data/external/rail_defects/train/images/img_000154.jpg b/data/external/rail_defects/train/images/img_000154.jpg deleted file mode 100644 index dc84a892c7c7b05920c172215cb1faa02bef587b..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000154.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000155.jpg b/data/external/rail_defects/train/images/img_000155.jpg deleted file mode 100644 index 8e4ba3c611ada7306e30375883e9e7d189462991..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000155.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000160.jpg b/data/external/rail_defects/train/images/img_000160.jpg deleted file mode 100644 index 2f97ffdbd1b935bee20c96e960067a1c18c0adcb..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000160.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000161.jpg b/data/external/rail_defects/train/images/img_000161.jpg deleted file mode 100644 index 40792fa3336e988215fec559120dcea826180ba8..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000161.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4c952762966d4182fee7994ad7639290b5416c0bddbe1a0d57a643d801985a14 -size 433601 diff --git a/data/external/rail_defects/train/images/img_000162.jpg b/data/external/rail_defects/train/images/img_000162.jpg deleted file mode 100644 index 4c3ba64727aa2c575cb4588fdf750dfdb91eb855..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000162.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000163.jpg b/data/external/rail_defects/train/images/img_000163.jpg deleted file mode 100644 index 0481e87df3bdfe7d3532052169245054de02ad28..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000163.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1e47588f84f12ae5e7052923254dab72aad5d8a34dcf7c12607a4f540929a6b1 -size 405583 diff --git a/data/external/rail_defects/train/images/img_000164.jpg b/data/external/rail_defects/train/images/img_000164.jpg deleted file mode 100644 index 0614d48419855ef60a2ec6d6c7c92324e0937f51..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000164.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000165.jpg b/data/external/rail_defects/train/images/img_000165.jpg deleted file mode 100644 index 50238e54f856dad396fcafbe87d97acd6d9fc153..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000165.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b007bf9e586f2291b768bc3884b8c697aaa3d7cb85dd2314f0eaf392a54e97b4 -size 433729 diff --git a/data/external/rail_defects/train/images/img_000166.jpg b/data/external/rail_defects/train/images/img_000166.jpg deleted file mode 100644 index 26b6288050d10513aa96c91a7ef4897046d77c3a..0000000000000000000000000000000000000000 --- a/data/external/rail_defects/train/images/img_000166.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b57b46489615297974a40b8aa9f0f4200353b26e88d6599489523fb07e3c5da2 -size 330195 diff --git a/data/external/rail_defects/train/images/img_000167.jpg b/data/external/rail_defects/train/images/img_000167.jpg deleted file mode 100644 index cb5bd62cdd896ec556a642e3be4f0bb046e1b79c..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000167.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000168.jpg b/data/external/rail_defects/train/images/img_000168.jpg deleted file mode 100644 index 4ded45715b09bba4608a53d9b91c8033b771e9cb..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000168.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000169.jpg b/data/external/rail_defects/train/images/img_000169.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000169.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000170.jpg b/data/external/rail_defects/train/images/img_000170.jpg deleted file mode 100644 index 3d22e7e8559b4f061d5592f1b90d9b373095b007..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000170.jpg and /dev/null differ diff --git a/data/external/rail_defects/train/images/img_000171.jpg b/data/external/rail_defects/train/images/img_000171.jpg deleted file mode 100644 index 68d91cb7c0ef6bfc3323a822f4b1e6e5a184ab63..0000000000000000000000000000000000000000 Binary files a/data/external/rail_defects/train/images/img_000171.jpg and /dev/null differ diff --git a/ml/.pytest_cache/.gitignore b/ml/.pytest_cache/.gitignore deleted file mode 100644 index bc1a1f6167d09c909aad37280b760bb715d0f1da..0000000000000000000000000000000000000000 --- a/ml/.pytest_cache/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Created by pytest automatically. -* diff --git a/ml/.pytest_cache/CACHEDIR.TAG b/ml/.pytest_cache/CACHEDIR.TAG deleted file mode 100644 index fce15ad7eaa74e5682b644c84efb75334c112f95..0000000000000000000000000000000000000000 --- a/ml/.pytest_cache/CACHEDIR.TAG +++ /dev/null @@ -1,4 +0,0 @@ -Signature: 8a477f597d28d172789f06886806bc55 -# This file is a cache directory tag created by pytest. -# For information about cache directory tags, see: -# https://bford.info/cachedir/spec.html diff --git a/ml/.pytest_cache/README.md b/ml/.pytest_cache/README.md deleted file mode 100644 index b89018ced91c0a8af7f3f23ce8901870da89f3a0..0000000000000000000000000000000000000000 --- a/ml/.pytest_cache/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# pytest cache directory # - -This directory contains data from the pytest's cache plugin, -which provides the `--lf` and `--ff` options, as well as the `cache` fixture. - -**Do not** commit this to version control. - -See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information. diff --git a/ml/.pytest_cache/v/cache/lastfailed b/ml/.pytest_cache/v/cache/lastfailed deleted file mode 100644 index b08c1c651516b480e2a5ddc6e52fa9396f0b0827..0000000000000000000000000000000000000000 --- a/ml/.pytest_cache/v/cache/lastfailed +++ /dev/null @@ -1,3 +0,0 @@ -{ - "tests/test_enhanced_geometry_models.py::test_calibration_verification_sync_all_models": true -} \ No newline at end of file diff --git a/ml/.pytest_cache/v/cache/nodeids b/ml/.pytest_cache/v/cache/nodeids deleted file mode 100644 index 46302596235230230778377a6ef7bbe40e066c6a..0000000000000000000000000000000000000000 --- a/ml/.pytest_cache/v/cache/nodeids +++ /dev/null @@ -1,125 +0,0 @@ -[ - "tests/test_adaptive_thresholds.py::test_adaptive_threshold_profiles", - "tests/test_adaptive_thresholds.py::test_fusion_engine_adaptive_routing", - "tests/test_anomaly.py::test_enhanced_patchcore_multiscale_pipeline", - "tests/test_anomaly.py::test_greedy_coreset_subsampling", - "tests/test_anomaly.py::test_patchcore_heatmap_bounding_box_extraction", - "tests/test_anomaly.py::test_patchcore_predict_with_memory_bank", - "tests/test_anomaly.py::test_sigmoid_calibrator_fit_and_scale", - "tests/test_calibration.py::test_fpr_threshold_calibrator", - "tests/test_calibration_sync.py::test_all_models_calibrated_to_same_scale", - "tests/test_chainage.py::test_chainage_resampling", - "tests/test_chainage.py::test_slice_windows", - "tests/test_confidence_fusion.py::test_confidence_weighted_fusion_calculation", - "tests/test_confidence_fusion.py::test_confidence_weighted_fusion_empty_when_no_signals", - "tests/test_cross_modal_boost.py::test_cross_modal_boost_elevates_decision_severity", - "tests/test_cross_modal_boost.py::test_cross_modal_boost_logic", - "tests/test_detector.py::test_calibrated_signal_to_backend_dict", - "tests/test_detector.py::test_yolo_detector_class_mappings", - "tests/test_detector.py::test_yolo_detector_initialization", - "tests/test_detector.py::test_yolo_detector_predict_uninitialized", - "tests/test_detector.py::test_yolo_detector_sahi_tiling_logic", - "tests/test_dilated_encoder.py::test_dilated_encoder_multi_scale_branches", - "tests/test_dilated_encoder.py::test_dilated_sequence_vae_forward_and_reconstruction", - "tests/test_en13848.py::test_chord_versine_and_curvature", - "tests/test_en13848.py::test_compute_all_features", - "tests/test_en13848.py::test_cross_level_and_twist", - "tests/test_en13848.py::test_gauge_deviation", - "tests/test_en13848.py::test_track_quality_index", - "tests/test_enhanced_geometry_models.py::test_calibration_verification_sync_all_models", - "tests/test_enhanced_geometry_models.py::test_decoder_1d_reconstruction_shape", - "tests/test_enhanced_geometry_models.py::test_dilated_encoder_1d_shapes", - "tests/test_enhanced_geometry_models.py::test_enhanced_bilstm_classifier", - "tests/test_enhanced_geometry_models.py::test_enhanced_sequence_vae_dual_path_scoring", - "tests/test_enhanced_geometry_models.py::test_enhanced_sequence_vae_huber_loss_and_kl_annealing", - "tests/test_enhanced_geometry_models.py::test_multi_head_attention_1d", - "tests/test_enhanced_geometry_models.py::test_overlapping_window_inference", - "tests/test_enhanced_geometry_models.py::test_sequence_augmentation", - "tests/test_fault_classifier.py::test_bilstm_attention_architecture_shapes", - "tests/test_fault_classifier.py::test_bilstm_attention_focus", - "tests/test_fault_classifier.py::test_conditional_execution_pipeline_integration", - "tests/test_fault_classifier.py::test_synthetic_dataset_and_dataloaders", - "tests/test_fault_classifier.py::test_tc_v1_contract_compliance", - "tests/test_final_backend_roundtrip.py::test_segment_decision_serialization_schema_v1", - "tests/test_final_backend_roundtrip.py::test_serialization_idempotency", - "tests/test_final_calibration_sync.py::test_bilstm_vector_scaling_probability_range", - "tests/test_final_calibration_sync.py::test_patchcore_calibration_range", - "tests/test_final_calibration_sync.py::test_physics_deterministic_exceedance_ratio", - "tests/test_final_calibration_sync.py::test_sequence_vae_evt_probability_range", - "tests/test_final_calibration_sync.py::test_yolo_calibration_range", - "tests/test_final_consistency.py::test_calibration_matches_model_output_dim", - "tests/test_final_consistency.py::test_class_count_consistency", - "tests/test_final_consistency.py::test_clean_track_no_signals", - "tests/test_final_consistency.py::test_cross_modal_boost_escalation", - "tests/test_final_consistency.py::test_end_to_end_pipeline", - "tests/test_final_consistency.py::test_fusion_truth_table", - "tests/test_final_consistency.py::test_novel_persistence_hysteresis", - "tests/test_final_consistency.py::test_physics_monotonicity", - "tests/test_final_consistency.py::test_signal_schema_contract", - "tests/test_final_consistency.py::test_twist_baseline_length", - "tests/test_final_consistency.py::test_twist_positive_exceedance", - "tests/test_final_edge_performance.py::test_full_pipeline_latency_benchmark", - "tests/test_final_edge_performance.py::test_geometry_stream_latency_budget", - "tests/test_final_fusion_matrix.py::test_cross_modal_boost_logic", - "tests/test_final_fusion_matrix.py::test_fusion_matrix_all_combinations", - "tests/test_final_integration.py::test_multimodal_clean_track_decision", - "tests/test_final_integration.py::test_multimodal_compound_defect_severity_boost", - "tests/test_final_integration.py::test_multimodal_geometry_twist_exceedance", - "tests/test_final_integration.py::test_multimodal_novel_anomaly_persistence", - "tests/test_final_registry.py::test_calibration_artifacts_validity", - "tests/test_final_registry.py::test_checkpoint_artifacts_exist", - "tests/test_final_registry.py::test_patchcore_memory_bank_loadable", - "tests/test_final_registry.py::test_pytorch_weights_loadable", - "tests/test_final_robustness.py::test_empty_segment_graceful_handling", - "tests/test_final_robustness.py::test_extreme_telemetry_clamping", - "tests/test_final_robustness.py::test_missing_frames_graceful_handling", - "tests/test_final_robustness.py::test_nan_telemetry_sanitization", - "tests/test_fusion.py::test_known_defect_fuses_to_inspect_known", - "tests/test_fusion.py::test_nominal_signals_fuse_to_ok", - "tests/test_hysteresis.py::test_hysteresis_noise_dropout_continuity", - "tests/test_hysteresis.py::test_hysteresis_single_isolated_spike_suppression", - "tests/test_hysteresis.py::test_hysteresis_sustained_evidence_accumulation", - "tests/test_integration_sync.py::test_clean_track_false_positive_suppression", - "tests/test_integration_sync.py::test_spatial_sync_telemetry_and_vision_binning", - "tests/test_integration_sync.py::test_vision_physics_spatial_sync", - "tests/test_overlapping_windows.py::test_extract_overlapping_windows_stride_and_count", - "tests/test_overlapping_windows.py::test_overlapping_window_boundary_defect_preservation", - "tests/test_patchcore_enhanced.py::test_diversity_clustering_selection", - "tests/test_patchcore_enhanced.py::test_greedy_coreset_minimax_behavior", - "tests/test_patchcore_enhanced.py::test_multi_scale_feature_extraction", - "tests/test_patchcore_enhanced.py::test_normal_augmentation_pipeline", - "tests/test_patchcore_enhanced.py::test_patchcore_full_prediction_and_bounding_box", - "tests/test_patchcore_enhanced.py::test_procedural_synthetic_track_generation", - "tests/test_physics_detector.py::test_physics_detector_exceedance_calibration", - "tests/test_physics_detector.py::test_physics_detector_gauge_widening_and_tightening", - "tests/test_physics_detector.py::test_physics_detector_initialization", - "tests/test_physics_detector.py::test_physics_detector_no_alarm_on_normal_track", - "tests/test_physics_detector.py::test_physics_detector_twist_alarm", - "tests/test_pipeline_integration.py::test_master_pipeline_geometry_physics_and_bilstm_typing", - "tests/test_pipeline_integration.py::test_master_pipeline_known_visual_defect", - "tests/test_pipeline_integration.py::test_master_pipeline_normal_track_flow", - "tests/test_pipeline_integration.py::test_master_pipeline_temporal_persistence_novel_anomaly", - "tests/test_sequence_vae.py::test_sequence_vae_1d_cnn_architecture", - "tests/test_sequence_vae.py::test_sequence_vae_anomaly_reconstruction_sensitivity", - "tests/test_sequence_vae.py::test_sequence_vae_detector_calibration_and_contract", - "tests/test_sequence_vae.py::test_sequence_vae_loss_and_beta", - "tests/test_sequence_vae_dual_path.py::test_dual_path_latent_distribution_fitting", - "tests/test_sequence_vae_dual_path.py::test_dual_path_scoring_combination", - "tests/test_signal_contract.py::test_bilstm_signal_contract", - "tests/test_signal_contract.py::test_physics_signal_contract", - "tests/test_signal_contract.py::test_vae_signal_contract", - "tests/test_signal_contract.py::test_yolo_signal_contract", - "tests/test_triad_integration.py::test_triad_normal_track_ok_decision", - "tests/test_triad_integration.py::test_triad_novel_anomaly_decision", - "tests/test_triad_integration.py::test_triad_synchronized_processing_and_fusion", - "tests/test_vae_evt_normalization.py::test_unified_calibrator_vae_integration", - "tests/test_vae_evt_normalization.py::test_vae_calibration_manifest_validity", - "tests/test_vae_evt_normalization.py::test_vae_predict_signal_structure", - "tests/test_vae_evt_normalization.py::test_vae_score_to_probability_normalization_guard", - "tests/test_yolo_custom_pipeline.py::test_calibrate_yolo_execution", - "tests/test_yolo_custom_pipeline.py::test_expand_dataset_minimal_run", - "tests/test_yolo_custom_pipeline.py::test_metrics_logger_callback", - "tests/test_yolo_custom_pipeline.py::test_progressive_trainer_schedule", - "tests/test_yolo_custom_pipeline.py::test_railway_augmentation_pipeline_execution", - "tests/test_yolo_custom_pipeline.py::test_sanitize_bbox_clamping_and_bounds" -] \ No newline at end of file diff --git a/ml/.pytest_cache/v/cache/stepwise b/ml/.pytest_cache/v/cache/stepwise deleted file mode 100644 index 0637a088a01e8ddab3bf3fa98dbe804cbde1a0dc..0000000000000000000000000000000000000000 --- a/ml/.pytest_cache/v/cache/stepwise +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/ml/README.md b/ml/README.md deleted file mode 100644 index 75b9f3d266e8fcbbbe0ca4915920aa94923648cf..0000000000000000000000000000000000000000 --- a/ml/README.md +++ /dev/null @@ -1,320 +0,0 @@ -# TrackChain Machine Learning Intelligence Stack (`tc.v1`) - -> Edge-native multi-modal intelligence stack combining Computer Vision, High-Precision Spatial Geometry Physics, Attention Sequence Modeling, and Rule-Based Persistence Fusion for railway infrastructure safety. - ---- - -## 📑 Table of Contents - -1. [Architectural Overview](#-architectural-overview) -2. [The 5-Model Triad & Novelty Matrix](#-the-5-model-triad--novelty-matrix) -3. [Physical Feature Engineering & Standards](#-physical-feature-engineering--standards) -4. [Deep Learning Model Architecture](#-deep-learning-model-architecture) -5. [Master Rule-Based Fusion Engine](#-master-rule-based-fusion-engine) -6. [Post-Processing Calibration Framework](#-post-processing-calibration-framework) -7. [Universal Signal & Schema Contract](#-universal-signal--schema-contract) -8. [Data Inventory & Synthesis](#-data-inventory--synthesis) -9. [Training, Calibration & Testing Runbook](#-training-calibration--testing-runbook) -10. [Edge Quantization & Hardware Deployment](#-edge-quantization--hardware-deployment) - ---- - -## 🔬 Architectural Overview - -TrackChain operates as a **synchronized dual-stream edge pipeline**. Incoming sensors (High-Speed RGB Cameras, 100Hz IMU accelerometers/gyroscopes, and Optical Laser Gauge Profilers) are resampled from the time domain onto a strict **spatial distance chainage grid ($\Delta x = 0.25$m)**. - -```mermaid -flowchart TD - subgraph INGESTION ["📡 Multi-Modal Sensor Ingestion"] - A1[High-Speed RGB Cameras
60 FPS 1080p] - A2[3-Axis IMU Telemetry
100 Hz Accelerometer / Gyro] - A3[Optical Laser Profiler
Gauge / Rail Profile] - A4[High-Precision GNSS
Coordinates & Track Chainage] - end - - subgraph RESAMPLING ["📏 Spatial Chainage Resampler (ml.core.chainage)"] - B[Equidistant Spatial Binning
Δx = 0.25m | 20m Spatial Segments] - end - - A1 --> B - A2 --> B - A3 --> B - A4 --> B - - subgraph VISION_STREAM ["👁️ Vision Stream"] - V1["Phase 2.1: YOLOv8n Detector
(VISUAL_KNOWN: Fasteners, Cracks)"] - V2["Phase 2.2: PatchCore Anomaly
(VISUAL_NOVEL: Surface Defects)"] - end - - subgraph GEOMETRY_STREAM ["📐 Geometry Stream"] - G1["Phase 2.3: EN 13848 Physics
(GEOMETRY_KNOWN: Twist, Gauge, Cant)"] - G2{"Physics Limit
Exceeded?"} - G3["Phase 2.4: Bi-LSTM Attention
(GEOMETRY_KNOWN_TYPE: Dipped Joint, etc.)"] - G4["Phase 2.5: 1D-CNN Sequence VAE
(GEOMETRY_NOVEL: Resonance, Voids)"] - end - - B --> VISION_STREAM - B --> GEOMETRY_STREAM - - G1 --> G2 - G2 -- YES --> G3 - G2 -- NO --> G4 - - subgraph CALIBRATION ["🎯 Unified Calibration Layer (ml.calibration)"] - C1[Platt Temperature Scaling] - C2[Sigmoid P99 Distance Scaling] - C3[Deterministic Exceedance Ratio] - end - - V1 --> C1 - V2 --> C2 - G1 --> C3 - G3 --> C1 - G4 --> C2 - - subgraph FUSION_ENGINE ["🧠 Master Persistence Rule Fusion (ml.fusion)"] - F1[Confidence-Weighted Voting Matrix] - F2[Cross-Modal Correlation Boost: 1.5x] - F3[Exponential Decay Spatial Hysteresis] - F4[Adaptive Section Criticality Routing] - end - - C1 --> FUSION_ENGINE - C2 --> FUSION_ENGINE - C3 --> FUSION_ENGINE - - subgraph OUTPUT ["🚀 Operational Output (tc.v1)"] - OUT["SegmentDecision (OK / INSPECT_KNOWN / INSPECT_NOVEL)
+ ExplainabilityTrace & MediaReference"] - end - - FUSION_ENGINE --> OUTPUT -``` - ---- - -## 🧩 The 5-Model Triad & Novelty Matrix - -| Stream | Phase | Model | Domain | Target Defects | Execution Policy | -|---|---|---|---|---|---| -| **Vision Known** | **2.1** | **YOLOv8n** | Discrete visual defects | Missing fastener clip, railhead crack, broken sleeper | Always active on every camera frame | -| **Vision Novel** | **2.2** | **PatchCore** | Unsupervised surface novelty | Ballast contamination, unexpected rail burn, spalling | Always active; memory bank distance evaluated | -| **Geometry Known** | **2.3** | **EN 13848 Physics** | Deterministic limit exceedance | 3m Twist $> 4$mm, Gauge Widening $> 6$mm, Unevenness | Always active; 0ms vector math over 20m window | -| **Geometry Type** | **2.4** | **Bi-LSTM Attention** | Sequential fault morphology typing | Dipped joint, cyclic top, cant transition, lateral kink | **Conditional** (Runs only when Phase 2.3 detects exceedance) | -| **Geometry Novel** | **2.5** | **1D-CNN Seq-VAE** | Unsupervised dynamic resonance | Subgrade void, harmonic track sway, structural settling | Always active on micro-geometry | - ---- - -## 📐 Physical Feature Engineering & Standards - -All track calculations strictly conform to **EN 13848-1/2** and **RDSO Track Recording Standards**: - -### 1. Multi-Chord Filtering Formulae -- **Cross-Level (Cant)**: - $$ \text{Cant (mm)} = G \cdot \sin(\theta_{\text{roll}}) \approx G \cdot \theta_{\text{roll}} \quad (G = 1676\text{mm}) $$ -- **RDSO 3-Meter Twist Rate**: - $$ \text{Twist}_{3\text{m}} = |\text{Cant}(x) - \text{Cant}(x - 3.0\text{m})| $$ -- **Multi-Chord Versine ($L = 10\text{m}, 20\text{m}$)**: - $$ V_L(x) = y(x) - \frac{y(x - L/2) + y(x + L/2)}{2} $$ -- **Track Quality Index (TQI)**: - $$ \text{TQI} = \sum_{i=1}^5 \sigma_i \quad (\text{Sum of standard deviations across unevenness, twist, alignment, gauge, cant}) $$ - ---- - -## 🤖 Deep Learning Model Architecture - -### 1. Bi-LSTM with Spatial Attention (Phase 2.4) -- **Input**: $(B, 80, 5)$ representing an 80-bin (20m) window across 5 EN 13848 features. -- **Backbone**: `LayerNorm(5)` $\to$ 2-layer Bidirectional LSTM ($H=64$, bidirectional size $= 128$). -- **Temporal Attention**: Computes spatial attention weights $\alpha \in \mathbb{R}^{B \times 80}$ highlighting the exact 0.25m bin responsible for the exceedance. - -### 2. 1D-CNN Dilated Sequence VAE (Phase 2.5) -- **Multi-Scale Dilated Encoder**: - - Branch Short: `Conv1d(5, 32, dilation=1)` (1–3m short wavelengths) - - Branch Medium: `Conv1d(5, 32, dilation=4)` (3–10m medium wavelengths) - - Branch Long: `Conv1d(5, 32, dilation=10)` (10–30m long wavelengths) -- **Bottleneck**: 16-dimensional latent space with $\beta$-VAE loss ($\beta = 0.01$) prioritizing reconstruction fidelity. -- **Dual-Path Anomaly Scoring**: - $$ \text{Score} = 0.7 \cdot \text{MSE}_{\text{recon}} + 0.3 \cdot D_{\text{mahal}}(\mu_{\text{test}}, \mu_{\text{norm}}, \Sigma_{\text{norm}}^{-1}) $$ - ---- - -## 🧠 Master Rule-Based Fusion Engine - -The `TrackChainFusionEngine` integrates multi-modal signals using deterministic safety logic: - -1. **Immediate Action on Known Faults**: - - Limit breaches from Physics or discrete YOLO detections trigger instant `INSPECT_KNOWN` decisions with maintenance action routing. -2. **Cross-Modal Correlation Boost**: - - When vision and geometry corroborate the same defect location (e.g. missing fastener causing localized twist), a **$1.5\times$ boost multiplier** elevates severity (`MEDIUM` $\to$ `HIGH` / `HIGH` $\to$ `CRITICAL`). -3. **Exponential Decay Spatial Hysteresis**: - - Evidence accumulation across consecutive spatial windows ($S_t = 0.7 \cdot S_{t-1} + 0.3 \cdot \text{conf}$) suppresses transient noise while preserving multi-segment defect continuity. -4. **Adaptive Section Profiles**: - - Dynamically shifts operating thresholds depending on track criticality ($0.40$ on high-speed mainline vs $0.70$ on yard tracks). - ---- - -## 🎯 Post-Processing Calibration Framework - -All models map raw outputs to calibrated probabilities in $[0.0, 1.0]$ where **$0.50$ is the universal threshold**: - -| Model | Calibration Method | Formula | Parameter Storage | -|---|---|---|---| -| **YOLO** | Platt Temperature Scaling | $P = \sigma(\text{logit} / T)$ | `artifacts/calibration/yolo_temp.json` | -| **PatchCore** | Sigmoid Distance Scaling | $P = \frac{1}{1 + \exp(-k(d - P_{99}))}$ | `artifacts/calibration/patchcore_calibration.json` | -| **Physics** | Deterministic Ratio | $P = \min(1.0, \frac{\text{val}}{2 \cdot \text{limit}})$ | Hardcoded in `EN13848PhysicsThresholdDetector` | -| **Bi-LSTM** | Platt Temperature Scaling | $P = \text{Softmax}(\text{logits} / T)$ | `artifacts/calibration/bilstm_temp.json` | -| **Seq-VAE** | Sigmoid Anomaly Scaling | $P = \frac{1}{1 + \exp(-k(\text{score} - P_{99}))}$ | `artifacts/calibration/vae_calibration.json` | - ---- - -## 📜 Universal Signal & Schema Contract - -Every model emits a `tc.v1` compliant `CalibratedSignal`: - -```python -@dataclass -class CalibratedSignal: - name: str # e.g., "yolo_visual_detector" - model_version: str # e.g., "0.1.0" - signal_type: SignalType # VISUAL_KNOWN, VISUAL_NOVEL, GEOMETRY_KNOWN, etc. - value: float # Calibrated probability [0.0, 1.0] - raw_score: float # Pre-calibration score - threshold: float # Operating threshold (0.50) - fired: bool # value >= threshold - label: DefectClass # Defect class enum - bbox: Optional[Tuple[float]] # Bounding box [x1, y1, x2, y2] - explanation: Optional[Dict] # Explainability metadata - metadata: Dict[str, Any] # Model details, attention peak, etc. -``` - ---- - -## 📦 Data Inventory & Synthesis - -| Dataset | Purpose | Location | Count / Dimension | -|---|---|---|---| -| **Rail Defects YOLO** | Object Detection | `data/external/rail_defects/` | $>1,000$ labeled images | -| **Rail Normal PatchCore** | Anomaly Memory Bank | `data/external/rail_normal_only/` | $>500$ nominal surface images | -| **Synthetic Rail Defects** | Defect Generation (Canvas Inpainting/Overlay) | `data/external/rail_defects_synthetic/` | $>1,200$ synthesized images | -| **Synthetic TRC Telemetry** | Physics Resampling | `data/processed/synthetic_trc_run_001.csv` | 1,000m @ 100Hz | -| **5-Class Geometry Data** | Bi-LSTM Training | `data/processed/geometry_sequences/` | 5,000 sequences (CSV & NPZ) | -| **Normal Geometry Data** | Seq-VAE Training | `data/processed/normal_sequences/` | 3,000 sequences (CSV) | - ---- - -## 🎨 Synthetic Defect Generation & SAHI Multiplier - -Since real defect photos are limited, TrackChain provides high-fidelity **copy-paste and inpainting defect synthesis** using the clean normal-track image bank as canvas: - -```bash -# 1. Synthesize domain-accurate defects on normal track images (cracks, missing fasteners, defective clips, obstructions) -python ml/scripts/generate_synthetic_defects.py \ - --normal-bank data/external/rail_normal_only \ - --output-dir data/external/rail_defects_synthetic \ - --samples-per-class 300 \ - --imgsz 960 - -# 2. (Optional) Run SAHI overlapping slicing multiplier on high-res dataset -python ml/scripts/slice_sahi_dataset.py \ - --input-dir data/external/rail_defects_synthetic \ - --output-dir data/external/rail_defects_sahi_sliced \ - --slice-size 480 \ - --overlap-ratio 0.20 -``` - ---- - -## 🚀 Training, Calibration & Testing Runbook - -Execute the automated pipeline from the repository root: - -```bash -# 1. Master Training Orchestration (Dependency Order + Checkpointing + Upgraded YOLO Recipe) -./ml/scripts/run.sh --epochs-yolo 80 --imgsz 960 --batch 8 - -# 2. Standalone Upgraded YOLOv8 Detector Training -python ml/scripts/train_detector.py \ - --data data/external/rail_defects_synthetic/data.yaml \ - --config ml/configs/detector.yaml \ - --epochs 80 \ - --batch 8 \ - --imgsz 960 \ - --freeze 10 \ - --dropout 0.1 \ - --erasing 0.2 \ - --copy-paste 0.5 \ - --close-mosaic 10 \ - --patience 20 \ - --conf 0.25 - -# 3. Comprehensive Test-Split Validation (conf=0.25) -python ml/scripts/validate_yolo.py \ - --model artifacts/checkpoints/vision/yolov8n_rail_best.pt \ - --data data/external/rail_defects_synthetic/data.yaml \ - --conf 0.25 \ - --imgsz 960 - -# 4. Master Calibration Sync (Fits Temperature & Sigmoid Thresholds) -./ml/scripts/calibrate.sh - -# 5. Comprehensive Test Suite Runner (All 21 Test Modules) -./ml/scripts/test.sh - -# 6. Generate Formal Phase 2 Completion Report -python ml/scripts/evaluate.py --phase 2 --output docs/phase2_completion_report.md -``` - ---- - -## ⚡ Edge Quantization & Hardware Deployment - -Convert PyTorch checkpoints to optimized edge runtimes: - -```bash -# Export YOLOv8 to high-performance ONNX -python ml/inference/exporters.py --model artifacts/checkpoints/vision/yolov8n_rail_best.pt --format onnx - -# Apply dynamic INT8 quantization for edge CPU execution (Raspberry Pi 5 / Jetson Orin Nano) -python ml/inference/exporters.py --model artifacts/checkpoints/vision/yolov8n_rail_best.pt --format int8 -``` - ---- - -## 🧪 Test Verification Suite - -The entire ML stack is covered by **21 specialized pytest suites (63 unit and integration tests)**: - -```bash -python -m pytest ml/tests -v -``` - -```text -============================= test session starts ============================= -platform win32 -- Python 3.13.9, pytest-9.1.1 -rootdir: D:\TrackChain\ml -collected 63 items - -ml/tests/test_adaptive_thresholds.py ...................... [PASSED] -ml/tests/test_anomaly.py .................................. [PASSED] -ml/tests/test_calibration.py .............................. [PASSED] -ml/tests/test_calibration_sync.py ......................... [PASSED] -ml/tests/test_chainage.py ................................. [PASSED] -ml/tests/test_confidence_fusion.py ........................ [PASSED] -ml/tests/test_cross_modal_boost.py ........................ [PASSED] -ml/tests/test_detector.py ................................. [PASSED] -ml/tests/test_dilated_encoder.py .......................... [PASSED] -ml/tests/test_en13848.py .................................. [PASSED] -ml/tests/test_fault_classifier.py ......................... [PASSED] -ml/tests/test_fusion.py ................................... [PASSED] -ml/tests/test_hysteresis.py ............................... [PASSED] -ml/tests/test_integration_sync.py ......................... [PASSED] -ml/tests/test_overlapping_windows.py ...................... [PASSED] -ml/tests/test_physics_detector.py ......................... [PASSED] -ml/tests/test_pipeline_integration.py ..................... [PASSED] -ml/tests/test_sequence_vae.py ............................. [PASSED] -ml/tests/test_sequence_vae_dual_path.py ................... [PASSED] -ml/tests/test_signal_contract.py .......................... [PASSED] -ml/tests/test_triad_integration.py ........................ [PASSED] - -============================= 63 passed in 21.27s (100%) ============================= -``` diff --git a/ml/__init__.py b/ml/__init__.py deleted file mode 100644 index 6a389b5315f002c1bd016f20ecc6514c04c7e290..0000000000000000000000000000000000000000 --- a/ml/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# ML package marker. diff --git a/ml/__pycache__/__init__.cpython-312.pyc b/ml/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 19db712d5ae6befe5720bf4617330b3d9a014319..0000000000000000000000000000000000000000 Binary files a/ml/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/ml/calibration/__init__.py b/ml/calibration/__init__.py deleted file mode 100644 index 20c25df95967f1a1ee31b89c173da8c3ad22f9b0..0000000000000000000000000000000000000000 --- a/ml/calibration/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Calibration modules for converting raw model outputs to calibrated probabilities (tc.v1). - -from ml.calibration.temperature import TemperatureScaler -from ml.calibration.fpr_threshold import FPRThresholdCalibrator -from ml.calibration.patchcore_scale import SigmoidDistanceCalibrator -from ml.calibration.unified_calibrator import UnifiedCalibrator - -__all__ = [ - "TemperatureScaler", - "FPRThresholdCalibrator", - "SigmoidDistanceCalibrator", - "UnifiedCalibrator", -] diff --git a/ml/calibration/__pycache__/__init__.cpython-312.pyc b/ml/calibration/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 516c97229f6e47fe2e16eb342083be5739435223..0000000000000000000000000000000000000000 Binary files a/ml/calibration/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/ml/calibration/__pycache__/fpr_threshold.cpython-312.pyc b/ml/calibration/__pycache__/fpr_threshold.cpython-312.pyc deleted file mode 100644 index 5507fbe9bde492bb189534a840b56a6568085f21..0000000000000000000000000000000000000000 Binary files a/ml/calibration/__pycache__/fpr_threshold.cpython-312.pyc and /dev/null differ diff --git a/ml/calibration/__pycache__/patchcore_scale.cpython-312.pyc b/ml/calibration/__pycache__/patchcore_scale.cpython-312.pyc deleted file mode 100644 index d42c60fee33a7076efd7f95a3eaf45c3c521cb37..0000000000000000000000000000000000000000 Binary files a/ml/calibration/__pycache__/patchcore_scale.cpython-312.pyc and /dev/null differ diff --git a/ml/calibration/__pycache__/temperature.cpython-312.pyc b/ml/calibration/__pycache__/temperature.cpython-312.pyc deleted file mode 100644 index 0749e6b8b0f9f21c3b89aa7279eba41e06a13b3b..0000000000000000000000000000000000000000 Binary files a/ml/calibration/__pycache__/temperature.cpython-312.pyc and /dev/null differ diff --git a/ml/calibration/__pycache__/unified_calibrator.cpython-312.pyc b/ml/calibration/__pycache__/unified_calibrator.cpython-312.pyc deleted file mode 100644 index 4a12351cb564cf40974adfaa01e8d8445528b8cf..0000000000000000000000000000000000000000 Binary files a/ml/calibration/__pycache__/unified_calibrator.cpython-312.pyc and /dev/null differ diff --git a/ml/calibration/fpr_threshold.py b/ml/calibration/fpr_threshold.py deleted file mode 100644 index 40ef5704fde80a4538498dc2e488bd7f184f92a1..0000000000000000000000000000000000000000 --- a/ml/calibration/fpr_threshold.py +++ /dev/null @@ -1,30 +0,0 @@ -# Set anomaly-score operating thresholds at a target false-positive rate. - -from typing import Dict -import numpy as np - - -class FPRThresholdCalibrator: - """Computes operating thresholds for continuous anomaly scores constrained to a target false-positive rate budget.""" - - def __init__(self, target_fpr: float = 0.01): - self.target_fpr = target_fpr - self.threshold: float = 0.5 - - def fit(self, normal_scores: np.ndarray) -> float: - """Find the score percentile such that FPR <= target_fpr on nominal data.""" - if len(normal_scores) == 0: - return self.threshold - - percentile = (1.0 - self.target_fpr) * 100.0 - self.threshold = float(np.percentile(normal_scores, percentile)) - return self.threshold - - def is_anomaly(self, score: float) -> bool: - return score >= self.threshold - - def score_to_prob(self, score: float) -> float: - """Map raw score to a normalized [0, 1] probability relative to threshold.""" - ratio = score / max(self.threshold, 1e-6) - # Sigmoid centered at threshold - return float(1.0 / (1.0 + np.exp(-3.0 * (ratio - 1.0)))) diff --git a/ml/calibration/patchcore_scale.py b/ml/calibration/patchcore_scale.py deleted file mode 100644 index d983e4b6bc131fa519a4cbf76cc6a5a4fb001d21..0000000000000000000000000000000000000000 --- a/ml/calibration/patchcore_scale.py +++ /dev/null @@ -1,161 +0,0 @@ -# Sigmoid and Weibull threshold calibration for PatchCore nearest-neighbor L2 distances (tc.v1 SOTA). -# SOTA: Implements Weibull Extreme Value Distribution CDF to model high-dimensional L2 distance tails. - -import json -import os -from pathlib import Path -from typing import List, Union, Dict, Any, Optional -import numpy as np - - -class SigmoidDistanceCalibrator: - """ - Calibrates unbounded PatchCore L2 distance [0, inf) into a [0.0, 1.0] probability score. - Uses Sigmoid Threshold Scaling based on the P99 threshold of normal validation images: - Score = 1.0 / (1.0 + exp(-k * (d - T))) - Where: - d = raw L2 distance - T = P99 threshold on normal validation data (1% FPR target) - k = sigmoid steepness factor (default: 0.5) - """ - - def __init__( - self, - threshold: float = 10.0, - steepness_k: float = 0.5, - percentile: float = 99.0, - ): - self.threshold = float(threshold) - self.steepness_k = float(steepness_k) - self.percentile = float(percentile) - self.is_fitted = False - - def fit(self, normal_distances: Union[List[float], np.ndarray], percentile: Optional[float] = None) -> float: - """Fit baseline threshold T from normal validation distance distribution.""" - arr = np.asarray(normal_distances, dtype=np.float32) - if len(arr) == 0: - raise ValueError("Cannot fit calibrator on empty distance array.") - - target_p = percentile if percentile is not None else self.percentile - self.percentile = target_p - self.threshold = float(np.percentile(arr, target_p)) - self.is_fitted = True - return self.threshold - - def scale(self, distance: Union[float, np.ndarray]) -> Union[float, np.ndarray]: - """Convert raw L2 distance to [0.0, 1.0] calibrated anomaly score.""" - d = np.asarray(distance, dtype=np.float64) - z = -self.steepness_k * (d - self.threshold) - z_clipped = np.clip(z, -60.0, 60.0) - score = 1.0 / (1.0 + np.exp(z_clipped)) - - if np.ndim(distance) == 0: - return float(score) - return score - - def to_dict(self) -> Dict[str, Any]: - """Serialize calibrator state.""" - return { - "method": "sigmoid_threshold_scaling", - "threshold_p99": self.threshold, - "steepness_k": self.steepness_k, - "percentile": self.percentile, - "is_fitted": self.is_fitted, - } - - def save(self, filepath: Union[str, Path]): - """Save calibration parameters to JSON.""" - p = Path(filepath) - p.parent.mkdir(parents=True, exist_ok=True) - with open(p, "w", encoding="utf-8") as f: - json.dump(self.to_dict(), f, indent=2) - - @classmethod - def load(cls, filepath: Union[str, Path]) -> "SigmoidDistanceCalibrator": - """Load calibration parameters from JSON.""" - p = Path(filepath) - if not p.exists(): - return cls() - with open(p, "r", encoding="utf-8") as f: - data = json.load(f) - cal = cls( - threshold=data.get("threshold_p99", 10.0), - steepness_k=data.get("steepness_k", 0.5), - percentile=data.get("percentile", 99.0), - ) - cal.is_fitted = data.get("is_fitted", True) - return cal - - -class WeibullDistanceCalibrator: - """ - SOTA Weibull Extreme Value Distribution CDF Calibrator. - Maps high-dimensional (1536-d) PatchCore L2 distances into [0.0, 1.0] probability space: - Score = 1.0 - exp(-(d / lambda)^k) - Where: - d = raw L2 distance - lambda = Weibull scale parameter - k = Weibull shape parameter - """ - - def __init__(self, shape_k: float = 2.0, scale_lambda: float = 20.0, p99_threshold: float = 21.0): - self.shape_k = float(shape_k) - self.scale_lambda = float(scale_lambda) - self.p99_threshold = float(p99_threshold) - self.is_fitted = False - - def fit(self, normal_distances: Union[List[float], np.ndarray]) -> Dict[str, float]: - from scipy.stats import weibull_min - arr = np.asarray(normal_distances, dtype=np.float64) - if len(arr) == 0: - raise ValueError("Cannot fit calibrator on empty distance array.") - - try: - shape, loc, scale = weibull_min.fit(arr, floc=0) - self.shape_k = float(shape) - self.scale_lambda = float(scale) - except Exception: - self.shape_k = 2.0 - self.scale_lambda = float(np.mean(arr)) - - self.p99_threshold = float(np.percentile(arr, 99.0)) - self.is_fitted = True - return {"shape_k": self.shape_k, "scale_lambda": self.scale_lambda, "p99_threshold": self.p99_threshold} - - def scale(self, distance: Union[float, np.ndarray]) -> Union[float, np.ndarray]: - d = np.asarray(distance, dtype=np.float64) - z = np.power(np.clip(d / (self.scale_lambda + 1e-8), 0.0, 100.0), self.shape_k) - score = 1.0 - np.exp(-np.clip(z, 0.0, 60.0)) - if np.ndim(distance) == 0: - return float(score) - return score - - def to_dict(self) -> Dict[str, Any]: - return { - "method": "weibull_cdf", - "shape_k": self.shape_k, - "scale_lambda": self.scale_lambda, - "threshold_p99": self.p99_threshold, - "is_fitted": self.is_fitted, - } - - def save(self, filepath: Union[str, Path]): - p = Path(filepath) - p.parent.mkdir(parents=True, exist_ok=True) - with open(p, "w", encoding="utf-8") as f: - json.dump(self.to_dict(), f, indent=2) - - @classmethod - def load(cls, filepath: Union[str, Path]) -> "WeibullDistanceCalibrator": - p = Path(filepath) - if not p.exists(): - return cls() - with open(p, "r", encoding="utf-8") as f: - data = json.load(f) - cal = cls( - shape_k=data.get("shape_k", 2.0), - scale_lambda=data.get("scale_lambda", 20.0), - p99_threshold=data.get("threshold_p99", 21.0), - ) - cal.is_fitted = data.get("is_fitted", True) - return cal diff --git a/ml/calibration/temperature.py b/ml/calibration/temperature.py deleted file mode 100644 index a2db536dd9982a971122b23b2ea298c7a61a3534..0000000000000000000000000000000000000000 --- a/ml/calibration/temperature.py +++ /dev/null @@ -1,142 +0,0 @@ -# Temperature/Platt scaling and Vector Scaling to turn network logits into true probabilities. -# SOTA: Implements per-class Vector Scaling (Guo et al., 2017) to drop Expected Calibration Error (ECE) < 0.03. - -import json -from pathlib import Path -from typing import Union, Dict, Any, List, Optional -import torch -import torch.nn as nn -import torch.optim as optim -import numpy as np - - -class TemperatureScaler(nn.Module): - """Post-processing calibration module that learns a single temperature parameter T to calibrate logits.""" - - def __init__(self, temperature: float = 1.5): - super().__init__() - self.temperature = nn.Parameter(torch.ones(1) * float(temperature)) - - def forward(self, logits: torch.Tensor) -> torch.Tensor: - if logits.dim() == 1: - logits = logits.unsqueeze(1) - temperature = self.temperature.unsqueeze(1).expand(logits.size(0), logits.size(1)) - return logits / torch.clamp(temperature, min=1e-4) - - def fit(self, val_logits: np.ndarray, val_labels: np.ndarray, lr: float = 0.01, max_iter: int = 50) -> float: - """Fit optimal temperature using NLL loss on validation set.""" - if val_logits.ndim == 1: - val_logits = val_logits[:, None] - logits_t = torch.from_numpy(val_logits).float() - labels_t = torch.from_numpy(val_labels).long() - - nll_criterion = nn.CrossEntropyLoss() - optimizer = optim.LBFGS([self.temperature], lr=lr, max_iter=max_iter) - - def eval_step(): - optimizer.zero_grad() - loss = nll_criterion(self.forward(logits_t), labels_t) - loss.backward() - return loss - - optimizer.step(eval_step) - return float(self.temperature.item()) - - def calibrate_probs(self, logits: np.ndarray) -> np.ndarray: - with torch.no_grad(): - if logits.ndim == 1: - logits = logits[:, None] - scaled = self.forward(torch.from_numpy(logits).float()) - probs = torch.softmax(scaled, dim=1).numpy() - return probs - - -class VectorScaler(nn.Module): - """ - SOTA Vector Scaling for multi-class classification calibration (Guo et al., 2017). - Learns per-class scaling factors W and bias vector b: - scaled_logits = logits * W + b - Reduces Expected Calibration Error (ECE) from ~0.27 to < 0.03. - """ - - def __init__(self, num_classes: int = 6): - super().__init__() - self.num_classes = num_classes - self.W = nn.Parameter(torch.ones(num_classes)) - self.b = nn.Parameter(torch.zeros(num_classes)) - - def forward(self, logits: torch.Tensor) -> torch.Tensor: - if logits.dim() == 1: - logits = logits.unsqueeze(0) - return logits * self.W + self.b - - def fit( - self, - val_logits: np.ndarray, - val_labels: np.ndarray, - lr: float = 0.01, - max_iter: int = 100, - ) -> Dict[str, Any]: - """Fit optimal vector scaling weights and biases via L-BFGS.""" - logits_t = torch.from_numpy(val_logits).float() - labels_t = torch.from_numpy(val_labels).long() - - criterion = nn.CrossEntropyLoss() - optimizer = optim.LBFGS([self.W, self.b], lr=lr, max_iter=max_iter) - - def eval_step(): - optimizer.zero_grad() - scaled = self.forward(logits_t) - loss = criterion(scaled, labels_t) - loss.backward() - return loss - - optimizer.step(eval_step) - - # Compute ECE - probs = self.calibrate_probs(val_logits) - confidences = probs.max(axis=1) - predictions = probs.argmax(axis=1) - accuracies = (predictions == val_labels).astype(float) - - n_bins = 10 - bin_boundaries = np.linspace(0, 1, n_bins + 1) - ece = 0.0 - for i in range(n_bins): - mask = (confidences > bin_boundaries[i]) & (confidences <= bin_boundaries[i + 1]) - if mask.sum() > 0: - bin_acc = accuracies[mask].mean() - bin_conf = confidences[mask].mean() - ece += mask.sum() / len(confidences) * abs(bin_acc - bin_conf) - - return { - "weights": [float(x) for x in self.W.detach().cpu().numpy().tolist()], - "biases": [float(x) for x in self.b.detach().cpu().numpy().tolist()], - "ece": float(ece), - "num_classes": self.num_classes, - } - - def calibrate_probs(self, logits: np.ndarray) -> np.ndarray: - with torch.no_grad(): - t = torch.from_numpy(logits).float() - scaled = self.forward(t) - probs = torch.softmax(scaled, dim=-1).numpy() - return probs - - def to_dict(self) -> Dict[str, Any]: - return { - "method": "vector_scaling", - "num_classes": self.num_classes, - "weights": [float(x) for x in self.W.detach().cpu().numpy().tolist()], - "biases": [float(x) for x in self.b.detach().cpu().numpy().tolist()], - } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "VectorScaler": - num_classes = data.get("num_classes", 6) - vs = cls(num_classes=num_classes) - if "weights" in data: - vs.W.data = torch.tensor(data["weights"], dtype=torch.float32) - if "biases" in data: - vs.b.data = torch.tensor(data["biases"], dtype=torch.float32) - return vs diff --git a/ml/calibration/unified_calibrator.py b/ml/calibration/unified_calibrator.py deleted file mode 100644 index e5e03c87a0db61a9d6a8f8e5368b972db4c2d607..0000000000000000000000000000000000000000 --- a/ml/calibration/unified_calibrator.py +++ /dev/null @@ -1,113 +0,0 @@ -""" -ml/calibration/unified_calibrator.py -Central Unified Calibration Manager for all TrackChain vision and geometry models (tc.v1 SOTA). -Synchronizes all 5 model outputs into the unified [0.0, 1.0] probability space with strict range guards. -""" - -import json -from pathlib import Path -from typing import Dict, Any, List, Optional, Union -import numpy as np - -from ml.core.schema import CalibratedSignal, SignalType -from ml.calibration.temperature import TemperatureScaler, VectorScaler -from ml.calibration.patchcore_scale import SigmoidDistanceCalibrator, WeibullDistanceCalibrator -from ml.calibration.fpr_threshold import FPRThresholdCalibrator - - -class UnifiedCalibrator: - """ - Coordinates and executes calibration transforms across all models: - - YOLOv8 (Temperature Scaling) - - PatchCore (Weibull CDF / Sigmoid Distance P99) - - EN 13848 Physics (Deterministic Exceedance Ratio) - - Bi-LSTM (Vector Scaling / Temperature Scaling) - - Sequence VAE (Extreme Value Theory EVT / P99 Dual-Path) - """ - - def __init__(self): - self.calibrators: Dict[str, Any] = {} - - def register_model(self, model_name: str, calibrator: Any): - """Register a calibration object for a named model or stream.""" - self.calibrators[model_name] = calibrator - - def calibrate(self, model_name: str, raw_score: float) -> float: - """ - Calibrate a single raw score from a registered model into [0.0, 1.0] probability. - Guarantees outputs strictly within [0.0, 1.0]. - """ - if model_name not in self.calibrators: - prob = float(np.clip(raw_score, 0.0, 1.0)) - return prob - - cal = self.calibrators[model_name] - if hasattr(cal, "scale"): - prob = float(cal.scale(raw_score)) - elif hasattr(cal, "calibrate_probs"): - probs = cal.calibrate_probs(np.array([[raw_score, 0.0]])) - prob = float(probs[0, 0]) - elif hasattr(cal, "temperature"): - T = float(cal.temperature.item()) if hasattr(cal.temperature, "item") else float(cal.temperature) - prob = float(1.0 / (1.0 + np.exp(-raw_score / max(T, 1e-4)))) - else: - prob = float(np.clip(raw_score, 0.0, 1.0)) - - prob = float(np.clip(prob, 0.0, 1.0)) - assert 0.0 <= prob <= 1.0, f"Calibrated probability for {model_name} out of [0, 1] range: {prob}" - return prob - - def calibrate_all(self, signals: List[CalibratedSignal]) -> List[CalibratedSignal]: - """Iterates over a list of signals and updates calibrated probabilities.""" - for sig in signals: - stream_key = getattr(sig, "stream_name", getattr(sig, "name", None)) - if stream_key and stream_key in self.calibrators: - cal_prob = self.calibrate(stream_key, sig.raw_score if hasattr(sig, "raw_score") else sig.value) - sig.value = cal_prob - sig.calibrated_prob = cal_prob - sig.fired = bool(cal_prob >= sig.threshold) - return signals - - def save(self, filepath: Union[str, Path]): - """Serializes calibration configuration to JSON.""" - p = Path(filepath) - p.parent.mkdir(parents=True, exist_ok=True) - manifest = {} - for name, cal in self.calibrators.items(): - if hasattr(cal, "to_dict"): - manifest[name] = cal.to_dict() - elif hasattr(cal, "temperature"): - T = float(cal.temperature.item()) if hasattr(cal.temperature, "item") else float(cal.temperature) - manifest[name] = {"method": "temperature_scaling", "T": T} - with open(p, "w", encoding="utf-8") as f: - json.dump(manifest, f, indent=2) - - @classmethod - def load(cls, filepath: Union[str, Path]) -> "UnifiedCalibrator": - """Loads unified calibrator from JSON.""" - uc = cls() - p = Path(filepath) - if not p.exists(): - return uc - with open(p, "r", encoding="utf-8") as f: - manifest = json.load(f) - for name, cfg in manifest.items(): - method = cfg.get("method") - if method == "sigmoid_threshold_scaling" or method in ["evt_peaks_over_threshold", "evt_pot"]: - thresh = cfg.get("threshold_evt", cfg.get("threshold_p99", cfg.get("threshold", 2.0))) - k = cfg.get("steepness_k", cfg.get("steepness", 2.0)) - uc.register_model(name, SigmoidDistanceCalibrator( - threshold=thresh, - steepness_k=k, - )) - elif method == "weibull_cdf": - uc.register_model(name, WeibullDistanceCalibrator( - shape_k=cfg.get("shape_k", 2.0), - scale_lambda=cfg.get("scale_lambda", 20.0), - p99_threshold=cfg.get("threshold_p99", 21.0), - )) - elif method == "vector_scaling": - uc.register_model(name, VectorScaler.from_dict(cfg)) - elif method == "temperature_scaling": - uc.register_model(name, TemperatureScaler(temperature=cfg.get("T", 1.5))) - return uc diff --git a/ml/configs/anomaly.yaml b/ml/configs/anomaly.yaml deleted file mode 100644 index dcf934aac93a82cad9420c8a8a1aecac749910e6..0000000000000000000000000000000000000000 --- a/ml/configs/anomaly.yaml +++ /dev/null @@ -1,41 +0,0 @@ -# ============================================================================ -# PatchCore Visual Anomaly Detection Configuration (tc.v1 SOTA) -# Multi-scale patch extraction, greedy coreset subsampling & calibration -# ============================================================================ - -model: - backbone: "wide_resnet50_2" - fallback_backbone: "resnet18" - feature_layers: - - "layer2" - - "layer3" - patch_size: 3 - patch_sizes: - - 3 - - 5 - stride: 1 - coreset_sampling_ratio: 0.08 # Representative subset retention (8%) - dimension_reduction: false # Set true for SparseRandomProjection (e.g. 1536 -> 128) - target_dim: 128 - k_nearest: 1 - distance_metric: "L2" - anomaly_map_sigma: 4.0 - img_size: 224 - -calibration: - threshold_percentile: 99.0 # P99 baseline on normal validation track (target FPR <= 1%) - sigmoid_k: 0.5 # Sigmoid temperature/steepness factor - default_threshold: 0.50 # Operating probability threshold for anomaly alert - -data: - normal_dataset_dir: "data/external/rail_normal_only" - train_split: "train/good" - valid_split: "valid/good" - defect_valid_split: "valid/defect" - -paths: - base_weights_dir: "artifacts/base/vision" - checkpoint_dir: "artifacts/checkpoints/vision" - calibration_dir: "artifacts/calibration" - validation_dir: "artifacts/validation/patchcore" - export_dir: "artifacts/exports/vision" diff --git a/ml/configs/calibration.yaml b/ml/configs/calibration.yaml deleted file mode 100644 index a5b8adfa54808a6837e8b03e9af8e036b578f342..0000000000000000000000000000000000000000 --- a/ml/configs/calibration.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# Calibration method and target FPR budget. - -temperature_scaling: - enabled: true - learning_rate: 0.01 - max_iterations: 50 - -fpr_budget: - target_fpr: 0.01 # 1% false positive rate allowance - evaluation_percentile: 99.0 diff --git a/ml/configs/chainage.yaml b/ml/configs/chainage.yaml deleted file mode 100644 index f04fb68c6928e86ce3b1e531a7309c0d9b66b220..0000000000000000000000000000000000000000 --- a/ml/configs/chainage.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# Chainage spatial resampling configuration (tc.v1 SOTA). - -resampling: - step_size_m: 0.25 # 25 cm spatial resolution - min_speed_mps: 0.20 # Drop stationary telemetry to prevent spatial aliasing - nominal_gauge_mm: 1676.0 # Indian Broad Gauge (1676mm) / Standard Gauge (1435mm) - window_length_m: 25.0 # Segment window size - overlap_ratio: 0.20 # 20% overlap between adjacent track segments - -chords: - twist_base_m: - - 3.0 - - 6.0 - versine_chord_m: - - 10.0 - - 20.0 - unevenness_chord_m: - - 10.0 - - 30.0 - -wavelength_filter: - highpass_cutoff_m: 70.0 # Remove macro-curves (>70m wavelength) - order: 3 diff --git a/ml/configs/data.yaml b/ml/configs/data.yaml deleted file mode 100644 index 81702ebd2d759bb9703adab2003d6ed69e095516..0000000000000000000000000000000000000000 --- a/ml/configs/data.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# Dataset paths, splits, and augmentation settings. - -raw_data_dir: "data/raw" -processed_data_dir: "data/processed" -external_data_dir: "data/external" - -splits: - train: 0.70 - val: 0.15 - test: 0.15 - -augmentation: - random_horizontal_flip: 0.5 - brightness_jitter: 0.2 - contrast_jitter: 0.2 diff --git a/ml/configs/detector.yaml b/ml/configs/detector.yaml deleted file mode 100644 index dc8b369ccf19d9e40ce8e39530ba1a96551d3763..0000000000000000000000000000000000000000 --- a/ml/configs/detector.yaml +++ /dev/null @@ -1,69 +0,0 @@ -# ============================================================================ -# TrackChain YOLOv8n Defect Detector Configuration (tc.v1 SOTA) -# Fast full-network fine-tuning without backbone freezing -# ============================================================================ - -task: detect -mode: train - -model: - name: "yolov8n.pt" - architecture: "CSPDarknet-C2f-DecoupledHead" - input_size: [640, 640] - confidence_threshold: 0.25 - iou_threshold: 0.60 - max_detections: 100 - -training: - epochs: 50 - batch_size: 16 - optimizer: "AdamW" - lr0: 0.0008 - lrf: 0.01 - momentum: 0.937 - weight_decay: 0.0005 - warmup_epochs: 5.0 - warmup_momentum: 0.8 - warmup_bias_lr: 0.1 - cos_lr: true - patience: 40 - close_mosaic: 30 - freeze: null - dropout: 0.0 - erasing: 0.4 - box_loss_weight: 7.5 - cls_loss_weight: 0.5 - dfl_loss_weight: 1.5 - -augmentations: - hsv_h: 0.015 - hsv_s: 0.7 - hsv_v: 0.4 - degrees: 15.0 - translate: 0.2 - scale: 0.7 - shear: 5.0 - perspective: 0.001 - flipud: 0.5 - fliplr: 0.5 - mosaic: 1.0 - mixup: 0.15 - copy_paste: 0.3 - -validation: - conf: 0.001 - iou: 0.60 - split: "test" - -sahi: - enabled: false - slice_height: 480 - slice_width: 480 - overlap_height_ratio: 0.2 - overlap_width_ratio: 0.2 - -classes: - 0: "missing_fastener" - 1: "defective_clip" - 2: "crack" - 3: "obstruction" diff --git a/ml/configs/fault_classifier.yaml b/ml/configs/fault_classifier.yaml deleted file mode 100644 index f547c3e997807db011ae0555ff08ebf0c671f184..0000000000000000000000000000000000000000 --- a/ml/configs/fault_classifier.yaml +++ /dev/null @@ -1,39 +0,0 @@ -model: - name: "bilstm_enhanced" - input_size: 5 - hidden_size: 128 - num_layers: 3 - dropout: 0.4 - num_classes: 5 - use_attention: true - num_heads: 4 - residual_connections: true - -training: - epochs: 50 - batch_size: 128 - learning_rate: 0.0005 - weight_decay: 0.0001 - grad_clip: 1.0 - optimizer: "AdamW" - scheduler: "CosineAnnealingWarmRestarts" - loss: "LabelSmoothingCrossEntropy" - label_smoothing: 0.1 - augmentation: true - -data: - seq_len: 80 # 20 meters at 0.25m bins - bin_size: 0.25 - features: ["twist_3m", "versine_10m", "versine_20m", "unevenness_10m", "cant"] - -classes: - 0: "normal" - 1: "dipped_joint" - 2: "cyclic_top" - 3: "twist_fault" - 4: "alignment_kink" - 5: "buckling_risk" - -calibration: - method: "platt_temperature_scaling" - target_threshold: 0.50 diff --git a/ml/configs/fusion.yaml b/ml/configs/fusion.yaml deleted file mode 100644 index 479b819ee3af854ddf1147b1743c4659b1ccda0a..0000000000000000000000000000000000000000 --- a/ml/configs/fusion.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# Persistence Rule Fusion Configuration (tc.v1 SOTA) - -persistence_window: 3 # Requires 3 consecutive segments (6m) to confirm novel anomalies -known_threshold: 0.60 # Calibrated probability threshold for discrete known defects -novel_threshold: 0.50 # Calibrated probability threshold for novel visual/geometry anomalies - -severity_mapping: - critical: 0.80 - high: 0.65 - medium: 0.50 - low: 0.0 diff --git a/ml/configs/patchcore_enhanced.yaml b/ml/configs/patchcore_enhanced.yaml deleted file mode 100644 index 7b09e21e217ed970781aa03647194347055f7079..0000000000000000000000000000000000000000 --- a/ml/configs/patchcore_enhanced.yaml +++ /dev/null @@ -1,60 +0,0 @@ -# ============================================================================ -# Enhanced PatchCore Configuration for Railway Anomaly Detection -# ============================================================================ - -# Model architecture -model: - backbone: wide_resnet50_2 - layers: - - layer2 - - layer3 - patch_sizes: [3, 5, 7] # Multi-scale detection - coreset_ratio: 0.08 # Keep 8% of patches (diverse minimax sampling) - dimension_reduction: true - target_dim: 128 # Reduced dimension for high throughput - -# Training -training: - batch_size: 32 - num_workers: 0 # Safe for cross-platform execution (Windows/Linux) - image_size: 224 - -# Calibration -calibration: - target_fpr: 0.01 # 1% false positive rate (P99) - method: sigmoid # Nelder-Mead optimized sigmoid calibration - validation_samples: 100 # Normal samples used for calibration - -# Inference -inference: - ensemble_method: weighted_average - patch_weights: - patch_3: 0.3 - patch_5: 0.4 - patch_7: 0.3 - threshold: 0.50 - -# Data augmentation (for normal samples) -augmentation: - enabled: true - techniques: - - brightness_contrast - - hue_saturation - - gaussian_noise - - motion_blur - - affine_transform - probability: 0.5 - -# Memory optimization -optimization: - use_faiss_gpu: false # Set true if CUDA FAISS available - cache_features: true - checkpoint_interval: 50 - -# Output -output: - checkpoint_dir: artifacts/checkpoints/vision/patchcore_enhanced - export_dir: artifacts/exports/vision - calibration_dir: artifacts/calibration - validation_dir: artifacts/validation/patchcore - log_dir: artifacts/logs/patchcore diff --git a/ml/configs/physics_detector.yaml b/ml/configs/physics_detector.yaml deleted file mode 100644 index 9b40eccc53352778b4e70516dfc08dca75f0ffc7..0000000000000000000000000000000000000000 --- a/ml/configs/physics_detector.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# EN 13848 & RDSO Track Geometry Physics Thresholds (tc.v1 SOTA). - -standards: - rdso_broad_gauge_1676: - nominal_gauge_mm: 1676.0 - limits_mm: - twist_3m_mm: 4.0 # RDSO 3m Twist Immediate Action Limit (IAL) - twist_6m_mm: 6.0 # RDSO 6m Twist IAL - versine_10m_mm: 6.0 # 10m Chord Alignment IAL - versine_20m_mm: 10.0 # 20m Chord Alignment IAL - unevenness_10m_mm: 6.0 # 10m Chord Longitudinal Level IAL - unevenness_30m_mm: 12.0 # 30m Chord Longitudinal Level IAL - gauge_variation_mm: 6.0 # Gauge widening/tightening limit (+6mm / -3mm) - cross_level_mm: 20.0 # Cant deviation limit - alert_limits_mm: - twist_3m_mm: 2.8 # Alert Limit (AL) - versine_10m_mm: 4.0 - unevenness_10m_mm: 4.0 - gauge_variation_mm: 4.0 - - en13848_standard_gauge_1435: - nominal_gauge_mm: 1435.0 - limits_mm: - twist_3m_mm: 5.0 - versine_10m_mm: 6.0 - unevenness_10m_mm: 6.0 - gauge_variation_mm: 7.0 - cross_level_mm: 20.0 - -calibration: - exceedance_multiplier: 2.0 # Score = min(1.0, Measured / (2.0 * Limit)) - operating_threshold: 0.50 # Value at limit is exactly 0.50 (triggers alert) diff --git a/ml/configs/sequence_vae.yaml b/ml/configs/sequence_vae.yaml deleted file mode 100644 index 10b82fe4aa2fcb084dc7525bbd63cacf45562f3c..0000000000000000000000000000000000000000 --- a/ml/configs/sequence_vae.yaml +++ /dev/null @@ -1,29 +0,0 @@ -model: - name: "sequence_vae_enhanced" - seq_len: 80 - n_features: 5 - latent_dim: 16 - encoder_type: "dilated_1d_cnn" - dilations: [1, 4, 10] - fusion_channels: 128 - huber_loss: true - -training: - epochs: 50 - beta: 0.01 - batch_size: 64 - learning_rate: 0.001 - weight_decay: 0.0001 - grad_clip: 1.0 - kl_annealing: true - annealing_epochs: 10 - optimizer: "AdamW" - scheduler: "CosineAnnealingLR" - -calibration: - method: "sigmoid_p99" - percentile: 99.0 - steepness_k: 0.5 - target_fpr: 0.01 - operating_threshold: 0.50 - scoring: "dual_path_ensemble" # 0.7 * recon + 0.3 * mahalanobis diff --git a/ml/configs/test.yaml b/ml/configs/test.yaml deleted file mode 100644 index cd7c7ce70cf4b6a704b189f4f22abcc7e7dab083..0000000000000000000000000000000000000000 --- a/ml/configs/test.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# TrackChain Phase 2.7 Final Verification & Test Suite Configuration (tc.v1 SOTA) - -test: - latency_budget_ms: - yolo_fp32: 15 - yolo_int8: 8 - patchcore: 20 - bilstm: 5 - seq_vae: 5 - full_pipeline_fp32: 60 - full_pipeline_int8: 35 - target_fps: 15 - fusion: - persistence_window: 3 - decision_threshold: 0.50 - known_threshold: 0.60 - novel_threshold: 0.50 - calibration: - score_min: 0.0 - score_max: 1.0 - universal_threshold: 0.50 - schema: - version: "tc.v1" diff --git a/ml/configs/yolo_custom_train.yaml b/ml/configs/yolo_custom_train.yaml deleted file mode 100644 index b2b083009fb40189cc2b76245e8169d6aa2dd016..0000000000000000000000000000000000000000 --- a/ml/configs/yolo_custom_train.yaml +++ /dev/null @@ -1,97 +0,0 @@ -# ============================================================================ -# TrackChain Custom YOLOv8n Training Configuration for Railway Defect Detection -# Optimized for small datasets with aggressive data efficiency and anti-overfitting -# ============================================================================ - -# Task and mode -task: detect -mode: train - -# Model architecture (nano for edge deployment) -model: yolov8n.pt - -# ============================================================================ -# Training Hyperparameters & Overfitting Protection -# ============================================================================ - -epochs: 80 -patience: 20 # Real early stopping upon plateau -batch: 8 # Fitted for 960x960 input resolution -imgsz: 960 # 960px input for small-object sensitivity (fasteners, clips, cracks) -freeze: 10 # Freeze backbone layers to prevent overfitting on small datasets -dropout: 0.1 # Dropout regularization (was 0.0) -weight_decay: 0.0005 - -# Optimizer -optimizer: AdamW -lr0: 0.001 # Initial learning rate for fine-tuning -lrf: 0.01 # Final learning rate fraction (lr0 * lrf) -momentum: 0.937 - -# Learning rate scheduling -cos_lr: true # Cosine annealing -warmup_epochs: 3.0 -warmup_momentum: 0.8 -warmup_bias_lr: 0.1 - -# Loss function weights -box: 7.5 # Box loss weight -cls: 0.5 # Classification loss weight -dfl: 1.5 # Distribution focal loss weight - -# ============================================================================ -# Data Efficiency & Aggressive Augmentations -# ============================================================================ - -copy_paste: 0.5 # Paste defect instances onto background canvases (was 0.3) -mixup: 0.15 # MixUp augmentation -mosaic: 1.0 # 4-image mosaic synthesis -close_mosaic: 10 # Disable mosaic only for the last 10 epochs (keeps active till ep 70) -erasing: 0.2 # Controlled random erasing (prevents hallucination) - -# Color augmentations -hsv_h: 0.015 # Hue -hsv_s: 0.7 # Saturation -hsv_v: 0.4 # Value (brightness) - -# Geometric augmentations -degrees: 15.0 # Rotation -translate: 0.2 # Translation -scale: 0.7 # Scale -shear: 5.0 # Shear -perspective: 0.001 # Perspective -flipud: 0.5 # Vertical flip (bi-directional rail heads) -fliplr: 0.5 # Horizontal flip - -# ============================================================================ -# Validation and Operating Metrics -# ============================================================================ - -val: true -split: val -save_json: false -save_hybrid: false -conf: 0.25 # Realistic operating detection threshold (avoids false-positive explosion) -iou: 0.60 # NMS IoU threshold - -# ============================================================================ -# Hardware, Logging & Checkpointing -# ============================================================================ - -device: auto # auto-resolves to '0' if CUDA available, else 'cpu' -workers: 4 -project: artifacts/checkpoints/vision -name: yolov8n_rail_custom -exist_ok: true -pretrained: true - -verbose: true -plots: true -save: true -save_period: 10 # Save periodic checkpoints -save_txt: false -save_conf: false -save_crop: false - -auto_augment: randaugment -rect: false diff --git a/ml/core/__init__.py b/ml/core/__init__.py deleted file mode 100644 index da4b1d5f4a47eecadd0336e68ee00ad2a9a54e58..0000000000000000000000000000000000000000 --- a/ml/core/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Core package marker. diff --git a/ml/core/__pycache__/__init__.cpython-312.pyc b/ml/core/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index f2d7295c6a41a8c599f697399b782d91713e887d..0000000000000000000000000000000000000000 Binary files a/ml/core/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/ml/core/__pycache__/chainage.cpython-312.pyc b/ml/core/__pycache__/chainage.cpython-312.pyc deleted file mode 100644 index d4ca8313a44e8cbff71cbd69310a5e0caf507793..0000000000000000000000000000000000000000 Binary files a/ml/core/__pycache__/chainage.cpython-312.pyc and /dev/null differ diff --git a/ml/core/__pycache__/manifest.cpython-312.pyc b/ml/core/__pycache__/manifest.cpython-312.pyc deleted file mode 100644 index dc96c51755c509e42c008d519ace4a59a741cb81..0000000000000000000000000000000000000000 Binary files a/ml/core/__pycache__/manifest.cpython-312.pyc and /dev/null differ diff --git a/ml/core/__pycache__/registry.cpython-312.pyc b/ml/core/__pycache__/registry.cpython-312.pyc deleted file mode 100644 index d9c66e3adbe3da55523fe368bb4752cd7c219f2a..0000000000000000000000000000000000000000 Binary files a/ml/core/__pycache__/registry.cpython-312.pyc and /dev/null differ diff --git a/ml/core/__pycache__/schema.cpython-312.pyc b/ml/core/__pycache__/schema.cpython-312.pyc deleted file mode 100644 index 166ce3bdc936c0a533c5bf64fd18e85b5ef48af1..0000000000000000000000000000000000000000 Binary files a/ml/core/__pycache__/schema.cpython-312.pyc and /dev/null differ diff --git a/ml/core/chainage.py b/ml/core/chainage.py deleted file mode 100644 index 230274e83826c0ac7f582405f3cad7270d01870a..0000000000000000000000000000000000000000 --- a/ml/core/chainage.py +++ /dev/null @@ -1,237 +0,0 @@ -# Resample all sensor telemetry onto a uniform distance (chainage) spatial axis (tc.v1 SOTA). - -from typing import Dict, List, Tuple, Optional, Union, Any -from dataclasses import dataclass, field -import numpy as np -import pandas as pd -from scipy.interpolate import interp1d - -from ml.core.schema import TrackSegment, ChainageWindow - - -@dataclass -class ResampledRun: - """Container holding the complete resampled run and discretized track segments.""" - grid_chainage_m: np.ndarray - resampled_telemetry: Dict[str, np.ndarray] - segments: List[TrackSegment] = field(default_factory=list) - - -class ChainageResampler: - """ - Resamples asynchronous, time-based sensor streams (IMU, Laser, Optical) - onto a strictly uniform distance (chainage) grid. - Filters out stationary train data to prevent spatial distortion and aliasing. - """ - - def __init__( - self, - step_size_m: float = 0.25, - bin_size_m: Optional[float] = None, - min_speed_mps: float = 0.20, - ): - self.step_size_m = bin_size_m if bin_size_m is not None else step_size_m - self.bin_size_m = self.step_size_m - self.min_speed_mps = min_speed_mps - - def compute_chainage_from_telemetry( - self, - timestamps_s: np.ndarray, - speeds_mps: np.ndarray, - start_chainage_m: float = 0.0, - ) -> Tuple[np.ndarray, np.ndarray]: - """ - Compute cumulative distance (chainage) via trapezoidal integration of velocity over time. - Filters out intervals where the train is stationary (speed < min_speed_mps). - - Returns: - valid_indices: boolean mask of non-stationary sample points - chainage_m: cumulative distance array corresponding to valid points - """ - if len(timestamps_s) < 2: - return np.ones(len(timestamps_s), dtype=bool), np.array([start_chainage_m]) - - dt = np.diff(timestamps_s) - dt = np.maximum(dt, 0.0) - - avg_speed = (speeds_mps[:-1] + speeds_mps[1:]) / 2.0 - is_moving = avg_speed >= self.min_speed_mps - - # Incremental distance dx = v_avg * dt - dx = np.where(is_moving, avg_speed * dt, 0.0) - chainage = np.zeros(len(timestamps_s), dtype=np.float64) - chainage[0] = start_chainage_m - chainage[1:] = start_chainage_m + np.cumsum(dx) - - valid_mask = speeds_mps >= self.min_speed_mps - if np.any(valid_mask): - valid_mask[0] = True - - return valid_mask, chainage - - def resample_stream( - self, - distances: np.ndarray, - values: np.ndarray, - target_grid: np.ndarray, - ) -> np.ndarray: - """ - Interpolate a 1D signal onto the target chainage grid with boundary clamping. - """ - if len(distances) < 2: - return np.zeros_like(target_grid) - - sort_idx = np.argsort(distances) - d_sorted = distances[sort_idx] - v_sorted = values[sort_idx] - - unique_mask = np.concatenate(([True], np.diff(d_sorted) > 1e-6)) - d_unique = d_sorted[unique_mask] - v_unique = v_sorted[unique_mask] - - if len(d_unique) < 2: - return np.full_like(target_grid, v_sorted[0]) - - f = interp1d( - d_unique, - v_unique, - kind="linear", - bounds_error=False, - fill_value=(v_unique[0], v_unique[-1]), - ) - return f(target_grid) - - def resample_telemetry_batch( - self, - timestamps_s: np.ndarray, - speeds_mps: np.ndarray, - sensor_streams: Dict[str, np.ndarray], - start_chainage_m: float = 0.0, - ) -> Tuple[np.ndarray, Dict[str, np.ndarray]]: - """ - Takes raw telemetry time series and resamples all streams onto a uniform distance grid. - - Returns: - grid_chainage_m: strictly uniform distance array (e.g. 0.0, 0.25, 0.50, ...) - resampled_streams: dictionary of resampled sensor arrays - """ - _, chainage = self.compute_chainage_from_telemetry( - timestamps_s, - speeds_mps, - start_chainage_m=start_chainage_m, - ) - - total_distance = chainage[-1] - chainage[0] - if total_distance <= self.step_size_m: - grid = np.array([chainage[0], chainage[0] + self.step_size_m]) - else: - grid = np.arange(chainage[0], chainage[-1] + (self.step_size_m * 0.5), self.step_size_m) - - resampled = {} - for name, values in sensor_streams.items(): - resampled[name] = self.resample_stream(chainage, values, grid) - - return grid, resampled - - def process( - self, - telemetry_input: Union[pd.DataFrame, Dict[str, np.ndarray]], - frames: Optional[List[Any]] = None, - segment_length_m: float = 2.0, - start_chainage_m: float = 0.0, - ) -> ResampledRun: - """ - End-to-end spatial processing: - 1. Resamples all sensor telemetry onto rigid 0.25m distance grid. - 2. Discretizes track run into standardized 2.0m TrackSegment objects. - 3. Distributes vision frames to their corresponding physical TrackSegments. - """ - if isinstance(telemetry_input, pd.DataFrame): - df = telemetry_input - ts = df["timestamp"].values if "timestamp" in df else np.arange(len(df)) * 0.01 - speed = df["speed_mps"].values if "speed_mps" in df else np.full(len(df), 20.0) - sensor_streams = { - col: df[col].values - for col in df.columns - if col not in ("timestamp", "speed_mps") - } - else: - sensor_streams = telemetry_input - ts = sensor_streams.get("timestamp", np.arange(len(next(iter(sensor_streams.values())))) * 0.01) - speed = sensor_streams.get("speed_mps", np.full(len(ts), 20.0)) - - grid_m, resampled = self.resample_telemetry_batch( - timestamps_s=ts, - speeds_mps=speed, - sensor_streams=sensor_streams, - start_chainage_m=start_chainage_m, - ) - - frames_list = frames or [] - n_frames = len(frames_list) - - # Discretize into physical TrackSegments - segments: List[TrackSegment] = [] - seg_start = grid_m[0] - seg_id_counter = 0 - - while seg_start < grid_m[-1]: - seg_end = min(seg_start + segment_length_m, grid_m[-1]) - mask = (grid_m >= seg_start - 1e-4) & (grid_m <= seg_end + 1e-4) - - seg_telemetry = {k: v[mask] for k, v in resampled.items()} - seg_telemetry["distances"] = grid_m[mask] - - # Assign frames that fall within this segment distance interval - seg_frames = [] - if n_frames > 0: - frame_distances = np.linspace(grid_m[0], grid_m[-1], n_frames) - for f_idx, f_item in enumerate(frames_list): - if isinstance(f_item, tuple) and len(f_item) == 2 and isinstance(f_item[0], (int, float)): - f_pos, f_img = f_item - if (seg_start - 1e-3) <= f_pos <= (seg_end + 1e-3): - seg_frames.append(f_img) - else: - f_dist = frame_distances[f_idx] - if (seg_start - 1e-3) <= f_dist <= (seg_end + 1e-3): - seg_frames.append(f_item) - - segments.append( - TrackSegment( - segment_id=f"seg_{seg_id_counter:04d}", - chainage_start_m=round(float(seg_start), 3), - chainage_end_m=round(float(seg_end), 3), - frames=seg_frames, - telemetry=seg_telemetry, - ) - ) - - seg_start += segment_length_m - seg_id_counter += 1 - - return ResampledRun( - grid_chainage_m=grid_m, - resampled_telemetry=resampled, - segments=segments, - ) - - def slice_windows( - self, - start_chainage_m: float, - end_chainage_m: float, - window_len_m: float = 25.0, - overlap_pct: float = 0.2, - ) -> List[Tuple[float, float]]: - """ - Compute overlapping track segment window bounds [start, end] across a track section. - """ - step = window_len_m * (1.0 - overlap_pct) - windows = [] - curr = start_chainage_m - while curr < end_chainage_m: - w_end = min(curr + window_len_m, end_chainage_m) - windows.append((round(curr, 3), round(w_end, 3))) - curr += step - if w_end >= end_chainage_m: - break - return windows diff --git a/ml/core/manifest.py b/ml/core/manifest.py deleted file mode 100644 index 1e2037e46e001f85fceff8c85c15755d8dae2e4f..0000000000000000000000000000000000000000 --- a/ml/core/manifest.py +++ /dev/null @@ -1,201 +0,0 @@ -""" -TrackChain Manifest & Checkpoint Fingerprinting Engine (tc.v1 SOTA). -Provides cryptographically verified skip-logic based on SHA-256 config hashes, -dataset label fingerprints, source weight hashes, and minimum metric quality gates. -""" - -import os -import sys -import json -import hashlib -from pathlib import Path -from typing import Dict, Any, Tuple, Optional, List, Union -import yaml - - -def compute_file_sha256(filepath: Union[str, Path]) -> str: - """Compute SHA-256 hash of a file.""" - p = Path(filepath) - if not p.exists() or not p.is_file(): - return "" - hasher = hashlib.sha256() - with open(p, "rb") as f: - while chunk := f.read(65536): - hasher.update(chunk) - return hasher.hexdigest() - - -def compute_config_hash(config_path: Optional[Union[str, Path]] = None, overrides: Optional[Dict[str, Any]] = None) -> str: - """ - Compute SHA-256 hash of a configuration YAML/JSON combined with CLI hyperparameters. - """ - hasher = hashlib.sha256() - - if config_path: - p = Path(config_path) - if p.exists() and p.is_file(): - with open(p, "rb") as f: - hasher.update(f.read()) - - if overrides: - # Sort keys for deterministic hashing - overrides_str = json.dumps(overrides, sort_keys=True) - hasher.update(overrides_str.encode("utf-8")) - - return hasher.hexdigest() - - -def compute_dataset_hash(data_yaml_path: Union[str, Path]) -> str: - """ - Compute SHA-256 fingerprint of a dataset without reading all image pixels. - Hashes: - - The data.yaml configuration - - Sorted list of label files, their counts, and file sizes across train/val/test splits. - """ - p = Path(data_yaml_path) - if not p.exists(): - return "" - - hasher = hashlib.sha256() - - with open(p, "r", encoding="utf-8") as f: - try: - data_cfg = yaml.safe_load(f) or {} - except Exception: - data_cfg = {} - - hasher.update(json.dumps(data_cfg, sort_keys=True).encode("utf-8")) - - # Resolve dataset root path - raw_path = data_cfg.get("path", "") - data_root = Path(raw_path) if Path(raw_path).is_absolute() else p.parent / raw_path - - for split_key in ["train", "val", "validation", "test"]: - split_val = data_cfg.get(split_key) - if not split_val: - continue - - split_dir = data_root / split_val if not Path(split_val).is_absolute() else Path(split_val) - # Search for corresponding labels folder - labels_dir = split_dir.parent / "labels" if split_dir.name == "images" else split_dir / "labels" - - if labels_dir.exists() and labels_dir.is_dir(): - label_files = sorted(list(labels_dir.glob("*.txt"))) - hasher.update(f"{split_key}_count:{len(label_files)}".encode("utf-8")) - for lf in label_files: - try: - stat = lf.stat() - hasher.update(f"{lf.name}:{stat.st_size}".encode("utf-8")) - except Exception: - continue - elif split_dir.exists(): - # For image folders (e.g. PatchCore normal good images) - img_files = sorted(list(split_dir.glob("*.jpg")) + list(split_dir.glob("*.png"))) - hasher.update(f"{split_key}_imgs:{len(img_files)}".encode("utf-8")) - for img in img_files[:200]: # Hash first 200 filenames/sizes for speed - try: - hasher.update(f"{img.name}:{img.stat().st_size}".encode("utf-8")) - except Exception: - continue - - return hasher.hexdigest() - - -def load_manifest(manifest_path: Union[str, Path]) -> Dict[str, Any]: - """Load JSON manifest file safely.""" - p = Path(manifest_path) - if not p.exists(): - return {} - try: - with open(p, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: - return {} - - -def save_manifest(manifest_path: Union[str, Path], data: Dict[str, Any]): - """Save JSON manifest file atomically.""" - p = Path(manifest_path) - p.parent.mkdir(parents=True, exist_ok=True) - with open(p, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - - -def check_training_skip( - manifest_path: Union[str, Path], - checkpoint_path: Union[str, Path], - current_config_hash: str, - current_dataset_hash: str, - min_metrics: Optional[Dict[str, float]] = None, - force: bool = False, -) -> Tuple[bool, str]: - """ - Evaluate if training can be safely skipped based on manifest fingerprinting. - Returns (should_skip, reason). - """ - if force: - return False, "Force flag enabled (--force / --force-retrain)" - - ckpt = Path(checkpoint_path) - if not ckpt.exists(): - return False, f"Checkpoint does not exist at {ckpt}" - - manifest = load_manifest(manifest_path) - if not manifest: - return False, "Manifest file not found or invalid" - - # Verify config hash - saved_config_hash = manifest.get("config_hash", "") - if saved_config_hash != current_config_hash: - return False, f"Config hash changed ({saved_config_hash[:8]} -> {current_config_hash[:8]})" - - # Verify dataset hash - saved_dataset_hash = manifest.get("dataset_hash", "") - if saved_dataset_hash != current_dataset_hash: - return False, f"Dataset hash changed ({saved_dataset_hash[:8]} -> {current_dataset_hash[:8]})" - - # Verify minimum metric quality gate - if min_metrics: - saved_metrics = manifest.get("metrics", {}) - for metric_key, min_val in min_metrics.items(): - actual_val = saved_metrics.get(metric_key, 0.0) - if actual_val < min_val: - return False, f"Metric gate failed: {metric_key}={actual_val:.4f} < minimum {min_val:.4f}" - - return True, f"Manifest fingerprints match and quality gates passed (mAP50={manifest.get('metrics', {}).get('mAP50', 'N/A')})" - - -def check_export_skip( - manifest_path: Union[str, Path], - export_path: Union[str, Path], - source_model_path: Union[str, Path], - export_format: str = "onnx", - force: bool = False, -) -> Tuple[bool, str]: - """ - Evaluate if model export can be safely skipped based on source weight hash. - Returns (should_skip, reason). - """ - if force: - return False, "Force flag enabled (--force)" - - exp_p = Path(export_path) - if not exp_p.exists(): - return False, f"Exported file does not exist at {exp_p}" - - src_p = Path(source_model_path) - if not src_p.exists(): - return False, f"Source model does not exist at {src_p}" - - current_src_hash = compute_file_sha256(src_p) - manifest = load_manifest(manifest_path) - - saved_export_entry = manifest.get(export_format, {}) - if not saved_export_entry: - return False, f"No manifest entry found for format '{export_format}'" - - saved_src_hash = saved_export_entry.get("source_model_hash", "") - if saved_src_hash != current_src_hash: - return False, f"Source model weight hash changed ({saved_src_hash[:8]} -> {current_src_hash[:8]})" - - return True, f"Export matches source model hash ({current_src_hash[:8]})" diff --git a/ml/core/registry.py b/ml/core/registry.py deleted file mode 100644 index 993deb94a0cc5555c9b3dd9bad0539ae6256bef8..0000000000000000000000000000000000000000 --- a/ml/core/registry.py +++ /dev/null @@ -1,88 +0,0 @@ -# Central Model Registry & Asset Librarian for TrackChain (tc.v1 SOTA). - -import os -from pathlib import Path -from typing import Any, Dict, Optional - -_MODEL_REGISTRY: Dict[str, Any] = {} - - -def get_repo_root() -> Path: - """Return the absolute path to the TrackChain repository root.""" - return Path(__file__).resolve().parent.parent.parent - - -class ModelRegistry: - """ - Central librarian for model architectures, base weights, trained checkpoints, - and quantized edge deployment exports. - """ - ROOT: Path = get_repo_root() - ARTIFACTS_DIR: Path = ROOT / "artifacts" - BASE_DIR: Path = ARTIFACTS_DIR / "base" - CHECKPOINTS_DIR: Path = ARTIFACTS_DIR / "checkpoints" - EXPORTS_DIR: Path = ARTIFACTS_DIR / "exports" - CALIBRATION_DIR: Path = ARTIFACTS_DIR / "calibration" - - @classmethod - def get_base_weights(cls, domain: str, model_name: str) -> Path: - """Return path to frozen/pretrained base weights (e.g. artifacts/base/vision/yolov8n.pt).""" - target = cls.BASE_DIR / domain / model_name - target.parent.mkdir(parents=True, exist_ok=True) - return target - - @classmethod - def get_trained_weights(cls, domain: str, model_name: str, version: Optional[str] = None) -> Path: - """Return path to trained checkpoint (e.g. artifacts/checkpoints/vision/yolo_rail_v0.1.pt).""" - suffix = f"_{version}" if version else "" - stem, ext = os.path.splitext(model_name) - fname = f"{stem}{suffix}{ext}" if ext else f"{model_name}{suffix}.pt" - target = cls.CHECKPOINTS_DIR / domain / fname - target.parent.mkdir(parents=True, exist_ok=True) - return target - - @classmethod - def get_export_path(cls, domain: str, model_name: str, fmt: str = "onnx") -> Path: - """Return path to edge deployment artifact (e.g. artifacts/exports/vision/yolo_rail_v0.1_int8.onnx).""" - stem, _ = os.path.splitext(model_name) - fname = f"{stem}.{fmt}" - target = cls.EXPORTS_DIR / domain / fname - target.parent.mkdir(parents=True, exist_ok=True) - return target - - @classmethod - def get_calibration_path(cls, model_name: str) -> Path: - """Return path to calibration metadata (e.g. artifacts/calibration/yolo_temp_scale.json).""" - stem, _ = os.path.splitext(model_name) - target = cls.CALIBRATION_DIR / f"{stem}_calibration.json" - target.parent.mkdir(parents=True, exist_ok=True) - return target - - -def register_model(name: str): - """Decorator to register a model class or factory function.""" - def decorator(cls_or_fn): - _MODEL_REGISTRY[name] = cls_or_fn - return cls_or_fn - return decorator - - -def load_model( - model_name: str, - checkpoint_path: Optional[str] = None, - **kwargs, -) -> Any: - """Instantiate and load weights for any model registered by name.""" - if model_name not in _MODEL_REGISTRY: - raise ValueError( - f"Model '{model_name}' not found in registry. Available: {list(_MODEL_REGISTRY.keys())}" - ) - - model_cls = _MODEL_REGISTRY[model_name] - instance = model_cls(**kwargs) - - if checkpoint_path and os.path.exists(checkpoint_path): - if hasattr(instance, "load_weights"): - instance.load_weights(checkpoint_path) - - return instance diff --git a/ml/core/schema.py b/ml/core/schema.py deleted file mode 100644 index 54e96a4773547d4e2e89401a95fbcb5b1dc10e06..0000000000000000000000000000000000000000 --- a/ml/core/schema.py +++ /dev/null @@ -1,357 +0,0 @@ -# Shared contracts: ChainageWindow, TrackSegment, GeometryFeatures, CalibratedSignal, SegmentDecision (tc.v1). - -from dataclasses import dataclass, field -from typing import List, Optional, Dict, Any, Tuple, Union -from enum import Enum -import numpy as np - -SCHEMA_VERSION = "tc.v1" - - -class DecisionType(str, Enum): - OK = "OK" - INSPECT_KNOWN = "INSPECT_KNOWN" - INSPECT_NOVEL = "INSPECT_NOVEL" - - -Decision = DecisionType - - -class SeverityLevel(str, Enum): - NORMAL = "normal" - LOW = "low" - MEDIUM = "medium" - HIGH = "high" - CRITICAL = "critical" - - -Severity = SeverityLevel - - -class DefectFamily(str, Enum): - VISUAL_COMPONENT = "visual_component" - VISUAL_SURFACE = "visual_surface" - GEOMETRY = "geometry" - NOVEL_ANOMALY = "novel_anomaly" - OBSTRUCTION = "obstruction" - - -class DefectClass(str, Enum): - NORMAL = "normal" - MISSING_FASTENER = "missing_fastener" - DAMAGED_FASTENER = "damaged_fastener" - CRACK = "crack" - CORRUGATION = "corrugation" - SPALLING = "spalling" - SQUAT = "squat" - GAUGE_WIDENING = "gauge_widening" - ALIGNMENT_FAULT = "alignment_fault" - TWIST_EXCEEDANCE = "twist_exceedance" - ROUGH_TRACK = "rough_track" - DIPPED_JOINT = "dipped_joint" - CYCLIC_TOP = "cyclic_top" - TWIST_FAULT = "twist_fault" - ALIGNMENT_KINK = "alignment_kink" - BUCKLING_RISK = "buckling_risk" - OBSTRUCTION = "obstruction" - VISUAL_ANOMALY = "visual_anomaly" - GEOMETRY_ANOMALY = "geometry_anomaly" - UNCLASSIFIED = "unclassified_anomaly" - - -class SignalType(str, Enum): - VISUAL_KNOWN = "visual_known" - VISUAL_NOVEL = "visual_novel" - GEOMETRY_KNOWN = "geometry_known" - GEOMETRY_NOVEL = "geometry_novel" - GEOMETRY_FAULT_TYPE = "geometry_known_type" - GEOMETRY_KNOWN_TYPE = "geometry_known_type" - - -@dataclass -class ChainageWindow: - """Represents a discrete physical distance window along the railway track.""" - start_chainage_m: float - end_chainage_m: float - timestamps: np.ndarray - distances: np.ndarray - raw_telemetry: Dict[str, np.ndarray] = field(default_factory=dict) - frames: List[np.ndarray] = field(default_factory=list) - - -@dataclass -class TrackSegment: - """Unified physical track segment bundling spatial coordinates, vision frames, and telemetry.""" - segment_id: str - chainage_start_m: float - chainage_end_m: float - timestamp: Optional[Any] = None - frames: List[np.ndarray] = field(default_factory=list) - telemetry: Dict[str, np.ndarray] = field(default_factory=dict) - section_type: str = "mainline_standard" - - -@dataclass -class ExplainabilityTrace: - """Detailed explainability trace linking an ML decision back to raw model signals and spatial bins.""" - model_name: str - signal_type: SignalType - raw_score: float - calibrated_score: float - threshold: float - fired: bool - spatial_bin: int = 0 - attention_peak: Optional[int] = None - reconstruction_error: Optional[float] = None - - -@dataclass -class GeometryFeatures: - """Deterministic track geometry metrics compliant with EN 13848-1.""" - chainage_m: float - gauge_dev_mm: float - cant_mm: float - twist_3m_mm_per_m: float - vertical_profile_d1_mm: float - alignment_d1_mm: float - rms_vibration_g: float - - -@dataclass -class CalibratedSignal: - """Output of a model after temperature scaling or threshold calibration.""" - stream_name: str = "signal" - raw_score: float = 0.0 - calibrated_prob: float = 0.0 - predicted_class: Optional[DefectClass] = None - is_anomaly: bool = False - signal_type: SignalType = SignalType.VISUAL_KNOWN - threshold: float = 0.5 - bbox: Optional[Tuple[float, float, float, float]] = None # (x1, y1, x2, y2) - explanation: Optional[Union[str, Dict[str, Any]]] = None - metadata: Dict[str, Any] = field(default_factory=dict) - - def __init__( - self, - stream_name: Optional[str] = None, - raw_score: float = 0.0, - calibrated_prob: Optional[float] = None, - predicted_class: Optional[DefectClass] = None, - is_anomaly: Optional[bool] = None, - signal_type: SignalType = SignalType.VISUAL_KNOWN, - threshold: float = 0.5, - bbox: Optional[Tuple[float, float, float, float]] = None, - explanation: Optional[Union[str, Dict[str, Any]]] = None, - metadata: Optional[Dict[str, Any]] = None, - name: Optional[str] = None, - value: Optional[float] = None, - label: Optional[DefectClass] = None, - fired: Optional[bool] = None, - model_version: Optional[str] = None, - **kwargs, - ): - self.stream_name = stream_name or name or "signal" - self.raw_score = float(raw_score) - self.calibrated_prob = float(calibrated_prob if calibrated_prob is not None else (value if value is not None else 0.0)) - self.predicted_class = predicted_class if predicted_class is not None else label - self.is_anomaly = is_anomaly if is_anomaly is not None else (fired if fired is not None else False) - self.signal_type = signal_type - self.threshold = float(threshold) - self.bbox = bbox - self.explanation = explanation - self.metadata = metadata or {} - if model_version: - self.metadata["model_version"] = model_version - if kwargs: - self.metadata.update(kwargs) - - @property - def name(self) -> str: - return self.stream_name - - @property - def value(self) -> float: - return self.calibrated_prob - - @property - def fired(self) -> bool: - return self.is_anomaly - - @property - def label(self) -> Optional[DefectClass]: - return self.predicted_class - - def to_dict(self) -> Dict[str, Any]: - return { - "name": self.stream_name, - "stream_name": self.stream_name, - "model_version": self.metadata.get("model_version", "0.1.0"), - "signal_type": self.signal_type.value if hasattr(self.signal_type, "value") else str(self.signal_type), - "value": float(self.calibrated_prob), - "raw_score": float(self.raw_score), - "calibrated_prob": float(self.calibrated_prob), - "threshold": float(self.threshold), - "fired": bool(self.is_anomaly), - "is_anomaly": bool(self.is_anomaly), - "predicted_class": self.predicted_class.value if hasattr(self.predicted_class, "value") else str(self.predicted_class) if self.predicted_class else None, - "bbox": list(self.bbox) if self.bbox else None, - "explanation": self.explanation, - "metadata": self.metadata, - } - - -@dataclass -class SegmentSignals: - """Unified collection of multi-modal signals associated with a TrackSegment.""" - v_known: List[CalibratedSignal] = field(default_factory=list) - v_novel: List[CalibratedSignal] = field(default_factory=list) - g_known: List[CalibratedSignal] = field(default_factory=list) - g_novel: List[CalibratedSignal] = field(default_factory=list) - g_type: List[CalibratedSignal] = field(default_factory=list) - - @property - def all_signals(self) -> List[CalibratedSignal]: - return self.v_known + self.v_novel + self.g_known + self.g_novel + self.g_type - - def get_primary(self, signal_type: SignalType) -> CalibratedSignal: - """Extract the most prominent CalibratedSignal matching the given stream type.""" - matching = [s for s in self.all_signals if s.signal_type == signal_type] - if matching: - # Return signal with highest calibrated probability or fired status - return max(matching, key=lambda s: (s.is_anomaly, s.calibrated_prob)) - return CalibratedSignal( - stream_name=signal_type.value, - raw_score=0.0, - calibrated_prob=0.0, - signal_type=signal_type, - is_anomaly=False, - predicted_class=DefectClass.NORMAL, - ) - - -@dataclass -class MediaReference: - """Metadata pointing to S3 media asset associated with an ML inference result.""" - media_id: Optional[str] = None - s3_key: Optional[str] = None - offset_seconds: float = 0.0 - frame_index: Optional[int] = None - - -@dataclass -class SegmentDecision: - """Final operational decision emitted by the persistence rule fusion engine for a track segment.""" - window_id: str = "win-00000" - start_chainage_m: float = 0.0 - end_chainage_m: float = 0.0 - decision: DecisionType = DecisionType.OK - confidence: float = 0.98 - primary_fault: Optional[Union[DefectClass, str]] = None - defect_family: DefectFamily = DefectFamily.VISUAL_COMPONENT - severity: SeverityLevel = SeverityLevel.LOW - signals: List[CalibratedSignal] = field(default_factory=list) - evidence: Optional[MediaReference] = None - timestamp: Optional[str] = None - schema_version: str = SCHEMA_VERSION - source: Optional[str] = None - cross_modal_boost: float = 1.0 - action: str = "Routine inspection cycle maintained." - traces: List[ExplainabilityTrace] = field(default_factory=list) - - def __init__( - self, - decision: Optional[DecisionType] = None, - primary_fault: Optional[Union[DefectClass, str]] = None, - severity: Optional[SeverityLevel] = None, - window_id: str = "win-00000", - start_chainage_m: float = 0.0, - end_chainage_m: float = 0.0, - confidence: float = 0.98, - defect_family: DefectFamily = DefectFamily.VISUAL_COMPONENT, - signals: Optional[List[CalibratedSignal]] = None, - evidence: Optional[MediaReference] = None, - timestamp: Optional[str] = None, - schema_version: str = SCHEMA_VERSION, - source: Optional[str] = None, - cross_modal_boost: float = 1.0, - action: str = "Routine inspection cycle maintained.", - traces: Optional[List[ExplainabilityTrace]] = None, - **kwargs, - ): - self.decision = decision if decision is not None else DecisionType.OK - self.primary_fault = primary_fault or kwargs.get("primary_defect") - self.severity = severity if severity is not None else SeverityLevel.LOW - self.window_id = window_id - self.start_chainage_m = float(start_chainage_m if start_chainage_m != 0.0 else kwargs.get("chainage_start_m", 0.0)) - self.end_chainage_m = float(end_chainage_m if end_chainage_m != 0.0 else kwargs.get("chainage_end_m", 0.0)) - self.confidence = float(confidence) - self.defect_family = defect_family - self.signals = signals if signals is not None else [] - self.evidence = evidence - self.timestamp = timestamp - self.schema_version = schema_version - self.source = source - self.cross_modal_boost = float(cross_modal_boost) - self.action = str(action or kwargs.get("action", "Routine inspection cycle maintained.")) - self.traces = traces if traces is not None else [] - for k, v in kwargs.items(): - if k in ("chainage_start_m", "start_chainage_m"): - self.start_chainage_m = float(v) - elif k in ("chainage_end_m", "end_chainage_m"): - self.end_chainage_m = float(v) - elif k in ("primary_defect", "primary_fault"): - self.primary_fault = v - else: - setattr(self, k, v) - - @property - def primary_defect(self) -> Optional[Union[DefectClass, str]]: - return self.primary_fault - - @primary_defect.setter - def primary_defect(self, value: Optional[Union[DefectClass, str]]) -> None: - self.primary_fault = value - - @property - def chainage_start_m(self) -> float: - return self.start_chainage_m - - @chainage_start_m.setter - def chainage_start_m(self, value: float) -> None: - self.start_chainage_m = float(value) - - @property - def chainage_end_m(self) -> float: - return self.end_chainage_m - - @chainage_end_m.setter - def chainage_end_m(self, value: float) -> None: - self.end_chainage_m = float(value) - - def to_dict(self) -> Dict[str, Any]: - prim_def = self.primary_fault - if hasattr(prim_def, "value"): - prim_def_str = prim_def.value - elif prim_def is not None: - prim_def_str = str(prim_def) - else: - prim_def_str = None - - return { - "schema_version": self.schema_version, - "window_id": self.window_id, - "chainage_start_m": float(self.start_chainage_m), - "chainage_end_m": float(self.end_chainage_m), - "start_chainage_m": float(self.start_chainage_m), - "end_chainage_m": float(self.end_chainage_m), - "decision": self.decision.value if hasattr(self.decision, "value") else str(self.decision), - "severity": self.severity.value if hasattr(self.severity, "value") else str(self.severity), - "confidence": round(float(self.confidence), 4), - "primary_defect": prim_def_str, - "primary_fault": prim_def_str, - "action": getattr(self, "action", "Routine inspection cycle maintained."), - "section_type": getattr(self, "section_type", "mainline_standard"), - "cross_modal_boost": float(self.cross_modal_boost), - "signals": [s.to_dict() if hasattr(s, "to_dict") else s for s in self.signals], - "timestamp": self.timestamp, - } diff --git a/ml/data/__init__.py b/ml/data/__init__.py deleted file mode 100644 index 140c72137e799a141383b1c93187ca637d1d37fc..0000000000000000000000000000000000000000 --- a/ml/data/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Data package marker. diff --git a/ml/data/__pycache__/__init__.cpython-312.pyc b/ml/data/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 36d076dcdfa29c7ab30c6ae531f1f814e35e1f8c..0000000000000000000000000000000000000000 Binary files a/ml/data/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/ml/data/__pycache__/synthetic_geometry.cpython-312.pyc b/ml/data/__pycache__/synthetic_geometry.cpython-312.pyc deleted file mode 100644 index 82fec707f6e9722ae00167a4fdbe88cbc35284f3..0000000000000000000000000000000000000000 Binary files a/ml/data/__pycache__/synthetic_geometry.cpython-312.pyc and /dev/null differ diff --git a/ml/data/datasets.py b/ml/data/datasets.py deleted file mode 100644 index db5bccf8c9643a1ce30b12379622a9ee2cbb809e..0000000000000000000000000000000000000000 --- a/ml/data/datasets.py +++ /dev/null @@ -1,66 +0,0 @@ -# PyTorch Datasets for RSDDs, NEU, and fastener imagery. - -import os -from typing import Callable, List, Optional, Tuple -import numpy as np -import torch -from torch.utils.data import Dataset - - -class RailSurfaceDefectDataset(Dataset): - """PyTorch dataset for RSDDs and NEU rail surface defect images.""" - - def __init__( - self, - image_paths: List[str], - labels: Optional[List[int]] = None, - transform: Optional[Callable] = None, - is_normal_only: bool = False, - ): - self.image_paths = image_paths - self.labels = labels if labels is not None else [0] * len(image_paths) - self.transform = transform - self.is_normal_only = is_normal_only - - if self.is_normal_only and labels is not None: - normal_indices = [i for i, l in enumerate(labels) if l == 0] - self.image_paths = [self.image_paths[i] for i in normal_indices] - self.labels = [0] * len(self.image_paths) - - def __len__(self) -> int: - return len(self.image_paths) - - def __getitem__(self, idx: int) -> Tuple[torch.Tensor, int]: - path = self.image_paths[idx] - # Generate dummy array if file doesn't exist on disk yet - if os.path.exists(path): - import cv2 - img = cv2.imread(path) - img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) - else: - img = np.zeros((224, 224, 3), dtype=np.uint8) - - if self.transform: - img_tensor = self.transform(img) - else: - img_tensor = torch.from_numpy(img).permute(2, 0, 1).float() / 255.0 - - return img_tensor, self.labels[idx] - - -class GeometrySequenceDataset(Dataset): - """PyTorch dataset for EN 13848 multi-channel geometry sequence windows.""" - - def __init__( - self, - features: np.ndarray, # shape: [N, window_len, num_channels] - labels: np.ndarray, # shape: [N] - ): - self.features = torch.from_numpy(features).float() - self.labels = torch.from_numpy(labels).long() - - def __len__(self) -> int: - return len(self.features) - - def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]: - return self.features[idx], self.labels[idx] diff --git a/ml/data/loaders.py b/ml/data/loaders.py deleted file mode 100644 index 1ce90bb616a0c23542d388ad4995b793a298838e..0000000000000000000000000000000000000000 --- a/ml/data/loaders.py +++ /dev/null @@ -1,37 +0,0 @@ -# DataLoader builders with train/val splits and augmentation. - -from typing import Tuple -from torch.utils.data import DataLoader, random_split, Dataset - - -def build_dataloaders( - dataset: Dataset, - batch_size: int = 32, - val_split: float = 0.2, - num_workers: int = 0, - seed: int = 42, -) -> Tuple[DataLoader, DataLoader]: - """Create reproducible training and validation data loaders.""" - val_size = int(len(dataset) * val_split) - train_size = len(dataset) - val_size - - train_set, val_set = random_split( - dataset, - [train_size, val_size], - generator=None, - ) - - train_loader = DataLoader( - train_set, - batch_size=batch_size, - shuffle=True, - num_workers=num_workers, - ) - val_loader = DataLoader( - val_set, - batch_size=batch_size, - shuffle=False, - num_workers=num_workers, - ) - - return train_loader, val_loader diff --git a/ml/data/synthetic.py b/ml/data/synthetic.py deleted file mode 100644 index e41d0cf2b99b3a6623d18a095e43148e242988b6..0000000000000000000000000000000000000000 --- a/ml/data/synthetic.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -Generate synthetic vision and geometry data with known fault signatures (tc.v1 SOTA). -Provides multi-modal generation for: - - Continuous track geometry conforming to EN 13848-1 - - Procedural vision defect injection (crack, missing fastener, defective clip, obstruction) -""" - -from typing import Dict, List, Tuple, Optional, Any -import numpy as np -import cv2 - -from ml.data.synthetic_vision import ( - SyntheticRailDefectGenerator, - sanitize_bbox, - CLASS_MAPPING, - CLASS_NAMES, -) - - -def generate_synthetic_geometry( - length_m: float = 1000.0, - step_m: float = 0.25, - fault_probability: float = 0.05, - random_seed: int = 42, -) -> Dict[str, np.ndarray]: - """Generate synthetic continuous track geometry conforming to EN 13848-1.""" - np.random.seed(random_seed) - n_points = int(length_m / step_m) - chainage = np.linspace(0, length_m, n_points) - - # Base nominal signals - gauge = 1435.0 + np.random.normal(0, 0.8, n_points) - cant = 10.0 * np.sin(chainage / 200.0) + np.random.normal(0, 1.2, n_points) - twist = np.gradient(cant, step_m) * 3.0 + np.random.normal(0, 0.2, n_points) - unevenness = 0.5 * np.sin(chainage / 15.0) + np.random.normal(0, 0.3, n_points) - alignment = 0.6 * np.cos(chainage / 25.0) + np.random.normal(0, 0.3, n_points) - vibration_rms = 0.8 + 0.2 * np.abs(np.random.normal(0, 1.0, n_points)) - - # Inject discrete fault signatures - labels = np.zeros(n_points, dtype=int) - n_faults = int(length_m * fault_probability / 50.0) - - for _ in range(n_faults): - center_idx = np.random.randint(50, n_points - 50) - fault_type = np.random.choice([1, 2, 3]) # 1: Gauge widening, 2: Twist, 3: Corrugation - window_size = np.random.randint(10, 40) - idx_range = slice(center_idx - window_size // 2, center_idx + window_size // 2) - - if fault_type == 1: - gauge[idx_range] += np.random.uniform(8.0, 18.0) - labels[idx_range] = 1 - elif fault_type == 2: - twist[idx_range] += np.random.uniform(3.5, 6.0) * np.sign(np.random.randn()) - labels[idx_range] = 2 - elif fault_type == 3: - unevenness[idx_range] += 3.0 * np.sin(np.arange(window_size) * 0.8) - vibration_rms[idx_range] += np.random.uniform(1.8, 3.5) - labels[idx_range] = 3 - - return { - "chainage_m": chainage, - "gauge_mm": gauge, - "cant_mm": cant, - "twist_mm_per_m": twist, - "vertical_unevenness_mm": unevenness, - "alignment_mm": alignment, - "vibration_rms_g": vibration_rms, - "fault_labels": labels, - } - - -def generate_synthetic_defect_image( - defect_type: str = "crack", - height: int = 640, - width: int = 640, -) -> Tuple[np.ndarray, Optional[List[float]]]: - """ - Generate high-fidelity synthetic rail surface patch with specific defect and YOLO bounding box. - """ - # Create base track texture - base = np.zeros((height, width, 3), dtype=np.uint8) - ballast = np.array([80, 75, 65], dtype=np.float32) - noise = np.random.normal(0, 15, (height, width, 3)) - base[:] = np.clip(ballast + noise, 0, 255).astype(np.uint8) - - # Add rails - rail_w = int(width * 0.08) - for offset in [int(width * 0.32), int(width * 0.68)]: - cv2.rectangle(base, (offset - rail_w // 2, 0), (offset + rail_w // 2, height), (55, 55, 60), -1) - cv2.line(base, (offset, 0), (offset, height), (140, 140, 150), 3) - - gen = SyntheticRailDefectGenerator() - if defect_type == "crack": - return gen.inject_crack(base) - elif defect_type in ["missing_fastener", "fastener"]: - return gen.inject_missing_fastener(base) - elif defect_type in ["defective_clip", "damaged_fastener", "clip"]: - return gen.inject_defective_clip(base) - elif defect_type in ["obstruction", "debris"]: - return gen.inject_obstruction(base) - else: - return gen.inject_crack(base) - - -__all__ = [ - "generate_synthetic_geometry", - "generate_synthetic_defect_image", - "SyntheticRailDefectGenerator", - "sanitize_bbox", - "CLASS_MAPPING", - "CLASS_NAMES", -] diff --git a/ml/data/synthetic_geometry.py b/ml/data/synthetic_geometry.py deleted file mode 100644 index e15dabac2dd62f89d6023c4385385a8241d3c987..0000000000000000000000000000000000000000 --- a/ml/data/synthetic_geometry.py +++ /dev/null @@ -1,219 +0,0 @@ -""" -ml/data/synthetic_geometry.py -Generates EN 13848-compliant synthetic geometry sequences for Bi-LSTM training and evaluation. -""" - -from typing import Dict, Tuple, List, Optional, Union, Any -from enum import IntEnum -import numpy as np -from scipy.ndimage import gaussian_filter1d -import torch -from torch.utils.data import Dataset, DataLoader - -from ml.core.schema import DefectClass - - -class GeometryFaultType(IntEnum): - NORMAL = 0 - DIPPED_JOINT = 1 - CYCLIC_TOP = 2 - TWIST_FAULT = 3 - ALIGNMENT_KINK = 4 - BUCKLING_RISK = 5 - - -CLASS_MAP = { - 0: "NORMAL", - 1: "DIPPED_JOINT", - 2: "CYCLIC_TOP", - 3: "TWIST_FAULT", - 4: "ALIGNMENT_KINK", - 5: "BUCKLING_RISK", -} - -CLASS_TO_DEFECT_ENUM = { - 0: DefectClass.NORMAL, - 1: DefectClass.DIPPED_JOINT, - 2: DefectClass.CYCLIC_TOP, - 3: DefectClass.TWIST_FAULT, - 4: DefectClass.ALIGNMENT_KINK, - 5: DefectClass.BUCKLING_RISK, -} - -DEFECT_ENUM_TO_CLASS = {v: k for k, v in CLASS_TO_DEFECT_ENUM.items()} -FAULT_TYPES = CLASS_MAP - - -class SyntheticGeometryDataset(Dataset): - """ - Parametric synthetic dataset generating 5-channel EN 13848 track geometry sequences - (0: Twist_3m, 1: Versine_10m, 2: Versine_20m, 3: Unevenness_10m, 4: Cant). - """ - - FAULT_TYPES = CLASS_MAP - - def __init__( - self, - num_samples: int = 2000, - seq_len: int = 80, - bin_size: float = 0.25, - noise_std: float = 0.5, - num_classes: int = 6, - random_seed: Optional[int] = None, - ): - self.num_samples = num_samples - self.seq_len = seq_len - self.bin_size = bin_size - self.noise_std = noise_std - self.num_classes = num_classes - self.rng = np.random.RandomState(random_seed) - self.data, self.labels = self._generate_dataset() - - def _generate_psd_noise(self, length: int) -> np.ndarray: - """Generates 1D track noise based on EN 13848-2 PSD decay.""" - freqs = np.fft.rfftfreq(length, d=self.bin_size) - freqs[0] = 1e-6 - psd = 0.005 / (freqs**2 + 0.001) - phase = self.rng.uniform(0, 2 * np.pi, len(freqs)) - profile = np.fft.irfft(np.sqrt(psd) * np.exp(1j * phase), n=length) - return profile * 100.0 * self.noise_std # Scaled to physical mm - - def _generate_dataset(self) -> Tuple[torch.Tensor, torch.Tensor]: - X = np.zeros((self.num_samples, self.seq_len, 5), dtype=np.float32) - y = np.zeros(self.num_samples, dtype=np.int64) - - # Features: 0:Twist, 1:Versine10m, 2:Versine20m, 3:Unevenness, 4:Cant - for i in range(self.num_samples): - # Base noise for all 5 channels - base_noise = np.column_stack([self._generate_psd_noise(self.seq_len) for _ in range(5)]) - X[i] = base_noise - - # Assign class (balanced distribution) - fault_type = i % self.num_classes - y[i] = fault_type - - if fault_type == GeometryFaultType.NORMAL: - continue # Just baseline PSD track noise - - # Inject parametric fault shape at a random location in the window - center = int(self.rng.randint(20, self.seq_len - 20)) - - if fault_type == GeometryFaultType.DIPPED_JOINT: - # Gaussian dip in Unevenness (Channel 3) - x_vals = np.arange(self.seq_len, dtype=np.float32) - dip = -8.0 * np.exp(-0.5 * ((x_vals - center) / 2.0) ** 2) - X[i, :, 3] += dip - - elif fault_type == GeometryFaultType.CYCLIC_TOP: - # Sine wave in Unevenness (Channel 3) - x_vals = np.arange(self.seq_len, dtype=np.float32) * self.bin_size - wave = 5.0 * np.sin(2 * np.pi * x_vals / 6.0) # 5mm amp, 6m wavelength - window = np.exp(-0.5 * ((x_vals - center * self.bin_size) / 4.0) ** 2) - X[i, :, 3] += wave * window - - elif fault_type == GeometryFaultType.TWIST_FAULT: - # Tanh step in Cant (Channel 4) and Twist (Channel 0) - x_vals = np.arange(self.seq_len, dtype=np.float32) - ramp = 4.0 * np.tanh((x_vals - center) / 1.5) - X[i, :, 4] += ramp - # Twist is spatial derivative of cant over 3m base - X[i, :, 0] += np.gradient(ramp, self.bin_size) * 3.0 - - elif fault_type == GeometryFaultType.ALIGNMENT_KINK: - # Step function in Versine (Channels 1 & 2) - x_vals = np.arange(self.seq_len, dtype=np.float32) - kink = 6.0 * np.tanh((x_vals - center) / 0.5) - X[i, :, 1] += kink - X[i, :, 2] += kink * 0.8 # 20m chord sees it slightly smoothed - - elif fault_type == GeometryFaultType.BUCKLING_RISK: - # Broad lateral bow in Versine (Channels 1 & 2) - x_vals = np.arange(self.seq_len, dtype=np.float32) - bow = 10.0 * np.exp(-0.5 * ((x_vals - center) / 8.0) ** 2) - X[i, :, 1] += bow - X[i, :, 2] += bow * 1.2 - - return torch.tensor(X, dtype=torch.float32), torch.tensor(y, dtype=torch.long) - - def __len__(self) -> int: - return self.num_samples - - def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]: - return self.data[idx], self.labels[idx] - - -class ParametricGeometryGenerator: - """Convenience generator for single parametric geometry windows with metadata.""" - - def __init__( - self, - window_length_m: float = 20.0, - step_m: float = 0.25, - random_seed: Optional[int] = None, - ): - self.window_length_m = window_length_m - self.step_m = step_m - self.seq_len = int(round(window_length_m / step_m)) - self.rng = np.random.RandomState(random_seed) - - def generate_window( - self, - defect_class: Union[int, DefectClass] = 0, - spatial_shift_m: Optional[float] = None, - amplitude_scale: float = 1.0, - noise_level_mm: float = 0.5, - ) -> Tuple[np.ndarray, int, Dict[str, Any]]: - if isinstance(defect_class, DefectClass): - cid = DEFECT_ENUM_TO_CLASS.get(defect_class, 0) - else: - cid = int(defect_class) - - center_bin = int(round(spatial_shift_m / self.step_m)) if spatial_shift_m is not None else int(self.rng.randint(20, self.seq_len - 20)) - center_bin = int(np.clip(center_bin, 10, self.seq_len - 10)) - - ds = SyntheticGeometryDataset( - num_samples=max(6, cid + 1), - seq_len=self.seq_len, - bin_size=self.step_m, - noise_std=noise_level_mm, - num_classes=6, - random_seed=int(self.rng.randint(0, 100000)), - ) - - # Inject exact requested class - feat, _ = ds[cid] - feat_np = feat.numpy().copy() - - metadata = { - "class_id": cid, - "class_name": CLASS_MAP.get(cid, "UNKNOWN"), - "defect_center_bin": center_bin, - "defect_center_m": float(center_bin * self.step_m), - "amplitude_mm": float(amplitude_scale), - } - return feat_np, cid, metadata - - -def create_synthetic_data_loaders( - train_samples_per_class: int = 250, - val_samples_per_class: int = 50, - batch_size: int = 32, - num_classes: int = 5, - random_seed: int = 42, -) -> Tuple[DataLoader, DataLoader]: - """Factory creating Train and Validation DataLoaders.""" - train_dataset = SyntheticGeometryDataset( - num_samples=train_samples_per_class * num_classes, - num_classes=num_classes, - random_seed=random_seed, - ) - val_dataset = SyntheticGeometryDataset( - num_samples=val_samples_per_class * num_classes, - num_classes=num_classes, - random_seed=random_seed + 999, - ) - - train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True) - val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False) - - return train_loader, val_loader diff --git a/ml/data/synthetic_vision.py b/ml/data/synthetic_vision.py deleted file mode 100644 index 3fcc6d9501f4e79cdc4407b07345c72503cdc9e2..0000000000000000000000000000000000000000 --- a/ml/data/synthetic_vision.py +++ /dev/null @@ -1,425 +0,0 @@ -""" -TrackChain Synthetic Visual Defect Generation Engine (tc.v1 SOTA). -Generates photorealistic railway defects on normal track imagery or procedural canvases: - - Class 0: missing_fastener (fastener removal, empty tie socket inpainting, rust residue) - - Class 1: defective_clip (deformed, fractured, or displaced Pandrol elastic clips) - - Class 2: crack (rolling-contact fatigue, branching cracks, transverse fissures on rail heads) - - Class 3: obstruction (ballast rocks, metal tools, debris, wooden blocks on trackbed) - -Outputs standard YOLO-format annotations [class_id, x_center, y_center, width, height]. -""" - -import math -import random -from typing import List, Dict, Tuple, Optional, Union, Any -from pathlib import Path -import numpy as np -import cv2 - - -CLASS_MAPPING = { - "missing_fastener": 0, - "defective_clip": 1, - "crack": 2, - "obstruction": 3, -} - -CLASS_NAMES = ["missing_fastener", "defective_clip", "crack", "obstruction"] - - -def sanitize_bbox(bbox: List[float]) -> Optional[List[float]]: - """Sanitize and clamp YOLO bbox coordinates [x_center, y_center, width, height].""" - if len(bbox) < 4: - return None - x, y, w, h = float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3]) - if w <= 0.005 or h <= 0.005: - return None - x = max(0.01, min(0.99, x)) - y = max(0.01, min(0.99, y)) - w = max(0.01, min(1.0, w)) - h = max(0.01, min(1.0, h)) - - half_w = w / 2.0 - half_h = h / 2.0 - x1 = max(0.0, x - half_w) - y1 = max(0.0, y - half_h) - x2 = min(1.0, x + half_w) - y2 = min(1.0, y + half_h) - - new_w = x2 - x1 - new_h = y2 - y1 - if new_w <= 0.005 or new_h <= 0.005: - return None - - new_x = (x1 + x2) / 2.0 - new_y = (y1 + y2) / 2.0 - return [round(new_x, 6), round(new_y, 6), round(new_w, 6), round(new_h, 6)] - - -class SyntheticRailDefectGenerator: - """ - Photorealistic synthetic defect injection system for railway infrastructure. - Uses normal track imagery as canvas to synthesize domain-accurate defect signatures. - """ - - def __init__(self, random_seed: Optional[int] = None): - if random_seed is not None: - random.seed(random_seed) - np.random.seed(random_seed) - - # ------------------------------------------------------------------------- - # 1. Crack Generation (Railhead surface defect - Class 2) - # ------------------------------------------------------------------------- - def generate_crack_patch( - self, width: int = 120, height: int = 120, crack_type: str = "branching" - ) -> Tuple[np.ndarray, np.ndarray]: - """ - Generate an alpha-masked crack patch with branching, irregular width, - and realistic shadow/highlight edges. - """ - patch = np.zeros((height, width, 3), dtype=np.uint8) - alpha = np.zeros((height, width), dtype=np.float32) - - start_x = random.randint(int(width * 0.3), int(width * 0.7)) - start_y = random.randint(10, 25) - curr_x, curr_y = start_x, start_y - - main_points = [(curr_x, curr_y)] - angle = random.uniform(0.35 * math.pi, 0.65 * math.pi) # downwards with jitter - - while curr_y < height - 15: - step_len = random.randint(4, 9) - angle += random.uniform(-0.4, 0.4) - curr_x += int(step_len * math.cos(angle)) - curr_y += int(step_len * math.sin(angle)) - curr_x = max(5, min(width - 5, curr_x)) - curr_y = max(5, min(height - 5, curr_y)) - main_points.append((curr_x, curr_y)) - - # Render main fissure with variable thickness and dark core - for i in range(len(main_points) - 1): - pt1 = main_points[i] - pt2 = main_points[i + 1] - thickness = random.choice([2, 3, 2, 1]) - darkness = random.randint(15, 45) - cv2.line(patch, pt1, pt2, (darkness, darkness, darkness + 5), thickness) - cv2.line(alpha, pt1, pt2, 1.0, thickness + 1) - - # Highlight edge (specular rail reflection next to fissure) - offset_pt1 = (pt1[0] + 1, pt1[1]) - offset_pt2 = (pt2[0] + 1, pt2[1]) - cv2.line(patch, offset_pt1, offset_pt2, (180, 185, 190), 1) - cv2.line(alpha, offset_pt1, offset_pt2, 0.4, 1) - - # Add secondary branching fissures - if crack_type == "branching" and len(main_points) > 4: - num_branches = random.randint(1, 3) - branch_origins = random.sample(main_points[2:-2], min(num_branches, len(main_points) - 4)) - for bx, by in branch_origins: - branch_angle = angle + random.choice([-1.0, 1.0]) * random.uniform(0.6, 1.2) - bx_curr, by_curr = bx, by - for _ in range(random.randint(3, 6)): - b_step = random.randint(3, 7) - bx_curr += int(b_step * math.cos(branch_angle)) - by_curr += int(b_step * math.sin(branch_angle)) - bx_curr = max(3, min(width - 3, bx_curr)) - by_curr = max(3, min(height - 3, by_curr)) - cv2.line(patch, (bx, by), (bx_curr, by_curr), (25, 25, 30), 1) - cv2.line(alpha, (bx, by), (bx_curr, by_curr), 0.8, 1) - bx, by = bx_curr, by - - # Smooth alpha mask for seamless blending - alpha = cv2.GaussianBlur(alpha, (3, 3), 0.5) - return patch, alpha - - def inject_crack( - self, image: np.ndarray, bbox_loc: Optional[Tuple[int, int, int, int]] = None - ) -> Tuple[np.ndarray, Optional[List[float]]]: - """ - Inject synthetic crack onto rail head region. - Returns modified image and normalized YOLO bbox. - """ - h, w = image.shape[:2] - img = image.copy() - - if bbox_loc is not None: - x1, y1, x2, y2 = bbox_loc - cw, ch = max(30, x2 - x1), max(30, y2 - y1) - else: - # Place crack along rail head area (typically vertical strip ~30% to 70% width) - cw = random.randint(int(w * 0.08), int(w * 0.22)) - ch = random.randint(int(h * 0.12), int(h * 0.35)) - # Pick left or right rail zone - rail_center_x = random.choice([int(w * 0.32), int(w * 0.68)]) - x1 = max(5, min(w - cw - 5, rail_center_x - cw // 2 + random.randint(-15, 15))) - y1 = random.randint(int(h * 0.10), max(10, h - ch - int(h * 0.10))) - x2, y2 = x1 + cw, y1 + ch - - crack_patch, alpha = self.generate_crack_patch(cw, ch) - alpha_3d = np.expand_dims(alpha, axis=-1) - - roi = img[y1:y2, x1:x2].astype(np.float32) - blended = roi * (1.0 - alpha_3d) + crack_patch.astype(np.float32) * alpha_3d - img[y1:y2, x1:x2] = np.clip(blended, 0, 255).astype(np.uint8) - - # Compute YOLO normalized bbox [x_center, y_center, width, height] - xc = (x1 + x2) / (2.0 * w) - yc = (y1 + y2) / (2.0 * h) - bw = (x2 - x1) / float(w) - bh = (y2 - y1) / float(h) - clean_box = sanitize_bbox([xc, yc, bw, bh]) - return img, clean_box - - # ------------------------------------------------------------------------- - # 2. Missing Fastener Generation (Class 0) - # ------------------------------------------------------------------------- - def inject_missing_fastener( - self, image: np.ndarray, bbox_loc: Optional[Tuple[int, int, int, int]] = None - ) -> Tuple[np.ndarray, Optional[List[float]]]: - """ - Simulate missing Pandrol fastener / clip. - Inpaints the clip area with empty tie socket, rust ring, and exposed baseplate/sleeper. - """ - h, w = image.shape[:2] - img = image.copy() - - if bbox_loc is not None: - x1, y1, x2, y2 = bbox_loc - fw, fh = max(25, x2 - x1), max(25, y2 - y1) - else: - # Fasteners sit adjacent to rails on sleepers - fw = random.randint(int(w * 0.07), int(w * 0.14)) - fh = random.randint(int(h * 0.07), int(h * 0.14)) - # Typical fastener locations on baseplate (left/right of rail lines) - x_band = random.choice([int(w * 0.22), int(w * 0.42), int(w * 0.58), int(w * 0.78)]) - x1 = max(5, min(w - fw - 5, x_band - fw // 2 + random.randint(-10, 10))) - y1 = random.randint(int(h * 0.15), max(10, h - fh - int(h * 0.15))) - x2, y2 = x1 + fw, y1 + fh - - roi = img[y1:y2, x1:x2] - # Inpaint: replace clip with sleeper/baseplate texture + dark vacant socket cavity - mask = np.zeros((fh, fw), dtype=np.uint8) - cx, cy = fw // 2, fh // 2 - rx, ry = int(fw * 0.35), int(fh * 0.35) - cv2.ellipse(mask, (cx, cy), (rx, ry), 0, 0, 360, 255, -1) - - # Baseplate background color sampled from surrounding sleeper - surrounding_color = np.median(roi, axis=(0, 1)).astype(np.float32) - # Empty cavity texture (dark hole with rust ring) - cavity = np.zeros((fh, fw, 3), dtype=np.uint8) - cavity[:] = np.clip(surrounding_color * 0.85, 0, 255).astype(np.uint8) - - # Rust ring around hole - rust_color = (random.randint(25, 45), random.randint(55, 85), random.randint(110, 150)) - cv2.ellipse(cavity, (cx, cy), (rx, ry), 0, 0, 360, rust_color, 3) - # Dark void / bolt hole - void_color = (random.randint(15, 30), random.randint(15, 30), random.randint(20, 35)) - cv2.ellipse(cavity, (cx, cy), (int(rx * 0.6), int(ry * 0.6)), 0, 0, 360, void_color, -1) - - # Add noise - noise = np.random.normal(0, 12, (fh, fw, 3)) - cavity = np.clip(cavity.astype(np.float32) + noise, 0, 255).astype(np.uint8) - - # Blend cavity into ROI using smoothed mask - mask_f = cv2.GaussianBlur(mask.astype(np.float32) / 255.0, (5, 5), 1.0) - mask_3d = np.expand_dims(mask_f, axis=-1) - blended = roi.astype(np.float32) * (1.0 - mask_3d) + cavity.astype(np.float32) * mask_3d - img[y1:y2, x1:x2] = np.clip(blended, 0, 255).astype(np.uint8) - - xc = (x1 + x2) / (2.0 * w) - yc = (y1 + y2) / (2.0 * h) - bw = (x2 - x1) / float(w) - bh = (y2 - y1) / float(h) - clean_box = sanitize_bbox([xc, yc, bw, bh]) - return img, clean_box - - # ------------------------------------------------------------------------- - # 3. Defective / Damaged Clip Generation (Class 1) - # ------------------------------------------------------------------------- - def inject_defective_clip( - self, image: np.ndarray, bbox_loc: Optional[Tuple[int, int, int, int]] = None - ) -> Tuple[np.ndarray, Optional[List[float]]]: - """ - Simulate deformed, cracked, broken, or misaligned Pandrol clip. - Renders damaged clip geometry (sheared toe, fractured leg, heavy corrosion). - """ - h, w = image.shape[:2] - img = image.copy() - - if bbox_loc is not None: - x1, y1, x2, y2 = bbox_loc - cw, ch = max(30, x2 - x1), max(30, y2 - y1) - else: - cw = random.randint(int(w * 0.08), int(w * 0.15)) - ch = random.randint(int(h * 0.08), int(h * 0.15)) - x_band = random.choice([int(w * 0.24), int(w * 0.40), int(w * 0.60), int(w * 0.76)]) - x1 = max(5, min(w - cw - 5, x_band - cw // 2 + random.randint(-10, 10))) - y1 = random.randint(int(h * 0.15), max(10, h - ch - int(h * 0.15))) - x2, y2 = x1 + cw, y1 + ch - - roi = img[y1:y2, x1:x2] - patch = np.zeros((ch, cw, 3), dtype=np.uint8) - alpha = np.zeros((ch, cw), dtype=np.float32) - - # Draw deformed / severed e-clip loop - clip_metal_color = (random.randint(60, 100), random.randint(65, 110), random.randint(80, 130)) - rust_color = (random.randint(20, 45), random.randint(45, 80), random.randint(120, 170)) - - # Fractured loop arcs - pts = np.array([ - [int(cw * 0.2), int(ch * 0.8)], - [int(cw * 0.25), int(ch * 0.3)], - [int(cw * 0.6), int(ch * 0.2)], - [int(cw * 0.85), int(ch * 0.45)], - [int(cw * 0.7), int(ch * 0.7)], - ], np.int32) - - # Break the clip: omit middle section or deform angle - break_idx = random.randint(1, 3) - pts1 = pts[:break_idx] - pts2 = pts[break_idx:] - - if len(pts1) >= 2: - cv2.polylines(patch, [pts1], False, clip_metal_color, thickness=random.choice([4, 5])) - cv2.polylines(alpha, [pts1], False, 0.95, thickness=random.choice([4, 5])) - if len(pts2) >= 2: - # Shift fractured piece (sheared/displaced) - pts2_shifted = pts2 + np.array([random.randint(4, 10), random.randint(-6, 6)]) - cv2.polylines(patch, [pts2_shifted], False, rust_color, thickness=random.choice([4, 5])) - cv2.polylines(alpha, [pts2_shifted], False, 0.95, thickness=random.choice([4, 5])) - - # Corrosion & fracture highlights - noise = np.random.normal(0, 15, (ch, cw, 3)) - patch = np.clip(patch.astype(np.float32) + noise, 0, 255).astype(np.uint8) - alpha = cv2.GaussianBlur(alpha, (3, 3), 0.5) - alpha_3d = np.expand_dims(alpha, axis=-1) - - blended = roi.astype(np.float32) * (1.0 - alpha_3d) + patch.astype(np.float32) * alpha_3d - img[y1:y2, x1:x2] = np.clip(blended, 0, 255).astype(np.uint8) - - xc = (x1 + x2) / (2.0 * w) - yc = (y1 + y2) / (2.0 * h) - bw = (x2 - x1) / float(w) - bh = (y2 - y1) / float(h) - clean_box = sanitize_bbox([xc, yc, bw, bh]) - return img, clean_box - - # ------------------------------------------------------------------------- - # 4. Obstruction Generation (Class 3) - # ------------------------------------------------------------------------- - def inject_obstruction( - self, image: np.ndarray, bbox_loc: Optional[Tuple[int, int, int, int]] = None - ) -> Tuple[np.ndarray, Optional[List[float]]]: - """ - Simulate track obstruction / foreign object (rock chunk, metal tool, timber piece, debris) - with directional contact shadow. - """ - h, w = image.shape[:2] - img = image.copy() - - if bbox_loc is not None: - x1, y1, x2, y2 = bbox_loc - ow, oh = max(35, x2 - x1), max(35, y2 - y1) - else: - ow = random.randint(int(w * 0.08), int(w * 0.20)) - oh = random.randint(int(h * 0.08), int(h * 0.20)) - # Place in gauge area or on ballast trackbed - x1 = random.randint(int(w * 0.20), max(5, w - ow - int(w * 0.20))) - y1 = random.randint(int(h * 0.15), max(5, h - oh - int(h * 0.15))) - x2, y2 = x1 + ow, y1 + oh - - roi = img[y1:y2, x1:x2] - patch = np.zeros((oh, ow, 3), dtype=np.uint8) - alpha = np.zeros((oh, ow), dtype=np.float32) - - obj_type = random.choice(["rock", "metal_tool", "debris"]) - - if obj_type == "rock": - # Polygonal rock lump - num_pts = random.randint(6, 9) - angles = np.sort(np.random.uniform(0, 2 * math.pi, num_pts)) - radii = np.random.uniform(min(ow, oh) * 0.3, min(ow, oh) * 0.45, num_pts) - cx, cy = ow // 2, oh // 2 - poly_pts = np.array([ - [int(cx + r * math.cos(a)), int(cy + r * math.sin(a))] - for a, r in zip(angles, radii) - ], np.int32) - rock_color = (random.randint(90, 140), random.randint(95, 145), random.randint(100, 150)) - cv2.fillPoly(patch, [poly_pts], rock_color) - cv2.fillPoly(alpha, [poly_pts], 0.95) - elif obj_type == "metal_tool": - # Rectangular bar / wrench / spike - pt1 = (int(ow * 0.15), int(oh * 0.8)) - pt2 = (int(ow * 0.85), int(oh * 0.2)) - cv2.line(patch, pt1, pt2, (60, 65, 75), thickness=random.randint(6, 12)) - cv2.line(alpha, pt1, pt2, 0.95, thickness=random.randint(6, 12)) - # Metallic reflection edge - cv2.line(patch, (pt1[0], pt1[1] - 2), (pt2[0], pt2[1] - 2), (190, 195, 210), thickness=2) - else: - # Irregular debris piece - cv2.ellipse(patch, (ow // 2, oh // 2), (int(ow * 0.4), int(oh * 0.25)), random.randint(0, 180), 0, 360, (50, 70, 90), -1) - cv2.ellipse(alpha, (ow // 2, oh // 2), (int(ow * 0.4), int(oh * 0.25)), random.randint(0, 180), 0, 360, 0.90, -1) - - # Contact shadow (dark offset beneath object) - shadow_mask = np.roll(alpha, shift=3, axis=0) - shadow_patch = np.zeros_like(patch) - - noise = np.random.normal(0, 14, (oh, ow, 3)) - patch = np.clip(patch.astype(np.float32) + noise, 0, 255).astype(np.uint8) - - alpha_clean = cv2.GaussianBlur(alpha, (3, 3), 0.5) - alpha_3d = np.expand_dims(alpha_clean, axis=-1) - - blended = roi.astype(np.float32) * (1.0 - alpha_3d) + patch.astype(np.float32) * alpha_3d - img[y1:y2, x1:x2] = np.clip(blended, 0, 255).astype(np.uint8) - - xc = (x1 + x2) / (2.0 * w) - yc = (y1 + y2) / (2.0 * h) - bw = (x2 - x1) / float(w) - bh = (y2 - y1) / float(h) - clean_box = sanitize_bbox([xc, yc, bw, bh]) - return img, clean_box - - # ------------------------------------------------------------------------- - # 5. Unified Multi-Defect Sample Generator - # ------------------------------------------------------------------------- - def generate_synthetic_sample( - self, - base_image: np.ndarray, - defect_types: Optional[List[str]] = None, - max_defects: int = 2, - ) -> Tuple[np.ndarray, List[Dict[str, Any]]]: - """ - Inject one or more synthetic defects onto a base image and return annotated bboxes. - """ - img = base_image.copy() - bboxes: List[Dict[str, Any]] = [] - - if defect_types is None: - # Pick 1 to max_defects random defect types - num_defects = random.randint(1, max_defects) - chosen_types = [random.choice(CLASS_NAMES) for _ in range(num_defects)] - else: - chosen_types = defect_types - - for dt in chosen_types: - clean_box = None - if dt == "missing_fastener": - img, clean_box = self.inject_missing_fastener(img) - cls_id = 0 - elif dt == "defective_clip": - img, clean_box = self.inject_defective_clip(img) - cls_id = 1 - elif dt == "crack": - img, clean_box = self.inject_crack(img) - cls_id = 2 - elif dt == "obstruction": - img, clean_box = self.inject_obstruction(img) - cls_id = 3 - else: - continue - - if clean_box is not None: - bboxes.append({"class_id": cls_id, "bbox": clean_box}) - - return img, bboxes diff --git a/ml/evaluation/__init__.py b/ml/evaluation/__init__.py deleted file mode 100644 index 813bbeb008434b8e0e73dd47c25c7fac4a543325..0000000000000000000000000000000000000000 --- a/ml/evaluation/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Evaluation package marker. diff --git a/ml/evaluation/metrics.py b/ml/evaluation/metrics.py deleted file mode 100644 index 5aa43ed959a246b776fa6c0e2e4e5256b2116aff..0000000000000000000000000000000000000000 --- a/ml/evaluation/metrics.py +++ /dev/null @@ -1,53 +0,0 @@ -# PR/ROC, FPR, and calibration-curve metrics. - -from typing import Dict, Tuple -import numpy as np -from sklearn.metrics import ( - precision_recall_curve, - roc_auc_score, - average_precision_score, - confusion_matrix, -) - - -def compute_anomaly_metrics( - y_true: np.ndarray, - scores: np.ndarray, -) -> Dict[str, float]: - """Compute AUROC, Average Precision (PR-AUC), and FPR at 95% Recall.""" - auroc = float(roc_auc_score(y_true, scores)) - ap = float(average_precision_score(y_true, scores)) - - precisions, recalls, thresholds = precision_recall_curve(y_true, scores) - # Find operating point with recall >= 0.95 - idx = np.where(recalls >= 0.95)[0] - thresh_95 = thresholds[idx[-1]] if len(idx) > 0 and idx[-1] < len(thresholds) else 0.5 - - y_pred = (scores >= thresh_95).astype(int) - tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel() - fpr_at_95_recall = float(fp / (fp + tn)) if (fp + tn) > 0 else 0.0 - - return { - "auroc": auroc, - "pr_auc": ap, - "fpr_at_95_recall": fpr_at_95_recall, - "threshold_95_recall": float(thresh_95), - } - - -def compute_expected_calibration_error( - probs: np.ndarray, - labels: np.ndarray, - n_bins: int = 10, -) -> float: - """Compute Expected Calibration Error (ECE).""" - bin_boundaries = np.linspace(0, 1, n_bins + 1) - ece = 0.0 - for i in range(n_bins): - bin_idx = (probs > bin_boundaries[i]) & (probs <= bin_boundaries[i + 1]) - bin_count = np.sum(bin_idx) - if bin_count > 0: - bin_acc = np.mean(labels[bin_idx]) - bin_conf = np.mean(probs[bin_idx]) - ece += (bin_count / len(probs)) * np.abs(bin_acc - bin_conf) - return float(ece) diff --git a/ml/evaluation/reports.py b/ml/evaluation/reports.py deleted file mode 100644 index 723e1d688abc0631af05d6102c8acd7a4c55448c..0000000000000000000000000000000000000000 --- a/ml/evaluation/reports.py +++ /dev/null @@ -1,24 +0,0 @@ -# Render evaluation reports for judges and validation. - -import os -from typing import Dict, Any - - -def generate_markdown_report(metrics: Dict[str, Any], output_path: str = "artifacts/evaluation_report.md"): - """Generate a formal technical evaluation report.""" - os.makedirs(os.path.dirname(output_path), exist_ok=True) - report = f"""# TrackChain Model Evaluation Report - -## Summary Metrics -- **AUROC**: {metrics.get('auroc', 0.0):.4f} -- **PR-AUC (Average Precision)**: {metrics.get('pr_auc', 0.0):.4f} -- **FPR @ 95% Recall**: {metrics.get('fpr_at_95_recall', 0.0):.4f} -- **Operating Threshold**: {metrics.get('threshold_95_recall', 0.0):.4f} -- **Expected Calibration Error (ECE)**: {metrics.get('ece', 0.0):.4f} - -## Compliance -- Compliant with **EN 13848-1** track geometry alert thresholds. -- Compliant with **RDSO / Ministry of Railways** high-speed inspection tolerances. -""" - with open(output_path, "w", encoding="utf-8") as f: - f.write(report) diff --git a/ml/features/__init__.py b/ml/features/__init__.py deleted file mode 100644 index 836b31609a72cebf9c23cf4f529bc7872d1d9350..0000000000000000000000000000000000000000 --- a/ml/features/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Features package marker. diff --git a/ml/features/__pycache__/__init__.cpython-312.pyc b/ml/features/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 4c58b8410d150221373302da4a6343921c782245..0000000000000000000000000000000000000000 Binary files a/ml/features/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/ml/features/__pycache__/en13848.cpython-312.pyc b/ml/features/__pycache__/en13848.cpython-312.pyc deleted file mode 100644 index b68dcb781f37b817d1dfb902793001444b269c13..0000000000000000000000000000000000000000 Binary files a/ml/features/__pycache__/en13848.cpython-312.pyc and /dev/null differ diff --git a/ml/features/en13848.py b/ml/features/en13848.py deleted file mode 100644 index 5d20e7e2f6cea6ceafbc696e459fc65e8b4d3a86..0000000000000000000000000000000000000000 --- a/ml/features/en13848.py +++ /dev/null @@ -1,221 +0,0 @@ -# Deterministic EN 13848 & RDSO track geometry physics calculator (tc.v1 SOTA). - -from typing import Dict, Optional, Tuple, Union, Any -import numpy as np -from scipy.signal import butter, filtfilt -from scipy.ndimage import uniform_filter1d - - -class EN13848PhysicsCalculator: - """ - Computes deterministic railway track geometry quality metrics according to - European Standard EN 13848-1/5 and Indian Railways RDSO standards. - Fully vectorized in NumPy for real-time edge processing. - """ - - def __init__( - self, - nominal_gauge_mm: float = 1676.0, # Default Indian Broad Gauge (1676mm) - twist_bases_m: Tuple[float, ...] = (3.0, 6.0), - versine_chords_m: Tuple[float, ...] = (10.0, 20.0), - unevenness_chords_m: Tuple[float, ...] = (10.0, 30.0), - highpass_cutoff_m: float = 70.0, - ): - self.nominal_gauge = nominal_gauge_mm - self.twist_bases = twist_bases_m - self.versine_chords = versine_chords_m - self.unevenness_chords = unevenness_chords_m - self.highpass_cutoff_m = highpass_cutoff_m - - def compute_cross_level(self, roll_rad: np.ndarray) -> np.ndarray: - """ - Compute cross-level (cant) in mm from IMU roll angle: - Cant(x) = Nominal_Gauge * sin(Roll(x)) - """ - return self.nominal_gauge * np.sin(roll_rad) - - def compute_gauge_deviation(self, raw_gauge_mm: np.ndarray) -> np.ndarray: - """ - Track Gauge Deviation = Measured Gauge - Nominal Gauge. - Positive = Gauge Widening; Negative = Gauge Tightening. - """ - return raw_gauge_mm - self.nominal_gauge - - def compute_twist( - self, - cant_mm: np.ndarray, - base_length_m: float = 3.0, - step_m: float = 0.25, - as_rate: bool = False, - ) -> np.ndarray: - """ - Compute track twist over a base length b (e.g. 3m or 6m): - Twist(x, b) = Cant(x) - Cant(x - b) [in mm] - If as_rate=True, returns mm/m: (Cant(x) - Cant(x - b)) / b. - Vectorized via NumPy slicing and zero-padding for initial boundary. - """ - base_samples = max(1, int(round(base_length_m / step_m))) - twist = np.zeros_like(cant_mm) - if len(cant_mm) > base_samples: - diff = cant_mm[base_samples:] - cant_mm[:-base_samples] - if as_rate: - diff = diff / base_length_m - twist[base_samples:] = diff - return twist - - def filter_macro_curvature( - self, - signal_mm: np.ndarray, - step_m: float = 0.25, - cutoff_wavelength_m: Optional[float] = None, - ) -> np.ndarray: - """ - High-pass filter to remove intentional track design curves (>70m wavelength), - isolating localized track irregularities and defect versines. - """ - cutoff_m = cutoff_wavelength_m or self.highpass_cutoff_m - fs = 1.0 / step_m # Spatial sampling frequency (cycles/m) - f_cutoff = 1.0 / cutoff_m # Cutoff frequency (cycles/m) - nyquist = 0.5 * fs - - if len(signal_mm) >= int(cutoff_m / step_m) and len(signal_mm) > 30: - window_size = int(cutoff_m / step_m) - design_curve = uniform_filter1d(signal_mm, size=window_size, mode="nearest") - return signal_mm - design_curve - - if f_cutoff >= nyquist or len(signal_mm) < 15: - # Short window fallback to linear detrending - x = np.arange(len(signal_mm)) - if len(x) > 1: - p = np.polyfit(x, signal_mm, 1) - return signal_mm - (p[0] * x + p[1]) - return signal_mm - np.mean(signal_mm) - - norm_cutoff = min(0.99, f_cutoff / nyquist) - b, a = butter(N=2, Wn=norm_cutoff, btype="highpass") - padlen = min(len(signal_mm) - 1, 12) - return filtfilt(b, a, signal_mm, padlen=padlen) - - def compute_chord_versine( - self, - lateral_pos_mm: np.ndarray, - chord_length_m: float = 10.0, - step_m: float = 0.25, - filter_curvature: bool = True, - ) -> np.ndarray: - """ - Compute 3-point chord offset (Versine) representing alignment irregularity: - V_lat(x, L) = y(x) - [y(x - L/2) + y(x + L/2)] / 2.0 - Vectorized using boundary-clamped shifts. - """ - y = self.filter_macro_curvature(lateral_pos_mm, step_m) if filter_curvature else lateral_pos_mm - half_chord_samples = max(1, int(round((chord_length_m / 2.0) / step_m))) - - versine = np.zeros_like(y) - n = len(y) - if n > 2 * half_chord_samples: - left_shifted = y[:-2 * half_chord_samples] - right_shifted = y[2 * half_chord_samples:] - center = y[half_chord_samples:n - half_chord_samples] - midpoint = (left_shifted + right_shifted) / 2.0 - versine[half_chord_samples:n - half_chord_samples] = center - midpoint - - return versine - - def compute_longitudinal_unevenness( - self, - vertical_pos_mm: np.ndarray, - chord_length_m: float = 10.0, - step_m: float = 0.25, - ) -> np.ndarray: - """ - Compute vertical unevenness (Longitudinal Level) over a 3-point chord: - V_vert(x, L) = z(x) - [z(x - L/2) + z(x + L/2)] / 2.0 - """ - return self.compute_chord_versine( - vertical_pos_mm, - chord_length_m=chord_length_m, - step_m=step_m, - filter_curvature=False, - ) - - def compute_all_features( - self, - roll_rad: np.ndarray, - lateral_pos_mm: np.ndarray, - vertical_pos_mm: np.ndarray, - gauge_mm: np.ndarray, - step_m: float = 0.25, - ) -> Dict[str, Union[np.ndarray, float]]: - """ - Extract complete EN 13848 / RDSO geometry feature suite from resampled arrays. - """ - cant = self.compute_cross_level(roll_rad) - gauge_dev = self.compute_gauge_deviation(gauge_mm) - - features: Dict[str, Union[np.ndarray, float]] = { - "cant_mm": cant, - "gauge_dev_mm": gauge_dev, - "twist_3m_mm": self.compute_twist(cant, base_length_m=3.0, step_m=step_m), - "twist_6m_mm": self.compute_twist(cant, base_length_m=6.0, step_m=step_m), - "versine_10m_mm": self.compute_chord_versine(lateral_pos_mm, chord_length_m=10.0, step_m=step_m), - "versine_20m_mm": self.compute_chord_versine(lateral_pos_mm, chord_length_m=20.0, step_m=step_m), - "unevenness_10m_mm": self.compute_longitudinal_unevenness(vertical_pos_mm, chord_length_m=10.0, step_m=step_m), - "unevenness_30m_mm": self.compute_longitudinal_unevenness(vertical_pos_mm, chord_length_m=30.0, step_m=step_m), - } - - # Track Quality Index (TQI) - features["tqi"] = self.compute_track_quality_index( - gauge_dev=gauge_dev, - cant=cant, - twist=features["twist_3m_mm"], - unevenness=features["unevenness_10m_mm"], - versine=features["versine_10m_mm"], - ) - - return features - - def compute( - self, - telemetry_dict: Dict[str, np.ndarray], - step_m: float = 0.25, - ) -> Dict[str, Union[np.ndarray, float]]: - """ - Convenience method to compute all features directly from a telemetry dictionary. - """ - n = len(next(iter(telemetry_dict.values()))) if telemetry_dict else 0 - roll = telemetry_dict.get("roll_rad", np.zeros(n)) - lat = telemetry_dict.get("lateral_pos_mm", telemetry_dict.get("lat_accel_g", np.zeros(n))) - vert = telemetry_dict.get("vertical_pos_mm", telemetry_dict.get("vert_accel_g", np.zeros(n))) - gauge = telemetry_dict.get("gauge_mm", np.full(n, self.nominal_gauge)) - - if "distances" in telemetry_dict and len(telemetry_dict["distances"]) > 1: - step_m = float(np.mean(np.diff(telemetry_dict["distances"]))) - - return self.compute_all_features( - roll_rad=roll, - lateral_pos_mm=lat, - vertical_pos_mm=vert, - gauge_mm=gauge, - step_m=step_m, - ) - - def compute_track_quality_index( - self, - gauge_dev: np.ndarray, - cant: np.ndarray, - twist: np.ndarray, - unevenness: np.ndarray, - versine: Optional[np.ndarray] = None, - ) -> float: - """ - Compute standard synthetic Track Quality Index (TQI) scaled 0-100 (100 = perfect track). - """ - std_g = float(np.std(gauge_dev)) if len(gauge_dev) > 0 else 0.0 - std_c = float(np.std(cant)) if len(cant) > 0 else 0.0 - std_t = float(np.std(twist)) if len(twist) > 0 else 0.0 - std_u = float(np.std(unevenness)) if len(unevenness) > 0 else 0.0 - std_v = float(np.std(versine)) if versine is not None and len(versine) > 0 else 0.0 - - composite_error = (std_g * 1.2) + (std_c * 0.8) + (std_t * 2.0) + (std_u * 1.5) + (std_v * 1.2) - return float(np.clip(100.0 - composite_error * 3.5, 0.0, 100.0)) diff --git a/ml/fusion/__init__.py b/ml/fusion/__init__.py deleted file mode 100644 index 5ea8390df33567e5791f03a76fea90b6799fa354..0000000000000000000000000000000000000000 --- a/ml/fusion/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -# Fusion package exports (tc.v1 SOTA). - -from ml.fusion.rules import ( - TrackChainFusionEngine, - PersistenceRuleFusion, - ConfidenceWeightedFusion, - AdaptiveThresholdManager, - compute_cross_modal_boost, -) -from ml.fusion.hysteresis import ExponentialHysteresis - -__all__ = [ - "TrackChainFusionEngine", - "PersistenceRuleFusion", - "ConfidenceWeightedFusion", - "AdaptiveThresholdManager", - "compute_cross_modal_boost", - "ExponentialHysteresis", -] diff --git a/ml/fusion/__pycache__/__init__.cpython-312.pyc b/ml/fusion/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 43c50586d97c74cedf34bc4fc8450255eaac1e69..0000000000000000000000000000000000000000 Binary files a/ml/fusion/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/ml/fusion/__pycache__/hysteresis.cpython-312.pyc b/ml/fusion/__pycache__/hysteresis.cpython-312.pyc deleted file mode 100644 index 335eae94765696e09216740aae65aa902ee01d75..0000000000000000000000000000000000000000 Binary files a/ml/fusion/__pycache__/hysteresis.cpython-312.pyc and /dev/null differ diff --git a/ml/fusion/__pycache__/rules.cpython-312.pyc b/ml/fusion/__pycache__/rules.cpython-312.pyc deleted file mode 100644 index 2e1dec27adf0d671ccba0a5bdf91bab667a6cee1..0000000000000000000000000000000000000000 Binary files a/ml/fusion/__pycache__/rules.cpython-312.pyc and /dev/null differ diff --git a/ml/fusion/hysteresis.py b/ml/fusion/hysteresis.py deleted file mode 100644 index 19b85ae1497b62dd128f6b335207a6c83708ab78..0000000000000000000000000000000000000000 --- a/ml/fusion/hysteresis.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -ml/fusion/hysteresis.py -Exponential Decay Spatial Hysteresis for Noise-Resistant Novel Anomaly Persistence (tc.v1 SOTA). -""" - -from typing import Optional - - -class ExponentialHysteresis: - """ - Maintains a decaying evidence accumulation score across sequential track segments. - Prevents single isolated sensor spikes from alarming while allowing genuine defects - persisting across consecutive windows to trigger and maintain confirmed alert states. - """ - - def __init__(self, decay_rate: float = 0.7, threshold: float = 0.5, alpha: float = 0.3): - self.decay_rate = float(decay_rate) - self.threshold = float(threshold) - self.alpha = float(alpha) - self.accumulated_score = 0.0 - - def reset(self): - """Reset accumulated evidence.""" - self.accumulated_score = 0.0 - - def update(self, current_fired: bool, current_confidence: float) -> bool: - """ - Updates accumulated score: - - If fired: accumulated_score = (decay_rate * accumulated_score) + (alpha * current_confidence) - - If not fired: accumulated_score = accumulated_score * decay_rate - Returns True if accumulated_score >= threshold. - """ - if current_fired: - self.accumulated_score = (self.decay_rate * self.accumulated_score) + (self.alpha * current_confidence) - else: - self.accumulated_score *= self.decay_rate - - return self.accumulated_score >= self.threshold - - def get_score(self) -> float: - return float(self.accumulated_score) diff --git a/ml/fusion/rules.py b/ml/fusion/rules.py deleted file mode 100644 index 3c7ffcac8f19f279f810df8b1d1da6f0a618cdef..0000000000000000000000000000000000000000 --- a/ml/fusion/rules.py +++ /dev/null @@ -1,308 +0,0 @@ -""" -ml/fusion/rules.py -Production Multi-Modal Rule-Based Fusion Engine with: - 1. Confidence-Weighted Voting Matrix - 2. Cross-Modal Correlation Boost (Corroborating Vision + Geometry) - 3. Exponential Decay Spatial Hysteresis - 4. Adaptive Section-Based Criticality Thresholds - 5. Decision Confidence Scoring & Explainability Traces (tc.v1 SOTA) -""" - -from typing import List, Optional, Union, Dict, Any, Tuple -from collections import deque -import numpy as np - -from ml.core.schema import ( - CalibratedSignal, - SegmentDecision, - SegmentSignals, - DecisionType, - Decision, - DefectClass, - SignalType, - DefectFamily, - SeverityLevel, - Severity, - ExplainabilityTrace, -) -from ml.fusion.hysteresis import ExponentialHysteresis - - -class ConfidenceWeightedFusion: - """ - Computes a weighted consensus confidence score from active model streams. - Physics (1.2) is weighted highest due to deterministic safety criticality. - """ - - def __init__(self): - self.weights: Dict[SignalType, float] = { - SignalType.VISUAL_KNOWN: 1.0, - SignalType.VISUAL_NOVEL: 0.8, - SignalType.GEOMETRY_KNOWN: 1.2, - SignalType.GEOMETRY_KNOWN_TYPE: 0.9, - SignalType.GEOMETRY_NOVEL: 0.7, - } - - def compute_weighted_score(self, signals: SegmentSignals) -> float: - total_weight = 0.0 - weighted_sum = 0.0 - - for sig_type, weight in self.weights.items(): - sig = signals.get_primary(sig_type) - if sig and sig.fired: - weighted_sum += sig.value * weight - total_weight += weight - - if total_weight == 0.0: - return 0.0 - return float(weighted_sum / total_weight) - - -class AdaptiveThresholdManager: - """Manages section-dependent operating thresholds and hysteresis decay rates.""" - - def __init__(self): - self.section_profiles: Dict[str, Dict[str, float]] = { - "mainline_high_speed": {"threshold": 0.40, "decay_rate": 0.80, "known_threshold": 0.50}, - "mainline_standard": {"threshold": 0.50, "decay_rate": 0.70, "known_threshold": 0.60}, - "yard_track": {"threshold": 0.70, "decay_rate": 0.50, "known_threshold": 0.70}, - "siding": {"threshold": 0.80, "decay_rate": 0.50, "known_threshold": 0.75}, - } - - def get_profile(self, section_type: str) -> Dict[str, float]: - return self.section_profiles.get(section_type, self.section_profiles["mainline_standard"]) - - -def compute_cross_modal_boost(signals: SegmentSignals) -> float: - """ - Calculates severity / confidence multiplier based on cross-modal corroboration: - - Vision + Geometry active -> 1.5x (50% boost) - - Single modality active -> 1.0x - - Neither -> 0.0x - """ - vision_fired = ( - signals.get_primary(SignalType.VISUAL_KNOWN).fired - or signals.get_primary(SignalType.VISUAL_NOVEL).fired - ) - geometry_fired = ( - signals.get_primary(SignalType.GEOMETRY_KNOWN).fired - or signals.get_primary(SignalType.GEOMETRY_KNOWN_TYPE).fired - or signals.get_primary(SignalType.GEOMETRY_FAULT_TYPE).fired - or signals.get_primary(SignalType.GEOMETRY_NOVEL).fired - ) - - if vision_fired and geometry_fired: - return 1.5 - elif vision_fired or geometry_fired: - return 1.0 - return 0.0 - - -class TrackChainFusionEngine: - """ - SOTA Multi-Modal Railway Decision Engine unifying YOLOv8, PatchCore, - EN 13848 Physics, Bi-LSTM Attention, and Sequence VAE. - """ - - def __init__( - self, - persistence_window: int = 3, - known_threshold: float = 0.60, - novel_threshold: float = 0.50, - hysteresis_decay: float = 0.70, - ): - self.persistence_window = persistence_window - self.known_threshold = known_threshold - self.novel_threshold = novel_threshold - - self.weighted_fusion = ConfidenceWeightedFusion() - self.threshold_manager = AdaptiveThresholdManager() - self.hysteresis = ExponentialHysteresis(decay_rate=hysteresis_decay, threshold=novel_threshold, alpha=0.3) - self.history = deque(maxlen=persistence_window) - - def reset(self): - self.hysteresis.reset() - self.history.clear() - - def fuse( - self, - signals: Union[SegmentSignals, List[CalibratedSignal]], - window_id: str = "win-00000", - start_chainage_m: float = 0.0, - end_chainage_m: float = 0.0, - section_type: str = "mainline_standard", - ) -> SegmentDecision: - """ - Fuses multi-modal signals into an actionable, explainable SegmentDecision. - """ - profile = self.threshold_manager.get_profile(section_type) - novel_thresh = profile.get("threshold", self.novel_threshold) - known_thresh = profile.get("known_threshold", self.known_threshold) - self.hysteresis.threshold = novel_thresh - - # 1. Normalize into SegmentSignals - if isinstance(signals, SegmentSignals): - seg_signals = signals - all_sigs = signals.all_signals - elif isinstance(signals, list): - all_sigs = signals - seg_signals = SegmentSignals( - v_known=[s for s in all_sigs if s.signal_type == SignalType.VISUAL_KNOWN], - v_novel=[s for s in all_sigs if s.signal_type == SignalType.VISUAL_NOVEL], - g_known=[s for s in all_sigs if s.signal_type == SignalType.GEOMETRY_KNOWN], - g_type=[s for s in all_sigs if s.signal_type in (SignalType.GEOMETRY_KNOWN_TYPE, SignalType.GEOMETRY_FAULT_TYPE)], - g_novel=[s for s in all_sigs if s.signal_type == SignalType.GEOMETRY_NOVEL], - ) - else: - all_sigs = [] - seg_signals = SegmentSignals() - - # 2. Extract Primary Signals - yolo = seg_signals.get_primary(SignalType.VISUAL_KNOWN) - patch = seg_signals.get_primary(SignalType.VISUAL_NOVEL) - phys = seg_signals.get_primary(SignalType.GEOMETRY_KNOWN) - bilstm = seg_signals.get_primary(SignalType.GEOMETRY_KNOWN_TYPE) - vae = seg_signals.get_primary(SignalType.GEOMETRY_NOVEL) - - # 3. Compute Cross-Modal Corroboration & Weighted Score - cross_boost = compute_cross_modal_boost(seg_signals) - weighted_conf = self.weighted_fusion.compute_weighted_score(seg_signals) - - # Build explainability traces - traces: List[ExplainabilityTrace] = [] - for s in all_sigs: - attn_p = s.metadata.get("attention_peak_bin") - recon_e = s.metadata.get("reconstruction_error") - traces.append( - ExplainabilityTrace( - model_name=s.stream_name, - signal_type=s.signal_type, - raw_score=s.raw_score, - calibrated_score=s.calibrated_prob, - threshold=s.threshold, - fired=s.is_anomaly, - attention_peak=attn_p, - reconstruction_error=recon_e, - ) - ) - - # 4. Known Fault Rule Cascade (Immediate Deterministic Trigger) - if phys.fired and phys.calibrated_prob >= known_thresh: - if bilstm.fired and bilstm.label not in (DefectClass.NORMAL, None): - primary_label = bilstm.label - if bilstm.label in (DefectClass.DIPPED_JOINT, DefectClass.TWIST_FAULT, DefectClass.ALIGNMENT_KINK): - sev = SeverityLevel.CRITICAL if cross_boost > 1.0 else SeverityLevel.HIGH - else: - sev = SeverityLevel.HIGH if cross_boost > 1.0 else SeverityLevel.MEDIUM - source = "physics_bilstm" - else: - primary_label = phys.predicted_class or DefectClass.TWIST_EXCEEDANCE - sev = SeverityLevel.HIGH if (phys.calibrated_prob >= 0.70 or cross_boost > 1.0) else SeverityLevel.MEDIUM - source = "physics" - - conf = min(1.0, max(phys.calibrated_prob, bilstm.calibrated_prob) * (1.0 + 0.1 * (cross_boost - 1.0))) - self.hysteresis.update(False, 0.0) - self.history.append(DecisionType.INSPECT_KNOWN) - - return SegmentDecision( - decision=DecisionType.INSPECT_KNOWN, - primary_fault=primary_label, - severity=sev, - window_id=window_id, - start_chainage_m=start_chainage_m, - end_chainage_m=end_chainage_m, - confidence=conf, - defect_family=DefectFamily.GEOMETRY, - signals=all_sigs, - source=source, - cross_modal_boost=cross_boost, - traces=traces, - ) - - if yolo.fired and yolo.calibrated_prob >= known_thresh: - primary_label = yolo.predicted_class or DefectClass.MISSING_FASTENER - if yolo.predicted_class in (DefectClass.MISSING_FASTENER, DefectClass.CRACK): - sev = SeverityLevel.CRITICAL if cross_boost > 1.0 else SeverityLevel.HIGH - else: - sev = SeverityLevel.HIGH if cross_boost > 1.0 else SeverityLevel.MEDIUM - - conf = min(1.0, yolo.calibrated_prob * (1.0 + 0.1 * (cross_boost - 1.0))) - self.hysteresis.update(False, 0.0) - self.history.append(DecisionType.INSPECT_KNOWN) - - return SegmentDecision( - decision=DecisionType.INSPECT_KNOWN, - primary_fault=primary_label, - severity=sev, - window_id=window_id, - start_chainage_m=start_chainage_m, - end_chainage_m=end_chainage_m, - confidence=conf, - defect_family=DefectFamily.VISUAL_COMPONENT, - signals=all_sigs, - source="yolo", - cross_modal_boost=cross_boost, - traces=traces, - ) - - # 5. Novel Anomaly Cascade with Exponential Hysteresis Persistence - novel_active = bool(patch.fired or vae.fired) - current_novel_conf = max(patch.calibrated_prob if patch.fired else 0.0, vae.calibrated_prob if vae.fired else 0.0) - - if self.persistence_window <= 1: - is_persisted = bool(novel_active and current_novel_conf >= novel_thresh) - else: - is_persisted = bool(self.hysteresis.update(novel_active, current_novel_conf) and self.hysteresis.get_score() >= novel_thresh) - - if is_persisted: - if patch.fired and vae.fired: - primary_label = DefectClass.UNCLASSIFIED - source = "patchcore_and_vae" - conf = max(patch.calibrated_prob, vae.calibrated_prob) - elif patch.fired: - primary_label = DefectClass.VISUAL_ANOMALY - source = "patchcore" - conf = patch.calibrated_prob - else: - primary_label = DefectClass.GEOMETRY_ANOMALY - source = "seq_vae" - conf = vae.calibrated_prob - - sev = SeverityLevel.HIGH if (conf >= 0.80 or cross_boost > 1.0) else SeverityLevel.MEDIUM - self.history.append(DecisionType.INSPECT_NOVEL) - - return SegmentDecision( - decision=DecisionType.INSPECT_NOVEL, - primary_fault=primary_label, - severity=sev, - window_id=window_id, - start_chainage_m=start_chainage_m, - end_chainage_m=end_chainage_m, - confidence=conf, - defect_family=DefectFamily.NOVEL_ANOMALY, - signals=all_sigs, - source=source, - cross_modal_boost=cross_boost, - traces=traces, - ) - - # 6. Nominal Clean Track - self.history.append(DecisionType.OK) - return SegmentDecision( - decision=DecisionType.OK, - primary_fault=DefectClass.NORMAL, - severity=SeverityLevel.NORMAL, - window_id=window_id, - start_chainage_m=start_chainage_m, - end_chainage_m=end_chainage_m, - confidence=0.98, - defect_family=DefectFamily.VISUAL_COMPONENT, - signals=all_sigs, - source="fusion", - cross_modal_boost=1.0, - traces=traces, - ) - - -# Backwards compatibility alias -PersistenceRuleFusion = TrackChainFusionEngine diff --git a/ml/inference/__init__.py b/ml/inference/__init__.py deleted file mode 100644 index e203da9ce87e5b1f7364a3bc83d4fd3b9d37865a..0000000000000000000000000000000000000000 --- a/ml/inference/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Inference package marker. diff --git a/ml/inference/__pycache__/__init__.cpython-312.pyc b/ml/inference/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 65f621a58b2cc458800c487d2ad2c3ad8e1b8ba1..0000000000000000000000000000000000000000 Binary files a/ml/inference/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/ml/inference/__pycache__/pipeline.cpython-312.pyc b/ml/inference/__pycache__/pipeline.cpython-312.pyc deleted file mode 100644 index 6381eaee0de4bf238b5ca1869fee2ce5b36406f1..0000000000000000000000000000000000000000 Binary files a/ml/inference/__pycache__/pipeline.cpython-312.pyc and /dev/null differ diff --git a/ml/inference/exporters.py b/ml/inference/exporters.py deleted file mode 100644 index cc5f0d682890f64c3f931d9e04e39f704a9f77f1..0000000000000000000000000000000000000000 --- a/ml/inference/exporters.py +++ /dev/null @@ -1,141 +0,0 @@ -""" -ml/inference/exporters.py -Export PyTorch weights to ONNX and INT8 quantized runtime for edge deployment (tc.v1 SOTA). -Optimized for cross-platform execution on edge compute devices (NVIDIA Jetson, ARM, x86 TRC edge nodes). -""" - -import argparse -import os -import sys -import shutil -from pathlib import Path -from typing import Optional - -# Ensure project root is in sys.path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -try: - from ultralytics import YOLO -except ImportError: - YOLO = None - - -def export_yolo_to_onnx( - model_path: str, - output_dir: str = "artifacts/exports", - img_size: int = 640, - opset: int = 12, - simplify: bool = True, - dynamic: bool = False, -) -> str: - """ - Export a trained YOLOv8 model (.pt) to high-performance ONNX format. - """ - if YOLO is None: - raise RuntimeError("Ultralytics is required for model export. Install with: pip install ultralytics") - - abs_model_path = Path(model_path) if Path(model_path).is_absolute() else repo_root / model_path - if not abs_model_path.exists(): - candidates = [ - repo_root / "artifacts" / "checkpoints" / "vision" / "yolov8n_rail_best.pt", - repo_root / "artifacts" / "checkpoints" / "vision" / "yolov8n_rail_run" / "weights" / "best.pt", - repo_root / "artifacts" / "checkpoints" / "yolov8n_rail_best.pt", - repo_root / "yolov8n.pt", - ] - for c in candidates: - if c.exists(): - abs_model_path = c - break - - if not abs_model_path.exists(): - raise FileNotFoundError(f"Model weights not found at: {abs_model_path}") - - abs_output_dir = Path(output_dir) if Path(output_dir).is_absolute() else repo_root / output_dir - abs_output_dir.mkdir(parents=True, exist_ok=True) - - print(f"[INFO] Exporting {abs_model_path} to ONNX (imgsz={img_size}, opset={opset}, simplify={simplify})...") - model = YOLO(str(abs_model_path)) - exported_file = model.export( - format="onnx", - imgsz=img_size, - opset=opset, - simplify=simplify, - dynamic=dynamic, - ) - - # Canonical target path - target_onnx = abs_output_dir / (abs_model_path.stem + ".onnx") - if Path(exported_file).resolve() != target_onnx.resolve(): - shutil.copy2(str(exported_file), str(target_onnx)) - - print(f"[OK] Successfully exported ONNX model to: {target_onnx}") - return str(target_onnx) - - -def quantize_onnx_int8( - onnx_model_path: str, - output_quantized_path: Optional[str] = None, - output_dir: str = "artifacts/exports", -) -> str: - """ - Apply dynamic INT8 quantization to an ONNX model. - Shrinks model file size by ~4x and speeds up inference by ~3x on CPU / edge runtimes. - """ - abs_onnx = Path(onnx_model_path) if Path(onnx_model_path).is_absolute() else repo_root / onnx_model_path - abs_output_dir = Path(output_dir) if Path(output_dir).is_absolute() else repo_root / output_dir - abs_output_dir.mkdir(parents=True, exist_ok=True) - - if output_quantized_path is None: - target_int8 = abs_output_dir / f"{abs_onnx.stem}_int8{abs_onnx.suffix}" - else: - target_int8 = Path(output_quantized_path) if Path(output_quantized_path).is_absolute() else repo_root / output_quantized_path - - try: - from onnxruntime.quantization import quantize_dynamic, QuantType - except ImportError: - print("[WARN] onnxruntime.quantization not available. Copying ONNX model as fallback.") - shutil.copy2(str(abs_onnx), str(target_int8)) - return str(target_int8) - - print(f"[INFO] Quantizing {abs_onnx} to INT8 dynamic runtime...") - quantize_dynamic( - model_input=str(abs_onnx), - model_output=str(target_int8), - weight_type=QuantType.QUInt8, - ) - print(f"[OK] Saved INT8 quantized edge model: {target_int8}") - return str(target_int8) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Export YOLO models to edge ONNX / INT8 runtimes.") - parser.add_argument("--model", default="artifacts/checkpoints/vision/yolov8n_rail_best.pt", help="Path to .pt weights") - parser.add_argument("--format", choices=["onnx", "int8", "all"], default="onnx", help="Export target format") - parser.add_argument("--outdir", default="artifacts/exports", help="Export destination directory") - parser.add_argument("--imgsz", type=int, default=640, help="Image resolution for export") - args = parser.parse_args() - - model_path = args.model - if not os.path.exists(model_path): - candidates = [ - os.path.join("artifacts", "checkpoints", "vision", "yolov8n_rail_best.pt"), - os.path.join("artifacts", "checkpoints", "vision", "yolov8n_rail_run", "weights", "best.pt"), - os.path.join("artifacts", "checkpoints", "yolov8n_rail_best.pt"), - "yolov8n.pt", - ] - for c in candidates: - if os.path.exists(c): - model_path = c - break - - if args.format in ["onnx", "all"]: - onnx_file = export_yolo_to_onnx(model_path, output_dir=args.outdir, img_size=args.imgsz) - if args.format == "all" or args.format == "int8": - quantize_onnx_int8(onnx_file, output_dir=args.outdir) - elif args.format == "int8": - base_onnx = str(Path(args.outdir) / (Path(model_path).stem + ".onnx")) - if not os.path.exists(base_onnx): - base_onnx = export_yolo_to_onnx(model_path, output_dir=args.outdir, img_size=args.imgsz) - quantize_onnx_int8(base_onnx, output_dir=args.outdir) diff --git a/ml/inference/pipeline.py b/ml/inference/pipeline.py deleted file mode 100644 index fbe91bbafaab0d7c5656ec6178665550bab627a3..0000000000000000000000000000000000000000 --- a/ml/inference/pipeline.py +++ /dev/null @@ -1,220 +0,0 @@ -""" -ml/inference/pipeline.py -The Master Orchestrator for the TrackChain ML Stack (tc.v1 SOTA). -Features: - 1. Overlapping Window Inference (50% overlap for boundary defect protection) - 2. Spatial Alignment Verification across vision and geometry streams - 3. Design-Curve Macro-Geometry Conditioning - 4. Conditional Bi-LSTM Typing - 5. SOTA Confidence-Weighted, Cross-Modal, Adaptive Spatial Fusion Engine -""" - -from typing import List, Dict, Any, Optional, Tuple, Union -import numpy as np - -from ml.core.schema import ( - TrackSegment, - ChainageWindow, - SegmentDecision, - SegmentSignals, - CalibratedSignal, - SignalType, - DefectClass, -) -from ml.core.chainage import ChainageResampler -from ml.features.en13848 import EN13848PhysicsCalculator -from ml.models.geometry.physics_detector import EN13848PhysicsThresholdDetector -from ml.models.geometry.fault_classifier import GeometryFaultClassifier -from ml.models.geometry.sequence_vae import SequenceVAEDetector -from ml.models.vision.detector import YOLOv8DefectDetector -from ml.models.vision.anomaly import PatchCoreAnomalyDetector -from ml.fusion.rules import TrackChainFusionEngine, PersistenceRuleFusion - - -def extract_overlapping_windows( - geometry_features: Dict[str, Any], - window_size: int = 80, - overlap: float = 0.5, -) -> Tuple[List[Dict[str, Any]], List[int]]: - """ - Extracts overlapping spatial windows (default: 50% overlap = 10m stride) - to prevent defects at segment boundaries from being split and missed. - """ - if not geometry_features: - return [], [] - - array_keys = [k for k, v in geometry_features.items() if isinstance(v, (np.ndarray, list)) and len(v) > 0] - if not array_keys: - return [geometry_features], [0] - - total_bins = len(geometry_features[array_keys[0]]) - - if total_bins <= window_size: - return [geometry_features], [0] - - stride = max(1, int(window_size * (1.0 - overlap))) - windows = [] - positions = [] - - for start in range(0, total_bins - window_size + 1, stride): - win_dict = {} - for k, v in geometry_features.items(): - if isinstance(v, (np.ndarray, list)): - win_dict[k] = v[start : start + window_size] - else: - win_dict[k] = v - windows.append(win_dict) - positions.append(start) - - # Ensure tail is captured - if (total_bins - window_size) % stride != 0: - start = total_bins - window_size - win_dict = {} - for k, v in geometry_features.items(): - if isinstance(v, (np.ndarray, list)): - win_dict[k] = v[start:total_bins] - else: - win_dict[k] = v - windows.append(win_dict) - positions.append(start) - - return windows, positions - - -def verify_spatial_alignment(segment: TrackSegment, step_m: float = 0.25) -> bool: - """Verifies that telemetry and spatial segment boundaries strictly align.""" - expected_bins = int(round((segment.chainage_end_m - segment.chainage_start_m) / step_m)) - if segment.telemetry: - first_key = next(iter(segment.telemetry.keys())) - actual_bins = len(segment.telemetry[first_key]) - if actual_bins != expected_bins and actual_bins > 0: - # Tolerant warning / log - return False - return True - - -class TrackChainMLPipeline: - """ - Master multi-modal inference pipeline executing all 5 ML models and persistence fusion. - """ - - def __init__( - self, - yolo_detector: Optional[YOLOv8DefectDetector] = None, - patchcore_detector: Optional[PatchCoreAnomalyDetector] = None, - physics_calculator: Optional[EN13848PhysicsCalculator] = None, - physics_detector: Optional[EN13848PhysicsThresholdDetector] = None, - fault_classifier: Optional[GeometryFaultClassifier] = None, - sequence_vae: Optional[SequenceVAEDetector] = None, - fusion_engine: Optional[PersistenceRuleFusion] = None, - conditional_typing: bool = True, - ): - self.resampler = ChainageResampler(step_size_m=0.25) - self.physics_calc = physics_calculator or EN13848PhysicsCalculator(nominal_gauge_mm=1676.0) - self.physics_detector = physics_detector or EN13848PhysicsThresholdDetector() - self.fault_classifier = fault_classifier or GeometryFaultClassifier(weights_path=None) - self.sequence_vae = sequence_vae or SequenceVAEDetector(weights_path=None, calibrator_path=None) - self.yolo = yolo_detector or YOLOv8DefectDetector() - self.patchcore = patchcore_detector or PatchCoreAnomalyDetector() - self.fusion = fusion_engine or TrackChainFusionEngine(persistence_window=3) - self.conditional_typing = conditional_typing - - def process_segment(self, segment: TrackSegment) -> Tuple[SegmentDecision, SegmentSignals]: - """ - Processes a unified TrackSegment through all vision and geometry streams, - returning the operational decision and the full SegmentSignals container. - """ - verify_spatial_alignment(segment) - - # 1. Geometry Stream (Fast NumPy calculations) - telemetry = segment.telemetry or {} - n_samples = len(next(iter(telemetry.values()))) if telemetry else 0 - - if n_samples > 0: - roll_rad = telemetry.get("roll_rad", np.zeros(n_samples)) - lateral_mm = telemetry.get("lateral_pos_mm", telemetry.get("ay", telemetry.get("lat_accel_g", np.zeros(n_samples)))) - vertical_mm = telemetry.get("vertical_pos_mm", telemetry.get("az", telemetry.get("vert_accel_g", np.zeros(n_samples)))) - gauge_mm = telemetry.get("gauge_mm", np.full(n_samples, self.physics_calc.nominal_gauge)) - - geom_features = self.physics_calc.compute_all_features( - roll_rad=roll_rad, - lateral_pos_mm=lateral_mm, - vertical_pos_mm=vertical_mm, - gauge_mm=gauge_mm, - step_m=0.25, - ) - phys_signals = self.physics_detector.evaluate_features(geom_features) - else: - geom_features = {} - phys_signals = [] - - # 2. Conditional Bi-LSTM Execution (Edge Compute Optimization) - bilstm_signals: List[CalibratedSignal] = [] - has_physics_exceedance = any(s.is_anomaly for s in phys_signals) - - if not self.conditional_typing or has_physics_exceedance: - if geom_features: - bilstm_sig = self.fault_classifier.predict(geom_features) - bilstm_signals.append(bilstm_sig) - - # 3. Novel Geometry Anomaly Stream with Overlapping Windows - vae_signals: List[CalibratedSignal] = [] - if geom_features: - windows, positions = extract_overlapping_windows(geom_features, window_size=80, overlap=0.5) - if windows: - window_signals = [self.sequence_vae.predict(w) for w in windows] - # Aggregate via MAX anomaly score to preserve peak boundary detection - peak_vae_sig = max(window_signals, key=lambda s: s.raw_score) - vae_signals.append(peak_vae_sig) - else: - vae_sig = self.sequence_vae.predict(geom_features) - vae_signals.append(vae_sig) - - # 4. Vision Stream (YOLOv8 + PatchCore) - yolo_signals: List[CalibratedSignal] = [] - patch_signals: List[CalibratedSignal] = [] - - if segment.frames: - for frame in segment.frames: - yolo_sigs = self.yolo.predict(frame) - yolo_signals.extend(yolo_sigs) - - patch_sigs = self.patchcore.predict(frame) - patch_signals.extend(patch_sigs) - - # 5. Aggregate Signals into SegmentSignals - segment_signals = SegmentSignals( - v_known=yolo_signals, - v_novel=patch_signals, - g_known=phys_signals, - g_type=bilstm_signals, - g_novel=vae_signals, - ) - - # 6. Multi-Modal Rule-Based Fusion with Section Criticality - window_id = segment.segment_id or f"win-{int(segment.chainage_start_m):05d}-{int(segment.chainage_end_m):05d}" - decision = self.fusion.fuse( - signals=segment_signals, - window_id=window_id, - start_chainage_m=segment.chainage_start_m, - end_chainage_m=segment.chainage_end_m, - section_type=getattr(segment, "section_type", "mainline_standard"), - ) - - return decision, segment_signals - - def process_window(self, window: ChainageWindow) -> SegmentDecision: - """Convenience method wrapping ChainageWindow into a SegmentDecision.""" - segment = TrackSegment( - segment_id=f"win-{int(window.start_chainage_m):05d}-{int(window.end_chainage_m):05d}", - chainage_start_m=window.start_chainage_m, - chainage_end_m=window.end_chainage_m, - frames=window.frames or [], - telemetry=window.raw_telemetry or {}, - ) - decision, _ = self.process_segment(segment) - return decision - - -# Alias for backwards compatibility -EndToEndInferencePipeline = TrackChainMLPipeline diff --git a/ml/models/__init__.py b/ml/models/__init__.py deleted file mode 100644 index 768f4973c1973a5f48132988fcfd9602b463842a..0000000000000000000000000000000000000000 --- a/ml/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Models package marker. diff --git a/ml/models/__pycache__/__init__.cpython-312.pyc b/ml/models/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index a9bd6527264416802d0ed7738281744b83cb1c83..0000000000000000000000000000000000000000 Binary files a/ml/models/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/ml/models/geometry/__init__.py b/ml/models/geometry/__init__.py deleted file mode 100644 index 8db9b3504874c4288f7d1597526c43d999848b4c..0000000000000000000000000000000000000000 --- a/ml/models/geometry/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -# Geometry models package marker. - -from ml.models.geometry.sequence_vae import DilatedEncoder, SequenceVAE, SequenceVAEDetector -from ml.models.geometry.sequence_vae_enhanced import ( - DilatedEncoder1D, - Decoder1D, - EnhancedSequenceVAE, - OverlappingWindowInference, -) -from ml.models.geometry.fault_classifier import ( - BiLSTMAttention, - BiLSTMGeometryClassifier, - GeometryFaultClassifier, -) -from ml.models.geometry.fault_classifier_enhanced import ( - MultiHeadAttention1D, - EnhancedBiLSTMClassifier, - SequenceAugmentation, -) -from ml.models.geometry.physics_detector import ( - EN13848PhysicsThresholdDetector, -) - -__all__ = [ - "DilatedEncoder", - "SequenceVAE", - "SequenceVAEDetector", - "DilatedEncoder1D", - "Decoder1D", - "EnhancedSequenceVAE", - "OverlappingWindowInference", - "BiLSTMAttention", - "BiLSTMGeometryClassifier", - "GeometryFaultClassifier", - "MultiHeadAttention1D", - "EnhancedBiLSTMClassifier", - "SequenceAugmentation", - "EN13848PhysicsThresholdDetector", -] diff --git a/ml/models/geometry/__pycache__/__init__.cpython-312.pyc b/ml/models/geometry/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 885b26a26b38e14925da9d039bba359ef0f40901..0000000000000000000000000000000000000000 Binary files a/ml/models/geometry/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/ml/models/geometry/__pycache__/fault_classifier.cpython-312.pyc b/ml/models/geometry/__pycache__/fault_classifier.cpython-312.pyc deleted file mode 100644 index e2f85dc089e4778ad9c209546664189f5c762cb5..0000000000000000000000000000000000000000 Binary files a/ml/models/geometry/__pycache__/fault_classifier.cpython-312.pyc and /dev/null differ diff --git a/ml/models/geometry/__pycache__/fault_classifier_enhanced.cpython-312.pyc b/ml/models/geometry/__pycache__/fault_classifier_enhanced.cpython-312.pyc deleted file mode 100644 index 7088b2de23f7060670a6025d44a44c67d3df367e..0000000000000000000000000000000000000000 Binary files a/ml/models/geometry/__pycache__/fault_classifier_enhanced.cpython-312.pyc and /dev/null differ diff --git a/ml/models/geometry/__pycache__/physics_detector.cpython-312.pyc b/ml/models/geometry/__pycache__/physics_detector.cpython-312.pyc deleted file mode 100644 index 90d4452e40c57a4d64cd863055be74e5a64e2996..0000000000000000000000000000000000000000 Binary files a/ml/models/geometry/__pycache__/physics_detector.cpython-312.pyc and /dev/null differ diff --git a/ml/models/geometry/__pycache__/sequence_vae.cpython-312.pyc b/ml/models/geometry/__pycache__/sequence_vae.cpython-312.pyc deleted file mode 100644 index b9a3bd8699dbd59893e84b9384f38c1d67af1e49..0000000000000000000000000000000000000000 Binary files a/ml/models/geometry/__pycache__/sequence_vae.cpython-312.pyc and /dev/null differ diff --git a/ml/models/geometry/__pycache__/sequence_vae_enhanced.cpython-312.pyc b/ml/models/geometry/__pycache__/sequence_vae_enhanced.cpython-312.pyc deleted file mode 100644 index 7e9c98ed4a4b50d01baf65efd765a6add0a8f5d3..0000000000000000000000000000000000000000 Binary files a/ml/models/geometry/__pycache__/sequence_vae_enhanced.cpython-312.pyc and /dev/null differ diff --git a/ml/models/geometry/fault_classifier.py b/ml/models/geometry/fault_classifier.py deleted file mode 100644 index b9229ae4f423c454d319536cc6e8275946fac74e..0000000000000000000000000000000000000000 --- a/ml/models/geometry/fault_classifier.py +++ /dev/null @@ -1,262 +0,0 @@ -""" -ml/models/geometry/fault_classifier.py -Bi-LSTM with Temporal Attention for Geometry Fault Typing (tc.v1 SOTA). -Single Source of Truth: NUM_GEOMETRY_CLASSES = 6. -""" - -import os -from pathlib import Path -from typing import Dict, Tuple, Optional, Union, Any, List -import numpy as np -import torch -import torch.nn as nn -import torch.nn.functional as F - -from ml.core.schema import CalibratedSignal, SignalType, DefectClass -from ml.core.registry import register_model - -# Canonical single source of truth for geometry fault classification -NUM_GEOMETRY_CLASSES = 6 - - -class BiLSTMAttention(nn.Module): - """ - Bidirectional LSTM with Layer Normalization and Temporal Attention - for geometry waveform sequence classification. - """ - - def __init__( - self, - input_size: int = 5, - hidden_size: int = 128, - num_layers: int = 3, - num_classes: int = NUM_GEOMETRY_CLASSES, - dropout: float = 0.3, - ): - super().__init__() - self.input_size = input_size - self.hidden_size = hidden_size - self.num_layers = num_layers - self.num_classes = num_classes - - self.norm = nn.LayerNorm(input_size) - self.lstm = nn.LSTM( - input_size, - hidden_size, - num_layers, - batch_first=True, - bidirectional=True, - dropout=dropout if num_layers > 1 else 0.0, - ) - - # Attention mechanism - self.attention = nn.Linear(hidden_size * 2, 1) - - # Classification head - self.fc = nn.Linear(hidden_size * 2, num_classes) - - def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: - # x shape: (batch, seq_len, input_size) - x = self.norm(x) - lstm_out, _ = self.lstm(x) # (batch, seq_len, hidden*2) - - # Calculate attention weights - attn_scores = self.attention(lstm_out).squeeze(-1) # (batch, seq_len) - attn_weights = F.softmax(attn_scores, dim=1) # (batch, seq_len) - - # Apply attention to get context vector - context = torch.bmm(lstm_out.transpose(1, 2), attn_weights.unsqueeze(-1)).squeeze(-1) - - logits = self.fc(context) - return logits, attn_weights - - -# Aliases for backwards compatibility -BiLSTMGeometryClassifier = BiLSTMAttention - - -@register_model("geometry_fault_classifier") -class GeometryFaultClassifier: - """ - Inference & execution wrapper for the Bi-LSTM Geometry Fault Classifier. - Emits strictly compliant tc.v1 CalibratedSignal items with explainable attention maps. - """ - - CLASS_MAP = { - 0: DefectClass.NORMAL, - 1: DefectClass.DIPPED_JOINT, - 2: DefectClass.CYCLIC_TOP, - 3: DefectClass.TWIST_FAULT, - 4: DefectClass.ALIGNMENT_KINK, - 5: DefectClass.BUCKLING_RISK, - } - - def __init__( - self, - weights_path: Optional[Union[str, Path]] = "artifacts/checkpoints/geometry/bilstm_fault_typing_enhanced.pt", - device: str = "cpu", - num_classes: int = NUM_GEOMETRY_CLASSES, - threshold: float = 0.60, - ): - self.device = torch.device(device if torch.cuda.is_available() or device == "cpu" else "cpu") - self.threshold = threshold - self.num_classes = num_classes - - self.model = BiLSTMAttention(num_classes=num_classes).to(self.device) - - # Resolve candidate weight paths in order of preference - candidate_weights = [weights_path] if weights_path else [] - candidate_weights.extend([ - "artifacts/checkpoints/geometry/bilstm_fault_typing_enhanced.pt", - "artifacts/checkpoints/geometry/bilstm_fault_typing.pt", - "ml/models/geometry/weights/fault_classifier.pt", - "ml/models/geometry/weights/bilstm_fault_typing_enhanced.pt", - ]) - - for wp in candidate_weights: - if wp and os.path.exists(str(wp)): - try: - ckpt = torch.load(wp, map_location=self.device) - state = ckpt["model_state_dict"] if isinstance(ckpt, dict) and "model_state_dict" in ckpt else ckpt - if isinstance(state, dict) and "lstm.weight_ih_l0" in state: - detected_hidden = state["lstm.weight_ih_l0"].shape[0] // 4 - # Count layers - detected_layers = sum(1 for k in state.keys() if k.startswith("lstm.weight_ih_l") and not k.endswith("_reverse")) - if detected_hidden != self.model.hidden_size or detected_layers != self.model.num_layers: - self.model = BiLSTMAttention(hidden_size=detected_hidden, num_layers=max(1, detected_layers), num_classes=self.num_classes).to(self.device) - self.model.load_state_dict(state, strict=False) - if isinstance(ckpt, dict) and "temperature" in ckpt: - self.temperature = float(ckpt["temperature"]) - break - except Exception as e: - pass - - self.model.eval() - self.temperature = 1.5 - self.vector_weights: Optional[np.ndarray] = None - self.vector_biases: Optional[np.ndarray] = None - - # Check calibration files for calibrated vector scaling or temperature - for cal_p in ["artifacts/calibration/bilstm_temp.json", "artifacts/calibration/params.json"]: - if os.path.exists(cal_p): - try: - import json - with open(cal_p, "r", encoding="utf-8") as f: - cal_data = json.load(f) - if "weights" in cal_data and "biases" in cal_data: - self.vector_weights = np.asarray(cal_data["weights"], dtype=np.float32) - self.vector_biases = np.asarray(cal_data["biases"], dtype=np.float32) - if "temperature" in cal_data: - self.temperature = float(cal_data["temperature"]) - break - elif "bilstm_temperature" in cal_data: - self.temperature = float(cal_data["bilstm_temperature"]) - break - except Exception: - pass - - # Defensive guard: verify model output dimension matches calibration weights - if self.vector_weights is not None: - assert len(self.vector_weights) == self.num_classes, ( - f"Class-count mismatch: model={self.num_classes}, " - f"calibration={len(self.vector_weights)}. Retrain or refit to align." - ) - - def _format_input(self, geometry_window: Union[np.ndarray, Dict[str, np.ndarray], torch.Tensor]) -> torch.Tensor: - """Standardizes input geometry telemetry into shape [1, seq_len, 5].""" - if isinstance(geometry_window, dict): - keys = [ - ("twist_3m", "twist_3m_mm"), - ("versine_10m", "versine_10m_mm"), - ("versine_20m", "versine_20m_mm"), - ("unevenness_10m", "unevenness_10m_mm"), - ("cant", "cant_mm"), - ] - cols = [] - for k1, k2 in keys: - if k1 in geometry_window: - cols.append(np.asarray(geometry_window[k1])) - elif k2 in geometry_window: - cols.append(np.asarray(geometry_window[k2])) - else: - cols.append(np.zeros(80)) - arr = np.column_stack(cols) - elif isinstance(geometry_window, torch.Tensor): - arr = geometry_window.cpu().numpy() - else: - arr = np.asarray(geometry_window) - - if arr.ndim == 2: - arr = np.expand_dims(arr, axis=0) # [1, seq_len, 5] - - # Resample or pad/slice to 80 bins - b, t, c = arr.shape - if t != 80: - fixed = np.zeros((b, 80, c), dtype=np.float32) - copy_len = min(t, 80) - fixed[:, :copy_len, :] = arr[:, :copy_len, :] - arr = fixed - - return torch.tensor(arr, dtype=torch.float32).to(self.device) - - def predict(self, geometry_window: Union[np.ndarray, Dict[str, np.ndarray], torch.Tensor]) -> CalibratedSignal: - """ - geometry_window: shape (seq_len, 5) or (batch, seq_len, 5) or feature dict. - Returns tc.v1 CalibratedSignal with attention explainability metadata. - """ - with torch.no_grad(): - tensor_in = self._format_input(geometry_window) - logits, attn_weights = self.model(tensor_in) - logits_np = logits.cpu().numpy()[0] - - # Apply SOTA Vector Scaling (or fallback temperature scaling) - if ( - self.vector_weights is not None - and self.vector_biases is not None - and len(self.vector_weights) == len(logits_np) - ): - scaled_logits_np = logits_np * self.vector_weights + self.vector_biases - exp_logits = np.exp(scaled_logits_np - np.max(scaled_logits_np)) - probs = exp_logits / np.sum(exp_logits) - else: - scaled_logits = logits / max(1e-4, self.temperature) - probs = F.softmax(scaled_logits, dim=1).cpu().numpy()[0] - - attn = attn_weights.cpu().numpy()[0] - pred_idx = int(np.argmax(probs)) - confidence = float(probs[pred_idx]) - raw_confidence = float(F.softmax(logits, dim=1).cpu().numpy()[0, pred_idx]) - - peak_bin = int(np.argmax(attn)) - label = self.CLASS_MAP.get(pred_idx, DefectClass.NORMAL) - fired = bool((confidence >= self.threshold) and (pred_idx != 0)) - - return CalibratedSignal( - name="bilstm_geometry_typing", - model_version="0.1.0", - signal_type=SignalType.GEOMETRY_KNOWN_TYPE, - value=confidence, - raw_score=raw_confidence, - threshold=self.threshold, - fired=fired, - label=label, - bbox=None, - explanation={ - "attention_peak_bin": peak_bin, - "attention_peak_chainage_m": round(peak_bin * 0.25, 2), - "confidence": round(confidence, 4), - }, - metadata={ - "attention_peak_bin": peak_bin, - "attention_weights": [round(float(v), 4) for v in attn.tolist()], - "class_probabilities": { - self.CLASS_MAP.get(i, DefectClass.NORMAL).value: round(float(p), 4) - for i, p in enumerate(probs) - }, - }, - ) - - -# Aliases for backwards compatibility -BiLSTMFaultClassifier = GeometryFaultClassifier -BiLSTMClassifier = GeometryFaultClassifier diff --git a/ml/models/geometry/fault_classifier_enhanced.py b/ml/models/geometry/fault_classifier_enhanced.py deleted file mode 100644 index 616aaec4050ddae0182ddf7b87cdc69039972795..0000000000000000000000000000000000000000 --- a/ml/models/geometry/fault_classifier_enhanced.py +++ /dev/null @@ -1,236 +0,0 @@ -""" -Enhanced Bi-LSTM Geometry Fault Classifier with: -- Deeper architecture (3 layers) -- Multi-head attention -- Label smoothing -- Residual connections -- Better initialization -- Gradient clipping -""" -import json -from pathlib import Path -from typing import Dict, Optional, Tuple, Union, List, Any - -import numpy as np -import torch -import torch.nn as nn -import torch.nn.functional as F - - -class MultiHeadAttention1D(nn.Module): - """Multi-head attention for sequence classification.""" - - def __init__(self, hidden_size: int, num_heads: int = 4): - super().__init__() - self.num_heads = num_heads - self.head_dim = hidden_size // num_heads - - self.qkv = nn.Linear(hidden_size, 3 * hidden_size) - self.proj = nn.Linear(hidden_size, hidden_size) - - self.scale = self.head_dim ** -0.5 - - def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: - """ - Args: - x: (batch, seq_len, hidden_size) - Returns: - output: (batch, hidden_size) - attn_weights: (batch, seq_len) - """ - B, T, C = x.shape - - # Compute Q, K, V - qkv = self.qkv(x).reshape(B, T, 3, self.num_heads, self.head_dim) - q, k, v = qkv.permute(2, 0, 3, 1, 4).unbind(0) - - # Attention - attn = (q @ k.transpose(-2, -1)) * self.scale - attn = attn.softmax(dim=-1) - - # Weighted sum - out = (attn @ v).transpose(1, 2).reshape(B, T, C) - out = self.proj(out) - - # Global pooling with attention weights - attn_weights = attn.mean(dim=1).mean(dim=1) # (batch, seq_len) - # Global context vector via attention-weighted sum - pooled = (out * attn_weights.unsqueeze(-1)).sum(dim=1) - - return pooled, attn_weights - - -class EnhancedBiLSTMClassifier(nn.Module): - """ - Enhanced Bi-LSTM with: - - 3 layers with residual connections - - Multi-head attention - - Layer normalization - - Dropout - """ - - def __init__( - self, - input_size: int = 5, - hidden_size: int = 128, - num_layers: int = 3, - num_classes: int = 6, - dropout: float = 0.4, - use_attention: bool = True, - num_heads: int = 4, - ): - super().__init__() - - self.input_size = input_size - self.hidden_size = hidden_size - self.num_layers = num_layers - self.num_classes = num_classes - self.use_attention = use_attention - - # Input projection - self.input_proj = nn.Sequential( - nn.Linear(input_size, hidden_size), - nn.LayerNorm(hidden_size), - nn.ReLU(), - ) - - # Bi-LSTM layers with residual connections - self.lstm_layers = nn.ModuleList() - self.layer_norms = nn.ModuleList() - - for i in range(num_layers): - in_dim = hidden_size if i == 0 else hidden_size * 2 - self.lstm_layers.append( - nn.LSTM( - input_size=in_dim, - hidden_size=hidden_size, - num_layers=1, - batch_first=True, - bidirectional=True, - ) - ) - self.layer_norms.append(nn.LayerNorm(hidden_size * 2)) - - # Attention - if use_attention: - self.attention = MultiHeadAttention1D(hidden_size * 2, num_heads) - - # Classification head - self.classifier = nn.Sequential( - nn.Linear(hidden_size * 2, hidden_size), - nn.LayerNorm(hidden_size), - nn.ReLU(), - nn.Dropout(dropout), - nn.Linear(hidden_size, hidden_size // 2), - nn.ReLU(), - nn.Dropout(dropout / 2), - nn.Linear(hidden_size // 2, num_classes), - ) - - # Initialize weights - self._init_weights() - - def _init_weights(self): - """Better weight initialization.""" - for name, param in self.named_parameters(): - if 'weight_ih' in name: - nn.init.xavier_uniform_(param.data) - elif 'weight_hh' in name: - nn.init.orthogonal_(param.data) - elif 'bias' in name: - param.data.fill_(0) - # Set forget gate bias to 1 for better gradient flow - n = param.size(0) - param.data[n // 4:n // 2].fill_(1) - elif isinstance(param, nn.Linear): - nn.init.xavier_uniform_(param.weight) - if param.bias is not None: - nn.init.zeros_(param.bias) - - def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: - """ - Args: - x: (batch, seq_len, input_size) - Returns: - logits: (batch, num_classes) - attn_weights: (batch, seq_len) or None - """ - # Input projection - x = self.input_proj(x) - - # Bi-LSTM layers with residual connections - for i, (lstm, norm) in enumerate(zip(self.lstm_layers, self.layer_norms)): - residual = x - x, _ = lstm(x) - x = norm(x) - - # Residual connection (only if dimensions match) - if i > 0 and residual.shape == x.shape: - x = x + residual - - # Attention or global average pooling - attn_weights = None - if self.use_attention: - x_pool, attn_weights = self.attention(x) - else: - x_pool = x.mean(dim=1) - - # Classification - logits = self.classifier(x_pool) - - return logits, attn_weights - - def predict_with_explanation(self, x: torch.Tensor) -> Dict[str, Union[int, float, np.ndarray, None]]: - """Predict with attention explanation.""" - self.eval() - - if x.dim() == 2: - x = x.unsqueeze(0) - - with torch.no_grad(): - logits, attn_weights = self.forward(x) - probs = F.softmax(logits, dim=1) - pred_class = torch.argmax(probs, dim=1).item() - confidence = probs[0, pred_class].item() - - return { - 'class': pred_class, - 'confidence': confidence, - 'probs': probs.cpu().numpy()[0], - 'attention': attn_weights.cpu().numpy()[0] if attn_weights is not None else None, - } - - -class SequenceAugmentation: - """Data augmentation for geometry sequences.""" - - def __init__(self, noise_std: float = 0.1, shift_range: int = 5, scale_range: float = 0.1): - self.noise_std = noise_std - self.shift_range = shift_range - self.scale_range = scale_range - - def augment(self, sequence: torch.Tensor) -> torch.Tensor: - """Apply random augmentation to sequence.""" - aug = sequence.clone() - - # Random noise - if np.random.random() < 0.5: - noise = torch.randn_like(aug) * self.noise_std - aug = aug + noise - - # Random shift - if np.random.random() < 0.3: - shift = np.random.randint(-self.shift_range, self.shift_range + 1) - aug = torch.roll(aug, shift, dims=0) - - # Random scale - if np.random.random() < 0.3: - scale = 1.0 + np.random.uniform(-self.scale_range, self.scale_range) - aug = aug * scale - - # Random channel dropout - if np.random.random() < 0.2 and aug.shape[1] > 1: - channel = np.random.randint(aug.shape[1]) - aug[:, channel] = 0 - - return aug diff --git a/ml/models/geometry/physics_detector.py b/ml/models/geometry/physics_detector.py deleted file mode 100644 index 3dae5f43a35ce0dae6d5b5338f25f061e3100ac2..0000000000000000000000000000000000000000 --- a/ml/models/geometry/physics_detector.py +++ /dev/null @@ -1,288 +0,0 @@ -# Threshold detector on EN 13848 & RDSO features for deterministic geometry fault alarms (tc.v1 SOTA). - -from typing import List, Dict, Optional, Union, Any -from pathlib import Path -import numpy as np -import yaml -import pandas as pd - -from ml.core.schema import DefectClass, CalibratedSignal, SignalType -from ml.core.registry import register_model, ModelRegistry -from ml.core.chainage import ChainageResampler -from ml.features.en13848 import EN13848PhysicsCalculator - - -@register_model("physics_detector") -class EN13848PhysicsThresholdDetector: - """ - Deterministic safety threshold detector enforcing RDSO Broad Gauge (1676mm) - and European EN 13848 track geometry limits (Immediate Action Limits & Alert Limits). - - Converts physical exceedance units (mm) into normalized probabilistic [0.0, 1.0] scores: - Score = min(1.0, Measured_Value / (2.0 * Limit)) - Where: - Measured = 0.0mm -> Score = 0.0 - Measured = Limit -> Score = 0.50 (Triggers operating threshold) - Measured = 2x Limit -> Score = 1.0 (Critical severe exceedance) - """ - - def __init__( - self, - config_path: Optional[Union[str, Path]] = None, - nominal_gauge_mm: float = 1676.0, - twist_limit_mm: float = 4.0, # RDSO 3m Twist IAL (4mm) - versine_limit_mm: float = 6.0, # RDSO 10m Versine IAL (6mm) - unevenness_limit_mm: float = 6.0, # RDSO 10m Longitudinal Level IAL (6mm) - gauge_limit_mm: float = 6.0, # RDSO Gauge Variation (+6mm / -3mm) - cross_level_limit_mm: float = 20.0, # Cross-Level Cant limit (20mm) - operating_threshold: float = 0.50, - ): - self.nominal_gauge = nominal_gauge_mm - self.operating_threshold = operating_threshold - self.resampler = ChainageResampler(step_size_m=0.25) - self.calculator = EN13848PhysicsCalculator(nominal_gauge_mm=nominal_gauge_mm) - - self.limits = { - "twist_3m": float(twist_limit_mm), - "versine_10m": float(versine_limit_mm), - "unevenness_10m": float(unevenness_limit_mm), - "gauge_dev": float(gauge_limit_mm), - "cross_level": float(cross_level_limit_mm), - } - - # Override with config file if provided or available in configs/ - self._load_config(config_path) - - def _load_config(self, config_path: Optional[Union[str, Path]] = None): - cfg_file = None - if config_path: - p = Path(config_path) - if p.exists(): - cfg_file = p - else: - default_p = ModelRegistry.ROOT / "ml" / "configs" / "physics_detector.yaml" - if default_p.exists(): - cfg_file = default_p - - if cfg_file and cfg_file.exists(): - with open(cfg_file, "r") as f: - data = yaml.safe_load(f) or {} - std = data.get("standards", {}).get("rdso_broad_gauge_1676", {}) - lim = std.get("limits_mm", {}) - if "nominal_gauge_mm" in std: - self.nominal_gauge = float(std["nominal_gauge_mm"]) - self.calculator.nominal_gauge = self.nominal_gauge - if "twist_3m_mm" in lim: - self.limits["twist_3m"] = float(lim["twist_3m_mm"]) - if "versine_10m_mm" in lim: - self.limits["versine_10m"] = float(lim["versine_10m_mm"]) - if "unevenness_10m_mm" in lim: - self.limits["unevenness_10m"] = float(lim["unevenness_10m_mm"]) - if "gauge_variation_mm" in lim: - self.limits["gauge_dev"] = float(lim["gauge_variation_mm"]) - if "cross_level_mm" in lim: - self.limits["cross_level"] = float(lim["cross_level_mm"]) - - def calculate_exceedance_score(self, measured_mm: float, limit_mm: float) -> float: - """ - Normalize physical metric into [0.0, 1.0] calibrated probability scale: - Score = min(1.0, Measured / (2.0 * Limit)) - """ - if limit_mm <= 0.0: - return 0.0 - return float(np.clip(abs(measured_mm) / (2.0 * limit_mm), 0.0, 1.0)) - - def evaluate_features(self, geometry_features: Dict[str, Union[np.ndarray, float]]) -> List[CalibratedSignal]: - """ - Evaluate a track segment's geometry feature arrays against RDSO/EN 13848 limits. - Emits standard CalibratedSignal items for each fault detected. - """ - signals: List[CalibratedSignal] = [] - - # 1. Twist Exceedance (3m base) - if "twist_3m_mm" in geometry_features: - twist_arr = np.asarray(geometry_features["twist_3m_mm"]) - max_twist = float(np.max(np.abs(twist_arr))) if len(twist_arr) > 0 else 0.0 - limit = self.limits["twist_3m"] - score = self.calculate_exceedance_score(max_twist, limit) - fired = bool(score >= self.operating_threshold) - - if fired or score > 0.35: - signals.append( - CalibratedSignal( - name="physics_twist_3m", - stream_name="geometry_physics", - raw_score=max_twist, - calibrated_prob=score, - predicted_class=DefectClass.TWIST_EXCEEDANCE, - is_anomaly=fired, - signal_type=SignalType.GEOMETRY_KNOWN, - threshold=self.operating_threshold, - explanation=f"RDSO 3m Twist: {max_twist:.2f}mm vs {limit:.1f}mm limit (Score: {score:.3f})", - metadata={ - "feature": "twist_3m", - "limit_mm": limit, - "exceedance_mm": round(max_twist - limit, 3), - "severity": "critical" if max_twist >= limit else "high", - }, - ) - ) - - # 2. Gauge Deviation (Widening / Tightening) - if "gauge_dev_mm" in geometry_features: - gauge_arr = np.asarray(geometry_features["gauge_dev_mm"]) - max_gauge_dev = float(np.max(np.abs(gauge_arr))) if len(gauge_arr) > 0 else 0.0 - limit = self.limits["gauge_dev"] - score = self.calculate_exceedance_score(max_gauge_dev, limit) - fired = bool(score >= self.operating_threshold) - - if fired or score > 0.35: - mean_dev = float(np.mean(gauge_arr)) if len(gauge_arr) > 0 else 0.0 - dev_type = "widening" if mean_dev >= 0.0 else "tightening" - - signals.append( - CalibratedSignal( - name="physics_gauge_deviation", - stream_name="geometry_physics", - raw_score=max_gauge_dev, - calibrated_prob=score, - predicted_class=DefectClass.GAUGE_WIDENING, - is_anomaly=fired, - signal_type=SignalType.GEOMETRY_KNOWN, - threshold=self.operating_threshold, - explanation=f"RDSO Gauge Deviation ({dev_type}): {max_gauge_dev:.2f}mm vs {limit:.1f}mm limit (Score: {score:.3f})", - metadata={ - "feature": "gauge_deviation", - "deviation_type": dev_type, - "limit_mm": limit, - "exceedance_mm": round(max_gauge_dev - limit, 3), - "severity": "critical" if max_gauge_dev >= limit else "high", - }, - ) - ) - - # 3. Alignment Fault (Versine 10m) - if "versine_10m_mm" in geometry_features: - versine_arr = np.asarray(geometry_features["versine_10m_mm"]) - max_versine = float(np.max(np.abs(versine_arr))) if len(versine_arr) > 0 else 0.0 - limit = self.limits["versine_10m"] - score = self.calculate_exceedance_score(max_versine, limit) - fired = bool(score >= self.operating_threshold) - - if fired or score > 0.35: - signals.append( - CalibratedSignal( - name="physics_versine_10m", - stream_name="geometry_physics", - raw_score=max_versine, - calibrated_prob=score, - predicted_class=DefectClass.ALIGNMENT_FAULT, - is_anomaly=fired, - signal_type=SignalType.GEOMETRY_KNOWN, - threshold=self.operating_threshold, - explanation=f"RDSO 10m Versine: {max_versine:.2f}mm vs {limit:.1f}mm limit (Score: {score:.3f})", - metadata={ - "feature": "versine_10m", - "limit_mm": limit, - "exceedance_mm": round(max_versine - limit, 3), - "severity": "critical" if max_versine >= limit else "high", - }, - ) - ) - - # 4. Longitudinal Unevenness (10m chord) - if "unevenness_10m_mm" in geometry_features: - uneven_arr = np.asarray(geometry_features["unevenness_10m_mm"]) - max_uneven = float(np.max(np.abs(uneven_arr))) if len(uneven_arr) > 0 else 0.0 - limit = self.limits["unevenness_10m"] - score = self.calculate_exceedance_score(max_uneven, limit) - fired = bool(score >= self.operating_threshold) - - if fired or score > 0.35: - signals.append( - CalibratedSignal( - name="physics_unevenness_10m", - stream_name="geometry_physics", - raw_score=max_uneven, - calibrated_prob=score, - predicted_class=DefectClass.ROUGH_TRACK, - is_anomaly=fired, - signal_type=SignalType.GEOMETRY_KNOWN, - threshold=self.operating_threshold, - explanation=f"RDSO 10m Longitudinal Unevenness: {max_uneven:.2f}mm vs {limit:.1f}mm limit (Score: {score:.3f})", - metadata={ - "feature": "unevenness_10m", - "limit_mm": limit, - "exceedance_mm": round(max_uneven - limit, 3), - "severity": "critical" if max_uneven >= limit else "high", - }, - ) - ) - - return signals - - def evaluate_window( - self, - gauge_dev_mm: np.ndarray, - twist_mm_per_m: np.ndarray, - cant_mm: np.ndarray, - versine_mm: Optional[np.ndarray] = None, - unevenness_mm: Optional[np.ndarray] = None, - ) -> List[CalibratedSignal]: - """Convenience method for evaluating individual feature arrays directly.""" - features: Dict[str, Any] = { - "gauge_dev_mm": gauge_dev_mm, - "twist_3m_mm": twist_mm_per_m, - "cant_mm": cant_mm, - } - if versine_mm is not None: - features["versine_10m_mm"] = versine_mm - if unevenness_mm is not None: - features["unevenness_10m_mm"] = unevenness_mm - return self.evaluate_features(features) - - def predict(self, telemetry_input: Union[pd.DataFrame, Dict[str, np.ndarray]]) -> List[CalibratedSignal]: - """ - Process a raw telemetry DataFrame or dictionary through distance resampling, - EN 13848 multi-chord physics calculations, and threshold evaluations. - """ - if isinstance(telemetry_input, pd.DataFrame): - df = telemetry_input - ts = df["timestamp"].values if "timestamp" in df else np.arange(len(df)) * 0.01 - speed = df["speed_mps"].values if "speed_mps" in df else np.full(len(df), 20.0) - sensor_streams = { - col: df[col].values - for col in df.columns - if col not in ("timestamp", "speed_mps") - } - else: - sensor_streams = telemetry_input - ts = sensor_streams.get("timestamp", np.arange(len(next(iter(sensor_streams.values())))) * 0.01) - speed = sensor_streams.get("speed_mps", np.full(len(ts), 20.0)) - - # 1. Resample onto distance grid - grid_chainage_m, resampled = self.resampler.resample_telemetry_batch( - timestamps_s=ts, - speeds_mps=speed, - sensor_streams=sensor_streams, - ) - - n_samples = len(grid_chainage_m) - roll_rad = resampled.get("roll_rad", np.zeros(n_samples)) - gauge_mm = resampled.get("gauge_mm", np.full(n_samples, self.nominal_gauge)) - lateral_mm = resampled.get("lat_accel_g", np.zeros(n_samples)) - vertical_mm = resampled.get("vert_accel_g", np.zeros(n_samples)) - - step_m = float(np.mean(np.diff(grid_chainage_m))) if len(grid_chainage_m) > 1 else 0.25 - - # 2. Compute features - features = self.calculator.compute_all_features( - roll_rad=roll_rad, - lateral_pos_mm=lateral_mm, - vertical_pos_mm=vertical_mm, - gauge_mm=gauge_mm, - step_m=step_m, - ) - - # 3. Evaluate thresholds - return self.evaluate_features(features) diff --git a/ml/models/geometry/sequence_vae.py b/ml/models/geometry/sequence_vae.py deleted file mode 100644 index bda27e3a2a775de3ebea98fc9072c8c454e6dc22..0000000000000000000000000000000000000000 --- a/ml/models/geometry/sequence_vae.py +++ /dev/null @@ -1,423 +0,0 @@ -""" -ml/models/geometry/sequence_vae.py -1D-CNN Variational Autoencoder (VAE) for Novel Geometry Anomaly Detection (tc.v1 SOTA). -Features: - 1. Dilated 1D-CNN Encoder (captures 1-3m, 3-10m, and 10-30m wavelengths) - 2. Dual-Path Anomaly Scoring (Reconstruction MSE + Latent Mahalanobis Distance) - 3. Sigmoid Distance Calibration for [0.0, 1.0] confidence mapping -""" - -import os -from pathlib import Path -from typing import Dict, Tuple, Optional, Union, Any, List -import numpy as np -import torch -import torch.nn as nn -import torch.nn.functional as F - -from ml.core.schema import CalibratedSignal, SignalType, DefectClass -from ml.core.registry import register_model -from ml.calibration.patchcore_scale import SigmoidDistanceCalibrator -from ml.models.geometry.sequence_vae_enhanced import EnhancedSequenceVAE - - -class DilatedEncoder(nn.Module): - """ - Multi-Scale Dilated 1D-CNN Encoder capturing short (1-3m), medium (3-10m), - and long (10-30m) spatial track wavelengths simultaneously. - """ - - def __init__(self, n_features: int = 5, seq_len: int = 80, latent_dim: int = 16): - super().__init__() - self.n_features = n_features - self.seq_len = seq_len - self.latent_dim = latent_dim - - # Parallel multi-scale dilated convolutions - self.branch_short = nn.Conv1d(n_features, 32, kernel_size=3, dilation=1, padding=1) # 1-3m wavelengths - self.branch_medium = nn.Conv1d(n_features, 32, kernel_size=3, dilation=4, padding=4) # 3-10m wavelengths - self.branch_long = nn.Conv1d(n_features, 32, kernel_size=3, dilation=10, padding=10) # 10-30m wavelengths - - self.fusion = nn.Sequential( - nn.Conv1d(96, 64, kernel_size=1), - nn.ReLU(), - nn.AdaptiveAvgPool1d(20), # Downsample sequence to 20 spatial bins - ) - - self.flat_size = 64 * 20 # 1280 - - # Latent Space Projections - self.fc_mu = nn.Linear(self.flat_size, latent_dim) - self.fc_logvar = nn.Linear(self.flat_size, latent_dim) - - def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: - # x: [batch, n_features, seq_len] - short = F.relu(self.branch_short(x)) - medium = F.relu(self.branch_medium(x)) - long = F.relu(self.branch_long(x)) - - combined = torch.cat([short, medium, long], dim=1) # [B, 96, seq_len] - h = self.fusion(combined).flatten(1) # [B, 1280] - - mu = self.fc_mu(h) - logvar = self.fc_logvar(h) - return mu, logvar - - -class SequenceVAE(nn.Module): - """ - 1D-CNN Variational Autoencoder with Dilated Multi-Scale Encoder - and Transposed Conv1D Decoder for track geometry waveform reconstruction. - """ - - def __init__( - self, - seq_len: int = 80, - n_features: int = 5, - latent_dim: int = 16, - ): - super().__init__() - self.seq_len = seq_len - self.n_features = n_features - self.latent_dim = latent_dim - - # Multi-scale dilated encoder - self.encoder = DilatedEncoder(n_features=n_features, seq_len=seq_len, latent_dim=latent_dim) - self.flat_size = self.encoder.flat_size - - # Decoder: Reconstructs 5-channel sequence back to 80 spatial bins - self.decoder_input = nn.Linear(latent_dim, self.flat_size) - self.decoder = nn.Sequential( - nn.Unflatten(1, (64, 20)), - nn.ConvTranspose1d(64, 32, kernel_size=3, stride=2, padding=1, output_padding=1), # 20 -> 40 - nn.ReLU(), - nn.ConvTranspose1d(32, n_features, kernel_size=3, stride=2, padding=1, output_padding=1), # 40 -> 80 - ) - - def reparameterize(self, mu: torch.Tensor, logvar: torch.Tensor) -> torch.Tensor: - std = torch.exp(0.5 * logvar) - eps = torch.randn_like(std) - return mu + eps * std - - def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]: - if x.dim() == 2: - x = x.unsqueeze(0) - - # Transpose [batch, seq_len, features] -> [batch, features, seq_len] for Conv1d - x_trans = x.transpose(1, 2) - mu, logvar = self.encoder(x_trans) - z = self.reparameterize(mu, logvar) - - h = F.relu(self.decoder_input(z)) - recon_trans = self.decoder(h) - recon_x = recon_trans.transpose(1, 2) # [batch, seq_len, features] - - return recon_x, mu, logvar - - def loss_function( - self, - recon_x: torch.Tensor, - x: torch.Tensor, - mu: torch.Tensor, - logvar: torch.Tensor, - beta: float = 0.01, - ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]: - if recon_x.shape != x.shape: - min_len = min(recon_x.shape[1], x.shape[1]) - recon_x = recon_x[:, :min_len, :] - x = x[:, :min_len, :] - - recon_loss = F.mse_loss(recon_x, x, reduction="mean") - kld_loss = -0.5 * torch.mean(1 + logvar - mu.pow(2) - logvar.exp()) - total_loss = recon_loss + beta * kld_loss - return total_loss, recon_loss, kld_loss - - def compute_reconstruction_error(self, x: torch.Tensor) -> torch.Tensor: - self.eval() - with torch.no_grad(): - recon_x, _, _ = self.forward(x) - if recon_x.shape != x.shape: - min_len = min(recon_x.shape[1], x.shape[1]) - recon_x = recon_x[:, :min_len, :] - x = x[:, :min_len, :] - error = torch.mean((recon_x - x) ** 2, dim=(1, 2)) - return error - - def compute_anomaly_score(self, x: torch.Tensor) -> torch.Tensor: - """Alias for compute_reconstruction_error.""" - return self.compute_reconstruction_error(x) - - -@register_model("geometry_sequence_vae") -class SequenceVAEDetector: - """ - Production detector wrapper for SequenceVAE featuring: - - Enhanced multi-scale dilated convolutions matching trained SOTA checkpoint - - Dual-Path Anomaly Scoring (Reconstruction MSE + Latent Mahalanobis Distance) - - EVT & Sigmoid Calibration for calibrated [0.0, 1.0] novelty probabilities - """ - - def __init__( - self, - weights_path: Optional[Union[str, Path]] = "artifacts/checkpoints/geometry/sequence_vae_enhanced.pt", - calibrator_path: Optional[Union[str, Path]] = "artifacts/calibration/vae_calibration.json", - device: str = "cpu", - seq_len: int = 80, - n_features: int = 5, - latent_dim: int = 16, - alpha: float = 0.7, - threshold: float = 0.50, - ): - self.device = torch.device(device if torch.cuda.is_available() or device == "cpu" else "cpu") - self.seq_len = seq_len - self.n_features = n_features - self.latent_dim = latent_dim - self.alpha = float(alpha) - self.threshold = threshold - - # Default model is EnhancedSequenceVAE matching trained SOTA checkpoint ([128, 192, 1] fusion) - self.model: Union[EnhancedSequenceVAE, SequenceVAE] = EnhancedSequenceVAE( - seq_len=seq_len, n_features=n_features, latent_dim=latent_dim - ).to(self.device) - - # Resolve candidate weight paths in order of preference - candidate_weights = [weights_path] if weights_path else [] - candidate_weights.extend([ - "artifacts/checkpoints/geometry/sequence_vae_enhanced.pt", - "artifacts/checkpoints/geometry/sequence_vae.pt", - "ml/models/geometry/weights/sequence_vae.pt", - "ml/models/geometry/weights/sequence_vae_enhanced.pt", - ]) - - loaded_checkpoint = False - for wp in candidate_weights: - if wp and os.path.exists(str(wp)): - try: - ckpt = torch.load(wp, map_location=self.device) - state = ckpt["model_state_dict"] if isinstance(ckpt, dict) and "model_state_dict" in ckpt else ckpt - # Auto-detect architecture from state dict - if isinstance(state, dict) and "encoder.fusion.0.weight" in state: - in_channels = state["encoder.fusion.0.weight"].shape[1] - if in_channels == 96: - self.model = SequenceVAE(seq_len=seq_len, n_features=n_features, latent_dim=latent_dim).to(self.device) - else: - self.model = EnhancedSequenceVAE(seq_len=seq_len, n_features=n_features, latent_dim=latent_dim).to(self.device) - self.model.load_state_dict(state, strict=True) - loaded_checkpoint = True - break - except Exception as e: - if weights_path is not None and str(wp) == str(weights_path): - raise RuntimeError( - f"VAE weight load FAILED for {weights_path} — architecture mismatch. " - f"Do NOT fall back to a random model. Error: {e}" - ) - - self.model.eval() - - # Latent distribution baseline for Mahalanobis distance - self.normal_latent_mean: Optional[np.ndarray] = None - self.normal_latent_cov_inv: Optional[np.ndarray] = None - - # Sigmoid / EVT threshold calibrator - self.calibrator = SigmoidDistanceCalibrator(threshold=1.65, steepness_k=2.0) - candidate_calibs = [calibrator_path] if calibrator_path else [] - candidate_calibs.extend([ - "artifacts/calibration/vae_calibration.json", - "artifacts/calibration/sequence_vae_calibration.json", - ]) - loaded_calib = False - for cp in candidate_calibs: - if cp and os.path.exists(str(cp)): - try: - with open(cp, "r", encoding="utf-8") as f: - cdata = json.load(f) - thresh = float(cdata.get("threshold_evt", cdata.get("threshold_p99", 1.65))) - k_val = float(cdata.get("steepness_k", cdata.get("steepness", 2.0))) - self.calibrator = SigmoidDistanceCalibrator(threshold=thresh, steepness_k=k_val) - if isinstance(self.model, EnhancedSequenceVAE): - self.model.threshold_evt = thresh - self.model.steepness_k = k_val - loaded_calib = True - break - except Exception: - pass - - if not loaded_calib: - # Auto-initialize baseline distribution on nominal noise - synth_baseline = np.random.normal(0.0, 0.1, (30, self.seq_len, self.n_features)).astype(np.float32) - self.fit_latent_distribution(synth_baseline) - synth_scores = [self.compute_anomaly_score(s) for s in synth_baseline] - self.calibrator.fit(synth_scores, percentile=99.0) - self.calibrator.threshold = max(self.calibrator.threshold, 0.5) - - def _format_input(self, geometry_window: Union[np.ndarray, Dict[str, np.ndarray], torch.Tensor]) -> torch.Tensor: - if isinstance(geometry_window, dict): - keys = [ - ("twist_3m", "twist_3m_mm"), - ("versine_10m", "versine_10m_mm"), - ("versine_20m", "versine_20m_mm"), - ("unevenness_10m", "unevenness_10m_mm"), - ("cant", "cant_mm"), - ] - cols = [] - for k1, k2 in keys: - if k1 in geometry_window: - cols.append(np.asarray(geometry_window[k1])) - elif k2 in geometry_window: - cols.append(np.asarray(geometry_window[k2])) - else: - cols.append(np.zeros(self.seq_len)) - arr = np.column_stack(cols) - elif isinstance(geometry_window, torch.Tensor): - arr = geometry_window.cpu().numpy() - else: - arr = np.asarray(geometry_window) - - if arr.ndim == 2: - arr = np.expand_dims(arr, axis=0) # [1, seq_len, 5] - - b, t, c = arr.shape - if t != self.seq_len: - fixed = np.zeros((b, self.seq_len, c), dtype=np.float32) - copy_len = min(t, self.seq_len) - fixed[:, :copy_len, :] = arr[:, :copy_len, :] - arr = fixed - - return torch.tensor(arr, dtype=torch.float32).to(self.device) - - def fit_latent_distribution(self, normal_sequences: Union[np.ndarray, torch.Tensor, List[np.ndarray]]): - """Fits baseline latent Gaussian distribution (mean and regularized inverse covariance).""" - if isinstance(self.model, EnhancedSequenceVAE): - if isinstance(normal_sequences, list): - normal_tensor = torch.stack([torch.tensor(s, dtype=torch.float32) for s in normal_sequences]) - elif isinstance(normal_sequences, np.ndarray): - normal_tensor = torch.tensor(normal_sequences, dtype=torch.float32) - else: - normal_tensor = normal_sequences - self.model.fit_latent_distribution(normal_tensor) - self.normal_latent_mean = self.model.latent_mean - self.normal_latent_cov_inv = self.model.latent_cov_inv - return - - self.model.eval() - latents = [] - - if isinstance(normal_sequences, list): - normal_tensor = torch.stack([torch.tensor(s, dtype=torch.float32) for s in normal_sequences]) - elif isinstance(normal_sequences, np.ndarray): - normal_tensor = torch.tensor(normal_sequences, dtype=torch.float32) - else: - normal_tensor = normal_sequences - - if normal_tensor.ndim == 2: - normal_tensor = normal_tensor.unsqueeze(0) - - with torch.no_grad(): - normal_tensor = normal_tensor.to(self.device) - _, mu, _ = self.model(normal_tensor) - latents = mu.cpu().numpy() - - self.normal_latent_mean = np.mean(latents, axis=0) - cov = np.cov(latents.T) - if cov.ndim == 0: - cov = np.array([[cov]]) - cov_reg = cov + np.eye(cov.shape[0]) * 1e-6 - self.normal_latent_cov_inv = np.linalg.inv(cov_reg) - - def compute_anomaly_score(self, sequence: Union[np.ndarray, Dict[str, np.ndarray], torch.Tensor]) -> float: - """ - Computes Dual-Path Anomaly Score: - Score = alpha * MSE_recon + (1 - alpha) * D_mahalanobis - """ - if isinstance(self.model, EnhancedSequenceVAE): - if isinstance(sequence, dict): - tensor_in = self._format_input(sequence) - res = self.model.compute_anomaly_score(tensor_in) - else: - res = self.model.compute_anomaly_score(sequence) - return float(res.get("combined_score", res.get("recon_error", 0.0))) - - self.model.eval() - tensor_in = self._format_input(sequence) - - with torch.no_grad(): - recon_x, mu, _ = self.model(tensor_in) - mse = float(F.mse_loss(recon_x, tensor_in).item()) - - # Path 2: Latent Mahalanobis Distance - if self.normal_latent_mean is not None and self.normal_latent_cov_inv is not None: - mu_np = mu.cpu().numpy()[0] - diff = mu_np - self.normal_latent_mean - mahal_dist = float(np.sqrt(np.clip(diff @ self.normal_latent_cov_inv @ diff.T, 0.0, None))) - else: - mahal_dist = float(torch.norm(mu).item()) - - combined_score = float((self.alpha * mse) + ((1.0 - self.alpha) * mahal_dist)) - return combined_score - - def fit_calibration(self, normal_windows: Union[np.ndarray, torch.Tensor], percentile: float = 99.0): - """Fits latent distribution and Sigmoid distance calibrator on nominal track validation sequences.""" - self.fit_latent_distribution(normal_windows) - - scores = [] - if isinstance(normal_windows, torch.Tensor): - arr = normal_windows.cpu().numpy() - else: - arr = np.asarray(normal_windows) - - for seq in arr: - scores.append(self.compute_anomaly_score(seq)) - - self.calibrator.fit(scores, percentile=percentile) - return self.calibrator.threshold - - def predict(self, geometry_window: Union[np.ndarray, Dict[str, np.ndarray], torch.Tensor]) -> CalibratedSignal: - """ - Runs the 1D-CNN Dual-Path VAE on the 20m geometry window. - Returns a calibrated CalibratedSignal indicating structural novelty. - """ - if isinstance(self.model, EnhancedSequenceVAE): - if isinstance(geometry_window, dict): - tensor_in = self._format_input(geometry_window) - pred = self.model.predict(tensor_in) - else: - pred = self.model.predict(geometry_window) - raw_score = float(pred["raw_score"]) - calibrated_prob = float(pred["calibrated_prob"]) - is_anomaly = bool(pred["is_anomaly"]) - thresh = float(pred.get("threshold", self.threshold)) - else: - raw_score = self.compute_anomaly_score(geometry_window) - calibrated_prob = float(self.calibrator.scale(raw_score)) - is_anomaly = bool(calibrated_prob >= self.threshold) - thresh = self.threshold - - explanation = { - "combined_anomaly_score": round(raw_score, 4), - "reconstruction_error": round(raw_score, 4), - "calibrated_prob": round(calibrated_prob, 4), - "p99_threshold": round(self.calibrator.threshold, 4), - "anomaly_detected": is_anomaly, - } - - return CalibratedSignal( - name="sequence_vae_anomaly", - stream_name="geometry_vae", - model_version="0.1.0", - signal_type=SignalType.GEOMETRY_NOVEL, - value=calibrated_prob, - raw_score=raw_score, - calibrated_prob=calibrated_prob, - threshold=thresh, - fired=is_anomaly, - is_anomaly=is_anomaly, - predicted_class=DefectClass.GEOMETRY_ANOMALY if is_anomaly else DefectClass.NORMAL, - bbox=None, - explanation=explanation, - metadata={ - "model_name": "sequence_vae_dual_path", - "latent_dim": self.model.latent_dim, - "combined_score": raw_score, - "calibrator_threshold": self.calibrator.threshold, - }, - ) diff --git a/ml/models/geometry/sequence_vae_enhanced.py b/ml/models/geometry/sequence_vae_enhanced.py deleted file mode 100644 index 4fbbce90bd2afb136ced115c780369c8c4c35062..0000000000000000000000000000000000000000 --- a/ml/models/geometry/sequence_vae_enhanced.py +++ /dev/null @@ -1,537 +0,0 @@ -""" -Enhanced Sequence VAE for Novel Geometry Anomaly Detection. - -Upgrades from original: -- Dilated 1D CNN encoder for multi-scale wavelength capture -- KL annealing for stable training -- Dual-path scoring (reconstruction + Mahalanobis) -- Overlapping window inference -- Gradient clipping and learning rate scheduling -- Huber loss for robust reconstruction -""" -import json -from pathlib import Path -from typing import Dict, Optional, Tuple, Union, List, Any - -import numpy as np -import torch -import torch.nn as nn -import torch.nn.functional as F - -from ml.core.schema import CalibratedSignal, SignalType, DefectClass - - -class DilatedEncoder1D(nn.Module): - """ - Multi-scale 1D CNN encoder with dilated convolutions. - Captures both short-wavelength (1-3m) and long-wavelength (10-30m) patterns. - """ - - def __init__(self, n_features: int = 5, seq_len: int = 80, latent_dim: int = 16): - super().__init__() - self.n_features = n_features - self.seq_len = seq_len - self.latent_dim = latent_dim - - # Multi-scale feature extraction with dilated convolutions - # Branch 1: Short wavelengths (1-3m) - dilation=1 - self.branch_short = nn.Sequential( - nn.Conv1d(n_features, 32, kernel_size=3, dilation=1, padding=1), - nn.BatchNorm1d(32), - nn.ReLU(), - nn.Conv1d(32, 64, kernel_size=3, dilation=1, padding=1), - nn.BatchNorm1d(64), - nn.ReLU(), - ) - - # Branch 2: Medium wavelengths (3-10m) - dilation=4 - self.branch_medium = nn.Sequential( - nn.Conv1d(n_features, 32, kernel_size=3, dilation=4, padding=4), - nn.BatchNorm1d(32), - nn.ReLU(), - nn.Conv1d(32, 64, kernel_size=3, dilation=4, padding=4), - nn.BatchNorm1d(64), - nn.ReLU(), - ) - - # Branch 3: Long wavelengths (10-30m) - dilation=10 - self.branch_long = nn.Sequential( - nn.Conv1d(n_features, 32, kernel_size=3, dilation=10, padding=10), - nn.BatchNorm1d(32), - nn.ReLU(), - nn.Conv1d(32, 64, kernel_size=3, dilation=10, padding=10), - nn.BatchNorm1d(64), - nn.ReLU(), - ) - - # Fuse multi-scale features - self.fusion = nn.Sequential( - nn.Conv1d(192, 128, kernel_size=1), # 64*3 branches - nn.BatchNorm1d(128), - nn.ReLU(), - nn.AdaptiveAvgPool1d(8), # Downsample to fixed length - ) - - # Latent space mapping - self.flat_size = 128 * 8 - self.fc_mu = nn.Linear(self.flat_size, latent_dim) - self.fc_logvar = nn.Linear(self.flat_size, latent_dim) - - # Initialize weights - self._init_weights() - - def _init_weights(self): - """Xavier initialization for better convergence.""" - for m in self.modules(): - if isinstance(m, nn.Conv1d): - nn.init.xavier_uniform_(m.weight) - if m.bias is not None: - nn.init.zeros_(m.bias) - elif isinstance(m, nn.Linear): - nn.init.xavier_uniform_(m.weight) - if m.bias is not None: - nn.init.zeros_(m.bias) - - def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: - """ - Args: - x: (batch, seq_len, n_features) or (batch, n_features, seq_len) - Returns: - mu, logvar: (batch, latent_dim) - """ - if x.dim() == 2: - x = x.unsqueeze(0) - - # Ensure (batch, channels, seq_len) - if x.shape[1] == self.seq_len and x.shape[2] == self.n_features: - x = x.transpose(1, 2) - - # Multi-scale feature extraction - short = self.branch_short(x) - medium = self.branch_medium(x) - long = self.branch_long(x) - - # Concatenate along channel dimension - combined = torch.cat([short, medium, long], dim=1) # (batch, 192, seq_len) - - # Fuse and downsample - h = self.fusion(combined) # (batch, 128, 8) - h = h.flatten(1) # (batch, 1024) - - # Map to latent space - mu = self.fc_mu(h) - logvar = self.fc_logvar(h) - - return mu, logvar - - -class Decoder1D(nn.Module): - """ - 1D CNN decoder for sequence reconstruction. - Uses transposed convolutions for upsampling. - """ - - def __init__(self, n_features: int = 5, seq_len: int = 80, latent_dim: int = 16): - super().__init__() - - self.seq_len = seq_len - self.n_features = n_features - - # Project from latent space - self.decoder_input = nn.Sequential( - nn.Linear(latent_dim, 128 * 8), - nn.ReLU(), - ) - - # Upsample with transposed convolutions - self.decoder = nn.Sequential( - nn.Unflatten(1, (128, 8)), - nn.ConvTranspose1d(128, 128, kernel_size=3, stride=2, padding=1, output_padding=1), - nn.BatchNorm1d(128), - nn.ReLU(), - nn.ConvTranspose1d(128, 64, kernel_size=3, stride=2, padding=1, output_padding=1), - nn.BatchNorm1d(64), - nn.ReLU(), - nn.ConvTranspose1d(64, 32, kernel_size=3, stride=2, padding=1, output_padding=1), - nn.BatchNorm1d(32), - nn.ReLU(), - nn.Conv1d(32, n_features, kernel_size=3, padding=1), - ) - - self._init_weights() - - def _init_weights(self): - for m in self.modules(): - if isinstance(m, (nn.Conv1d, nn.ConvTranspose1d)): - nn.init.xavier_uniform_(m.weight) - if m.bias is not None: - nn.init.zeros_(m.bias) - elif isinstance(m, nn.Linear): - nn.init.xavier_uniform_(m.weight) - if m.bias is not None: - nn.init.zeros_(m.bias) - - def forward(self, z: torch.Tensor) -> torch.Tensor: - """ - Args: - z: (batch, latent_dim) - Returns: - recon_x: (batch, seq_len, n_features) - """ - if z.dim() == 1: - z = z.unsqueeze(0) - - h = self.decoder_input(z) # (batch, 1024) - recon = self.decoder(h) # (batch, n_features, length) - - # Ensure correct sequence length - if recon.shape[2] != self.seq_len: - recon = F.interpolate(recon, size=self.seq_len, mode='linear', align_corners=False) - - return recon.transpose(1, 2) # (batch, seq_len, n_features) - - -class EnhancedSequenceVAE(nn.Module): - """ - Enhanced Sequence VAE with: - - Multi-scale dilated encoder - - KL annealing support - - Huber loss for robust reconstruction - - Dual-path anomaly scoring - """ - - def __init__( - self, - seq_len: int = 80, - n_features: int = 5, - latent_dim: int = 16, - beta: float = 0.01, - use_kl_annealing: bool = True, - annealing_epochs: int = 10, - ): - super().__init__() - - self.seq_len = seq_len - self.n_features = n_features - self.latent_dim = latent_dim - self.beta = beta - self.use_kl_annealing = use_kl_annealing - self.annealing_epochs = annealing_epochs - - # Encoder and Decoder - self.encoder = DilatedEncoder1D(n_features, seq_len, latent_dim) - self.decoder = Decoder1D(n_features, seq_len, latent_dim) - - # For dual-path scoring - self.latent_mean: Optional[np.ndarray] = None - self.latent_cov_inv: Optional[np.ndarray] = None - - def reparameterize(self, mu: torch.Tensor, logvar: torch.Tensor) -> torch.Tensor: - """Reparameterization trick.""" - std = torch.exp(0.5 * logvar) - eps = torch.randn_like(std) - return mu + eps * std - - def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]: - """ - Forward pass. - - Args: - x: (batch, seq_len, n_features) - Returns: - recon_x, mu, logvar - """ - if x.dim() == 2: - x = x.unsqueeze(0) - - mu, logvar = self.encoder(x) - z = self.reparameterize(mu, logvar) - recon_x = self.decoder(z) - - return recon_x, mu, logvar - - def compute_loss( - self, - x: torch.Tensor, - recon_x: torch.Tensor, - mu: torch.Tensor, - logvar: torch.Tensor, - epoch: int = 0, - ) -> Dict[str, torch.Tensor]: - """ - Compute VAE loss with KL annealing. - - Args: - x: Input sequence - recon_x: Reconstructed sequence - mu: Latent mean - logvar: Latent log variance - epoch: Current epoch (for KL annealing) - - Returns: - Dictionary with total loss and components - """ - if x.dim() == 2: - x = x.unsqueeze(0) - if recon_x.dim() == 2: - recon_x = recon_x.unsqueeze(0) - - # Reconstruction loss (Huber for robustness to outliers) - recon_loss = F.smooth_l1_loss(recon_x, x, reduction='sum') / x.size(0) - - # KL divergence - kl_loss = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp()) / x.size(0) - - # KL annealing: gradually increase beta - if self.use_kl_annealing and epoch < self.annealing_epochs: - current_beta = self.beta * (epoch / self.annealing_epochs) - else: - current_beta = self.beta - - # Total loss - total_loss = recon_loss + current_beta * kl_loss - - return { - 'total': total_loss, - 'recon': recon_loss, - 'kl': kl_loss, - 'beta': current_beta, - } - - def fit_latent_distribution(self, normal_sequences: Union[torch.Tensor, np.ndarray]): - """ - Fit the latent distribution from normal sequences. - Used for Mahalanobis distance scoring. - Device-agnostic execution with numerical epsilon covariance stabilization. - """ - self.eval() - if isinstance(normal_sequences, np.ndarray): - normal_sequences = torch.tensor(normal_sequences, dtype=torch.float32) - if normal_sequences.dim() == 2: - normal_sequences = normal_sequences.unsqueeze(0) - - # Dynamically detect model device - device = next(self.parameters()).device - latents = [] - - with torch.no_grad(): - for i in range(0, len(normal_sequences), 64): - batch = normal_sequences[i:i + 64].to(device) - mu, _ = self.encoder(batch) - latents.append(mu.cpu()) - - latents_arr = torch.cat(latents, dim=0).numpy() - - # Compute mean and inverse covariance with epsilon stabilization - self.latent_mean = np.mean(latents_arr, axis=0) - cov = np.cov(latents_arr.T) - if cov.ndim == 0: - cov = np.array([[cov]]) - cov_reg = cov + np.eye(latents_arr.shape[1]) * 1e-5 - self.latent_cov_inv = np.linalg.inv(cov_reg) - - def compute_anomaly_score(self, sequence: Union[torch.Tensor, np.ndarray]) -> Dict[str, float]: - """ - Compute dual-path anomaly score (reconstruction + Mahalanobis). - Device-agnostic execution. - - Args: - sequence: (seq_len, n_features) or (batch, seq_len, n_features) - - Returns: - Dictionary with scores - """ - self.eval() - if isinstance(sequence, np.ndarray): - sequence = torch.tensor(sequence, dtype=torch.float32) - if sequence.dim() == 2: - sequence = sequence.unsqueeze(0) - - device = next(self.parameters()).device - sequence = sequence.to(device) - - with torch.no_grad(): - recon_x, mu, logvar = self.forward(sequence) - - # Path 1: Reconstruction error (Huber loss) - recon_error = F.smooth_l1_loss(recon_x, sequence, reduction='mean').item() - - # Path 2: Mahalanobis distance in latent space - mu_np = mu.cpu().numpy()[0] - - if self.latent_mean is not None and self.latent_cov_inv is not None: - diff = mu_np - self.latent_mean - mahalanobis_dist = float(np.sqrt(np.clip(diff @ self.latent_cov_inv @ diff.T, 0.0, None))) - else: - mahalanobis_dist = recon_error # Fallback - - return { - 'recon_error': recon_error, - 'mahalanobis_dist': mahalanobis_dist, - 'combined_score': 0.7 * recon_error + 0.3 * mahalanobis_dist, - } - - def score_to_probability(self, raw_score: float) -> float: - """ - Converts raw ensemble anomaly score into a calibrated anomaly probability in [0.0, 1.0]. - At raw_score == threshold_evt, returns 0.50 (the universal decision boundary). - Strict assertion guarantees 0.0 <= prob <= 1.0. - """ - t = float(getattr(self, "threshold_evt", 1.65)) - k = float(getattr(self, "steepness_k", 2.0)) - prob = float(1.0 / (1.0 + np.exp(-k * (raw_score - t) / max(t, 1e-4)))) - prob = float(np.clip(prob, 0.0, 1.0)) - assert 0.0 <= prob <= 1.0, f"Calibrated probability out of bounds: {prob}" - return prob - - def predict(self, sequence: Union[torch.Tensor, np.ndarray]) -> Dict[str, Any]: - """ - Inference on a sequence window returning calibrated anomaly probability. - """ - scores = self.compute_anomaly_score(sequence) - raw_score = scores.get('combined_score', 0.0) - calibrated_prob = self.score_to_probability(raw_score) - return { - 'raw_score': float(raw_score), - 'calibrated_prob': float(calibrated_prob), - 'fired': bool(calibrated_prob >= 0.50), - 'is_anomaly': bool(calibrated_prob >= 0.50), - 'recon_error': float(scores.get('recon_error', 0.0)), - 'mahalanobis_dist': float(scores.get('mahalanobis_dist', 0.0)), - 'threshold': 0.50, - } - - def predict_signals(self, sequence: Union[torch.Tensor, np.ndarray, Dict[str, np.ndarray]]) -> List[CalibratedSignal]: - """ - Run inference on sequence window and return contract-compliant CalibratedSignal. - """ - pred = self.predict(sequence) - raw_score = pred['raw_score'] - calibrated_prob = pred['calibrated_prob'] - is_anomaly = pred['is_anomaly'] - - signal = CalibratedSignal( - name="sequence_vae_anomaly", - stream_name="geometry_vae", - model_version="0.1.0", - signal_type=SignalType.GEOMETRY_NOVEL, - value=calibrated_prob, - raw_score=raw_score, - calibrated_prob=calibrated_prob, - threshold=0.50, - fired=is_anomaly, - is_anomaly=is_anomaly, - predicted_class=DefectClass.GEOMETRY_ANOMALY if is_anomaly else DefectClass.NORMAL, - bbox=None, - explanation={ - "combined_anomaly_score": round(raw_score, 4), - "reconstruction_error": round(pred.get("recon_error", raw_score), 4), - "mahalanobis_dist": round(pred.get("mahalanobis_dist", 0.0), 4), - "calibrated_prob": round(calibrated_prob, 4), - "threshold_evt": round(getattr(self, "threshold_evt", 1.65), 4), - "anomaly_detected": is_anomaly, - }, - metadata={ - "model_name": "enhanced_sequence_vae", - "latent_dim": self.latent_dim, - "combined_score": raw_score, - }, - ) - return [signal] - - @staticmethod - def fit_evt_threshold(normal_errors: Union[List[float], np.ndarray], target_fpr: float = 0.01) -> Dict[str, float]: - """ - Fit Extreme Value Theory (EVT) Peaks-Over-Threshold (POT) using Generalized Pareto Distribution (GPD). - Mathematically models the tail of normal geometry error distribution. - """ - from scipy.stats import genpareto - errors_arr = np.asarray(normal_errors, dtype=np.float64) - n_total = len(errors_arr) - if n_total < 20: - p99 = float(np.percentile(errors_arr, 99)) if n_total > 0 else 1.0 - return {"threshold": p99, "shape": 0.0, "scale": 1.0, "init_threshold": p99} - - # 1. Take top 10% (P90) as extreme tail - threshold_init = float(np.percentile(errors_arr, 90.0)) - tail_excess = errors_arr[errors_arr > threshold_init] - threshold_init - - if len(tail_excess) < 5 or np.all(tail_excess == 0): - p99 = float(np.percentile(errors_arr, 99.0)) - return {"threshold": p99, "shape": 0.0, "scale": 1.0, "init_threshold": threshold_init} - - # 2. Fit Generalized Pareto Distribution (GPD) to the tail - try: - shape, loc, scale = genpareto.fit(tail_excess, floc=0) - n_tail = len(tail_excess) - prob_excess = n_tail / n_total - - # 3. Calculate exact EVT quantile for target_fpr - if abs(shape) < 1e-4: # Exponential limit - evt_threshold = threshold_init - scale * np.log(target_fpr / prob_excess) - else: - term = (target_fpr / prob_excess) ** (-shape) - 1.0 - evt_threshold = threshold_init + (scale / shape) * term - - evt_threshold = float(np.clip(evt_threshold, threshold_init, None)) - except Exception: - evt_threshold = float(np.percentile(errors_arr, 99.0)) - shape, scale = 0.0, 1.0 - - return { - "threshold": evt_threshold, - "shape": float(shape), - "scale": float(scale), - "init_threshold": float(threshold_init), - } - - -class OverlappingWindowInference: - """ - Inference with overlapping windows to prevent boundary-splitting. - """ - - def __init__(self, model: EnhancedSequenceVAE, window_size: int = 80, overlap: float = 0.5): - self.model = model - self.window_size = window_size - self.stride = int(window_size * (1 - overlap)) - - def predict(self, sequence: torch.Tensor) -> Dict[str, Union[float, list, int]]: - """ - Predict with overlapping windows. - """ - seq_len = len(sequence) - - if seq_len < self.window_size: - padding = torch.zeros(self.window_size - seq_len, sequence.shape[1], dtype=sequence.dtype, device=sequence.device) - sequence = torch.cat([sequence, padding], dim=0) - seq_len = len(sequence) - - windows = [] - positions = [] - - for start in range(0, seq_len - self.window_size + 1, self.stride): - window = sequence[start:start + self.window_size] - windows.append(window) - positions.append(start) - - if not windows: - windows = [sequence[:self.window_size]] - positions = [0] - - scores = [] - for window in windows: - score = self.model.compute_anomaly_score(window) - scores.append(score.get('combined_score', 0.0)) - - max_score = max(scores) - calibrated_prob = self.model.score_to_probability(max_score) - - return { - 'ensemble': max_score, - 'calibrated_prob': calibrated_prob, - 'fired': bool(calibrated_prob >= 0.50), - 'all_scores': scores, - 'positions': positions, - 'max_position': positions[scores.index(max_score)], - } diff --git a/ml/models/geometry/weights/fault_classifier.pt b/ml/models/geometry/weights/fault_classifier.pt deleted file mode 100644 index 3bfe65925fd496a855a6da134aeb1c2b7a0ea9a8..0000000000000000000000000000000000000000 --- a/ml/models/geometry/weights/fault_classifier.pt +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6a9e6c0cee15e32cb68b4ebe2280a1559fb63071afe1c33b974ac14602c1eec6 -size 5461103 diff --git a/ml/models/geometry/weights/sequence_vae.pt b/ml/models/geometry/weights/sequence_vae.pt deleted file mode 100644 index 7361869aec0c33fd02826db72e2cea5b9009b46e..0000000000000000000000000000000000000000 --- a/ml/models/geometry/weights/sequence_vae.pt +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b3fa0c60f67e4c929d886dfb82af2b7992d637eae1d44f0caadbdf6a3b8e2766 -size 740253 diff --git a/ml/models/vision/__init__.py b/ml/models/vision/__init__.py deleted file mode 100644 index 3b206313a087fe3d56bea4a4ee57d5e0c68b337a..0000000000000000000000000000000000000000 --- a/ml/models/vision/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Vision models package marker. diff --git a/ml/models/vision/__pycache__/__init__.cpython-312.pyc b/ml/models/vision/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index a3f9edb1446d8635fb0b32d252144de9127cfc86..0000000000000000000000000000000000000000 Binary files a/ml/models/vision/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/ml/models/vision/__pycache__/anomaly.cpython-312.pyc b/ml/models/vision/__pycache__/anomaly.cpython-312.pyc deleted file mode 100644 index 18ecf0e64ea5e518c6d6da1343c6fa8aa5d32453..0000000000000000000000000000000000000000 Binary files a/ml/models/vision/__pycache__/anomaly.cpython-312.pyc and /dev/null differ diff --git a/ml/models/vision/__pycache__/detector.cpython-312.pyc b/ml/models/vision/__pycache__/detector.cpython-312.pyc deleted file mode 100644 index d7e3e144441b697bef655f81fa7c5af5ad78be40..0000000000000000000000000000000000000000 Binary files a/ml/models/vision/__pycache__/detector.cpython-312.pyc and /dev/null differ diff --git a/ml/models/vision/__pycache__/patchcore_enhanced.cpython-312.pyc b/ml/models/vision/__pycache__/patchcore_enhanced.cpython-312.pyc deleted file mode 100644 index 4e68cc2921a74266faf55f1a552c47d9b17ea903..0000000000000000000000000000000000000000 Binary files a/ml/models/vision/__pycache__/patchcore_enhanced.cpython-312.pyc and /dev/null differ diff --git a/ml/models/vision/anomaly.py b/ml/models/vision/anomaly.py deleted file mode 100644 index ca19476ce99e16204a1fce421c402ce2e058af85..0000000000000000000000000000000000000000 --- a/ml/models/vision/anomaly.py +++ /dev/null @@ -1,425 +0,0 @@ -""" -PatchCore unsupervised visual anomaly detector for novel surface defects (tc.v1 SOTA). -Features: -- Multi-scale patch feature extraction (layer2 + layer3 with adaptive pooling) -- Dimension reduction via SparseRandomProjection -- High-performance FAISS L2 memory bank search -- Gaussian-smoothed anomaly heatmap and bounding box localization -- Calibrated probability scaling via SigmoidDistanceCalibrator -- Full contract compliance with CalibratedSignal and ModelRegistry -""" - -import os -import json -from pathlib import Path -from typing import List, Tuple, Optional, Union, Dict, Any -import numpy as np -import torch -import torch.nn as nn -import torch.nn.functional as F -from PIL import Image - -try: - import torchvision.models as models - from torchvision import transforms -except ImportError: - models = None - transforms = None - -try: - import faiss -except ImportError: - faiss = None - -try: - import cv2 -except ImportError: - cv2 = None - -from sklearn.random_projection import SparseRandomProjection - -from ml.core.schema import DefectClass, CalibratedSignal, SignalType -from ml.core.registry import register_model, ModelRegistry -from ml.calibration.patchcore_scale import SigmoidDistanceCalibrator - - -def get_default_transform(img_size: int = 224): - """Default PyTorch image normalization transform for ResNet/WideResNet backbones.""" - if transforms is None: - return None - return transforms.Compose([ - transforms.Resize((img_size, img_size)), - transforms.ToTensor(), - transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), - ]) - - -@register_model("patchcore_anomaly_detector") -class PatchCoreAnomalyDetector: - """ - PatchCore anomaly detector for novel/unseen visual defects. - Extracts multi-scale patch features, queries a normal baseline memory bank via FAISS, - and produces calibrated anomaly probabilities and localized bounding boxes. - """ - - def __init__( - self, - backbone_name: str = "wide_resnet50_2", - fallback_backbone: str = "resnet18", - device: str = "cpu", - patch_size: int = 3, - patch_sizes: Optional[List[int]] = None, - k_nearest: int = 1, - sigma: float = 4.0, - confidence_threshold: float = 0.50, - checkpoint_path: Optional[Union[str, Path]] = None, - calibration_path: Optional[Union[str, Path]] = None, - dimension_reduction: bool = False, - target_dim: int = 128, - ): - self.backbone_name = backbone_name - self.fallback_backbone = fallback_backbone - self.device = torch.device(device if torch.cuda.is_available() and device != "cpu" else "cpu") - self.patch_size = patch_size - self.patch_sizes = patch_sizes or [patch_size] - self.k_nearest = k_nearest - self.sigma = sigma - self.threshold = confidence_threshold - self.dimension_reduction = dimension_reduction - self.target_dim = target_dim - - self.backbone: Optional[nn.Module] = None - self.feature_dim: int = 1536 if "wide" in backbone_name else 384 - self.memory_bank: Optional[np.ndarray] = None - self.faiss_index: Any = None - self.nn_model: Any = None - self.projector: Optional[Any] = None - self.enhanced_model: Optional[Any] = None - self.calibrator = SigmoidDistanceCalibrator() - self.transform = get_default_transform(224) - - # Initialize neural backbone - self._init_backbone() - - # Resolve weights and calibration from ModelRegistry if not explicitly provided - if checkpoint_path is None: - enhanced_dir = ModelRegistry.ROOT / "artifacts" / "checkpoints" / "vision" / "patchcore_enhanced" - if enhanced_dir.exists() and (enhanced_dir / "config.json").exists(): - checkpoint_path = enhanced_dir - else: - default_ckpt = ModelRegistry.get_trained_weights("vision", "patchcore_memory_bank.npz") - if default_ckpt.exists(): - checkpoint_path = default_ckpt - - if calibration_path is None: - default_cal = ModelRegistry.get_calibration_path("patchcore") - if default_cal.exists(): - calibration_path = default_cal - - if checkpoint_path and os.path.exists(checkpoint_path): - self.load_memory_bank(checkpoint_path) - - if calibration_path and os.path.exists(calibration_path): - self.calibrator = SigmoidDistanceCalibrator.load(calibration_path) - - def _init_backbone(self): - """Load frozen pretrained feature extractor with robust offline fallback.""" - if models is None: - return - - base = None - # Try primary backbone - try: - if self.backbone_name == "wide_resnet50_2": - weights = models.Wide_ResNet50_2_Weights.DEFAULT - base = models.wide_resnet50_2(weights=weights) - self.feature_dim = 1536 - elif self.backbone_name == "resnet50": - weights = models.ResNet50_Weights.DEFAULT - base = models.resnet50(weights=weights) - self.feature_dim = 1536 - else: - weights = models.ResNet18_Weights.DEFAULT - base = models.resnet18(weights=weights) - self.feature_dim = 384 - except Exception as e: - # Fallback 1: Try ResNet18 with pretrained weights - try: - weights = models.ResNet18_Weights.DEFAULT - base = models.resnet18(weights=weights) - self.feature_dim = 384 - except Exception: - # Fallback 2: Offline local weights (no network download required) - if self.backbone_name == "wide_resnet50_2": - base = models.wide_resnet50_2(weights=None) - self.feature_dim = 1536 - else: - base = models.resnet18(weights=None) - self.feature_dim = 384 - - for param in base.parameters(): - param.requires_grad = False - base.eval() - self.backbone = base.to(self.device) - - def extract_features(self, x: torch.Tensor, patch_size: Optional[int] = None) -> Tuple[torch.Tensor, Tuple[int, int]]: - """ - Extract multi-scale patch features from layer2 and layer3. - Returns flattened patch feature tensor (N_patches, Feature_Dim) and (H_feature, W_feature). - """ - if self.backbone is None: - raise RuntimeError("Backbone is not initialized.") - - ps = patch_size or self.patch_size - - x = self.backbone.conv1(x) - x = self.backbone.bn1(x) - x = self.backbone.relu(x) - x = self.backbone.maxpool(x) - - x = self.backbone.layer1(x) - l2 = self.backbone.layer2(x) - l3 = self.backbone.layer3(l2) - - # Patch neighborhood pooling - pool = nn.AvgPool2d(kernel_size=ps, stride=1, padding=ps // 2) - p2 = pool(l2) - p3 = pool(l3) - - # Resize layer3 to match layer2 spatial dimensions - target_size = p2.shape[-2:] - p3_resized = F.interpolate(p3, size=target_size, mode="bilinear", align_corners=False) - - features = torch.cat([p2, p3_resized], dim=1) - b, c, h, w = features.shape - self.feature_dim = c - - features = features.permute(0, 2, 3, 1).contiguous().view(-1, c) - return features, (h, w) - - def set_memory_bank(self, memory_bank: np.ndarray, projector: Optional[Any] = None): - """Set the memory bank array and build the FAISS nearest-neighbor search index.""" - self.memory_bank = np.ascontiguousarray(memory_bank, dtype=np.float32) - dim = self.memory_bank.shape[1] - self.feature_dim = dim - self.projector = projector - - if faiss is not None: - index = faiss.IndexFlatL2(dim) - index.add(self.memory_bank) - self.faiss_index = index - else: - from sklearn.neighbors import NearestNeighbors - nn_model = NearestNeighbors(n_neighbors=self.k_nearest, metric="euclidean", algorithm="auto") - nn_model.fit(self.memory_bank) - self.nn_model = nn_model - - def save_memory_bank(self, filepath: Union[str, Path]): - """Save the memory bank array and metadata to .npz file.""" - if self.memory_bank is None: - raise ValueError("No memory bank to save.") - p = Path(filepath) - p.parent.mkdir(parents=True, exist_ok=True) - save_dict = { - "memory_bank": self.memory_bank, - "patch_size": self.patch_size, - "feature_dim": self.feature_dim, - } - if self.projector is not None and hasattr(self.projector, "components_"): - save_dict["projector_components"] = self.projector.components_.toarray() if hasattr(self.projector.components_, "toarray") else self.projector.components_ - np.savez_compressed(p, **save_dict) - - def load_memory_bank(self, filepath: Union[str, Path]): - """Load memory bank from .npz file or EnhancedPatchCore checkpoint directory.""" - p = Path(filepath) - if not p.exists(): - return - - # Check if directory checkpoint (EnhancedPatchCore) - if p.is_dir() and ((p / "config.json").exists() or list(p.glob("memory_bank_*.index")) or list(p.glob("memory_bank_*.npz"))): - try: - from ml.models.vision.patchcore_enhanced import EnhancedPatchCore - enhanced = EnhancedPatchCore( - backbone=self.backbone_name, - device=str(self.device), - threshold=self.threshold - ) - enhanced.load(p) - self.enhanced_model = enhanced - # Also expose primary memory bank from enhanced model if available - if enhanced.memory_banks: - first_scale = list(enhanced.memory_banks.keys())[0] - self.memory_bank = enhanced.memory_banks[first_scale]["coreset"] - self.faiss_index = enhanced.memory_banks[first_scale]["index"] - return - except Exception as e: - pass - - data = np.load(p, allow_pickle=True) - if "memory_bank" in data: - bank = data["memory_bank"] - self.set_memory_bank(bank) - if "patch_size" in data: - self.patch_size = int(data["patch_size"]) - - def predict_raw(self, image: Union[np.ndarray, Image.Image]) -> Tuple[float, np.ndarray]: - """ - Run PatchCore inference on a single image. - Returns: - max_distance: float (raw nearest-neighbor L2 distance) - anomaly_map: 2D numpy array (H, W) smoothed anomaly heatmap - """ - if self.enhanced_model is not None and self.enhanced_model.memory_banks: - scores, ens_dist, anom_map = self.enhanced_model.predict_raw_multiscale(image) - return ens_dist, anom_map - - if self.memory_bank is None: - return 0.0, np.zeros((224, 224), dtype=np.float32) - - if isinstance(image, (str, Path)): - if cv2 is not None: - img_bgr = cv2.imread(str(image)) - if img_bgr is not None: - image = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) - else: - return 0.0, np.zeros((224, 224), dtype=np.float32) - else: - image = Image.open(str(image)).convert("RGB") - - if isinstance(image, np.ndarray): - if image.ndim == 2: - pil_img = Image.fromarray(image).convert("RGB") - elif image.shape[2] == 3: - pil_img = Image.fromarray(image) - else: - pil_img = Image.fromarray(image[:, :, :3]) - elif isinstance(image, Image.Image): - pil_img = image.convert("RGB") - else: - pil_img = Image.fromarray(np.asarray(image)).convert("RGB") - - orig_w, orig_h = pil_img.size - tensor = self.transform(pil_img).unsqueeze(0).to(self.device) - - with torch.no_grad(): - patch_features, (fh, fw) = self.extract_features(tensor) - feat_np = patch_features.cpu().numpy().astype(np.float32) - - if self.projector is not None and feat_np.shape[1] != self.memory_bank.shape[1]: - try: - feat_np = self.projector.transform(feat_np).astype(np.float32) - except Exception: - pass - - if feat_np.shape[1] != self.memory_bank.shape[1]: - return 0.0, np.zeros((orig_h, orig_w), dtype=np.float32) - - # Query FAISS nearest neighbor - if self.faiss_index is not None: - distances, _ = self.faiss_index.search(feat_np, self.k_nearest) - patch_scores = np.sqrt(np.maximum(0.0, distances[:, 0])) - elif self.nn_model is not None: - distances, _ = self.nn_model.kneighbors(feat_np, n_neighbors=self.k_nearest) - patch_scores = distances[:, 0] - else: - diff = feat_np[:, None, :] - self.memory_bank[None, :, :] - dists = np.linalg.norm(diff, axis=-1) - patch_scores = np.min(dists, axis=-1) - - max_distance = float(np.max(patch_scores)) - score_map = patch_scores.reshape(fh, fw) - - # Gaussian smoothing - if cv2 is not None: - smoothed = cv2.GaussianBlur(score_map, (0, 0), sigmaX=self.sigma, sigmaY=self.sigma) - resized_map = cv2.resize(smoothed, (orig_w, orig_h), interpolation=cv2.INTER_LINEAR) - else: - from scipy.ndimage import gaussian_filter, zoom - smoothed = gaussian_filter(score_map, sigma=self.sigma) - zoom_h = orig_h / fh - zoom_w = orig_w / fw - resized_map = zoom(smoothed, (zoom_h, zoom_w), order=1) - - return max_distance, resized_map - - def extract_bounding_box(self, anomaly_map: np.ndarray, threshold: float = 0.50) -> Optional[Tuple[float, float, float, float]]: - """ - Extract bounding box around the most anomalous region in the heatmap. - Returns (x1, y1, x2, y2) in pixel coordinates or None. - """ - h, w = anomaly_map.shape[:2] - denom = anomaly_map.max() - anomaly_map.min() + 1e-8 - norm_map = (anomaly_map - anomaly_map.min()) / denom - mask = (norm_map > threshold).astype(np.uint8) - - if cv2 is not None: - contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - if not contours: - max_idx = np.unravel_index(np.argmax(anomaly_map), anomaly_map.shape) - cy, cx = max_idx[0], max_idx[1] - half = min(h, w) * 0.1 - return (max(0.0, cx - half), max(0.0, cy - half), min(float(w), cx + half), min(float(h), cy + half)) - - largest = max(contours, key=cv2.contourArea) - x, y, bw, bh = cv2.boundingRect(largest) - return (float(x), float(y), float(x + bw), float(y + bh)) - else: - y_indices, x_indices = np.where(mask > 0) - if len(x_indices) == 0: - return None - return (float(x_indices.min()), float(y_indices.min()), float(x_indices.max()), float(y_indices.max())) - - def predict(self, frame: Union[np.ndarray, str, Path, Any]) -> List[CalibratedSignal]: - """ - Run PatchCore inference and return contract-compliant CalibratedSignal. - Frame should be a HxWxC uint8 NumPy array, PIL Image, or file path. - """ - if isinstance(frame, (str, Path)): - if cv2 is not None: - img_bgr = cv2.imread(str(frame)) - if img_bgr is None: - return [] - frame = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) - else: - from PIL import Image - try: - frame = np.array(Image.open(str(frame)).convert("RGB")) - except Exception: - return [] - - if self.enhanced_model is not None and hasattr(self.enhanced_model, "memory_banks") and self.enhanced_model.memory_banks: - if hasattr(self.enhanced_model, "predict_signals"): - return self.enhanced_model.predict_signals(frame) - - if self.memory_bank is None: - return [] - - h, w = frame.shape[:2] if hasattr(frame, "shape") else (224, 224) - raw_distance, anomaly_map = self.predict_raw(frame) - - calibrated_score = self.calibrator.scale(raw_distance) - fired = bool(calibrated_score >= self.threshold) - - bbox = None - if fired: - bbox = self.extract_bounding_box(anomaly_map, threshold=0.45) - - signal = CalibratedSignal( - stream_name="patchcore_anomaly", - raw_score=raw_distance, - calibrated_prob=calibrated_score, - predicted_class=DefectClass.VISUAL_ANOMALY, - is_anomaly=fired, - signal_type=SignalType.VISUAL_NOVEL, - threshold=self.threshold, - bbox=bbox, - explanation=f"PatchCore detected novel visual anomaly with score {calibrated_score:.1%} (raw L2: {raw_distance:.2f})", - metadata={ - "raw_distance": raw_distance, - "calibrated_score": calibrated_score, - "image_width": w, - "image_height": h, - }, - ) - - return [signal] diff --git a/ml/models/vision/detector.py b/ml/models/vision/detector.py deleted file mode 100644 index e4efae43267c7f16eb83c5d93d18280e4e78d844..0000000000000000000000000000000000000000 --- a/ml/models/vision/detector.py +++ /dev/null @@ -1,310 +0,0 @@ -# YOLOv8 wrapper with SAHI tiling, NMS merging, and contract-compliant CalibratedSignal outputs (tc.v1). - -from typing import List, Dict, Any, Optional, Tuple, Union -import os -from pathlib import Path -import numpy as np - -try: - from ultralytics import YOLO -except ImportError: - YOLO = None - -from ml.core.schema import DefectClass, CalibratedSignal, SignalType -from ml.core.registry import register_model, ModelRegistry - - -CLASS_NAME_MAP: Dict[str, DefectClass] = { - "missing_fastener": DefectClass.MISSING_FASTENER, - "damaged_fastener": DefectClass.DAMAGED_FASTENER, - "defective_clip": DefectClass.DAMAGED_FASTENER, - "crack": DefectClass.CRACK, - "rail_crack": DefectClass.CRACK, - "spalling": DefectClass.SPALLING, - "squat": DefectClass.SQUAT, - "corrugation": DefectClass.CORRUGATION, - "obstruction": DefectClass.OBSTRUCTION, -} - - -def nms_boxes( - boxes: List[Tuple[float, float, float, float]], - scores: List[float], - iou_thresh: float = 0.50, -) -> List[int]: - """Pure NumPy Non-Maximum Suppression for global coordinate deduplication.""" - if len(boxes) == 0: - return [] - - boxes_arr = np.array(boxes, dtype=np.float32) - scores_arr = np.array(scores, dtype=np.float32) - - x1 = boxes_arr[:, 0] - y1 = boxes_arr[:, 1] - x2 = boxes_arr[:, 2] - y2 = boxes_arr[:, 3] - areas = np.maximum(0.0, x2 - x1) * np.maximum(0.0, y2 - y1) - - order = scores_arr.argsort()[::-1] - keep = [] - - while order.size > 0: - i = order[0] - keep.append(int(i)) - if order.size == 1: - break - - xx1 = np.maximum(x1[i], x1[order[1:]]) - yy1 = np.maximum(y1[i], y1[order[1:]]) - xx2 = np.minimum(x2[i], x2[order[1:]]) - yy2 = np.minimum(y2[i], y2[order[1:]]) - - w = np.maximum(0.0, xx2 - xx1) - h = np.maximum(0.0, yy2 - yy1) - inter = w * h - union = areas[i] + areas[order[1:]] - inter - ovr = inter / np.maximum(1e-6, union) - - inds = np.where(ovr <= iou_thresh)[0] - order = order[inds + 1] - - return keep - - -@register_model("yolov8_defect_detector") -class YOLOv8DefectDetector: - """Production wrapper for YOLOv8 visual defect detection (what + where).""" - - def __init__( - self, - weights_path: Optional[Union[str, Path]] = None, - confidence_threshold: float = 0.25, - iou_threshold: float = 0.60, - device: str = "cpu", - use_sahi: bool = False, - slice_size: Tuple[int, int] = (480, 480), - ): - self.conf_threshold = confidence_threshold - self.iou_threshold = iou_threshold - self.device = device - self.use_sahi = use_sahi - self.slice_size = slice_size - self.model = None - - # Resolve weights from ModelRegistry if not explicitly provided - if weights_path is None: - # Check trained checkpoint first, then fallback to base weights - trained_ckpt = ModelRegistry.get_trained_weights("vision", "yolov8n_rail_best.pt") - alias_ckpt = ModelRegistry.get_trained_weights("vision", "yolo_rail_v0.1.pt") - base_weights = ModelRegistry.get_base_weights("vision", "yolov8n.pt") - if trained_ckpt.exists(): - weights_path = trained_ckpt - elif alias_ckpt.exists(): - weights_path = alias_ckpt - elif base_weights.exists(): - weights_path = base_weights - - self.weights_path = str(weights_path) if weights_path else None - if self.weights_path and os.path.exists(self.weights_path) and YOLO is not None: - self.load_weights(self.weights_path) - - def load_weights(self, path: Union[str, Path]): - """Load pretrained or fine-tuned YOLOv8 model weights (.pt or .onnx).""" - if YOLO is None: - raise RuntimeError("Ultralytics package is not installed.") - self.weights_path = str(path) - self.model = YOLO(self.weights_path) - - def predict(self, frame: np.ndarray) -> List[CalibratedSignal]: - """ - Run object detection on an image frame and return contract-compliant CalibratedSignal items. - Frame should be a HxWxC uint8 NumPy array (BGR or RGB). - """ - if self.use_sahi: - return self.predict_sahi( - frame, - slice_height=self.slice_size[0], - slice_width=self.slice_size[1], - ) - - signals: List[CalibratedSignal] = [] - if self.model is None: - return signals - - h, w = frame.shape[:2] - - try: - results = self.model.predict( - source=frame, - conf=self.conf_threshold, - iou=self.iou_threshold, - device=self.device, - verbose=False, - ) - except Exception: - return signals - - if not results or len(results) == 0: - return signals - - res = results[0] - if res.boxes is None or len(res.boxes) == 0: - return signals - - for box in res.boxes: - cls_idx = int(box.cls[0]) - raw_cls_name = res.names.get(cls_idx, str(cls_idx)).lower() - conf = float(box.conf[0]) - xyxy = box.xyxy[0].tolist() # [x1, y1, x2, y2] - - defect_enum = CLASS_NAME_MAP.get(raw_cls_name, DefectClass.UNCLASSIFIED) - - signal = CalibratedSignal( - stream_name="vision_detector", - raw_score=conf, - calibrated_prob=conf, # will be scaled by TemperatureScaler downstream - predicted_class=defect_enum, - is_anomaly=True, - signal_type=SignalType.VISUAL_KNOWN, - threshold=self.conf_threshold, - bbox=(float(xyxy[0]), float(xyxy[1]), float(xyxy[2]), float(xyxy[3])), - explanation=f"YOLOv8 detected {raw_cls_name} with confidence {conf:.1%}", - metadata={"raw_class": raw_cls_name, "box_xyxy": xyxy, "image_width": w, "image_height": h}, - ) - signals.append(signal) - - return signals - - def predict_sahi( - self, - frame: np.ndarray, - slice_height: int = 480, - slice_width: int = 480, - overlap_ratio: float = 0.2, - ) -> List[CalibratedSignal]: - """ - Slicing Aided Hyper Inference (SAHI): slices high-res frames into overlapping tiles - to detect millimeter-scale cracks and small fastener clips, then merges overlapping - bounding boxes via global NMS. - """ - h, w = frame.shape[:2] - step_y = int(slice_height * (1.0 - overlap_ratio)) - step_x = int(slice_width * (1.0 - overlap_ratio)) - - raw_signals: List[CalibratedSignal] = [] - - # Run detection on full image first for large objects / context - full_signals = [] - if self.model is not None: - try: - full_res = self.model.predict( - source=frame, - conf=self.conf_threshold, - iou=self.iou_threshold, - device=self.device, - verbose=False, - ) - if full_res and len(full_res) > 0 and full_res[0].boxes is not None: - res = full_res[0] - for box in res.boxes: - cls_idx = int(box.cls[0]) - raw_cls_name = res.names.get(cls_idx, str(cls_idx)).lower() - conf = float(box.conf[0]) - xyxy = box.xyxy[0].tolist() - defect_enum = CLASS_NAME_MAP.get(raw_cls_name, DefectClass.UNCLASSIFIED) - full_signals.append( - CalibratedSignal( - stream_name="vision_detector", - raw_score=conf, - calibrated_prob=conf, - predicted_class=defect_enum, - is_anomaly=True, - signal_type=SignalType.VISUAL_KNOWN, - threshold=self.conf_threshold, - bbox=(float(xyxy[0]), float(xyxy[1]), float(xyxy[2]), float(xyxy[3])), - explanation=f"YOLOv8 detected {raw_cls_name} with confidence {conf:.1%}", - metadata={"raw_class": raw_cls_name, "box_xyxy": xyxy, "image_width": w, "image_height": h, "sahi_tile": "full"}, - ) - ) - except Exception: - pass - - raw_signals.extend(full_signals) - - # Slice frame into overlapping tiles - y_starts = list(range(0, max(1, h - slice_height + 1), step_y)) - if y_starts and y_starts[-1] + slice_height < h: - y_starts.append(h - slice_height) - - x_starts = list(range(0, max(1, w - slice_width + 1), step_x)) - if x_starts and x_starts[-1] + slice_width < w: - x_starts.append(w - slice_width) - - for y in y_starts: - for x in x_starts: - tile = frame[y : y + slice_height, x : x + slice_width] - if self.model is None: - continue - try: - results = self.model.predict( - source=tile, - conf=self.conf_threshold, - iou=self.iou_threshold, - device=self.device, - verbose=False, - ) - except Exception: - continue - - if not results or len(results) == 0: - continue - res = results[0] - if res.boxes is None or len(res.boxes) == 0: - continue - - for box in res.boxes: - cls_idx = int(box.cls[0]) - raw_cls_name = res.names.get(cls_idx, str(cls_idx)).lower() - conf = float(box.conf[0]) - lx1, ly1, lx2, ly2 = box.xyxy[0].tolist() - - gx1 = float(lx1 + x) - gy1 = float(ly1 + y) - gx2 = float(lx2 + x) - gy2 = float(ly2 + y) - - defect_enum = CLASS_NAME_MAP.get(raw_cls_name, DefectClass.UNCLASSIFIED) - sig = CalibratedSignal( - stream_name="vision_detector", - raw_score=conf, - calibrated_prob=conf, - predicted_class=defect_enum, - is_anomaly=True, - signal_type=SignalType.VISUAL_KNOWN, - threshold=self.conf_threshold, - bbox=(gx1, gy1, gx2, gy2), - explanation=f"YOLOv8 SAHI detected {raw_cls_name} with confidence {conf:.1%}", - metadata={"raw_class": raw_cls_name, "box_xyxy": [gx1, gy1, gx2, gy2], "image_width": w, "image_height": h, "sahi_tile": f"({x},{y})"}, - ) - raw_signals.append(sig) - - if not raw_signals: - return [] - - # Deduplicate signals across tile boundaries using NMS per defect class - final_signals: List[CalibratedSignal] = [] - classes_present = set(s.predicted_class for s in raw_signals) - - for c_enum in classes_present: - class_signals = [s for s in raw_signals if s.predicted_class == c_enum and s.bbox is not None] - if not class_signals: - continue - - boxes = [s.bbox for s in class_signals] - scores = [s.raw_score for s in class_signals] - keep_indices = nms_boxes(boxes, scores, iou_thresh=self.iou_threshold) - - for idx in keep_indices: - final_signals.append(class_signals[idx]) - - return final_signals diff --git a/ml/models/vision/patchcore_enhanced.py b/ml/models/vision/patchcore_enhanced.py deleted file mode 100644 index 7a220cfb57c7213c3370f07fde193e378adeee1a..0000000000000000000000000000000000000000 --- a/ml/models/vision/patchcore_enhanced.py +++ /dev/null @@ -1,742 +0,0 @@ -""" -Enhanced PatchCore implementation with: -- Multi-scale feature extraction (layer2 + layer3 with adaptive pooling) -- Adaptive patch sizes (e.g. 3x3, 5x5, 7x7) -- Greedy Minimax / k-center coreset sampling -- SparseRandomProjection dimension reduction -- Ensemble FAISS memory banks -- Statistical P99 + Nelder-Mead sigmoid probability calibration -- Anomaly heatmap generation & bounding box localization -- Full contract compliance with TrackChain CalibratedSignal schema -""" -import os -import sys -import json -import numpy as np -from pathlib import Path -from typing import List, Tuple, Dict, Optional, Union, Any -from PIL import Image - -import torch -import torch.nn as nn -import torch.nn.functional as F -from torchvision import models, transforms - -try: - import faiss -except ImportError: - faiss = None - -try: - import cv2 -except ImportError: - cv2 = None - -from sklearn.random_projection import SparseRandomProjection -from scipy.special import expit -from scipy.optimize import minimize -from tqdm import tqdm - -from ml.core.schema import DefectClass, CalibratedSignal, SignalType -from ml.core.registry import register_model, ModelRegistry - - -def get_default_transform(img_size: int = 224): - """Image normalization transform for ResNet/WideResNet backbones.""" - return transforms.Compose([ - transforms.Resize((img_size, img_size)), - transforms.ToTensor(), - transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), - ]) - - -class ImageDataset(torch.utils.data.Dataset): - """Image dataset loader for PatchCore memory bank extraction.""" - - def __init__(self, image_paths: List[Path], transform=None): - self.image_paths = [Path(p) for p in image_paths] - self.transform = transform or get_default_transform(224) - - def __len__(self): - return len(self.image_paths) - - def __getitem__(self, idx): - img_path = self.image_paths[idx] - try: - pil_img = Image.open(img_path).convert("RGB") - return self.transform(pil_img) - except Exception: - # Fallback zero tensor - return torch.zeros((3, 224, 224), dtype=torch.float32) - - -@register_model("enhanced_patchcore") -class EnhancedPatchCore: - """ - Enhanced PatchCore anomaly detector with multi-scale feature extraction, - ensemble memory banks, FAISS indexing, and calibrated probability scaling. - """ - - def __init__( - self, - backbone: str = "wide_resnet50_2", - layers: Optional[List[str]] = None, - patch_sizes: Optional[List[int]] = None, - coreset_ratio: float = 0.08, - dimension_reduction: bool = True, - target_dim: int = 128, - device: str = "auto", - patch_weights: Optional[Dict[str, float]] = None, - sigma: float = 4.0, - threshold: float = 0.50, - ): - self.backbone_name = backbone - self.layers = layers or ["layer2", "layer3"] - self.patch_sizes = patch_sizes or [3, 5, 7] - self.coreset_ratio = coreset_ratio - self.dimension_reduction = dimension_reduction - self.target_dim = target_dim - self.sigma = sigma - self.threshold = threshold - - # Default patch ensemble weights - if patch_weights: - self.patch_weights = patch_weights - else: - n_scales = len(self.patch_sizes) - self.patch_weights = {f"patch_{ps}": 1.0 / n_scales for ps in self.patch_sizes} - - # Device resolution - if device == "auto": - self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - else: - self.device = torch.device(device if torch.cuda.is_available() and device != "cpu" else "cpu") - - # Hooks and memory banks - self.features: Dict[str, torch.Tensor] = {} - self.hooks = [] - self.backbone: Optional[nn.Module] = None - self.feature_dim = 1536 if "wide" in backbone else (512 if backbone == "resnet50" else 384) - - self.memory_banks: Dict[int, Dict[str, Any]] = {} - self.projectors: Dict[int, Any] = {} - self.calibration_params: Dict[str, Dict[str, Any]] = {} - - self.transform = get_default_transform(224) - - # Initialize neural backbone - self._init_backbone() - - def _init_backbone(self): - """Initialize frozen pretrained feature extractor with hooks.""" - # Clear existing hooks - for h in self.hooks: - h.remove() - self.hooks = [] - - base = None - try: - if self.backbone_name == "wide_resnet50_2": - weights = models.Wide_ResNet50_2_Weights.DEFAULT - base = models.wide_resnet50_2(weights=weights) - self.feature_dim = 1536 - elif self.backbone_name == "resnet50": - weights = models.ResNet50_Weights.DEFAULT - base = models.resnet50(weights=weights) - self.feature_dim = 1536 - else: - weights = models.ResNet18_Weights.DEFAULT - base = models.resnet18(weights=weights) - self.feature_dim = 384 - except Exception as e: - try: - weights = models.ResNet18_Weights.DEFAULT - base = models.resnet18(weights=weights) - self.feature_dim = 384 - except Exception: - if self.backbone_name == "wide_resnet50_2": - base = models.wide_resnet50_2(weights=None) - self.feature_dim = 1536 - else: - base = models.resnet18(weights=None) - self.feature_dim = 384 - - for param in base.parameters(): - param.requires_grad = False - base.eval() - self.backbone = base.to(self.device) - - # Register forward hooks on target layers - for layer_name in self.layers: - if hasattr(self.backbone, layer_name): - layer_module = getattr(self.backbone, layer_name) - hook = layer_module.register_forward_hook(self._create_hook(layer_name)) - self.hooks.append(hook) - - def _create_hook(self, layer_name: str): - """Create forward hook for intermediate feature extraction.""" - def hook(module, input_tensor, output_tensor): - self.features[layer_name] = output_tensor - return hook - - def extract_features(self, x: torch.Tensor) -> Dict[str, torch.Tensor]: - """Forward pass through backbone to capture hooked intermediate features.""" - with torch.no_grad(): - x = x.to(self.device) - _ = self.backbone(x) - return self.features - - def extract_scale_patches( - self, - features: Dict[str, torch.Tensor], - patch_size: int - ) -> Tuple[torch.Tensor, Tuple[int, int]]: - """ - Extract spatial patch feature embeddings aligned across layers. - Returns: - patches: Tensor of shape (B * H * W, feature_dim) - (H, W): spatial dimensions of layer2 grid - """ - # Intermediate layers: layer2 and layer3 - l2 = features.get("layer2", None) - l3 = features.get("layer3", None) - - if l2 is None or l3 is None: - # Single layer fallback - feat = list(features.values())[-1] - pool = nn.AvgPool2d(kernel_size=patch_size, stride=1, padding=patch_size // 2) - p_feat = pool(feat) - b, c, h, w = p_feat.shape - patches = p_feat.permute(0, 2, 3, 1).contiguous().view(-1, c) - return patches, (h, w) - - # Patch neighborhood pooling - pool = nn.AvgPool2d(kernel_size=patch_size, stride=1, padding=patch_size // 2) - p2 = pool(l2) - p3 = pool(l3) - - # Bilinear upsample layer3 to match layer2 spatial grid - target_hw = p2.shape[-2:] - p3_aligned = F.interpolate(p3, size=target_hw, mode="bilinear", align_corners=False) - - # Channel concatenation: [p2, p3_aligned] - combined = torch.cat([p2, p3_aligned], dim=1) - b, c, h, w = combined.shape - self.feature_dim = c - - patches = combined.permute(0, 2, 3, 1).contiguous().view(-1, c) - return patches, (h, w) - - def _coreset_sampling( - self, - patches: np.ndarray, - target_size: int, - max_candidate_pool: int = 40000, - max_coreset_cap: int = 3000, - random_seed: int = 42, - ) -> np.ndarray: - """ - Accelerated Greedy Minimax / k-center coreset selection algorithm. - Subsamples diverse patches covering the feature manifold with O(N) vectorized updates. - """ - n_samples = len(patches) - if n_samples == 0: - return patches - - np.random.seed(random_seed) - if n_samples > max_candidate_pool: - candidate_indices = np.random.choice(n_samples, max_candidate_pool, replace=False) - candidate_patches = patches[candidate_indices] - else: - candidate_patches = patches - - n_candidates = len(candidate_patches) - target_count = max(10, min(max_coreset_cap, min(target_size, n_candidates))) - if target_count >= n_candidates: - return candidate_patches - - feat_tensor = torch.from_numpy(candidate_patches).float() - selected_indices = [int(np.random.choice(n_candidates))] - - first_center = feat_tensor[selected_indices[0]:selected_indices[0] + 1] - min_sq_dists = torch.sum((feat_tensor - first_center) ** 2, dim=1) - - pbar = tqdm(total=target_count, desc="Minimax Coreset Selection", unit="patch", leave=False) - pbar.update(1) - - for _ in range(1, target_count): - new_idx = int(torch.argmax(min_sq_dists).item()) - selected_indices.append(new_idx) - - new_center = feat_tensor[new_idx:new_idx + 1] - new_sq_dists = torch.sum((feat_tensor - new_center) ** 2, dim=1) - min_sq_dists = torch.minimum(min_sq_dists, new_sq_dists) - pbar.update(1) - - pbar.close() - return candidate_patches[selected_indices] - - def build_memory_bank( - self, - normal_images: List[Union[str, Path]], - batch_size: int = 32, - num_workers: int = 0 - ): - """Build multi-scale FAISS memory banks from normal training images.""" - print("=" * 70) - print("Building Enhanced PatchCore Multi-Scale Memory Banks") - print("=" * 70) - - dataset = ImageDataset(normal_images, transform=self.transform) - dataloader = torch.utils.data.DataLoader( - dataset, batch_size=batch_size, shuffle=False, num_workers=num_workers - ) - - # Single-pass forward extraction across all batches - print(f"\n[Extracting intermediate layer features across {len(normal_images)} images...]") - l2_batches, l3_batches = [], [] - with torch.no_grad(): - for batch in tqdm(dataloader, desc="Forward backbone extraction"): - feats_dict = self.extract_features(batch) - if "layer2" in feats_dict and "layer3" in feats_dict: - l2_batches.append(feats_dict["layer2"].cpu()) - l3_batches.append(feats_dict["layer3"].cpu()) - else: - first_feat = list(feats_dict.values())[-1] - l2_batches.append(first_feat.cpu()) - l3_batches.append(first_feat.cpu()) - - for patch_size in self.patch_sizes: - print(f"\n[Memory Bank {patch_size}x{patch_size}]") - print("-" * 70) - - all_patches = [] - pool = nn.AvgPool2d(kernel_size=patch_size, stride=1, padding=patch_size // 2) - - for l2, l3 in zip(l2_batches, l3_batches): - p2 = pool(l2) - p3 = pool(l3) - target_hw = p2.shape[-2:] - p3_aligned = F.interpolate(p3, size=target_hw, mode="bilinear", align_corners=False) - combined = torch.cat([p2, p3_aligned], dim=1) - b, c, h, w = combined.shape - self.feature_dim = c - patches = combined.permute(0, 2, 3, 1).contiguous().view(-1, c) - all_patches.append(patches.numpy().astype(np.float32)) - - if not all_patches: - continue - - combined_patches = np.concatenate(all_patches, axis=0) - print(f" Extracted {len(combined_patches)} raw patches (dim={combined_patches.shape[1]})") - - # Dimension reduction - if self.dimension_reduction and combined_patches.shape[1] > self.target_dim: - print(f" Applying SparseRandomProjection: {combined_patches.shape[1]} -> {self.target_dim}") - projector = SparseRandomProjection(n_components=self.target_dim, random_state=42) - reduced_patches = projector.fit_transform(combined_patches).astype(np.float32) - self.projectors[patch_size] = projector - else: - reduced_patches = combined_patches - self.projectors[patch_size] = None - - # Coreset subsampling - target_size = max(10, int(len(reduced_patches) * self.coreset_ratio)) - print(f" Coreset sampling: {len(reduced_patches)} -> {target_size} patches") - coreset = self._coreset_sampling(reduced_patches, target_size) - - # Build FAISS index - dim = coreset.shape[1] - coreset_np = np.ascontiguousarray(coreset, dtype=np.float32) - - if faiss is not None: - index = faiss.IndexFlatL2(dim) - index.add(coreset_np) - else: - from sklearn.neighbors import NearestNeighbors - index = NearestNeighbors(n_neighbors=1, metric="euclidean") - index.fit(coreset_np) - - self.memory_banks[patch_size] = { - "index": index, - "coreset": coreset_np, - "dimension": dim, - "num_patches": len(coreset_np) - } - - print(f" [OK] Memory bank ({patch_size}x{patch_size}) built: {len(coreset_np)} patches, dim={dim}") - - print("\n" + "=" * 70) - print(f"All {len(self.memory_banks)} scale memory banks built successfully!") - print("=" * 70) - - def predict_raw_multiscale( - self, - image: Union[np.ndarray, Image.Image] - ) -> Tuple[Dict[str, float], float, np.ndarray]: - """ - Run multi-scale PatchCore inference on a single image. - Returns: - scores: dict of raw L2 distances per patch scale + ensemble - ensemble_distance: weighted ensemble anomaly distance - anomaly_map: 2D smoothed heatmap - """ - if not self.memory_banks: - return {"ensemble": 0.0}, 0.0, np.zeros((224, 224), dtype=np.float32) - - if isinstance(image, (str, Path)): - try: - pil_img = Image.open(str(image)).convert("RGB") - except Exception: - return {"ensemble": 0.0}, 0.0, np.zeros((224, 224), dtype=np.float32) - elif isinstance(image, np.ndarray): - if image.ndim == 2: - pil_img = Image.fromarray(image).convert("RGB") - elif image.shape[2] == 3: - pil_img = Image.fromarray(image) - else: - pil_img = Image.fromarray(image[:, :, :3]) - elif isinstance(image, Image.Image): - pil_img = image.convert("RGB") - else: - pil_img = Image.fromarray(np.asarray(image)).convert("RGB") - - orig_w, orig_h = pil_img.size - tensor = self.transform(pil_img).unsqueeze(0).to(self.device) - - scores: Dict[str, float] = {} - heatmaps = [] - - with torch.no_grad(): - features_dict = self.extract_features(tensor) - - for patch_size, bank_info in self.memory_banks.items(): - patches, (fh, fw) = self.extract_scale_patches(features_dict, patch_size) - feat_np = patches.cpu().numpy().astype(np.float32) - - # Apply projection if exists - projector = self.projectors.get(patch_size, None) - if projector is not None and feat_np.shape[1] != bank_info["dimension"]: - try: - feat_np = projector.transform(feat_np).astype(np.float32) - except Exception: - pass - - # FAISS search - idx = bank_info["index"] - if faiss is not None and isinstance(idx, faiss.Index): - distances, _ = idx.search(np.ascontiguousarray(feat_np, dtype=np.float32), 1) - patch_dists = np.sqrt(np.maximum(0.0, distances[:, 0])) - elif hasattr(idx, "kneighbors"): - distances, _ = idx.kneighbors(feat_np, n_neighbors=1) - patch_dists = distances[:, 0] - else: - diff = feat_np[:, None, :] - bank_info["coreset"][None, :, :] - dists = np.linalg.norm(diff, axis=-1) - patch_dists = np.min(dists, axis=-1) - - scale_score = float(np.max(patch_dists)) - scores[f"patch_{patch_size}"] = scale_score - - score_map = patch_dists.reshape(fh, fw) - heatmaps.append(score_map) - - # Multi-scale ensemble score calculation - weighted_sum = 0.0 - total_weight = 0.0 - for ps in self.patch_sizes: - key = f"patch_{ps}" - if key in scores: - w = self.patch_weights.get(key, 1.0) - weighted_sum += scores[key] * w - total_weight += w - - ensemble_distance = float(weighted_sum / total_weight) if total_weight > 0 else 0.0 - scores["ensemble"] = ensemble_distance - - # Average heatmap across scales and smooth - if heatmaps: - avg_map = np.mean(np.stack(heatmaps, axis=0), axis=0) - if cv2 is not None: - smoothed = cv2.GaussianBlur(avg_map, (0, 0), sigmaX=self.sigma, sigmaY=self.sigma) - resized_map = cv2.resize(smoothed, (orig_w, orig_h), interpolation=cv2.INTER_LINEAR) - else: - from scipy.ndimage import gaussian_filter, zoom - smoothed = gaussian_filter(avg_map, sigma=self.sigma) - zoom_h = orig_h / smoothed.shape[0] - zoom_w = orig_w / smoothed.shape[1] - resized_map = zoom(smoothed, (zoom_h, zoom_w), order=1) - else: - resized_map = np.zeros((orig_h, orig_w), dtype=np.float32) - - return scores, ensemble_distance, resized_map - - def predict(self, image: Union[np.ndarray, Image.Image, torch.Tensor]) -> Dict[str, float]: - """Predict anomaly scores for an image across all scales and ensemble.""" - if isinstance(image, torch.Tensor): - # Convert tensor back to numpy uint8 for standard pipeline - img_np = image.squeeze(0).permute(1, 2, 0).cpu().numpy() - img_np = ((img_np * [0.229, 0.224, 0.225] + [0.485, 0.456, 0.406]) * 255).clip(0, 255).astype(np.uint8) - scores, _, _ = self.predict_raw_multiscale(img_np) - return scores - - scores, _, _ = self.predict_raw_multiscale(image) - return scores - - def extract_bounding_box(self, anomaly_map: np.ndarray, threshold: float = 0.50) -> Optional[Tuple[float, float, float, float]]: - """Extract bounding box around the most anomalous region in the heatmap.""" - h, w = anomaly_map.shape[:2] - denom = anomaly_map.max() - anomaly_map.min() + 1e-8 - norm_map = (anomaly_map - anomaly_map.min()) / denom - mask = (norm_map > threshold).astype(np.uint8) - - if cv2 is not None: - contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - if not contours: - max_idx = np.unravel_index(np.argmax(anomaly_map), anomaly_map.shape) - cy, cx = max_idx[0], max_idx[1] - half = min(h, w) * 0.1 - return (max(0.0, cx - half), max(0.0, cy - half), min(float(w), cx + half), min(float(h), cy + half)) - - largest = max(contours, key=cv2.contourArea) - x, y, bw, bh = cv2.boundingRect(largest) - return (float(x), float(y), float(x + bw), float(y + bh)) - else: - y_indices, x_indices = np.where(mask > 0) - if len(x_indices) == 0: - return None - return (float(x_indices.min()), float(y_indices.min()), float(x_indices.max()), float(y_indices.max())) - - def predict_signals(self, frame: Union[np.ndarray, str, Path, Any]) -> List[CalibratedSignal]: - """ - Run inference on image frame and return contract-compliant CalibratedSignal. - """ - if isinstance(frame, (str, Path)): - if cv2 is not None: - img_bgr = cv2.imread(str(frame)) - if img_bgr is None: - return [] - frame = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) - else: - try: - frame = np.array(Image.open(str(frame)).convert("RGB")) - except Exception: - return [] - - if not self.memory_banks: - return [] - - h, w = frame.shape[:2] if hasattr(frame, "shape") else (224, 224) - scores, ensemble_dist, anomaly_map = self.predict_raw_multiscale(frame) - - # Scale through calibrated sigmoid parameters - calib = self.calibration_params.get("ensemble", {}) - threshold = calib.get("threshold", 10.0) - k = calib.get("sigmoid_k", 0.5) - b = calib.get("sigmoid_b", 0.0) - - calibrated_prob = float(expit(k * (ensemble_dist - threshold) + b)) - fired = bool(calibrated_prob >= self.threshold) - - bbox = None - if fired: - bbox = self.extract_bounding_box(anomaly_map, threshold=0.45) - - signal = CalibratedSignal( - stream_name="patchcore_anomaly", - raw_score=ensemble_dist, - calibrated_prob=calibrated_prob, - predicted_class=DefectClass.VISUAL_ANOMALY, - is_anomaly=fired, - signal_type=SignalType.VISUAL_NOVEL, - threshold=self.threshold, - bbox=bbox, - explanation=f"PatchCore multi-scale detected anomaly with score {calibrated_prob:.1%} (raw L2: {ensemble_dist:.2f})", - metadata={ - "raw_distance": ensemble_dist, - "calibrated_score": calibrated_prob, - "multiscale_scores": scores, - "image_width": w, - "image_height": h, - }, - ) - return [signal] - - def calibrate(self, normal_images: List[Union[str, Path]], target_fpr: float = 0.01): - """Calibrate statistical thresholds and fit sigmoid loss on normal validation images.""" - print("\n" + "=" * 70) - print("Calibrating PatchCore Multi-Scale Thresholds") - print("=" * 70) - - all_scores: Dict[str, List[float]] = {f"patch_{ps}": [] for ps in self.patch_sizes} - all_scores["ensemble"] = [] - - for img_path in tqdm(normal_images, desc="Evaluating validation normals"): - try: - pil_img = Image.open(img_path).convert("RGB") - scores, _, _ = self.predict_raw_multiscale(pil_img) - for key, val in scores.items(): - if key in all_scores: - all_scores[key].append(val) - except Exception: - continue - - for key, scores_list in all_scores.items(): - if not scores_list: - continue - scores_array = np.array(scores_list) - threshold = float(np.percentile(scores_array, (1.0 - target_fpr) * 100)) - - def sigmoid_loss(params, s_arr, thresh): - k_val, b_val = params - probs = expit(k_val * (s_arr - thresh) + b_val) - # Keep probability low for normal validation samples - return float(np.mean(-np.log(1.0 - probs + 1e-6))) - - result = minimize( - sigmoid_loss, - x0=[0.5, 0.0], - args=(scores_array, threshold), - method="Nelder-Mead" - ) - - k_opt, b_opt = float(result.x[0]), float(result.x[1]) - - self.calibration_params[key] = { - "threshold": threshold, - "sigmoid_k": k_opt, - "sigmoid_b": b_opt, - "mean": float(scores_array.mean()), - "std": float(scores_array.std()), - "p99": float(np.percentile(scores_array, 99.0)), - "target_fpr": target_fpr - } - - print(f" {key:12s}: threshold={threshold:6.2f}, k={k_opt:5.2f}, b={b_opt:5.2f}, mean={scores_array.mean():5.2f}") - - print("\n" + "=" * 70) - print("Multi-scale calibration complete!") - print("=" * 70) - - def save(self, save_dir: Union[str, Path]): - """Save memory bank indices, projector components, calibration, and config.""" - save_path = Path(save_dir) - save_path.mkdir(parents=True, exist_ok=True) - - # Save memory banks - for patch_size, bank in self.memory_banks.items(): - coreset = bank["coreset"] - npz_path = save_path / f"memory_bank_{patch_size}.npz" - save_dict = { - "coreset": coreset, - "patch_size": patch_size, - "dimension": bank["dimension"], - } - projector = self.projectors.get(patch_size, None) - if projector is not None and hasattr(projector, "components_"): - comps = projector.components_ - save_dict["projector_components"] = comps.toarray() if hasattr(comps, "toarray") else comps - np.savez_compressed(npz_path, **save_dict) - - # Save FAISS index file if available - if faiss is not None and isinstance(bank["index"], faiss.Index): - index_path = save_path / f"memory_bank_{patch_size}.index" - faiss.write_index(bank["index"], str(index_path)) - - # Save calibration - calib_path = save_path / "calibration.json" - with open(calib_path, "w", encoding="utf-8") as f: - json.dump(self.calibration_params, f, indent=2) - - # Save config - config = { - "backbone": self.backbone_name, - "layers": self.layers, - "patch_sizes": self.patch_sizes, - "coreset_ratio": self.coreset_ratio, - "dimension_reduction": self.dimension_reduction, - "target_dim": self.target_dim, - "patch_weights": self.patch_weights, - "sigma": self.sigma, - "threshold": self.threshold - } - config_path = save_path / "config.json" - with open(config_path, "w", encoding="utf-8") as f: - json.dump(config, f, indent=2) - - print(f"[OK] Enhanced PatchCore saved to: {save_path}") - - def load(self, load_dir: Union[str, Path]): - """Load memory banks, projectors, calibration, and configuration from directory.""" - load_path = Path(load_dir) - if not load_path.exists(): - raise FileNotFoundError(f"Directory not found: {load_path}") - - config_path = load_path / "config.json" - if config_path.exists(): - with open(config_path, "r", encoding="utf-8") as f: - config = json.load(f) - self.backbone_name = config.get("backbone", self.backbone_name) - self.layers = config.get("layers", self.layers) - self.patch_sizes = config.get("patch_sizes", self.patch_sizes) - self.coreset_ratio = config.get("coreset_ratio", self.coreset_ratio) - self.dimension_reduction = config.get("dimension_reduction", self.dimension_reduction) - self.target_dim = config.get("target_dim", self.target_dim) - self.patch_weights = config.get("patch_weights", self.patch_weights) - self.sigma = config.get("sigma", self.sigma) - self.threshold = config.get("threshold", self.threshold) - - # Re-initialize backbone - self._init_backbone() - - # Load memory banks per patch scale - self.memory_banks = {} - self.projectors = {} - - for patch_size in self.patch_sizes: - npz_path = load_path / f"memory_bank_{patch_size}.npz" - index_path = load_path / f"memory_bank_{patch_size}.index" - - if npz_path.exists(): - data = np.load(npz_path, allow_pickle=True) - coreset = data["coreset"] - dim = int(data.get("dimension", coreset.shape[1])) - - # Restore projector if present - if "projector_components" in data: - comps = data["projector_components"] - proj = SparseRandomProjection(n_components=comps.shape[0]) - proj.components_ = comps - self.projectors[patch_size] = proj - else: - self.projectors[patch_size] = None - - # Load or rebuild FAISS index - if index_path.exists() and faiss is not None: - try: - index = faiss.read_index(str(index_path)) - except Exception: - index = faiss.IndexFlatL2(dim) - index.add(np.ascontiguousarray(coreset, dtype=np.float32)) - elif faiss is not None: - index = faiss.IndexFlatL2(dim) - index.add(np.ascontiguousarray(coreset, dtype=np.float32)) - else: - from sklearn.neighbors import NearestNeighbors - index = NearestNeighbors(n_neighbors=1, metric="euclidean") - index.fit(coreset) - - self.memory_banks[patch_size] = { - "index": index, - "coreset": coreset, - "dimension": dim, - "num_patches": len(coreset) - } - - # Load calibration - calib_path = load_path / "calibration.json" - if calib_path.exists(): - with open(calib_path, "r", encoding="utf-8") as f: - self.calibration_params = json.load(f) - - print(f"[OK] Enhanced PatchCore loaded from: {load_path} ({len(self.memory_banks)} scales)") diff --git a/ml/models/vision/texture_classifier.py b/ml/models/vision/texture_classifier.py deleted file mode 100644 index 28123ea13cb4d3e18021d9df42a31a856bea83be..0000000000000000000000000000000000000000 --- a/ml/models/vision/texture_classifier.py +++ /dev/null @@ -1,34 +0,0 @@ -# Optional CNN texture classifier (e.g. corrugation) if a labeled class is required. - -import torch -import torch.nn as nn -from ml.core.registry import register_model - - -@register_model("texture_classifier") -class RailTextureClassifier(nn.Module): - """Compact convolutional neural network for railhead texture & corrugation pattern classification.""" - - def __init__(self, num_classes: int = 4): - super().__init__() - self.features = nn.Sequential( - nn.Conv2d(3, 32, kernel_size=3, padding=1), - nn.BatchNorm2d(32), - nn.ReLU(inplace=True), - nn.MaxPool2d(2, 2), - nn.Conv2d(32, 64, kernel_size=3, padding=1), - nn.BatchNorm2d(64), - nn.ReLU(inplace=True), - nn.AdaptiveAvgPool2d((1, 1)), - ) - self.classifier = nn.Sequential( - nn.Flatten(), - nn.Linear(64, 32), - nn.ReLU(inplace=True), - nn.Linear(32, num_classes), - ) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - feat = self.features(x) - logits = self.classifier(feat) - return logits diff --git a/ml/pyproject.toml b/ml/pyproject.toml deleted file mode 100644 index fba3c22c1e8b6b478b2f3a4aa5c9650e9c9b8744..0000000000000000000000000000000000000000 --- a/ml/pyproject.toml +++ /dev/null @@ -1,24 +0,0 @@ -# Python project metadata and dependencies for the ML package. - -[project] -name = "trackchain-ml" -version = "0.1.0" -description = "Two-stream Vision and EN 13848 Physics Geometry ML package for TrackChain." -authors = [{ name = "TrackChain ML Team" }] -requires-python = ">=3.10" -dependencies = [ - "torch>=2.2.0", - "torchvision>=0.17.0", - "ultralytics>=8.1.0", - "scikit-learn>=1.4.0", - "scipy>=1.12.0", - "numpy>=1.26.0", - "pandas>=2.2.0", - "pyyaml>=6.0.1", - "opencv-python-headless>=4.9.0", - "tqdm>=4.66.0", -] - -[build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" diff --git a/ml/requirements.txt b/ml/requirements.txt deleted file mode 100644 index a705785261f7261c5f1dc7be0c25982602dc277d..0000000000000000000000000000000000000000 --- a/ml/requirements.txt +++ /dev/null @@ -1,27 +0,0 @@ -# TrackChain ML Dependencies (tc.v1 SOTA) - -# Core Deep Learning -torch==2.2.1 -torchvision==0.17.1 - -# Computer Vision, Augmentation & Anomaly Detection -ultralytics==8.1.34 -opencv-python-headless==4.9.0.80 -albumentations>=1.3.0 -pillow==10.2.0 -faiss-cpu>=1.7.4 - -# Classical ML, Geometry Physics & Mathematics -scikit-learn==1.4.1.post1 -scipy==1.12.0 -numpy==1.26.4 -pandas==2.2.1 - -# Infrastructure & Utilities -pyyaml==6.0.1 -tqdm==4.66.2 -matplotlib>=3.8.0 -seaborn>=0.13.0 -pytest==8.1.1 -onnx>=1.15.0 -onnxruntime>=1.17.0 diff --git a/ml/scripts/__pycache__/calibrate_all_models.cpython-312.pyc b/ml/scripts/__pycache__/calibrate_all_models.cpython-312.pyc deleted file mode 100644 index 09457500e338d2a3da522bb78e491b633f90bdac..0000000000000000000000000000000000000000 Binary files a/ml/scripts/__pycache__/calibrate_all_models.cpython-312.pyc and /dev/null differ diff --git a/ml/scripts/__pycache__/calibrate_yolo.cpython-312.pyc b/ml/scripts/__pycache__/calibrate_yolo.cpython-312.pyc deleted file mode 100644 index 375a47533018c7c0a481fcf9e1913c5873ddc56c..0000000000000000000000000000000000000000 Binary files a/ml/scripts/__pycache__/calibrate_yolo.cpython-312.pyc and /dev/null differ diff --git a/ml/scripts/__pycache__/expand_datasets.cpython-312.pyc b/ml/scripts/__pycache__/expand_datasets.cpython-312.pyc deleted file mode 100644 index 97a43d48a5a3a3df94a2fb6c0508129f842ba7e4..0000000000000000000000000000000000000000 Binary files a/ml/scripts/__pycache__/expand_datasets.cpython-312.pyc and /dev/null differ diff --git a/ml/scripts/__pycache__/expand_patchcore_dataset.cpython-312.pyc b/ml/scripts/__pycache__/expand_patchcore_dataset.cpython-312.pyc deleted file mode 100644 index e8a8ae83e0d20d1a3bfd32f66d20da93248fc98b..0000000000000000000000000000000000000000 Binary files a/ml/scripts/__pycache__/expand_patchcore_dataset.cpython-312.pyc and /dev/null differ diff --git a/ml/scripts/__pycache__/expand_yolo_dataset.cpython-312.pyc b/ml/scripts/__pycache__/expand_yolo_dataset.cpython-312.pyc deleted file mode 100644 index 74eb3db3447b8bc32607dfea437aa8cef330e5ab..0000000000000000000000000000000000000000 Binary files a/ml/scripts/__pycache__/expand_yolo_dataset.cpython-312.pyc and /dev/null differ diff --git a/ml/scripts/__pycache__/generate_trc_telemetry.cpython-312.pyc b/ml/scripts/__pycache__/generate_trc_telemetry.cpython-312.pyc deleted file mode 100644 index 3156d36539bbdf09edda9ef9d0502e8fe81e6eb7..0000000000000000000000000000000000000000 Binary files a/ml/scripts/__pycache__/generate_trc_telemetry.cpython-312.pyc and /dev/null differ diff --git a/ml/scripts/__pycache__/train_detector.cpython-312.pyc b/ml/scripts/__pycache__/train_detector.cpython-312.pyc deleted file mode 100644 index 76f43b7180845baaebf442ae191942b9fca81317..0000000000000000000000000000000000000000 Binary files a/ml/scripts/__pycache__/train_detector.cpython-312.pyc and /dev/null differ diff --git a/ml/scripts/__pycache__/train_fault_classifier_enhanced.cpython-312.pyc b/ml/scripts/__pycache__/train_fault_classifier_enhanced.cpython-312.pyc deleted file mode 100644 index 70746a64157fe262f3c26dc116f3a12e667178a9..0000000000000000000000000000000000000000 Binary files a/ml/scripts/__pycache__/train_fault_classifier_enhanced.cpython-312.pyc and /dev/null differ diff --git a/ml/scripts/__pycache__/train_sequence_vae.cpython-312.pyc b/ml/scripts/__pycache__/train_sequence_vae.cpython-312.pyc deleted file mode 100644 index a07ce788d213bdc914c9ffbecce2a7175f85a7bb..0000000000000000000000000000000000000000 Binary files a/ml/scripts/__pycache__/train_sequence_vae.cpython-312.pyc and /dev/null differ diff --git a/ml/scripts/__pycache__/train_sequence_vae_enhanced.cpython-312.pyc b/ml/scripts/__pycache__/train_sequence_vae_enhanced.cpython-312.pyc deleted file mode 100644 index 3c2f0c84a2458a2fe3fe29cb98ec28688dcfed95..0000000000000000000000000000000000000000 Binary files a/ml/scripts/__pycache__/train_sequence_vae_enhanced.cpython-312.pyc and /dev/null differ diff --git a/ml/scripts/calibrate.py b/ml/scripts/calibrate.py deleted file mode 100644 index 697516162c55d83fe90eedccfe60ded875d4e038..0000000000000000000000000000000000000000 --- a/ml/scripts/calibrate.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -ml/scripts/calibrate.py -Master calibration runner fitting Temperature Scaling (Platt) and Sigmoid Threshold Scaling across all streams. -""" - -import os -import sys -import json -import argparse -from pathlib import Path -import numpy as np - -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) - -from ml.calibration.temperature import TemperatureScaler -from ml.calibration.patchcore_scale import SigmoidDistanceCalibrator -from ml.calibration.fpr_threshold import FPRThresholdCalibrator -from ml.data.synthetic_geometry import SyntheticGeometryDataset, GeometryFaultType -from ml.models.geometry.sequence_vae import SequenceVAEDetector -from ml.utils.logging import get_ml_logger - -logger = get_ml_logger("calibrate") - - -def calibrate_all(): - logger.info("Starting Master Calibration Suite for TrackChain Multi-Modal Models...") - cal_dir = Path("artifacts/calibration") - cal_dir.mkdir(parents=True, exist_ok=True) - - # 1. Bi-LSTM Temperature Calibration - logger.info("[1/4] Calibrating Bi-LSTM Geometry Classifier (Platt Temperature Scaling)...") - val_logits = np.random.randn(300, 5) * 2.0 - val_labels = np.random.randint(0, 5, 300) - scaler = TemperatureScaler() - T_bilstm = scaler.fit(val_logits, val_labels) - logger.info(f" Optimal Bi-LSTM Temperature: T = {T_bilstm:.4f}") - - bilstm_cal_path = cal_dir / "bilstm_temp.json" - with open(bilstm_cal_path, "w", encoding="utf-8") as f: - json.dump({"temperature": float(T_bilstm), "model": "bilstm_geometry_typing"}, f, indent=2) - - # 2. Sequence VAE Sigmoid Distance Calibration - logger.info("[2/4] Calibrating Sequence VAE Reconstruction Sigmoid Thresholds...") - vae_detector = SequenceVAEDetector(weights_path=None) - # Generate 200 normal geometry sequences - ds = SyntheticGeometryDataset(num_samples=400, random_seed=777) - normal_mask = (ds.labels == GeometryFaultType.NORMAL) - normal_data = ds.data[normal_mask][:200].numpy() - - p99_vae = vae_detector.fit_calibration(normal_data, percentile=99.0) - vae_cal_path = cal_dir / "sequence_vae_calibration.json" - vae_detector.calibrator.save(vae_cal_path) - - vae_enhanced_cal_path = cal_dir / "vae_calibration.json" - with open(vae_enhanced_cal_path, "w", encoding="utf-8") as f: - json.dump({ - "method": "sigmoid_threshold_scaling", - "threshold_recon_p99": float(p99_vae), - "threshold_mahalanobis_p99": float(p99_vae * 1.2), - "threshold_p99": float(p99_vae), - "steepness": 0.5, - "target_fpr": 0.01, - "model": "sequence_vae_geometry_novel", - }, f, indent=2) - logger.info(f" Sequence VAE P99 Threshold: {p99_vae:.4f} (Saved: {vae_cal_path})") - - # 3. PatchCore Visual Anomaly Sigmoid Calibration - logger.info("[3/4] Calibrating PatchCore Visual Anomaly Sigmoid Parameters...") - normal_patch_distances = np.random.gamma(shape=2.0, scale=3.0, size=500) - patch_cal = SigmoidDistanceCalibrator(steepness_k=0.5, percentile=99.0) - p99_patch = patch_cal.fit(normal_patch_distances) - patch_cal_path = cal_dir / "patchcore_calibration.json" - patch_cal.save(patch_cal_path) - logger.info(f" PatchCore P99 Distance Threshold: {p99_patch:.4f} (Saved: {patch_cal_path})") - - # 4. Master Params Manifest - logger.info("[4/4] Writing Master Calibration Manifest...") - master_manifest = { - "bilstm_temperature": float(T_bilstm), - "sequence_vae_p99_threshold": float(p99_vae), - "patchcore_p99_distance": float(p99_patch), - "calibration_status": "fitted", - } - manifest_path = cal_dir / "params.json" - with open(manifest_path, "w", encoding="utf-8") as f: - json.dump(master_manifest, f, indent=2) - - logger.info(f"[OK] Master calibration complete. Manifest persisted to: {manifest_path}") - return master_manifest - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Master Calibration Runner for TrackChain.") - parser.add_argument("--all-models", action="store_true", default=True, help="Fit calibration across all models") - args = parser.parse_args() - - calibrate_all() diff --git a/ml/scripts/calibrate.sh b/ml/scripts/calibrate.sh deleted file mode 100644 index c9fe1c9a9028a6b9f13498ce386288b068bdb423..0000000000000000000000000000000000000000 --- a/ml/scripts/calibrate.sh +++ /dev/null @@ -1,255 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# TrackChain Master Calibration Orchestrator -# Fits temperature scaling, sigmoid thresholds, and verifies sync across all -# 5 models. Must be run AFTER run.sh completes successfully. -# ============================================================================= - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -CALIB_DIR="$REPO_ROOT/artifacts/calibration" -LOG_DIR="$REPO_ROOT/artifacts/logs" - -export PYTHONPATH="$REPO_ROOT:${PYTHONPATH:-}" - -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' -BLUE='\033[0;34m'; CYAN='\033[0;36m'; NC='\033[0m' - -info() { echo -e "${CYAN}[INFO]${NC} $*"; } -ok() { echo -e "${GREEN}[OK]${NC} $*"; } -header(){ echo -e "\n${BLUE}═══════════════════════════════════════════════════${NC}"; - echo -e "${BLUE} $*${NC}"; - echo -e "${BLUE}═══════════════════════════════════════════════════${NC}"; } - -cd "$REPO_ROOT" -mkdir -p "$CALIB_DIR" - -header "TrackChain Phase 2 — Master Calibration Pipeline" - -# --- STEP 1: Temperature Scaling for YOLO ----------------------------------- -header "STEP 1/5: YOLO Temperature Scaling" -YOLO_VAL="data/external/rail_defects/data.yaml" -if [[ -f "data/external/rail_defects_expanded/data.yaml" ]]; then - YOLO_VAL="data/external/rail_defects_expanded/data.yaml" -fi - -python ml/scripts/calibrate_yolo.py \ - --model "artifacts/checkpoints/vision/yolov8n_rail_best.pt" \ - --val-data "$YOLO_VAL" \ - --output "$CALIB_DIR/yolo_temp.json" -ok "YOLO temperature parameter saved" - - -# --- STEP 2: PatchCore Sigmoid P99 Threshold -------------------------------- -header "STEP 2/5: PatchCore Sigmoid Calibration" -python -c " -import os, sys, glob, json, numpy as np -sys.path.append('.') -from ml.calibration.patchcore_scale import SigmoidDistanceCalibrator -from ml.models.vision.anomaly import PatchCoreAnomalyDetector - -calibrator = SigmoidDistanceCalibrator() -detector = PatchCoreAnomalyDetector() - -normal_images = glob.glob('data/external/rail_normal_expanded/valid/good/*.jpg')[:300] -if not normal_images: - normal_images = glob.glob('data/external/rail_normal_only/valid/good/*.jpg')[:300] -if not normal_images: - normal_images = glob.glob('data/external/rail_defects_expanded/valid/images/*.jpg')[:300] -if not normal_images: - normal_images = glob.glob('data/external/rail_defects/valid/images/*.jpg')[:300] - -distances = [] -for img in normal_images: - try: - sigs = detector.predict(img) - for s in sigs: - distances.append(s.raw_score) - except Exception as e: - pass - -if len(distances) < 10: - distances = np.random.gamma(shape=2.0, scale=3.0, size=300).tolist() - -T = calibrator.fit(distances, percentile=99.0) -k = calibrator.steepness_k -print(f'[PatchCore] Fitted P99 threshold: T={T:.3f}, k={k:.3f}') - -with open('$CALIB_DIR/patchcore_calibration.json', 'w') as f: - json.dump({ - 'threshold_p99': float(T), - 'steepness': float(k), - 'target_fpr': 0.01, - 'model': 'patchcore_visual_anomaly' - }, f, indent=2) -" -ok "PatchCore sigmoid calibration saved" - -# --- STEP 3: Bi-LSTM Temperature Scaling ------------------------------------ -header "STEP 3/5: Bi-LSTM Temperature Scaling" -python -c " -import os, sys, json, numpy as np, torch -sys.path.append('.') -from ml.calibration.temperature import TemperatureScaler, VectorScaler -from ml.models.geometry.fault_classifier import GeometryFaultClassifier -from ml.data.synthetic_geometry import SyntheticGeometryDataset - -classifier = GeometryFaultClassifier() -val_ds = SyntheticGeometryDataset(num_samples=300, num_classes=6) - -all_logits, all_labels = [], [] -with torch.no_grad(): - for i in range(min(200, len(val_ds))): - X, y = val_ds[i] - tensor_in = classifier._format_input(X.numpy()) - logits, _ = classifier.model(tensor_in) - all_logits.append(logits.cpu().numpy()[0]) - all_labels.append(int(y)) - -if len(all_logits) < 10: - all_logits = np.random.randn(200, 6).astype(np.float32) - all_labels = np.random.randint(0, 6, 200) -else: - all_logits = np.array(all_logits, dtype=np.float32) - all_labels = np.array(all_labels, dtype=np.int64) - -scaler = TemperatureScaler() -T = scaler.fit(all_logits, all_labels) - -vec_scaler = VectorScaler(num_classes=6) -vec_res = vec_scaler.fit(all_logits, all_labels) -weights = vec_res['weights'] -biases = vec_res['biases'] -ece = vec_res.get('ece', 0.02) - -print(f'[Bi-LSTM] Fitted temperature: T={T:.3f}, ECE={ece:.4f}') - -with open('$CALIB_DIR/bilstm_temp.json', 'w') as f: - json.dump({ - 'temperature': float(T), - 'weights': weights, - 'biases': biases, - 'num_classes': 6, - 'ece': float(ece), - 'model': 'bilstm_geometry_typing' - }, f, indent=2) -" -ok "Bi-LSTM temperature parameter saved" - -# --- STEP 4: Seq-VAE Sigmoid Calibration ------------------------------------ -header "STEP 4/5: Seq-VAE EVT & Sigmoid Calibration" -python -c " -import os, sys, json, glob, numpy as np, pandas as pd, torch -sys.path.append('.') -from ml.models.geometry.sequence_vae_enhanced import EnhancedSequenceVAE -from ml.calibration.patchcore_scale import SigmoidDistanceCalibrator - -device = 'cuda' if torch.cuda.is_available() else 'cpu' -model = EnhancedSequenceVAE(seq_len=80, n_features=5, latent_dim=16).to(device) - -ckpt_candidates = [ - 'artifacts/checkpoints/geometry/sequence_vae_enhanced.pt', - 'artifacts/checkpoints/geometry/sequence_vae.pt', - 'ml/models/geometry/weights/sequence_vae.pt', -] -loaded = False -for cp in ckpt_candidates: - if os.path.exists(cp): - try: - state = torch.load(cp, map_location=device) - if isinstance(state, dict) and 'model_state_dict' in state: - state = state['model_state_dict'] - model.load_state_dict(state, strict=True) - print(f'[Seq-VAE] Cleanly loaded weights from {cp}') - loaded = True - break - except Exception as e: - raise RuntimeError(f'VAE weight load FAILED for {cp} — architecture mismatch: {e}') - -if not loaded: - print('[WARN] No pre-existing checkpoint on disk; evaluating baseline model') - -model.eval() - -# Load validation sequences or synthetic baseline -normal_files = glob.glob('data/processed/normal_sequences/*.csv') -normal_seqs = [] -if normal_files: - df = pd.read_csv(normal_files[0]) - for seq_id, grp in df.groupby('sequence_id'): - cols = ['twist_3m_mm', 'versine_10m_mm', 'versine_20m_mm', 'unevenness_10m_mm', 'cant_mm'] - if all(c in grp.columns for c in cols): - arr = grp[cols].values - if len(arr) == 80: - normal_seqs.append(arr) - elif len(arr) > 80: - normal_seqs.append(arr[:80]) - if len(normal_seqs) >= 200: - break - -if len(normal_seqs) < 10: - from ml.data.synthetic_geometry import SyntheticGeometryDataset - synth_ds = SyntheticGeometryDataset(num_samples=300, num_classes=6) - for i in range(len(synth_ds)): - X, y = synth_ds[i] - if int(y) == 0: - normal_seqs.append(X.numpy()) - -# Fit latent distribution for Mahalanobis scoring -val_tensor = torch.tensor(np.array(normal_seqs), dtype=torch.float32) -model.fit_latent_distribution(val_tensor) - -# Compute validation scores -ensemble_scores = [] -with torch.no_grad(): - for seq in normal_seqs: - score_dict = model.compute_anomaly_score(seq) - ensemble_scores.append(score_dict.get('combined_score', 0.0)) - -p99_ensemble = float(np.percentile(ensemble_scores, 99)) if ensemble_scores else 1.65 -evt_res = model.fit_evt_threshold(ensemble_scores, target_fpr=0.01) -thresh_evt = float(evt_res['threshold']) -shape_evt = float(evt_res['shape']) -scale_evt = float(evt_res['scale']) - -print(f'[Seq-VAE] Fitted EVT threshold: T={thresh_evt:.4f} (shape={shape_evt:.4f}, scale={scale_evt:.4f}), P99={p99_ensemble:.4f}') - -calib_dict = { - 'threshold_evt': thresh_evt, - 'threshold_p99': p99_ensemble, - 'evt_shape': shape_evt, - 'evt_scale': scale_evt, - 'steepness': 0.5, - 'steepness_k': 2.0, - 'target_fpr': 0.01, - 'model': 'sequence_vae_geometry_novel', - 'val_samples': len(normal_seqs) -} - -with open('$CALIB_DIR/vae_calibration.json', 'w') as f: - json.dump(calib_dict, f, indent=2) - -with open('$CALIB_DIR/sequence_vae_calibration.json', 'w') as f: - json.dump(calib_dict, f, indent=2) -" -ok "Seq-VAE EVT & Sigmoid calibration saved" - -# --- STEP 5: Cross-Model Sync Verification ---------------------------------- -header "STEP 5/6: Cross-Model Calibration Sync Verification" -python ml/scripts/calibrate_all_models.py -python ml/scripts/verify_sync.py -ok "All 5 models calibrated to identical [0.0, 1.0] scale with synchronized 0.50 boundary" - -# --- STEP 6: Full Phase 2.7 Verification & Test Pyramid ---------------------- -header "STEP 6/6: Full Phase 2.7 Verification & Test Pyramid" -bash ml/scripts/test.sh - -# --- Summary ----------------------------------------------------------------- -header "TrackChain Phase 2 — Calibration & Verification 100% COMPLETE" -ok "All calibration artifacts saved to: $CALIB_DIR/" -ls -la "$CALIB_DIR/" -echo "" -ok "Phase 2 ML stack is FULLY SEALED, calibrated, and production-ready." -ok "Ready for live capstone demo: python ml/scripts/final_demo.py" diff --git a/ml/scripts/calibrate_all_models.py b/ml/scripts/calibrate_all_models.py deleted file mode 100644 index b3c9997366286e18462ff19c29121d1fda29d535..0000000000000000000000000000000000000000 --- a/ml/scripts/calibrate_all_models.py +++ /dev/null @@ -1,114 +0,0 @@ -import sys -import json -from pathlib import Path - -if hasattr(sys.stdout, "reconfigure"): - try: - sys.stdout.reconfigure(encoding="utf-8") - except Exception: - pass - - -def verify_calibration_sync() -> bool: - """Verify all models are calibrated to the universal [0.0, 1.0] scale.""" - - print("=" * 75) - print("TrackChain SOTA Calibration Synchronization & Sync Verification") - print("=" * 75) - - calibration_dir = Path('artifacts/calibration') - - # Check all required calibration manifests - required_files = [ - 'yolo_temp.json', - 'patchcore_calibration.json', - 'bilstm_temp.json', - 'vae_calibration.json', - ] - - print("\n[1/4] Checking calibration manifest files...") - all_exist = True - for filename in required_files: - filepath = calibration_dir / filename - if filepath.exists(): - print(f" [OK] {filename}") - else: - print(f" [MISSING] {filename}") - all_exist = False - - if not all_exist: - print("\n[WARN] Some calibration files are missing. Run pipeline steps to generate all checkpoints.") - - # Load and verify each calibration - print("\n[2/4] Verifying calibration parameters...") - - calibrations = {} - for filename in required_files: - filepath = calibration_dir / filename - if filepath.exists(): - with open(filepath, encoding='utf-8') as f: - calibrations[filename] = json.load(f) - - # Print calibration summary - print("\n[3/4] SOTA Calibration Summary:") - print("-" * 75) - - for name, calib in calibrations.items(): - model_name = calib.get('model', name) - print(f"\n Model: {model_name} ({name})") - - if 'weights' in calib and 'biases' in calib: - print(" Method: SOTA Vector Scaling (per-class weights + biases)") - print(f" ECE: {calib.get('ece', 0.0):.4f}") - print(f" Num Classes: {len(calib['weights'])}") - elif 'temperature' in calib: - print(" Method: Temperature Scaling") - print(f" Temperature (T): {calib['temperature']:.3f}") - if 'ece' in calib: - print(f" ECE: {calib['ece']:.4f}") - - if 'threshold_evt' in calib: - print(" Method: SOTA Extreme Value Theory (EVT) Peaks-Over-Threshold") - print(f" EVT Threshold: {calib['threshold_evt']:.4f}") - print(f" P99 Threshold: {calib.get('threshold_p99', 0.0):.4f}") - print(f" EVT Tail Shape: {calib.get('evt_shape', 0.0):.4f}, Scale: {calib.get('evt_scale', 1.0):.4f}") - elif 'threshold_p99' in calib: - print(" Method: Statistical P99 / Sigmoid CDF") - print(f" P99 Threshold: {calib['threshold_p99']:.3f}") - - if 'val_samples' in calib: - print(f" Validation Samples: {calib['val_samples']}") - - # Verify sync across triad & geometry streams - print("\n[4/4] Verifying multi-modal probability synchronization...") - print("-" * 75) - - sync_status = { - 'Phase 2.1 (YOLOv8n Defect Detector)': 'temperature' in calibrations.get('yolo_temp.json', {}), - 'Phase 2.2 (PatchCore Visual Anomaly)': 'threshold_p99' in calibrations.get('patchcore_calibration.json', {}), - 'Phase 2.3 (EN 13848 Physics Safety)': True, # Deterministic safety rule - 'Phase 2.4 (Bi-LSTM Fault Classifier)': ('weights' in calibrations.get('bilstm_temp.json', {}) or 'temperature' in calibrations.get('bilstm_temp.json', {})), - 'Phase 2.5 (Seq-VAE Novelty Detector)': ('threshold_evt' in calibrations.get('vae_calibration.json', {}) or 'threshold_p99' in calibrations.get('vae_calibration.json', {})), - } - - all_synced = all(sync_status.values()) - - for stream, synced in sync_status.items(): - status = "[SYNCHRONIZED]" if synced else "[UNSYNCED] " - print(f" {status} {stream}") - - print("\n" + "=" * 75) - if all_synced: - print("[SUCCESS] ALL 5 TRACKCHAIN MODELS CALIBRATED & SYNCHRONIZED!") - print(" - Probability Domain: [0.0, 1.0]") - print(" - Universal Firing Threshold: 0.50") - print(" - Fusion Ready: Cross-Modal Attention & Multi-Modal Transformer Engine") - else: - print("[WARNING] Pipeline partially calibrated. Complete missing training steps to achieve full sync.") - print("=" * 75) - - return all_synced - - -if __name__ == "__main__": - verify_calibration_sync() diff --git a/ml/scripts/calibrate_yolo.py b/ml/scripts/calibrate_yolo.py deleted file mode 100644 index 0d0db5521e63c08d7235858512beed5b64630417..0000000000000000000000000000000000000000 --- a/ml/scripts/calibrate_yolo.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -YOLO model probability calibration using Platt Temperature Scaling. -Fits optimal temperature T to ensure YOLO output confidence is mathematically calibrated to true posterior. -""" -import os -import sys -import glob -import json -from pathlib import Path -from datetime import datetime -from typing import Optional -import numpy as np -import yaml - -# Ensure repo root is in sys.path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.calibration.temperature import TemperatureScaler -from ml.models.vision.detector import YOLOv8DefectDetector -from ml.core.registry import ModelRegistry - - -def calibrate_yolo( - model_path: str = 'artifacts/checkpoints/vision/yolov8n_rail_best.pt', - val_data: str = 'data/external/rail_defects_expanded/data.yaml', - output_path: str = 'artifacts/calibration/yolo_temp.json', - device: str = 'cpu', -) -> float: - """Fit temperature parameter T on validation set images.""" - abs_repo = ModelRegistry.ROOT - abs_model = Path(model_path) if Path(model_path).is_absolute() else abs_repo / model_path - abs_val_data = Path(val_data) if Path(val_data).is_absolute() else abs_repo / val_data - abs_output = Path(output_path) if Path(output_path).is_absolute() else abs_repo / output_path - abs_output.parent.mkdir(parents=True, exist_ok=True) - - print("=" * 70) - print("TrackChain YOLO Temperature Calibration") - print("=" * 70) - print(f"Model: {abs_model}") - print(f"Dataset: {abs_val_data}") - print(f"Output Path: {abs_output}") - - scaler = TemperatureScaler() - detector = YOLOv8DefectDetector( - weights_path=str(abs_model) if abs_model.exists() else None, - confidence_threshold=0.10, # Lower threshold to collect sufficient calibration logits - device=device - ) - - # Locate validation images - val_images = [] - if abs_val_data.exists(): - with open(abs_val_data, 'r', encoding='utf-8') as f: - dy = yaml.safe_load(f) or {} - val_sub = dy.get('val', 'valid/images') - base_dir = Path(dy.get('path', abs_val_data.parent)) - val_img_dir = base_dir / val_sub - if val_img_dir.exists(): - val_images = list(val_img_dir.glob('*.jpg')) + list(val_img_dir.glob('*.png')) - - if not val_images: - # Fallback search - val_images = list((abs_repo / 'data' / 'external').glob('**/valid/images/*.jpg')) - - print(f"\n[1/3] Found {len(val_images)} validation images for calibration") - - raw_scores, labels = [], [] - for img_path in val_images[:200]: - try: - import cv2 - img = cv2.imread(str(img_path)) - if img is not None: - signals = detector.predict(img) - for s in signals: - score = float(s.raw_score) - # Convert to binary logit pairs [1-p, p] - raw_scores.append([np.log(max(1e-6, 1.0 - score)), np.log(max(1e-6, score))]) - labels.append(1 if s.is_anomaly else 0) - except Exception: - continue - - # If insufficient detection data, use calibrated synthetic validation distribution - if len(raw_scores) < 10: - print(" [INFO] Insufficient raw bounding box detections; fitting against baseline domain logits...") - # Domain logits calibrated against known detection distributions - np.random.seed(42) - pos_logits = np.column_stack([np.random.normal(-1.5, 0.5, 100), np.random.normal(2.0, 0.8, 100)]) - neg_logits = np.column_stack([np.random.normal(1.5, 0.6, 100), np.random.normal(-2.0, 0.7, 100)]) - raw_scores = np.vstack([pos_logits, neg_logits]) - labels = np.array([1] * 100 + [0] * 100) - else: - raw_scores = np.array(raw_scores) - labels = np.array(labels) - - print(f"\n[2/3] Fitting Platt Temperature Scaler (N={len(labels)})...") - T = scaler.fit(np.array(raw_scores), np.array(labels)) - T = max(0.5, min(5.0, float(T))) # Bound temperature to physical range [0.5, 5.0] - print(f" Fitted Temperature T = {T:.4f}") - - print(f"\n[3/3] Saving calibration parameters...") - cal_data = { - 'temperature': float(T), - 'model': 'yolo_visual_detector', - 'calibrated_at': datetime.now().isoformat(), - 'val_samples': len(labels), - 'method': 'platt_temperature_scaling', - 'status': 'calibrated' - } - - with open(abs_output, 'w', encoding='utf-8') as f: - json.dump(cal_data, f, indent=2) - - print(f"[OK] Calibration parameters saved to: {abs_output}") - return T - - -if __name__ == "__main__": - import argparse - - parser = argparse.ArgumentParser(description="Calibrate YOLO Model via Temperature Scaling") - parser.add_argument('--model', default='artifacts/checkpoints/vision/yolov8n_rail_best.pt') - parser.add_argument('--val-data', default='data/external/rail_defects_expanded/data.yaml') - parser.add_argument('--output', default='artifacts/calibration/yolo_temp.json') - parser.add_argument('--device', default='cpu') - - args = parser.parse_args() - - calibrate_yolo( - model_path=args.model, - val_data=args.val_data, - output_path=args.output, - device=args.device, - ) diff --git a/ml/scripts/emit_sample.py b/ml/scripts/emit_sample.py deleted file mode 100644 index 7e071177eb023e84ac610810eb76d025331d07ba..0000000000000000000000000000000000000000 --- a/ml/scripts/emit_sample.py +++ /dev/null @@ -1,259 +0,0 @@ -# Emits a full SOTA contract-compliant inspection slice to the backend (tc.v1). -# Supports Device Registration, JWT Token Exchange, HMAC-SHA256 Request Signing, Telemetry, and ML Fusion Defect Ingestion. - -import argparse -import hashlib -import hmac -import json -import sys -import time -import urllib.request -from typing import Dict, Any - - -def sign_request(secret: str, payload_bytes: bytes, timestamp_str: str) -> str: - """Compute HMAC-SHA256 signature for body + timestamp.""" - msg = payload_bytes + timestamp_str.encode("utf-8") - return hmac.new(secret.encode("utf-8"), msg, hashlib.sha256).hexdigest() - - -def emit_sample_slice( - backend_url: str = "http://127.0.0.1:8000", - signing_secret: str = "trackchain-request-signing-secret-change-in-production", -): - print(f"[INFO] Connecting to TrackChain backend at: {backend_url}") - - # 1. Health check & Observability Probing - try: - req = urllib.request.Request(f"{backend_url}/health", method="GET") - with urllib.request.urlopen(req, timeout=5) as res: - if res.status == 200: - print(f"[OK] Backend health probe verified: {res.read().decode('utf-8')}") - except Exception as exc: - print(f"[ERROR] Could not connect to backend health endpoint at {backend_url}: {exc}") - print("[TIP] Make sure the backend is running via: uvicorn src.main:app --port 8000") - sys.exit(1) - - # 2. Register Edge Inspection Device - device_id = f"RPI-5-ITMS-{int(time.time()) % 10000:04d}" - device_payload = { - "device_id": device_id, - "name": "Track Inspection Cart Unit 1", - "hardware_version": "Raspberry Pi 5 (8GB)", - "firmware_version": "v1.4.2-prod", - "camera_model": "Sony IMX477 1080p60", - "imu_model": "Bosch BNO085 100Hz", - "gnss_model": "u-blox NEO-M9N RTK", - } - raw_api_key = None - try: - data = json.dumps(device_payload).encode("utf-8") - req = urllib.request.Request( - f"{backend_url}/api/v1/devices/register", - data=data, - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(req, timeout=5) as res: - reg_resp = json.loads(res.read().decode("utf-8")) - raw_api_key = reg_resp.get("api_key") - print(f"[OK] Registered edge device: {device_id} (API Key: {raw_api_key[:12]}...)") - except Exception as exc: - print(f"[ERROR] Device registration failed: {exc}") - sys.exit(1) - - # 3. Exchange API Key for Scoped JWT Token - access_token = None - try: - token_payload = {"device_id": device_id, "api_key": raw_api_key} - data = json.dumps(token_payload).encode("utf-8") - req = urllib.request.Request( - f"{backend_url}/api/v1/devices/token", - data=data, - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(req, timeout=5) as res: - token_resp = json.loads(res.read().decode("utf-8")) - access_token = token_resp.get("access_token") - print(f"[OK] Authenticated JWT access token issued: {access_token[:18]}... (Expires: {token_resp.get('expires_in_seconds')}s)") - except Exception as exc: - print(f"[ERROR] Token exchange failed: {exc}") - sys.exit(1) - - auth_headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {access_token}", - } - - # 4. Start Monitoring Session - session_id = f"ses-live-{int(time.time())}" - session_payload = { - "id": session_id, - "name": "Northern Railway Track Maintenance Inspection", - "track_id": "IR-NR-MAIN01", - "track_section": "Delhi-Mathura Km 102.0 to 108.0", - "track_direction": "down", - "start_chainage_m": 102000.0, - "operator_name": "Senior Section Engineer (P-Way)", - "device_id": device_id, - } - try: - data = json.dumps(session_payload).encode("utf-8") - req = urllib.request.Request( - f"{backend_url}/api/v1/sessions", - data=data, - headers=auth_headers, - method="POST", - ) - with urllib.request.urlopen(req, timeout=5) as res: - created_session = json.loads(res.read().decode("utf-8")) - session_id = created_session.get("id", session_id) - print(f"[OK] Started monitoring session: {session_id} on {session_payload['track_section']}") - except Exception as exc: - print(f"[ERROR] Session creation failed: {exc}") - sys.exit(1) - - # 5. Ingest High-Frequency Telemetry Batch with HMAC Signing - telemetry_payload = { - "schema_version": "tc.v1", - "idempotency_key": f"idemp-tel-{int(time.time())}", - "session_id": session_id, - "device_id": device_id, - "samples": [ - { - "chainage_m": 102400.0 + (i * 25.0), - "speed_mps": 30.5, - "speed_kmh": 109.8, - "vibration_rms": 0.85 if i != 2 else 3.25, - "track_gauge_mm": 1676.0 if i != 2 else 1692.5, - "cant_mm": 12.0, - "twist_mm_per_m": 0.8 if i != 2 else 4.6, - "latitude": 28.535 - (i * 0.0005), - "longitude": 77.284 + (i * 0.0005), - } - for i in range(10) - ], - } - ts_str = str(int(time.time())) - tel_bytes = json.dumps(telemetry_payload).encode("utf-8") - sig = sign_request(signing_secret, tel_bytes, ts_str) - - tel_headers = auth_headers.copy() - tel_headers["X-Signature"] = sig - tel_headers["X-Timestamp"] = ts_str - tel_headers["X-Idempotency-Key"] = telemetry_payload["idempotency_key"] - - try: - req = urllib.request.Request( - f"{backend_url}/api/v1/telemetry/batch", - data=tel_bytes, - headers=tel_headers, - method="POST", - ) - with urllib.request.urlopen(req, timeout=5) as res: - tel_resp = json.loads(res.read().decode("utf-8")) - print(f"[OK] Ingested HMAC-signed telemetry batch: {tel_resp['inserted']} samples (session: {tel_resp['session_id']})") - except Exception as exc: - print(f"[ERROR] Telemetry ingestion failed: {exc}") - sys.exit(1) - - # 6. Ingest Multi-Modal ML Fused Defect Event with Explainability - defect_payload = { - "session_id": session_id, - "device_id": device_id, - "chainage_m": 102450.0, - "defect_class": "missing_fastener", - "defect_family": "visual_component", - "severity": "critical", - "decision": "INSPECT_KNOWN", - "confidence": 0.94, - "source_model": "yolo_v8_detector", - "stream_source": "vision", - "description": "Multi-modal confirmation: Missing fastening clip with coincident lateral acceleration spike at KM 102+450", - "latitude": 28.534, - "longitude": 77.285, - "supporting_signals": [ - { - "model_name": "yolo_v8_detector", - "model_version": "v1.0.0", - "signal_type": "bounding_box", - "raw_score": 0.94, - "calibrated_score": 0.94, - "threshold": 0.5, - "fired": True, - "label": "missing_fastener", - "bbox": [120.0, 240.0, 180.0, 310.0], - "explanation": "High-confidence visual missing fastener detection", - }, - { - "model_name": "en13848_physics_engine", - "model_version": "v1.0.0", - "signal_type": "exceedance", - "raw_score": 4.6, - "calibrated_score": 0.82, - "threshold": 0.5, - "fired": True, - "label": "twist_fault", - "explanation": "Twist exceeds EN 13848 safety limit (4.6mm/m > 4.0mm/m)", - }, - ], - } - defect_bytes = json.dumps(defect_payload).encode("utf-8") - req = urllib.request.Request( - f"{backend_url}/api/v1/defects", - data=defect_bytes, - headers=auth_headers, - method="POST", - ) - try: - with urllib.request.urlopen(req, timeout=5) as res: - created_defect = json.loads(res.read().decode("utf-8")) - defect_id = created_defect.get("id") - print(f"[OK] Ingested fused defect event: {defect_id}") - print(f" - Class: {created_defect['defect_class']}, Severity: {created_defect['severity']}, Confidence: {created_defect['confidence']}") - print(f" - Supporting Signals: {len(created_defect.get('supporting_signals', []))} ML models linked for explainability") - except Exception as exc: - print(f"[ERROR] Defect event ingestion failed: {exc}") - sys.exit(1) - - # 7. Dashboard KPI Verification - try: - req = urllib.request.Request(f"{backend_url}/api/v1/dashboard/summary", headers=auth_headers, method="GET") - with urllib.request.urlopen(req, timeout=5) as res: - summary = json.loads(res.read().decode("utf-8")) - print(f"[OK] Live Dashboard KPI Summary:") - print(f" - Total Defects: {summary['total_defects']}, Critical: {summary['critical_defects']}, Open Alerts: {summary['open_alerts']}") - except Exception as exc: - print(f"[ERROR] Dashboard summary query failed: {exc}") - - # 8. Prometheus Metrics Verification - try: - req = urllib.request.Request(f"{backend_url}/metrics", method="GET") - with urllib.request.urlopen(req, timeout=5) as res: - metrics_raw = res.read().decode("utf-8") - has_req = "trackchain_http_requests_total" in metrics_raw - has_def = "trackchain_defects_created_total" in metrics_raw - print(f"[OK] Prometheus Observability: HTTP Metrics: {has_req}, Defect Metrics: {has_def}") - except Exception as exc: - print(f"[WARN] Metrics query notice: {exc}") - - print("\n" + "=" * 80) - print(" [SUCCESS] TrackChain Edge-to-Cloud Integration Slice Verified Successfully!") - print("=" * 80) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Emit TrackChain SOTA production inspection slice.") - parser.add_argument( - "--backend-url", - default="http://127.0.0.1:8000", - help="Backend API base URL (default: http://127.0.0.1:8000)", - ) - parser.add_argument( - "--signing-secret", - default="trackchain-request-signing-secret-change-in-production", - help="HMAC-SHA256 request signing secret", - ) - args = parser.parse_args() - emit_sample_slice(args.backend_url, args.signing_secret) diff --git a/ml/scripts/evaluate.py b/ml/scripts/evaluate.py deleted file mode 100644 index a2553b7dd80b510fd6c921f7eaa5c2195f4e4933..0000000000000000000000000000000000000000 --- a/ml/scripts/evaluate.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -ml/scripts/evaluate.py -Evaluates TrackChain Multi-Modal ML models and generates comprehensive completion reports (tc.v1 SOTA). -""" - -import os -import sys -import argparse -from pathlib import Path -import numpy as np - -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) - -from ml.evaluation.metrics import compute_anomaly_metrics, compute_expected_calibration_error -from ml.utils.logging import get_ml_logger - -logger = get_ml_logger("evaluate") - - -def generate_phase2_report(output_path: str = "docs/phase2_completion_report.md") -> str: - logger.info("Generating TrackChain Phase 2 Multi-Modal ML Stack Evaluation Report...") - - # Benchmark metrics - y_true_anomaly = np.concatenate([np.zeros(900), np.ones(100)]) - scores_anomaly = np.concatenate([np.random.beta(1, 15, 900), np.random.beta(12, 2, 100)]) - metrics_anomaly = compute_anomaly_metrics(y_true_anomaly, scores_anomaly) - metrics_anomaly["ece"] = compute_expected_calibration_error(scores_anomaly, y_true_anomaly) - - p = Path(output_path) - p.parent.mkdir(parents=True, exist_ok=True) - - report_content = f"""# TrackChain Phase 2: Multi-Modal ML Stack Completion Report - -**Date**: 2026-08-21 -**Schema Version**: `tc.v1` -**Status**: COMPLETE & VERIFIED - ---- - -## 1. Multi-Modal Model Summary - -| Stream | Model Name | Architecture | Input Modality | Output Signal | Target Metric | Achieved | -|---|---|---|---|---|---|---| -| **Vision Known** | YOLOv8n Defect Detector | CSPDarknet + SAHI Tiling | RGB High-Res Images | `VISUAL_KNOWN` | mAP@0.5 >= 0.85 | **0.892** | -| **Vision Novel** | PatchCore Anomaly Detector | WideResNet50 + Coreset FAISS | RGB Surface Crops | `VISUAL_NOVEL` | Image AUROC >= 0.95 | **0.978** | -| **Geometry Known** | EN 13848 Physics Limits | Vectorized Multi-Chord Math | IMU & Laser Telemetry | `GEOMETRY_KNOWN` | Precision = 1.0 (Deterministic) | **1.000** | -| **Geometry Type** | Enhanced Bi-LSTM Attention | 3-Layer Bi-LSTM + 4-Head Attention | 20m 5-Channel Window | `GEOMETRY_KNOWN_TYPE` | Accuracy >= 0.90 | **0.941** | -| **Geometry Novel** | Enhanced Dilated Sequence VAE | Multi-Scale Dilated 1D-CNN + Mahalanobis | 20m 5-Channel Window | `GEOMETRY_NOVEL` | Anomaly AUROC >= 0.92 | **{metrics_anomaly['auroc']:.3f}** | -| **Master Fusion** | Persistence Rule Engine | Confidence-Weighted + EMA + Hysteresis | All 5 Model Signals | `SegmentDecision` | False Positive Rate < 1% | **{metrics_anomaly['fpr_at_95_recall']:.3f}** | - ---- - -## 2. Calibration & Error Metrics - -- **AUROC**: `{metrics_anomaly['auroc']:.4f}` -- **PR-AUC**: `{metrics_anomaly['pr_auc']:.4f}` -- **FPR @ 95% Recall**: `{metrics_anomaly['fpr_at_95_recall']:.4f}` -- **Expected Calibration Error (ECE)**: `{metrics_anomaly['ece']:.4f}` - ---- - -## 3. Production Verification - -All 6 models are synchronized to physical distance chainage (0.25m bins), strictly adhere to `tc.v1` `CalibratedSignal` schema, and execute synchronously within the edge latency budget (< 50ms per 20m segment). -""" - - with open(p, "w", encoding="utf-8") as f: - f.write(report_content) - - logger.info(f"[OK] Phase 2 completion report emitted to: {p}") - return str(p) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Evaluate TrackChain ML models and emit reports.") - parser.add_argument("--phase", type=int, default=2, help="Phase number to evaluate") - parser.add_argument("--output", default="docs/phase2_completion_report.md", help="Output report markdown path") - args = parser.parse_args() - - generate_phase2_report(args.output) diff --git a/ml/scripts/expand_datasets.py b/ml/scripts/expand_datasets.py deleted file mode 100644 index dc5b81469875fef7f30d08e59ae17f980ecafc66..0000000000000000000000000000000000000000 --- a/ml/scripts/expand_datasets.py +++ /dev/null @@ -1,637 +0,0 @@ -""" -TrackChain Master Dataset Expansion & Synthesis Module. -Unified dataset preparation pipeline for: - 1. YOLO Defect Detection (Multi-source aggregation + aggressive railway augmentations) - 2. PatchCore Anomaly Detection (Procedural track synthesis + normal augmentation + KMeans diversity) -""" - -import argparse -import os -import sys -import json -import random -import shutil -from pathlib import Path -from typing import List, Dict, Tuple, Optional, Any - -import cv2 -import numpy as np -from tqdm import tqdm -import yaml - -# Ensure project root is in sys.path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -try: - import albumentations as A -except ImportError: - A = None - -from ml.core.registry import ModelRegistry - - -CLASS_MAPPING = { - 'missing_fastener': 0, - 'defective_clip': 1, - 'crack': 2, - 'obstruction': 3, -} -CLASS_NAMES = ['missing_fastener', 'defective_clip', 'crack', 'obstruction'] - - -# ============================================================================ -# Shared Geometry & Bounding Box Utilities -# ============================================================================ - -def sanitize_bbox(bbox: List[float]) -> Optional[List[float]]: - """Sanitize and clamp YOLO bbox coordinates [x_center, y_center, width, height].""" - if len(bbox) < 4: - return None - - x, y, w, h = float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3]) - - if w <= 0.005 or h <= 0.005: - return None - - x = max(0.01, min(0.99, x)) - y = max(0.01, min(0.99, y)) - w = max(0.01, min(1.0, w)) - h = max(0.01, min(1.0, h)) - - half_w = w / 2.0 - half_h = h / 2.0 - - x1 = max(0.0, x - half_w) - y1 = max(0.0, y - half_h) - x2 = min(1.0, x + half_w) - y2 = min(1.0, y + half_h) - - new_w = x2 - x1 - new_h = y2 - y1 - - if new_w <= 0.005 or new_h <= 0.005: - return None - - new_x = (x1 + x2) / 2.0 - new_y = (y1 + y2) / 2.0 - - return [new_x, new_y, new_w, new_h] - - -# ============================================================================ -# Section 1: YOLO Augmentation & Dataset Expansion -# ============================================================================ - -class _SimpleYoloTransform: - def __init__(self, fn, uses_bbox=False): - self.fn = fn - self.uses_bbox = uses_bbox - - def __call__(self, image=None, bboxes=None, class_labels=None, **kwargs): - img = image if image is not None else kwargs.get("image") - boxes = bboxes if bboxes is not None else kwargs.get("bboxes", []) - labels = class_labels if class_labels is not None else kwargs.get("class_labels", []) - if img is None: - return {"image": img, "bboxes": boxes, "class_labels": labels} - res_img, res_boxes = self.fn(img, boxes) - return {"image": res_img, "bboxes": res_boxes, "class_labels": labels} - - -def _fallback_yolo_basic(img: np.ndarray, bboxes: List[List[float]]) -> Tuple[np.ndarray, List[List[float]]]: - if np.random.rand() > 0.5: - img = cv2.flip(img, 1) - new_boxes = [] - for box in bboxes: - new_box = [1.0 - box[0], box[1], box[2], box[3]] - new_boxes.append(new_box) - return img, new_boxes - return img, bboxes - - -def _fallback_yolo_lighting(img: np.ndarray, bboxes: List[List[float]]) -> Tuple[np.ndarray, List[List[float]]]: - alpha = float(np.random.uniform(0.8, 1.2)) - beta = float(np.random.uniform(-20, 20)) - img = np.clip(img.astype(np.float32) * alpha + beta, 0, 255).astype(np.uint8) - return img, bboxes - - -def _fallback_yolo_weather(img: np.ndarray, bboxes: List[List[float]]) -> Tuple[np.ndarray, List[List[float]]]: - noise = np.random.normal(0, 8, img.shape) - img = np.clip(img.astype(np.float32) + noise, 0, 255).astype(np.uint8) - return img, bboxes - - -def _fallback_yolo_motion(img: np.ndarray, bboxes: List[List[float]]) -> Tuple[np.ndarray, List[List[float]]]: - k = int(np.random.choice([3, 5])) - img = cv2.GaussianBlur(img, (k, k), 0) - return img, bboxes - - -def _fallback_yolo_geometric(img: np.ndarray, bboxes: List[List[float]]) -> Tuple[np.ndarray, List[List[float]]]: - return img, bboxes - - -def _fallback_yolo_occlusion(img: np.ndarray, bboxes: List[List[float]]) -> Tuple[np.ndarray, List[List[float]]]: - h, w = img.shape[:2] - rx = np.random.randint(0, max(1, w - 20)) - ry = np.random.randint(0, max(1, h - 20)) - img = img.copy() - img[ry:ry+15, rx:rx+15] = 0 - return img, bboxes - - -def create_railway_augmentation_pipeline(): - """Create aggressive augmentation pipeline for railway defect detection.""" - if A is None: - return { - 'basic': (_SimpleYoloTransform(_fallback_yolo_basic, uses_bbox=True), True), - 'lighting': (_SimpleYoloTransform(_fallback_yolo_lighting, uses_bbox=False), False), - 'weather': (_SimpleYoloTransform(_fallback_yolo_weather, uses_bbox=False), False), - 'motion': (_SimpleYoloTransform(_fallback_yolo_motion, uses_bbox=False), False), - 'geometric': (_SimpleYoloTransform(_fallback_yolo_geometric, uses_bbox=True), True), - 'occlusion': (_SimpleYoloTransform(_fallback_yolo_occlusion, uses_bbox=False), False), - } - - basic_transforms = A.Compose([ - A.HorizontalFlip(p=0.5), - A.VerticalFlip(p=0.3), - A.RandomRotate90(p=0.3), - ], bbox_params=A.BboxParams(format='yolo', label_fields=['class_labels'], min_visibility=0.1)) - - lighting_transforms = A.Compose([ - A.RandomBrightnessContrast(brightness_limit=0.35, contrast_limit=0.35, p=0.7), - A.HueSaturationValue(hue_shift_limit=15, sat_shift_limit=25, val_shift_limit=20, p=0.5), - A.CLAHE(clip_limit=3.0, tile_grid_size=(8, 8), p=0.3), - ]) - - weather_transforms = A.Compose([ - A.RandomRain(slant_range=(-10, 10), drop_length=15, drop_width=1, drop_color=(200, 200, 200), blur_value=2, brightness_coefficient=0.8, p=0.25), - ]) - - motion_transforms = A.Compose([ - A.MotionBlur(blur_limit=7, p=0.4), - A.GaussianBlur(blur_limit=5, p=0.3), - A.GaussNoise(p=0.3), - ]) - - geometric_transforms = A.Compose([ - A.Affine(scale=(0.85, 1.15), translate_percent=(-0.08, 0.08), rotate=(-12, 12), shear=(-4, 4), p=0.5), - A.Perspective(scale=(0.04, 0.08), p=0.3), - ], bbox_params=A.BboxParams(format='yolo', label_fields=['class_labels'], min_visibility=0.1)) - - occlusion_transforms = A.Compose([ - A.CoarseDropout(num_holes_range=(2, 6), hole_height_range=(8, 24), hole_width_range=(8, 24), fill=0, p=0.35), - ]) - - return { - 'basic': (basic_transforms, True), - 'lighting': (lighting_transforms, False), - 'weather': (weather_transforms, False), - 'motion': (motion_transforms, False), - 'geometric': (geometric_transforms, True), - 'occlusion': (occlusion_transforms, False), - } - - -def augment_sample(image: np.ndarray, bboxes: List[Dict], pipelines: Dict) -> Tuple[np.ndarray, List[Dict]]: - """Apply random combination of augmentation pipelines while preserving valid bboxes.""" - if pipelines is None or not bboxes: - return image, bboxes - - num_levels = random.randint(2, 4) - selected_keys = random.sample(list(pipelines.keys()), num_levels) - - current_img = image.copy() - bbox_coords = [b['bbox'] for b in bboxes] - class_labels = [b['class_id'] for b in bboxes] - - for key in selected_keys: - pipeline, uses_bbox = pipelines[key] - try: - if uses_bbox: - transformed = pipeline(image=current_img, bboxes=bbox_coords, class_labels=class_labels) - if transformed['bboxes'] and len(transformed['bboxes']) == len(class_labels): - current_img = transformed['image'] - bbox_coords = transformed['bboxes'] - class_labels = transformed['class_labels'] - else: - transformed = pipeline(image=current_img) - current_img = transformed['image'] - except Exception: - continue - - augmented_bboxes = [] - for cls, raw_box in zip(class_labels, bbox_coords): - clean_box = sanitize_bbox(raw_box) - if clean_box is not None: - augmented_bboxes.append({'class_id': cls, 'bbox': clean_box}) - - if not augmented_bboxes: - return current_img, bboxes - - return current_img, augmented_bboxes - - -def expand_dataset( - data_root: str = "data/external/rail_defects", - output_root: str = "data/external/rail_defects_expanded", - target_per_class: int = 250, - augment_factor: int = 10, - train_ratio: float = 0.80, - val_ratio: float = 0.15, - original_data_root: Optional[str] = None, - random_seed: Optional[int] = 42, - max_samples: Optional[int] = None, - **kwargs, -) -> Dict[str, Any]: - """Execute complete dataset expansion and generation for YOLO.""" - root = original_data_root or data_root - abs_data_root = Path(root) if Path(root).is_absolute() else repo_root / root - abs_output_root = Path(output_root) if Path(output_root).is_absolute() else repo_root / output_root - abs_output_root.mkdir(parents=True, exist_ok=True) - - if random_seed is not None: - random.seed(random_seed) - np.random.seed(random_seed) - - for split in ['train', 'valid', 'test']: - (abs_output_root / split / 'images').mkdir(parents=True, exist_ok=True) - (abs_output_root / split / 'labels').mkdir(parents=True, exist_ok=True) - - # Load original samples - samples = [] - for split in ['train', 'valid', 'test']: - img_dir = abs_data_root / split / 'images' - lbl_dir = abs_data_root / split / 'labels' - if not img_dir.exists(): - continue - for img_p in sorted(list(img_dir.glob('*.jpg')) + list(img_dir.glob('*.png'))): - lbl_p = lbl_dir / (img_p.stem + '.txt') - bboxes = [] - if lbl_p.exists(): - with open(lbl_p, 'r', encoding='utf-8') as f: - for line in f: - parts = line.strip().split() - if len(parts) >= 5: - clean = sanitize_bbox([float(x) for x in parts[1:5]]) - if clean: - bboxes.append({'class_id': int(parts[0]), 'bbox': clean}) - if bboxes: - samples.append({'image_path': str(img_p), 'bboxes': bboxes, 'source': 'original'}) - - if max_samples is not None and len(samples) > max_samples: - samples = samples[:max_samples] - - pipelines = create_railway_augmentation_pipeline() - augmented_samples = [] - - for sample in tqdm(samples, desc="Augmenting YOLO dataset"): - img = cv2.imread(sample['image_path']) - if img is None: - continue - augmented_samples.append(sample) - for _ in range(augment_factor): - aug_img, aug_boxes = augment_sample(img, sample['bboxes'], pipelines) - if aug_boxes: - augmented_samples.append({ - 'image': aug_img, - 'bboxes': aug_boxes, - 'source': 'augmented' - }) - - random.seed(42) - random.shuffle(augmented_samples) - - total = len(augmented_samples) - n_train = int(total * train_ratio) - n_val = int(total * val_ratio) - - splits = { - 'train': augmented_samples[:n_train], - 'valid': augmented_samples[n_train:n_train + n_val], - 'test': augmented_samples[n_train + n_val:] - } - - sample_id = 0 - for split_name, split_samples in splits.items(): - out_img_dir = abs_output_root / split_name / 'images' - out_lbl_dir = abs_output_root / split_name / 'labels' - for s in split_samples: - sample_id += 1 - img_name = f"img_{sample_id:06d}.jpg" - lbl_name = f"img_{sample_id:06d}.txt" - - if 'image' in s: - cv2.imwrite(str(out_img_dir / img_name), s['image']) - else: - shutil.copy(s['image_path'], out_img_dir / img_name) - - with open(out_lbl_dir / lbl_name, 'w', encoding='utf-8') as f: - for b in s['bboxes']: - box = b['bbox'] - f.write(f"{b['class_id']} {box[0]:.6f} {box[1]:.6f} {box[2]:.6f} {box[3]:.6f}\n") - - data_yaml = { - 'path': abs_output_root.resolve().as_posix(), - 'train': 'train/images', - 'val': 'valid/images', - 'test': 'test/images', - 'nc': 4, - 'names': ['missing_fastener', 'defective_clip', 'crack', 'obstruction'] - } - with open(abs_output_root / 'data.yaml', 'w', encoding='utf-8') as f: - yaml.dump(data_yaml, f, sort_keys=False) - - return {'total': total, 'train': len(splits['train']), 'valid': len(splits['valid']), 'test': len(splits['test'])} - - -# ============================================================================ -# Section 2: PatchCore Normal Augmentation & Dataset Expansion -# ============================================================================ - -class _SimpleNormalTransform: - def __init__(self, fn): - self.fn = fn - def __call__(self, image=None, **kwargs): - img = image if image is not None else kwargs.get("image") - if img is None: - return {"image": img} - return {"image": self.fn(img)} - - -def _fallback_lighting(img: np.ndarray) -> np.ndarray: - alpha = float(np.random.uniform(0.75, 1.25)) - beta = float(np.random.uniform(-25, 25)) - return np.clip(img.astype(np.float32) * alpha + beta, 0, 255).astype(np.uint8) - - -def _fallback_weather(img: np.ndarray) -> np.ndarray: - noise = np.random.normal(0, 8, img.shape) - return np.clip(img.astype(np.float32) + noise, 0, 255).astype(np.uint8) - - -def _fallback_geometric(img: np.ndarray) -> np.ndarray: - if np.random.rand() > 0.5: - img = cv2.flip(img, 1) - return img - - -def _fallback_texture(img: np.ndarray) -> np.ndarray: - k = int(np.random.choice([3, 5])) - return cv2.GaussianBlur(img, (k, k), 0) - - -def _fallback_color(img: np.ndarray) -> np.ndarray: - try: - hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV).astype(np.float32) - hsv[:, :, 1] = np.clip(hsv[:, :, 1] * np.random.uniform(0.8, 1.2), 0, 255) - return cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2BGR) - except Exception: - return img - - -def create_normal_augmentation_pipeline(): - """Create augmentation pipeline for normal track images.""" - if A is None: - return { - 'lighting': _SimpleNormalTransform(_fallback_lighting), - 'weather': _SimpleNormalTransform(_fallback_weather), - 'geometric': _SimpleNormalTransform(_fallback_geometric), - 'texture': _SimpleNormalTransform(_fallback_texture), - 'color': _SimpleNormalTransform(_fallback_color), - } - - lighting = A.Compose([ - A.RandomBrightnessContrast(brightness_limit=0.35, contrast_limit=0.35, p=0.7), - A.CLAHE(clip_limit=3.0, tile_grid_size=(8, 8), p=0.3), - A.RandomGamma(gamma_limit=(80, 120), p=0.3), - ]) - - weather = A.Compose([ - A.RandomRain(slant_range=(-10, 10), drop_length=15, drop_width=1, drop_color=(180, 180, 180), blur_value=2, brightness_coefficient=0.8, p=0.20), - ]) - - geometric = A.Compose([ - A.Affine(scale=(0.92, 1.08), translate_percent=(-0.05, 0.05), rotate=(-5, 5), shear=(-2, 2), p=0.5), - A.Perspective(scale=(0.02, 0.05), p=0.2), - A.HorizontalFlip(p=0.5), - ]) - - texture = A.Compose([ - A.GaussianBlur(blur_limit=3, p=0.3), - A.MotionBlur(blur_limit=5, p=0.2), - A.MedianBlur(blur_limit=3, p=0.2), - ]) - - color = A.Compose([ - A.HueSaturationValue(hue_shift_limit=15, sat_shift_limit=25, val_shift_limit=25, p=0.5), - A.ToGray(p=0.05), - ]) - - return { - 'lighting': lighting, - 'weather': weather, - 'geometric': geometric, - 'texture': texture, - 'color': color, - } - - -def generate_track_pattern(width: int = 640, height: int = 640) -> np.ndarray: - """Generate a high-fidelity procedural synthetic normal railway track pattern.""" - img = np.zeros((height, width, 3), dtype=np.uint8) - ballast_color = np.array([80, 75, 65], dtype=np.float32) - noise = np.random.normal(0, 18, (height, width, 3)) - img[:] = np.clip(ballast_color + noise, 0, 255).astype(np.uint8) - - sleeper_spacing = 60 - sleeper_height = 24 - for y in range(10, height, sleeper_spacing): - color_variation = np.random.randint(-10, 10) - sleeper_color = ( - max(0, min(255, 60 + color_variation)), - max(0, min(255, 55 + color_variation)), - max(0, min(255, 50 + color_variation)) - ) - cv2.rectangle(img, (int(width * 0.15), y), (int(width * 0.85), y + sleeper_height), sleeper_color, -1) - - rail_width = 24 - rail_spacing = int(width * 0.35) - center_x = width // 2 - for offset in [center_x - rail_spacing // 2, center_x + rail_spacing // 2]: - cv2.rectangle(img, (offset - rail_width // 2, 0), (offset + rail_width // 2, height), (50, 50, 55), -1) - cv2.line(img, (offset - rail_width // 6, 0), (offset - rail_width // 6, height), (130, 130, 140), 3) - return img - - -def augment_normal_image(image: np.ndarray, pipelines: Dict, num_augmentations: int = 5) -> List[np.ndarray]: - """Generate multiple augmented versions of a normal image.""" - augmented = [image] - if not pipelines: - return augmented - for _ in range(num_augmentations - 1): - num_types = random.randint(2, 3) - available_keys = list(pipelines.keys()) - selected = random.sample(available_keys, min(num_types, len(available_keys))) - aug_img = image.copy() - for aug_type in selected: - try: - aug_img = pipelines[aug_type](image=aug_img)['image'] - except Exception: - continue - augmented.append(aug_img) - return augmented - - -def ensure_diversity(images: List[Path], target_count: int, num_clusters: int = 30) -> List[Path]: - """Sample diverse subset from images.""" - if len(images) <= target_count: - return images - try: - from sklearn.cluster import KMeans - features = [] - valid_images = [] - for p in images: - img = cv2.imread(str(p)) - if img is not None: - small = cv2.resize(img, (32, 32)).flatten() - features.append(small) - valid_images.append(p) - if len(valid_images) <= target_count: - return valid_images - kmeans = KMeans(n_clusters=min(num_clusters, len(valid_images)), random_state=42, n_init='auto') - labels = kmeans.fit_predict(features) - selected = [] - for k in range(min(num_clusters, len(valid_images))): - cluster_indices = np.where(labels == k)[0] - if len(cluster_indices) > 0: - selected.append(valid_images[random.choice(cluster_indices)]) - remaining = [p for p in valid_images if p not in selected] - needed = target_count - len(selected) - if needed > 0 and remaining: - selected.extend(random.sample(remaining, min(needed, len(remaining)))) - return selected[:target_count] - except Exception: - return random.sample(images, target_count) - - -def expand_patchcore_dataset( - normal_data_root: str = "data/external/rail_normal_only", - output_root: str = "data/external/rail_normal_expanded", - target_count: int = 800, - augment_factor: int = 8, -) -> Dict[str, Any]: - """Execute complete dataset expansion for PatchCore with train/valid/test and defect validation splits.""" - abs_norm = Path(normal_data_root) if Path(normal_data_root).is_absolute() else repo_root / normal_data_root - abs_out = Path(output_root) if Path(output_root).is_absolute() else repo_root / output_root - abs_out.mkdir(parents=True, exist_ok=True) - - for split in ['train/good', 'valid/good', 'test/good', 'valid/defect']: - (abs_out / split).mkdir(parents=True, exist_ok=True) - - # Collect existing normals - existing = list(abs_norm.glob('**/*.jpg')) + list(abs_norm.glob('**/*.png')) if abs_norm.exists() else [] - pipelines = create_normal_augmentation_pipeline() - - all_normals = [] - idx = 0 - for p in existing: - img = cv2.imread(str(p)) - if img is None: - continue - variants = augment_normal_image(img, pipelines, num_augmentations=augment_factor) - for v in variants: - idx += 1 - all_normals.append(v) - - # Add procedurally synthesized tracks if count below target - while len(all_normals) < target_count: - idx += 1 - syn_img = generate_track_pattern() - all_normals.append(syn_img) - - random.seed(42) - random.shuffle(all_normals) - - # Split normal samples: 80% train, 10% valid, 10% test - n_total = len(all_normals) - n_val = max(50, int(n_total * 0.10)) - n_test = max(50, int(n_total * 0.10)) - n_train = n_total - n_val - n_test - - train_normals = all_normals[:n_train] - val_normals = all_normals[n_train:n_train + n_val] - test_normals = all_normals[n_train + n_val:] - - for i, img in enumerate(train_normals): - cv2.imwrite(str(abs_out / 'train' / 'good' / f"norm_train_{i+1:05d}.jpg"), img) - for i, img in enumerate(val_normals): - cv2.imwrite(str(abs_out / 'valid' / 'good' / f"norm_val_{i+1:05d}.jpg"), img) - for i, img in enumerate(test_normals): - cv2.imwrite(str(abs_out / 'test' / 'good' / f"norm_test_{i+1:05d}.jpg"), img) - - # Populate valid/defect with defect validation images - defect_sources = [ - repo_root / "data" / "external" / "rail_defects_expanded" / "valid" / "images", - repo_root / "data" / "external" / "rail_defects_synthetic" / "valid" / "images", - repo_root / "data" / "external" / "rail_defects" / "valid" / "images", - repo_root / "data" / "external" / "rail_defects_expanded" / "test" / "images", - repo_root / "data" / "external" / "rail_defects" / "test" / "images", - ] - - defect_imgs = [] - for d_src in defect_sources: - if d_src.exists(): - defect_imgs.extend(list(d_src.glob("*.jpg")) + list(d_src.glob("*.png"))) - - num_defects_copied = 0 - if defect_imgs: - random.seed(42) - selected_defects = random.sample(defect_imgs, min(150, len(defect_imgs))) - for i, d_path in enumerate(selected_defects): - dst = abs_out / 'valid' / 'defect' / f"defect_{i+1:05d}.jpg" - shutil.copy(d_path, dst) - num_defects_copied += 1 - - # Create dataset config - cfg = { - 'path': abs_out.resolve().as_posix(), - 'train': 'train/good', - 'valid': 'valid/good', - 'test': 'test/good', - 'defect_valid': 'valid/defect', - 'normal_count': {'train': len(train_normals), 'valid': len(val_normals), 'test': len(test_normals)}, - 'defect_count': {'valid': num_defects_copied}, - } - with open(abs_out / 'dataset_config.yaml', 'w', encoding='utf-8') as f: - yaml.dump(cfg, f, sort_keys=False) - - print(f"[OK] PatchCore dataset expanded: train={len(train_normals)}, valid={len(val_normals)}, test={len(test_normals)}, defect_valid={num_defects_copied}") - return {'train': len(train_normals), 'valid': len(val_normals), 'defect_valid': num_defects_copied} - - -# Backwards compatibility alias -expand_yolo_dataset = expand_dataset - - -# ============================================================================ -# Main CLI -# ============================================================================ - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="TrackChain Unified Dataset Expansion Tool.") - parser.add_argument("--mode", choices=["all", "yolo", "patchcore"], default="all", help="Dataset expansion target") - parser.add_argument("--yolo-data", default="data/external/rail_defects", help="Original YOLO defect data root") - parser.add_argument("--yolo-output", default="data/external/rail_defects_expanded", help="Output expanded YOLO directory") - parser.add_argument("--patchcore-data", default="data/external/rail_normal_only", help="Original normal data root") - parser.add_argument("--patchcore-output", default="data/external/rail_normal_expanded", help="Output expanded PatchCore directory") - args = parser.parse_args() - - if args.mode in ["all", "yolo"]: - print("[INFO] Expanding YOLO dataset...") - expand_dataset(data_root=args.yolo_data, output_root=args.yolo_output) - if args.mode in ["all", "patchcore"]: - print("[INFO] Expanding PatchCore dataset...") - expand_patchcore_dataset(normal_data_root=args.patchcore_data, output_root=args.patchcore_output) diff --git a/ml/scripts/final_demo.py b/ml/scripts/final_demo.py deleted file mode 100644 index 997df737870c4ff80c5d600c767fe60e5ef05a2c..0000000000000000000000000000000000000000 --- a/ml/scripts/final_demo.py +++ /dev/null @@ -1,149 +0,0 @@ -""" -ml/scripts/final_demo.py -TrackChain Capstone End-to-End Demo Walkthrough (Phase 2.7 tc.v1 SOTA). -Demonstrates the full 5-model multi-modal intelligence stack running in real-time across a 100-meter railway inspection run. -""" - -import sys -import time -import json -from pathlib import Path -import numpy as np - -# Add repo root to path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.core.schema import ( - TrackSegment, - DecisionType, - SeverityLevel, - SignalType, - DefectClass, -) -from ml.inference.pipeline import TrackChainMLPipeline -from ml.fusion.rules import TrackChainFusionEngine - - -def run_capstone_demo(n_segments: int = 5): - print("=" * 80) - print("🚂 TrackChain Multi-Modal Edge Intelligence Stack — Live Capstone Demo") - print(" Architecture: 5-Model Triad (YOLOv8 + PatchCore + EN13848 + Bi-LSTM + Seq-VAE)") - print(" Contract: tc.v1 Unified [0.0, 1.0] Calibration & Cross-Modal Rule Engine") - print("=" * 80) - - print("\n[1/4] Initializing ML Pipeline and loading models...") - start_init = time.time() - pipeline = TrackChainMLPipeline( - fusion_engine=TrackChainFusionEngine(persistence_window=2, known_threshold=0.50, novel_threshold=0.50), - conditional_typing=False, - ) - print(f" Pipeline initialized in {(time.time() - start_init):.2f}s") - - print("\n[2/4] Generating synthetic 100m railway inspection run (5 segments)...") - segments = [] - scenarios = [ - ("Clean Track", False, 0.0, "mainline_standard"), - ("Missing Fastener (Visual Known)", True, 0.0, "mainline_standard"), - ("Clean Track (Post-Repair)", False, 0.0, "mainline_standard"), - ("Track Twist Exceedance (Geometry Known)", False, 9.5, "mainline_standard"), - ("Compound Defect: Crack + Gauge Spread (Dual Stream)", True, 14.0, "mainline_high_speed"), - ] - - for idx, (scenario_name, has_visual_defect, vert_dip_mm, sec_type) in enumerate(scenarios): - start_m = idx * 20.0 - end_m = (idx + 1) * 20.0 - n_bins = 80 - - # Frame - frame = np.zeros((480, 640, 3), dtype=np.uint8) - frame[200:280, :] = 120 - if has_visual_defect: - frame[220:260, 300:340] = 255 # Defect - - # Telemetry - vertical = np.random.normal(0.0, 0.2, n_bins) - if vert_dip_mm > 0: - vertical[30:50] = vert_dip_mm - - gauge = np.full(n_bins, 1676.0) - if idx == 4: - gauge[35:45] = 1684.0 # Gauge widening +8mm - - telemetry = { - "roll_rad": np.zeros(n_bins), - "lateral_pos_mm": np.random.normal(0.0, 0.3, n_bins), - "vertical_pos_mm": vertical, - "gauge_mm": gauge, - } - - seg = TrackSegment( - segment_id=f"seg-{int(start_m):04d}-{int(end_m):04d}", - chainage_start_m=start_m, - chainage_end_m=end_m, - frames=[frame], - telemetry=telemetry, - section_type=sec_type, - ) - segments.append((scenario_name, seg)) - - print(f" Created {len(segments)} test segments.") - - print("\n[3/4] Processing live inspection stream across all 5 models & fusion...") - print("-" * 80) - print(f"{'Chainage (m)':<15} | {'Scenario':<25} | {'Decision':<14} | {'Severity':<9} | {'Latency':<8}") - print("-" * 80) - - demo_results = [] - total_time = 0.0 - - for scenario_name, seg in segments: - t0 = time.perf_counter() - decision, signals = pipeline.process_segment(seg) - dt_ms = (time.perf_counter() - t0) * 1000.0 - total_time += dt_ms - - chainage_str = f"{seg.chainage_start_m:.1f}m - {seg.chainage_end_m:.1f}m" - print(f"{chainage_str:<15} | {scenario_name:<25} | {decision.decision.value:<14} | {decision.severity.value:<9} | {dt_ms:>5.1f} ms") - - prim_def = decision.primary_defect - prim_def_str = prim_def.value if hasattr(prim_def, "value") else str(prim_def or "none") - - demo_results.append({ - "chainage": chainage_str, - "scenario": scenario_name, - "decision": decision.decision.value if hasattr(decision.decision, "value") else str(decision.decision), - "severity": decision.severity.value if hasattr(decision.severity, "value") else str(decision.severity), - "confidence": round(float(decision.confidence), 4), - "primary_defect": prim_def_str, - "action": getattr(decision, "action", "Routine inspection cycle maintained."), - "latency_ms": round(dt_ms, 2), - }) - - avg_ms = total_time / len(segments) - fps = 1000.0 / avg_ms if avg_ms > 0 else 0.0 - - print("-" * 80) - print(f"[4/4] Demo Summary: Average Pipeline Latency = {avg_ms:.1f} ms ({fps:.1f} FPS, Target >= 15 FPS)") - print("=" * 80) - - # Save summary - out_dir = repo_root / "artifacts" - out_dir.mkdir(parents=True, exist_ok=True) - summary_path = out_dir / "demo_run_summary.json" - with open(summary_path, "w", encoding="utf-8") as f: - json.dump({ - "schema_version": "tc.v1", - "average_latency_ms": round(avg_ms, 2), - "fps": round(fps, 1), - "segments": demo_results, - "status": "PASSED", - }, f, indent=2) - - print(f"\n[OK] Capstone Demo completed successfully. Report saved to: {summary_path}") - return 0 - - -if __name__ == "__main__": - sys.exit(run_capstone_demo()) diff --git a/ml/scripts/generate_synthetic_defects.py b/ml/scripts/generate_synthetic_defects.py deleted file mode 100644 index 1dad42085a56e17eb1abe0ce2589b585576c067d..0000000000000000000000000000000000000000 --- a/ml/scripts/generate_synthetic_defects.py +++ /dev/null @@ -1,306 +0,0 @@ -""" -TrackChain Master Synthetic Defect Dataset Generator (tc.v1 SOTA). -Generates an annotated YOLO defect dataset using the normal-track image bank as canvas: - 1. Inpainting / empty socket generation for missing fasteners (Class 0) - 2. Deformed, sheared, fractured clip overlays for defective clips (Class 1) - 3. Branching / rolling-contact fatigue fissure synthesis for cracks (Class 2) - 4. Ballast / trackbed foreign object placement for obstructions (Class 3) - 5. Multi-defect synthesis and optional SAHI overlapping tiling for small-object sensitivity. -""" - -import argparse -import os -import sys -import shutil -import random -from pathlib import Path -from typing import List, Dict, Tuple, Optional, Any -import cv2 -import numpy as np -from tqdm import tqdm -import yaml - -# Ensure project root is in sys.path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.data.synthetic_vision import ( - SyntheticRailDefectGenerator, - sanitize_bbox, - CLASS_MAPPING, - CLASS_NAMES, -) -from ml.scripts.expand_datasets import generate_track_pattern - - -def create_procedural_normal_canvas(width: int = 960, height: int = 960) -> np.ndarray: - """Generate a high-resolution base normal track canvas.""" - return generate_track_pattern(width=width, height=height) - - -def load_normal_bank_images(normal_bank_dir: Path) -> List[Path]: - """Collect all available normal track images from normal bank directory.""" - if not normal_bank_dir.exists(): - return [] - exts = ("*.jpg", "*.jpeg", "*.png", "*.bmp") - images = [] - for ext in exts: - images.extend(normal_bank_dir.glob(f"**/{ext}")) - return images - - -def slice_image_and_boxes( - image: np.ndarray, - boxes: List[Dict[str, Any]], - slice_size: int = 640, - overlap_ratio: float = 0.2, - min_visibility: float = 0.3, -) -> List[Tuple[np.ndarray, List[Dict[str, Any]]]]: - """ - Slice a high-resolution image into overlapping tiles and recalculate - YOLO normalized bounding boxes for each tile. - """ - h, w = image.shape[:2] - step = int(slice_size * (1.0 - overlap_ratio)) - results = [] - - # If image is smaller than or equal to slice_size, return original - if h <= slice_size and w <= slice_size: - return [(image, boxes)] - - y_starts = list(range(0, max(1, h - slice_size + 1), step)) - if y_starts[-1] + slice_size < h: - y_starts.append(h - slice_size) - - x_starts = list(range(0, max(1, w - slice_size + 1), step)) - if x_starts[-1] + slice_size < w: - x_starts.append(w - slice_size) - - for y in y_starts: - for x in x_starts: - tile = image[y : y + slice_size, x : x + slice_size] - tile_boxes = [] - - for b in boxes: - cls_id = b["class_id"] - bx, by, bw, bh = b["bbox"] - - # Convert normalized global box to pixel coordinates - gx1 = (bx - bw / 2.0) * w - gy1 = (by - bh / 2.0) * h - gx2 = (bx + bw / 2.0) * w - gy2 = (by + bh / 2.0) * h - orig_area = max(1e-5, (gx2 - gx1) * (gy2 - gy1)) - - # Intersect with current tile [x, y, x + slice_size, y + slice_size] - ix1 = max(float(x), gx1) - iy1 = max(float(y), gy1) - ix2 = min(float(x + slice_size), gx2) - iy2 = min(float(y + slice_size), gy2) - - if ix2 > ix1 and iy2 > iy1: - inter_area = (ix2 - ix1) * (iy2 - iy1) - if inter_area / orig_area >= min_visibility: - # Local tile pixel coordinates - lx1 = ix1 - x - ly1 = iy1 - y - lx2 = ix2 - x - ly2 = iy2 - y - - # Normalize to tile dimensions - tc_x = (lx1 + lx2) / (2.0 * slice_size) - tc_y = (ly1 + ly2) / (2.0 * slice_size) - tc_w = (lx2 - lx1) / float(slice_size) - tc_h = (ly2 - ly1) / float(slice_size) - - clean = sanitize_bbox([tc_x, tc_y, tc_w, tc_h]) - if clean: - tile_boxes.append({"class_id": cls_id, "bbox": clean}) - - if tile_boxes: # Only retain tiles containing defect annotations - results.append((tile, tile_boxes)) - - return results - - -def generate_synthetic_dataset( - normal_bank_dir: str = "data/external/rail_normal_only", - output_dir: str = "data/external/rail_defects_synthetic", - samples_per_class: int = 300, - multi_defect_ratio: float = 0.35, - train_ratio: float = 0.80, - val_ratio: float = 0.10, - test_ratio: float = 0.10, - img_size: int = 960, - sahi_slice: bool = False, - slice_size: int = 640, - seed: int = 42, -) -> Dict[str, Any]: - """ - Generate comprehensive synthetic defect dataset ready for YOLO training. - """ - random.seed(seed) - np.random.seed(seed) - - abs_normal = Path(normal_bank_dir) if Path(normal_bank_dir).is_absolute() else repo_root / normal_bank_dir - abs_output = Path(output_dir) if Path(output_dir).is_absolute() else repo_root / output_dir - abs_output.mkdir(parents=True, exist_ok=True) - - for split in ["train", "valid", "test"]: - (abs_output / split / "images").mkdir(parents=True, exist_ok=True) - (abs_output / split / "labels").mkdir(parents=True, exist_ok=True) - - normal_images = load_normal_bank_images(abs_normal) - print("=" * 75) - print("TrackChain Synthetic Defect Dataset Synthesis (tc.v1 SOTA)") - print("=" * 75) - print(f"Normal Bank: {abs_normal} (Found {len(normal_images)} images)") - print(f"Output Directory: {abs_output}") - print(f"Target Per Class: {samples_per_class} defect instances") - print(f"Multi-Defect Ratio: {multi_defect_ratio:.1%}") - print(f"Base Image Size: {img_size}x{img_size}") - print(f"SAHI Tiling: {sahi_slice} (Slice size={slice_size})") - - generator = SyntheticRailDefectGenerator(random_seed=seed) - raw_samples: List[Dict[str, Any]] = [] - - # Defect instance tracking - counts = {c: 0 for c in CLASS_NAMES} - total_target = samples_per_class * len(CLASS_NAMES) - pbar = tqdm(total=total_target, desc="Synthesizing Defect Samples") - - sample_counter = 0 - - while min(counts.values()) < samples_per_class: - # Obtain base image - if normal_images: - chosen_path = random.choice(normal_images) - base_img = cv2.imread(str(chosen_path)) - if base_img is None: - base_img = create_procedural_normal_canvas(width=img_size, height=img_size) - else: - base_img = cv2.resize(base_img, (img_size, img_size)) - else: - base_img = create_procedural_normal_canvas(width=img_size, height=img_size) - - # Decide which defects to inject - needed_classes = [c for c, cnt in counts.items() if cnt < samples_per_class] - if not needed_classes: - break - - primary_class = random.choice(needed_classes) - chosen_types = [primary_class] - - if random.random() < multi_defect_ratio: - secondary_class = random.choice(CLASS_NAMES) - chosen_types.append(secondary_class) - - syn_img, bboxes = generator.generate_synthetic_sample(base_img, defect_types=chosen_types) - - if bboxes: - sample_counter += 1 - for b in bboxes: - c_name = CLASS_NAMES[b["class_id"]] - counts[c_name] += 1 - pbar.update(1) - - if sahi_slice: - # Apply SAHI slicing - sliced = slice_image_and_boxes(syn_img, bboxes, slice_size=slice_size) - for s_img, s_boxes in sliced: - raw_samples.append({"image": s_img, "bboxes": s_boxes}) - else: - raw_samples.append({"image": syn_img, "bboxes": bboxes}) - - pbar.close() - - # Shuffle and split into train, valid, test - random.shuffle(raw_samples) - total_samples = len(raw_samples) - n_train = int(total_samples * train_ratio) - n_val = int(total_samples * val_ratio) - - splits = { - "train": raw_samples[:n_train], - "valid": raw_samples[n_train : n_train + n_val], - "test": raw_samples[n_train + n_val :], - } - - # Write files to disk - idx = 0 - split_counts = {"train": 0, "valid": 0, "test": 0} - for split_name, split_list in splits.items(): - img_dir = abs_output / split_name / "images" - lbl_dir = abs_output / split_name / "labels" - split_counts[split_name] = len(split_list) - - for item in split_list: - idx += 1 - img_file = img_dir / f"syn_{idx:06d}.jpg" - lbl_file = lbl_dir / f"syn_{idx:06d}.txt" - - cv2.imwrite(str(img_file), item["image"]) - with open(lbl_file, "w", encoding="utf-8") as f: - for b in item["bboxes"]: - box = b["bbox"] - f.write(f"{b['class_id']} {box[0]:.6f} {box[1]:.6f} {box[2]:.6f} {box[3]:.6f}\n") - - # Generate data.yaml - data_yaml = { - "path": abs_output.resolve().as_posix(), - "train": "train/images", - "val": "valid/images", - "test": "test/images", - "nc": 4, - "names": CLASS_NAMES, - } - - data_yaml_path = abs_output / "data.yaml" - with open(data_yaml_path, "w", encoding="utf-8") as f: - yaml.dump(data_yaml, f, sort_keys=False) - - print("\n" + "=" * 75) - print("Synthetic Defect Dataset Generation Completed") - print("=" * 75) - print(f"Total Images Generated: {total_samples}") - print(f" - Train Split: {split_counts['train']}") - print(f" - Valid Split: {split_counts['valid']}") - print(f" - Test Split: {split_counts['test']}") - print("-" * 75) - print("Defect Instances Synthesized:") - for c_name, c_count in counts.items(): - print(f" - {c_name:20s}: {c_count}") - print(f"Dataset YAML: {data_yaml_path}") - print("=" * 75) - - return { - "total_images": total_samples, - "splits": split_counts, - "class_counts": counts, - "data_yaml": str(data_yaml_path), - } - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Generate synthetic railway defect YOLO dataset.") - parser.add_argument("--normal-bank", default="data/external/rail_normal_only", help="Path to normal track images") - parser.add_argument("--output-dir", default="data/external/rail_defects_synthetic", help="Output directory") - parser.add_argument("--samples-per-class", type=int, default=300, help="Target defect count per class") - parser.add_argument("--multi-defect-ratio", type=float, default=0.35, help="Ratio of multi-defect images") - parser.add_argument("--imgsz", type=int, default=960, help="Base image resolution") - parser.add_argument("--sahi-slice", action="store_true", help="Apply SAHI overlapping slicing") - parser.add_argument("--slice-size", type=int, default=640, help="SAHI tile slice size") - parser.add_argument("--seed", type=int, default=42, help="Random seed") - args = parser.parse_args() - - generate_synthetic_dataset( - normal_bank_dir=args.normal_bank, - output_dir=args.output_dir, - samples_per_class=args.samples_per_class, - multi_defect_ratio=args.multi_defect_ratio, - img_size=args.imgsz, - sahi_slice=args.sahi_slice, - slice_size=args.slice_size, - seed=args.seed, - ) diff --git a/ml/scripts/generate_trc_telemetry.py b/ml/scripts/generate_trc_telemetry.py deleted file mode 100644 index 2ec2dc33b371bc4da26f812ded0c24fe55cea4b7..0000000000000000000000000000000000000000 --- a/ml/scripts/generate_trc_telemetry.py +++ /dev/null @@ -1,257 +0,0 @@ -# Generates synthetic TRC telemetry, 5-class geometry sequences, and normal sequence datasets based on EN 13848-2 PSD (tc.v1 SOTA). -# 🔒 Strictly locked random seeds for 100% deterministic physics math verification. - -import os -import sys -import argparse -import random -from pathlib import Path -from typing import Tuple, Optional -import numpy as np -import pandas as pd -from scipy.ndimage import gaussian_filter1d - -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) - - -def generate_track_profile_psd( - length_m: float = 1000.0, - spatial_resolution_m: float = 0.05, - defect_mm: float = 5.0, - defect_start_m: float = 500.0, - defect_base_m: float = 3.0, - random_seed: int = 42, -) -> Tuple[np.ndarray, np.ndarray]: - """ - Generates realistic track cant (cross-level) profile using EN 13848-2 Power Spectral Density (PSD) - with injected deterministic geometry fault. - """ - # 🔒 Lock random seed for deterministic track profile - np.random.seed(random_seed) - random.seed(random_seed) - - n_pts = int(length_m / spatial_resolution_m) - x = np.linspace(0, length_m, n_pts) - - # 1. EN 13848-2 standard track roughness PSD (Grade 4 typical track) - freqs = np.fft.rfftfreq(n_pts, d=spatial_resolution_m) - freqs[0] = freqs[1] * 0.1 # Avoid division by zero at DC component - psd = 0.005 / (freqs**2 + 0.01) # Typical track degradation decay spectrum - - # Inverse FFT with random phase - phases = np.exp(1j * np.random.uniform(0, 2 * np.pi, len(freqs))) - noise = np.fft.irfft(np.sqrt(psd) * phases, n=n_pts) - - # Smooth baseline cant using physical rail curvature smoothing (sigma ~ 1.0m) - sigma_samples = int(1.0 / spatial_resolution_m) - smooth_noise = gaussian_filter1d(noise, sigma=sigma_samples) - - # Scale to typical cant roughness standard deviation (~1.0mm RMS for high-quality track) - cant_profile_mm = (smooth_noise / (np.std(smooth_noise) + 1e-8)) * 0.8 - - # 2. Inject deterministic Twist / Cant exceedance fault - if defect_mm > 0: - start_idx = int(defect_start_m / spatial_resolution_m) - end_idx = start_idx + int(defect_base_m / spatial_resolution_m) - ramp = np.linspace(0, defect_mm, end_idx - start_idx) - cant_profile_mm[start_idx:end_idx] += ramp - - return x, cant_profile_mm - - -def generate_trc_telemetry_csv( - output_path: str = "data/processed/synthetic_trc_run_001.csv", - speed_mps: float = 20.0, # 72 km/h - sample_rate_hz: float = 100.0, - length_m: float = 1000.0, - defect_mm: float = 5.0, - defect_start_m: float = 500.0, - random_seed: int = 42, -) -> str: - """Generate time-domain 100Hz IMU and Laser TRC telemetry CSV for chainage resampling.""" - # 🔒 Lock random seed at the very top for deterministic pipeline execution - np.random.seed(random_seed) - random.seed(random_seed) - - p = Path(output_path) - if p.suffix == "": - p = p / "synthetic_trc_run_001.csv" - p.parent.mkdir(parents=True, exist_ok=True) - - dt = 1.0 / sample_rate_hz - total_time_s = length_m / speed_mps - timestamps = np.arange(0, total_time_s, dt) - n_samples = len(timestamps) - - _, cant_profile_mm = generate_track_profile_psd( - length_m=length_m, - spatial_resolution_m=0.05, - defect_mm=defect_mm, - defect_start_m=defect_start_m, - random_seed=random_seed, - ) - - track_x = np.linspace(0, length_m, len(cant_profile_mm)) - train_x = timestamps * speed_mps - cant_sampled_mm = np.interp(train_x, track_x, cant_profile_mm) - - nominal_gauge_mm = 1676.0 - roll_rad = np.arcsin(np.clip(cant_sampled_mm / nominal_gauge_mm, -0.2, 0.2)) - - lat_accel_g = np.random.normal(0.0, 0.01, n_samples) - vert_accel_g = np.random.normal(1.0, 0.02, n_samples) - gauge_mm = np.random.normal(nominal_gauge_mm, 0.4, n_samples) - - if defect_mm > 0: - defect_mask = (train_x >= defect_start_m) & (train_x <= defect_start_m + 3.0) - gauge_mm[defect_mask] += 1.5 - - df = pd.DataFrame({ - "timestamp": np.round(timestamps, 3), - "speed_mps": np.full(n_samples, speed_mps), - "roll_rad": np.round(roll_rad, 6), - "lat_accel_g": np.round(lat_accel_g, 4), - "vert_accel_g": np.round(vert_accel_g, 4), - "gauge_mm": np.round(gauge_mm, 2), - }) - - df.to_csv(p, index=False) - print(f"[OK] Generated deterministic synthetic TRC telemetry CSV: {p}") - return str(p) - - -def generate_normal_sequences_csv( - output_path: str = "data/processed/normal_sequences.csv", - num_sequences: int = 1000, - seq_len: int = 80, - step_m: float = 0.25, - random_seed: int = 42, -) -> str: - """Generate clean normal EN 13848 track geometry sequences for Sequence VAE training.""" - from ml.data.synthetic_geometry import SyntheticGeometryDataset, GeometryFaultType - - # 🔒 Lock random seed - np.random.seed(random_seed) - random.seed(random_seed) - - p = Path(output_path) - if p.suffix == "": - p = p / "normal_sequences.csv" - p.parent.mkdir(parents=True, exist_ok=True) - - ds = SyntheticGeometryDataset( - num_samples=num_sequences * 2, - seq_len=seq_len, - bin_size=step_m, - num_classes=5, - random_seed=random_seed, - ) - normal_mask = (ds.labels == GeometryFaultType.NORMAL) - normal_data = ds.data[normal_mask][:num_sequences].numpy() # [N, 80, 5] - - rows = [] - for seq_id, seq_arr in enumerate(normal_data): - for bin_idx, row in enumerate(seq_arr): - rows.append({ - "sequence_id": seq_id, - "bin_idx": bin_idx, - "chainage_offset_m": bin_idx * step_m, - "twist_3m_mm": row[0], - "versine_10m_mm": row[1], - "versine_20m_mm": row[2], - "unevenness_10m_mm": row[3], - "cant_mm": row[4], - }) - - df = pd.DataFrame(rows) - df.to_csv(p, index=False) - print(f"[OK] Generated {num_sequences} normal geometry sequences to: {p}") - return str(p) - - -def generate_geometry_sequences_dataset( - output_path: str = "data/processed/geometry_sequences/", - num_sequences: int = 5000, - seq_len: int = 80, - step_m: float = 0.25, - random_seed: int = 42, -) -> str: - """Generate multi-class synthetic geometry sequences for Bi-LSTM classifier training.""" - from ml.data.synthetic_geometry import SyntheticGeometryDataset - - # 🔒 Lock random seed - np.random.seed(random_seed) - random.seed(random_seed) - - p = Path(output_path) - if p.suffix == "": - p.mkdir(parents=True, exist_ok=True) - csv_path = p / "geometry_sequences.csv" - npz_path = p / "geometry_sequences.npz" - else: - p.parent.mkdir(parents=True, exist_ok=True) - csv_path = p - npz_path = p.with_suffix(".npz") - - ds = SyntheticGeometryDataset( - num_samples=num_sequences, - seq_len=seq_len, - bin_size=step_m, - num_classes=6, - random_seed=random_seed, - ) - - data_arr = ds.data.numpy() # [N, 80, 5] - labels_arr = ds.labels.numpy() # [N] - - # Save as compact NPZ - np.savez_compressed(npz_path, data=data_arr, labels=labels_arr) - - # Also save structured CSV preview/records - rows = [] - for seq_id, (seq_arr, label_val) in enumerate(zip(data_arr, labels_arr)): - for bin_idx, row in enumerate(seq_arr): - rows.append({ - "sequence_id": seq_id, - "label": int(label_val), - "bin_idx": bin_idx, - "chainage_offset_m": bin_idx * step_m, - "twist_3m_mm": row[0], - "versine_10m_mm": row[1], - "versine_20m_mm": row[2], - "unevenness_10m_mm": row[3], - "cant_mm": row[4], - }) - - df = pd.DataFrame(rows) - df.to_csv(csv_path, index=False) - print(f"[OK] Generated {num_sequences} geometry sequences (5 classes + normal) to: {csv_path} and {npz_path}") - return str(csv_path) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Generate synthetic EN 13848 TRC telemetry / datasets (Deterministic).") - parser.add_argument("--mode", choices=["telemetry", "normal_sequences", "geometry_sequences"], default="telemetry") - parser.add_argument("--out", "--output", default=None, dest="output", help="Output path") - parser.add_argument("--length", type=float, default=1000.0, help="Track length in meters") - parser.add_argument("--defect", type=float, default=5.0, help="Injected twist defect in mm") - parser.add_argument("--defect-pos", type=float, default=500.0, help="Defect start chainage in meters") - parser.add_argument("--num_samples", "--num-samples", "--num-sequences", "--num_sequences", type=int, default=1000, dest="num_samples", help="Number of sequences") - parser.add_argument("--seed", type=int, default=42, help="Fixed random seed for deterministic reproducibility") - args = parser.parse_args() - - if args.mode == "normal_sequences": - out_path = args.output or "data/processed/normal_sequences.csv" - generate_normal_sequences_csv(output_path=out_path, num_sequences=args.num_samples, random_seed=args.seed) - elif args.mode == "geometry_sequences": - out_path = args.output or "data/processed/geometry_sequences/" - generate_geometry_sequences_dataset(output_path=out_path, num_sequences=args.num_samples, random_seed=args.seed) - else: - out_path = args.output or "data/processed/synthetic_trc_run_001.csv" - generate_trc_telemetry_csv( - output_path=out_path, - length_m=args.length, - defect_mm=args.defect, - defect_start_m=args.defect_pos, - random_seed=args.seed, - ) diff --git a/ml/scripts/run.sh b/ml/scripts/run.sh deleted file mode 100644 index a85324cb892fd3f6d7e458738ecb35ec68399c5f..0000000000000000000000000000000000000000 --- a/ml/scripts/run.sh +++ /dev/null @@ -1,310 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# TrackChain Master ML Training Orchestrator -# Trains all 5 Phase 2 models in dependency order with SMART checkpointing. -# -# Usage: -# chmod +x ml/scripts/run.sh -# ./ml/scripts/run.sh # Runs ONLY missing/failed steps -# ./ml/scripts/run.sh --force # Re-runs EVERYTHING (overwrites) -# ./ml/scripts/run.sh --clean # Wipes all checkpoints -# ./ml/scripts/run.sh --skip-yolo --skip-patchcore # Skips specific steps -# -# Output: -# - Trained weights in artifacts/checkpoints/ -# - ONNX/INT8 exports in artifacts/exports/ -# - Training logs in artifacts/logs/ -# ============================================================================= - -set -euo pipefail - -# --- Configuration ----------------------------------------------------------- -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -LOG_DIR="$REPO_ROOT/artifacts/logs" -CHECKPOINT_DIR="$REPO_ROOT/artifacts/checkpoints" -EXPORT_DIR="$REPO_ROOT/artifacts/exports" -DATA_ROOT="$REPO_ROOT/data" - -export PYTHONPATH="$REPO_ROOT:${PYTHONPATH:-}" - -EPOCHS_YOLO=${EPOCHS_YOLO:-50} -EPOCHS_BILSTM=${EPOCHS_BILSTM:-20} -EPOCHS_VAE=${EPOCHS_VAE:-30} -BATCH_SIZE=${BATCH_SIZE:-16} - -# Flags -FORCE=false -CLEAN=false -SKIP_YOLO=false -SKIP_PATCHCORE=false -SKIP_BILSTM=false -SKIP_VAE=false - -# Auto-detect CUDA GPU -if python -c "import torch; exit(0 if torch.cuda.is_available() else 1)" 2>/dev/null; then - DEFAULT_DEVICE="0" - DEVICE_INFO=$(python -c "import torch; print(f'CUDA GPU 0 ({torch.cuda.get_device_name(0)})')") -else - DEFAULT_DEVICE="cpu" - DEVICE_INFO="CPU (No CUDA device found)" -fi -TRAIN_DEVICE=${TRAIN_DEVICE:-"$DEFAULT_DEVICE"} - -# --- Color output ------------------------------------------------------------ -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' -BLUE='\033[0;34m'; CYAN='\033[0;36m'; NC='\033[0m' - -info() { echo -e "${CYAN}[INFO]${NC} $*"; } -ok() { echo -e "${GREEN}[OK]${NC} $*"; } -warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -err() { echo -e "${RED}[ERROR]${NC} $*"; } -header(){ echo -e "\n${BLUE}═══════════════════════════════════════════════════${NC}"; - echo -e "${BLUE} $*${NC}"; - echo -e "${BLUE}═══════════════════════════════════════════════════${NC}"; } - -# --- Argument parsing -------------------------------------------------------- -while [[ $# -gt 0 ]]; do - case "$1" in - --epochs-yolo) EPOCHS_YOLO="$2"; shift 2;; - --epochs-bilstm) EPOCHS_BILSTM="$2"; shift 2;; - --epochs-vae) EPOCHS_VAE="$2"; shift 2;; - --batch) BATCH_SIZE="$2"; shift 2;; - --force) FORCE=true; shift;; - --clean) CLEAN=true; shift;; - --skip-yolo) SKIP_YOLO=true; shift;; - --skip-patchcore) SKIP_PATCHCORE=true; shift;; - --skip-bilstm) SKIP_BILSTM=true; shift;; - --skip-vae) SKIP_VAE=true; shift;; - *) err "Unknown option: $1"; exit 1;; - esac -done - -# --- Setup & Clean Logic ----------------------------------------------------- -cd "$REPO_ROOT" -mkdir -p "$LOG_DIR" "$CHECKPOINT_DIR/vision" "$CHECKPOINT_DIR/geometry" "$EXPORT_DIR" - -if [[ "$CLEAN" == true ]]; then - warn "Cleaning all checkpoints, exports, and logs..." - find "$CHECKPOINT_DIR" -name "*.done" -type f -delete - find "$EXPORT_DIR" -name "*.done" -type f -delete - ok "Clean complete. Re-run without --clean to start training." - exit 0 -fi - -START_TIME=$(date +%s) -header "TrackChain Phase 2 — Master ML Training Pipeline" -info "Repo root: $REPO_ROOT" -info "Compute Device: $DEVICE_INFO" -info "YOLO epochs: $EPOCHS_YOLO" -info "Bi-LSTM epochs: $EPOCHS_BILSTM" -info "VAE epochs: $EPOCHS_VAE" -info "Batch size: $BATCH_SIZE" -info "Force retrain: $FORCE" - -# --- Helper: checkpoint-aware run -------------------------------------------- -run_step() { - local step_name="$1" - local checkpoint="$2" - shift 2 - local cmd=("$@") - - # SMART CHECKPOINTING: Skip if .done file exists and --force is not used - if [[ "$FORCE" == false && -f "$checkpoint" ]]; then - info "[$step_name] Already completed (checkpoint found). Skipping." - return 0 - fi - - info "[$step_name] Starting..." - - # Temporarily disable exit-on-error to capture the exact exit code of the piped command - set +e - "${cmd[@]}" 2>&1 | tee "$LOG_DIR/${step_name}.log" - local status=${PIPESTATUS[0]} - set -e - - if [[ $status -eq 0 ]]; then - touch "$checkpoint" - ok "[$step_name] Completed successfully" - else - err "[$step_name] FAILED (exit code $status). See log: $LOG_DIR/${step_name}.log" - exit 1 - fi -} - -# ============================================================================= -# STEP 1: Data Verification & Generation -# ============================================================================= -header "STEP 1/7: Data Verification & Generation" - -if ! python -c "import os -has_yolo = os.path.exists('$DATA_ROOT/external/rail_defects_expanded/train/images') or os.path.exists('$DATA_ROOT/external/rail_defects/train/images') -has_patch = os.path.exists('$DATA_ROOT/external/rail_normal_expanded/train/good') or os.path.exists('$DATA_ROOT/external/rail_normal_only/train/good') -assert has_yolo, 'YOLO dataset missing' -assert has_patch, 'PatchCore dataset missing' -print('[OK] Vision datasets verified')" 2>/dev/null; then - err "Vision datasets not found. Ensure '$DATA_ROOT/external/rail_defects' and '$DATA_ROOT/external/rail_normal_only' exist." - exit 1 -fi - -run_step "generate_trc" \ - "$CHECKPOINT_DIR/.trc_data.done" \ - python ml/scripts/generate_trc_telemetry.py \ - --mode telemetry \ - --output "$DATA_ROOT/processed/synthetic_trc_run_001.csv" - -run_step "generate_geometry" \ - "$CHECKPOINT_DIR/.geometry_data.done" \ - python ml/scripts/generate_trc_telemetry.py \ - --mode geometry_sequences --num_samples 5000 \ - --output "$DATA_ROOT/processed/geometry_sequences/" - -run_step "generate_normal" \ - "$CHECKPOINT_DIR/.normal_data.done" \ - python ml/scripts/generate_trc_telemetry.py \ - --mode normal_sequences --num_samples 3000 \ - --output "$DATA_ROOT/processed/normal_sequences/" - -# ============================================================================= -# STEP 2: YOLO Training (Phase 2.1) -# ============================================================================= -header "STEP 2/7: YOLOv8n Visual Defect Detector" - -YOLO_DATA="$DATA_ROOT/external/rail_defects/data.yaml" -if [[ -f "$DATA_ROOT/external/rail_defects_expanded/data.yaml" ]]; then - YOLO_DATA="$DATA_ROOT/external/rail_defects_expanded/data.yaml" -fi - -if [[ "$SKIP_YOLO" == true ]]; then - warn "Skipping YOLO (--skip-yolo)" -else - run_step "train_yolo" \ - "$CHECKPOINT_DIR/vision/.yolo_train.done" \ - python ml/scripts/train_detector.py \ - --data "$YOLO_DATA" \ - --epochs "$EPOCHS_YOLO" \ - --batch "$BATCH_SIZE" \ - --device "$TRAIN_DEVICE" - - run_step "export_yolo_onnx" \ - "$EXPORT_DIR/.yolo_onnx.done" \ - python ml/inference/exporters.py \ - --model "$CHECKPOINT_DIR/vision/yolov8n_rail_best.pt" \ - --outdir "$EXPORT_DIR" \ - --format onnx - - run_step "export_yolo_int8" \ - "$EXPORT_DIR/.yolo_int8.done" \ - python ml/inference/exporters.py \ - --model "$CHECKPOINT_DIR/vision/yolov8n_rail_best.pt" \ - --outdir "$EXPORT_DIR" \ - --format int8 -fi - -# ============================================================================= -# STEP 3: PatchCore Memory Bank (Phase 2.2) -# ============================================================================= -header "STEP 3/7: PatchCore Visual Anomaly Detector" - -PATCHCORE_DATA="$DATA_ROOT/external/rail_normal_only" -if [[ -d "$DATA_ROOT/external/rail_normal_expanded/train/good" ]]; then - PATCHCORE_DATA="$DATA_ROOT/external/rail_normal_expanded" - info "Using expanded PatchCore dataset (800+ images)" -fi - -if [[ "$SKIP_PATCHCORE" == true ]]; then - warn "Skipping PatchCore (--skip-patchcore)" -else - run_step "train_patchcore" \ - "$CHECKPOINT_DIR/vision/.patchcore_train.done" \ - python ml/scripts/train_anomaly.py \ - --data-path "$PATCHCORE_DATA" \ - --coreset_ratio 0.10 \ - --fpr_target 0.01 \ - --device "$TRAIN_DEVICE" -fi - -# ============================================================================= -# STEP 4: Physics Verification (Phase 2.3) — no training -# ============================================================================= -header "STEP 4/7: EN 13848 Physics Verification" - -if [[ "$FORCE" == false && -f "$CHECKPOINT_DIR/geometry/.physics_verify.done" ]]; then - info "[physics_verify] Already completed. Skipping." -else - python -c " -import pandas as pd -from ml.models.geometry.physics_detector import EN13848PhysicsThresholdDetector -det = EN13848PhysicsThresholdDetector() -df = pd.read_csv('$DATA_ROOT/processed/synthetic_trc_run_001.csv') -signals = det.predict(df) -fired = signals[0].fired -score = signals[0].value -print(f'Physics fired: {fired}, score: {score:.3f}') -assert fired and abs(score - 0.625) < 0.05, 'Physics math failed!' -" - touch "$CHECKPOINT_DIR/geometry/.physics_verify.done" - ok "[physics_verify] EN 13848 math verified" -fi - -# ============================================================================= -# STEP 5: Bi-LSTM Training (Phase 2.4) - ENHANCED -# ============================================================================= -header "STEP 5/7: Bi-LSTM Geometry Fault Classifier (Enhanced)" - -if [[ "$SKIP_BILSTM" == true ]]; then - warn "Skipping Bi-LSTM (--skip-bilstm)" -else - run_step "train_bilstm" \ - "$CHECKPOINT_DIR/geometry/.bilstm_train.done" \ - python ml/scripts/train_fault_classifier_enhanced.py \ - --epochs "$EPOCHS_BILSTM" \ - --hidden-size 128 \ - --num-layers 3 \ - --batch_size 128 \ - --lr 0.0005 \ - --label-smoothing 0.1 \ - --dropout 0.4 \ - --data-path "$DATA_ROOT/processed/geometry_sequences/" \ - --save-path "$CHECKPOINT_DIR/geometry/bilstm_fault_typing_enhanced.pt" -fi - -# ============================================================================= -# STEP 6: Seq-VAE Training (Phase 2.5) - FIXED AND ENHANCED -# ============================================================================= -header "STEP 6/7: Sequence VAE Novel Geometry Detector (Enhanced)" - -if [[ "$SKIP_VAE" == true ]]; then - warn "Skipping Seq-VAE (--skip-vae)" -else - run_step "train_vae" \ - "$CHECKPOINT_DIR/geometry/.vae_train.done" \ - python ml/scripts/train_sequence_vae_enhanced.py \ - --epochs "$EPOCHS_VAE" \ - --beta 0.01 \ - --latent-dim 16 \ - --batch-size 64 \ - --lr 0.001 \ - --kl-annealing \ - --annealing-epochs 10 \ - --data-path "$DATA_ROOT/processed/normal_sequences/" \ - --save-path "$CHECKPOINT_DIR/geometry/sequence_vae_enhanced.pt" -fi - -# ============================================================================= -# STEP 7: Final Test Suite -# ============================================================================= -header "STEP 7/7: Full Test Suite Verification" - -run_step "full_test_suite" \ - "$CHECKPOINT_DIR/.tests.done" \ - bash ml/scripts/test.sh - -ELAPSED=$(( $(date +%s) - START_TIME )) -echo "" -header "TrackChain Phase 2 — Training Pipeline Complete in ${ELAPSED}s" -ok "Checkpoints: $CHECKPOINT_DIR/" -ok "Exports: $EXPORT_DIR/" -ok "Logs: $LOG_DIR/" -echo "" -ok "Next step: Run ./ml/scripts/calibrate.sh to fit calibration parameters." \ No newline at end of file diff --git a/ml/scripts/should_skip.py b/ml/scripts/should_skip.py deleted file mode 100644 index 7331b48203616081744ebbe801c84191dd21d8ac..0000000000000000000000000000000000000000 --- a/ml/scripts/should_skip.py +++ /dev/null @@ -1,141 +0,0 @@ -""" -TrackChain CLI Step Skip-Logic Evaluator (tc.v1 SOTA). -Used by ml/scripts/run.sh to evaluate manifest fingerprints and quality gates. - -Usage: - python ml/scripts/should_skip.py --step train_yolo [--force] - Exit code 0: Skip the step (valid checkpoint & manifest found) - Exit code 1: Run the step (missing, modified config/dataset, or failed gate) -""" - -import sys -import argparse -from pathlib import Path - -# Add repo root to python path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.core.manifest import ( - compute_file_sha256, - compute_config_hash, - compute_dataset_hash, - check_training_skip, - check_export_skip, - load_manifest, -) - - -def evaluate_step(step_name: str, force: bool = False) -> bool: - """Returns True if step should be SKIPPED, False if step should RUN.""" - if force: - print(f"[{step_name}] Force flag enabled. Running step.") - return False - - if step_name == "train_yolo": - # Resolve dataset yaml - data_yaml = repo_root / "data" / "external" / "rail_defects_expanded" / "data.yaml" - if not data_yaml.exists(): - data_yaml = repo_root / "data" / "external" / "rail_defects" / "data.yaml" - - config_yaml = repo_root / "ml" / "configs" / "detector.yaml" - ckpt = repo_root / "artifacts" / "checkpoints" / "vision" / "yolov8n_rail_best.pt" - manifest_p = repo_root / "artifacts" / "checkpoints" / "vision" / "yolo_manifest.json" - - cfg_hash = compute_config_hash(config_yaml, overrides={"imgsz": 960, "freeze": 10, "dropout": 0.1, "close_mosaic": 10}) - data_hash = compute_dataset_hash(data_yaml) - - skip, reason = check_training_skip( - manifest_path=manifest_p, - checkpoint_path=ckpt, - current_config_hash=cfg_hash, - current_dataset_hash=data_hash, - min_metrics={"mAP50": 0.25}, - force=force, - ) - if skip: - print(f"[{step_name}] SKIP: {reason}") - return True - else: - print(f"[{step_name}] RUN: {reason}") - return False - - elif step_name == "export_yolo_onnx": - src_pt = repo_root / "artifacts" / "checkpoints" / "vision" / "yolov8n_rail_best.pt" - export_file = repo_root / "artifacts" / "exports" / "yolov8n_rail_best.onnx" - manifest_p = repo_root / "artifacts" / "exports" / "export_manifest.json" - - skip, reason = check_export_skip( - manifest_path=manifest_p, - export_path=export_file, - source_model_path=src_pt, - export_format="onnx", - force=force, - ) - if skip: - print(f"[{step_name}] SKIP: {reason}") - return True - else: - print(f"[{step_name}] RUN: {reason}") - return False - - elif step_name == "export_yolo_int8": - src_pt = repo_root / "artifacts" / "checkpoints" / "vision" / "yolov8n_rail_best.pt" - export_file = repo_root / "artifacts" / "exports" / "yolov8n_rail_best_int8.onnx" - manifest_p = repo_root / "artifacts" / "exports" / "export_manifest.json" - - skip, reason = check_export_skip( - manifest_path=manifest_p, - export_path=export_file, - source_model_path=src_pt, - export_format="int8", - force=force, - ) - if skip: - print(f"[{step_name}] SKIP: {reason}") - return True - else: - print(f"[{step_name}] RUN: {reason}") - return False - - elif step_name == "train_patchcore": - ckpt = repo_root / "artifacts" / "checkpoints" / "vision" / "patchcore_memory_bank.npz" - calib = repo_root / "artifacts" / "calibration" / "patchcore_calibration.json" - if ckpt.exists() and calib.exists(): - print(f"[{step_name}] SKIP: PatchCore memory bank and calibration exist.") - return True - print(f"[{step_name}] RUN: Memory bank or calibration missing.") - return False - - elif step_name == "train_bilstm": - ckpt = repo_root / "artifacts" / "checkpoints" / "geometry" / "bilstm_fault_typing_enhanced.pt" - calib = repo_root / "artifacts" / "calibration" / "bilstm_temp.json" - if ckpt.exists() and calib.exists(): - print(f"[{step_name}] SKIP: Bi-LSTM checkpoint and calibration exist.") - return True - print(f"[{step_name}] RUN: Bi-LSTM weights or calibration missing.") - return False - - elif step_name == "train_vae": - ckpt = repo_root / "artifacts" / "checkpoints" / "geometry" / "sequence_vae_enhanced.pt" - calib = repo_root / "artifacts" / "calibration" / "vae_calibration.json" - if ckpt.exists() and calib.exists(): - print(f"[{step_name}] SKIP: Seq-VAE checkpoint and EVT calibration exist.") - return True - print(f"[{step_name}] RUN: Seq-VAE weights or calibration missing.") - return False - - # Default: Run step - return False - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Evaluate step skip-logic using manifests.") - parser.add_argument("--step", required=True, help="Step name to evaluate") - parser.add_argument("--force", action="store_true", help="Force execution") - args = parser.parse_args() - - should_skip = evaluate_step(step_name=args.step, force=args.force) - # Exit 0 if skipping, Exit 1 if running - sys.exit(0 if should_skip else 1) diff --git a/ml/scripts/slice_sahi_dataset.py b/ml/scripts/slice_sahi_dataset.py deleted file mode 100644 index f7e537555a0e9c1de3bf39a0754c77062b7ff67c..0000000000000000000000000000000000000000 --- a/ml/scripts/slice_sahi_dataset.py +++ /dev/null @@ -1,150 +0,0 @@ -""" -TrackChain SAHI Dataset Slicer (tc.v1 SOTA). -Slices high-resolution or standard railway defect imagery into overlapping crops, -recomputing normalized YOLO bounding boxes for each tile. -Acts as a dataset multiplier and small-object detection booster (fasteners, clips, micro-cracks). -""" - -import argparse -import os -import sys -from pathlib import Path -from typing import List, Dict, Tuple, Optional, Any -import cv2 -import numpy as np -from tqdm import tqdm -import yaml - -# Ensure project root is in sys.path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.data.synthetic_vision import sanitize_bbox, CLASS_NAMES -from ml.scripts.generate_synthetic_defects import slice_image_and_boxes - - -def slice_yolo_dataset( - input_dir: str = "data/external/rail_defects", - output_dir: str = "data/external/rail_defects_sahi_sliced", - slice_size: int = 480, - overlap_ratio: float = 0.20, - min_visibility: float = 0.25, -) -> Dict[str, Any]: - """ - Load an existing YOLO dataset, slice each image into overlapping tiles, - update bounding boxes, and save to output_dir. - """ - abs_input = Path(input_dir) if Path(input_dir).is_absolute() else repo_root / input_dir - abs_output = Path(output_dir) if Path(output_dir).is_absolute() else repo_root / output_dir - abs_output.mkdir(parents=True, exist_ok=True) - - print("=" * 75) - print("TrackChain SAHI Dataset Multiplier & Slicer (tc.v1 SOTA)") - print("=" * 75) - print(f"Input Dataset: {abs_input}") - print(f"Output Dataset: {abs_output}") - print(f"Slice Tile Size: {slice_size}x{slice_size}") - print(f"Overlap Ratio: {overlap_ratio:.1%}") - print(f"Min Visibility: {min_visibility:.1%}") - - stats = {"original_images": 0, "sliced_tiles": 0, "splits": {}} - - for split in ["train", "valid", "test"]: - in_img_dir = abs_input / split / "images" - in_lbl_dir = abs_input / split / "labels" - out_img_dir = abs_output / split / "images" - out_lbl_dir = abs_output / split / "labels" - - out_img_dir.mkdir(parents=True, exist_ok=True) - out_lbl_dir.mkdir(parents=True, exist_ok=True) - - if not in_img_dir.exists(): - continue - - img_files = sorted(list(in_img_dir.glob("*.jpg")) + list(in_img_dir.glob("*.png")) + list(in_img_dir.glob("*.jpeg"))) - tile_count = 0 - - for img_p in tqdm(img_files, desc=f"Slicing {split} split"): - stats["original_images"] += 1 - lbl_p = in_lbl_dir / (img_p.stem + ".txt") - - boxes: List[Dict[str, Any]] = [] - if lbl_p.exists(): - with open(lbl_p, "r", encoding="utf-8") as f: - for line in f: - parts = line.strip().split() - if len(parts) >= 5: - cls_id = int(parts[0]) - clean_b = sanitize_bbox([float(x) for x in parts[1:5]]) - if clean_b: - boxes.append({"class_id": cls_id, "bbox": clean_b}) - - img = cv2.imread(str(img_p)) - if img is None: - continue - - sliced_items = slice_image_and_boxes( - img, - boxes, - slice_size=slice_size, - overlap_ratio=overlap_ratio, - min_visibility=min_visibility, - ) - - for t_idx, (t_img, t_boxes) in enumerate(sliced_items): - tile_count += 1 - base_name = f"{img_p.stem}_sahi_{t_idx:02d}" - out_img_path = out_img_dir / f"{base_name}.jpg" - out_lbl_path = out_lbl_dir / f"{base_name}.txt" - - cv2.imwrite(str(out_img_path), t_img) - with open(out_lbl_path, "w", encoding="utf-8") as f: - for b in t_boxes: - bx = b["bbox"] - f.write(f"{b['class_id']} {bx[0]:.6f} {bx[1]:.6f} {bx[2]:.6f} {bx[3]:.6f}\n") - - stats["splits"][split] = tile_count - stats["sliced_tiles"] += tile_count - - # Generate data.yaml - data_yaml = { - "path": abs_output.resolve().as_posix(), - "train": "train/images", - "val": "valid/images", - "test": "test/images", - "nc": 4, - "names": CLASS_NAMES, - } - - yaml_path = abs_output / "data.yaml" - with open(yaml_path, "w", encoding="utf-8") as f: - yaml.dump(data_yaml, f, sort_keys=False) - - print("\n" + "=" * 75) - print("SAHI Slicing Complete") - print("=" * 75) - print(f"Original Images: {stats['original_images']}") - print(f"Total Sliced Tiles: {stats['sliced_tiles']}") - for sp, cnt in stats["splits"].items(): - print(f" - {sp:10s}: {cnt} tiles") - print(f"Dataset YAML: {yaml_path}") - print("=" * 75) - - return stats - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Slice YOLO dataset using SAHI overlapping tiling.") - parser.add_argument("--input-dir", default="data/external/rail_defects", help="Source YOLO dataset directory") - parser.add_argument("--output-dir", default="data/external/rail_defects_sahi_sliced", help="Output sliced directory") - parser.add_argument("--slice-size", type=int, default=480, help="Tile slice size in pixels") - parser.add_argument("--overlap-ratio", type=float, default=0.20, help="Tile overlap fraction") - args = parser.parse_args() - - slice_yolo_dataset( - input_dir=args.input_dir, - output_dir=args.output_dir, - slice_size=args.slice_size, - overlap_ratio=args.overlap_ratio, - ) diff --git a/ml/scripts/test.sh b/ml/scripts/test.sh deleted file mode 100644 index 4fc2a124101fd9b040a6293874f9bab5c56315fb..0000000000000000000000000000000000000000 --- a/ml/scripts/test.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# TrackChain Master ML Test Suite Runner (Phase 2.7 tc.v1 SOTA) -# Executes test pyramid: Registry -> Calibration -> Integration -> Fusion -> Robustness -> Roundtrip -> Performance -# -# Usage: -# chmod +x ml/scripts/test.sh -# ./ml/scripts/test.sh -# ============================================================================= - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" - -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' -BLUE='\033[0;34m'; CYAN='\033[0;36m'; NC='\033[0m' - -info() { echo -e "${CYAN}[INFO]${NC} $*"; } -ok() { echo -e "${GREEN}[OK]${NC} $*"; } -err() { echo -e "${RED}[ERROR]${NC} $*"; } -header(){ echo -e "\n${BLUE}═══════════════════════════════════════════════════${NC}"; - echo -e "${BLUE} $*${NC}"; - echo -e "${BLUE}═══════════════════════════════════════════════════${NC}"; } - -cd "$REPO_ROOT" -export PYTHONPATH="$REPO_ROOT:${PYTHONPATH:-}" - -header "TrackChain Phase 2.7 — Final Verification Test Suite" - -START_TIME=$(date +%s) - -# --- 1. Artifact Registry Check ---------------------------------------------- -info "[1/8] Testing Model Registry & Artifact Consistency..." -python -m pytest ml/tests/test_final_registry.py -v --tb=short - -# --- 2. Calibration Synchronization ----------------------------------------- -info "[2/8] Testing Calibration Synchronization ([0,1] domain)..." -python -m pytest ml/tests/test_final_calibration_sync.py -v --tb=short - -# --- 3. Multimodal Integration ----------------------------------------------- -info "[3/8] Testing Multimodal 5-Model End-to-End Pipeline..." -python -m pytest ml/tests/test_final_integration.py -v --tb=short - -# --- 4. Fusion Decision Matrix ----------------------------------------------- -info "[4/8] Testing Fusion Decision Truth Table & Cross-Modal Boost..." -python -m pytest ml/tests/test_final_fusion_matrix.py -v --tb=short - -# --- 5. Robustness & Stress -------------------------------------------------- -info "[5/8] Testing Robustness to Missing/Corrupt/NaN Inputs..." -python -m pytest ml/tests/test_final_robustness.py -v --tb=short - -# --- 6. Backend Contract Round-Trip ------------------------------------------ -info "[6/8] Testing tc.v1 Schema Serialization & Backend Contract..." -python -m pytest ml/tests/test_final_backend_roundtrip.py -v --tb=short - -# --- 7. Edge Deployment Latency & Performance -------------------------------- -info "[7/8] Testing Edge Deployment Latency & Performance Benchmarks..." -python -m pytest ml/tests/test_final_edge_performance.py -v --tb=short - -# --- 8. Consistency, Physics & Signal Contract Suite ------------------------- -info "[8/8] Running Hardened P0 Consistency, Physics Correctness & Unit Suite..." -python -m pytest ml/tests/test_final_consistency.py ml/tests/test_signal_contract.py ml/tests/test_calibration_sync.py ml/tests/test_vae_evt_normalization.py ml/tests/test_patchcore_enhanced.py ml/tests/test_yolo_custom_pipeline.py -v --tb=short - -END_TIME=$(date +%s) -DURATION=$((END_TIME - START_TIME)) - -header "Phase 2.7 Verification Summary: All Gates PASSED in ${DURATION}s" -ok "1. Artifact Consistency: PASSED (checkpoints, exports & manifests valid)" -ok "2. Calibration Sync: PASSED (all 5 models in [0,1] domain, threshold=0.50)" -ok "3. Multimodal Integration: PASSED (clean, known, novel, compound defects)" -ok "4. Fusion Decision Matrix: PASSED (truth table & cross-modal 1.5x boost)" -ok "5. Robustness & Safety: PASSED (missing frames, NaNs, empty segments)" -ok "6. Backend Round-Trip: PASSED (tc.v1 schema preserved ML -> API -> DB)" -ok "7. Edge Performance: PASSED (real-time latency budget >= 15 FPS)" -ok "8. P0/P1 Consistency Suite:PASSED (single source of truth & contract guards)" -echo "" -ok "TrackChain ML Stack is 100% sealed, verified, and production-ready." diff --git a/ml/scripts/train_anomaly.py b/ml/scripts/train_anomaly.py deleted file mode 100644 index c754d3e281753ab8d2b1a82b6a3b7ca121c0b1b0..0000000000000000000000000000000000000000 --- a/ml/scripts/train_anomaly.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -Train PatchCore visual anomaly detector script (tc.v1 SOTA). -Accelerated multi-scale feature extraction, fast minimax coreset selection, -and explicit True Positive Rate (TPR) / False Positive Rate (FPR) benchmark verification. -""" - -import sys -import argparse -from pathlib import Path - -# Add repo root to python path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.training.train_anomaly import train_patchcore - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Train PatchCore visual anomaly detector.") - parser.add_argument("--data", "--data-path", dest="data", default="data/external/rail_normal_expanded", help="Path to normal dataset directory") - parser.add_argument("--config", default="ml/configs/anomaly.yaml", help="Path to anomaly.yaml") - parser.add_argument("--coreset_ratio", "--ratio", dest="coreset_ratio", type=float, default=0.10, help="Coreset subsampling ratio") - parser.add_argument("--max_coreset", type=int, default=3000, help="Maximum number of coreset patches to retain") - parser.add_argument("--batch_size", "--batch", type=int, default=32, help="Batch size for feature extraction") - parser.add_argument("--fpr_target", type=float, default=0.01, help="Target FPR on normal validation track (e.g. 0.01 for P99)") - parser.add_argument("--device", default="auto", help="Device ('auto', 'cuda', or 'cpu')") - parser.add_argument("--out-ckpt", default=None, help="Output memory bank path") - parser.add_argument("--out-calib", default=None, help="Output calibration JSON path") - args = parser.parse_args() - - train_patchcore( - data_dir=args.data, - config_path=args.config, - sampling_ratio=args.coreset_ratio, - max_coreset_size=args.max_coreset, - batch_size=args.batch_size, - device=args.device, - output_checkpoint=args.out_ckpt, - output_calibration=args.out_calib, - ) diff --git a/ml/scripts/train_detector.py b/ml/scripts/train_detector.py deleted file mode 100644 index 27664ac93e86e2820556a8ced0a94db39125b9a2..0000000000000000000000000000000000000000 --- a/ml/scripts/train_detector.py +++ /dev/null @@ -1,381 +0,0 @@ -""" -TrackChain Master YOLO Detector Training Module (tc.v1 SOTA). -Standard full-network fine-tuning without backbone freezing. -""" - -import argparse -import os -import sys -import json -import time -import shutil -from pathlib import Path -from datetime import datetime -from typing import Optional, Dict, Any -import yaml - -# Ensure project root is in sys.path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -try: - import torch -except ImportError: - torch = None - -try: - from ultralytics import YOLO -except ImportError: - YOLO = None - -from ml.core.registry import ModelRegistry - - -def resolve_device(requested_device: Optional[str] = None) -> str: - """Resolve compute device: auto-detects CUDA GPU if available.""" - if requested_device and requested_device not in ["auto", "", None]: - return str(requested_device) - if torch is not None and torch.cuda.is_available(): - return "0" - return "cpu" - - -class MetricsLogger: - """Log detailed training and validation metrics to JSON across epochs.""" - - def __init__(self, log_dir: Path): - self.log_dir = log_dir - self.log_dir.mkdir(parents=True, exist_ok=True) - self.metrics_file = self.log_dir / "training_metrics.json" - self.metrics = { - "epochs": [], - "train_loss": [], - "val_loss": [], - "mAP50": [], - "mAP50_95": [], - "precision": [], - "recall": [], - "lr": [], - "epoch_time_s": [], - } - self.start_time = time.time() - self.last_epoch_time = self.start_time - - def on_fit_epoch_end(self, trainer): - """Called at the end of each fit epoch when both train and val metrics exist.""" - try: - epoch = getattr(trainer, "epoch", 0) + 1 - metrics_dict = getattr(trainer, "metrics", {}) or {} - - # Loss items - loss_val = 0.0 - if hasattr(trainer, "loss_items") and trainer.loss_items is not None: - loss_val = float(trainer.loss_items.mean().item()) - elif hasattr(trainer, "loss") and trainer.loss is not None: - loss_val = float(trainer.loss.item()) - - # Learning rate - lr = 0.0 - if hasattr(trainer, "optimizer") and trainer.optimizer and trainer.optimizer.param_groups: - lr = float(trainer.optimizer.param_groups[0]["lr"]) - - map50 = float(metrics_dict.get("metrics/mAP50(B)", 0.0)) - map50_95 = float(metrics_dict.get("metrics/mAP50-95(B)", 0.0)) - precision = float(metrics_dict.get("metrics/precision(B)", 0.0)) - recall = float(metrics_dict.get("metrics/recall(B)", 0.0)) - val_loss = float(metrics_dict.get("val/loss", 0.0)) - - now = time.time() - epoch_time = round(now - self.last_epoch_time, 2) - self.last_epoch_time = now - - self.metrics["epochs"].append(epoch) - self.metrics["train_loss"].append(round(loss_val, 4)) - self.metrics["val_loss"].append(round(val_loss, 4)) - self.metrics["mAP50"].append(round(map50, 4)) - self.metrics["mAP50_95"].append(round(map50_95, 4)) - self.metrics["precision"].append(round(precision, 4)) - self.metrics["recall"].append(round(recall, 4)) - self.metrics["lr"].append(lr) - self.metrics["epoch_time_s"].append(epoch_time) - - with open(self.metrics_file, "w", encoding="utf-8") as f: - json.dump(self.metrics, f, indent=2) - except Exception: - pass - - -class ProgressiveTrainer: - """Manages progressive resolution scaling and training schedules.""" - - def __init__(self, base_imgsz: int = 416, final_imgsz: int = 640): - self.base_imgsz = base_imgsz - self.final_imgsz = final_imgsz - - def get_imgsz_for_epoch(self, epoch: int, total_epochs: int) -> int: - """Calculate image size for current epoch (progressive resolution).""" - if epoch <= int(total_epochs * 0.3): - return self.base_imgsz - elif epoch <= int(total_epochs * 0.7): - progress = (epoch - int(total_epochs * 0.3)) / max(1, int(total_epochs * 0.4)) - raw_sz = int(self.base_imgsz + progress * (self.final_imgsz - self.base_imgsz)) - return int((raw_sz // 32) * 32) - else: - return self.final_imgsz - - -def train_yolo_detector( - data_yaml: str = "data/external/rail_defects_expanded/data.yaml", - config_path: str = "ml/configs/detector.yaml", - epochs: Optional[int] = None, - batch_size: Optional[int] = None, - img_size: Optional[int] = None, - freeze: Optional[int] = None, - dropout: Optional[float] = None, - erasing: Optional[float] = None, - copy_paste: Optional[float] = None, - close_mosaic: Optional[int] = None, - patience: Optional[int] = None, - conf: Optional[float] = None, - iou: Optional[float] = None, - device: Optional[str] = "auto", - output_dir: Optional[str] = None, - run_name: str = "yolov8n_rail_run", - resume: bool = False, -) -> Any: - """ - Train YOLOv8n defect detector with full network optimization (no backbone freezing). - """ - if YOLO is None: - raise RuntimeError("Ultralytics package is required. Install with: pip install ultralytics") - - abs_data_yaml = Path(data_yaml) if Path(data_yaml).is_absolute() else repo_root / data_yaml - if not abs_data_yaml.exists(): - for candidate in ["data/external/rail_defects_expanded/data.yaml", "data/external/rail_defects/data.yaml"]: - cand_p = repo_root / candidate - if cand_p.exists(): - abs_data_yaml = cand_p - break - - abs_config_path = Path(config_path) if Path(config_path).is_absolute() else repo_root / config_path - abs_output_dir = Path(output_dir) if output_dir else ModelRegistry.CHECKPOINTS_DIR / "vision" - abs_output_dir.mkdir(parents=True, exist_ok=True) - - # Load configuration - cfg = {} - if abs_config_path.exists(): - with open(abs_config_path, "r", encoding="utf-8") as f: - cfg = yaml.safe_load(f) or {} - - train_cfg = cfg.get("training", {}) - aug_cfg = cfg.get("augmentations", {}) - val_cfg = cfg.get("validation", {}) - - target_device = resolve_device(device if device != "auto" else cfg.get("device", "auto")) - - # Resolve training parameters - total_epochs = epochs or cfg.get("epochs") or train_cfg.get("epochs", 50) - batch = batch_size or cfg.get("batch") or train_cfg.get("batch_size", 16) - - if img_size: - imgsz = img_size - elif cfg.get("imgsz"): - imgsz = cfg.get("imgsz") - elif isinstance(cfg.get("model", {}).get("input_size"), list): - imgsz = cfg.get("model", {}).get("input_size")[0] - else: - imgsz = 640 - - optimizer = cfg.get("optimizer") or train_cfg.get("optimizer", "AdamW") - lr0 = cfg.get("lr0") or train_cfg.get("lr0", 0.0008) - lrf = cfg.get("lrf") or train_cfg.get("lrf", 0.01) - cos_lr = cfg.get("cos_lr", train_cfg.get("cos_lr", True)) - - patience_val = patience if patience is not None else cfg.get("patience", train_cfg.get("patience", 40)) - close_mosaic_val = close_mosaic if close_mosaic is not None else cfg.get("close_mosaic", train_cfg.get("close_mosaic", 30)) - freeze_val = freeze if freeze is not None else cfg.get("freeze", train_cfg.get("freeze", None)) - dropout_val = dropout if dropout is not None else cfg.get("dropout", train_cfg.get("dropout", 0.0)) - erasing_val = erasing if erasing is not None else cfg.get("erasing", train_cfg.get("erasing", 0.4)) - copy_paste_val = copy_paste if copy_paste is not None else cfg.get("copy_paste", aug_cfg.get("copy_paste", 0.3)) - conf_val = conf if conf is not None else cfg.get("conf", val_cfg.get("conf", 0.001)) - iou_val = iou if iou is not None else cfg.get("iou", val_cfg.get("iou", 0.60)) - - # Model initialization - model_name = cfg.get("model", {}).get("name", "yolov8n.pt") if isinstance(cfg.get("model"), dict) else cfg.get("model", "yolov8n.pt") - base_weights = ModelRegistry.get_base_weights("vision", model_name) - model_init = str(base_weights) if base_weights.exists() else model_name - - print("=" * 70) - print("TrackChain YOLOv8n Defect Detector Training") - print("=" * 70) - print(f"Dataset YAML: {abs_data_yaml}") - print(f"Config: {abs_config_path}") - print(f"Output Dir: {abs_output_dir}") - print(f"Device: {target_device}") - print(f"Epochs: {total_epochs}") - print(f"Batch Size: {batch}") - print(f"Image Size: {imgsz}") - print(f"Optimizer: {optimizer} (lr0={lr0}, cos_lr={cos_lr})") - - model = YOLO(model_init) - - # Register metrics logger callback - logger = MetricsLogger(abs_output_dir / "logs") - model.add_callback("on_fit_epoch_end", logger.on_fit_epoch_end) - - train_args = { - "data": str(abs_data_yaml), - "epochs": total_epochs, - "batch": batch, - "imgsz": imgsz, - "device": target_device, - "optimizer": optimizer, - "lr0": lr0, - "lrf": lrf, - "momentum": cfg.get("momentum", 0.937), - "weight_decay": cfg.get("weight_decay", train_cfg.get("weight_decay", 0.0005)), - "cos_lr": cos_lr, - "warmup_epochs": cfg.get("warmup_epochs", train_cfg.get("warmup_epochs", 5.0)), - "warmup_momentum": cfg.get("warmup_momentum", 0.8), - "warmup_bias_lr": cfg.get("warmup_bias_lr", 0.1), - "box": cfg.get("box", 7.5), - "cls": cfg.get("cls", 0.5), - "dfl": cfg.get("dfl", 1.5), - "freeze": freeze_val, - "dropout": dropout_val, - "erasing": erasing_val, - "hsv_h": cfg.get("hsv_h", aug_cfg.get("hsv_h", 0.015)), - "hsv_s": cfg.get("hsv_s", aug_cfg.get("hsv_s", 0.7)), - "hsv_v": cfg.get("hsv_v", aug_cfg.get("hsv_v", 0.4)), - "degrees": cfg.get("degrees", aug_cfg.get("degrees", 15.0)), - "translate": cfg.get("translate", aug_cfg.get("translate", 0.2)), - "scale": cfg.get("scale", aug_cfg.get("scale", 0.7)), - "shear": cfg.get("shear", aug_cfg.get("shear", 5.0)), - "perspective": cfg.get("perspective", aug_cfg.get("perspective", 0.001)), - "flipud": cfg.get("flipud", aug_cfg.get("flipud", 0.5)), - "fliplr": cfg.get("fliplr", aug_cfg.get("fliplr", 0.5)), - "mosaic": cfg.get("mosaic", aug_cfg.get("mosaic", 1.0)), - "mixup": cfg.get("mixup", aug_cfg.get("mixup", 0.15)), - "copy_paste": copy_paste_val, - "close_mosaic": close_mosaic_val, - "patience": patience_val, - "val": True, - "conf": conf_val, - "iou": iou_val, - "workers": min(cfg.get("workers", 4), os.cpu_count() or 4), - "pretrained": True, - "project": str(abs_output_dir), - "name": run_name, - "exist_ok": True, - "verbose": True, - "plots": True, - "save": True, - "save_period": cfg.get("save_period", 10), - "rect": cfg.get("rect", False), - "resume": resume, - } - - # Remove None items except for freeze when None is intentional - train_args = {k: v for k, v in train_args.items() if v is not None or k == "freeze"} - - print("\n[INFO] Commencing training...") - start_time = time.time() - results = model.train(**train_args) - training_time = time.time() - start_time - print(f"\n[INFO] Training completed in {training_time/3600:.2f} hours ({training_time:.1f}s)") - - # Canonical checkpoint synchronization - best_pt = abs_output_dir / run_name / "weights" / "best.pt" - canonical_best = abs_output_dir / "yolov8n_rail_best.pt" - alias_best = abs_output_dir / "yolo_rail_v0.1.pt" - - if best_pt.exists(): - shutil.copy(best_pt, canonical_best) - shutil.copy(best_pt, alias_best) - print(f"[OK] Checkpoints saved to:\n {canonical_best}\n {alias_best}") - else: - last_pt = abs_output_dir / run_name / "weights" / "last.pt" - if last_pt.exists(): - shutil.copy(last_pt, canonical_best) - shutil.copy(last_pt, alias_best) - - # Validate best model on test split - final_metrics = { - "training_time_hours": round(training_time / 3600, 4), - "training_time_seconds": round(training_time, 2), - "best_model": str(best_pt if best_pt.exists() else canonical_best), - "timestamp": datetime.now().isoformat(), - "device": target_device, - "imgsz": imgsz, - "conf_threshold": conf_val, - "iou_threshold": iou_val, - } - - if canonical_best.exists(): - try: - best_model = YOLO(str(canonical_best)) - val_results = best_model.val(data=str(abs_data_yaml), split="test", conf=conf_val, iou=iou_val) - final_metrics["test_mAP50"] = float(val_results.box.map50) - final_metrics["test_mAP50_95"] = float(val_results.box.map) - final_metrics["test_precision"] = float(val_results.box.mp) - final_metrics["test_recall"] = float(val_results.box.mr) - - print("\n" + "=" * 75) - print(f"Post-Training Test Set Validation Results (conf={conf_val})") - print("=" * 75) - print(f"Test mAP50: {val_results.box.map50:.4f}") - print(f"Test mAP50-95: {val_results.box.map:.4f}") - print(f"Test Precision:{val_results.box.mp:.4f}") - print(f"Test Recall: {val_results.box.mr:.4f}") - except Exception as e: - print(f"[WARN] Test set validation skipped: {e}") - - with open(abs_output_dir / "final_metrics.json", "w", encoding="utf-8") as f: - json.dump(final_metrics, f, indent=2) - - return results - - -# Alias for backwards compatibility -train_custom = train_yolo_detector - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Train TrackChain YOLOv8n defect detector.") - parser.add_argument("--data", default="data/external/rail_defects_expanded/data.yaml", help="Path to data.yaml") - parser.add_argument("--config", default="ml/configs/detector.yaml", help="Path to config yaml") - parser.add_argument("--epochs", type=int, default=None, help="Number of training epochs (default 50)") - parser.add_argument("--batch", type=int, default=None, help="Batch size (default 16)") - parser.add_argument("--imgsz", type=int, default=None, help="Input resolution (default 640)") - parser.add_argument("--freeze", type=int, default=None, help="Number of backbone layers to freeze (default None)") - parser.add_argument("--dropout", type=float, default=None, help="Dropout regularization rate (default 0.0)") - parser.add_argument("--erasing", type=float, default=None, help="Random erasing probability (default 0.4)") - parser.add_argument("--copy-paste", type=float, default=None, help="Copy-paste augmentation rate (default 0.3)") - parser.add_argument("--close-mosaic", type=int, default=None, help="Epochs before end to disable mosaic (default 30)") - parser.add_argument("--patience", type=int, default=None, help="Early stopping patience (default 40)") - parser.add_argument("--conf", type=float, default=None, help="Validation confidence threshold (default 0.001)") - parser.add_argument("--iou", type=float, default=None, help="NMS IoU threshold (default 0.60)") - parser.add_argument("--device", default="auto", help="Device to train on ('auto', '0' for CUDA GPU, or 'cpu')") - parser.add_argument("--output-dir", default=None, help="Output directory for checkpoints") - parser.add_argument("--resume", action="store_true", help="Resume training from last checkpoint") - args = parser.parse_args() - - train_yolo_detector( - data_yaml=args.data, - config_path=args.config, - epochs=args.epochs, - batch_size=args.batch, - img_size=args.imgsz, - freeze=args.freeze, - dropout=args.dropout, - erasing=args.erasing, - copy_paste=args.copy_paste, - close_mosaic=args.close_mosaic, - patience=args.patience, - conf=args.conf, - iou=args.iou, - device=args.device, - output_dir=args.output_dir, - resume=args.resume, - ) diff --git a/ml/scripts/train_fault_classifier.py b/ml/scripts/train_fault_classifier.py deleted file mode 100644 index 9babdf2dab78e166fa79d7561878ec5622f56c7b..0000000000000000000000000000000000000000 --- a/ml/scripts/train_fault_classifier.py +++ /dev/null @@ -1,105 +0,0 @@ -""" -ml/scripts/train_fault_classifier.py -Trains the Bi-LSTM Geometry Fault Classifier (tc.v1 SOTA). -""" - -import os -import sys -import argparse -from pathlib import Path -import torch -import torch.nn as nn -import torch.optim as optim -from torch.utils.data import DataLoader - -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) - -from ml.data.synthetic_geometry import SyntheticGeometryDataset -from ml.models.geometry.fault_classifier import BiLSTMAttention - - -def train( - num_samples: int = 5000, - val_samples: int = 1000, - epochs: int = 20, - batch_size: int = 64, - lr: float = 1e-3, - save_path: str = "artifacts/checkpoints/geometry/bilstm_fault_typing.pt", -): - device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - print(f"Using device: {device}") - - # 1. Data - train_ds = SyntheticGeometryDataset(num_samples=num_samples) - val_ds = SyntheticGeometryDataset(num_samples=val_samples) - train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True) - val_loader = DataLoader(val_ds, batch_size=batch_size) - - # 2. Model - model = BiLSTMAttention().to(device) - - # 3. SOTA Loss & Optimizer - criterion = nn.CrossEntropyLoss(label_smoothing=0.1) - optimizer = optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4) - scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) - - # 4. Training Loop - print(f"Starting training for {epochs} epochs on {len(train_ds)} samples...") - for epoch in range(epochs): - model.train() - train_loss = 0.0 - for X, y in train_loader: - X, y = X.to(device), y.to(device) - optimizer.zero_grad() - logits, _ = model(X) - loss = criterion(logits, y) - loss.backward() - optimizer.step() - train_loss += loss.item() - - scheduler.step() - - # Validation - model.eval() - correct = 0 - total = 0 - with torch.no_grad(): - for X, y in val_loader: - X, y = X.to(device), y.to(device) - logits, _ = model(X) - preds = torch.argmax(logits, dim=1) - correct += (preds == y).sum().item() - total += y.size(0) - - val_acc = correct / max(1, total) - print(f"Epoch {epoch+1:02d}/{epochs:02d} | Loss: {train_loss/len(train_loader):.4f} | Val Acc: {val_acc * 100:.2f}%") - - # 5. Save Checkpoint - out_dir = os.path.dirname(save_path) - os.makedirs(out_dir, exist_ok=True) - torch.save(model.state_dict(), save_path) - print(f"[OK] Model saved to {save_path}") - - # Mirror to ml/models/geometry/weights for unified resolution - mirror_path = "ml/models/geometry/weights/fault_classifier.pt" - os.makedirs(os.path.dirname(mirror_path), exist_ok=True) - torch.save({"model_state_dict": model.state_dict(), "temperature": 1.5}, mirror_path) - print(f"[OK] Mirrored weights to {mirror_path}") - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Train Bi-LSTM Geometry Classifier.") - parser.add_argument("--epochs", type=int, default=20, help="Training epochs") - parser.add_argument("--batch_size", "--batch-size", "--batch", type=int, default=64, dest="batch_size", help="Batch size") - parser.add_argument("--lr", type=float, default=1e-3, help="Learning rate") - parser.add_argument("--save_path", "--save-path", default="artifacts/checkpoints/geometry/bilstm_fault_typing.pt", dest="save_path") - parser.add_argument("--num_samples", "--num-samples", type=int, default=5000, dest="num_samples") - args = parser.parse_args() - - train( - epochs=args.epochs, - batch_size=args.batch_size, - lr=args.lr, - save_path=args.save_path, - num_samples=args.num_samples, - ) diff --git a/ml/scripts/train_fault_classifier_enhanced.py b/ml/scripts/train_fault_classifier_enhanced.py deleted file mode 100644 index aeeaf89ee6892231c6d563f273509ccd33e494ce..0000000000000000000000000000000000000000 --- a/ml/scripts/train_fault_classifier_enhanced.py +++ /dev/null @@ -1,467 +0,0 @@ -""" -Enhanced training script for Bi-LSTM Geometry Fault Classifier. - -Improvements: -- Deeper architecture (3 layers, 128 hidden) -- Multi-head attention -- Label smoothing -- Sequence augmentation -- Learning rate scheduling -- Gradient clipping -- Comprehensive metrics logging -""" -import argparse -import json -import sys -import time -from pathlib import Path - -import numpy as np -import torch -import torch.nn as nn -import torch.nn.functional as F -import torch.optim as optim -from torch.utils.data import DataLoader, Dataset - -# Add repo root to path -sys.path.append(str(Path(__file__).parent.parent.parent)) -from ml.models.geometry.fault_classifier_enhanced import ( - EnhancedBiLSTMClassifier, - SequenceAugmentation, -) - - -class GeometrySequenceClassificationDataset(Dataset): - """Dataset for geometry sequence classification.""" - - def __init__(self, csv_path: str, seq_len: int = 80, augment: bool = False): - import pandas as pd - - self.seq_len = seq_len - self.augment = augment - self.augmenter = SequenceAugmentation() if augment else None - - # Load data - df = pd.read_csv(csv_path) - - # Extract features - feature_cols = ['twist_3m', 'versine_10m', 'versine_20m', 'unevenness_10m', 'cant'] - alt_feature_cols = ['twist_3m_mm', 'versine_10m_mm', 'versine_20m_mm', 'unevenness_10m_mm', 'cant_mm'] - - if all(col in df.columns for col in feature_cols): - self.features = df[feature_cols].values.astype(np.float32) - elif all(col in df.columns for col in alt_feature_cols): - self.features = df[alt_feature_cols].values.astype(np.float32) - else: - numeric_cols = df.select_dtypes(include=[np.number]).columns - self.features = df[numeric_cols[:5]].values.astype(np.float32) - - # Extract labels - if 'label' in df.columns: - self.labels = df['label'].values.astype(np.int64) - elif 'fault_type' in df.columns: - self.labels = df['fault_type'].values.astype(np.int64) - else: - self.labels = np.zeros(len(df), dtype=np.int64) - - # Normalize features - self.mean = self.features.mean(axis=0) - self.std = self.features.std(axis=0) + 1e-8 - self.features = (self.features - self.mean) / self.std - - # Create sequences - self.sequences = [] - self.seq_labels = [] - - for i in range(0, len(self.features) - seq_len + 1, seq_len): - self.sequences.append(self.features[i:i + seq_len]) - # Use majority label in window - window_labels = self.labels[i:i + seq_len] - self.seq_labels.append(np.bincount(window_labels).argmax()) - - self.sequences = np.array(self.sequences) - self.seq_labels = np.array(self.seq_labels) - - print(f"Loaded {len(self.sequences)} sequences from {csv_path}") - - def __len__(self): - return len(self.sequences) - - def __getitem__(self, idx): - seq = torch.tensor(self.sequences[idx], dtype=torch.float32) - label = torch.tensor(self.seq_labels[idx], dtype=torch.long) - - if self.augment and self.augmenter is not None: - seq = self.augmenter.augment(seq) - - return seq, label - - -class LabelSmoothingLoss(nn.Module): - """Label smoothing cross-entropy loss.""" - - def __init__(self, num_classes: int, smoothing: float = 0.1): - super().__init__() - self.num_classes = num_classes - self.smoothing = smoothing - - def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor: - log_probs = F.log_softmax(pred, dim=1) - - # Create smoothed labels - with torch.no_grad(): - true_dist = torch.zeros_like(log_probs) - true_dist.fill_(self.smoothing / max(1, self.num_classes - 1)) - true_dist.scatter_(1, target.unsqueeze(1), 1.0 - self.smoothing) - - return torch.mean(torch.sum(-true_dist * log_probs, dim=1)) - - -def train_enhanced_bilstm(args): - """Train the enhanced Bi-LSTM classifier.""" - - print("=" * 70) - print("TrackChain Enhanced Bi-LSTM Training Pipeline") - print("=" * 70) - - device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - print(f"Using device: {device}") - - # Print configuration - print(f"\nConfiguration:") - print(f" Epochs: {args.epochs}") - print(f" Hidden size: {args.hidden_size}") - print(f" Num layers: {args.num_layers}") - print(f" Batch size: {args.batch_size}") - print(f" Learning rate: {args.lr}") - print(f" Label smoothing: {args.label_smoothing}") - - # Load data - print(f"\n[1/7] Loading data from {args.data_path}...") - - data_path = Path(args.data_path) - csv_files = [] - if data_path.exists(): - if data_path.is_file() and data_path.suffix == '.csv': - csv_files = [data_path] - else: - csv_files = list(data_path.glob('*.csv')) + list(data_path.glob('**/*.csv')) - - if not csv_files: - print(" No CSV found in data path, generating synthetic dataset...") - from ml.data.synthetic_geometry import SyntheticGeometryDataset - full_dataset = SyntheticGeometryDataset(num_samples=2500, seq_len=args.seq_len, num_classes=5) - - n_total = len(full_dataset) - n_train = int(0.8 * n_total) - n_val = n_total - n_train - - train_subset, val_subset = torch.utils.data.random_split(full_dataset, [n_train, n_val]) - n_classes = 5 - else: - csv_path = csv_files[0] - print(f" Using: {csv_path}") - - # Create datasets - train_dataset = GeometrySequenceClassificationDataset( - str(csv_path), seq_len=args.seq_len, augment=True - ) - val_dataset = GeometrySequenceClassificationDataset( - str(csv_path), seq_len=args.seq_len, augment=False - ) - - n_total = len(train_dataset) - n_train = int(0.8 * n_total) - n_val = n_total - n_train - - train_subset, val_subset = torch.utils.data.random_split( - train_dataset, [n_train, n_val] - ) - n_classes = len(np.unique(train_dataset.seq_labels)) if len(train_dataset) > 0 else 5 - - train_loader = DataLoader( - train_subset, batch_size=args.batch_size, shuffle=True, drop_last=(len(train_subset) > args.batch_size) - ) - val_loader = DataLoader( - val_subset, batch_size=args.batch_size, shuffle=False - ) - - print(f" Train: {len(train_subset)} sequences") - print(f" Valid: {len(val_subset)} sequences") - print(f" Classes: {n_classes}") - - # Initialize model - print(f"\n[2/7] Initializing Enhanced Bi-LSTM...") - model = EnhancedBiLSTMClassifier( - input_size=5, - hidden_size=args.hidden_size, - num_layers=args.num_layers, - num_classes=n_classes, - dropout=args.dropout, - use_attention=True, - num_heads=4, - ).to(device) - - n_params = sum(p.numel() for p in model.parameters()) - print(f" Model parameters: {n_params:,}") - - # Loss, optimizer, scheduler - criterion = LabelSmoothingLoss(n_classes, smoothing=args.label_smoothing) - - optimizer = optim.AdamW( - model.parameters(), - lr=args.lr, - weight_decay=args.weight_decay, - ) - - scheduler = optim.lr_scheduler.CosineAnnealingWarmRestarts( - optimizer, - T_0=10, - T_mult=2, - eta_min=args.lr * 0.01, - ) - - # Training loop - print(f"\n[3/7] Training for {args.epochs} epochs...") - print("-" * 70) - - best_val_acc = 0.0 - best_epoch = 0 - training_history = { - 'epochs': [], - 'train_loss': [], - 'train_acc': [], - 'val_loss': [], - 'val_acc': [], - 'lr': [], - } - - start_time = time.time() - - for epoch in range(args.epochs): - # Training - model.train() - train_loss = 0.0 - train_correct = 0 - train_total = 0 - - for batch_data, batch_labels in train_loader: - batch_data = batch_data.to(device) - batch_labels = batch_labels.to(device) - - optimizer.zero_grad() - - # Forward pass - logits, _ = model(batch_data) - - # Compute loss - loss = criterion(logits, batch_labels) - - # Backward pass - loss.backward() - - # Gradient clipping - torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=args.grad_clip) - - optimizer.step() - - train_loss += loss.item() - _, predicted = logits.max(1) - train_total += batch_labels.size(0) - train_correct += predicted.eq(batch_labels).sum().item() - - # Validation - model.eval() - val_loss = 0.0 - val_correct = 0 - val_total = 0 - - with torch.no_grad(): - for batch_data, batch_labels in val_loader: - batch_data = batch_data.to(device) - batch_labels = batch_labels.to(device) - - logits, _ = model(batch_data) - loss = criterion(logits, batch_labels) - - val_loss += loss.item() - _, predicted = logits.max(1) - val_total += batch_labels.size(0) - val_correct += predicted.eq(batch_labels).sum().item() - - # Compute metrics - train_acc = 100.0 * train_correct / max(1, train_total) - val_acc = 100.0 * val_correct / max(1, val_total) - - avg_train_loss = train_loss / max(1, len(train_loader)) - avg_val_loss = val_loss / max(1, len(val_loader)) - - # Log - training_history['epochs'].append(epoch + 1) - training_history['train_loss'].append(avg_train_loss) - training_history['train_acc'].append(train_acc) - training_history['val_loss'].append(avg_val_loss) - training_history['val_acc'].append(val_acc) - training_history['lr'].append(optimizer.param_groups[0]['lr']) - - if (epoch + 1) % 5 == 0 or epoch == 0 or epoch == args.epochs - 1: - print(f"Epoch {epoch+1:02d}/{args.epochs} | " - f"Train Loss: {avg_train_loss:.4f} Acc: {train_acc:.2f}% | " - f"Val Loss: {avg_val_loss:.4f} Acc: {val_acc:.2f}% | " - f"LR: {optimizer.param_groups[0]['lr']:.6f}") - - # Save best model - if val_acc > best_val_acc: - best_val_acc = val_acc - best_epoch = epoch + 1 - - save_path = Path(args.save_path) - save_path.parent.mkdir(parents=True, exist_ok=True) - torch.save(model.state_dict(), save_path) - - # Update learning rate - scheduler.step() - - training_time = time.time() - start_time - - print(f"\n[4/7] Training completed in {training_time/60:.2f} minutes") - print(f" Best epoch: {best_epoch}") - print(f" Best val accuracy: {best_val_acc:.2f}%") - - # Calibrate with SOTA Vector Scaling & Temperature Scaling - print(f"\n[5/7] Calibrating with SOTA Vector Scaling (ECE optimization)...") - - if Path(args.save_path).exists(): - model.load_state_dict(torch.load(args.save_path, map_location=device)) - model.eval() - - # Collect logits from validation set - val_logits = [] - val_labels = [] - - with torch.no_grad(): - for batch_data, batch_labels in val_loader: - batch_data = batch_data.to(device) - logits, _ = model(batch_data) - val_logits.append(logits.cpu()) - val_labels.append(batch_labels.cpu()) - - if val_logits: - val_logits = torch.cat(val_logits, dim=0).numpy() - val_labels = torch.cat(val_labels, dim=0).numpy() - else: - val_logits = np.random.randn(50, n_classes) - val_labels = np.random.randint(0, n_classes, 50) - - # 1. Scalar Temperature Baseline - from scipy.optimize import minimize - - def nll_with_temperature(T): - scaled_logits = val_logits / max(1e-4, T[0]) - probs = np.exp(scaled_logits - np.max(scaled_logits, axis=1, keepdims=True)) - probs = probs / probs.sum(axis=1, keepdims=True) - nll = -np.log(probs[np.arange(len(val_labels)), val_labels] + 1e-10).mean() - return nll - - result = minimize(nll_with_temperature, x0=[1.5], bounds=[(0.1, 10.0)]) - optimal_temperature = float(result.x[0]) - - # 2. SOTA Vector Scaling (per-class weights W and biases b via L-BFGS) - from ml.calibration.temperature import VectorScaler - vector_scaler = VectorScaler(num_classes=n_classes) - vs_res = vector_scaler.fit(val_logits, val_labels, lr=0.01, max_iter=100) - ece_vector = vs_res["ece"] - - print(f" Scalar Temperature: {optimal_temperature:.3f}") - print(f" Vector Scaling ECE: {ece_vector:.4f} (per-class weights + biases)") - - # Save calibration manifest - calibration = { - 'temperature': float(optimal_temperature), - 'ece': float(ece_vector), - 'method': 'vector_scaling', - 'weights': vs_res["weights"], - 'biases': vs_res["biases"], - 'model': 'bilstm_geometry_typing', - 'val_samples': len(val_labels), - 'best_val_acc': float(best_val_acc), - } - - calib_path = Path('artifacts/calibration/bilstm_temp.json') - calib_path.parent.mkdir(parents=True, exist_ok=True) - - with open(calib_path, 'w', encoding='utf-8') as f: - json.dump(calibration, f, indent=2) - - # Save training history - history_path = Path(args.save_path).parent / 'bilstm_training_history.json' - history_path.parent.mkdir(parents=True, exist_ok=True) - with open(history_path, 'w', encoding='utf-8') as f: - json.dump(training_history, f, indent=2) - - # Test attention explainability - print(f"\n[6/7] Testing attention explainability...") - - sample_seq = val_subset[0][0] - explanation = model.predict_with_explanation(sample_seq.to(device)) - - if explanation['attention'] is not None: - print(f" Attention shape: {explanation['attention'].shape}") - print(f" Attention peak at bin: {np.argmax(explanation['attention'])}") - - # Mirror checkpoints to canonical & legacy locations for seamless inference - canonical_pt = Path('artifacts/checkpoints/geometry/bilstm_fault_typing.pt') - canonical_pt.parent.mkdir(parents=True, exist_ok=True) - torch.save(model.state_dict(), canonical_pt) - - weights_mirror = Path('ml/models/geometry/weights/fault_classifier.pt') - weights_mirror.parent.mkdir(parents=True, exist_ok=True) - torch.save({"model_state_dict": model.state_dict(), "temperature": float(optimal_temperature)}, weights_mirror) - - # Final summary - print(f"\n[7/7] Training Summary") - print("=" * 70) - print(f"Model saved to: {args.save_path}") - print(f"Mirrored to: {canonical_pt}") - print(f" {weights_mirror}") - print(f"Calibration saved: {calib_path}") - print(f"Training history: {history_path}") - print(f"\nFinal Metrics:") - print(f" Best Validation Accuracy: {best_val_acc:.2f}%") - print(f" Optimal Temperature: {optimal_temperature:.3f}") - print(f" Vector Scaling ECE: {ece_vector:.4f}") - print(f" Training Time: {training_time/60:.2f} minutes") - - return model, calibration - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Train Enhanced Bi-LSTM") - - # Training parameters - parser.add_argument('--epochs', type=int, default=50, help='Number of training epochs') - parser.add_argument('--batch-size', '--batch_size', dest='batch_size', type=int, default=128, help='Batch size') - parser.add_argument('--lr', type=float, default=0.0005, help='Learning rate') - parser.add_argument('--weight-decay', '--weight_decay', dest='weight_decay', type=float, default=1e-4, help='Weight decay') - parser.add_argument('--grad-clip', '--grad_clip', dest='grad_clip', type=float, default=1.0, help='Gradient clipping norm') - - # Model parameters - parser.add_argument('--hidden-size', '--hidden_size', dest='hidden_size', type=int, default=128, help='Hidden size') - parser.add_argument('--num-layers', '--num_layers', dest='num_layers', type=int, default=3, help='Number of LSTM layers') - parser.add_argument('--dropout', type=float, default=0.4, help='Dropout rate') - - # Loss parameters - parser.add_argument('--label-smoothing', '--label_smoothing', dest='label_smoothing', type=float, default=0.1, help='Label smoothing') - - # Data parameters - parser.add_argument('--seq-len', '--seq_len', dest='seq_len', type=int, default=80, help='Sequence length') - parser.add_argument('--data-path', '--data_path', dest='data_path', type=str, - default='data/processed/geometry_sequences/') - - # Output - parser.add_argument('--save-path', '--save_path', dest='save_path', type=str, - default='artifacts/checkpoints/geometry/bilstm_fault_typing_enhanced.pt') - - args = parser.parse_args() - - train_enhanced_bilstm(args) diff --git a/ml/scripts/train_sequence_vae.py b/ml/scripts/train_sequence_vae.py deleted file mode 100644 index bc5f55fc24577abf7ae98376c650a3c9713d20d5..0000000000000000000000000000000000000000 --- a/ml/scripts/train_sequence_vae.py +++ /dev/null @@ -1,176 +0,0 @@ -""" -ml/scripts/train_sequence_vae.py -Trains the 1D-CNN Sequence VAE on normal track geometry sequences (Beta-VAE, beta=0.01). -""" - -import os -import sys -import argparse -from pathlib import Path -import torch -import torch.optim as optim -from torch.utils.data import DataLoader, TensorDataset - -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) - -from ml.models.geometry.sequence_vae import SequenceVAE, SequenceVAEDetector -from ml.data.synthetic_geometry import SyntheticGeometryDataset, GeometryFaultType -from ml.calibration.patchcore_scale import SigmoidDistanceCalibrator - - -def train_sequence_vae( - num_normal_samples: int = 3000, - val_samples: int = 600, - seq_len: int = 80, - n_features: int = 5, - latent_dim: int = 16, - beta: float = 0.01, - epochs: int = 25, - batch_size: int = 64, - lr: float = 1e-3, - save_path: str = "artifacts/checkpoints/geometry/sequence_vae.pt", - calibrator_save_path: str = "artifacts/calibration/sequence_vae_calibration.json", -): - device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - print(f"[VAE] Training 1D-CNN Sequence VAE on device: {device}") - - # 1. Generate Normal Track Geometry Dataset - print(f"[VAE] Synthesizing {num_normal_samples} nominal EN 13848 track sequences...") - full_train_ds = SyntheticGeometryDataset( - num_samples=num_normal_samples * 2, - seq_len=seq_len, - num_classes=5, - random_seed=42, - ) - # Filter ONLY normal sequences (label == 0) - normal_train_mask = (full_train_ds.labels == GeometryFaultType.NORMAL) - normal_train_data = full_train_ds.data[normal_train_mask][:num_normal_samples] - - full_val_ds = SyntheticGeometryDataset( - num_samples=val_samples * 2, - seq_len=seq_len, - num_classes=5, - random_seed=999, - ) - normal_val_mask = (full_val_ds.labels == GeometryFaultType.NORMAL) - normal_val_data = full_val_ds.data[normal_val_mask][:val_samples] - - train_loader = DataLoader( - TensorDataset(normal_train_data), - batch_size=batch_size, - shuffle=True, - ) - val_loader = DataLoader( - TensorDataset(normal_val_data), - batch_size=batch_size, - shuffle=False, - ) - - # 2. Instantiate 1D-CNN Model - model = SequenceVAE( - seq_len=seq_len, - n_features=n_features, - latent_dim=latent_dim, - ).to(device) - - optimizer = optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4) - scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) - - # 3. Training Loop - print(f"[VAE] Starting optimization ({epochs} epochs, Beta={beta})...") - for epoch in range(epochs): - model.train() - total_loss = 0.0 - recon_total = 0.0 - kld_total = 0.0 - n_batches = 0 - - for (batch_x,) in train_loader: - batch_x = batch_x.to(device) - optimizer.zero_grad() - - recon_x, mu, logvar = model(batch_x) - loss, recon_loss, kld_loss = model.loss_function( - recon_x, batch_x, mu, logvar, beta=beta - ) - - loss.backward() - optimizer.step() - - total_loss += loss.item() - recon_total += recon_loss.item() - kld_total += kld_loss.item() - n_batches += 1 - - scheduler.step() - - if (epoch + 1) % 5 == 0 or epoch == 0 or epoch == epochs - 1: - # Validation reconstruction error - model.eval() - val_mse = 0.0 - val_batches = 0 - with torch.no_grad(): - for (batch_val,) in val_loader: - batch_val = batch_val.to(device) - recon_val, _, _ = model(batch_val) - val_mse += torch.mean((recon_val - batch_val) ** 2).item() - val_batches += 1 - - avg_val_mse = val_mse / max(1, val_batches) - print( - f" Epoch [{epoch+1:02d}/{epochs:02d}] " - f"Train Loss: {total_loss/n_batches:.4f} (Recon: {recon_total/n_batches:.4f}, KLD: {kld_total/n_batches:.4f}) | " - f"Val MSE: {avg_val_mse:.4f}" - ) - - # 4. Fit Sigmoid Distance Calibrator on Normal Validation Errors - print("[VAE] Fitting Sigmoid calibrator on normal validation reconstruction errors...") - model.eval() - val_errors = [] - with torch.no_grad(): - for (batch_val,) in val_loader: - batch_val = batch_val.to(device) - errs = model.compute_anomaly_score(batch_val).cpu().numpy() - val_errors.extend(errs.tolist()) - - calibrator = SigmoidDistanceCalibrator(steepness_k=2.5, percentile=99.0) - p99_thresh = calibrator.fit(val_errors) - print(f" Fitted P99 Anomaly Threshold: {p99_thresh:.4f}") - - # 5. Save Checkpoint & Calibrator - out_dir = os.path.dirname(save_path) - os.makedirs(out_dir, exist_ok=True) - torch.save(model.state_dict(), save_path) - print(f"[OK] Sequence VAE weights saved to: {save_path}") - - # Mirror to ml/models/geometry/weights/ - mirror_path = "ml/models/geometry/weights/sequence_vae.pt" - os.makedirs(os.path.dirname(mirror_path), exist_ok=True) - torch.save(model.state_dict(), mirror_path) - - calibrator.save(calibrator_save_path) - print(f"[OK] Sigmoid calibrator state saved to: {calibrator_save_path}") - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Train 1D-CNN Sequence VAE for Novel Geometry.") - parser.add_argument("--epochs", type=int, default=25, help="Number of training epochs") - parser.add_argument("--beta", type=float, default=0.01, help="Beta-VAE KL weight") - parser.add_argument("--batch-size", "--batch_size", dest="batch_size", type=int, default=64, help="Batch size") - parser.add_argument("--lr", type=float, default=0.001, help="Learning rate") - parser.add_argument("--latent-dim", "--latent_dim", dest="latent_dim", type=int, default=16, help="Latent space dimension") - parser.add_argument("--seq-len", "--seq_len", dest="seq_len", type=int, default=80, help="Sequence length") - parser.add_argument("--n-features", "--n_features", dest="n_features", type=int, default=5, help="Number of input features") - parser.add_argument("--save-path", "--save_path", dest="save_path", default="artifacts/checkpoints/geometry/sequence_vae.pt") - args = parser.parse_args() - - train_sequence_vae( - epochs=args.epochs, - beta=args.beta, - batch_size=args.batch_size, - lr=args.lr, - latent_dim=args.latent_dim, - seq_len=args.seq_len, - n_features=args.n_features, - save_path=args.save_path, - ) diff --git a/ml/scripts/train_sequence_vae_enhanced.py b/ml/scripts/train_sequence_vae_enhanced.py deleted file mode 100644 index 2fdabe28d51dafb048555ef407d8787d2cfa4b4f..0000000000000000000000000000000000000000 --- a/ml/scripts/train_sequence_vae_enhanced.py +++ /dev/null @@ -1,502 +0,0 @@ -""" -Enhanced training script for Sequence VAE. - -Fixes: -- Accepts both --latent_dim and --latent-dim arguments -- KL annealing for stable training -- Learning rate scheduling -- Gradient clipping -- Comprehensive metrics logging -- Dual-path scoring calibration - -Usage: - python ml/scripts/train_sequence_vae_enhanced.py \ - --epochs 50 \ - --beta 0.01 \ - --latent-dim 16 \ - --batch-size 64 \ - --lr 0.001 \ - --save-path artifacts/checkpoints/geometry/sequence_vae_enhanced.pt -""" -import argparse -import json -import sys -import time -from pathlib import Path - -import numpy as np -import torch -import torch.nn as nn -import torch.optim as optim -from torch.utils.data import DataLoader, Dataset - -# Add repo root to path -sys.path.append(str(Path(__file__).parent.parent.parent)) -from ml.models.geometry.sequence_vae_enhanced import EnhancedSequenceVAE - - -class GeometrySequenceDataset(Dataset): - """Dataset for geometry sequences.""" - - def __init__(self, csv_path: str, seq_len: int = 80, n_features: int = 5): - import pandas as pd - - self.seq_len = seq_len - self.n_features = n_features - - # Load data - df = pd.read_csv(csv_path) - - # Extract features - feature_cols = ['twist_3m', 'versine_10m', 'versine_20m', 'unevenness_10m', 'cant'] - alt_feature_cols = ['twist_3m_mm', 'versine_10m_mm', 'versine_20m_mm', 'unevenness_10m_mm', 'cant_mm'] - - if all(col in df.columns for col in feature_cols): - self.data = df[feature_cols].values.astype(np.float32) - elif all(col in df.columns for col in alt_feature_cols): - self.data = df[alt_feature_cols].values.astype(np.float32) - else: - # Fallback: use first n_features numeric columns - numeric_cols = df.select_dtypes(include=[np.number]).columns - self.data = df[numeric_cols[:n_features]].values.astype(np.float32) - - # Normalize - self.mean = self.data.mean(axis=0) - self.std = self.data.std(axis=0) + 1e-8 - self.data = (self.data - self.mean) / self.std - - # Create sequences - self.sequences = [] - stride = max(1, seq_len // 2) - for i in range(0, len(self.data) - seq_len + 1, stride): # 50% overlap - self.sequences.append(self.data[i:i + seq_len]) - - self.sequences = np.array(self.sequences) - - # Filter to normal only (if labels available) - if 'label' in df.columns and len(self.sequences) > 0: - # Keep only normal sequences (label == 0) - normal_mask = df['label'].values[:len(self.sequences)] == 0 - if normal_mask.sum() > 0: - self.sequences = self.sequences[normal_mask] - - print(f"Loaded {len(self.sequences)} sequences from {csv_path}") - - def __len__(self): - return len(self.sequences) - - def __getitem__(self, idx): - return torch.tensor(self.sequences[idx], dtype=torch.float32) - - -def train_enhanced_vae(args): - """Train the enhanced Sequence VAE.""" - - print("=" * 70) - print("TrackChain Enhanced Sequence VAE Training Pipeline") - print("=" * 70) - - device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - print(f"Using device: {device}") - - # Print configuration - print(f"\nConfiguration:") - print(f" Epochs: {args.epochs}") - print(f" Beta: {args.beta}") - print(f" Latent dim: {args.latent_dim}") - print(f" Batch size: {args.batch_size}") - print(f" Learning rate: {args.lr}") - print(f" KL annealing: {args.kl_annealing}") - print(f" Annealing epochs: {args.annealing_epochs}") - - # Load data - print(f"\n[1/6] Loading data from {args.data_path}...") - - # Find CSV file - data_path = Path(args.data_path) - csv_files = [] - if data_path.exists(): - if data_path.is_file() and data_path.suffix == '.csv': - csv_files = [data_path] - else: - csv_files = list(data_path.glob('*.csv')) + list(data_path.glob('**/*.csv')) - - if not csv_files: - # Generate synthetic data - print(" No CSV found, generating synthetic data...") - from ml.data.synthetic_geometry import SyntheticGeometryDataset - dataset = SyntheticGeometryDataset(num_samples=3000, seq_len=args.seq_len) - - # Filter to normal only - normal_indices = [i for i, (_, label) in enumerate(dataset) if label == 0] - - # Create sequences tensor - sequences = [] - for i in normal_indices: - seq, _ = dataset[i] - sequences.append(seq.numpy() if hasattr(seq, 'numpy') else np.asarray(seq)) - - sequences = np.array(sequences) - else: - csv_path = csv_files[0] - print(f" Using: {csv_path}") - dataset = GeometrySequenceDataset(str(csv_path), seq_len=args.seq_len, n_features=args.n_features) - sequences = dataset.sequences - if len(sequences) == 0: - print(" CSV sequence count was 0, falling back to synthetic data...") - from ml.data.synthetic_geometry import SyntheticGeometryDataset - synth_dataset = SyntheticGeometryDataset(num_samples=3000, seq_len=args.seq_len) - normal_indices = [i for i, (_, label) in enumerate(synth_dataset) if label == 0] - sequences = np.array([synth_dataset[i][0].numpy() for i in normal_indices]) - - # Split into train and validation - n_total = len(sequences) - n_train = max(1, int(0.8 * n_total)) - - train_sequences = sequences[:n_train] - val_sequences = sequences[n_train:] if n_train < n_total else sequences[:max(1, int(0.2 * n_total))] - - print(f" Train: {len(train_sequences)} sequences") - print(f" Valid: {len(val_sequences)} sequences") - - # Create data loaders - train_loader = DataLoader( - torch.utils.data.TensorDataset(torch.tensor(train_sequences, dtype=torch.float32)), - batch_size=min(args.batch_size, len(train_sequences)), - shuffle=True, - drop_last=(len(train_sequences) > args.batch_size), - ) - - val_loader = DataLoader( - torch.utils.data.TensorDataset(torch.tensor(val_sequences, dtype=torch.float32)), - batch_size=min(args.batch_size, len(val_sequences)), - shuffle=False, - ) - - # Initialize model - print(f"\n[2/6] Initializing Enhanced Sequence VAE...") - model = EnhancedSequenceVAE( - seq_len=args.seq_len, - n_features=args.n_features, - latent_dim=args.latent_dim, - beta=args.beta, - use_kl_annealing=args.kl_annealing, - annealing_epochs=args.annealing_epochs, - ).to(device) - - # Count parameters - n_params = sum(p.numel() for p in model.parameters()) - print(f" Model parameters: {n_params:,}") - - # Optimizer and scheduler - optimizer = optim.AdamW( - model.parameters(), - lr=args.lr, - weight_decay=args.weight_decay, - ) - - scheduler = optim.lr_scheduler.CosineAnnealingLR( - optimizer, - T_max=max(1, args.epochs), - eta_min=args.lr * 0.01, - ) - - # Training loop - print(f"\n[3/6] Training for {args.epochs} epochs...") - print("-" * 70) - - best_val_loss = float('inf') - best_epoch = 0 - training_history = { - 'epochs': [], - 'train_loss': [], - 'train_recon': [], - 'train_kl': [], - 'val_loss': [], - 'val_recon': [], - 'val_kl': [], - 'lr': [], - 'beta': [], - } - - start_time = time.time() - - for epoch in range(args.epochs): - # Training - model.train() - train_total_loss = 0.0 - train_recon_loss = 0.0 - train_kl_loss = 0.0 - current_beta = 0.0 - - for batch_idx, (batch_data,) in enumerate(train_loader): - batch_data = batch_data.to(device) - - optimizer.zero_grad() - - # Forward pass - recon_batch, mu, logvar = model(batch_data) - - # Compute loss with KL annealing - losses = model.compute_loss(batch_data, recon_batch, mu, logvar, epoch) - - # Backward pass - losses['total'].backward() - - # Gradient clipping - torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=args.grad_clip) - - optimizer.step() - - train_total_loss += losses['total'].item() - train_recon_loss += losses['recon'].item() - train_kl_loss += losses['kl'].item() - current_beta = float(losses['beta']) - - # Validation - model.eval() - val_total_loss = 0.0 - val_recon_loss = 0.0 - val_kl_loss = 0.0 - - with torch.no_grad(): - for (batch_data,) in val_loader: - batch_data = batch_data.to(device) - - recon_batch, mu, logvar = model(batch_data) - losses = model.compute_loss(batch_data, recon_batch, mu, logvar, epoch) - - val_total_loss += losses['total'].item() - val_recon_loss += losses['recon'].item() - val_kl_loss += losses['kl'].item() - - # Compute averages - n_train_batches = max(1, len(train_loader)) - n_val_batches = max(1, len(val_loader)) - - avg_train_loss = train_total_loss / n_train_batches - avg_train_recon = train_recon_loss / n_train_batches - avg_train_kl = train_kl_loss / n_train_batches - - avg_val_loss = val_total_loss / n_val_batches - avg_val_recon = val_recon_loss / n_val_batches - avg_val_kl = val_kl_loss / n_val_batches - - # Log - training_history['epochs'].append(epoch + 1) - training_history['train_loss'].append(avg_train_loss) - training_history['train_recon'].append(avg_train_recon) - training_history['train_kl'].append(avg_train_kl) - training_history['val_loss'].append(avg_val_loss) - training_history['val_recon'].append(avg_val_recon) - training_history['val_kl'].append(avg_val_kl) - training_history['lr'].append(optimizer.param_groups[0]['lr']) - training_history['beta'].append(current_beta) - - # Print progress - if (epoch + 1) % 5 == 0 or epoch == 0 or epoch == args.epochs - 1: - print(f"Epoch {epoch+1:02d}/{args.epochs} | " - f"Train: {avg_train_loss:.4f} (R:{avg_train_recon:.4f} K:{avg_train_kl:.4f}) | " - f"Val: {avg_val_loss:.4f} (R:{avg_val_recon:.4f} K:{avg_val_kl:.4f}) | " - f"β:{current_beta:.4f} | " - f"LR:{optimizer.param_groups[0]['lr']:.6f}") - - # Save best model and check early stopping - if avg_val_loss < best_val_loss: - best_val_loss = avg_val_loss - best_epoch = epoch + 1 - early_stopping_counter = 0 - - save_path = Path(args.save_path) - save_path.parent.mkdir(parents=True, exist_ok=True) - torch.save(model.state_dict(), save_path) - else: - early_stopping_counter += 1 - if early_stopping_counter >= getattr(args, 'patience', 8): - print(f"\n[INFO] Early stopping triggered at epoch {epoch+1} (patience={args.patience} reached, best epoch: {best_epoch})") - break - - # Update learning rate - scheduler.step() - - training_time = time.time() - start_time - - print(f"\n[4/6] Training completed in {training_time/60:.2f} minutes") - print(f" Best epoch: {best_epoch}") - print(f" Best val loss: {best_val_loss:.4f}") - - # Fit latent distribution for Mahalanobis scoring - print(f"\n[5/6] Fitting latent distribution for dual-path scoring...") - if Path(args.save_path).exists(): - model.load_state_dict(torch.load(args.save_path, map_location=device)) - model.eval() - - # Use validation sequences for fitting (model handles device mapping internally) - val_tensor = torch.tensor(val_sequences, dtype=torch.float32) - model.fit_latent_distribution(val_tensor) - - print(f" Latent mean shape: {model.latent_mean.shape}") - print(f" Latent covariance inverse shape: {model.latent_cov_inv.shape}") - - # Calibrate thresholds with Extreme Value Theory (EVT) and P99 - print(f"\n[6/6] Calibrating anomaly thresholds with Extreme Value Theory (EVT)...") - - # Compute reconstruction errors on validation set - val_errors = [] - val_mahalanobis = [] - - with torch.no_grad(): - for i in range(0, len(val_sequences), args.batch_size): - batch = torch.tensor(val_sequences[i:i + args.batch_size], dtype=torch.float32).to(device) - - for sample_idx in range(batch.size(0)): - scores = model.compute_anomaly_score(batch[sample_idx]) - val_errors.append(scores.get('recon_error', 0.0)) - val_mahalanobis.append(scores.get('mahalanobis_dist', 0.0)) - - # Compute P99 thresholds - p99_recon = float(np.percentile(val_errors, 99)) if val_errors else 1.0 - p99_mahalanobis = float(np.percentile(val_mahalanobis, 99)) if val_mahalanobis else 1.0 - ensemble_scores = [0.7 * e + 0.3 * m for e, m in zip(val_errors, val_mahalanobis)] if val_errors else [1.0] - p99_ensemble = float(np.percentile(ensemble_scores, 99)) - - # SOTA: Compute Extreme Value Theory (EVT) Peaks-Over-Threshold - evt_recon = model.fit_evt_threshold(val_errors, target_fpr=0.01) - evt_ensemble = model.fit_evt_threshold(ensemble_scores, target_fpr=0.01) - - # Empirical False Positive Rate (FPR) Validation Check (Upgrade B) - actual_fpr = float(np.mean([s > evt_ensemble['threshold'] for s in ensemble_scores])) - print(f" P99 Ensemble Threshold: {p99_ensemble:.4f}") - print(f" EVT Ensemble Threshold: {evt_ensemble['threshold']:.4f} (shape={evt_ensemble['shape']:.4f}, scale={evt_ensemble['scale']:.4f})") - print(f" Empirical Validation FPR: {actual_fpr*100:.2f}% (Target: 1.00%, Tolerance <= 2.00%)") - assert actual_fpr <= 0.02, f"FPR validation check failed: {actual_fpr:.4f} > 0.02" - - # Strict Normalization Guard Check (Upgrade C) - model.threshold_evt = float(evt_ensemble['threshold']) - test_prob_min = model.score_to_probability(0.0) - test_prob_thresh = model.score_to_probability(evt_ensemble['threshold']) - test_prob_extreme = model.score_to_probability(evt_ensemble['threshold'] * 5.0) - assert 0.0 <= test_prob_min <= 0.20, f"Min score probability out of expected range: {test_prob_min}" - assert abs(test_prob_thresh - 0.50) < 0.01, f"Threshold decision boundary not 0.50: {test_prob_thresh}" - assert 0.90 <= test_prob_extreme <= 1.0, f"Extreme anomaly probability not near 1.0: {test_prob_extreme}" - print(f" Probability Normalization: Verified (P(0)={test_prob_min:.3f}, P(Threshold)={test_prob_thresh:.3f}, P(Extreme)={test_prob_extreme:.3f})") - - # Save calibration manifest - calibration = { - 'threshold_evt': float(evt_ensemble['threshold']), - 'threshold_p99': float(p99_ensemble), - 'threshold_recon_evt': float(evt_recon['threshold']), - 'threshold_recon_p99': float(p99_recon), - 'threshold_mahalanobis_p99': float(p99_mahalanobis), - 'evt_shape': float(evt_ensemble['shape']), - 'evt_scale': float(evt_ensemble['scale']), - 'evt_init_threshold': float(evt_ensemble['init_threshold']), - 'steepness': 0.5, - 'steepness_k': 2.0, - 'target_fpr': 0.01, - 'actual_fpr': actual_fpr, - 'fpr_verified': True, - 'normalization_guard_passed': True, - 'model': 'sequence_vae_geometry_novel', - 'val_samples': len(val_sequences), - 'mean_recon_error': float(np.mean(val_errors)) if val_errors else 0.0, - 'std_recon_error': float(np.std(val_errors)) if val_errors else 0.0, - 'mean_mahalanobis': float(np.mean(val_mahalanobis)) if val_mahalanobis else 0.0, - 'std_mahalanobis': float(np.std(val_mahalanobis)) if val_mahalanobis else 0.0, - } - - calib_path = Path('artifacts/calibration/vae_calibration.json') - calib_path.parent.mkdir(parents=True, exist_ok=True) - - with open(calib_path, 'w', encoding='utf-8') as f: - json.dump(calibration, f, indent=2) - - # Mirror calibration to legacy path - legacy_calib = Path('artifacts/calibration/sequence_vae_calibration.json') - with open(legacy_calib, 'w', encoding='utf-8') as f: - json.dump({ - "method": "evt_peaks_over_threshold", - "threshold_evt": float(evt_ensemble['threshold']), - "threshold_p99": float(p99_ensemble), - "steepness_k": 0.5, - "percentile": 99.0, - "is_fitted": True, - "model": "sequence_vae_geometry_novel" - }, f, indent=2) - - # Save training history - history_path = Path(args.save_path).parent / 'training_history.json' - history_path.parent.mkdir(parents=True, exist_ok=True) - with open(history_path, 'w', encoding='utf-8') as f: - json.dump(training_history, f, indent=2) - - # Save latent distribution - latent_dist_path = Path(args.save_path).parent / 'latent_distribution.npz' - np.savez( - latent_dist_path, - mean=model.latent_mean, - cov_inv=model.latent_cov_inv, - ) - - # Mirror checkpoints to canonical & legacy locations for seamless inference - canonical_pt = Path('artifacts/checkpoints/geometry/sequence_vae.pt') - canonical_pt.parent.mkdir(parents=True, exist_ok=True) - torch.save(model.state_dict(), canonical_pt) - - weights_mirror = Path('ml/models/geometry/weights/sequence_vae.pt') - weights_mirror.parent.mkdir(parents=True, exist_ok=True) - torch.save(model.state_dict(), weights_mirror) - - print(f"\n" + "=" * 70) - print("Enhanced Sequence VAE Training Complete!") - print("=" * 70) - print(f"Model saved to: {args.save_path}") - print(f"Mirrored to: {canonical_pt}") - print(f" {weights_mirror}") - print(f"Calibration saved: {calib_path}") - print(f"Training history: {history_path}") - print(f"\nCalibration parameters:") - print(f" P99 reconstruction error: {p99_recon:.4f}") - print(f" P99 Mahalanobis distance: {p99_mahalanobis:.4f}") - print(f" P99 ensemble score: {p99_ensemble:.4f}") - print(f" Target FPR: 1%") - - return model, calibration - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Train Enhanced Sequence VAE") - - # Training parameters - parser.add_argument('--epochs', type=int, default=50, help='Number of training epochs') - parser.add_argument('--beta', type=float, default=0.01, help='Beta-VAE weight for KL divergence') - parser.add_argument('--batch-size', '--batch_size', dest='batch_size', type=int, default=64, help='Batch size') - parser.add_argument('--lr', type=float, default=0.001, help='Learning rate') - parser.add_argument('--weight-decay', '--weight_decay', dest='weight_decay', type=float, default=1e-4, help='Weight decay') - parser.add_argument('--grad-clip', '--grad_clip', dest='grad_clip', type=float, default=1.0, help='Gradient clipping norm') - parser.add_argument('--patience', type=int, default=8, help='Early stopping patience') - - # Model parameters - ACCEPT BOTH HYPHEN AND UNDERSCORE FORMATS - parser.add_argument('--latent-dim', '--latent_dim', dest='latent_dim', type=int, default=16, - help='Latent space dimension') - parser.add_argument('--seq-len', '--seq_len', dest='seq_len', type=int, default=80, - help='Sequence length') - parser.add_argument('--n-features', '--n_features', dest='n_features', type=int, default=5, - help='Number of input features') - - # KL annealing - parser.add_argument('--kl-annealing', '--kl_annealing', dest='kl_annealing', action='store_true', default=True, - help='Use KL annealing') - parser.add_argument('--no-kl-annealing', '--no_kl_annealing', dest='kl_annealing', action='store_false') - parser.add_argument('--annealing-epochs', '--annealing_epochs', dest='annealing_epochs', type=int, default=10, - help='Number of epochs for KL annealing') - - # Paths - parser.add_argument('--save-path', '--save_path', dest='save_path', type=str, - default='artifacts/checkpoints/geometry/sequence_vae_enhanced.pt') - parser.add_argument('--data-path', '--data_path', dest='data_path', type=str, - default='data/processed/normal_sequences/') - - args = parser.parse_args() - - train_enhanced_vae(args) diff --git a/ml/scripts/validate_yolo.py b/ml/scripts/validate_yolo.py deleted file mode 100644 index 6ea27d2ed3bf4ae59b1fbe5d9098c2054f1d4196..0000000000000000000000000000000000000000 --- a/ml/scripts/validate_yolo.py +++ /dev/null @@ -1,266 +0,0 @@ -""" -Comprehensive validation of trained YOLO railway defect model (tc.v1 SOTA). -Evaluates precision, recall, mAP50, mAP50-95, per-class metrics, confusion matrices, and PR curves. -Default validation operates at conf=0.25 (realistic operating threshold). -""" -import os -import sys -import json -from pathlib import Path -from typing import Dict, Any, Optional -import numpy as np - -# Ensure repo root is in sys.path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -try: - from ultralytics import YOLO -except ImportError: - YOLO = None - -try: - import matplotlib - matplotlib.use('Agg') # Headless backend - import matplotlib.pyplot as plt - import seaborn as sns -except ImportError: - plt = None - sns = None - -from ml.core.registry import ModelRegistry - - -def validate_model( - model_path: str, - data_yaml: str, - output_dir: str = 'artifacts/validation/yolo', - split: str = 'test', - conf: float = 0.25, - iou: float = 0.60, - imgsz: int = 960, - device: str = 'cpu', -) -> Dict[str, Any]: - """Run comprehensive validation on YOLO model and produce validation report.""" - if YOLO is None: - raise RuntimeError("Ultralytics is required for validation. Install with: pip install ultralytics") - - abs_repo = ModelRegistry.ROOT - abs_model = Path(model_path) if Path(model_path).is_absolute() else abs_repo / model_path - abs_data = Path(data_yaml) if Path(data_yaml).is_absolute() else abs_repo / data_yaml - abs_output = Path(output_dir) if Path(output_dir).is_absolute() else abs_repo / output_dir - abs_output.mkdir(parents=True, exist_ok=True) - - print("=" * 75) - print("TrackChain YOLO Model Comprehensive Validation (tc.v1 SOTA)") - print("=" * 75) - print(f"Model: {abs_model}") - print(f"Data YAML: {abs_data}") - print(f"Split: {split}") - print(f"Conf Threshold: {conf}") - print(f"IoU Threshold: {iou}") - print(f"Input Size: {imgsz}x{imgsz}") - print(f"Output Dir: {abs_output}") - - if not abs_model.exists(): - # Search for canonical checkpoints in vision checkpoint directory - canon = ModelRegistry.CHECKPOINTS_DIR / "vision" / "yolov8n_rail_best.pt" - if canon.exists(): - abs_model = canon - else: - raise FileNotFoundError(f"Model weights not found at {abs_model}") - - model = YOLO(str(abs_model)) - - # Run validation - print(f"\n[1/4] Running validation on {split} split (conf={conf}, iou={iou})...") - try: - results = model.val( - data=str(abs_data), - split=split, - conf=conf, - iou=iou, - imgsz=imgsz, - device=device, - verbose=True, - ) - except Exception as e: - print(f"[WARN] Validation on split='{split}' encountered '{e}'. Falling back to split='val'...") - results = model.val( - data=str(abs_data), - split='val', - conf=conf, - iou=iou, - imgsz=imgsz, - device=device, - verbose=True, - ) - - # Extract metrics - mp = float(results.box.mp) - mr = float(results.box.mr) - f1 = 2 * (mp * mr) / (mp + mr + 1e-6) - - metrics = { - 'mAP50': float(results.box.map50), - 'mAP50_95': float(results.box.map), - 'precision': mp, - 'recall': mr, - 'f1_score': float(f1), - 'operating_conf': conf, - 'operating_iou': iou, - } - - # Extract per-class metrics - class_metrics = {} - class_names = results.names or {} - ap50_list = results.box.ap50 if hasattr(results.box, 'ap50') else [] - ap_list = results.box.ap if hasattr(results.box, 'ap') else [] - - for i, (cls_id, cls_name) in enumerate(class_names.items()): - cls_ap50 = float(ap50_list[i]) if i < len(ap50_list) else 0.0 - cls_ap = float(ap_list[i]) if i < len(ap_list) else 0.0 - class_metrics[cls_name] = { - 'AP50': cls_ap50, - 'AP50_95': cls_ap, - } - - # Confusion matrix generation - print("\n[2/4] Generating confusion matrix...") - if plt is not None and sns is not None and hasattr(results, 'confusion_matrix') and results.confusion_matrix is not None: - try: - cm = results.confusion_matrix.matrix - labels = [class_names.get(i, f"class_{i}") for i in range(len(class_names))] - x_labels = labels + ['background'] - y_labels = labels + ['background'] if cm.shape[0] > len(labels) else labels - - plt.figure(figsize=(9, 7)) - sns.heatmap(cm, annot=True, fmt='.1f' if cm.dtype == float else 'd', cmap='Blues', - xticklabels=x_labels[:cm.shape[1]], - yticklabels=y_labels[:cm.shape[0]]) - plt.xlabel('Predicted') - plt.ylabel('Ground Truth') - plt.title(f'Railway Defect Detection - Confusion Matrix (conf={conf})') - cm_path = abs_output / 'confusion_matrix.png' - plt.savefig(cm_path, dpi=300, bbox_inches='tight') - plt.close() - print(f" Saved confusion matrix: {cm_path}") - except Exception as e: - print(f" [WARN] Confusion matrix plotting skipped: {e}") - - # PR curve visualization - print("\n[3/4] Generating PR curves...") - if plt is not None and hasattr(results, 'curves_results'): - try: - plt.figure(figsize=(9, 6)) - for i, cls_name in class_names.items(): - ap_val = class_metrics.get(cls_name, {}).get('AP50', 0.0) - plt.plot([0, 1], [ap_val, ap_val], label=f"{cls_name} (AP50={ap_val:.3f})") - plt.xlabel('Recall') - plt.ylabel('Precision') - plt.title('Precision-Recall Curves by Defect Class') - plt.legend(loc='lower left') - plt.grid(True, linestyle='--', alpha=0.5) - pr_path = abs_output / 'pr_curves.png' - plt.savefig(pr_path, dpi=300, bbox_inches='tight') - plt.close() - print(f" Saved PR curve: {pr_path}") - except Exception as e: - print(f" [WARN] PR curve plotting skipped: {e}") - - # Test-Time Augmentation (TTA) optional evaluation - tta_metrics = {} - if tta: - print(f"\n[INFO] Running Test-Time Augmentation (TTA) evaluation on {split} split...") - try: - tta_results = model.val( - data=str(abs_data), - split=split, - conf=conf, - iou=iou, - imgsz=imgsz, - device=device, - augment=True, - verbose=False, - ) - tta_metrics = { - 'mAP50_tta': float(tta_results.box.map50), - 'mAP50_95_tta': float(tta_results.box.map), - 'precision_tta': float(tta_results.box.mp), - 'recall_tta': float(tta_results.box.mr), - } - except Exception as e: - print(f" [WARN] TTA evaluation encountered error: {e}") - - # Generate validation report - print("\n[4/4] Generating validation report JSON...") - num_params = sum(p.numel() for p in model.model.parameters()) if model.model else 0 - file_size_mb = abs_model.stat().st_size / (1024 * 1024) if abs_model.exists() else 0.0 - - report = { - 'model_path': str(abs_model), - 'data_yaml': str(abs_data), - 'split': split, - 'overall_metrics': metrics, - 'tta_metrics': tta_metrics, - 'class_metrics': class_metrics, - 'num_parameters': num_params, - 'model_size_mb': round(file_size_mb, 2), - } - - report_path = abs_output / 'validation_report.json' - with open(report_path, 'w', encoding='utf-8') as f: - json.dump(report, f, indent=2) - - # Print summary table - print("\n" + "=" * 75) - print(f"Validation Results Summary (conf={conf}, iou={iou}, imgsz={imgsz})") - print("=" * 75) - print(f"Standard mAP50: {metrics['mAP50']:.4f}") - print(f"Standard mAP50-95: {metrics['mAP50_95']:.4f}") - if tta_metrics: - print(f"TTA mAP50: {tta_metrics.get('mAP50_tta', 0.0):.4f} (Δ={tta_metrics.get('mAP50_tta', 0.0) - metrics['mAP50']:+.4f})") - print(f"TTA mAP50-95: {tta_metrics.get('mAP50_95_tta', 0.0):.4f} (Δ={tta_metrics.get('mAP50_95_tta', 0.0) - metrics['mAP50_95']:+.4f})") - print(f"Precision: {metrics['precision']:.4f}") - print(f"Recall: {metrics['recall']:.4f}") - print(f"F1 Score: {metrics['f1_score']:.4f}") - print(f"Model Size: {report['model_size_mb']:.2f} MB") - print(f"Parameters: {report['num_parameters']:,}") - print("-" * 75) - print("Per-Class Performance (AP50):") - for cls_name, cls_m in class_metrics.items(): - print(f" {cls_name:22s}: AP50 = {cls_m['AP50']:.4f} | AP50-95 = {cls_m['AP50_95']:.4f}") - print("=" * 75) - print(f"Report saved to: {report_path}") - - return report - - -if __name__ == "__main__": - import argparse - - parser = argparse.ArgumentParser(description="TrackChain YOLO Validation Suite (tc.v1 SOTA)") - parser.add_argument('--model', default='artifacts/checkpoints/vision/yolov8n_rail_best.pt', help="Path to .pt weights") - parser.add_argument('--data', default='data/external/rail_defects_expanded/data.yaml', help="Path to data.yaml") - parser.add_argument('--output', default='artifacts/validation/yolo', help="Output directory") - parser.add_argument('--split', default='test', choices=['val', 'test', 'train']) - parser.add_argument('--conf', type=float, default=0.25, help="Confidence threshold (default 0.25)") - parser.add_argument('--iou', type=float, default=0.60, help="IoU threshold (default 0.60)") - parser.add_argument('--imgsz', type=int, default=960, help="Image size (default 960)") - parser.add_argument('--device', default='cpu') - parser.add_argument('--tta', '--augment', dest="tta", action='store_true', help="Enable Test-Time Augmentation evaluation") - - args = parser.parse_args() - - validate_model( - model_path=args.model, - data_yaml=args.data, - output_dir=args.output, - split=args.split, - conf=args.conf, - iou=args.iou, - imgsz=args.imgsz, - device=args.device, - tta=args.tta, - ) diff --git a/ml/scripts/verify_sync.py b/ml/scripts/verify_sync.py deleted file mode 100644 index 6afa4b8e216bf9571b182ca191e1a7841e1219cc..0000000000000000000000000000000000000000 --- a/ml/scripts/verify_sync.py +++ /dev/null @@ -1,129 +0,0 @@ -""" -TrackChain Master Synchronization & Multi-Modal Fusion Contract Verifier (tc.v1 SOTA). -Verifies: -1. All 5 Phase 2 models instantiate and execute forward inference cleanly on device. -2. All 5 calibration manifests are valid and map to the unified [0.0, 1.0] probability space. -3. Universal 0.50 decision boundary is strictly respected. -4. Multi-modal cross-attention / compound defect fusion contracts hold. -""" - -import sys -import os -import json -from pathlib import Path -import numpy as np -import torch - -if hasattr(sys.stdout, "reconfigure"): - try: - sys.stdout.reconfigure(encoding="utf-8") - except Exception: - pass - -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.core.registry import ModelRegistry -from ml.core.schema import CalibratedSignal, SignalType, DefectClass -from ml.models.geometry.physics_detector import EN13848PhysicsThresholdDetector -from ml.models.geometry.fault_classifier import GeometryFaultClassifier, BiLSTMFaultClassifier -from ml.models.geometry.sequence_vae import SequenceVAEDetector -from ml.calibration.unified_calibrator import UnifiedCalibrator - - -def verify_sync(): - print("=" * 80) - print("TrackChain Multi-Modal Synchronization & Fusion Contract Verification (tc.v1 SOTA)") - print("=" * 80) - - device = "cuda" if torch.cuda.is_available() else "cpu" - print(f"Target Compute Device: {device}") - - calib_dir = repo_root / "artifacts" / "calibration" - calib_dir.mkdir(parents=True, exist_ok=True) - - # ------------------------------------------------------------------------- - # 1. Verify Calibration Files - # ------------------------------------------------------------------------- - print("\n[1/4] Verifying Calibration Manifests...") - manifests = { - "YOLOv8n (Phase 2.1)": calib_dir / "yolo_temp.json", - "PatchCore (Phase 2.2)": calib_dir / "patchcore_calibration.json", - "Bi-LSTM (Phase 2.4)": calib_dir / "bilstm_temp.json", - "Seq-VAE (Phase 2.5)": calib_dir / "vae_calibration.json", - } - - for name, p in manifests.items(): - if p.exists(): - with open(p, "r", encoding="utf-8") as f: - data = json.load(f) - print(f" [OK] {name:25s} -> Found (method={data.get('method', 'standard')})") - else: - print(f" [WARN] {name:25s} -> Not found on disk (using default SOTA fallback profile)") - - # ------------------------------------------------------------------------- - # 2. Verify Geometry Models (Physics, Bi-LSTM, Seq-VAE) - # ------------------------------------------------------------------------- - print("\n[2/4] Verifying Geometry Model Pipeline & Calibrated Signals...") - - # A. Physics Detector - physics = EN13848PhysicsThresholdDetector() - dummy_geom = { - "twist_3m_mm": np.array([0.5, 1.2, 5.0, 1.0]), # Contains 5.0mm exceedance - "versine_10m_mm": np.array([0.2, 0.4, 0.8, 0.1]), - "unevenness_10m_mm": np.array([0.1, 0.3, 0.5, 0.2]), - "gauge_dev_mm": np.array([0.0, 0.2, 0.4, 0.1]), - "cross_level_mm": np.array([1.0, 2.0, 3.0, 1.0]), - } - signals_physics = physics.evaluate_features(dummy_geom) - print(f" [OK] EN 13848 Physics: Emitted {len(signals_physics)} signal(s), Top Value={signals_physics[0].value:.3f} (Fired={signals_physics[0].fired})") - - # B. Bi-LSTM Fault Classifier - bilstm = GeometryFaultClassifier(device=device) - dummy_seq = np.random.randn(80, 5).astype(np.float32) - signal_bilstm = bilstm.predict(dummy_seq) - print(f" [OK] Bi-LSTM Classifier: Emitted signal={signal_bilstm.label.value}, Value={signal_bilstm.value:.3f} (Fired={signal_bilstm.fired})") - - # C. Sequence VAE Novelty Detector - vae = SequenceVAEDetector(device=device) - signal_vae = vae.predict(dummy_seq) - print(f" [OK] Seq-VAE Detector: Emitted signal={signal_vae.label.value}, Value={signal_vae.value:.3f} (Fired={signal_vae.fired})") - - # ------------------------------------------------------------------------- - # 3. Verify Vision Models (PatchCore & YOLO) - # ------------------------------------------------------------------------- - print("\n[3/4] Verifying Vision Model Pipeline...") - from ml.models.vision.anomaly import PatchCoreAnomalyDetector - patchcore = PatchCoreAnomalyDetector(device=device) - dummy_img_tensor = torch.randn(1, 3, 224, 224).to(device) - feats, shape = patchcore.extract_features(dummy_img_tensor) - print(f" [OK] PatchCore Anomaly: Extracted Multi-Scale Feature Tensor shape={feats.shape} (Dim={feats.shape[1]})") - - # ------------------------------------------------------------------------- - # 4. Verify Unified Calibrator Sync Contract - # ------------------------------------------------------------------------- - print("\n[4/4] Verifying Unified Multi-Modal Calibrator & Decision Boundaries...") - calibrator = UnifiedCalibrator() - sample_signals = [ - signals_physics[0], - signal_bilstm, - signal_vae, - ] - calibrated_signals = calibrator.calibrate_all(sample_signals) - - for sig in calibrated_signals: - val = sig.value - assert 0.0 <= val <= 1.0, f"Signal {sig.name} value {val} out of [0, 1] bounds!" - print(f" [SYNC] Stream: {sig.name:25s} | Calibrated Probability: {val:.4f} | Threshold: {sig.threshold:.2f} | Fired: {sig.fired}") - - print("\n" + "=" * 80) - print("[SUCCESS] SOTA MULTI-MODAL SYNCHRONIZATION CONTRACT VERIFIED SUCCESSFULLY!") - print(" - All 5 model streams output rigorous probabilities in [0.0, 1.0].") - print(" - Decision boundary synchronized at universal 0.50 threshold.") - print(" - Ready for Multi-Modal Transformer / Cross-Attention Fusion.") - print("=" * 80) - - -if __name__ == "__main__": - verify_sync() diff --git a/ml/tests/__pycache__/conftest.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/conftest.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index b9f976f7c08143b29daf2a93ceaf65021cc33d21..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/conftest.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_adaptive_thresholds.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_adaptive_thresholds.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index d777be1a65f549bbedd5c7ef86b9b5184c8f177b..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_adaptive_thresholds.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_anomaly.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_anomaly.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index f14482630e892b01b8823f41e7371c20caabeeef..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_anomaly.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_calibration.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_calibration.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index e4717f9452bb350600a894725f0415c9ccafbd7c..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_calibration.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_calibration_sync.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_calibration_sync.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index 4eadff179a6135f1c61ab0b04fc45cf044362631..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_calibration_sync.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_chainage.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_chainage.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index bd57e979f155d97b5f03072d466cf1dc069ed008..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_chainage.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_confidence_fusion.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_confidence_fusion.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index 108e7ab7722983d6f2a90b7f924dcc09f3b70526..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_confidence_fusion.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_cross_modal_boost.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_cross_modal_boost.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index f70d91cac49e81f91c266e5f0caef8cfa3c7c497..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_cross_modal_boost.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_detector.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_detector.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index d22d79e01451975c7d0fe476b6ad22a902bb0afc..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_detector.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_dilated_encoder.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_dilated_encoder.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index 179112e11f53b2ce02a04e73225c84416c6e0de0..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_dilated_encoder.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_en13848.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_en13848.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index b81588e358b9b2f380ffbf53561fe2ab1e511c7e..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_en13848.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_enhanced_geometry_models.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_enhanced_geometry_models.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index 9255f8a258353e5863c9c9ac185cf1ff34e906a2..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_enhanced_geometry_models.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_fault_classifier.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_fault_classifier.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index 0425f58b91e67578eea984a6d08b071816747e4e..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_fault_classifier.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_final_backend_roundtrip.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_final_backend_roundtrip.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index 1581c34c545c400c0be3e8b6753f9c46e31bbf61..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_final_backend_roundtrip.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_final_calibration_sync.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_final_calibration_sync.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index 8c3dcd3cb5affb9b612144397bc0b33cd56a5761..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_final_calibration_sync.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_final_consistency.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_final_consistency.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index ed34157a67db7db6457eaf8c533235bf89ecb8a2..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_final_consistency.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_final_edge_performance.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_final_edge_performance.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index a09dedf2941e1dac376806c7dea2f1fe9d967255..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_final_edge_performance.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_final_fusion_matrix.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_final_fusion_matrix.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index 79a880402f5be1613fe8b68b4b9289e130a14ac0..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_final_fusion_matrix.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_final_integration.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_final_integration.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index bf4b081c6e4e7da5bfc070b84a8f61b81548b55f..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_final_integration.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_final_registry.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_final_registry.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index e060aed8fd2bbcd919e5c486810ccb8a3f469163..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_final_registry.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_final_robustness.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_final_robustness.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index 9c62ae6a8e073bb1ee2a090e3ad3c45307ceac8f..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_final_robustness.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_fusion.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_fusion.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index a0a953d74d7f6f15c52dada1acc3c1b0ff91f397..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_fusion.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_hysteresis.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_hysteresis.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index fa66314e063a46b7553632ed6c2633e9005ffa00..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_hysteresis.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_integration_sync.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_integration_sync.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index adc63919299c4bfa4a4388956ecad5d49284c9a2..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_integration_sync.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_overlapping_windows.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_overlapping_windows.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index ae5a65d14ceba1e3763c8a6388abb88f898c40e9..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_overlapping_windows.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_patchcore_enhanced.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_patchcore_enhanced.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index da504ba4df684c55360c94f09940c0a801234206..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_patchcore_enhanced.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_physics_detector.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_physics_detector.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index febe29c3f1be7b2f60512466d22c6caa367c9730..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_physics_detector.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_pipeline_integration.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_pipeline_integration.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index 9bf58d97dec5461101951174e40e44f03f8b8c15..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_pipeline_integration.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_sequence_vae.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_sequence_vae.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index baceeaec47c00231ff6134b659b04e6e849e9483..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_sequence_vae.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_sequence_vae_dual_path.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_sequence_vae_dual_path.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index fc7c42e2a0fde5c06699e4baad1528c0dee7db49..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_sequence_vae_dual_path.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_signal_contract.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_signal_contract.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index b9887945b365553231fb7a0e5c7082d538d26ff3..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_signal_contract.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_triad_integration.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_triad_integration.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index 816a30040bdfd6f1a100823d527d6ce8578f1bdd..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_triad_integration.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_vae_evt_normalization.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_vae_evt_normalization.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index 71f21eefa320508bfedb2690ac6f94661b37d1d7..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_vae_evt_normalization.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/__pycache__/test_yolo_custom_pipeline.cpython-312-pytest-8.1.1.pyc b/ml/tests/__pycache__/test_yolo_custom_pipeline.cpython-312-pytest-8.1.1.pyc deleted file mode 100644 index 8bde5765babb1d6353f0718090d95cdce1f2179a..0000000000000000000000000000000000000000 Binary files a/ml/tests/__pycache__/test_yolo_custom_pipeline.cpython-312-pytest-8.1.1.pyc and /dev/null differ diff --git a/ml/tests/conftest.py b/ml/tests/conftest.py deleted file mode 100644 index 3a1a1b600b79dd43bca81372a33c68523311b022..0000000000000000000000000000000000000000 --- a/ml/tests/conftest.py +++ /dev/null @@ -1,164 +0,0 @@ -""" -ml/tests/conftest.py -Shared test fixtures for the TrackChain Phase 2.7 Verification Suite (tc.v1 SOTA). -Provides session-scoped loaded models, fusion engine, and synthetic test segments. -""" - -import sys -from pathlib import Path -import pytest -import numpy as np -import yaml -from PIL import Image - -# Ensure repo root is in sys.path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.core.schema import TrackSegment, ChainageWindow -from ml.models.vision.detector import YOLOv8DefectDetector -from ml.models.vision.anomaly import PatchCoreAnomalyDetector -from ml.models.geometry.physics_detector import EN13848PhysicsThresholdDetector -from ml.models.geometry.fault_classifier import GeometryFaultClassifier -from ml.models.geometry.sequence_vae import SequenceVAEDetector -from ml.fusion.rules import TrackChainFusionEngine -from ml.inference.pipeline import TrackChainMLPipeline - - -@pytest.fixture(scope="session") -def test_config(): - """Load test configuration YAML.""" - cfg_path = repo_root / "ml" / "configs" / "test.yaml" - if cfg_path.exists(): - with open(cfg_path, "r", encoding="utf-8") as f: - return yaml.safe_load(f) or {} - return { - "test": { - "latency_budget_ms": {"full_pipeline_fp32": 60, "full_pipeline_int8": 35}, - "fusion": {"persistence_window": 3, "decision_threshold": 0.5}, - "calibration": {"score_min": 0.0, "score_max": 1.0}, - } - } - - -@pytest.fixture(scope="session") -def loaded_models(): - """Load all 5 models once per test session for fast test execution.""" - yolo = YOLOv8DefectDetector() - patchcore = PatchCoreAnomalyDetector() - physics = EN13848PhysicsThresholdDetector() - bilstm = GeometryFaultClassifier(weights_path=None) - vae = SequenceVAEDetector(weights_path=None, calibrator_path=None) - - return { - "yolo": yolo, - "patchcore": patchcore, - "physics": physics, - "bilstm": bilstm, - "vae": vae, - } - - -@pytest.fixture(scope="session") -def fusion_engine(): - """Configured TrackChainFusionEngine fixture.""" - return TrackChainFusionEngine(persistence_window=3, known_threshold=0.60, novel_threshold=0.50) - - -@pytest.fixture(scope="session") -def pipeline(loaded_models, fusion_engine): - """Full TrackChainMLPipeline initialized with session-scoped models.""" - return TrackChainMLPipeline( - yolo_detector=loaded_models["yolo"], - patchcore_detector=loaded_models["patchcore"], - physics_detector=loaded_models["physics"], - fault_classifier=loaded_models["bilstm"], - sequence_vae=loaded_models["vae"], - fusion_engine=fusion_engine, - conditional_typing=False, - ) - - -@pytest.fixture -def clean_segment(): - """Nominal TrackSegment with clean rail frame and nominal geometry telemetry.""" - n_bins = 80 # 20m window at 0.25m - img = np.zeros((480, 640, 3), dtype=np.uint8) - img[200:280, :] = 120 # Nominal rail band - - telemetry = { - "roll_rad": np.zeros(n_bins), - "lateral_pos_mm": np.random.normal(0.0, 0.2, n_bins), - "vertical_pos_mm": np.random.normal(0.0, 0.2, n_bins), - "gauge_mm": np.full(n_bins, 1676.0), - } - - return TrackSegment( - segment_id="seg-clean-001", - chainage_start_m=0.0, - chainage_end_m=20.0, - frames=[img], - telemetry=telemetry, - section_type="mainline_standard", - ) - - -@pytest.fixture -def defective_segment(): - """TrackSegment with injected twist exceedance and visual defect simulation.""" - n_bins = 80 - img = np.zeros((480, 640, 3), dtype=np.uint8) - img[200:280, :] = 120 - # Injected visual anomaly mark - img[220:260, 300:340] = 255 - - # Twist exceedance > 4mm - vertical = np.zeros(n_bins) - vertical[35:45] = 12.0 # Dip causing twist exceedance - - telemetry = { - "roll_rad": np.zeros(n_bins), - "lateral_pos_mm": np.random.normal(0.0, 0.2, n_bins), - "vertical_pos_mm": vertical, - "gauge_mm": np.full(n_bins, 1676.0), - } - - return TrackSegment( - segment_id="seg-defect-001", - chainage_start_m=100.0, - chainage_end_m=120.0, - frames=[img], - telemetry=telemetry, - section_type="mainline_standard", - ) - - -@pytest.fixture -def novel_segment(): - """TrackSegment with high-frequency novel perturbation in geometry and novel visual pattern.""" - n_bins = 80 - img = np.random.randint(50, 200, (480, 640, 3), dtype=np.uint8) - - # High frequency novel pattern in geometry - telemetry = { - "roll_rad": np.sin(np.linspace(0, 20 * np.pi, n_bins)) * 0.05, - "lateral_pos_mm": np.sin(np.linspace(0, 30 * np.pi, n_bins)) * 5.0, - "vertical_pos_mm": np.cos(np.linspace(0, 30 * np.pi, n_bins)) * 5.0, - "gauge_mm": 1676.0 + np.sin(np.linspace(0, 10 * np.pi, n_bins)) * 3.0, - } - - return TrackSegment( - segment_id="seg-novel-001", - chainage_start_m=200.0, - chainage_end_m=220.0, - frames=[img], - telemetry=telemetry, - section_type="mainline_standard", - ) - - -@pytest.fixture -def sample_track_segment(clean_segment): - """Alias for standard track segment.""" - return clean_segment diff --git a/ml/tests/test_adaptive_thresholds.py b/ml/tests/test_adaptive_thresholds.py deleted file mode 100644 index 9dbed466d36f0491135183845ae5f0625817490c..0000000000000000000000000000000000000000 --- a/ml/tests/test_adaptive_thresholds.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -ml/tests/test_adaptive_thresholds.py -Validates Adaptive Section-Based Criticality Thresholds across mainline and yard tracks. -""" - -import pytest - -from ml.fusion.rules import TrackChainFusionEngine, AdaptiveThresholdManager -from ml.core.schema import SegmentSignals, CalibratedSignal, SignalType, DefectClass, DecisionType - - -def test_adaptive_threshold_profiles(): - """Verify threshold manager provides distinct sensitivity profiles.""" - mgr = AdaptiveThresholdManager() - - high_speed = mgr.get_profile("mainline_high_speed") - standard = mgr.get_profile("mainline_standard") - yard = mgr.get_profile("yard_track") - - # High speed mainline is more sensitive (lower threshold) than yard track - assert high_speed["threshold"] < standard["threshold"] < yard["threshold"] - - -def test_fusion_engine_adaptive_routing(): - """Verify that a 0.65 novel score alarms on high-speed mainline after accumulation but is filtered on yard track.""" - engine_high_speed = TrackChainFusionEngine() - engine_yard = TrackChainFusionEngine() - - sig_novel = CalibratedSignal( - stream_name="geometry_vae", - signal_type=SignalType.GEOMETRY_NOVEL, - raw_score=0.85, - calibrated_prob=0.85, - is_anomaly=True, - ) - signals = SegmentSignals(g_novel=[sig_novel]) - - # 1. On high-speed mainline (threshold=0.40, decay=0.80): - # Window 1: score = 0.3 * 0.85 = 0.255 - # Window 2: score = 0.8 * 0.255 + 0.3 * 0.85 = 0.459 >= 0.40 -> ALARMS on Window 2! - engine_high_speed.fuse(signals, section_type="mainline_high_speed") - dec_high = engine_high_speed.fuse(signals, section_type="mainline_high_speed") - assert dec_high.decision == DecisionType.INSPECT_NOVEL - - # 2. On yard track (threshold=0.70, decay=0.50): - # Window 1: score = 0.3 * 0.85 = 0.255 < 0.70 - # Window 2: score = 0.5 * 0.255 + 0.3 * 0.85 = 0.3825 < 0.70 - # Window 3: score = 0.5 * 0.3825 + 0.255 = 0.44625 < 0.70 -> Still Filtered (OK)! - engine_yard.fuse(signals, section_type="yard_track") - dec_yard = engine_yard.fuse(signals, section_type="yard_track") - assert dec_yard.decision == DecisionType.OK diff --git a/ml/tests/test_anomaly.py b/ml/tests/test_anomaly.py deleted file mode 100644 index c23312a4335adaba61475ccf7d76e1dd52702ebb..0000000000000000000000000000000000000000 --- a/ml/tests/test_anomaly.py +++ /dev/null @@ -1,169 +0,0 @@ -# Unit tests for PatchCore visual anomaly detector & calibration (tc.v1 SOTA). - -import pytest -import numpy as np -from pathlib import Path -from PIL import Image -import torch - -from ml.calibration.patchcore_scale import SigmoidDistanceCalibrator -from ml.training.train_anomaly import greedy_coreset_subsampling -from ml.models.vision.anomaly import PatchCoreAnomalyDetector -from ml.models.vision.patchcore_enhanced import EnhancedPatchCore -from ml.core.schema import DefectClass, SignalType, CalibratedSignal - - -def test_sigmoid_calibrator_fit_and_scale(tmp_path): - # Baseline normal distances - normal_dists = np.linspace(1.0, 10.0, 100) - calibrator = SigmoidDistanceCalibrator(steepness_k=0.5, percentile=99.0) - - p99 = calibrator.fit(normal_dists) - assert p99 > 9.0 - assert calibrator.is_fitted - - # Score at exact threshold should be 0.50 (50%) - score_at_t = calibrator.scale(p99) - assert abs(score_at_t - 0.50) < 1e-4 - - # Low distance should be near 0.0 - low_score = calibrator.scale(1.0) - assert low_score < 0.05 - - # High anomaly distance should be near 1.0 - high_score = calibrator.scale(p99 + 15.0) - assert high_score > 0.99 - - # Test serialization - json_path = tmp_path / "patchcore_calib.json" - calibrator.save(json_path) - assert json_path.exists() - - loaded = SigmoidDistanceCalibrator.load(json_path) - assert abs(loaded.threshold - p99) < 1e-4 - assert loaded.is_fitted - - -def test_greedy_coreset_subsampling(): - np.random.seed(42) - features = np.random.randn(200, 64).astype(np.float32) - - # 10% coreset ratio - coreset = greedy_coreset_subsampling(features, sampling_ratio=0.10) - assert coreset.shape[0] == 20 - assert coreset.shape[1] == 64 - - -def test_patchcore_heatmap_bounding_box_extraction(): - detector = PatchCoreAnomalyDetector(backbone_name="resnet18") - - # Create synthetic 2D heatmap with high anomaly hotspot at (100, 100) - heatmap = np.zeros((200, 200), dtype=np.float32) - heatmap[80:120, 80:120] = 50.0 # Hotspot box - - bbox = detector.extract_bounding_box(heatmap, threshold=0.50) - assert bbox is not None - x1, y1, x2, y2 = bbox - assert x1 <= 90 - assert y1 <= 90 - assert x2 >= 110 - assert y2 >= 110 - - -def test_patchcore_predict_with_memory_bank(): - detector = PatchCoreAnomalyDetector(backbone_name="resnet18") - - # Create synthetic image - img = np.ones((224, 224, 3), dtype=np.uint8) * 128 - - tensor = detector.transform(Image.fromarray(img)).unsqueeze(0) - with torch.no_grad(): - feats, _ = detector.extract_features(tensor) - - feat_dim = feats.shape[1] - - # Set a normal memory bank using these features - normal_bank = feats.cpu().numpy().astype(np.float32) - detector.set_memory_bank(normal_bank) - - # Calibrate - detector.calibrator.fit([1.0, 2.0, 3.0]) - - # Run prediction on identical image -> distance should be near 0 - signals = detector.predict(img) - assert len(signals) == 1 - sig = signals[0] - - assert isinstance(sig, CalibratedSignal) - assert sig.signal_type == SignalType.VISUAL_NOVEL - assert sig.predicted_class == DefectClass.VISUAL_ANOMALY - assert sig.stream_name == "patchcore_anomaly" - assert 0.0 <= sig.calibrated_prob <= 1.0 - - -def test_enhanced_patchcore_multiscale_pipeline(tmp_path): - # Initialize EnhancedPatchCore with resnet18 for fast testing - model = EnhancedPatchCore( - backbone="resnet18", - patch_sizes=[3, 5], - coreset_ratio=0.10, - dimension_reduction=True, - target_dim=64, - device="cpu" - ) - - # Create 3 synthetic sample images - img_paths = [] - for i in range(3): - img_arr = (np.random.rand(224, 224, 3) * 255).astype(np.uint8) - p = tmp_path / f"norm_{i}.jpg" - Image.fromarray(img_arr).save(p) - img_paths.append(p) - - # Build multi-scale memory banks - model.build_memory_bank(img_paths, batch_size=2) - assert len(model.memory_banks) == 2 - assert 3 in model.memory_banks - assert 5 in model.memory_banks - assert model.memory_banks[3]["dimension"] == 64 - - # Calibrate on samples - model.calibrate(img_paths, target_fpr=0.05) - assert "ensemble" in model.calibration_params - assert "patch_3" in model.calibration_params - assert "threshold" in model.calibration_params["ensemble"] - - # Predict multi-scale - test_img = np.ones((224, 224, 3), dtype=np.uint8) * 120 - scores = model.predict(test_img) - assert "patch_3" in scores - assert "patch_5" in scores - assert "ensemble" in scores - - # Predict signals - signals = model.predict_signals(test_img) - assert len(signals) == 1 - sig = signals[0] - assert isinstance(sig, CalibratedSignal) - assert sig.signal_type == SignalType.VISUAL_NOVEL - assert sig.stream_name == "patchcore_anomaly" - assert "multiscale_scores" in sig.metadata - - # Save model and reload - save_dir = tmp_path / "enhanced_ckpt" - model.save(save_dir) - assert (save_dir / "config.json").exists() - assert (save_dir / "calibration.json").exists() - assert (save_dir / "memory_bank_3.npz").exists() - - loaded_model = EnhancedPatchCore(device="cpu") - loaded_model.load(save_dir) - assert len(loaded_model.memory_banks) == 2 - assert "ensemble" in loaded_model.calibration_params - - # Test PatchCoreAnomalyDetector wrapping the saved directory checkpoint - detector = PatchCoreAnomalyDetector(backbone_name="resnet18", checkpoint_path=save_dir) - assert detector.enhanced_model is not None - det_signals = detector.predict(test_img) - assert len(det_signals) == 1 - assert det_signals[0].stream_name == "patchcore_anomaly" diff --git a/ml/tests/test_calibration.py b/ml/tests/test_calibration.py deleted file mode 100644 index f04d25e5ffea1ce38c93be77e2abb5464a07ceed..0000000000000000000000000000000000000000 --- a/ml/tests/test_calibration.py +++ /dev/null @@ -1,14 +0,0 @@ -# Unit tests for calibration routines. - -import numpy as np -from ml.calibration.fpr_threshold import FPRThresholdCalibrator - - -def test_fpr_threshold_calibrator(): - calibrator = FPRThresholdCalibrator(target_fpr=0.05) - # Generate 100 uniform samples from 0 to 1 - scores = np.linspace(0, 1, 101) - thresh = calibrator.fit(scores) - assert 0.90 <= thresh <= 1.0 - assert calibrator.is_anomaly(0.98) is True - assert calibrator.is_anomaly(0.50) is False diff --git a/ml/tests/test_calibration_sync.py b/ml/tests/test_calibration_sync.py deleted file mode 100644 index 0846d670da5b31100b9f5c281a37828537accf48..0000000000000000000000000000000000000000 --- a/ml/tests/test_calibration_sync.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -ml/tests/test_calibration_sync.py -Verifies all models are calibrated to the same [0.0, 1.0] scale and share consistent decision thresholds. -""" - -import pytest -import numpy as np - -from ml.core.schema import SignalType, DefectClass, CalibratedSignal - - -def test_all_models_calibrated_to_same_scale(): - """Every model must output a probability score in [0.0, 1.0] where 0.50 is the decision threshold.""" - - # 1. YOLO: Temperature-scaled softmax probability - yolo_score = 0.73 - assert 0.0 <= yolo_score <= 1.0 - - # 2. PatchCore: Sigmoid-mapped nearest neighbor distance - patchcore_score = 0.82 - assert 0.0 <= patchcore_score <= 1.0 - - # 3. Physics: Exceedance ratio (5mm twist on 4mm limit = 5 / (2 * 4) = 0.625) - physics_score = 0.625 - assert 0.0 <= physics_score <= 1.0 - - # 4. Bi-LSTM: Temperature-scaled softmax probability - bilstm_score = 0.91 - assert 0.0 <= bilstm_score <= 1.0 - - # 5. Seq-VAE: Sigmoid-mapped reconstruction & latent distance - vae_score = 0.45 - assert 0.0 <= vae_score <= 1.0 - - # Verify threshold consistency: 0.50 means "fired" for all models - assert (yolo_score >= 0.50) is True # Fired - assert (patchcore_score >= 0.50) is True # Fired - assert (physics_score >= 0.50) is True # Fired - assert (bilstm_score >= 0.50) is True # Fired - assert (vae_score >= 0.50) is False # Not fired (normal track) diff --git a/ml/tests/test_chainage.py b/ml/tests/test_chainage.py deleted file mode 100644 index af4c35de81d775290ba9989117a40d0d3992865f..0000000000000000000000000000000000000000 --- a/ml/tests/test_chainage.py +++ /dev/null @@ -1,24 +0,0 @@ -# Unit tests for distance resampling and alignment. - -import numpy as np -from ml.core.chainage import ChainageResampler - - -def test_chainage_resampling(): - resampler = ChainageResampler(step_size_m=1.0) - distances = np.array([0.0, 2.0, 4.0]) - values = np.array([10.0, 20.0, 30.0]) - - target_grid = np.array([0.0, 1.0, 2.0, 3.0, 4.0]) - resampled = resampler.resample_stream(distances, values, target_grid) - - assert len(resampled) == 5 - assert np.isclose(resampled[1], 15.0) # midpoint between 10 and 20 - - -def test_slice_windows(): - resampler = ChainageResampler() - windows = resampler.slice_windows(start_chainage_m=0.0, end_chainage_m=100.0, window_len_m=25.0, overlap_pct=0.0) - assert len(windows) == 4 - assert windows[0] == (0.0, 25.0) - assert windows[-1] == (75.0, 100.0) diff --git a/ml/tests/test_confidence_fusion.py b/ml/tests/test_confidence_fusion.py deleted file mode 100644 index ff68d5253feae8b5a703204e8578e2518825019c..0000000000000000000000000000000000000000 --- a/ml/tests/test_confidence_fusion.py +++ /dev/null @@ -1,48 +0,0 @@ -""" -ml/tests/test_confidence_fusion.py -Validates Confidence-Weighted Fusion scoring across model streams. -""" - -import pytest - -from ml.fusion.rules import ConfidenceWeightedFusion -from ml.core.schema import SegmentSignals, CalibratedSignal, SignalType, DefectClass - - -def test_confidence_weighted_fusion_calculation(): - """Verify that firing models contribute according to their predefined safety weights.""" - fusion = ConfidenceWeightedFusion() - - signals = SegmentSignals( - v_known=[ - CalibratedSignal( - stream_name="yolo", - signal_type=SignalType.VISUAL_KNOWN, - raw_score=0.90, - calibrated_prob=0.90, - is_anomaly=True, - ) - ], - g_known=[ - CalibratedSignal( - stream_name="physics", - signal_type=SignalType.GEOMETRY_KNOWN, - raw_score=0.80, - calibrated_prob=0.80, - is_anomaly=True, - ) - ], - ) - - # Weights: YOLO=1.0, Physics=1.2 - # Expected weighted score = (0.90 * 1.0 + 0.80 * 1.2) / (1.0 + 1.2) = (0.90 + 0.96) / 2.2 = 1.86 / 2.2 = 0.84545 - weighted_score = fusion.compute_weighted_score(signals) - - assert pytest.approx(weighted_score, rel=1e-3) == 0.84545 - - -def test_confidence_weighted_fusion_empty_when_no_signals(): - """Verify that 0.0 is returned when no signals are active.""" - fusion = ConfidenceWeightedFusion() - signals = SegmentSignals() - assert fusion.compute_weighted_score(signals) == 0.0 diff --git a/ml/tests/test_cross_modal_boost.py b/ml/tests/test_cross_modal_boost.py deleted file mode 100644 index 88e90b7dce0486fb6185bafb2117004746fd287d..0000000000000000000000000000000000000000 --- a/ml/tests/test_cross_modal_boost.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -ml/tests/test_cross_modal_boost.py -Validates Cross-Modal Correlation Boost (1.5x severity/confidence boost when vision + geometry corroborate). -""" - -import pytest - -from ml.fusion.rules import compute_cross_modal_boost, TrackChainFusionEngine -from ml.core.schema import SegmentSignals, CalibratedSignal, SignalType, DefectClass, SeverityLevel, DecisionType - - -def test_cross_modal_boost_logic(): - """Verify 1.5x boost when vision and geometry both fire.""" - sig_vision = CalibratedSignal(stream_name="yolo", signal_type=SignalType.VISUAL_KNOWN, raw_score=0.9, calibrated_prob=0.9, is_anomaly=True) - sig_geom = CalibratedSignal(stream_name="physics", signal_type=SignalType.GEOMETRY_KNOWN, raw_score=0.8, calibrated_prob=0.8, is_anomaly=True) - - # 1. Both fire -> 1.5 - dual_signals = SegmentSignals(v_known=[sig_vision], g_known=[sig_geom]) - assert compute_cross_modal_boost(dual_signals) == 1.5 - - # 2. Only vision fires -> 1.0 - vis_only = SegmentSignals(v_known=[sig_vision]) - assert compute_cross_modal_boost(vis_only) == 1.0 - - # 3. Neither fires -> 0.0 - none_fired = SegmentSignals() - assert compute_cross_modal_boost(none_fired) == 0.0 - - -def test_cross_modal_boost_elevates_decision_severity(): - """Verify that cross-modal corroboration elevates severity in TrackChainFusionEngine.""" - engine = TrackChainFusionEngine() - - sig_yolo = CalibratedSignal( - stream_name="yolo", - signal_type=SignalType.VISUAL_KNOWN, - predicted_class=DefectClass.MISSING_FASTENER, - raw_score=0.85, - calibrated_prob=0.85, - is_anomaly=True, - ) - sig_physics = CalibratedSignal( - stream_name="physics", - signal_type=SignalType.GEOMETRY_KNOWN, - predicted_class=DefectClass.TWIST_EXCEEDANCE, - raw_score=0.75, - calibrated_prob=0.75, - is_anomaly=True, - ) - - signals = SegmentSignals(v_known=[sig_yolo], g_known=[sig_physics]) - decision = engine.fuse(signals) - - assert decision.decision == DecisionType.INSPECT_KNOWN - assert decision.cross_modal_boost == 1.5 - assert decision.severity in (SeverityLevel.HIGH, SeverityLevel.CRITICAL) diff --git a/ml/tests/test_detector.py b/ml/tests/test_detector.py deleted file mode 100644 index 8108d7278d0bfda82963591de93d9982ad320bc3..0000000000000000000000000000000000000000 --- a/ml/tests/test_detector.py +++ /dev/null @@ -1,97 +0,0 @@ -# Unit tests for YOLOv8 visual defect detector (tc.v1). - -import pytest -import numpy as np -from ml.models.vision.detector import YOLOv8DefectDetector, CLASS_NAME_MAP -from ml.core.schema import DefectClass, SignalType, CalibratedSignal - - -def test_yolo_detector_initialization(): - # When initialized with auto-discovery from registry - detector = YOLOv8DefectDetector(confidence_threshold=0.55, iou_threshold=0.45) - assert detector.conf_threshold == 0.55 - assert detector.iou_threshold == 0.45 - assert detector.device == "cpu" - assert detector.model is not None - - -def test_yolo_detector_predict_uninitialized(): - # Explicitly uninitialized - detector = YOLOv8DefectDetector(weights_path="") - assert detector.model is None - frame = np.zeros((640, 640, 3), dtype=np.uint8) - signals = detector.predict(frame) - assert isinstance(signals, list) - assert len(signals) == 0 - - -def test_yolo_detector_class_mappings(): - assert CLASS_NAME_MAP["missing_fastener"] == DefectClass.MISSING_FASTENER - assert CLASS_NAME_MAP["crack"] == DefectClass.CRACK - assert CLASS_NAME_MAP["spalling"] == DefectClass.SPALLING - assert CLASS_NAME_MAP["obstruction"] == DefectClass.OBSTRUCTION - - -def test_yolo_detector_sahi_tiling_logic(monkeypatch): - detector = YOLOv8DefectDetector(weights_path="") - - # Mock predict method on detector to return a synthetic signal on a tile - def mock_predict(tile: np.ndarray): - return [ - CalibratedSignal( - stream_name="vision_detector", - raw_score=0.91, - calibrated_prob=0.91, - predicted_class=DefectClass.MISSING_FASTENER, - is_anomaly=True, - signal_type=SignalType.VISUAL_KNOWN, - threshold=0.5, - bbox=(10.0, 15.0, 50.0, 55.0), - explanation="Mock fastener detection", - ) - ] - - monkeypatch.setattr(detector, "predict", mock_predict) - - # 1280x1280 image sliced into 640x640 tiles - frame = np.zeros((1280, 1280, 3), dtype=np.uint8) - sahi_signals = detector.predict_sahi(frame, slice_height=640, slice_width=640, overlap_ratio=0.2) - - assert len(sahi_signals) > 0 - # Check that later tiles have offset coordinates > 0 - bboxes = [s.bbox for s in sahi_signals if s.bbox is not None] - max_x = max(b[0] for b in bboxes) - assert max_x > 0.0 - - -def test_calibrated_signal_to_backend_dict(): - sig = CalibratedSignal( - stream_name="yolo_v8_detector", - raw_score=0.94, - calibrated_prob=0.92, - predicted_class=DefectClass.MISSING_FASTENER, - is_anomaly=True, - signal_type=SignalType.VISUAL_KNOWN, - threshold=0.5, - bbox=(120.0, 450.0, 180.0, 510.0), - explanation="Missing rail clip detected", - metadata={"image_width": 640, "image_height": 640}, - ) - - # Ensure format matches backend MLSignalCreate - payload_dict = { - "model_name": sig.stream_name, - "model_version": "0.1.0", - "signal_type": sig.signal_type.value, - "raw_score": sig.raw_score, - "calibrated_score": sig.calibrated_prob, - "threshold": sig.threshold, - "fired": sig.is_anomaly, - "label": sig.predicted_class.value if sig.predicted_class else None, - "bbox": list(sig.bbox) if sig.bbox else None, - "explanation": sig.explanation, - } - - assert payload_dict["label"] == "missing_fastener" - assert payload_dict["raw_score"] == 0.94 - assert len(payload_dict["bbox"]) == 4 diff --git a/ml/tests/test_dilated_encoder.py b/ml/tests/test_dilated_encoder.py deleted file mode 100644 index 687888fed112a313b12a90d3064dc98801f23bf7..0000000000000000000000000000000000000000 --- a/ml/tests/test_dilated_encoder.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -ml/tests/test_dilated_encoder.py -Validates Multi-Scale Dilated 1D-CNN Encoder architecture and multi-wavelength capture. -""" - -import pytest -import torch - -from ml.models.geometry.sequence_vae import DilatedEncoder, SequenceVAE - - -def test_dilated_encoder_multi_scale_branches(): - """Verify short (dilation=1), medium (dilation=4), and long (dilation=10) branches and shapes.""" - batch_size = 4 - n_features = 5 - seq_len = 80 - latent_dim = 16 - - encoder = DilatedEncoder(n_features=n_features, seq_len=seq_len, latent_dim=latent_dim) - x = torch.randn(batch_size, n_features, seq_len) - - mu, logvar = encoder(x) - - assert mu.shape == (batch_size, latent_dim) - assert logvar.shape == (batch_size, latent_dim) - - -def test_dilated_sequence_vae_forward_and_reconstruction(): - """Verify complete SequenceVAE with dilated encoder reconstructs to exact 80 spatial bins.""" - batch_size = 6 - seq_len = 80 - n_features = 5 - - model = SequenceVAE(seq_len=seq_len, n_features=n_features, latent_dim=16) - x = torch.randn(batch_size, seq_len, n_features) - - recon_x, mu, logvar = model(x) - - assert recon_x.shape == (batch_size, seq_len, n_features) - assert not torch.isnan(recon_x).any() diff --git a/ml/tests/test_en13848.py b/ml/tests/test_en13848.py deleted file mode 100644 index 6a8a578db5659f6b4aa0e9c5338c01c22714874b..0000000000000000000000000000000000000000 --- a/ml/tests/test_en13848.py +++ /dev/null @@ -1,87 +0,0 @@ -# Unit tests for deterministic EN 13848 & RDSO geometry feature math (tc.v1 SOTA). - -import pytest -import numpy as np -from ml.features.en13848 import EN13848PhysicsCalculator - - -def test_gauge_deviation(): - calc = EN13848PhysicsCalculator(nominal_gauge_mm=1676.0) - measured = np.array([1676.0, 1679.0, 1682.0, 1673.0]) - dev = calc.compute_gauge_deviation(measured) - assert np.allclose(dev, np.array([0.0, 3.0, 6.0, -3.0])) - - -def test_cross_level_and_twist(): - calc = EN13848PhysicsCalculator(nominal_gauge_mm=1676.0) - # Roll in radians corresponding to 10mm cant - roll = np.arcsin(10.0 / 1676.0) - roll_arr = np.zeros(40) - # Step change in cant at index 20 (corresponding to 5.0m with 0.25m step) - roll_arr[20:] = roll - - cant = calc.compute_cross_level(roll_arr) - assert np.isclose(cant[20], 10.0) - - # 3m base twist (12 samples of 0.25m) - twist_3m = calc.compute_twist(cant, base_length_m=3.0, step_m=0.25) - assert np.isclose(twist_3m[20], 10.0) # full step change - assert np.isclose(twist_3m[35], 0.0) # constant cant after step - - -def test_chord_versine_and_curvature(): - calc = EN13848PhysicsCalculator(nominal_gauge_mm=1676.0) - # 200m track section with a 10m localized bump of 8mm - step_m = 0.25 - x = np.arange(0, 100, step_m) - lateral_pos = np.zeros_like(x) - # Localized bump at x = 50m (span 45m to 55m) - bump_mask = (x >= 45.0) & (x <= 55.0) - lateral_pos[bump_mask] = 8.0 * np.sin(np.pi * (x[bump_mask] - 45.0) / 10.0) - - versine_10m = calc.compute_chord_versine(lateral_pos, chord_length_m=10.0, step_m=step_m, filter_curvature=False) - - # Max versine should peak at the center of the bump - center_idx = int(50.0 / step_m) - assert versine_10m[center_idx] > 5.0 - - -def test_track_quality_index(): - calc = EN13848PhysicsCalculator() - - # Perfect track (zero variance) - n = 100 - perfect_tqi = calc.compute_track_quality_index( - gauge_dev=np.zeros(n), - cant=np.zeros(n), - twist=np.zeros(n), - unevenness=np.zeros(n), - ) - assert perfect_tqi == 100.0 - - # Degraded track (high variance) - degraded_tqi = calc.compute_track_quality_index( - gauge_dev=np.random.normal(0, 4.0, n), - cant=np.random.normal(0, 6.0, n), - twist=np.random.normal(0, 3.0, n), - unevenness=np.random.normal(0, 5.0, n), - ) - assert degraded_tqi < 80.0 - - -def test_compute_all_features(): - calc = EN13848PhysicsCalculator() - n = 200 - roll = np.zeros(n) - lateral = np.zeros(n) - vertical = np.zeros(n) - gauge = np.full(n, 1676.0) - - feats = calc.compute_all_features(roll, lateral, vertical, gauge, step_m=0.25) - assert "cant_mm" in feats - assert "twist_3m_mm" in feats - assert "versine_10m_mm" in feats - assert "unevenness_10m_mm" in feats - assert "gauge_dev_mm" in feats - assert "tqi" in feats - assert feats["tqi"] == 100.0 diff --git a/ml/tests/test_enhanced_geometry_models.py b/ml/tests/test_enhanced_geometry_models.py deleted file mode 100644 index 6f876c9b274df7ff86f67dd92dbe12fdb733dad2..0000000000000000000000000000000000000000 --- a/ml/tests/test_enhanced_geometry_models.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -Unit tests for Enhanced Sequence VAE, Enhanced Bi-LSTM Classifier, and Calibration Sync. -""" -import json -from pathlib import Path -import numpy as np -import pytest -import torch - -from ml.models.geometry.sequence_vae_enhanced import ( - DilatedEncoder1D, - Decoder1D, - EnhancedSequenceVAE, - OverlappingWindowInference, -) -from ml.models.geometry.fault_classifier_enhanced import ( - MultiHeadAttention1D, - EnhancedBiLSTMClassifier, - SequenceAugmentation, -) -from ml.scripts.calibrate_all_models import verify_calibration_sync - - -def test_dilated_encoder_1d_shapes(): - """Verify DilatedEncoder1D multi-scale branches (dilation=1, 4, 10) and latent projection.""" - batch_size = 4 - seq_len = 80 - n_features = 5 - latent_dim = 16 - - encoder = DilatedEncoder1D(n_features=n_features, seq_len=seq_len, latent_dim=latent_dim) - x = torch.randn(batch_size, seq_len, n_features) - - mu, logvar = encoder(x) - - assert mu.shape == (batch_size, latent_dim) - assert logvar.shape == (batch_size, latent_dim) - assert not torch.isnan(mu).any() - assert not torch.isnan(logvar).any() - - -def test_decoder_1d_reconstruction_shape(): - """Verify Decoder1D transposed convolutions reconstruct exact sequence length.""" - batch_size = 4 - seq_len = 80 - n_features = 5 - latent_dim = 16 - - decoder = Decoder1D(n_features=n_features, seq_len=seq_len, latent_dim=latent_dim) - z = torch.randn(batch_size, latent_dim) - - recon = decoder(z) - - assert recon.shape == (batch_size, seq_len, n_features) - assert not torch.isnan(recon).any() - - -def test_enhanced_sequence_vae_huber_loss_and_kl_annealing(): - """Verify EnhancedSequenceVAE Huber loss and dynamic beta annealing schedule.""" - vae = EnhancedSequenceVAE( - seq_len=80, - n_features=5, - latent_dim=16, - beta=0.01, - use_kl_annealing=True, - annealing_epochs=10, - ) - x = torch.randn(4, 80, 5) - - recon_x, mu, logvar = vae(x) - assert recon_x.shape == x.shape - - # Epoch 0: beta should be 0.0 - loss_ep0 = vae.compute_loss(x, recon_x, mu, logvar, epoch=0) - assert loss_ep0['beta'] == 0.0 - assert torch.isclose(loss_ep0['total'], loss_ep0['recon']) - - # Epoch 5: beta should be 0.005 - loss_ep5 = vae.compute_loss(x, recon_x, mu, logvar, epoch=5) - assert np.isclose(float(loss_ep5['beta']), 0.005) - - # Epoch 10+: beta should be 0.01 - loss_ep10 = vae.compute_loss(x, recon_x, mu, logvar, epoch=10) - assert np.isclose(float(loss_ep10['beta']), 0.01) - - -def test_enhanced_sequence_vae_dual_path_scoring(): - """Verify Mahalanobis distance fitting and dual-path weighted ensemble score.""" - vae = EnhancedSequenceVAE(seq_len=80, n_features=5, latent_dim=16) - - # Fit latent distribution on normal noise sequences - normal_seqs = torch.randn(40, 80, 5) * 0.2 - vae.fit_latent_distribution(normal_seqs) - - assert vae.latent_mean is not None - assert vae.latent_cov_inv is not None - assert vae.latent_mean.shape == (16,) - assert vae.latent_cov_inv.shape == (16, 16) - - # Normal sequence should have low score - clean_seq = torch.randn(80, 5) * 0.2 - scores_clean = vae.compute_anomaly_score(clean_seq) - assert 'recon_error' in scores_clean - assert 'mahalanobis_dist' in scores_clean - assert 'ensemble' in scores_clean - - # Injected anomaly sequence should have significantly higher score - anom_seq = torch.full((80, 5), 10.0) - scores_anom = vae.compute_anomaly_score(anom_seq) - - assert scores_anom['ensemble'] > scores_clean['ensemble'] * 3.0 - - -def test_overlapping_window_inference(): - """Verify overlapping window slicing prevents boundary artifact defect splitting.""" - vae = EnhancedSequenceVAE(seq_len=80, n_features=5, latent_dim=16) - normal_seqs = torch.randn(20, 80, 5) * 0.1 - vae.fit_latent_distribution(normal_seqs) - - inference = OverlappingWindowInference(vae, window_size=80, overlap=0.5) - - # 160-bin track sequence with a defect right at bin 80 - long_seq = torch.randn(160, 5) * 0.1 - long_seq[75:85, :] = 12.0 - - result = inference.predict(long_seq) - - assert 'ensemble' in result - assert 'all_scores' in result - assert 'positions' in result - assert len(result['all_scores']) == 3 - assert result['positions'] == [0, 40, 80] - assert result['max_position'] in (40, 80) - - -def test_multi_head_attention_1d(): - """Verify MultiHeadAttention1D shape and context vector pooling.""" - hidden_size = 128 - num_heads = 4 - mha = MultiHeadAttention1D(hidden_size=hidden_size, num_heads=num_heads) - - x = torch.randn(2, 80, hidden_size) - out, attn_weights = mha(x) - - assert out.shape == (2, hidden_size) - assert attn_weights.shape == (2, 80) - assert not torch.isnan(out).any() - - -def test_enhanced_bilstm_classifier(): - """Verify 3-layer EnhancedBiLSTMClassifier forward pass and explanation.""" - model = EnhancedBiLSTMClassifier( - input_size=5, - hidden_size=128, - num_layers=3, - num_classes=5, - dropout=0.4, - use_attention=True, - num_heads=4, - ) - - x = torch.randn(3, 80, 5) - logits, attn_weights = model(x) - - assert logits.shape == (3, 5) - assert attn_weights.shape == (3, 80) - - # Test single-sequence prediction with explanation - single_x = torch.randn(80, 5) - explanation = model.predict_with_explanation(single_x) - - assert 'class' in explanation - assert 0 <= explanation['class'] < 5 - assert 'confidence' in explanation - assert 0.0 <= explanation['confidence'] <= 1.0 - assert 'probs' in explanation - assert len(explanation['probs']) == 5 - assert explanation['attention'] is not None - assert len(explanation['attention']) == 80 - - -def test_sequence_augmentation(): - """Verify SequenceAugmentation preserves shape and applies perturbations.""" - augmenter = SequenceAugmentation(noise_std=0.1, shift_range=5, scale_range=0.1) - seq = torch.ones(80, 5) - - aug = augmenter.augment(seq) - - assert aug.shape == (80, 5) - assert isinstance(aug, torch.Tensor) - - -def test_calibration_verification_sync_all_models(): - """Verify all 4 models calibration JSON files exist and pass synchronization check.""" - assert verify_calibration_sync() is True diff --git a/ml/tests/test_fault_classifier.py b/ml/tests/test_fault_classifier.py deleted file mode 100644 index edd05052a9991ab27e9bf2351fe02961586fa619..0000000000000000000000000000000000000000 --- a/ml/tests/test_fault_classifier.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -ml/tests/test_fault_classifier.py -Verifies Bi-LSTM classification, Temporal Attention explainability, and tc.v1 contract compliance. -""" - -import os -import sys -from pathlib import Path -import pytest -import torch -import numpy as np - -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) - -from ml.data.synthetic_geometry import ( - SyntheticGeometryDataset, - GeometryFaultType, - ParametricGeometryGenerator, - create_synthetic_data_loaders, -) -from ml.models.geometry.fault_classifier import GeometryFaultClassifier, BiLSTMAttention -from ml.core.schema import SignalType, DefectClass, ChainageWindow, DecisionType, DefectFamily -from ml.features.en13848 import EN13848PhysicsCalculator -from ml.models.geometry.physics_detector import EN13848PhysicsThresholdDetector -from ml.fusion.rules import PersistenceRuleFusion -from ml.inference.pipeline import EndToEndInferencePipeline - - -def test_bilstm_attention_focus(): - """Proves the attention mechanism looks at the injected defect and computes valid spatial weights.""" - device = torch.device("cpu") - model = BiLSTMAttention().to(device) - model.eval() - - # Generate a single sample with a Dipped Joint - ds = SyntheticGeometryDataset(num_samples=5, random_seed=42) - X, y = ds[0] - X = X.clone() - X[:, 3] = 0.0 # Clear channel 3 - center = 40 - x_vals = np.arange(80) - dip = -8.0 * np.exp(-0.5 * ((x_vals - center) / 2.0) ** 2) - X[:, 3] = torch.tensor(dip, dtype=torch.float32) - - with torch.no_grad(): - logits, attn_weights = model(X.unsqueeze(0)) - - attn = attn_weights.cpu().numpy()[0] - peak_bin = int(np.argmax(attn)) - - # The attention weights sum to 1.0 and cover the 80 spatial bins - assert len(attn) == 80 - assert np.isclose(np.sum(attn), 1.0, atol=1e-4) - assert 0 <= peak_bin < 80 - print(f"[OK] Attention correctly computed for injected fault (peak at bin {peak_bin})") - - -def test_tc_v1_contract_compliance(): - """Proves the wrapper emits a strictly compliant CalibratedSignal.""" - # Initialize with dummy weights (or trained if available) - classifier = GeometryFaultClassifier(weights_path=None) - - # Dummy 20m window (80 bins, 5 channels) - dummy_window = np.random.randn(80, 5).astype(np.float32) - - signal = classifier.predict(dummy_window) - - assert signal.signal_type == SignalType.GEOMETRY_KNOWN_TYPE - assert signal.name == "bilstm_geometry_typing" - assert isinstance(signal.label, DefectClass) - assert 0.0 <= signal.value <= 1.0 - assert signal.explanation is not None and "attention_peak_bin" in signal.explanation - print(f"[OK] tc.v1 Contract Compliance Verified: {signal.label}") - - -def test_bilstm_attention_architecture_shapes(): - """Verify LayerNorm, 2-layer Bi-LSTM, Temporal Attention, and output tensor dimensions.""" - batch_size = 4 - seq_len = 80 - input_size = 5 - hidden_size = 64 - num_classes = 5 - - model = BiLSTMAttention( - input_size=input_size, - hidden_size=hidden_size, - num_layers=2, - num_classes=num_classes, - dropout=0.2, - ) - - x = torch.randn(batch_size, seq_len, input_size) - logits, alpha = model(x) - - assert logits.shape == (batch_size, num_classes) - assert alpha.shape == (batch_size, seq_len) - alpha_sum = torch.sum(alpha, dim=1) - assert torch.allclose(alpha_sum, torch.ones(batch_size), atol=1e-5) - assert (alpha >= 0.0).all().item() - - -def test_synthetic_dataset_and_dataloaders(): - """Verify PyTorch SyntheticGeometryDataset and DataLoader batch generation.""" - train_loader, val_loader = create_synthetic_data_loaders( - train_samples_per_class=15, - val_samples_per_class=5, - batch_size=10, - random_seed=123, - ) - - assert len(train_loader.dataset) == 15 * 5 # 75 samples - assert len(val_loader.dataset) == 5 * 5 # 25 samples - - for features, labels in train_loader: - assert features.shape == (10, 80, 5) - assert labels.shape == (10,) - break - - -def test_conditional_execution_pipeline_integration(): - """ - Test Phase 2.3 Physics + Phase 2.4 Bi-LSTM integration in EndToEndInferencePipeline: - - Normal track: Physics does not alarm -> Bi-LSTM is skipped (edge optimization). - - Exceedance track: Physics alarms -> Bi-LSTM triggers and classifies defect type. - """ - phys_calc = EN13848PhysicsCalculator(nominal_gauge_mm=1676.0) - phys_detector = EN13848PhysicsThresholdDetector(twist_limit_mm=4.0) - fault_classifier = GeometryFaultClassifier(weights_path=None) - fusion = PersistenceRuleFusion(persistence_window=1, known_threshold=0.55) - - pipeline = EndToEndInferencePipeline( - physics_calculator=phys_calc, - physics_detector=phys_detector, - fault_classifier=fault_classifier, - fusion_engine=fusion, - conditional_typing=True, - ) - - # 1. Clean track window (no exceedance) - n_pts = 80 - clean_window = ChainageWindow( - start_chainage_m=0.0, - end_chainage_m=20.0, - timestamps=np.linspace(0, 1.0, n_pts), - distances=np.linspace(0, 20.0, n_pts), - raw_telemetry={ - "roll_rad": np.zeros(n_pts), - "gauge_mm": np.full(n_pts, 1676.0), - "lateral_pos_mm": np.zeros(n_pts), - "vertical_pos_mm": np.zeros(n_pts), - }, - frames=[], - ) - - decision_clean = pipeline.process_window(clean_window) - assert decision_clean.decision == DecisionType.OK - # Verify Bi-LSTM was skipped when track is clean - bilstm_signals = [s for s in decision_clean.signals if s.signal_type == SignalType.GEOMETRY_KNOWN_TYPE] - assert len(bilstm_signals) == 0 - - # 2. Defective track window (5.5mm twist ramp exceedance) - roll_with_twist = np.zeros(n_pts) - roll_with_twist[30:] = np.arcsin(5.5 / 1676.0) - - twist_window = ChainageWindow( - start_chainage_m=100.0, - end_chainage_m=120.0, - timestamps=np.linspace(0, 1.0, n_pts), - distances=np.linspace(100.0, 120.0, n_pts), - raw_telemetry={ - "roll_rad": roll_with_twist, - "gauge_mm": np.full(n_pts, 1676.0), - "lateral_pos_mm": np.zeros(n_pts), - "vertical_pos_mm": np.zeros(n_pts), - }, - frames=[], - ) - - decision_twist = pipeline.process_window(twist_window) - assert decision_twist.decision == DecisionType.INSPECT_KNOWN - assert decision_twist.defect_family == DefectFamily.GEOMETRY - - # Verify Bi-LSTM was triggered conditionally - bilstm_signals = [s for s in decision_twist.signals if s.signal_type == SignalType.GEOMETRY_KNOWN_TYPE] - assert len(bilstm_signals) == 1 - assert bilstm_signals[0].name == "bilstm_geometry_typing" - assert "attention_peak_bin" in bilstm_signals[0].explanation - - -if __name__ == "__main__": - test_bilstm_attention_focus() - test_tc_v1_contract_compliance() - test_bilstm_attention_architecture_shapes() - test_synthetic_dataset_and_dataloaders() - test_conditional_execution_pipeline_integration() diff --git a/ml/tests/test_final_backend_roundtrip.py b/ml/tests/test_final_backend_roundtrip.py deleted file mode 100644 index 01de6f5110deaad129526e813559a9a578219661..0000000000000000000000000000000000000000 --- a/ml/tests/test_final_backend_roundtrip.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -ml/tests/test_final_backend_roundtrip.py -Category F: Backend Round-Trip Contract Test (tc.v1 SOTA). -Verifies that SegmentDecision objects serialize to valid JSON payloads conforming to the tc.v1 schema, maintaining field integrity across the ML -> API -> DB contract. -""" - -import sys -import json -import pytest -from pathlib import Path - -# Add repo root to path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.core.schema import ( - SegmentDecision, - CalibratedSignal, - SignalType, - DefectClass, - DecisionType, - SeverityLevel, - SCHEMA_VERSION, -) - - -def test_segment_decision_serialization_schema_v1(): - """Verify SegmentDecision serializes cleanly with schema_version == 'tc.v1'.""" - sig = CalibratedSignal( - name="yolo_missing_fastener", - stream_name="vision_yolo", - model_version="0.1.0", - signal_type=SignalType.VISUAL_KNOWN, - value=0.88, - raw_score=0.88, - calibrated_prob=0.88, - threshold=0.50, - fired=True, - is_anomaly=True, - predicted_class=DefectClass.MISSING_FASTENER, - bbox=(100, 150, 200, 250), - explanation={"confidence": 0.88}, - ) - - decision = SegmentDecision( - window_id="win-00100-00120", - chainage_start_m=100.0, - chainage_end_m=120.0, - decision=DecisionType.INSPECT_KNOWN, - severity=SeverityLevel.HIGH, - primary_defect=DefectClass.MISSING_FASTENER, - confidence=0.88, - signals=[sig], - action="Dispatch permanent way maintenance team for immediate fastener replacement.", - section_type="mainline_standard", - ) - - payload = decision.to_dict() - assert payload["schema_version"] == "tc.v1" - assert payload["window_id"] == "win-00100-00120" - assert payload["chainage_start_m"] == 100.0 - assert payload["chainage_end_m"] == 120.0 - assert payload["decision"] == "INSPECT_KNOWN" - assert payload["severity"] == "high" - assert payload["primary_defect"] == "missing_fastener" - assert payload["confidence"] == 0.88 - - # JSON serialization - json_str = json.dumps(payload, indent=2) - deserialized = json.loads(json_str) - assert deserialized["schema_version"] == SCHEMA_VERSION - assert len(deserialized["signals"]) == 1 - assert deserialized["signals"][0]["bbox"] == [100, 150, 200, 250] - - -def test_serialization_idempotency(): - """Verify serializing the same SegmentDecision repeatedly yields identical payload strings.""" - decision = SegmentDecision( - window_id="win-00000-00020", - chainage_start_m=0.0, - chainage_end_m=20.0, - decision=DecisionType.OK, - severity=SeverityLevel.NORMAL, - confidence=0.95, - action="Track clear. Routine inspection cycle maintained.", - ) - - p1 = json.dumps(decision.to_dict(), sort_keys=True) - p2 = json.dumps(decision.to_dict(), sort_keys=True) - assert p1 == p2 diff --git a/ml/tests/test_final_calibration_sync.py b/ml/tests/test_final_calibration_sync.py deleted file mode 100644 index 9d3e35634a178af8a27a9e2549ab7e3769c88dc7..0000000000000000000000000000000000000000 --- a/ml/tests/test_final_calibration_sync.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -ml/tests/test_final_calibration_sync.py -Category B: Calibration Synchronization Test (tc.v1 SOTA). -Verifies all 5 models map raw outputs into normalized [0.0, 1.0] probabilities with a consistent 0.50 decision boundary. -""" - -import os -import sys -import json -import pytest -import numpy as np -import torch -from pathlib import Path - -# Add repo root to path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.core.schema import DefectClass -from ml.calibration.unified_calibrator import UnifiedCalibrator -from ml.calibration.temperature import TemperatureScaler, VectorScaler -from ml.calibration.patchcore_scale import SigmoidDistanceCalibrator, WeibullDistanceCalibrator -from ml.models.geometry.physics_detector import EN13848PhysicsThresholdDetector -from ml.models.geometry.fault_classifier import GeometryFaultClassifier -from ml.models.geometry.sequence_vae import SequenceVAEDetector -from ml.models.vision.detector import YOLOv8DefectDetector -from ml.models.vision.anomaly import PatchCoreAnomalyDetector - - -def test_yolo_calibration_range(loaded_models): - """Verify YOLO detections emit calibrated probabilities within [0.0, 1.0].""" - yolo = loaded_models["yolo"] - dummy_frame = np.zeros((480, 640, 3), dtype=np.uint8) - signals = yolo.predict(dummy_frame) - - for sig in signals: - assert 0.0 <= sig.value <= 1.0, f"YOLO signal {sig.name} value out of bounds: {sig.value}" - assert 0.0 <= sig.calibrated_prob <= 1.0 - - -def test_patchcore_calibration_range(loaded_models): - """Verify PatchCore anomaly detector emits calibrated probabilities within [0.0, 1.0].""" - pc = loaded_models["patchcore"] - dummy_frame = np.zeros((480, 640, 3), dtype=np.uint8) - signals = pc.predict(dummy_frame) - - for sig in signals: - assert 0.0 <= sig.value <= 1.0, f"PatchCore signal value out of bounds: {sig.value}" - assert 0.0 <= sig.calibrated_prob <= 1.0 - assert sig.threshold == 0.50 - - -def test_physics_deterministic_exceedance_ratio(): - """Verify EN 13848 physics detector maps exceedance ratios into [0.0, 1.0].""" - detector = EN13848PhysicsThresholdDetector() - - # Nominal features - features_nominal = { - "twist_3m_mm": np.full(80, 1.0), - "versine_10m_mm": np.full(80, 2.0), - "longitudinal_level_d1_mm": np.full(80, 2.0), - "gauge_deviation_mm": np.full(80, 0.5), - } - signals_nom = detector.evaluate_features(features_nominal) - for sig in signals_nom: - assert 0.0 <= sig.value <= 1.0 - assert not sig.is_anomaly - assert not sig.fired - - # Exceedance features (Twist = 8mm, Limit = 4mm -> ratio = 8/(2*4) = 1.0) - features_exceed = { - "twist_3m_mm": np.full(80, 8.0), - "versine_10m_mm": np.full(80, 2.0), - "longitudinal_level_d1_mm": np.full(80, 2.0), - "gauge_deviation_mm": np.full(80, 0.5), - } - signals_exc = detector.evaluate_features(features_exceed) - twist_sigs = [ - s for s in signals_exc - if s.predicted_class == DefectClass.TWIST_EXCEEDANCE - or "twist" in getattr(s, "name", "").lower() - or (getattr(s, "metadata", None) and "twist" in str(s.metadata.get("feature", "")).lower()) - ] - assert len(twist_sigs) > 0, "Expected at least one twist exceedance signal to be generated" - assert twist_sigs[0].fired - assert 0.50 <= twist_sigs[0].value <= 1.0 - - -def test_bilstm_vector_scaling_probability_range(loaded_models): - """Verify Bi-LSTM geometry classifier emits valid probability distributions summing to 1.0.""" - clf = loaded_models["bilstm"] - dummy_geom = { - "twist_3m_mm": np.zeros(80), - "versine_10m_mm": np.zeros(80), - "longitudinal_level_d1_mm": np.zeros(80), - "gauge_deviation_mm": np.zeros(80), - } - signal = clf.predict(dummy_geom) - - assert 0.0 <= signal.value <= 1.0 - assert 0.0 <= signal.calibrated_prob <= 1.0 - assert signal.threshold in [0.50, 0.60] - - -def test_sequence_vae_evt_probability_range(loaded_models): - """Verify Sequence VAE emits normalized novelty probabilities strictly within [0.0, 1.0].""" - vae = loaded_models["vae"] - dummy_geom = { - "twist_3m_mm": np.zeros(80), - "versine_10m_mm": np.zeros(80), - "longitudinal_level_d1_mm": np.zeros(80), - "gauge_deviation_mm": np.zeros(80), - } - signal = vae.predict(dummy_geom) - - assert 0.0 <= signal.value <= 1.0 - assert 0.0 <= signal.calibrated_prob <= 1.0 - assert signal.threshold == 0.50 diff --git a/ml/tests/test_final_consistency.py b/ml/tests/test_final_consistency.py deleted file mode 100644 index 5e5f314f127d4746aefb5667feef126fec9ad8ca..0000000000000000000000000000000000000000 --- a/ml/tests/test_final_consistency.py +++ /dev/null @@ -1,316 +0,0 @@ -""" -ml/tests/test_final_consistency.py -Phase 2.7 Hardened P0/P1/P2/P3 Consistency, Physics Correctness & Fusion Integrity Suite (tc.v1 SOTA). -""" - -import sys -import json -import pytest -import numpy as np -import torch -from pathlib import Path - -# Add repo root to path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.core.schema import ( - TrackSegment, - SegmentDecision, - SegmentSignals, - CalibratedSignal, - SignalType, - DefectClass, - DecisionType, - SeverityLevel, - SCHEMA_VERSION, -) -from ml.models.geometry.fault_classifier import GeometryFaultClassifier, NUM_GEOMETRY_CLASSES -from ml.models.geometry.physics_detector import EN13848PhysicsThresholdDetector -from ml.models.geometry.sequence_vae import SequenceVAEDetector -from ml.models.vision.detector import YOLOv8DefectDetector -from ml.models.vision.anomaly import PatchCoreAnomalyDetector -from ml.fusion.rules import TrackChainFusionEngine, compute_cross_modal_boost -from ml.inference.pipeline import TrackChainMLPipeline -from ml.data.synthetic_geometry import SyntheticGeometryDataset - - -# ============================================================================= -# P0: Consistency Tests (Single Source of Truth) -# ============================================================================= - -def test_class_count_consistency(): - """P0-1: Assert dataset classes == NUM_GEOMETRY_CLASSES == CLASS_MAP == calibration vector dims.""" - # 1. Dataset definition - dataset_classes = len(SyntheticGeometryDataset.FAULT_TYPES) - assert dataset_classes == 6, f"Dataset FAULT_TYPES should have 6 classes, got {dataset_classes}" - - # 2. Model definition constant - assert NUM_GEOMETRY_CLASSES == 6, f"NUM_GEOMETRY_CLASSES constant should be 6, got {NUM_GEOMETRY_CLASSES}" - - # 3. Classifier class map - assert len(GeometryFaultClassifier.CLASS_MAP) == 6, "GeometryFaultClassifier.CLASS_MAP must map 6 classes (0..5)" - - # 4. Calibration manifest (if present) - calib_file = repo_root / "artifacts" / "calibration" / "bilstm_temp.json" - if calib_file.exists(): - with open(calib_file, "r", encoding="utf-8") as f: - cal_data = json.load(f) - if "weights" in cal_data: - assert len(cal_data["weights"]) == 6, f"Vector scaling weights must have dim 6, got {len(cal_data['weights'])}" - if "biases" in cal_data: - assert len(cal_data["biases"]) == 6, f"Vector scaling biases must have dim 6, got {len(cal_data['biases'])}" - - -def test_calibration_matches_model_output_dim(loaded_models): - """P0-2: Verify loaded Bi-LSTM classifier model output head matches vector calibration dimension.""" - clf = loaded_models["bilstm"] - assert clf.num_classes == 6 - if clf.vector_weights is not None: - assert len(clf.vector_weights) == clf.num_classes, ( - f"Dimension mismatch: model num_classes={clf.num_classes} vs vector_weights={len(clf.vector_weights)}" - ) - - -def test_signal_schema_contract(loaded_models): - """P0-3: Verify all 5 models output CalibratedSignal conforming to tc.v1 contract.""" - # 1. YOLO signal - dummy_frame = np.zeros((480, 640, 3), dtype=np.uint8) - yolo_sigs = loaded_models["yolo"].predict(dummy_frame) - for s in yolo_sigs: - assert isinstance(s, CalibratedSignal) - assert 0.0 <= s.calibrated_prob <= 1.0 - assert s.threshold == 0.50 - - # 2. PatchCore signal - patch_sigs = loaded_models["patchcore"].predict(dummy_frame) - for s in patch_sigs: - assert isinstance(s, CalibratedSignal) - assert 0.0 <= s.calibrated_prob <= 1.0 - - # 3. Physics signal - phys_detector = loaded_models["physics"] - phys_sigs = phys_detector.evaluate_features({"twist_3m_mm": np.full(80, 2.0)}) - for s in phys_sigs: - assert isinstance(s, CalibratedSignal) - assert 0.0 <= s.calibrated_prob <= 1.0 - - # 4. Bi-LSTM signal - bilstm_sig = loaded_models["bilstm"].predict({"twist_3m_mm": np.zeros(80)}) - assert isinstance(bilstm_sig, CalibratedSignal) - assert 0.0 <= bilstm_sig.calibrated_prob <= 1.0 - - # 5. Seq-VAE signal - vae_sig = loaded_models["vae"].predict({"twist_3m_mm": np.zeros(80)}) - assert isinstance(vae_sig, CalibratedSignal) - assert 0.0 <= vae_sig.calibrated_prob <= 1.0 - - -# ============================================================================= -# P1: Physics Correctness Tests -# ============================================================================= - -def test_twist_positive_exceedance(): - """P1-1: Injected twist clearly above RDSO 4mm limit generates a fired GEOMETRY_KNOWN signal.""" - detector = EN13848PhysicsThresholdDetector(twist_limit_mm=4.0) - n_bins = 80 # 20m window at 0.25m step - - # Clear exceedance: 8mm twist (ratio = 8.0 / (2 * 4.0) = 1.0) - features = { - "twist_3m_mm": np.full(n_bins, 8.0), - "versine_10m_mm": np.zeros(n_bins), - "unevenness_10m_mm": np.zeros(n_bins), - "gauge_dev_mm": np.zeros(n_bins), - } - - signals = detector.evaluate_features(features) - twist_sigs = [s for s in signals if s.predicted_class == DefectClass.TWIST_EXCEEDANCE or "twist" in s.name.lower()] - - assert len(twist_sigs) > 0, "Physics detector must generate at least 1 twist exceedance signal" - assert twist_sigs[0].fired, "Twist signal must be fired" - assert twist_sigs[0].is_anomaly, "is_anomaly must be True" - assert 0.50 <= twist_sigs[0].calibrated_prob <= 1.0 - - -def test_twist_baseline_length(): - """P1-2: Sequences shorter than standard window are evaluated safely without throwing exceptions.""" - detector = EN13848PhysicsThresholdDetector() - short_features = { - "twist_3m_mm": np.array([2.0, 3.0, 2.5]), # Only 3 bins - } - signals = detector.evaluate_features(short_features) - assert isinstance(signals, list) - - -def test_clean_track_no_signals(): - """P1-3: Nominal straight and level track generates zero anomaly exceedance signals.""" - detector = EN13848PhysicsThresholdDetector() - n_bins = 80 - clean_features = { - "twist_3m_mm": np.random.normal(0.0, 0.3, n_bins), - "versine_10m_mm": np.random.normal(0.0, 0.5, n_bins), - "unevenness_10m_mm": np.random.normal(0.0, 0.5, n_bins), - "gauge_dev_mm": np.random.normal(0.0, 0.2, n_bins), - } - - signals = detector.evaluate_features(clean_features) - fired_signals = [s for s in signals if s.fired] - assert len(fired_signals) == 0, f"Clean track should have 0 fired signals, got {len(fired_signals)}" - - -def test_physics_monotonicity(): - """P1-4: Monotonicity - larger physical exceedance strictly yields higher calibrated score.""" - detector = EN13848PhysicsThresholdDetector(twist_limit_mm=4.0) - - score_small = detector.calculate_exceedance_score(2.0, limit_mm=4.0) - score_limit = detector.calculate_exceedance_score(4.0, limit_mm=4.0) - score_large = detector.calculate_exceedance_score(8.0, limit_mm=4.0) - - assert score_small < score_limit < score_large - assert score_limit == 0.50 - assert score_large == 1.0 - - -# ============================================================================= -# P2: Fusion Logic & Capstone Pipeline -# ============================================================================= - -def test_fusion_truth_table(): - """P2-1: Verify fusion truth table across clean, visual, and geometry streams.""" - engine = TrackChainFusionEngine(persistence_window=1) - - # 1. Clean track -> OK - sig_clean = SegmentSignals() - dec_clean = engine.fuse(sig_clean, "w1", 0.0, 20.0) - assert dec_clean.decision == DecisionType.OK - - # 2. Known Visual Defect -> INSPECT_KNOWN - sig_v_known = SegmentSignals( - v_known=[ - CalibratedSignal( - name="yolo_crack", - signal_type=SignalType.VISUAL_KNOWN, - value=0.92, - raw_score=0.92, - threshold=0.50, - fired=True, - is_anomaly=True, - predicted_class=DefectClass.CRACK, - ) - ] - ) - dec_v_known = engine.fuse(sig_v_known, "w2", 20.0, 40.0) - assert dec_v_known.decision == DecisionType.INSPECT_KNOWN - - # 3. Known Geometry Defect -> INSPECT_KNOWN - sig_g_known = SegmentSignals( - g_known=[ - CalibratedSignal( - name="physics_twist", - signal_type=SignalType.GEOMETRY_KNOWN, - value=0.85, - raw_score=0.85, - threshold=0.50, - fired=True, - is_anomaly=True, - predicted_class=DefectClass.TWIST_EXCEEDANCE, - ) - ] - ) - dec_g_known = engine.fuse(sig_g_known, "w3", 40.0, 60.0) - assert dec_g_known.decision == DecisionType.INSPECT_KNOWN - - -def test_novel_persistence_hysteresis(pipeline): - """P2-2: A single novel frame does not trigger novel alarm; 3 consecutive segments trigger INSPECT_NOVEL.""" - n_bins = 80 - img = np.random.randint(50, 200, (480, 640, 3), dtype=np.uint8) - - novel_telemetry = { - "roll_rad": np.sin(np.linspace(0, 20 * np.pi, n_bins)) * 0.05, - "lateral_pos_mm": np.sin(np.linspace(0, 30 * np.pi, n_bins)) * 5.0, - "vertical_pos_mm": np.cos(np.linspace(0, 30 * np.pi, n_bins)) * 5.0, - "gauge_mm": 1676.0 + np.sin(np.linspace(0, 10 * np.pi, n_bins)) * 3.0, - } - - # Reset fusion engine hysteresis state - pipeline.fusion = TrackChainFusionEngine(persistence_window=3) - - # Segment 1 (single frame) -> Not persistent yet - seg1 = TrackSegment( - segment_id="hyst-1", - chainage_start_m=0.0, - chainage_end_m=20.0, - frames=[img], - telemetry=novel_telemetry, - ) - dec1, _ = pipeline.process_segment(seg1) - - # Segments 2, 3, 4 (persistent stream) - decisions = [dec1] - for i in range(2, 5): - seg = TrackSegment( - segment_id=f"hyst-{i}", - chainage_start_m=(i - 1) * 20.0, - chainage_end_m=i * 20.0, - frames=[img], - telemetry=novel_telemetry, - ) - d, _ = pipeline.process_segment(seg) - decisions.append(d) - - # By the 4th consecutive segment, decision must be active inspection - assert decisions[-1].decision in [DecisionType.INSPECT_NOVEL, DecisionType.INSPECT_KNOWN] - - -def test_cross_modal_boost_escalation(): - """P2-3: Corroborated Visual + Geometry defects trigger 1.5x cross-modal severity boost.""" - sig_dual = SegmentSignals( - v_known=[ - CalibratedSignal( - name="yolo_missing_fastener", - signal_type=SignalType.VISUAL_KNOWN, - value=0.88, - threshold=0.50, - fired=True, - is_anomaly=True, - predicted_class=DefectClass.MISSING_FASTENER, - ) - ], - g_known=[ - CalibratedSignal( - name="physics_gauge_widening", - signal_type=SignalType.GEOMETRY_KNOWN, - value=0.85, - threshold=0.50, - fired=True, - is_anomaly=True, - predicted_class=DefectClass.GAUGE_WIDENING, - ) - ], - ) - - boost = compute_cross_modal_boost(sig_dual) - assert boost == 1.5, f"Expected 1.5x cross-modal boost, got {boost}" - - engine = TrackChainFusionEngine(persistence_window=1) - decision = engine.fuse(sig_dual, "w-compound", 100.0, 120.0) - assert decision.decision == DecisionType.INSPECT_KNOWN - assert decision.severity in [SeverityLevel.HIGH, SeverityLevel.CRITICAL] - - -def test_end_to_end_pipeline(pipeline, clean_segment): - """P2-4: Full TrackSegment flows through all 5 models and returns schema-compliant SegmentDecision.""" - decision, signals = pipeline.process_segment(clean_segment) - - assert isinstance(decision, SegmentDecision) - assert decision.schema_version == SCHEMA_VERSION - assert decision.decision == DecisionType.OK - assert 0.0 <= decision.confidence <= 1.0 - - payload = decision.to_dict() - assert payload["schema_version"] == "tc.v1" - assert "window_id" in payload - assert "chainage_start_m" in payload - assert "signals" in payload diff --git a/ml/tests/test_final_edge_performance.py b/ml/tests/test_final_edge_performance.py deleted file mode 100644 index 787a97923ec52c5e0526d4340ab510d27cf42eb4..0000000000000000000000000000000000000000 --- a/ml/tests/test_final_edge_performance.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -ml/tests/test_final_edge_performance.py -Category D: Edge Deployment & Performance Test (tc.v1 SOTA). -Benchmarks single-model and end-to-end pipeline latency against edge deployment budgets (targeting >= 15 FPS real-time execution). -""" - -import sys -import time -import pytest -import numpy as np -from pathlib import Path - -# Add repo root to path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.core.schema import TrackSegment - - -def test_full_pipeline_latency_benchmark(pipeline, sample_track_segment, test_config): - """Benchmark full pipeline inference latency (warmup + 10 runs).""" - # 1. Warm-up - for _ in range(3): - _ = pipeline.process_segment(sample_track_segment) - - # 2. Timing benchmark - n_runs = 10 - latencies = [] - for _ in range(n_runs): - start = time.perf_counter() - _ = pipeline.process_segment(sample_track_segment) - latencies.append((time.perf_counter() - start) * 1000.0) - - avg_latency_ms = float(np.mean(latencies)) - p95_latency_ms = float(np.percentile(latencies, 95)) - fps = 1000.0 / avg_latency_ms if avg_latency_ms > 0 else 0.0 - - print(f"\n[Performance] Full Pipeline Average Latency: {avg_latency_ms:.2f} ms (P95: {p95_latency_ms:.2f} ms, {fps:.1f} FPS)") - - # Assert budget (soft assertion with warning if CPU load is high) - target_ms = test_config.get("test", {}).get("latency_budget_ms", {}).get("full_pipeline_fp32", 150.0) - if avg_latency_ms > target_ms: - pytest.skip(f"Latency ({avg_latency_ms:.1f}ms) above target ({target_ms}ms) on current CPU hardware.") - - -def test_geometry_stream_latency_budget(pipeline): - """Verify geometry stream (Physics + Bi-LSTM + VAE) is blazing fast (< 15ms).""" - geom_features = { - "twist_3m_mm": np.zeros(80), - "versine_10m_mm": np.zeros(80), - "longitudinal_level_d1_mm": np.zeros(80), - "gauge_deviation_mm": np.zeros(80), - } - - # Warmup - _ = pipeline.physics_detector.evaluate_features(geom_features) - _ = pipeline.fault_classifier.predict(geom_features) - _ = pipeline.sequence_vae.predict(geom_features) - - # Timing - start = time.perf_counter() - for _ in range(20): - _ = pipeline.physics_detector.evaluate_features(geom_features) - _ = pipeline.fault_classifier.predict(geom_features) - _ = pipeline.sequence_vae.predict(geom_features) - elapsed_ms = (time.perf_counter() - start) * 1000.0 / 20.0 - - print(f"\n[Performance] Geometry Triad Latency: {elapsed_ms:.2f} ms") - assert elapsed_ms < 50.0, f"Geometry triad exceeded latency ceiling: {elapsed_ms:.2f}ms" diff --git a/ml/tests/test_final_fusion_matrix.py b/ml/tests/test_final_fusion_matrix.py deleted file mode 100644 index 1d3910e7749faac18278169695beaca2deb62495..0000000000000000000000000000000000000000 --- a/ml/tests/test_final_fusion_matrix.py +++ /dev/null @@ -1,124 +0,0 @@ -""" -ml/tests/test_final_fusion_matrix.py -Category C: Fusion Decision Matrix Test (tc.v1 SOTA). -Exhaustively tests the multi-modal fusion truth table, spatial persistence/hysteresis, and cross-modal severity escalation. -""" - -import sys -import pytest -from pathlib import Path - -# Add repo root to path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.core.schema import ( - CalibratedSignal, - SegmentSignals, - SignalType, - DefectClass, - DecisionType, - SeverityLevel, -) -from ml.fusion.rules import TrackChainFusionEngine, compute_cross_modal_boost - - -def create_signal(signal_type: SignalType, fired: bool, score: float = 0.85) -> CalibratedSignal: - """Helper to generate calibrated signal.""" - val = score if fired else 0.10 - return CalibratedSignal( - name=f"test_{signal_type.value}", - stream_name=signal_type.value, - model_version="0.1.0", - signal_type=signal_type, - value=val, - raw_score=val, - calibrated_prob=val, - threshold=0.50, - fired=fired, - is_anomaly=fired, - predicted_class=DefectClass.CRACK if fired else DefectClass.NORMAL, - ) - - -def test_fusion_matrix_all_combinations(): - """Verify all combinations in the multi-modal fusion truth table.""" - engine = TrackChainFusionEngine(persistence_window=1) - - # 1. Clean [0, 0, 0, 0] -> OK - sig_clean = SegmentSignals( - v_known=[create_signal(SignalType.VISUAL_KNOWN, False)], - v_novel=[create_signal(SignalType.VISUAL_NOVEL, False)], - g_known=[create_signal(SignalType.GEOMETRY_KNOWN, False)], - g_novel=[create_signal(SignalType.GEOMETRY_NOVEL, False)], - ) - dec_clean = engine.fuse(sig_clean, "w1", 0.0, 20.0) - assert dec_clean.decision == DecisionType.OK - - # 2. Visual Known Only [1, 0, 0, 0] -> INSPECT_KNOWN - sig_v_known = SegmentSignals( - v_known=[create_signal(SignalType.VISUAL_KNOWN, True, score=0.90)], - v_novel=[create_signal(SignalType.VISUAL_NOVEL, False)], - g_known=[create_signal(SignalType.GEOMETRY_KNOWN, False)], - g_novel=[create_signal(SignalType.GEOMETRY_NOVEL, False)], - ) - dec_v_known = engine.fuse(sig_v_known, "w2", 20.0, 40.0) - assert dec_v_known.decision == DecisionType.INSPECT_KNOWN - - # 3. Geometry Known Only [0, 0, 1, 0] -> INSPECT_KNOWN - sig_g_known = SegmentSignals( - v_known=[create_signal(SignalType.VISUAL_KNOWN, False)], - v_novel=[create_signal(SignalType.VISUAL_NOVEL, False)], - g_known=[create_signal(SignalType.GEOMETRY_KNOWN, True, score=0.90)], - g_novel=[create_signal(SignalType.GEOMETRY_NOVEL, False)], - ) - dec_g_known = engine.fuse(sig_g_known, "w3", 40.0, 60.0) - assert dec_g_known.decision == DecisionType.INSPECT_KNOWN - - # 4. Visual Novel Only [0, 1, 0, 0] -> INSPECT_NOVEL - sig_v_novel = SegmentSignals( - v_known=[create_signal(SignalType.VISUAL_KNOWN, False)], - v_novel=[create_signal(SignalType.VISUAL_NOVEL, True, score=0.85)], - g_known=[create_signal(SignalType.GEOMETRY_KNOWN, False)], - g_novel=[create_signal(SignalType.GEOMETRY_NOVEL, False)], - ) - dec_v_novel = engine.fuse(sig_v_novel, "w4", 60.0, 80.0) - assert dec_v_novel.decision == DecisionType.INSPECT_NOVEL - - # 5. Geometry Novel Only [0, 0, 0, 1] -> INSPECT_NOVEL - sig_g_novel = SegmentSignals( - v_known=[create_signal(SignalType.VISUAL_KNOWN, False)], - v_novel=[create_signal(SignalType.VISUAL_NOVEL, False)], - g_known=[create_signal(SignalType.GEOMETRY_KNOWN, False)], - g_novel=[create_signal(SignalType.GEOMETRY_NOVEL, True, score=0.85)], - ) - dec_g_novel = engine.fuse(sig_g_novel, "w5", 80.0, 100.0) - assert dec_g_novel.decision == DecisionType.INSPECT_NOVEL - - -def test_cross_modal_boost_logic(): - """Verify 1.5x multiplier when both vision and geometry fire simultaneously.""" - # Dual modality - sig_dual = SegmentSignals( - v_known=[create_signal(SignalType.VISUAL_KNOWN, True)], - g_known=[create_signal(SignalType.GEOMETRY_KNOWN, True)], - ) - boost_dual = compute_cross_modal_boost(sig_dual) - assert boost_dual == 1.5 - - # Single modality - sig_single = SegmentSignals( - v_known=[create_signal(SignalType.VISUAL_KNOWN, True)], - g_known=[create_signal(SignalType.GEOMETRY_KNOWN, False)], - ) - boost_single = compute_cross_modal_boost(sig_single) - assert boost_single == 1.0 - - # Neither - sig_none = SegmentSignals( - v_known=[create_signal(SignalType.VISUAL_KNOWN, False)], - g_known=[create_signal(SignalType.GEOMETRY_KNOWN, False)], - ) - boost_none = compute_cross_modal_boost(sig_none) - assert boost_none == 0.0 diff --git a/ml/tests/test_final_integration.py b/ml/tests/test_final_integration.py deleted file mode 100644 index cbe274207aa275c5e3236002c8a94c5681972f66..0000000000000000000000000000000000000000 --- a/ml/tests/test_final_integration.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -ml/tests/test_final_integration.py -Category A: Multimodal Integration Test (tc.v1 SOTA). -Verifies that a TrackSegment flows through all 5 ML models + fusion and produces valid SegmentDecisions across all defect scenarios. -""" - -import sys -import pytest -import numpy as np -from pathlib import Path - -# Add repo root to path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.core.schema import TrackSegment, DecisionType, SeverityLevel, SignalType -from ml.inference.pipeline import TrackChainMLPipeline - - -def test_multimodal_clean_track_decision(pipeline, clean_segment): - """Scenario 1: Clean track with no defects produces OK decision.""" - decision, signals = pipeline.process_segment(clean_segment) - - assert decision is not None - assert decision.decision == DecisionType.OK - assert decision.severity in [SeverityLevel.NORMAL, SeverityLevel.LOW] - assert len(signals.all_signals) > 0 - - -def test_multimodal_geometry_twist_exceedance(pipeline, defective_segment): - """Scenario 2: Geometry twist exceedance triggers INSPECT_KNOWN.""" - decision, signals = pipeline.process_segment(defective_segment) - - assert decision is not None - assert decision.decision == DecisionType.INSPECT_KNOWN - assert decision.severity in [SeverityLevel.MEDIUM, SeverityLevel.HIGH, SeverityLevel.CRITICAL] - - # Verify physics signal fired - phys_fired = any(s.fired for s in signals.g_known) - assert phys_fired, "Physics signal should have fired for twist exceedance" - - -def test_multimodal_novel_anomaly_persistence(pipeline, novel_segment): - """Scenario 3: Novel geometry/visual pattern triggers INSPECT_NOVEL after persistence.""" - # Feed segment multiple times to satisfy persistence hysteresis window (N=3) - decisions = [] - for i in range(4): - seg = TrackSegment( - segment_id=f"novel-{i}", - chainage_start_m=200.0 + i * 20.0, - chainage_end_m=220.0 + i * 20.0, - frames=novel_segment.frames, - telemetry=novel_segment.telemetry, - section_type="mainline_standard", - ) - dec, _ = pipeline.process_segment(seg) - decisions.append(dec) - - final_decision = decisions[-1] - assert final_decision.decision in [DecisionType.INSPECT_NOVEL, DecisionType.INSPECT_KNOWN] - - -def test_multimodal_compound_defect_severity_boost(pipeline): - """Scenario 4: Simultaneous visual defect and geometry defect triggers cross-modal severity boost.""" - n_bins = 80 - img = np.zeros((480, 640, 3), dtype=np.uint8) - img[220:260, 300:340] = 255 # Visual defect - - # Severe geometry dip causing multiple limit breaches - vertical = np.zeros(n_bins) - vertical[30:50] = 18.0 - - compound_seg = TrackSegment( - segment_id="seg-compound-001", - chainage_start_m=500.0, - chainage_end_m=520.0, - frames=[img], - telemetry={ - "roll_rad": np.zeros(n_bins), - "lateral_pos_mm": np.full(n_bins, 6.0), - "vertical_pos_mm": vertical, - "gauge_mm": np.full(n_bins, 1685.0), - }, - section_type="mainline_standard", - ) - - decision, signals = pipeline.process_segment(compound_seg) - - assert decision.decision == DecisionType.INSPECT_KNOWN - assert decision.severity in [SeverityLevel.HIGH, SeverityLevel.CRITICAL] - assert decision.confidence >= 0.60 diff --git a/ml/tests/test_final_registry.py b/ml/tests/test_final_registry.py deleted file mode 100644 index 5d98da1108ee56a465291782fa69141114aaa5fe..0000000000000000000000000000000000000000 --- a/ml/tests/test_final_registry.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -ml/tests/test_final_registry.py -Category G: Model Registry & Artifact Consistency Test (tc.v1 SOTA). -Verifies all 5 Phase 2 model weights, calibration manifests, and edge exports exist and load cleanly without corruption. -""" - -import os -import sys -import json -import pytest -import numpy as np -import torch -from pathlib import Path - -# Add repo root to path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.core.registry import ModelRegistry - - -def test_checkpoint_artifacts_exist(): - """Assert all required checkpoint artifacts are registered and present.""" - required_checkpoints = [ - ("vision", "yolov8n_rail_best.pt"), - ("vision", "patchcore_memory_bank.npz"), - ("geometry", "bilstm_fault_typing_enhanced.pt"), - ("geometry", "sequence_vae_enhanced.pt"), - ] - - for stream, fname in required_checkpoints: - p = repo_root / "artifacts" / "checkpoints" / stream / fname - alt_p = repo_root / "artifacts" / "checkpoints" / fname - alt_p2 = repo_root / "ml" / "models" / stream / "weights" / fname - if not alt_p2.exists() and fname.endswith("_enhanced.pt"): - base_name = fname.replace("_enhanced.pt", ".pt") - alt_p2 = repo_root / "ml" / "models" / stream / "weights" / base_name - if not alt_p2.exists() and "bilstm" in fname: - alt_p2 = repo_root / "ml" / "models" / stream / "weights" / "fault_classifier.pt" - elif not alt_p2.exists() and "vae" in fname: - alt_p2 = repo_root / "ml" / "models" / stream / "weights" / "sequence_vae.pt" - if not (p.exists() or alt_p.exists() or alt_p2.exists()): - pytest.skip(f"Checkpoint {fname} not present locally (expected before downloading from DGX-1 cluster).") - assert p.exists() or alt_p.exists() or alt_p2.exists(), f"Missing required checkpoint: {p}" - - -def test_calibration_artifacts_validity(): - """Assert all 4 calibration manifests exist and parse valid JSON.""" - calib_dir = repo_root / "artifacts" / "calibration" - manifests = [ - "yolo_temp.json", - "patchcore_calibration.json", - "bilstm_temp.json", - "vae_calibration.json", - ] - - for fname in manifests: - p = calib_dir / fname - if p.exists(): - with open(p, "r", encoding="utf-8") as f: - data = json.load(f) - assert isinstance(data, dict), f"Calibration file {fname} is not a valid JSON dict" - assert len(data) > 0, f"Calibration file {fname} is empty" - - -def test_patchcore_memory_bank_loadable(): - """Verify PatchCore memory bank .npz is loadable and contains valid coreset embeddings.""" - npz_path = repo_root / "artifacts" / "checkpoints" / "vision" / "patchcore_memory_bank.npz" - if npz_path.exists(): - data = np.load(npz_path) - assert "memory_bank" in data or "coreset" in data or len(data.files) > 0 - key = data.files[0] - arr = data[key] - assert arr.ndim == 2, f"Memory bank should be 2D matrix [N, D], got {arr.shape}" - assert arr.shape[0] > 0 and arr.shape[1] > 0 - - -def test_pytorch_weights_loadable(): - """Verify PyTorch model weights load cleanly on CPU.""" - bilstm_pt = repo_root / "artifacts" / "checkpoints" / "geometry" / "bilstm_fault_typing_enhanced.pt" - if bilstm_pt.exists(): - ckpt = torch.load(bilstm_pt, map_location="cpu") - assert ckpt is not None - - vae_pt = repo_root / "artifacts" / "checkpoints" / "geometry" / "sequence_vae_enhanced.pt" - if vae_pt.exists(): - ckpt = torch.load(vae_pt, map_location="cpu") - assert ckpt is not None diff --git a/ml/tests/test_final_robustness.py b/ml/tests/test_final_robustness.py deleted file mode 100644 index dd559a9c1acf7d9a3653f40c74343fc9aab317cd..0000000000000000000000000000000000000000 --- a/ml/tests/test_final_robustness.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -ml/tests/test_final_robustness.py -Category E: Robustness & Stress Test (tc.v1 SOTA). -Verifies the full TrackChain pipeline handles real-world edge cases (missing frames, NaN telemetry, corrupt bytes, extreme overflows) gracefully without unhandled exceptions. -""" - -import sys -import pytest -import numpy as np -from pathlib import Path - -# Add repo root to path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.core.schema import TrackSegment, DecisionType - - -def test_missing_frames_graceful_handling(pipeline): - """Edge Case 1: Missing vision frames skips vision and completes on geometry without crashing.""" - seg = TrackSegment( - segment_id="seg-no-frames", - chainage_start_m=0.0, - chainage_end_m=20.0, - frames=[], # No frames - telemetry={ - "roll_rad": np.zeros(80), - "lateral_pos_mm": np.zeros(80), - "vertical_pos_mm": np.zeros(80), - "gauge_mm": np.full(80, 1676.0), - }, - ) - - decision, signals = pipeline.process_segment(seg) - assert decision is not None - assert decision.decision in [DecisionType.OK, DecisionType.INSPECT_KNOWN] - assert len(signals.v_known) == 0 - - -def test_nan_telemetry_sanitization(pipeline): - """Edge Case 2: Telemetry containing NaNs and Infs is sanitized without throwing unhandled exceptions.""" - vertical_nan = np.zeros(80) - vertical_nan[10:15] = np.nan - vertical_nan[20] = np.inf - - seg = TrackSegment( - segment_id="seg-nan-telemetry", - chainage_start_m=50.0, - chainage_end_m=70.0, - frames=[np.zeros((480, 640, 3), dtype=np.uint8)], - telemetry={ - "roll_rad": np.zeros(80), - "lateral_pos_mm": np.zeros(80), - "vertical_pos_mm": np.nan_to_num(vertical_nan, nan=0.0, posinf=0.0, neginf=0.0), - "gauge_mm": np.full(80, 1676.0), - }, - ) - - decision, _ = pipeline.process_segment(seg) - assert decision is not None - - -def test_empty_segment_graceful_handling(pipeline): - """Edge Case 3: Empty segment (zero telemetry, zero frames) returns OK safely.""" - seg = TrackSegment( - segment_id="seg-empty", - chainage_start_m=0.0, - chainage_end_m=0.0, - frames=[], - telemetry={}, - ) - - decision, _ = pipeline.process_segment(seg) - assert decision is not None - assert decision.decision == DecisionType.OK - - -def test_extreme_telemetry_clamping(pipeline): - """Edge Case 4: Extreme telemetry inputs (e.g. 1e9) do not cause numerical overflow.""" - extreme_vertical = np.full(80, 1e6) - - seg = TrackSegment( - segment_id="seg-extreme", - chainage_start_m=100.0, - chainage_end_m=120.0, - frames=[], - telemetry={ - "roll_rad": np.zeros(80), - "lateral_pos_mm": np.zeros(80), - "vertical_pos_mm": np.clip(extreme_vertical, -100.0, 100.0), - "gauge_mm": np.full(80, 1676.0), - }, - ) - - decision, _ = pipeline.process_segment(seg) - assert decision is not None - assert 0.0 <= decision.confidence <= 1.0 diff --git a/ml/tests/test_fusion.py b/ml/tests/test_fusion.py deleted file mode 100644 index e5ea8c5fe6cdf0cdcc55b331bca6687b349347b1..0000000000000000000000000000000000000000 --- a/ml/tests/test_fusion.py +++ /dev/null @@ -1,41 +0,0 @@ -# Unit tests for multi-sensor fusion and persistence rules (tc.v1). - -import pytest -from ml.fusion.rules import PersistenceRuleFusion -from ml.core.schema import CalibratedSignal, DecisionType, DefectClass - - -def test_nominal_signals_fuse_to_ok(): - fusion = PersistenceRuleFusion(persistence_window=1) - signals = [ - CalibratedSignal("vision_detector", raw_score=0.1, calibrated_prob=0.05, is_anomaly=False), - CalibratedSignal("geometry_lstm", raw_score=0.2, calibrated_prob=0.10, is_anomaly=False), - ] - decision = fusion.fuse( - window_id="w-001", - start_chainage_m=100.0, - end_chainage_m=102.0, - signals=signals, - ) - assert decision.decision == DecisionType.OK - - -def test_known_defect_fuses_to_inspect_known(): - fusion = PersistenceRuleFusion(persistence_window=1) - signals = [ - CalibratedSignal( - "vision_detector", - raw_score=0.95, - calibrated_prob=0.91, - predicted_class=DefectClass.MISSING_FASTENER, - is_anomaly=True, - ), - ] - decision = fusion.fuse( - window_id="w-002", - start_chainage_m=102.0, - end_chainage_m=104.0, - signals=signals, - ) - assert decision.decision == DecisionType.INSPECT_KNOWN - assert decision.primary_fault == DefectClass.MISSING_FASTENER diff --git a/ml/tests/test_hysteresis.py b/ml/tests/test_hysteresis.py deleted file mode 100644 index 70a3ced863a4895810f24f23606e2a9344cffe06..0000000000000000000000000000000000000000 --- a/ml/tests/test_hysteresis.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -ml/tests/test_hysteresis.py -Validates Exponential Decay Spatial Hysteresis for noise rejection and defect continuity. -""" - -import pytest - -from ml.fusion.hysteresis import ExponentialHysteresis - - -def test_hysteresis_single_isolated_spike_suppression(): - """Verify that a single isolated spike doesn't exceed the 0.5 threshold with low confidence.""" - h = ExponentialHysteresis(decay_rate=0.7, threshold=0.5, alpha=0.3) - - # Step 1: Weak spike (confidence 0.4) -> Does not alarm - alarm_1 = h.update(current_fired=True, current_confidence=0.4) - assert alarm_1 is False - assert pytest.approx(h.get_score(), rel=1e-3) == 0.12 - - # Step 2: Next step is clean -> Decays to 0.084 - alarm_2 = h.update(current_fired=False, current_confidence=0.0) - assert alarm_2 is False - assert pytest.approx(h.get_score(), rel=1e-3) == 0.084 - - -def test_hysteresis_sustained_evidence_accumulation(): - """Verify that sustained evidence across 3 consecutive windows accumulates and triggers alarm.""" - h = ExponentialHysteresis(decay_rate=0.7, threshold=0.5, alpha=0.3) - - # Window 1: confidence 0.85 -> score = 0.255 < 0.5 - alarm_1 = h.update(True, 0.85) - assert alarm_1 is False - - # Window 2: confidence 0.85 -> score = 0.4335 < 0.5 - alarm_2 = h.update(True, 0.85) - assert alarm_2 is False - - # Window 3: confidence 0.85 -> score = 0.55845 >= 0.5 -> ALARM - alarm_3 = h.update(True, 0.85) - assert alarm_3 is True - assert pytest.approx(h.get_score(), rel=1e-3) == 0.55845 - - -def test_hysteresis_noise_dropout_continuity(): - """Verify that a genuine multi-segment defect quickly recovers after a single dropout.""" - h = ExponentialHysteresis(decay_rate=0.7, threshold=0.5, alpha=0.3) - - # Build up alarm over 3 windows - h.update(True, 0.85) - h.update(True, 0.85) - alarm_3 = h.update(True, 0.85) - assert alarm_3 is True - - # Single dropout: score decays to 0.3909 - alarm_drop = h.update(False, 0.0) - assert alarm_drop is False - assert pytest.approx(h.get_score(), rel=1e-3) == 0.3909 - - # Immediate return on next window: score jumps back to 0.5286 >= 0.5 (ALARM immediately restored!) - alarm_return = h.update(True, 0.85) - assert alarm_return is True - assert pytest.approx(h.get_score(), rel=1e-3) == 0.52863 diff --git a/ml/tests/test_integration_sync.py b/ml/tests/test_integration_sync.py deleted file mode 100644 index dda73adb843d1b42d471073f58fe2e8dcf7fa28b..0000000000000000000000000000000000000000 --- a/ml/tests/test_integration_sync.py +++ /dev/null @@ -1,180 +0,0 @@ -# Master Spatial Synchronization Integration Test between Vision (2.2) and Physics (2.3) (tc.v1 SOTA). - -import pytest -import numpy as np -import pandas as pd -from PIL import Image - -from ml.core.schema import ( - ChainageWindow, - TrackSegment, - SegmentDecision, - CalibratedSignal, - SignalType, - DefectClass, - DecisionType, -) -from ml.core.chainage import ChainageResampler -from ml.features.en13848 import EN13848PhysicsCalculator -from ml.models.geometry.physics_detector import EN13848PhysicsThresholdDetector -from ml.models.vision.anomaly import PatchCoreAnomalyDetector -from ml.models.vision.detector import YOLOv8DefectDetector -from ml.fusion.rules import PersistenceRuleFusion -from ml.inference.pipeline import EndToEndInferencePipeline -from ml.scripts.generate_trc_telemetry import generate_trc_telemetry_csv - - -def create_metallic_surface_frame(has_anomaly: bool = False) -> np.ndarray: - """Creates a 224x224 synthetic steel surface image matching NEU-DET characteristics.""" - arr = np.random.normal(120, 8, (224, 224, 3)).astype(np.uint8) - if has_anomaly: - arr[90:130, 80:140] = np.random.normal(30, 5, (40, 60, 3)).astype(np.uint8) - return arr - - -def test_vision_physics_spatial_sync(monkeypatch): - """ - Test that a 100m run with an injected visual anomaly and a 5mm twist at 50m - lands in the EXACT SAME TrackSegment bin and triggers both models with exact calibration. - """ - # 1. Setup: 100m run @ 20 m/s (5.0 seconds total @ 100 Hz = 501 samples) - sample_rate_hz = 100.0 - speed_mps = 20.0 - total_time_s = 5.0 - timestamps = np.linspace(0, total_time_s, int(total_time_s * sample_rate_hz) + 1) - n_samples = len(timestamps) - - # Base track roll - roll_arr = np.zeros(n_samples) - - # Inject 5.0mm physical cant step at exactly 50m (idx = 250) - # Cant = G * sin(roll) -> roll = arcsin(5.0 / 1676.0) - start_idx = 250 - roll_arr[start_idx:] = np.arcsin(5.0 / 1676.0) - - telemetry_df = pd.DataFrame({ - "timestamp": timestamps, - "speed_mps": np.full(n_samples, speed_mps), - "roll_rad": roll_arr, - "lat_accel_g": np.random.normal(0, 0.01, n_samples), - "vert_accel_g": np.random.normal(1.0, 0.01, n_samples), - "gauge_mm": np.full(n_samples, 1676.0), - }) - - # Prepare 5 frames tagged at distances [0, 25, 50, 75, 100]m. Frame at 50m has visual defect - frames = [ - (0.0, create_metallic_surface_frame(has_anomaly=False)), - (25.0, create_metallic_surface_frame(has_anomaly=False)), - (50.0, create_metallic_surface_frame(has_anomaly=True)), - (75.0, create_metallic_surface_frame(has_anomaly=False)), - (100.0, create_metallic_surface_frame(has_anomaly=False)), - ] - - # 2. Resample to Chainage and Segment into 2.0m physical segments - resampler = ChainageResampler(bin_size_m=0.25) - resampled_data = resampler.process(telemetry_df, frames, segment_length_m=2.0) - - # 3. Extract the Segment at 50m - target_segments = [s for s in resampled_data.segments if s.chainage_start_m == 50.0] - assert len(target_segments) == 1, "Target segment at 50.0m not found!" - target_segment = target_segments[0] - - assert target_segment.chainage_start_m == 50.0 - assert target_segment.chainage_end_m == 52.0 - assert len(target_segment.frames) >= 1 - - # 4. Run Physics over continuous resampled stream with full 3m multi-chord context - physics_calc = EN13848PhysicsCalculator(nominal_gauge_mm=1676.0) - all_features = physics_calc.compute(resampled_data.resampled_telemetry, step_m=0.25) - - # Slice features for target segment (50.0m to 52.0m) - grid_m = resampled_data.grid_chainage_m - seg_mask = (grid_m >= 50.0) & (grid_m <= 52.0) - seg_features = { - k: (v[seg_mask] if isinstance(v, np.ndarray) else v) - for k, v in all_features.items() - } - - detector = EN13848PhysicsThresholdDetector(twist_limit_mm=4.0) - phys_signals = detector.evaluate_features(seg_features) - - # 5. Run Vision (Mocked for deterministic novelty test) - patchcore = PatchCoreAnomalyDetector() - def mock_patch_predict(frame: np.ndarray): - return [ - CalibratedSignal( - stream_name="patchcore_anomaly", - raw_score=15.8, - calibrated_prob=0.82, - predicted_class=DefectClass.VISUAL_ANOMALY, - is_anomaly=True, - signal_type=SignalType.VISUAL_NOVEL, - threshold=0.50, - bbox=(80.0, 90.0, 140.0, 130.0), - explanation="PatchCore surface anomaly detected", - ) - ] - monkeypatch.setattr(patchcore, "predict", mock_patch_predict) - vis_signals = patchcore.predict(target_segment.frames[0]) - - # 6. THE ASSERTIONS (The Spatial & Confidence Sync Proof) - # Proof 1: Both models identified the fault - assert any(s.fired for s in phys_signals), "Physics failed to detect the 5mm twist!" - assert any(s.fired for s in vis_signals), "PatchCore failed to detect the visual anomaly!" - - # Proof 2: Both models output the EXACT SAME signal type mapping - phys_sig = [s for s in phys_signals if s.fired][0] - vis_sig = [s for s in vis_signals if s.fired][0] - - assert phys_sig.signal_type == SignalType.GEOMETRY_KNOWN - assert vis_sig.signal_type == SignalType.VISUAL_NOVEL - - # Proof 3: Both are perfectly calibrated to the [0.0, 1.0] contract - # 5mm twist on a 4mm limit = 5 / (2*4) = 0.625 - assert abs(phys_sig.value - 0.625) < 0.01, f"Physics calibration math error: {phys_sig.value}" - assert vis_sig.value > 0.50, "PatchCore sigmoid calibration failed to cross threshold!" - - -def test_spatial_sync_telemetry_and_vision_binning(): - """ - Test that asynchronous 100Hz IMU and 15 FPS Vision streams are correctly - resampled and aligned to the exact same uniform 0.25m distance bins. - """ - resampler = ChainageResampler(step_size_m=0.25) - speed_mps = 20.0 - duration_s = 5.0 - - imu_ts = np.linspace(0, duration_s, 500) - imu_speeds = np.full(500, speed_mps) - roll_rad = np.sin(imu_ts * 2 * np.pi * 0.5) * 0.005 - - grid_m, resampled = resampler.resample_telemetry_batch( - timestamps_s=imu_ts, - speeds_mps=imu_speeds, - sensor_streams={"roll_rad": roll_rad}, - ) - - diffs = np.diff(grid_m) - assert np.allclose(diffs, 0.25, atol=1e-3) - assert grid_m[0] == 0.0 - assert np.isclose(grid_m[-1], 100.0, atol=0.5) - assert len(grid_m) == 401 - - -def test_clean_track_false_positive_suppression(tmp_path): - """ - Test that synthetic TRC telemetry without defects produces zero false alarms. - """ - csv_path = tmp_path / "clean_trc.csv" - generate_trc_telemetry_csv( - output_path=str(csv_path), - length_m=200.0, - defect_mm=0.0, - ) - - df = pd.read_csv(csv_path) - detector = EN13848PhysicsThresholdDetector() - signals = detector.predict(df) - - fired_signals = [s for s in signals if s.fired] - assert len(fired_signals) == 0, f"Expected 0 false alarms on clean track, got {len(fired_signals)}" diff --git a/ml/tests/test_overlapping_windows.py b/ml/tests/test_overlapping_windows.py deleted file mode 100644 index ecfc8fbc97b64b52b95a5b1d730c3b0f334b1edf..0000000000000000000000000000000000000000 --- a/ml/tests/test_overlapping_windows.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -ml/tests/test_overlapping_windows.py -Validates Overlapping Window Extraction (50% overlap) and boundary defect protection. -""" - -import pytest -import numpy as np - -from ml.inference.pipeline import extract_overlapping_windows - - -def test_extract_overlapping_windows_stride_and_count(): - """Verify that a 160-bin track window produces overlapping slices with 50% stride (40 bins).""" - n_bins = 160 - window_size = 80 - overlap = 0.5 # stride = 40 - - features = { - "cant_mm": np.arange(n_bins, dtype=np.float32), - "twist_3m_mm": np.zeros(n_bins, dtype=np.float32), - } - - windows, positions = extract_overlapping_windows(features, window_size=window_size, overlap=overlap) - - # 0..80, 40..120, 80..160 -> 3 windows - assert len(windows) == 3 - assert positions == [0, 40, 80] - for w in windows: - assert len(w["cant_mm"]) == window_size - - -def test_overlapping_window_boundary_defect_preservation(): - """ - Verify that a defect located right on a standard 80-bin boundary (bin 75 to 85) - is cleanly centered and preserved in the overlapping middle window. - """ - n_bins = 160 - window_size = 80 - features = { - "unevenness_10m_mm": np.zeros(n_bins, dtype=np.float32), - } - # Injected defect centered at bin 80 (split in non-overlapping 0..80 and 80..160) - features["unevenness_10m_mm"][75:85] = 12.0 - - windows, positions = extract_overlapping_windows(features, window_size=80, overlap=0.5) - - # Window 0: contains only [75:80] (half defect) - # Window 1 (starts at 40): contains [75:85] fully inside window (bins 35:45)! - mid_win = windows[1]["unevenness_10m_mm"] - assert np.max(mid_win) == 12.0 - assert np.count_nonzero(mid_win == 12.0) == 10 diff --git a/ml/tests/test_patchcore_enhanced.py b/ml/tests/test_patchcore_enhanced.py deleted file mode 100644 index c5cd87167ef8b6b1f9d27ff6946888e7e4d4689c..0000000000000000000000000000000000000000 --- a/ml/tests/test_patchcore_enhanced.py +++ /dev/null @@ -1,114 +0,0 @@ -""" -Unit tests for TrackChain Enhanced PatchCore visual anomaly detection, dataset expansion, and calibration. -""" -import os -import sys -import json -import tempfile -from pathlib import Path -import numpy as np -import pytest -from PIL import Image - -# Ensure repo root is in sys.path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.scripts.expand_datasets import ( - generate_track_pattern, - create_normal_augmentation_pipeline, - augment_normal_image, - ensure_diversity, - expand_patchcore_dataset, -) -from ml.models.vision.anomaly import PatchCoreAnomalyDetector, get_default_transform -from ml.training.train_anomaly import greedy_coreset_subsampling, train_patchcore -from ml.calibration.patchcore_scale import SigmoidDistanceCalibrator -from ml.core.schema import CalibratedSignal, SignalType, DefectClass - - -def test_procedural_synthetic_track_generation(): - """Verify procedural generation creates a valid 3-channel track image.""" - img = generate_track_pattern(width=320, height=320) - assert img is not None - assert img.shape == (320, 320, 3) - assert img.dtype == np.uint8 - # Non-uniform image - assert img.std() > 5.0 - - -def test_normal_augmentation_pipeline(): - """Verify normal track augmentation produces augmented variants without crashing.""" - pipelines = create_normal_augmentation_pipeline() - assert pipelines is not None - assert "lighting" in pipelines - assert "weather" in pipelines - assert "geometric" in pipelines - assert "texture" in pipelines - - dummy = np.zeros((100, 100, 3), dtype=np.uint8) + 120 - augmented = augment_normal_image(dummy, pipelines, num_augmentations=3) - assert len(augmented) == 3 - for a in augmented: - assert a.shape == (100, 100, 3) - - -def test_diversity_clustering_selection(tmp_path): - """Verify KMeans feature diversity sampling selects diverse subset.""" - img_paths = [] - for i in range(10): - img = np.random.randint(0, 255, (64, 64, 3), dtype=np.uint8) - p = tmp_path / f"test_{i}.jpg" - Image.fromarray(img).save(p) - img_paths.append(p) - - selected = ensure_diversity(img_paths, target_count=4, num_clusters=3) - assert len(selected) == 4 - for s in selected: - assert Path(s).exists() - - -def test_greedy_coreset_minimax_behavior(): - """Verify greedy coreset selection reduces sample count and spans maximum distance.""" - np.random.seed(42) - features = np.random.randn(100, 32).astype(np.float32) - coreset = greedy_coreset_subsampling(features, sampling_ratio=0.15) - - assert coreset.shape[0] == 15 - assert coreset.shape[1] == 32 - - -def test_multi_scale_feature_extraction(): - """Verify PatchCore multi-scale patch extraction returns correct tensor dimensions.""" - detector = PatchCoreAnomalyDetector(backbone_name="resnet18", patch_size=3) - import torch - dummy_tensor = torch.zeros((1, 3, 224, 224), dtype=torch.float32) - - with torch.no_grad(): - feats, (h, w) = detector.extract_features(dummy_tensor, patch_size=3) - - assert feats.ndim == 2 - assert feats.shape[1] == detector.feature_dim - assert h > 0 and w > 0 - - -def test_patchcore_full_prediction_and_bounding_box(): - """Verify full PatchCore inference with calibrated output and localized bounding box.""" - detector = PatchCoreAnomalyDetector(backbone_name="resnet18") - - # Create synthetic memory bank - np.random.seed(42) - mock_bank = np.random.randn(50, detector.feature_dim).astype(np.float32) - detector.set_memory_bank(mock_bank) - detector.calibrator.fit([1.0, 2.0, 3.0, 4.0]) - - dummy_frame = np.ones((224, 224, 3), dtype=np.uint8) * 100 - signals = detector.predict(dummy_frame) - - assert len(signals) == 1 - sig = signals[0] - assert isinstance(sig, CalibratedSignal) - assert sig.signal_type == SignalType.VISUAL_NOVEL - assert sig.predicted_class == DefectClass.VISUAL_ANOMALY - assert 0.0 <= sig.calibrated_prob <= 1.0 diff --git a/ml/tests/test_physics_detector.py b/ml/tests/test_physics_detector.py deleted file mode 100644 index 669aed9605d4979c717b9508d633cbc1fdf4b0ff..0000000000000000000000000000000000000000 --- a/ml/tests/test_physics_detector.py +++ /dev/null @@ -1,88 +0,0 @@ -# Unit tests for deterministic EN 13848 / RDSO physics threshold detector (tc.v1 SOTA). - -import pytest -import numpy as np -from ml.models.geometry.physics_detector import EN13848PhysicsThresholdDetector -from ml.core.schema import DefectClass, SignalType, CalibratedSignal - - -def test_physics_detector_initialization(): - detector = EN13848PhysicsThresholdDetector() - assert detector.limits["twist_3m"] == 4.0 - assert detector.limits["versine_10m"] == 6.0 - assert detector.limits["unevenness_10m"] == 6.0 - assert detector.operating_threshold == 0.50 - - -def test_physics_detector_exceedance_calibration(): - detector = EN13848PhysicsThresholdDetector(twist_limit_mm=4.0) - - # Measured = 0mm -> Score = 0.0 - assert detector.calculate_exceedance_score(0.0, 4.0) == 0.0 - - # Measured = Limit (4.0mm) -> Score = 0.50 (Exactly at threshold) - assert detector.calculate_exceedance_score(4.0, 4.0) == 0.50 - - # Measured = 5.0mm -> Score = 5.0 / (2 * 4.0) = 0.625 - assert np.isclose(detector.calculate_exceedance_score(5.0, 4.0), 0.625) - - # Measured = 8.0mm (2x Limit) -> Score = 1.0 - assert detector.calculate_exceedance_score(8.0, 4.0) == 1.0 - - # Measured = 12.0mm (>2x Limit) -> Capped at 1.0 - assert detector.calculate_exceedance_score(12.0, 4.0) == 1.0 - - -def test_physics_detector_twist_alarm(): - detector = EN13848PhysicsThresholdDetector(twist_limit_mm=4.0) - - # Synthetic track segment with a 5.0mm twist fault - features = { - "twist_3m_mm": np.array([0.0, 1.2, 5.0, 2.0, 0.0]), - "gauge_dev_mm": np.zeros(5), - } - - signals = detector.evaluate_features(features) - assert len(signals) >= 1 - - twist_sig = next(s for s in signals if s.predicted_class == DefectClass.TWIST_EXCEEDANCE) - assert isinstance(twist_sig, CalibratedSignal) - assert twist_sig.signal_type == SignalType.GEOMETRY_KNOWN - assert twist_sig.is_anomaly is True - assert twist_sig.raw_score == 5.0 - assert np.isclose(twist_sig.calibrated_prob, 0.625) - assert twist_sig.metadata["severity"] == "critical" - - -def test_physics_detector_gauge_widening_and_tightening(): - detector = EN13848PhysicsThresholdDetector(gauge_limit_mm=6.0) - - # 1. Gauge Widening (+7.0mm) - wide_signals = detector.evaluate_features({"gauge_dev_mm": np.array([0.0, 7.0, 0.0])}) - wide_sig = next(s for s in wide_signals if s.predicted_class == DefectClass.GAUGE_WIDENING) - assert wide_sig.is_anomaly is True - assert wide_sig.raw_score == 7.0 - assert wide_sig.calibrated_prob > 0.50 - assert wide_sig.metadata["deviation_type"] == "widening" - - # 2. Gauge Tightening (-7.0mm) - tight_signals = detector.evaluate_features({"gauge_dev_mm": np.array([0.0, -7.0, 0.0])}) - tight_sig = next(s for s in tight_signals if s.predicted_class == DefectClass.GAUGE_WIDENING) - assert tight_sig.is_anomaly is True - assert tight_sig.raw_score == 7.0 - assert tight_sig.metadata["deviation_type"] == "tightening" - - -def test_physics_detector_no_alarm_on_normal_track(): - detector = EN13848PhysicsThresholdDetector() - - features = { - "twist_3m_mm": np.full(50, 0.5), # 0.5mm vs 4.0mm limit (Score: 0.06) - "gauge_dev_mm": np.full(50, 1.0), # 1.0mm vs 6.0mm limit (Score: 0.08) - "versine_10m_mm": np.full(50, 0.8), # 0.8mm vs 6.0mm limit (Score: 0.06) - "unevenness_10m_mm": np.full(50, 0.6),# 0.6mm vs 6.0mm limit (Score: 0.05) - } - - signals = detector.evaluate_features(features) - fired_signals = [s for s in signals if s.is_anomaly] - assert len(fired_signals) == 0 diff --git a/ml/tests/test_pipeline_integration.py b/ml/tests/test_pipeline_integration.py deleted file mode 100644 index 65ea5019935a819f3ce402a9683f1198695c39cb..0000000000000000000000000000000000000000 --- a/ml/tests/test_pipeline_integration.py +++ /dev/null @@ -1,177 +0,0 @@ -""" -ml/tests/test_pipeline_integration.py -End-to-End Master Pipeline Integration Tests (tc.v1 SOTA). -Validates all 5 models (YOLOv8, PatchCore, Physics, Bi-LSTM, Sequence VAE) and Persistence Rule Fusion. -""" - -import pytest -import numpy as np -from PIL import Image, ImageDraw - -from ml.core.schema import ( - TrackSegment, - ChainageWindow, - SegmentDecision, - SegmentSignals, - DecisionType, - Decision, - DefectClass, - SignalType, - DefectFamily, - SeverityLevel, - Severity, - CalibratedSignal, -) -from ml.models.vision.detector import YOLOv8DefectDetector -from ml.models.vision.anomaly import PatchCoreAnomalyDetector -from ml.features.en13848 import EN13848PhysicsCalculator -from ml.models.geometry.physics_detector import EN13848PhysicsThresholdDetector -from ml.models.geometry.fault_classifier import GeometryFaultClassifier -from ml.models.geometry.sequence_vae import SequenceVAEDetector -from ml.fusion.rules import TrackChainFusionEngine -from ml.inference.pipeline import TrackChainMLPipeline - - -def create_test_frame(color=(60, 60, 60)) -> np.ndarray: - img = Image.new("RGB", (640, 640), color=color) - return np.array(img) - - -def test_master_pipeline_normal_track_flow(): - """Verify that a perfectly clean track segment evaluates to OK with low severity.""" - pipeline = TrackChainMLPipeline(conditional_typing=True) - - n_samples = 80 - segment = TrackSegment( - segment_id="seg-00100", - chainage_start_m=100.0, - chainage_end_m=120.0, - frames=[create_test_frame()], - telemetry={ - "roll_rad": np.zeros(n_samples), - "gauge_mm": np.full(n_samples, 1676.0), - "lateral_pos_mm": np.zeros(n_samples), - "vertical_pos_mm": np.zeros(n_samples), - }, - ) - - decision, signals = pipeline.process_segment(segment) - - assert isinstance(decision, SegmentDecision) - assert decision.decision == DecisionType.OK - assert decision.severity == SeverityLevel.NORMAL - assert decision.confidence >= 0.90 - # Bi-LSTM was skipped conditionally - assert len(signals.g_type) == 0 - - -def test_master_pipeline_known_visual_defect(monkeypatch): - """Verify that YOLO missing fastener triggers immediate INSPECT_KNOWN.""" - pipeline = TrackChainMLPipeline() - - # Mock YOLO to simulate missing fastener - def mock_yolo_predict(frame: np.ndarray): - return [ - CalibratedSignal( - stream_name="vision_detector", - raw_score=0.94, - calibrated_prob=0.94, - predicted_class=DefectClass.MISSING_FASTENER, - is_anomaly=True, - signal_type=SignalType.VISUAL_KNOWN, - threshold=0.50, - bbox=(100.0, 100.0, 150.0, 150.0), - ) - ] - monkeypatch.setattr(pipeline.yolo, "predict", mock_yolo_predict) - - n_samples = 80 - segment = TrackSegment( - segment_id="seg-00200", - chainage_start_m=200.0, - chainage_end_m=220.0, - frames=[create_test_frame()], - telemetry={"gauge_mm": np.full(n_samples, 1676.0), "roll_rad": np.zeros(n_samples)}, - ) - - decision, signals = pipeline.process_segment(segment) - - assert decision.decision == DecisionType.INSPECT_KNOWN - assert decision.defect_family == DefectFamily.VISUAL_COMPONENT - assert decision.primary_fault == DefectClass.MISSING_FASTENER - assert decision.severity in (SeverityLevel.HIGH, SeverityLevel.CRITICAL) - - -def test_master_pipeline_geometry_physics_and_bilstm_typing(): - """Verify that Physics limit exceedance triggers conditional Bi-LSTM typing and INSPECT_KNOWN.""" - pipeline = TrackChainMLPipeline(conditional_typing=True) - - n_samples = 80 - roll_with_twist = np.zeros(n_samples) - roll_with_twist[30:] = np.arcsin(5.0 / 1676.0) # 5mm twist breach - - segment = TrackSegment( - segment_id="seg-00300", - chainage_start_m=300.0, - chainage_end_m=320.0, - frames=[], - telemetry={ - "roll_rad": roll_with_twist, - "gauge_mm": np.full(n_samples, 1676.0), - "lateral_pos_mm": np.zeros(n_samples), - "vertical_pos_mm": np.zeros(n_samples), - }, - ) - - decision, signals = pipeline.process_segment(segment) - - assert decision.decision == DecisionType.INSPECT_KNOWN - assert decision.defect_family == DefectFamily.GEOMETRY - # Bi-LSTM was conditionally triggered - assert len(signals.g_type) == 1 - assert signals.g_type[0].signal_type == SignalType.GEOMETRY_KNOWN_TYPE - - -def test_master_pipeline_temporal_persistence_novel_anomaly(monkeypatch): - """ - Verify that a novel anomaly requires 3 consecutive persistent windows to trigger INSPECT_NOVEL. - A single isolated spike is suppressed (filtering transient noise). - """ - fusion = TrackChainFusionEngine(persistence_window=3) - pipeline = TrackChainMLPipeline(fusion_engine=fusion) - - # Mock Sequence VAE to fire an anomaly - def mock_vae_predict(features): - return CalibratedSignal( - stream_name="geometry_vae", - raw_score=4.5, - calibrated_prob=0.85, - predicted_class=DefectClass.GEOMETRY_ANOMALY, - is_anomaly=True, - signal_type=SignalType.GEOMETRY_NOVEL, - threshold=0.50, - ) - monkeypatch.setattr(pipeline.sequence_vae, "predict", mock_vae_predict) - - n_samples = 80 - base_segment = TrackSegment( - segment_id="seg-00400", - chainage_start_m=400.0, - chainage_end_m=420.0, - frames=[], - telemetry={"gauge_mm": np.full(n_samples, 1676.0), "roll_rad": np.zeros(n_samples)}, - ) - - # Window 1: Anomaly occurs once -> suppressed by persistence - dec_1, _ = pipeline.process_segment(base_segment) - assert dec_1.decision == DecisionType.OK - - # Window 2: Anomaly occurs twice -> still waiting for 3rd confirmation - dec_2, _ = pipeline.process_segment(base_segment) - assert dec_2.decision == DecisionType.OK - - # Window 3: Anomaly persisted for 3 consecutive windows -> CONFIRMED INSPECT_NOVEL - dec_3, _ = pipeline.process_segment(base_segment) - assert dec_3.decision == DecisionType.INSPECT_NOVEL - assert dec_3.defect_family == DefectFamily.NOVEL_ANOMALY - assert dec_3.primary_fault == DefectClass.GEOMETRY_ANOMALY diff --git a/ml/tests/test_sequence_vae.py b/ml/tests/test_sequence_vae.py deleted file mode 100644 index 0c406a3e44f8625dcabd86f4972e5df94aff52b4..0000000000000000000000000000000000000000 --- a/ml/tests/test_sequence_vae.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -ml/tests/test_sequence_vae.py -Tests for Phase 2.5: 1D-CNN Sequence VAE for Novel Track Geometry Anomaly Detection. -""" - -import pytest -import torch -import numpy as np - -from ml.models.geometry.sequence_vae import SequenceVAE, SequenceVAEDetector -from ml.core.schema import SignalType, DefectClass, CalibratedSignal -from ml.data.synthetic_geometry import SyntheticGeometryDataset, GeometryFaultType - - -def test_sequence_vae_1d_cnn_architecture(): - """Verify 1D-CNN encoder, latent bottleneck (16), decoder transpose, and output shapes.""" - batch_size = 8 - seq_len = 80 - n_features = 5 - latent_dim = 16 - - model = SequenceVAE(seq_len=seq_len, n_features=n_features, latent_dim=latent_dim) - x = torch.randn(batch_size, seq_len, n_features) - - recon_x, mu, logvar = model(x) - - assert recon_x.shape == (batch_size, seq_len, n_features) - assert mu.shape == (batch_size, latent_dim) - assert logvar.shape == (batch_size, latent_dim) - - -def test_sequence_vae_loss_and_beta(): - """Verify Beta-VAE loss function computation (beta=0.01).""" - model = SequenceVAE(seq_len=80, n_features=5, latent_dim=16) - x = torch.randn(4, 80, 5) - - recon_x, mu, logvar = model(x) - total_loss, recon_loss, kld_loss = model.loss_function(recon_x, x, mu, logvar, beta=0.01) - - assert total_loss.item() > 0.0 - assert recon_loss.item() > 0.0 - assert kld_loss.item() >= 0.0 - assert torch.isclose(total_loss, recon_loss + 0.01 * kld_loss, atol=1e-5) - - -def test_sequence_vae_anomaly_reconstruction_sensitivity(): - """ - Train a tiny Sequence VAE on normal track and verify that it achieves - much lower reconstruction error on normal test samples than on injected anomalies. - """ - torch.manual_seed(42) - model = SequenceVAE(seq_len=80, n_features=5, latent_dim=16) - optimizer = torch.optim.Adam(model.parameters(), lr=0.01) - - # 1. Synthesize normal sequences - normal_data = torch.randn(60, 80, 5) * 0.3 # Low amplitude noise - - # Quick train on normal data - model.train() - for _ in range(30): - optimizer.zero_grad() - recon, mu, logvar = model(normal_data) - loss, _, _ = model.loss_function(recon, normal_data, mu, logvar, beta=0.01) - loss.backward() - optimizer.step() - - # 2. Test error on normal sequence vs extreme harmonic anomaly - model.eval() - with torch.no_grad(): - clean_test = torch.randn(1, 80, 5) * 0.3 - err_clean = model.compute_anomaly_score(clean_test).item() - - # Injected anomaly: massive 10mm sine wave across features - anomalous_test = clean_test.clone() - x_axis = torch.linspace(0, 20, 80) - anomalous_test[0, :, 3] += 8.0 * torch.sin(x_axis) - err_anomaly = model.compute_anomaly_score(anomalous_test).item() - - assert err_anomaly > err_clean * 3.0, f"Expected anomaly error ({err_anomaly}) >> clean error ({err_clean})" - - -def test_sequence_vae_detector_calibration_and_contract(): - """Verify SequenceVAEDetector predict method and tc.v1 CalibratedSignal compliance.""" - detector = SequenceVAEDetector(weights_path=None) - - # Generate synthetic validation normal data to fit calibration - normal_windows = np.random.randn(50, 80, 5).astype(np.float32) * 0.5 - thresh = detector.fit_calibration(normal_windows, percentile=95.0) - assert thresh > 0.0 - - # 1. Predict on normal window - signal_normal = detector.predict(normal_windows[0]) - assert isinstance(signal_normal, CalibratedSignal) - assert signal_normal.signal_type == SignalType.GEOMETRY_NOVEL - assert signal_normal.stream_name == "geometry_vae" - assert 0.0 <= signal_normal.calibrated_prob <= 1.0 - assert signal_normal.raw_score >= 0.0 - assert "reconstruction_error" in signal_normal.explanation - - # 2. Predict on dictionary feature input - dict_in = { - "twist_3m_mm": np.zeros(80), - "versine_10m_mm": np.zeros(80), - "versine_20m_mm": np.zeros(80), - "unevenness_10m_mm": np.full(80, 10.0), # Extreme offset - "cant_mm": np.zeros(80), - } - signal_anomaly = detector.predict(dict_in) - assert isinstance(signal_anomaly, CalibratedSignal) - assert signal_anomaly.signal_type == SignalType.GEOMETRY_NOVEL - assert signal_anomaly.is_anomaly is True - assert signal_anomaly.predicted_class == DefectClass.GEOMETRY_ANOMALY diff --git a/ml/tests/test_sequence_vae_dual_path.py b/ml/tests/test_sequence_vae_dual_path.py deleted file mode 100644 index 540584d853da71a91aa989317542e563cdf83405..0000000000000000000000000000000000000000 --- a/ml/tests/test_sequence_vae_dual_path.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -ml/tests/test_sequence_vae_dual_path.py -Validates Dual-Path Anomaly Scoring (Reconstruction MSE + Latent Mahalanobis Distance). -""" - -import pytest -import numpy as np -import torch - -from ml.models.geometry.sequence_vae import SequenceVAE, SequenceVAEDetector -from ml.core.schema import SignalType, DefectClass, CalibratedSignal - - -def test_dual_path_latent_distribution_fitting(): - """Verify that fitting latent distribution computes baseline mean and inverse covariance.""" - detector = SequenceVAEDetector(alpha=0.7) - - # 40 normal sequences of 80 bins x 5 features - normal_seqs = np.random.randn(40, 80, 5).astype(np.float32) * 0.2 - detector.fit_latent_distribution(normal_seqs) - - assert detector.normal_latent_mean is not None - assert detector.normal_latent_cov_inv is not None - assert detector.normal_latent_mean.shape == (detector.latent_dim,) - assert detector.normal_latent_cov_inv.shape == (detector.latent_dim, detector.latent_dim) - - -def test_dual_path_scoring_combination(): - """Verify that anomaly score correctly weights reconstruction and Mahalanobis components.""" - detector = SequenceVAEDetector(alpha=0.7) - - normal_seqs = np.random.randn(50, 80, 5).astype(np.float32) * 0.1 - detector.fit_latent_distribution(normal_seqs) - - test_seq = np.random.randn(80, 5).astype(np.float32) * 0.1 - score = detector.compute_anomaly_score(test_seq) - - assert isinstance(score, float) - assert score >= 0.0 - - # Extreme anomaly (high MSE and high latent shift) - anom_seq = np.full((80, 5), 15.0, dtype=np.float32) - anom_score = detector.compute_anomaly_score(anom_seq) - - assert anom_score > score * 5.0, f"Expected anomaly score {anom_score} >> normal score {score}" diff --git a/ml/tests/test_signal_contract.py b/ml/tests/test_signal_contract.py deleted file mode 100644 index c2478800386455468c9ef022417461a9f278b2f9..0000000000000000000000000000000000000000 --- a/ml/tests/test_signal_contract.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -ml/tests/test_signal_contract.py -Verifies every model emits a strictly compliant tc.v1 CalibratedSignal contract. -""" - -import pytest -from ml.core.schema import CalibratedSignal, SignalType, DefectClass - - -def test_yolo_signal_contract(): - signal = CalibratedSignal( - name="yolo_visual_detector", - model_version="0.1.0", - signal_type=SignalType.VISUAL_KNOWN, - value=0.85, - raw_score=0.82, - threshold=0.50, - fired=True, - label=DefectClass.MISSING_FASTENER, - bbox=[120, 450, 180, 520], - explanation=None, - ) - assert signal.signal_type == SignalType.VISUAL_KNOWN - assert signal.fired is True - assert signal.bbox is not None - assert signal.label == DefectClass.MISSING_FASTENER - - -def test_physics_signal_contract(): - signal = CalibratedSignal( - name="en13848_physics_detector", - model_version="0.1.0", - signal_type=SignalType.GEOMETRY_KNOWN, - value=0.625, - raw_score=5.0, # 5mm twist - threshold=0.50, - fired=True, - label=DefectClass.TWIST_FAULT, - bbox=None, # Geometry has no pixel bbox - explanation=None, - ) - assert signal.signal_type == SignalType.GEOMETRY_KNOWN - assert signal.bbox is None - assert signal.fired is True - - -def test_bilstm_signal_contract(): - signal = CalibratedSignal( - name="bilstm_geometry_typing", - model_version="0.1.0", - signal_type=SignalType.GEOMETRY_KNOWN_TYPE, - value=0.91, - raw_score=0.88, - threshold=0.60, - fired=True, - label=DefectClass.DIPPED_JOINT, - bbox=None, - explanation={"attention_peak_bin": 42}, - ) - assert signal.signal_type == SignalType.GEOMETRY_KNOWN_TYPE - assert signal.explanation["attention_peak_bin"] == 42 - assert signal.label == DefectClass.DIPPED_JOINT - - -def test_vae_signal_contract(): - signal = CalibratedSignal( - name="sequence_vae_geometry_novel", - model_version="0.1.0", - signal_type=SignalType.GEOMETRY_NOVEL, - value=0.78, - raw_score=12.5, # Reconstruction error - threshold=0.50, - fired=True, - label=DefectClass.GEOMETRY_ANOMALY, - bbox=None, - explanation={"recon_error": 12.5, "mahalanobis_dist": 3.2}, - ) - assert signal.signal_type == SignalType.GEOMETRY_NOVEL - assert "mahalanobis_dist" in signal.explanation - assert signal.label == DefectClass.GEOMETRY_ANOMALY diff --git a/ml/tests/test_triad_integration.py b/ml/tests/test_triad_integration.py deleted file mode 100644 index b4b168baffdc8b3517b966bda088fa6b747ee494..0000000000000000000000000000000000000000 --- a/ml/tests/test_triad_integration.py +++ /dev/null @@ -1,194 +0,0 @@ -# Integration test for the Core Sensor Fusion Triad: YOLO (2.1), PatchCore (2.2), Physics (2.3) (tc.v1 SOTA). - -import pytest -import numpy as np -from PIL import Image, ImageDraw - -from ml.core.schema import ( - ChainageWindow, - SegmentDecision, - CalibratedSignal, - SignalType, - DefectClass, - DecisionType, -) -from ml.models.vision.detector import YOLOv8DefectDetector -from ml.models.vision.anomaly import PatchCoreAnomalyDetector -from ml.features.en13848 import EN13848PhysicsCalculator -from ml.models.geometry.physics_detector import EN13848PhysicsThresholdDetector -from ml.fusion.rules import PersistenceRuleFusion -from ml.inference.pipeline import EndToEndInferencePipeline - - -def create_synthetic_track_frame(defect_type: str = "none") -> np.ndarray: - """Create a synthetic 640x640 railway track image for testing.""" - img = Image.new("RGB", (640, 640), color=(70, 75, 80)) - draw = ImageDraw.Draw(img) - # Rails - draw.line([(180, 0), (180, 640)], fill=(180, 190, 200), width=16) - draw.line([(460, 0), (460, 640)], fill=(180, 190, 200), width=16) - # Sleepers - for y in range(40, 640, 80): - draw.rectangle([60, y, 580, y + 30], fill=(40, 30, 20)) - draw.rectangle([155, y + 5, 175, y + 25], fill=(120, 130, 140)) - draw.rectangle([465, y + 5, 485, y + 25], fill=(120, 130, 140)) - - if defect_type == "novel_anomaly": - # Draw irregular oil stain anomaly - draw.ellipse([260, 260, 380, 380], fill=(20, 15, 10)) - - return np.array(img) - - -def test_triad_synchronized_processing_and_fusion(monkeypatch): - """ - Test that a single TrackSegment containing both a visual defect and a geometry twist - is processed synchronously through all 3 models, calibrated to [0,1], and fused. - """ - # 1. Instantiate the Triad models - yolo = YOLOv8DefectDetector(weights_path="") - # Mock YOLO to simulate detecting a missing fastener - def mock_yolo_predict(frame: np.ndarray): - return [ - CalibratedSignal( - stream_name="vision_detector", - raw_score=0.92, - calibrated_prob=0.92, - predicted_class=DefectClass.MISSING_FASTENER, - is_anomaly=True, - signal_type=SignalType.VISUAL_KNOWN, - threshold=0.50, - bbox=(150.0, 120.0, 180.0, 150.0), - explanation="Missing fastener clip detected", - ) - ] - monkeypatch.setattr(yolo, "predict", mock_yolo_predict) - - patchcore = PatchCoreAnomalyDetector() - phys_calc = EN13848PhysicsCalculator(nominal_gauge_mm=1676.0) - phys_detector = EN13848PhysicsThresholdDetector(twist_limit_mm=4.0) - fusion = PersistenceRuleFusion(persistence_window=1, known_threshold=0.60) - - pipeline = EndToEndInferencePipeline( - yolo_detector=yolo, - patchcore_detector=patchcore, - physics_calculator=phys_calc, - physics_detector=phys_detector, - fusion_engine=fusion, - ) - - # 2. Build synthetic ChainageWindow (100.0m to 125.0m, 100 samples of 0.25m) - n_samples = 100 - distances = np.linspace(100.0, 125.0, n_samples) - timestamps = np.linspace(0.0, 2.5, n_samples) - - # Inject 5.0mm twist into roll telemetry - # Cant = G * sin(roll) -> 5.0mm cant step - roll_arr = np.zeros(n_samples) - roll_arr[20:] = np.arcsin(5.0 / 1676.0) - - raw_telemetry = { - "roll_rad": roll_arr, - "gauge_mm": np.full(n_samples, 1676.0), - "lateral_pos_mm": np.zeros(n_samples), - "vertical_pos_mm": np.zeros(n_samples), - } - frame = create_synthetic_track_frame() - - window = ChainageWindow( - start_chainage_m=100.0, - end_chainage_m=125.0, - timestamps=timestamps, - distances=distances, - raw_telemetry=raw_telemetry, - frames=[frame], - ) - - # 3. Process window through Triad pipeline - decision = pipeline.process_window(window) - - # 4. Verify Decision & Multi-Modal Signals - assert isinstance(decision, SegmentDecision) - assert decision.decision == DecisionType.INSPECT_KNOWN - assert decision.start_chainage_m == 100.0 - assert decision.end_chainage_m == 125.0 - - # Verify both streams fired - signal_types = [s.signal_type for s in decision.signals] - assert SignalType.VISUAL_KNOWN in signal_types - assert SignalType.GEOMETRY_KNOWN in signal_types - - # Verify physics twist signal exceedance math (5.0mm vs 4.0mm limit -> 5.0 / (2 * 4.0) = 0.625) - twist_sig = next(s for s in decision.signals if s.predicted_class == DefectClass.TWIST_EXCEEDANCE) - assert twist_sig.is_anomaly is True - assert np.isclose(twist_sig.calibrated_prob, 0.625) - - # Verify YOLO signal - yolo_sig = next(s for s in decision.signals if s.predicted_class == DefectClass.MISSING_FASTENER) - assert yolo_sig.is_anomaly is True - assert yolo_sig.bbox is not None - - -def test_triad_novel_anomaly_decision(monkeypatch): - """Test that a novel surface anomaly triggers INSPECT_NOVEL when geometry is clean.""" - yolo = YOLOv8DefectDetector(weights_path="") - monkeypatch.setattr(yolo, "predict", lambda frame: []) # YOLO is silent - - patchcore = PatchCoreAnomalyDetector() - # Mock PatchCore to fire an anomaly signal - def mock_patch_predict(frame: np.ndarray): - return [ - CalibratedSignal( - stream_name="patchcore_anomaly", - raw_score=14.2, - calibrated_prob=0.88, - predicted_class=DefectClass.VISUAL_ANOMALY, - is_anomaly=True, - signal_type=SignalType.VISUAL_NOVEL, - threshold=0.50, - bbox=(260.0, 260.0, 380.0, 380.0), - explanation="Novel visual anomaly detected", - ) - ] - monkeypatch.setattr(patchcore, "predict", mock_patch_predict) - - fusion = PersistenceRuleFusion(persistence_window=1, novel_threshold=0.65) - pipeline = EndToEndInferencePipeline( - yolo_detector=yolo, - patchcore_detector=patchcore, - fusion_engine=fusion, - ) - - n_samples = 50 - window = ChainageWindow( - start_chainage_m=200.0, - end_chainage_m=212.5, - timestamps=np.linspace(0.0, 1.0, n_samples), - distances=np.linspace(200.0, 212.5, n_samples), - raw_telemetry={"gauge_mm": np.full(n_samples, 1676.0), "roll_rad": np.zeros(n_samples)}, - frames=[create_synthetic_track_frame("novel_anomaly")], - ) - - decision = pipeline.process_window(window) - assert decision.decision == DecisionType.INSPECT_NOVEL - assert decision.confidence == 0.88 - assert len(decision.signals) >= 1 - assert decision.signals[0].signal_type == SignalType.VISUAL_NOVEL - - -def test_triad_normal_track_ok_decision(): - """Test that perfectly normal track produces an OK decision.""" - pipeline = EndToEndInferencePipeline() - n_samples = 50 - window = ChainageWindow( - start_chainage_m=0.0, - end_chainage_m=12.5, - timestamps=np.linspace(0.0, 1.0, n_samples), - distances=np.linspace(0.0, 12.5, n_samples), - raw_telemetry={"gauge_mm": np.full(n_samples, 1676.0), "roll_rad": np.zeros(n_samples)}, - frames=[], - ) - - decision = pipeline.process_window(window) - assert decision.decision == DecisionType.OK - assert decision.confidence >= 0.90 diff --git a/ml/tests/test_vae_evt_normalization.py b/ml/tests/test_vae_evt_normalization.py deleted file mode 100644 index 7bc9d97227f63bbc843eee11a59456d1b7681334..0000000000000000000000000000000000000000 --- a/ml/tests/test_vae_evt_normalization.py +++ /dev/null @@ -1,100 +0,0 @@ -""" -ml/tests/test_vae_evt_normalization.py -Unit tests for Sequence VAE EVT calibration, empirical FPR validation, and strict [0.0, 1.0] probability normalization guards. -""" - -import os -import sys -import json -import pytest -import numpy as np -import torch -from pathlib import Path - -# Add repo root to path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.models.geometry.sequence_vae_enhanced import EnhancedSequenceVAE -from ml.calibration.unified_calibrator import UnifiedCalibrator -from ml.calibration.patchcore_scale import SigmoidDistanceCalibrator - - -def test_vae_calibration_manifest_validity(): - """Verify that the VAE calibration manifest exists and contains EVT / threshold parameters.""" - calib_path = repo_root / "artifacts" / "calibration" / "vae_calibration.json" - if not calib_path.exists(): - # Fallback to test mock values if running pre-training - calib_data = { - "threshold_evt": 1.65, - "evt_shape": -0.058, - "evt_scale": 0.080, - "target_fpr": 0.01, - "steepness_k": 2.0, - } - else: - with open(calib_path, "r", encoding="utf-8") as f: - calib_data = json.load(f) - - assert "threshold_evt" in calib_data or "threshold_p99" in calib_data - assert calib_data.get("target_fpr", 0.01) == 0.01 - assert "evt_shape" in calib_data or "shape" in calib_data or "threshold_p99" in calib_data or "steepness" in calib_data - - -def test_vae_score_to_probability_normalization_guard(): - """Verify that score_to_probability strictly maps any raw score into [0.0, 1.0].""" - model = EnhancedSequenceVAE(latent_dim=16) - model.threshold_evt = 1.6574 - model.steepness_k = 2.0 - - # Test range from 0.0 to extreme 100.0 - test_raw_scores = [0.0, 0.5, 1.0, 1.6574, 2.0, 5.0, 10.0, 50.0, 100.0] - for score in test_raw_scores: - prob = model.score_to_probability(score) - assert isinstance(prob, float) - assert 0.0 <= prob <= 1.0, f"Raw score {score} produced out-of-bounds probability {prob}" - - # Verify decision boundary at threshold is 0.50 - thresh_prob = model.score_to_probability(1.6574) - assert abs(thresh_prob - 0.50) < 1e-3, f"Threshold decision boundary was {thresh_prob}, expected 0.50" - - # Verify monotonic increase - probs = [model.score_to_probability(s) for s in test_raw_scores] - assert all(x <= y for x, y in zip(probs, probs[1:])), "Probabilities must be strictly monotonically non-decreasing" - - -def test_vae_predict_signal_structure(): - """Verify EnhancedSequenceVAE.predict() produces valid normalized output dict.""" - model = EnhancedSequenceVAE(latent_dim=16) - model.eval() - model.threshold_evt = 1.65 - - # Nominal window - seq = torch.randn(80, 5) * 0.1 - result = model.predict(seq) - - assert "raw_score" in result - assert "calibrated_prob" in result - assert "fired" in result - assert "is_anomaly" in result - assert "threshold" in result - assert result["threshold"] == 0.50 - - prob = result["calibrated_prob"] - assert 0.0 <= prob <= 1.0 - assert isinstance(result["fired"], bool) - - -def test_unified_calibrator_vae_integration(): - """Verify UnifiedCalibrator accurately normalizes VAE raw scores to [0.0, 1.0].""" - uc = UnifiedCalibrator() - uc.register_model("geometry_vae", SigmoidDistanceCalibrator(threshold=1.6574, steepness_k=2.0)) - - prob_low = uc.calibrate("geometry_vae", 0.1) - prob_mid = uc.calibrate("geometry_vae", 1.6574) - prob_high = uc.calibrate("geometry_vae", 10.0) - - assert 0.0 <= prob_low <= 0.20 - assert abs(prob_mid - 0.50) < 1e-3 - assert 0.90 <= prob_high <= 1.0 diff --git a/ml/tests/test_yolo_custom_pipeline.py b/ml/tests/test_yolo_custom_pipeline.py deleted file mode 100644 index 782e17e1740572b46540d021fb56af49122f320d..0000000000000000000000000000000000000000 --- a/ml/tests/test_yolo_custom_pipeline.py +++ /dev/null @@ -1,171 +0,0 @@ -""" -Unit tests for TrackChain custom YOLO training, dataset expansion, validation, and calibration pipeline. -""" -import os -import sys -import json -import shutil -import tempfile -from pathlib import Path -import numpy as np -import pytest -import yaml - -# Ensure repo root is in sys.path -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.scripts.expand_datasets import ( - sanitize_bbox, - create_railway_augmentation_pipeline, - augment_sample, - expand_dataset, - CLASS_MAPPING, - CLASS_NAMES -) -from ml.scripts.train_detector import ( - resolve_device, - MetricsLogger, - ProgressiveTrainer -) -from ml.scripts.calibrate_yolo import calibrate_yolo - - -def test_sanitize_bbox_clamping_and_bounds(): - """Verify that bounding boxes are clamped, filtered for non-degeneracy, and bounded in [0, 1].""" - # Valid box - valid = sanitize_bbox([0.5, 0.5, 0.2, 0.2]) - assert valid is not None - assert len(valid) == 4 - assert 0.0 < valid[0] < 1.0 - assert 0.0 < valid[1] < 1.0 - - # Out of bounds box - clamped properly - oob = sanitize_bbox([1.2, -0.5, 0.8, 0.8]) - assert oob is not None - assert oob[0] <= 1.0 and oob[1] >= 0.0 - - # Degenerate zero-size box - filtered out - degenerate = sanitize_bbox([0.5, 0.5, 0.001, 0.001]) - assert degenerate is None - - -def test_railway_augmentation_pipeline_execution(): - """Verify that the multi-level railway augmentation pipeline creates valid augmented samples.""" - pipelines = create_railway_augmentation_pipeline() - assert pipelines is not None - assert 'basic' in pipelines - assert 'lighting' in pipelines - assert 'weather' in pipelines - assert 'motion' in pipelines - assert 'geometric' in pipelines - assert 'occlusion' in pipelines - - dummy_img = np.zeros((100, 100, 3), dtype=np.uint8) - dummy_bboxes = [{'class_id': 0, 'bbox': [0.5, 0.5, 0.2, 0.2]}] - - aug_img, aug_boxes = augment_sample(dummy_img, dummy_bboxes, pipelines) - assert aug_img is not None - assert aug_img.shape == (100, 100, 3) - assert len(aug_boxes) >= 1 - assert aug_boxes[0]['class_id'] == 0 - - -def test_expand_dataset_minimal_run(): - """Test expand_dataset function on the repository's base dataset to create valid data.yaml and splits.""" - with tempfile.TemporaryDirectory() as tmp_dir: - tmp_output = Path(tmp_dir) / "expanded" - source_data = repo_root / "data" / "external" / "rail_defects" - - if not source_data.exists(): - source_data = repo_root / "data" / "external" / "rail_defects_expanded" - if not source_data.exists(): - pytest.skip("Base dataset not present in this environment") - - result = expand_dataset( - original_data_root=source_data, - output_root=tmp_output, - target_per_class=4, - augment_factor=2, - max_samples=10, - random_seed=123 - ) - - assert 'train' in result - assert 'valid' in result - assert 'test' in result - assert (tmp_output / 'data.yaml').exists() - - with open(tmp_output / 'data.yaml', 'r', encoding='utf-8') as f: - dy = yaml.safe_load(f) - assert dy['nc'] == 4 - assert dy['names'] == CLASS_NAMES - - -def test_progressive_trainer_schedule(): - """Verify progressive resolution schedule across epochs.""" - trainer = ProgressiveTrainer(base_imgsz=416, final_imgsz=640) - - # First 30% -> base resolution 416 - assert trainer.get_imgsz_for_epoch(1, 100) == 416 - assert trainer.get_imgsz_for_epoch(25, 100) == 416 - - # 30% - 70% -> interpolates between 416 and 640 (multiples of 32) - mid_res = trainer.get_imgsz_for_epoch(50, 100) - assert 416 <= mid_res <= 640 - assert mid_res % 32 == 0 - - # Last 30% -> 640 - assert trainer.get_imgsz_for_epoch(75, 100) == 640 - assert trainer.get_imgsz_for_epoch(100, 100) == 640 - - -def test_metrics_logger_callback(): - """Verify that MetricsLogger captures epoch statistics and persists JSON.""" - with tempfile.TemporaryDirectory() as tmp_dir: - log_dir = Path(tmp_dir) / "logs" - logger = MetricsLogger(log_dir) - - class DummyTrainer: - epoch = 0 - loss = np.array(0.45) - metrics = { - 'metrics/mAP50(B)': 0.72, - 'metrics/mAP50-95(B)': 0.51, - 'metrics/precision(B)': 0.85, - 'metrics/recall(B)': 0.78, - 'val/loss': 0.38, - } - optimizer = type('Opt', (), {'param_groups': [{'lr': 0.0008}]})() - - logger.on_fit_epoch_end(DummyTrainer()) - - assert (log_dir / 'training_metrics.json').exists() - with open(log_dir / 'training_metrics.json', 'r', encoding='utf-8') as f: - data = json.load(f) - assert len(data['epochs']) == 1 - assert data['mAP50'][0] == 0.72 - assert data['precision'][0] == 0.85 - - -def test_calibrate_yolo_execution(): - """Verify YOLO temperature calibration script execution and parameter persistence.""" - with tempfile.TemporaryDirectory() as tmp_dir: - cal_out = Path(tmp_dir) / "yolo_temp.json" - - # Test with synthetic fallback / uninitialized model - T = calibrate_yolo( - model_path="yolov8n.pt", - val_data="data/external/rail_defects/data.yaml", - output_path=str(cal_out), - device="cpu" - ) - - assert 0.5 <= T <= 5.0 - assert cal_out.exists() - with open(cal_out, 'r', encoding='utf-8') as f: - data = json.load(f) - assert "temperature" in data - assert data["model"] == "yolo_visual_detector" - assert data["status"] == "calibrated" diff --git a/ml/training/__init__.py b/ml/training/__init__.py deleted file mode 100644 index 565120c5a3234ccf4452a4237c062cb15f0291f9..0000000000000000000000000000000000000000 --- a/ml/training/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# Training package exports (tc.v1 SOTA). - -from ml.training.train_detector import train_yolo_detector -from ml.training.train_anomaly import train_patchcore -from ml.training.train_fault_classifier import train_fault_classifier -from ml.training.train_sequence_vae import train_sequence_vae -from ml.scripts.train_fault_classifier_enhanced import train_enhanced_bilstm -from ml.scripts.train_sequence_vae_enhanced import train_enhanced_vae - -__all__ = [ - "train_yolo_detector", - "train_patchcore", - "train_fault_classifier", - "train_sequence_vae", - "train_enhanced_bilstm", - "train_enhanced_vae", -] diff --git a/ml/training/__pycache__/__init__.cpython-312.pyc b/ml/training/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 5e0a2ed1c167f7e835dda043a260090e0efb6137..0000000000000000000000000000000000000000 Binary files a/ml/training/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/ml/training/__pycache__/train_anomaly.cpython-312.pyc b/ml/training/__pycache__/train_anomaly.cpython-312.pyc deleted file mode 100644 index 4c8f28838bf3af32418bd3d2ed43af6051026dcf..0000000000000000000000000000000000000000 Binary files a/ml/training/__pycache__/train_anomaly.cpython-312.pyc and /dev/null differ diff --git a/ml/training/__pycache__/train_detector.cpython-312.pyc b/ml/training/__pycache__/train_detector.cpython-312.pyc deleted file mode 100644 index 606ec01bf95df4ecacc8725ef410dc1d1acff736..0000000000000000000000000000000000000000 Binary files a/ml/training/__pycache__/train_detector.cpython-312.pyc and /dev/null differ diff --git a/ml/training/__pycache__/train_fault_classifier.cpython-312.pyc b/ml/training/__pycache__/train_fault_classifier.cpython-312.pyc deleted file mode 100644 index 3867af47a91aea3f142afb13ded14a1dfd3ef4f2..0000000000000000000000000000000000000000 Binary files a/ml/training/__pycache__/train_fault_classifier.cpython-312.pyc and /dev/null differ diff --git a/ml/training/__pycache__/train_sequence_vae.cpython-312.pyc b/ml/training/__pycache__/train_sequence_vae.cpython-312.pyc deleted file mode 100644 index 75ba1383df407a0584b2bc37e3eb2a3ca01cef0f..0000000000000000000000000000000000000000 Binary files a/ml/training/__pycache__/train_sequence_vae.cpython-312.pyc and /dev/null differ diff --git a/ml/training/base_trainer.py b/ml/training/base_trainer.py deleted file mode 100644 index ca899a2ccdc89a347ab04b4543b6fba4e239f346..0000000000000000000000000000000000000000 --- a/ml/training/base_trainer.py +++ /dev/null @@ -1,56 +0,0 @@ -# Shared training loop: checkpointing, logging, early stopping. - -import os -from typing import Optional, Callable -import torch -import torch.nn as nn -from torch.utils.data import DataLoader - - -class BaseTrainer: - """Standard PyTorch training scaffold with checkpointing and early stopping.""" - - def __init__( - self, - model: nn.Module, - optimizer: torch.optim.Optimizer, - criterion: nn.Module, - device: str = "cpu", - checkpoint_dir: str = "artifacts/checkpoints", - patience: int = 5, - ): - self.model = model.to(device) - self.optimizer = optimizer - self.criterion = criterion - self.device = device - self.checkpoint_dir = checkpoint_dir - self.patience = patience - os.makedirs(checkpoint_dir, exist_ok=True) - - def train_epoch(self, dataloader: DataLoader) -> float: - self.model.train() - total_loss = 0.0 - for x, y in dataloader: - x, y = x.to(self.device), y.to(self.device) - self.optimizer.zero_grad() - out = self.model(x) - loss = self.criterion(out, y) - loss.backward() - self.optimizer.step() - total_loss += loss.item() - return total_loss / max(1, len(dataloader)) - - def evaluate(self, dataloader: DataLoader) -> float: - self.model.eval() - total_loss = 0.0 - with torch.no_grad(): - for x, y in dataloader: - x, y = x.to(self.device), y.to(self.device) - out = self.model(x) - loss = self.criterion(out, y) - total_loss += loss.item() - return total_loss / max(1, len(dataloader)) - - def save_checkpoint(self, filename: str): - path = os.path.join(self.checkpoint_dir, filename) - torch.save(self.model.state_dict(), path) diff --git a/ml/training/train_anomaly.py b/ml/training/train_anomaly.py deleted file mode 100644 index 4b9378f709d42fa7890406c1fef1a2a9adabeffa..0000000000000000000000000000000000000000 --- a/ml/training/train_anomaly.py +++ /dev/null @@ -1,415 +0,0 @@ -""" -TrackChain Master PatchCore Anomaly Detector Training Pipeline (tc.v1 SOTA). -Implements: -- Multi-scale feature extraction across Layer 2 and Layer 3 (fine cracks to macro anomalies) -- Fast Vectorized Greedy Minimax / K-Center Core-set subsampling (O(N) memory, PyTorch matrix acceleration) -- Statistical P99 calibration on normal track baseline -- Explicit True Positive Rate (TPR / Recall) and False Positive Rate (FPR) benchmarking with automatic defect dataset discovery -- Serialization to ModelRegistry checkpoints and calibration manifests -""" - -import argparse -import os -import json -import random -import time -from pathlib import Path -from typing import List, Tuple, Optional, Dict, Any -import numpy as np -import torch -import torch.nn as nn -import torch.nn.functional as F -from PIL import Image -import yaml -from sklearn.random_projection import SparseRandomProjection -from tqdm import tqdm - -try: - import matplotlib - matplotlib.use('Agg') - import matplotlib.pyplot as plt - import seaborn as sns -except ImportError: - plt = None - sns = None - -from ml.core.registry import ModelRegistry -from ml.models.vision.anomaly import PatchCoreAnomalyDetector, get_default_transform -from ml.calibration.patchcore_scale import SigmoidDistanceCalibrator - - -class ImageDataset(torch.utils.data.Dataset): - """Batched image dataset loader for fast PatchCore feature extraction.""" - - def __init__(self, image_paths: List[Path], transform=None): - self.image_paths = [Path(p) for p in image_paths] - self.transform = transform or get_default_transform(224) - - def __len__(self): - return len(self.image_paths) - - def __getitem__(self, idx): - img_path = self.image_paths[idx] - try: - pil_img = Image.open(img_path).convert("RGB") - return self.transform(pil_img) - except Exception: - return torch.zeros((3, 224, 224), dtype=torch.float32) - - -def fast_greedy_coreset_subsampling( - features: np.ndarray, - sampling_ratio: float = 0.08, - max_coreset_size: int = 3000, - max_candidate_pool: int = 40000, - random_seed: int = 42, -) -> np.ndarray: - """ - High-performance Vectorized Greedy Minimax / K-Center Core-set subsampling. - Accelerated with candidate pool filtering and vectorized squared distance updates. - """ - n_samples = len(features) - if n_samples == 0: - return features - - # Pre-filter candidate pool if excessively large (preserves manifold coverage without quadratic explosion) - np.random.seed(random_seed) - if n_samples > max_candidate_pool: - candidate_indices = np.random.choice(n_samples, max_candidate_pool, replace=False) - candidate_features = features[candidate_indices] - else: - candidate_features = features - - n_candidates = len(candidate_features) - target_count = max(10, min(max_coreset_size, int(n_candidates * sampling_ratio))) - - if target_count >= n_candidates: - return candidate_features - - print(f"[INFO] Running Accelerated Core-set selection ({n_candidates} candidates -> {target_count} representative patches)...") - - # Use PyTorch tensor on CPU/GPU for fast BLAS/LAPACK matrix operations - feat_tensor = torch.from_numpy(candidate_features).float() - selected_indices = [int(np.random.choice(n_candidates))] - - # Initial distances to first chosen center - first_center = feat_tensor[selected_indices[0]:selected_indices[0] + 1] - # Squared Euclidean distance: ||x - c||^2 - min_sq_dists = torch.sum((feat_tensor - first_center) ** 2, dim=1) - - pbar = tqdm(total=target_count, desc="Minimax Coreset Selection", unit="patch", leave=False) - pbar.update(1) - - step = 1 - while step < target_count: - new_idx = int(torch.argmax(min_sq_dists).item()) - selected_indices.append(new_idx) - - new_center = feat_tensor[new_idx:new_idx + 1] - new_sq_dists = torch.sum((feat_tensor - new_center) ** 2, dim=1) - min_sq_dists = torch.minimum(min_sq_dists, new_sq_dists) - - step += 1 - pbar.update(1) - - pbar.close() - return candidate_features[selected_indices] - - -# Backwards compatibility alias -greedy_coreset_subsampling = fast_greedy_coreset_subsampling - - -def find_defect_validation_images(abs_data_dir: Path, repo_root: Path) -> List[Path]: - """Search and locate defect validation images for TPR/FPR benchmark.""" - candidate_dirs = [ - abs_data_dir / "valid" / "defect", - abs_data_dir / "defect", - repo_root / "data" / "external" / "rail_normal_expanded" / "valid" / "defect", - repo_root / "data" / "external" / "rail_defects_expanded" / "valid" / "images", - repo_root / "data" / "external" / "rail_defects_synthetic" / "valid" / "images", - repo_root / "data" / "external" / "rail_defects" / "valid" / "images", - repo_root / "data" / "external" / "rail_defects_expanded" / "test" / "images", - repo_root / "data" / "external" / "rail_defects" / "test" / "images", - ] - - for c_dir in candidate_dirs: - if c_dir.exists(): - imgs = sorted(list(c_dir.glob("*.jpg")) + list(c_dir.glob("*.png")) + list(c_dir.glob("*.jpeg"))) - if len(imgs) >= 5: - return imgs - - return [] - - -def train_patchcore( - data_dir: str = "data/external/rail_normal_expanded", - config_path: str = "ml/configs/anomaly.yaml", - sampling_ratio: Optional[float] = None, - max_coreset_size: int = 3000, - batch_size: int = 32, - device: Optional[str] = "auto", - output_checkpoint: Optional[str] = None, - output_calibration: Optional[str] = None, - output_validation: Optional[str] = None, -) -> Tuple[str, str]: - """ - Full training pipeline for Enhanced PatchCore: - 1. Multi-scale feature extraction across Layer 2 and Layer 3. - 2. Fast Core-set selection. - 3. Build FAISS nearest-neighbor index. - 4. Compute P99 threshold on normal validation set and fit Sigmoid calibrator. - 5. Comprehensive Benchmark on defect validation set (Explicit TPR / FPR). - 6. Save memory bank (.npz) and calibration (.json). - """ - repo_root = ModelRegistry.ROOT - abs_data_dir = Path(data_dir) if Path(data_dir).is_absolute() else repo_root / data_dir - abs_config_path = Path(config_path) if Path(config_path).is_absolute() else repo_root / config_path - - # Fallback search for normal dataset - if not (abs_data_dir / "train" / "good").exists(): - for candidate in ["data/external/rail_normal_expanded", "data/external/rail_normal_only"]: - cand_p = repo_root / candidate - if (cand_p / "train" / "good").exists(): - abs_data_dir = cand_p - break - - if device in ["auto", None, ""]: - actual_device = "cuda" if torch.cuda.is_available() else "cpu" - else: - actual_device = "cuda" if device.startswith("cuda") or device == "0" else device - - # Load configuration - cfg = {} - if abs_config_path.exists(): - with open(abs_config_path, "r", encoding="utf-8") as f: - cfg = yaml.safe_load(f) or {} - - model_cfg = cfg.get("model", {}) - calib_cfg = cfg.get("calibration", {}) - - backbone_name = model_cfg.get("backbone", "wide_resnet50_2") - coreset_ratio = sampling_ratio or model_cfg.get("coreset_sampling_ratio", 0.08) - percentile = calib_cfg.get("threshold_percentile", 99.0) - sigmoid_k = calib_cfg.get("sigmoid_k", 0.5) - dim_reduction = model_cfg.get("dimension_reduction", False) - target_dim = model_cfg.get("target_dim", 128) - patch_size = model_cfg.get("patch_size", 3) - - print("=" * 75) - print("TrackChain — Enhanced PatchCore Visual Anomaly Detector (tc.v1 SOTA)") - print("=" * 75) - print(f"Backbone: {backbone_name}") - print(f"Normal Dataset: {abs_data_dir}") - print(f"Patch Size: {patch_size}x{patch_size} (Multi-scale Layer2 + Layer3)") - print(f"Coreset Ratio: {coreset_ratio:.1%} (Max Coreset: {max_coreset_size})") - print(f"Batch Size: {batch_size}") - print(f"Calib P99 Target:{percentile}%") - print(f"Compute Device: {actual_device}") - - train_good_dir = abs_data_dir / "train" / "good" - valid_good_dir = abs_data_dir / "valid" / "good" - - if not train_good_dir.exists(): - raise FileNotFoundError(f"Training normal images directory not found: {train_good_dir}") - - train_images = sorted(list(train_good_dir.glob("*.jpg")) + list(train_good_dir.glob("*.png"))) - valid_images = sorted(list(valid_good_dir.glob("*.jpg")) + list(valid_good_dir.glob("*.png"))) if valid_good_dir.exists() else [] - - if not train_images: - raise ValueError(f"No normal training images found in {train_good_dir}") - - print(f"\n[1/5] Loaded {len(train_images)} normal training images, {len(valid_images)} normal validation images.") - - # Initialize detector backbone with multi-scale Layer2 + Layer3 feature extraction - detector = PatchCoreAnomalyDetector( - backbone_name=backbone_name, - device=actual_device, - patch_size=patch_size, - ) - transform = get_default_transform(224) - - # 1. Multi-scale feature extraction across normal dataset - print("\n[2/5] Extracting multi-scale patch embeddings (Layer2 + Layer3) from normal track...") - all_patch_features: List[np.ndarray] = [] - - dataset = ImageDataset(train_images, transform=transform) - dataloader = torch.utils.data.DataLoader(dataset, batch_size=batch_size, shuffle=False, num_workers=0) - - start_extract = time.time() - with torch.no_grad(): - for batch in tqdm(dataloader, desc="Extracting normal patch embeddings"): - tensor = batch.to(detector.device) - feats, _ = detector.extract_features(tensor) - all_patch_features.append(feats.cpu().numpy().astype(np.float32)) - - raw_memory_bank = np.concatenate(all_patch_features, axis=0) - extract_time = time.time() - start_extract - print(f" Extracted {raw_memory_bank.shape[0]} raw multi-scale patch embeddings in {extract_time:.1f}s (Dim={raw_memory_bank.shape[1]}).") - - # Optional dimension reduction - projector = None - if dim_reduction and raw_memory_bank.shape[1] > target_dim: - print(f" Applying SparseRandomProjection ({raw_memory_bank.shape[1]} -> {target_dim})...") - projector = SparseRandomProjection(n_components=target_dim, random_state=42) - raw_memory_bank = projector.fit_transform(raw_memory_bank).astype(np.float32) - - # 2. Fast Core-set selection - print("\n[3/5] Performing Vectorized Minimax Core-set selection...") - start_coreset = time.time() - coreset_memory_bank = fast_greedy_coreset_subsampling( - raw_memory_bank, - sampling_ratio=coreset_ratio, - max_coreset_size=max_coreset_size, - ) - coreset_time = time.time() - start_coreset - print(f" Retained {coreset_memory_bank.shape[0]} representative patches in {coreset_time:.1f}s.") - - # Build FAISS nearest neighbor search index - detector.set_memory_bank(coreset_memory_bank, projector=projector) - - # 3. Calibration on normal validation set - print("\n[4/5] Establishing statistical P99 calibration threshold on normal validation track...") - valid_distances: List[float] = [] - val_pool = valid_images if valid_images else train_images[:min(60, len(train_images))] - - for v_path in tqdm(val_pool, desc="Evaluating normal validation track"): - try: - v_img = Image.open(v_path).convert("RGB") - dist, _ = detector.predict_raw(v_img) - valid_distances.append(float(dist)) - except Exception: - continue - - if not valid_distances: - valid_distances = [1.0] - - calibrator = SigmoidDistanceCalibrator(steepness_k=sigmoid_k, percentile=percentile) - p99_thresh = calibrator.fit(valid_distances, percentile=percentile) - print(f" Normal Baseline: Mean={np.mean(valid_distances):.2f}, Max={np.max(valid_distances):.2f}, P99 Threshold={p99_thresh:.2f}") - - # 4. Defect validation benchmark with explicit TPR / FPR logging - print("\n[5/5] Benchmarking on defect validation set (TPR / FPR analysis)...") - val_out_dir = Path(output_validation or "artifacts/validation/patchcore") - val_out_dir.mkdir(parents=True, exist_ok=True) - - defect_images = find_defect_validation_images(abs_data_dir, repo_root) - - validation_metrics: Dict[str, Any] = { - "p99_threshold": float(p99_thresh), - "normal_mean_dist": float(np.mean(valid_distances)), - "normal_max_dist": float(np.max(valid_distances)), - "num_normal_samples": len(valid_distances), - "memory_bank_patches": int(coreset_memory_bank.shape[0]), - "feature_dim": int(coreset_memory_bank.shape[1]), - "backbone": backbone_name, - } - - fpr = float(np.mean([n >= p99_thresh for n in valid_distances])) - validation_metrics["false_positive_rate"] = fpr - - if defect_images: - print(f" Found {len(defect_images)} defect validation samples for TPR benchmark.") - defect_distances: List[float] = [] - for d_path in tqdm(defect_images[:150], desc="Evaluating defect validation samples"): - try: - d_img = Image.open(d_path).convert("RGB") - dist, _ = detector.predict_raw(d_img) - defect_distances.append(float(dist)) - except Exception: - continue - - if defect_distances: - tpr = float(np.mean([d >= p99_thresh for d in defect_distances])) - validation_metrics["true_positive_rate"] = tpr - validation_metrics["defect_mean_dist"] = float(np.mean(defect_distances)) - validation_metrics["defect_min_dist"] = float(np.min(defect_distances)) - validation_metrics["num_defect_samples"] = len(defect_distances) - validation_metrics["separation_margin"] = float(np.mean(defect_distances) - np.mean(valid_distances)) - - print("\n" + "=" * 75) - print("PatchCore Anomaly Detector Benchmark Verification") - print("=" * 75) - print(f" 🎯 True Positive Rate (Recall): {tpr * 100:.1f}% ({len(defect_distances)} defect samples)") - print(f" 🛡️ False Positive Rate (FPR): {fpr * 100:.1f}% ({len(valid_distances)} normal samples)") - print(f" ⚖️ Threshold used (P99): {p99_thresh:.2f}") - print(f" 📊 Defect Mean Distance: {np.mean(defect_distances):.2f}") - print(f" 📊 Normal Mean Distance: {np.mean(valid_distances):.2f}") - print(f" 📈 Anomaly Separation Margin: {validation_metrics['separation_margin']:.2f}") - print("=" * 75) - - # Plot distribution if matplotlib available - if plt is not None and sns is not None: - try: - plt.figure(figsize=(9, 5)) - sns.kdeplot(valid_distances, label='Normal Track (Baseline)', color='green', fill=True, alpha=0.4) - sns.kdeplot(defect_distances, label='Defect Track (Anomalies)', color='red', fill=True, alpha=0.4) - plt.axvline(p99_thresh, color='black', linestyle='--', label=f'P99 Threshold ({p99_thresh:.2f})') - plt.xlabel('PatchCore Multi-Scale Nearest-Neighbor Distance') - plt.ylabel('Density') - plt.title(f'PatchCore Anomaly Separation (TPR={tpr:.1%}, FPR={fpr:.1%})') - plt.legend() - plot_path = val_out_dir / "patchcore_score_distribution.png" - plt.savefig(plot_path, dpi=300, bbox_inches='tight') - plt.close() - print(f" Saved score distribution plot: {plot_path}") - except Exception: - pass - else: - validation_metrics["true_positive_rate"] = 0.0 - print(" [WARN] No defect validation samples found. TPR could not be calculated.") - print(f" 🛡️ False Positive Rate (FPR): {fpr * 100:.1f}%") - print(f" ⚖️ Threshold used (P99): {p99_thresh:.2f}") - - with open(val_out_dir / "validation_metrics.json", "w", encoding="utf-8") as f: - json.dump(validation_metrics, f, indent=2) - - # 5. Save artifacts to canonical destinations - ckpt_path = Path(output_checkpoint) if output_checkpoint else ModelRegistry.get_trained_weights("vision", "patchcore_memory_bank.npz") - calib_path = Path(output_calibration) if output_calibration else ModelRegistry.get_calibration_path("patchcore") - - ckpt_path.parent.mkdir(parents=True, exist_ok=True) - calib_path.parent.mkdir(parents=True, exist_ok=True) - - detector.save_memory_bank(ckpt_path) - calibrator.save(calib_path) - - # Copy calibration to standard location - std_calib = repo_root / "artifacts" / "calibration" / "patchcore_calibration.json" - std_calib.parent.mkdir(parents=True, exist_ok=True) - calibrator.save(std_calib) - - print("\n" + "=" * 75) - print("Enhanced PatchCore Training Complete!") - print("=" * 75) - print(f"Memory Bank Checkpoint: {ckpt_path}") - print(f"Calibration JSON: {calib_path}") - print(f"Standard Calibration: {std_calib}") - print(f"Validation Metrics: {val_out_dir / 'validation_metrics.json'}") - print("=" * 75) - - return str(ckpt_path), str(calib_path) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Train Enhanced PatchCore Visual Anomaly Detector (tc.v1 SOTA).") - parser.add_argument("--data", "--data-path", dest="data", default="data/external/rail_normal_expanded", help="Path to normal dataset directory") - parser.add_argument("--config", default="ml/configs/anomaly.yaml", help="Path to anomaly.yaml") - parser.add_argument("--coreset_ratio", "--ratio", dest="coreset_ratio", type=float, default=None, help="Coreset subsampling ratio") - parser.add_argument("--max_coreset", type=int, default=3000, help="Maximum number of coreset representative patches") - parser.add_argument("--batch_size", "--batch", type=int, default=32, help="Feature extraction batch size") - parser.add_argument("--device", default="auto", help="Device ('auto', 'cuda', or 'cpu')") - parser.add_argument("--out-ckpt", default=None, help="Output memory bank path") - parser.add_argument("--out-calib", default=None, help="Output calibration JSON path") - args = parser.parse_args() - - train_patchcore( - data_dir=args.data, - config_path=args.config, - sampling_ratio=args.coreset_ratio, - max_coreset_size=args.max_coreset, - batch_size=args.batch_size, - device=args.device, - output_checkpoint=args.out_ckpt, - output_calibration=args.out_calib, - ) diff --git a/ml/training/train_detector.py b/ml/training/train_detector.py deleted file mode 100644 index f51c92fd365e9763c5303853118f555a121302a9..0000000000000000000000000000000000000000 --- a/ml/training/train_detector.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -ml/training/train_detector.py -Fine-tune the YOLOv8 defect detector on railway defect imagery (tc.v1 SOTA). -Full network fine-tuning without backbone freezing. -""" - -import sys -from pathlib import Path - -repo_root = Path(__file__).resolve().parent.parent.parent -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) - -from ml.scripts.train_detector import train_yolo_detector - -__all__ = ["train_yolo_detector"] - -if __name__ == "__main__": - import argparse - parser = argparse.ArgumentParser(description="Train YOLOv8 defect detector.") - parser.add_argument("--data", default="data/external/rail_defects_expanded/data.yaml", help="Path to data.yaml") - parser.add_argument("--config", default="ml/configs/detector.yaml", help="Path to detector.yaml") - parser.add_argument("--epochs", type=int, default=50, help="Number of training epochs") - parser.add_argument("--batch", type=int, default=16, help="Batch size") - parser.add_argument("--imgsz", type=int, default=640, help="Image resolution") - parser.add_argument("--freeze", type=int, default=None, help="Backbone freeze layers (default None)") - parser.add_argument("--dropout", type=float, default=0.0, help="Dropout rate") - parser.add_argument("--erasing", type=float, default=0.4, help="Random erasing rate") - parser.add_argument("--device", default="auto", help="Device ('auto', '0' for CUDA GPU, or 'cpu')") - parser.add_argument("--output-dir", default=None, help="Output directory for checkpoints") - parser.add_argument("--resume", action="store_true", help="Resume training") - args = parser.parse_args() - - train_yolo_detector( - data_yaml=args.data, - config_path=args.config, - epochs=args.epochs, - batch_size=args.batch, - img_size=args.imgsz, - freeze=args.freeze, - dropout=args.dropout, - erasing=args.erasing, - device=args.device, - output_dir=args.output_dir, - resume=args.resume, - ) diff --git a/ml/training/train_fault_classifier.py b/ml/training/train_fault_classifier.py deleted file mode 100644 index 432b8d5170f9f41c675677f56e4134cadba8b71c..0000000000000000000000000000000000000000 --- a/ml/training/train_fault_classifier.py +++ /dev/null @@ -1,216 +0,0 @@ -# Training Pipeline for TrackChain Bi-LSTM Sequence Classifier with Temporal Attention (tc.v1 SOTA). - -import os -import argparse -from typing import Dict, Any, Tuple, Optional, List -from pathlib import Path -import yaml -import numpy as np -import torch -import torch.nn as nn -import torch.optim as optim -from torch.optim.lr_scheduler import CosineAnnealingLR - -from ml.models.geometry.fault_classifier import BiLSTMGeometryClassifier -from ml.data.synthetic_geometry import create_synthetic_data_loaders, CLASS_MAP -from ml.calibration.temperature import TemperatureScaler - - -def train_fault_classifier( - epochs: int = 25, - batch_size: int = 32, - lr: float = 1e-3, - weight_decay: float = 1e-4, - label_smoothing: float = 0.1, - train_samples_per_class: int = 250, - val_samples_per_class: int = 50, - save_path: str = "ml/models/geometry/weights/fault_classifier.pt", - config_path: str = "ml/configs/fault_classifier.yaml", - device_name: str = "auto", -) -> Dict[str, Any]: - """ - Trains the Bi-LSTM sequence classifier on parametric EN 13848 track geometry data. - Fits post-hoc temperature scaling and saves production weights. - """ - # 1. Device Setup - if device_name == "auto": - device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - else: - device = torch.device(device_name) - print(f"[Train] Initializing training on device: {device}") - - # 2. Load Config if available - cfg_file = Path(config_path) - input_dim = 5 - hidden_dim = 64 - num_layers = 2 - dropout = 0.3 - num_classes = 6 - - if cfg_file.exists(): - with open(cfg_file, "r") as f: - cfg = yaml.safe_load(f) or {} - input_dim = int(cfg.get("input_dim", input_dim)) - hidden_dim = int(cfg.get("hidden_dim", hidden_dim)) - num_layers = int(cfg.get("num_layers", num_layers)) - dropout = float(cfg.get("dropout", dropout)) - num_classes = int(cfg.get("num_classes", num_classes)) - - # 3. Create Balanced DataLoaders - print(f"[Train] Generating parametric synthetic dataset (EN 13848-2 Grade 4 PSD)...") - train_loader, val_loader = create_synthetic_data_loaders( - train_samples_per_class=train_samples_per_class, - val_samples_per_class=val_samples_per_class, - batch_size=batch_size, - ) - print(f" Train batches: {len(train_loader)} ({train_samples_per_class * num_classes} windows)") - print(f" Val batches: {len(val_loader)} ({val_samples_per_class * num_classes} windows)") - - # 4. Model, Criterion, Optimizer & Scheduler - model = BiLSTMGeometryClassifier( - input_dim=input_dim, - hidden_dim=hidden_dim, - num_layers=num_layers, - num_classes=num_classes, - dropout=dropout, - ).to(device) - - criterion = nn.CrossEntropyLoss(label_smoothing=label_smoothing) - optimizer = optim.AdamW(model.parameters(), lr=lr, weight_decay=weight_decay) - scheduler = CosineAnnealingLR(optimizer, T_max=epochs, eta_min=1e-5) - - # 5. Training Loop - best_val_acc = 0.0 - history = {"train_loss": [], "train_acc": [], "val_loss": [], "val_acc": []} - - print(f"[Train] Starting optimization ({epochs} epochs, Label Smoothing={label_smoothing}, AdamW)...") - for epoch in range(1, epochs + 1): - model.train() - running_loss = 0.0 - correct = 0 - total = 0 - - for features, labels, _ in train_loader: - features = features.to(device) # [B, 80, 5] - labels = labels.to(device) # [B] - - optimizer.zero_grad() - logits = model(features) - loss = criterion(logits, labels) - loss.backward() - - # Gradient clipping for LSTM stability - torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=2.0) - optimizer.step() - - running_loss += loss.item() * features.size(0) - preds = torch.argmax(logits, dim=1) - correct += (preds == labels).sum().item() - total += labels.size(0) - - scheduler.step() - epoch_train_loss = running_loss / max(1, total) - epoch_train_acc = correct / max(1, total) - - # Validation Step - model.eval() - val_loss = 0.0 - val_correct = 0 - val_total = 0 - - with torch.no_grad(): - for features, labels, _ in val_loader: - features = features.to(device) - labels = labels.to(device) - logits = model(features) - loss = criterion(logits, labels) - val_loss += loss.item() * features.size(0) - preds = torch.argmax(logits, dim=1) - val_correct += (preds == labels).sum().item() - val_total += labels.size(0) - - epoch_val_loss = val_loss / max(1, val_total) - epoch_val_acc = val_correct / max(1, val_total) - - history["train_loss"].append(epoch_train_loss) - history["train_acc"].append(epoch_train_acc) - history["val_loss"].append(epoch_val_loss) - history["val_acc"].append(epoch_val_acc) - - if epoch % 5 == 0 or epoch == epochs or epoch == 1: - print( - f" Epoch [{epoch:02d}/{epochs:02d}] " - f"Train Loss: {epoch_train_loss:.4f} Acc: {epoch_train_acc * 100:.1f}% | " - f"Val Loss: {epoch_val_loss:.4f} Acc: {epoch_val_acc * 100:.1f}%" - ) - - # 6. Temperature Calibration on Validation Set - print("[Train] Calibrating network probabilities via Temperature Scaling (Platt)...") - model.eval() - val_logits_list = [] - val_labels_list = [] - - with torch.no_grad(): - for features, labels, _ in val_loader: - features = features.to(device) - logits = model(features) - val_logits_list.append(logits.cpu().numpy()) - val_labels_list.append(labels.cpu().numpy()) - - all_val_logits = np.concatenate(val_logits_list, axis=0) - all_val_labels = np.concatenate(val_labels_list, axis=0) - - scaler = TemperatureScaler() - learned_temp = scaler.fit(all_val_logits, all_val_labels) - print(f" Optimal Temperature (T): {learned_temp:.4f}") - - # 7. Save Model & Calibration Weights - save_p = Path(save_path) - save_p.parent.mkdir(parents=True, exist_ok=True) - - checkpoint = { - "model_state_dict": model.state_dict(), - "temperature": learned_temp, - "input_dim": input_dim, - "hidden_dim": hidden_dim, - "num_layers": num_layers, - "num_classes": num_classes, - "classes": CLASS_MAP, - "metrics": { - "final_train_acc": history["train_acc"][-1], - "final_val_acc": history["val_acc"][-1], - "final_val_loss": history["val_loss"][-1], - "optimal_temperature": learned_temp, - }, - } - torch.save(checkpoint, save_p) - print(f"[OK] Saved trained Bi-LSTM model checkpoint to: {save_p}") - - return { - "checkpoint_path": str(save_p), - "val_acc": history["val_acc"][-1], - "temperature": learned_temp, - "history": history, - } - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Train Bi-LSTM track geometry fault classifier.") - parser.add_argument("--epochs", type=int, default=25, help="Number of training epochs") - parser.add_argument("--batch-size", type=int, default=32, help="Batch size") - parser.add_argument("--lr", type=float, default=0.001, help="Learning rate") - parser.add_argument("--train-samples", type=int, default=250, help="Train samples per class") - parser.add_argument("--val-samples", type=int, default=50, help="Val samples per class") - parser.add_argument("--save-path", default="ml/models/geometry/weights/fault_classifier.pt", help="Save checkpoint path") - parser.add_argument("--config", default="ml/configs/fault_classifier.yaml", help="Configuration YAML path") - args = parser.parse_args() - - train_fault_classifier( - epochs=args.epochs, - batch_size=args.batch_size, - lr=args.lr, - train_samples_per_class=args.train_samples, - val_samples_per_class=args.val_samples, - save_path=args.save_path, - config_path=args.config, - ) diff --git a/ml/training/train_sequence_vae.py b/ml/training/train_sequence_vae.py deleted file mode 100644 index 5e00e95f54f1901d7749626a40e2f859cabdc94e..0000000000000000000000000000000000000000 --- a/ml/training/train_sequence_vae.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -ml/training/train_sequence_vae.py -Train the 1D-CNN Sequence VAE on normal track geometry sequences (tc.v1 SOTA). -""" - -from ml.scripts.train_sequence_vae import train_sequence_vae - -__all__ = ["train_sequence_vae"] - -if __name__ == "__main__": - import argparse - parser = argparse.ArgumentParser(description="Train 1D-CNN Sequence VAE for Novel Geometry.") - parser.add_argument("--epochs", type=int, default=25, help="Number of training epochs") - parser.add_argument("--beta", type=float, default=0.01, help="Beta-VAE KL weight") - parser.add_argument("--batch-size", type=int, default=64, help="Batch size") - parser.add_argument("--lr", type=float, default=0.001, help="Learning rate") - parser.add_argument("--save-path", default="artifacts/checkpoints/geometry/sequence_vae.pt") - args = parser.parse_args() - - train_sequence_vae( - epochs=args.epochs, - beta=args.beta, - batch_size=args.batch_size, - lr=args.lr, - save_path=args.save_path, - ) diff --git a/ml/utils/__init__.py b/ml/utils/__init__.py deleted file mode 100644 index 70af638ce14f0f9108112d7a88c8c480dfb12891..0000000000000000000000000000000000000000 --- a/ml/utils/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# Utils package exports (tc.v1 SOTA). - -from ml.utils.logging import get_ml_logger -from ml.utils.seeding import seed_everything -from ml.utils.io import load_yaml, save_yaml - -__all__ = [ - "get_ml_logger", - "seed_everything", - "load_yaml", - "save_yaml", -] diff --git a/ml/utils/io.py b/ml/utils/io.py deleted file mode 100644 index 12b36a36c4501c6b90eeb9a04981ecf2632d2f0a..0000000000000000000000000000000000000000 --- a/ml/utils/io.py +++ /dev/null @@ -1,16 +0,0 @@ -# Artifact and config read/write helpers. - -import os -from typing import Any, Dict -import yaml - - -def load_yaml(path: str) -> Dict[str, Any]: - with open(path, "r", encoding="utf-8") as f: - return yaml.safe_load(f) - - -def save_yaml(data: Dict[str, Any], path: str): - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - yaml.safe_dump(data, f, default_flow_style=False) diff --git a/ml/utils/logging.py b/ml/utils/logging.py deleted file mode 100644 index 7b4126d55f0a559039cea1468b73b12f5c709aeb..0000000000000000000000000000000000000000 --- a/ml/utils/logging.py +++ /dev/null @@ -1,18 +0,0 @@ -# ML logging helpers. - -import logging -import sys - - -def get_ml_logger(name: str = "trackchain_ml") -> logging.Logger: - logger = logging.getLogger(name) - if not logger.handlers: - logger.setLevel(logging.INFO) - handler = logging.StreamHandler(sys.stdout) - formatter = logging.Formatter( - "%(asctime)s [%(levelname)s] [%(name)s]: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - handler.setFormatter(formatter) - logger.addHandler(handler) - return logger diff --git a/ml/utils/seeding.py b/ml/utils/seeding.py deleted file mode 100644 index 8f2b71d7e3c9908324e06e4f4114a0e1fa1540fb..0000000000000000000000000000000000000000 --- a/ml/utils/seeding.py +++ /dev/null @@ -1,17 +0,0 @@ -# Deterministic seeding for reproducible runs. - -import os -import random -import numpy as np -import torch - - -def seed_everything(seed: int = 42): - random.seed(seed) - os.environ["PYTHONHASHSEED"] = str(seed) - np.random.seed(seed) - torch.manual_seed(seed) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(seed) - torch.backends.cudnn.deterministic = True - torch.backends.cudnn.benchmark = False