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