mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-11 23:32:17 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # crates/engine/Cargo.toml
This commit is contained in:
Generated
+346
-9
@@ -582,7 +582,7 @@ dependencies = [
|
||||
"oneshot",
|
||||
"pastey",
|
||||
"portable-atomic",
|
||||
"rand",
|
||||
"rand 0.10.3",
|
||||
"regress",
|
||||
"rustc-hash 2.1.3",
|
||||
"ryu-js",
|
||||
@@ -723,6 +723,12 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
@@ -786,6 +792,45 @@ dependencies = [
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "candle-core"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c15b675b80d994b2eadb20a4bbe434eabeb454eac3ee5e2b4cf6f147ee9be091"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"float8",
|
||||
"gemm",
|
||||
"half",
|
||||
"libm",
|
||||
"memmap2",
|
||||
"num-traits",
|
||||
"num_cpus",
|
||||
"rand 0.9.5",
|
||||
"rand_distr",
|
||||
"rayon",
|
||||
"safetensors",
|
||||
"thiserror 2.0.21",
|
||||
"yoke",
|
||||
"zip 7.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "candle-nn"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3045fa9e7aef8567d209a27d56b692f60b96f4d0569f4c3011f8ca6715c65e03"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"half",
|
||||
"libc",
|
||||
"num-traits",
|
||||
"rayon",
|
||||
"safetensors",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
@@ -839,7 +884,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1141,7 +1186,7 @@ dependencies = [
|
||||
"verifysign",
|
||||
"winreg",
|
||||
"winsafe",
|
||||
"zip",
|
||||
"zip 9.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1310,6 +1355,22 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-stack"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dyn-stack-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dyn-stack-macros"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9"
|
||||
|
||||
[[package]]
|
||||
name = "dynify"
|
||||
version = "0.1.2"
|
||||
@@ -1394,7 +1455,10 @@ dependencies = [
|
||||
"effectcraft-gpu",
|
||||
"effectcraft-host",
|
||||
"effectcraft-render",
|
||||
"effectcraft-sam2",
|
||||
"effectcraft-segment",
|
||||
"effectcraft-time",
|
||||
"effectcraft-track",
|
||||
"effectcraft-ui-egui",
|
||||
"image",
|
||||
"serde_json",
|
||||
@@ -1445,6 +1509,7 @@ dependencies = [
|
||||
"effectcraft-psd",
|
||||
"effectcraft-raster",
|
||||
"effectcraft-render",
|
||||
"effectcraft-sam2",
|
||||
"effectcraft-segment",
|
||||
"effectcraft-svg",
|
||||
"effectcraft-text",
|
||||
@@ -1764,6 +1829,21 @@ dependencies = [
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-sam2"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
"effectcraft-segment",
|
||||
"half",
|
||||
"log",
|
||||
"pollster 1.0.1",
|
||||
"rayon",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-script"
|
||||
version = "0.18.0"
|
||||
@@ -1852,6 +1932,7 @@ dependencies = [
|
||||
"effectcraft-path",
|
||||
"effectcraft-project",
|
||||
"effectcraft-render",
|
||||
"effectcraft-segment",
|
||||
"effectcraft-text",
|
||||
"effectcraft-time",
|
||||
"eframe",
|
||||
@@ -2060,6 +2141,18 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-as-inner"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -2585,6 +2678,18 @@ dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "float8"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "719a903cc23e4a89e87962c2a80fdb45cdaad0983a89bd150bb57b4c8571a7d5"
|
||||
dependencies = [
|
||||
"half",
|
||||
"num-traits",
|
||||
"rand 0.9.5",
|
||||
"rand_distr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fmv-macos-events"
|
||||
version = "0.1.0"
|
||||
@@ -2727,6 +2832,125 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"gemm-c32",
|
||||
"gemm-c64",
|
||||
"gemm-common",
|
||||
"gemm-f16",
|
||||
"gemm-f32",
|
||||
"gemm-f64",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-c32"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"gemm-common",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-c64"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"gemm-common",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-common"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dyn-stack",
|
||||
"half",
|
||||
"libm",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"paste",
|
||||
"pulp",
|
||||
"raw-cpuid",
|
||||
"rayon",
|
||||
"seq-macro",
|
||||
"sysctl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-f16"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"gemm-common",
|
||||
"gemm-f32",
|
||||
"half",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"rayon",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-f32"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"gemm-common",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemm-f64"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a"
|
||||
dependencies = [
|
||||
"dyn-stack",
|
||||
"gemm-common",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"raw-cpuid",
|
||||
"seq-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -2780,7 +3004,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -2928,9 +3152,12 @@ version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"num-traits",
|
||||
"rand 0.9.5",
|
||||
"rand_distr",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
@@ -2970,6 +3197,8 @@ dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3605,6 +3834,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3856,6 +4086,16 @@ dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_cpus"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum"
|
||||
version = "0.7.6"
|
||||
@@ -4295,7 +4535,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.45.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4564,6 +4804,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "presser"
|
||||
version = "0.3.1"
|
||||
@@ -4606,7 +4855,7 @@ dependencies = [
|
||||
"chacha20",
|
||||
"core_detect",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand 0.10.3",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
@@ -4694,6 +4943,16 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
@@ -4702,7 +4961,26 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4711,13 +4989,23 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_distr"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"rand 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5039,6 +5327,17 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714"
|
||||
|
||||
[[package]]
|
||||
name = "safetensors"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -5114,6 +5413,12 @@ version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
|
||||
|
||||
[[package]]
|
||||
name = "seq-macro"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
@@ -5641,6 +5946,20 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysctl"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"byteorder",
|
||||
"enum-as-inner",
|
||||
"libc",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tag_ptr"
|
||||
version = "0.1.0"
|
||||
@@ -5932,6 +6251,12 @@ dependencies = [
|
||||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.13.0"
|
||||
@@ -7470,6 +7795,18 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "7.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"typed-path 0.12.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.1.3"
|
||||
@@ -7482,7 +7819,7 @@ dependencies = [
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"nix",
|
||||
"typed-path",
|
||||
"typed-path 0.13.0",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
@@ -28,6 +28,7 @@ effectcraft-text = { path = "crates/text" }
|
||||
effectcraft-effects = { path = "crates/effects" }
|
||||
effectcraft-track = { path = "crates/track" }
|
||||
effectcraft-segment = { path = "crates/segment" }
|
||||
effectcraft-sam2 = { path = "crates/sam2" }
|
||||
effectcraft-model = { path = "crates/model" }
|
||||
effectcraft-render = { path = "crates/render" }
|
||||
effectcraft-gpu = { path = "crates/gpu" }
|
||||
|
||||
@@ -20,6 +20,9 @@ effectcraft-ui-egui = { workspace = true }
|
||||
effectcraft-render = { workspace = true }
|
||||
effectcraft-gpu = { workspace = true }
|
||||
effectcraft-time = { workspace = true }
|
||||
effectcraft-track = { workspace = true }
|
||||
effectcraft-segment = { workspace = true }
|
||||
effectcraft-sam2 = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
image = { workspace = true }
|
||||
cpu-time = "1"
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
//! (--gpu: CPU vs GPU ms/frame for every comp at Full and Half)
|
||||
//! effectcraft-cli bench --ops [--small] [--layers N] [--comps N] [--footage N] everyday-operation timings
|
||||
//! on a large generated project (open, save, auto-save, undo/redo, timeline, Project panel)
|
||||
//! effectcraft-cli bench --roto [--frames N] [--sam2 model.safetensors] [--mobilesam mobile_sam.pt] [--fixes] [--clip FRAMES --truth MASKS] [--json] Roto Brush trackers on synthetic clips with known mattes
|
||||
//! (classical engine, MobileSAM and SAM 2.1 (any installed size) when installed; IoU, flicker, re-appearance, seconds per frame)
|
||||
//! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script
|
||||
//! effectcraft-cli mcp [--autosave | --bridge PORT] MCP server on stdio
|
||||
//!
|
||||
@@ -124,6 +126,9 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
||||
bench --ops [--small] [--layers N] [--comps N] [--footage N]
|
||||
everyday operations on a large generated project: startup, open,
|
||||
save, auto-save, edits + undo/redo, timeline, Project panel
|
||||
bench --roto [--frames N] [--sam2 FILE] [--mobilesam FILE] [--fixes] [--clip FRAMES_DIR --truth MASKS_DIR] [--json]
|
||||
Roto Brush trackers on synthetic clips with known mattes: movement,
|
||||
lighting change, occlusion, leaving and re-entering the frame
|
||||
script FILE.jsx [F.ecproj] | --eval CODE run JavaScript with the After Effects-style object model
|
||||
(app.project, comps, layers, properties…); prints writeLn
|
||||
output and the result; errors exit 1 with file:line:col
|
||||
@@ -137,6 +142,11 @@ options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge
|
||||
/// Options that take a value.
|
||||
const VALUED: &[&str] = &[
|
||||
"--n",
|
||||
"--clip",
|
||||
"--truth",
|
||||
"--frames",
|
||||
"--sam2",
|
||||
"--mobilesam",
|
||||
"--play",
|
||||
"--layers",
|
||||
"--comps",
|
||||
@@ -187,6 +197,7 @@ const VALUED: &[&str] = &[
|
||||
/// Options without a value. Any other `--option` is a usage error.
|
||||
const FLAGS: &[&str] = &[
|
||||
"--json",
|
||||
"--fixes",
|
||||
"--save",
|
||||
"--demo",
|
||||
"--empty",
|
||||
@@ -199,6 +210,7 @@ const FLAGS: &[&str] = &[
|
||||
"--work-area",
|
||||
"--queue",
|
||||
"--ops",
|
||||
"--roto",
|
||||
"--small",
|
||||
"--adv3d",
|
||||
"--autosave",
|
||||
@@ -975,6 +987,9 @@ fn bench_cmd(args: &Args) -> Result<(), Failure> {
|
||||
if args.flag("--ops") {
|
||||
return bench_ops(args);
|
||||
}
|
||||
if args.flag("--roto") {
|
||||
return bench_roto(args);
|
||||
}
|
||||
let mut s = effectcraft_host::session();
|
||||
match &args.project {
|
||||
Some(p) => s.execute("file.open", json!({"path": p})),
|
||||
@@ -997,6 +1012,117 @@ fn bench_cmd(args: &Args) -> Result<(), Failure> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The model a bench option asks for: the file named on the command line, where failing to read or
|
||||
/// build it is an error saying why (not "not installed"); else the installed file, where a file that
|
||||
/// cannot be used just leaves the model out.
|
||||
fn bench_weights<T>(
|
||||
flag: &str,
|
||||
named: Option<&str>,
|
||||
installed: Option<std::path::PathBuf>,
|
||||
build: impl Fn(&[u8]) -> Result<T, String>,
|
||||
) -> Result<Option<T>, Failure> {
|
||||
match named {
|
||||
Some(path) => {
|
||||
let bytes = std::fs::read(path).map_err(|e| Failure::Error(format!("{flag} {path}: {e}")))?;
|
||||
build(&bytes).map(Some).map_err(|e| Failure::Error(format!("{flag} {path}: {e}")))
|
||||
}
|
||||
None => Ok(installed.and_then(|p| std::fs::read(p).ok()).and_then(|bytes| build(&bytes).ok())),
|
||||
}
|
||||
}
|
||||
|
||||
/// `bench --roto`: the Roto Brush's trackers on synthetic clips whose true mattes are known.
|
||||
fn bench_roto(args: &Args) -> Result<(), Failure> {
|
||||
use effectcraft_segment::{Loaded, MOBILE_SAM, MaskModel};
|
||||
use effectcraft_track::roto::bench;
|
||||
let frames = args.num("--frames")?.unwrap_or(24.0).clamp(1.0, 240.0) as usize;
|
||||
let session = effectcraft_host::session();
|
||||
// `--mobilesam FILE` uses the weights at FILE (a file that cannot be used is an error);
|
||||
// otherwise the installed ones.
|
||||
let mobilesam: Option<std::sync::Arc<dyn MaskModel>> = bench_weights("--mobilesam", args.opt("--mobilesam"), session.model_path(&MOBILE_SAM), |bytes| {
|
||||
match effectcraft_segment::load(MOBILE_SAM.id, bytes)? {
|
||||
Loaded::Mask(m) => Ok(m),
|
||||
_ => Err("not a mask model".to_string()),
|
||||
}
|
||||
})?;
|
||||
if mobilesam.is_none() {
|
||||
note!("bench --roto: MobileSAM is not installed; comparing the classical engine only");
|
||||
}
|
||||
// `--sam2 FILE` uses the weights at FILE (a file that cannot be used is an error); otherwise
|
||||
// the installed ones.
|
||||
let installed_sam2 = effectcraft_sam2::mask::SIZES.iter().find_map(|m| session.model_path(m));
|
||||
let sam2: Option<std::sync::Arc<dyn MaskModel>> =
|
||||
bench_weights("--sam2", args.opt("--sam2"), installed_sam2, |bytes| effectcraft_sam2::mask::load(bytes).map(|m| m as std::sync::Arc<dyn MaskModel>))?;
|
||||
if sam2.is_none() {
|
||||
note!("bench --roto: no SAM 2.1 size is installed; no SAM 2 column");
|
||||
}
|
||||
// `--fixes`: count correction rounds before every frame reaches IoU 0.9 (up to 6 rounds, each up to two strokes).
|
||||
let fixes = args.flag("--fixes");
|
||||
let measure = |sc: &bench::Scenario, run: &dyn Fn(&bench::Scenario) -> Option<bench::Run>| if fixes { bench::with_fixes(sc, 0.9, 6, run) } else { run(sc) };
|
||||
let mut runs = vec![];
|
||||
// User clips pair unique filename stems, allowing JPEG frames with PNG truth masks.
|
||||
let scenarios = if let Some(dir) = args.opt("--clip") {
|
||||
let truth = args.opt("--truth").ok_or_else(|| Failure::Error("--clip needs --truth MASKS_DIR".into()))?;
|
||||
let name = std::path::Path::new(dir).file_name().map_or_else(|| "clip".to_string(), |n| n.to_string_lossy().chars().take(10).collect());
|
||||
vec![load_bench_clip(&name, dir, truth, frames).map_err(Failure::Error)?]
|
||||
} else {
|
||||
bench::scenarios(frames)
|
||||
};
|
||||
for sc in scenarios {
|
||||
runs.extend(measure(&sc, &|s| Some(bench::run_flow(s, "classical", None))));
|
||||
if let Some(m) = &mobilesam {
|
||||
runs.extend(measure(&sc, &|s| Some(bench::run_flow(s, "mobilesam", Some(m.as_ref())))));
|
||||
}
|
||||
if let Some(m) = &sam2 {
|
||||
let label = m.info().id.replacen("sam2.1-hiera-", "sam2-", 1);
|
||||
runs.extend(measure(&sc, &|s| bench::run_tracker(s, &label, m.as_ref())));
|
||||
}
|
||||
}
|
||||
if args.flag("--json") {
|
||||
say!("{}", serde_json::to_string_pretty(&runs).map_err(|e| Failure::Error(e.to_string()))?);
|
||||
} else {
|
||||
say!("{}", bench::table(&runs));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The image files of `dir` (png, jpg, jpeg), ordered by stem then full file name.
|
||||
fn image_files(dir: &str) -> Result<Vec<std::path::PathBuf>, String> {
|
||||
let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
|
||||
.map_err(|e| format!("cannot read {dir}: {e}"))?
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["png", "jpg", "jpeg"].contains(&e.to_ascii_lowercase().as_str())))
|
||||
.collect();
|
||||
files.sort_by(|a, b| a.file_stem().cmp(&b.file_stem()).then_with(|| a.file_name().cmp(&b.file_name())));
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// A benchmark scenario from the user's frames and truth masks (at most `limit` frames, frames at most 480 px
|
||||
/// wide; a mask pixel above 127 is the object). Nothing is copied or kept.
|
||||
fn load_bench_clip(name: &str, frames_dir: &str, truth_dir: &str, limit: usize) -> Result<effectcraft_track::roto::bench::Scenario, String> {
|
||||
let (frames, masks) = (image_files(frames_dir)?, image_files(truth_dir)?);
|
||||
if frames.is_empty() || frames.len() != masks.len() {
|
||||
return Err(format!("{} frames in {frames_dir} but {} masks in {truth_dir}: they must match one to one", frames.len(), masks.len()));
|
||||
}
|
||||
if frames.iter().map(|p| p.file_stem()).ne(masks.iter().map(|p| p.file_stem()))
|
||||
|| frames.windows(2).chain(masks.windows(2)).any(|pair| pair.first().and_then(|p| p.file_stem()) == pair.get(1).and_then(|p| p.file_stem()))
|
||||
{
|
||||
return Err("frame and truth file names must match one to one, with unique stems (extensions may differ)".into());
|
||||
}
|
||||
let n = frames.len().min(limit.clamp(1, 240));
|
||||
let (mut imgs, mut truth) = (vec![], vec![]);
|
||||
for (f, m) in frames.iter().zip(&masks).take(n) {
|
||||
let rgba = image::open(f).map_err(|e| format!("{}: {e}", f.display()))?.to_rgba8();
|
||||
let scale = (480.0 / f64::from(rgba.width().max(1))).min(1.0);
|
||||
let (w, h) = (((f64::from(rgba.width()) * scale).round() as u32).max(1), ((f64::from(rgba.height()) * scale).round() as u32).max(1));
|
||||
let rgba = if (w, h) == rgba.dimensions() { rgba } else { image::imageops::resize(&rgba, w, h, image::imageops::FilterType::Triangle) };
|
||||
let mask = image::open(m).map_err(|e| format!("{}: {e}", m.display()))?.to_luma8();
|
||||
let mask = image::imageops::resize(&mask, w, h, image::imageops::FilterType::Nearest);
|
||||
imgs.push(effectcraft_render::Image::from_rgba8(w, h, rgba.as_raw()));
|
||||
truth.push(mask.as_raw().iter().map(|v| u8::from(*v > 127)).collect());
|
||||
}
|
||||
effectcraft_track::roto::bench::scenario_from(name, imgs, truth)
|
||||
}
|
||||
|
||||
/// `bench --ops`: everyday operations on a large generated project (200 comps, 5,000 layers in
|
||||
/// the main comp, 300 footage items, 20-deep precomp nesting, expressions): startup to first
|
||||
/// frame, open, save, auto-save, small and large edits with undo/redo, first frame, timeline
|
||||
|
||||
@@ -739,6 +739,113 @@ fn render_bitrate_applies_to_vp9_webm() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// `bench --roto --clip FRAMES --truth MASKS` runs the trackers on the user's own image sequences: here a bright disc
|
||||
/// drifting over a dark textured frame, with its true masks. Mismatched sequences are refused with a message.
|
||||
#[test]
|
||||
fn bench_roto_runs_on_a_users_clip() {
|
||||
let (frames, masks) = (tmp("clip-frames"), tmp("clip-masks"));
|
||||
let config = frames.join("config");
|
||||
for d in [&frames, &masks] {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
let (w, h) = (96u32, 72u32);
|
||||
for f in 0..14u32 {
|
||||
let (cx, cy) = (30.0 + 4.0 * f as f32, 36.0f32);
|
||||
let (mut img, mut mask) = (image::RgbImage::new(w, h), image::GrayImage::new(w, h));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let inside = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt() < 14.0;
|
||||
let tex = 40 + ((x * 7 + y * 13) % 30) as u8;
|
||||
img.put_pixel(x, y, if inside { image::Rgb([230, 120, 40 + (x % 20) as u8]) } else { image::Rgb([tex / 2, tex, tex + 20]) });
|
||||
mask.put_pixel(x, y, image::Luma([if inside { 255 } else { 0 }]));
|
||||
}
|
||||
}
|
||||
let extension = if f == 0 { "jpg" } else { "png" };
|
||||
let stem = match f {
|
||||
0 => "a".into(),
|
||||
1 => "a.jpg".into(),
|
||||
_ => format!("f{f:03}"),
|
||||
};
|
||||
img.save(frames.join(format!("{stem}.{extension}"))).unwrap();
|
||||
mask.save(masks.join(format!("{stem}.png"))).unwrap();
|
||||
}
|
||||
let (f, m) = (frames.to_str().unwrap(), masks.to_str().unwrap());
|
||||
let out = bin().env("EFFECTCRAFT_CONFIG_DIR", &config).args(["bench", "--roto", "--clip", f, "--truth", m, "--frames", "6", "--json"]).output().unwrap();
|
||||
let text = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
assert!(out.status.success(), "{text}\n{}", String::from_utf8_lossy(&out.stderr));
|
||||
let rows: Value = serde_json::from_str(&text).unwrap();
|
||||
let row = rows.as_array().unwrap().iter().find(|row| row["tracker"] == "classical").unwrap();
|
||||
assert_eq!(row["per_frame"].as_array().unwrap().len(), 6, "a user clip respects the requested frame limit");
|
||||
let mean = row["mean_iou"].as_f64().unwrap();
|
||||
assert!(mean > 0.8, "classical mean IoU on the user's clip: {mean}\n{text}");
|
||||
// one mask short: refused
|
||||
std::fs::remove_file(masks.join("f005.png")).unwrap();
|
||||
let out = bin().env("EFFECTCRAFT_CONFIG_DIR", &config).args(["bench", "--roto", "--clip", f, "--truth", m]).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("must match"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
// --clip without --truth
|
||||
let out = bin().env("EFFECTCRAFT_CONFIG_DIR", &config).args(["bench", "--roto", "--clip", f]).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bench_roto_rejects_mismatched_names_before_loading_images() {
|
||||
let root = tmp("bench-clip-names");
|
||||
let (frames, masks) = (root.join("frames"), root.join("masks"));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
for dir in [&frames, &masks] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join("f000.png"), []).unwrap();
|
||||
}
|
||||
std::fs::write(frames.join("f001.png"), []).unwrap();
|
||||
std::fs::write(masks.join("f002.png"), []).unwrap();
|
||||
let out = bin()
|
||||
.env("EFFECTCRAFT_CONFIG_DIR", root.join("config"))
|
||||
.args(["bench", "--roto", "--frames", "1", "--clip"])
|
||||
.arg(&frames)
|
||||
.arg("--truth")
|
||||
.arg(&masks)
|
||||
.output()
|
||||
.unwrap();
|
||||
let error = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success());
|
||||
assert!(error.contains("names must match"), "validate the entire name set before decoding: {error}");
|
||||
std::fs::remove_file(frames.join("f001.png")).unwrap();
|
||||
std::fs::remove_file(masks.join("f002.png")).unwrap();
|
||||
for dir in [&frames, &masks] {
|
||||
std::fs::write(dir.join("f000.jpg"), []).unwrap();
|
||||
}
|
||||
let out =
|
||||
bin().env("EFFECTCRAFT_CONFIG_DIR", root.join("config")).args(["bench", "--roto", "--clip"]).arg(&frames).arg("--truth").arg(&masks).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("unique stems"));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// `bench --roto --sam2 FILE` / `--mobilesam FILE` say why a file named on the command line cannot be
|
||||
/// used (a missing file, one that is not the published weights), instead of "not installed".
|
||||
#[test]
|
||||
fn bench_roto_reports_why_a_named_model_file_failed() {
|
||||
let dir = tmp("bench-weights");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let bogus = dir.join("bogus.bin");
|
||||
std::fs::write(&bogus, b"not the weights").unwrap();
|
||||
for flag in ["--sam2", "--mobilesam"] {
|
||||
let missing = dir.join("missing.bin");
|
||||
let out = bin().env("EFFECTCRAFT_CONFIG_DIR", dir.join("config")).args(["bench", "--roto", flag, missing.to_str().unwrap()]).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(!out.status.success(), "{flag}: {err}");
|
||||
assert!(err.contains(flag) && err.contains("missing.bin"), "{flag} names the file that could not be read: {err}");
|
||||
let out = bin().env("EFFECTCRAFT_CONFIG_DIR", dir.join("config")).args(["bench", "--roto", flag, bogus.to_str().unwrap()]).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(!out.status.success(), "{flag}: {err}");
|
||||
assert!(err.contains("does not match"), "{flag} says the file is not the published weights: {err}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Render uses the same empty/project/demo selection as the other CLI commands.
|
||||
#[test]
|
||||
fn render_empty_does_not_load_the_demo() {
|
||||
|
||||
@@ -128,7 +128,8 @@ pub fn effects_markdown() -> String {
|
||||
s.push_str("# Effects\n\n");
|
||||
s.push_str("<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.\n");
|
||||
s.push_str(" Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->\n\n");
|
||||
let params: usize = r.iter().map(|e| e.params.len()).sum();
|
||||
// The parameters the table's rows list: the shown ones, not the hidden bookkeeping ones.
|
||||
let params: usize = r.iter().map(|e| e.params.iter().filter(|p| !matches!(p.ui, effectcraft_project::ParamUi::Hidden)).count()).sum();
|
||||
let gpu = r.iter().filter(|e| e.gpu).count();
|
||||
let float = r.iter().filter(|e| e.float).count();
|
||||
let partial = r.iter().filter(|e| PARTIAL.iter().any(|(id, _)| *id == e.id)).count();
|
||||
|
||||
@@ -765,7 +765,13 @@ not a stroke",
|
||||
let data = effectcraft_track::roto::RotoData {
|
||||
base: Some(0),
|
||||
span: [0, 10],
|
||||
strokes: vec![effectcraft_track::roto::Stroke { kind: effectcraft_track::roto::StrokeKind::Fg, frame: 0, radius: 5.0, points: vec![[2.0, 2.0]] }],
|
||||
strokes: vec![effectcraft_track::roto::Stroke {
|
||||
kind: effectcraft_track::roto::StrokeKind::Fg,
|
||||
frame: 0,
|
||||
radius: 5.0,
|
||||
points: vec![[2.0, 2.0]],
|
||||
object: 0,
|
||||
}],
|
||||
};
|
||||
roto.get_mut(crate::roto::STROKES).unwrap().value = Value::Str(data.to_json());
|
||||
to_effect_space(spec, &mut roto, SIZE, D, &own);
|
||||
|
||||
@@ -144,7 +144,7 @@ pub fn prompt_strokes(params: &Params, layer_size: [f64; 2], frame: i64, with_mo
|
||||
if !inside(pt) {
|
||||
return None;
|
||||
}
|
||||
let mut out = vec![Stroke { kind: StrokeKind::Fg, frame, radius: POINT_RADIUS, points: vec![pt] }];
|
||||
let mut out = vec![Stroke { kind: StrokeKind::Fg, frame, radius: POINT_RADIUS, points: vec![pt], object: 0 }];
|
||||
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]];
|
||||
let boxed = b(params, "useBox").then(|| Some((v2(params, "boxTopLeft")?, v2(params, "boxBottomRight")?))).flatten();
|
||||
match boxed {
|
||||
@@ -158,9 +158,9 @@ pub fn prompt_strokes(params: &Params, layer_size: [f64; 2], frame: i64, with_mo
|
||||
}
|
||||
out.push(if with_model {
|
||||
// The model takes the box itself as its box prompt.
|
||||
Stroke { kind: StrokeKind::Box, frame, radius: 0.0, points: vec![[x0, y0], [x1, y1]] }
|
||||
Stroke { kind: StrokeKind::Box, frame, radius: 0.0, points: vec![[x0, y0], [x1, y1]], object: 0 }
|
||||
} else {
|
||||
Stroke { kind: StrokeKind::Bg, frame, radius: EDGE_RADIUS, points: rect(x0, y0, x1, y1) }
|
||||
Stroke { kind: StrokeKind::Bg, frame, radius: EDGE_RADIUS, points: rect(x0, y0, x1, y1), object: 0 }
|
||||
});
|
||||
}
|
||||
None if !with_model => {
|
||||
@@ -168,7 +168,7 @@ pub fn prompt_strokes(params: &Params, layer_size: [f64; 2], frame: i64, with_mo
|
||||
if w <= 4.0 * i || h <= 4.0 * i {
|
||||
return None;
|
||||
}
|
||||
out.push(Stroke { kind: StrokeKind::Bg, frame, radius: EDGE_RADIUS, points: rect(i, i, w - i, h - i) });
|
||||
out.push(Stroke { kind: StrokeKind::Bg, frame, radius: EDGE_RADIUS, points: rect(i, i, w - i, h - i), object: 0 });
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
@@ -227,7 +227,7 @@ pub fn job_frames(params: &Params, limits: [i64; 2]) -> Vec<i64> {
|
||||
pub fn chain(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, scale: f64, model: Option<Arc<dyn MaskModel>>) -> Option<Chain> {
|
||||
let data = roto_data(params, layer_size, f, model.is_some())?;
|
||||
let keys = data.chain_keys(seed(params, layer_size, fps, model.as_deref()));
|
||||
Some(Chain { data: Arc::new(data), keys, opts: seg_opts(scale), refine_radius: 0.0, model })
|
||||
Some(Chain { data: Arc::new(data), keys, opts: seg_opts(scale), refine_radius: 0.0, model, saved: None, show_object: 0 })
|
||||
}
|
||||
|
||||
/// Frame `f`'s segmentation at full resolution, computing what is missing (the background
|
||||
|
||||
+1016
-13
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
//! Bounded assembly of the objects a tracker reports separately for each frame.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::mem::size_of;
|
||||
|
||||
use effectcraft_segment::TrackedFrame;
|
||||
|
||||
/// Separate from the model's memory banks: probability maps are full-resolution and can otherwise
|
||||
/// grow with the entire clip when objects start on different frames.
|
||||
pub(super) const MAX_PENDING_BYTES: usize = 256 << 20;
|
||||
|
||||
pub(super) struct PendingMasks {
|
||||
frames: BTreeMap<i64, Vec<Option<TrackedFrame>>>,
|
||||
objects: usize,
|
||||
bytes: usize,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
impl PendingMasks {
|
||||
pub(super) fn new(objects: usize, limit: usize) -> Self {
|
||||
Self { frames: BTreeMap::new(), objects, bytes: 0, limit }
|
||||
}
|
||||
|
||||
/// Probability allocation plus a conservative allowance for the map node and object slots.
|
||||
fn frame_bytes(&self) -> usize {
|
||||
self.objects.saturating_mul(size_of::<Option<TrackedFrame>>()).saturating_add(256)
|
||||
}
|
||||
|
||||
fn mask_bytes(mask: &TrackedFrame) -> usize {
|
||||
mask.prob.capacity().saturating_mul(size_of::<f32>())
|
||||
}
|
||||
|
||||
/// Complete frames leave immediately; incomplete frames may never exceed the budget. A rejected
|
||||
/// insertion leaves the buffer unchanged, including when it replaces an object's previous result.
|
||||
pub(super) fn insert(&mut self, object: usize, frame: i64, mask: TrackedFrame) -> Result<Option<Vec<Option<TrackedFrame>>>, String> {
|
||||
if object >= self.objects {
|
||||
return Err("tracker reported an unknown object".into());
|
||||
}
|
||||
if self.objects == 1 {
|
||||
return Ok(Some(vec![Some(mask)]));
|
||||
}
|
||||
let existing = self.frames.get(&frame);
|
||||
let old_bytes = existing.and_then(|p| p.get(object)).and_then(Option::as_ref).map_or(0, Self::mask_bytes);
|
||||
let added = Self::mask_bytes(&mask).saturating_add(if existing.is_none() { self.frame_bytes() } else { 0 });
|
||||
let bytes = self.bytes.saturating_sub(old_bytes).checked_add(added);
|
||||
let Some(bytes) = bytes.filter(|n| *n <= self.limit) else {
|
||||
return Err(format!("pending object masks exceed their {} MiB memory budget", self.limit >> 20));
|
||||
};
|
||||
let parts = self.frames.entry(frame).or_insert_with(|| vec![None; self.objects]);
|
||||
if let Some(slot) = parts.get_mut(object) {
|
||||
*slot = Some(mask);
|
||||
}
|
||||
self.bytes = bytes;
|
||||
if parts.iter().all(Option::is_some) {
|
||||
let parts = self.frames.remove(&frame).unwrap_or_default();
|
||||
let used = parts.iter().flatten().fold(self.frame_bytes(), |n, m| n.saturating_add(Self::mask_bytes(m)));
|
||||
self.bytes = self.bytes.saturating_sub(used);
|
||||
return Ok(Some(parts));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(super) fn into_incomplete(self) -> BTreeMap<i64, Vec<Option<TrackedFrame>>> {
|
||||
self.frames
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn mask(value: f32) -> TrackedFrame {
|
||||
TrackedFrame { prob: vec![value; 16], w: 4, h: 4, confidence: value, occluded: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_start_passes_stop_before_buffering_the_whole_clip() {
|
||||
let mut pending = PendingMasks::new(2, 2048);
|
||||
let mut accepted = 0;
|
||||
// SAM 2 can report all of object 0 before starting object 1. A long clip must stop
|
||||
// at the memory limit even though no frame has received all its objects yet.
|
||||
for frame in 0..10_000 {
|
||||
match pending.insert(0, frame, mask(0.9)) {
|
||||
Ok(None) => accepted += 1,
|
||||
Ok(Some(_)) => panic!("no frame is complete yet"),
|
||||
Err(e) => {
|
||||
assert!(e.contains("memory budget"), "{e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(accepted > 0 && accepted < 10_000);
|
||||
assert_eq!(pending.frames.len(), accepted);
|
||||
assert!(pending.bytes <= pending.limit);
|
||||
let before = pending.bytes;
|
||||
assert!(pending.insert(0, 10_001, mask(0.8)).is_err());
|
||||
assert_eq!(pending.bytes, before, "rejection must not retain the new map");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interleaved_objects_release_the_budget_and_keep_their_masks() {
|
||||
let mut pending = PendingMasks::new(2, 2048);
|
||||
for frame in 0..10_000 {
|
||||
let first = (frame % 2) as usize;
|
||||
assert!(pending.insert(first, frame, mask(first as f32)).unwrap().is_none());
|
||||
let parts = pending.insert(1 - first, frame, mask((1 - first) as f32)).unwrap().unwrap();
|
||||
assert_eq!(parts[0].as_ref().unwrap().prob, vec![0.0; 16]);
|
||||
assert_eq!(parts[1].as_ref().unwrap().prob, vec![1.0; 16]);
|
||||
assert!(pending.frames.is_empty());
|
||||
assert_eq!(pending.bytes, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacements_charge_capacity_and_partial_frames_are_preserved() {
|
||||
let mut pending = PendingMasks::new(2, 2048);
|
||||
pending.insert(0, 7, mask(0.1)).unwrap();
|
||||
let before = pending.bytes;
|
||||
pending.insert(0, 7, mask(0.2)).unwrap();
|
||||
assert_eq!(pending.bytes, before, "a duplicate replaces rather than accumulates");
|
||||
let mut oversized = mask(0.3);
|
||||
oversized.prob.reserve(2048);
|
||||
assert!(pending.insert(0, 7, oversized).is_err(), "capacity, not length, owns the memory");
|
||||
assert_eq!(pending.bytes, before);
|
||||
let frames = pending.into_incomplete();
|
||||
assert_eq!(frames[&7][0].as_ref().unwrap().prob, vec![0.2; 16]);
|
||||
assert!(frames[&7][1].is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_objects_need_no_pending_budget_and_unknown_objects_are_rejected() {
|
||||
let mut pending = PendingMasks::new(1, 0);
|
||||
assert!(pending.insert(0, 0, mask(0.9)).unwrap().is_some());
|
||||
assert_eq!(pending.bytes, 0);
|
||||
assert!(pending.insert(1, 0, mask(0.9)).is_err());
|
||||
assert!(pending.frames.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,8 @@ image = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
exr = { workspace = true }
|
||||
|
||||
# Native only: the volume's own identity for a file, to tell that a hard link is the footage it leads to.
|
||||
# Native file identity and SAM 2 inference; browsers keep the built-in engines.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
same-file = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
effectcraft-sam2 = { workspace = true }
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use effectcraft_segment::face::FaceModel;
|
||||
use effectcraft_segment::{self as seg, Loaded, ModelInfo, Task};
|
||||
use serde_json::{Value, json};
|
||||
use web_time::Instant;
|
||||
|
||||
use crate::commands::{CommandSpec, always, b_p, bad, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
@@ -27,13 +29,21 @@ use crate::{EngineError, Result, Session, cmd, query};
|
||||
pub struct Status {
|
||||
/// What is happening now ("Downloading MobileSAM…").
|
||||
pub busy: Option<String>,
|
||||
/// Model whose load/download owns this lane, for selection-specific waits.
|
||||
pub busy_model: Option<&'static str>,
|
||||
/// The model in use.
|
||||
pub active: Option<String>,
|
||||
/// The last failure, for the Settings page.
|
||||
pub error: Option<String>,
|
||||
/// Model whose operation failed, so Roto status does not report another model's download.
|
||||
pub error_model: Option<&'static str>,
|
||||
/// Failure of the selected model's load; downloads cannot block or retry that load.
|
||||
pub load_error: Option<String>,
|
||||
/// A finished job's message and whether it failed, for a toast (taken by
|
||||
/// [`Session::poll_models`]).
|
||||
pub done: Option<(String, bool)>,
|
||||
/// Counts selection and selected-weight changes: an older load is stale and its model is discarded.
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
/// The session's trained models: per task, the worker's status; and the face model in use.
|
||||
@@ -61,7 +71,14 @@ impl Models {
|
||||
self.face_model.lock().ok().and_then(|m| m.clone())
|
||||
}
|
||||
|
||||
fn set_active(&self, task: Task, model: Option<Loaded>) {
|
||||
/// Activate a model that finished loading for the choice counted as `generation`; `false` (and the model is
|
||||
/// dropped) when the choice changed meanwhile.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_active_if_current(&self, task: Task, model: Loaded, generation: u64) -> bool {
|
||||
activate_if_current(self.lane(task), &self.face_model, task, model, generation)
|
||||
}
|
||||
|
||||
pub(crate) fn set_active(&self, task: Task, model: Option<Loaded>) {
|
||||
activate(self.lane(task), &self.face_model, task, model);
|
||||
}
|
||||
}
|
||||
@@ -90,6 +107,30 @@ fn activate(lane: &Arc<Mutex<Status>>, face: &FaceSlot, task: Task, model: Optio
|
||||
set(lane, |s| s.active = id);
|
||||
}
|
||||
|
||||
/// [`activate`] a freshly loaded model unless the choice changed since the load started (`generation`); whether it
|
||||
/// was activated. The check and the activation happen under the lane's lock, so a selection can't slip between them.
|
||||
fn activate_if_current(lane: &Arc<Mutex<Status>>, face: &FaceSlot, task: Task, model: Loaded, generation: u64) -> bool {
|
||||
let Ok(mut s) = lane.lock() else { return false };
|
||||
if s.generation != generation {
|
||||
return false;
|
||||
}
|
||||
let id = match &model {
|
||||
Loaded::Mask(m) => m.info().id.to_string(),
|
||||
Loaded::Face(m) => m.info().id.to_string(),
|
||||
};
|
||||
match (task, model) {
|
||||
(Task::Mask, Loaded::Mask(m)) => crate::effects::roto::set_model(Some(m)),
|
||||
(Task::Face, Loaded::Face(f)) => {
|
||||
if let Ok(mut slot) = face.lock() {
|
||||
*slot = Some(f);
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
s.active = Some(id);
|
||||
true
|
||||
}
|
||||
|
||||
fn set(status: &Arc<Mutex<Status>>, f: impl FnOnce(&mut Status)) {
|
||||
if let Ok(mut s) = status.lock() {
|
||||
f(&mut s);
|
||||
@@ -126,6 +167,13 @@ fn kind(task: Task) -> &'static Kind {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a Roto Brush model may sit unused before its memory is freed.
|
||||
const ROTO_MODEL_IDLE: Duration = Duration::from_secs(600);
|
||||
|
||||
/// How long a job that needs the Roto Brush model waits for it to load (or download) before it
|
||||
/// gives up. A worker that never reports back must not hang the command.
|
||||
const ROTO_MODEL_WAIT: Duration = Duration::from_secs(600);
|
||||
|
||||
impl Session {
|
||||
/// The folder installed models live in: the host's, else `models` next to the settings.
|
||||
pub fn models_dir(&self) -> Option<PathBuf> {
|
||||
@@ -151,7 +199,20 @@ impl Session {
|
||||
Task::Mask => &mut self.prefs.roto.model,
|
||||
Task::Face => &mut self.prefs.face.model,
|
||||
};
|
||||
*slot = id.to_string();
|
||||
let changed = slot != id;
|
||||
if changed {
|
||||
*slot = id.to_string();
|
||||
}
|
||||
set(self.models.lane(task), |s| {
|
||||
// A load still running for the previous choice must not activate or report an error.
|
||||
if changed {
|
||||
s.generation = s.generation.wrapping_add(1);
|
||||
}
|
||||
// Selecting the same model explicitly retries a failed load.
|
||||
s.error = None;
|
||||
s.error_model = None;
|
||||
s.load_error = None;
|
||||
});
|
||||
}
|
||||
|
||||
/// Load (or drop) the models Settings chooses; called whenever settings change.
|
||||
@@ -173,7 +234,7 @@ impl Session {
|
||||
}
|
||||
return;
|
||||
};
|
||||
if st.active.as_deref() == Some(info.id) || st.busy.is_some() {
|
||||
if st.active.as_deref() == Some(info.id) || st.busy.is_some() || st.load_error.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(path) = self.model_path(info) else {
|
||||
@@ -181,19 +242,195 @@ impl Session {
|
||||
return;
|
||||
};
|
||||
let (face_slot, label) = (self.models.face_model.clone(), kind(task).label);
|
||||
spawn(&lane, label, format!("Loading {}…", info.name), wait, move |status| {
|
||||
let generation = st.generation;
|
||||
spawn(&lane, label, format!("Loading {}…", info.name), wait, Some(generation), info.id, move |status| {
|
||||
let m = load_file(&path, info.id)?;
|
||||
activate(status, &face_slot, task, Some(m));
|
||||
// the choice changed while this loaded: drop the model, `poll_models` loads the new choice
|
||||
if !activate_if_current(status, &face_slot, task, m, generation) {
|
||||
return Ok(String::new());
|
||||
}
|
||||
Ok(format!("{label}: {} loaded", info.name))
|
||||
});
|
||||
}
|
||||
|
||||
/// The Roto Brush model in use (`None`: the classic engine).
|
||||
pub fn models_roto_active(&self) -> Option<String> {
|
||||
self.models.status(Task::Mask).active
|
||||
}
|
||||
|
||||
/// Only installing the chosen model is a retry of that model's failed load.
|
||||
fn retry_installed_model(&mut self, task: Task, id: &str, wait: bool) -> std::result::Result<bool, String> {
|
||||
if self.chosen_model(task) != id {
|
||||
return Ok(false);
|
||||
}
|
||||
set(self.models.lane(task), |s| {
|
||||
s.error = None;
|
||||
s.error_model = None;
|
||||
s.load_error = None;
|
||||
// A load that read the old file must not report failure after these weights replaced it.
|
||||
s.generation = s.generation.wrapping_add(1);
|
||||
});
|
||||
if self.models.status(task).active.as_deref() == Some(id) {
|
||||
return Ok(true);
|
||||
}
|
||||
if wait {
|
||||
self.wait_model_worker(task, id)?;
|
||||
}
|
||||
self.apply_model(task, wait);
|
||||
if wait {
|
||||
let status = self.models.status(task);
|
||||
if status.active.as_deref() != Some(id) {
|
||||
return Err(status.load_error.unwrap_or_else(|| format!("{id} is not loaded: try again when the model worker is ready")));
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn wait_model_worker(&self, task: Task, id: &str) -> std::result::Result<(), String> {
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
let status = self.models.status(task);
|
||||
let Some(operation) = status.busy else { return Ok(()) };
|
||||
if status.busy_model != Some(id) {
|
||||
return Err(format!("{id} cannot load while another model operation is running ({operation}): try again when it is ready"));
|
||||
}
|
||||
if started.elapsed() >= ROTO_MODEL_WAIT {
|
||||
return Err(format!("{} is still loading after {} minutes", self.chosen_model(task), ROTO_MODEL_WAIT.as_secs() / 60));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// The Roto Brush model Settings chose, when it is one (not the classic engine).
|
||||
fn roto_model_info(&self) -> Option<&'static ModelInfo> {
|
||||
seg::info(self.chosen_model(Task::Mask)).filter(|m| m.task == Task::Mask)
|
||||
}
|
||||
|
||||
/// Whether the chosen Roto Brush model is still loading or downloading.
|
||||
pub fn roto_model_loading(&self) -> bool {
|
||||
let status = self.models.status(Task::Mask);
|
||||
let chosen = self.chosen_model(Task::Mask);
|
||||
status.active.as_deref() != Some(chosen) && status.busy.is_some() && status.busy_model == Some(chosen)
|
||||
}
|
||||
|
||||
/// The Roto Brush model Settings chose and its state (`roto.status`).
|
||||
pub fn roto_model(&self) -> Value {
|
||||
let st = self.models.status(Task::Mask);
|
||||
json!({
|
||||
"selected": self.chosen_model(Task::Mask),
|
||||
"installed": self.roto_model_info().is_none_or(|m| self.model_path(m).is_some()),
|
||||
"active": st.active,
|
||||
"loading": self.roto_model_loading(),
|
||||
"error": st.load_error.or(st.error.filter(|_| st.error_model == Some(self.chosen_model(Task::Mask)))),
|
||||
})
|
||||
}
|
||||
|
||||
/// What `roto.status` says when the chosen model is not the one in use for this layer (`None`:
|
||||
/// nothing to say). `uses_model`: the layer segments with a model at all (Roto Brush 1.0 does not).
|
||||
pub fn roto_model_notice(&self, uses_model: bool) -> Option<String> {
|
||||
let info = self.roto_model_info()?;
|
||||
if !uses_model {
|
||||
return Some(format!("{} is selected, but this layer is Roto Brush 1.0: it segments with the classic engine", info.name));
|
||||
}
|
||||
let st = self.models.status(Task::Mask);
|
||||
if st.active.as_deref() == Some(info.id) {
|
||||
None
|
||||
} else if self.model_path(info).is_none() {
|
||||
Some(format!("{} is selected but not installed: propagate refuses until it is installed or Classic is chosen", info.name))
|
||||
} else if let Some(e) = st.load_error {
|
||||
Some(format!("{} is selected but failed to load ({e}): propagate refuses until it is selected again", info.name))
|
||||
} else {
|
||||
Some(format!("{} is selected but not loaded yet: propagate loads it first with wait: true, and refuses without it", info.name))
|
||||
}
|
||||
}
|
||||
|
||||
/// What `roto.status` says when the saved tracking applies but Settings choose another model (`None`:
|
||||
/// they agree, or nothing is saved).
|
||||
pub fn saved_tracking_notice(&self, saved: Option<&str>) -> Option<String> {
|
||||
let saved = saved?;
|
||||
let chosen = self.chosen_model(Task::Mask);
|
||||
if saved == chosen {
|
||||
return None;
|
||||
}
|
||||
let name = |id: &str| seg::info(id).map_or(ROTO.classic_name, |m| m.name);
|
||||
Some(format!("The saved tracking was made with {}; {} is chosen: Propagate again to replace it", name(saved), name(chosen)))
|
||||
}
|
||||
|
||||
/// Make sure the Roto Brush model Settings chose is in use before a job that segments with it
|
||||
/// starts, so the job never runs on the classic engine without saying so. With `wait`, a load
|
||||
/// under way is waited for and one not started is loaded here; without it, a model that is not
|
||||
/// ready is refused (and starts loading). A model that is not installed, or failed to load, is
|
||||
/// refused either way. The classic engine needs nothing.
|
||||
pub fn ensure_roto_model(&mut self, wait: bool) -> std::result::Result<(), String> {
|
||||
let Some(info) = self.roto_model_info() else { return Ok(()) };
|
||||
if self.models.status(Task::Mask).active.as_deref() == Some(info.id) {
|
||||
return Ok(());
|
||||
}
|
||||
// A download or load under way (from Settings, or `select` without `wait`) finishes first.
|
||||
// Nothing is in flight in the browser build, so this never sleeps there.
|
||||
if wait {
|
||||
self.wait_model_worker(Task::Mask, info.id)?;
|
||||
}
|
||||
if self.model_path(info).is_none() {
|
||||
if self.models.status(Task::Mask).busy.is_some() {
|
||||
return Err(format!("{} is still downloading: try again when it is ready, or pass wait: true", info.name));
|
||||
}
|
||||
return Err(format!("{} is selected for Roto Brush but not installed: install or download it, or choose {}", info.name, ROTO.classic_name));
|
||||
}
|
||||
let st = self.models.status(Task::Mask);
|
||||
if st.active.as_deref() != Some(info.id) && st.busy.is_none() && st.load_error.is_none() {
|
||||
self.apply_model(Task::Mask, wait);
|
||||
}
|
||||
let st = self.models.status(Task::Mask);
|
||||
if st.active.as_deref() == Some(info.id) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(e) = st.load_error {
|
||||
return Err(format!("{} failed to load: {e}", info.name));
|
||||
}
|
||||
Err(format!("{} is still loading: try again when it is ready, or pass wait: true", info.name))
|
||||
}
|
||||
|
||||
/// Free a Roto Brush model that has been idle for [`ROTO_MODEL_IDLE`] (and is not under a job). The
|
||||
/// choice stays: it loads again when a job or a stroke needs it.
|
||||
fn unload_idle_roto_model(&mut self) {
|
||||
if self.is_roto_running() || self.models.status(Task::Mask).busy.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(m) = crate::effects::roto::installed_model() else { return };
|
||||
if m.idle_secs().is_some_and(|s| s >= ROTO_MODEL_IDLE.as_secs_f64()) {
|
||||
let name = m.info().name;
|
||||
drop(m);
|
||||
self.models.set_active(Task::Mask, None);
|
||||
self.events.push(crate::Event::Toast {
|
||||
message: format!("{name} unloaded after {} minutes idle; it loads again when it is used", ROTO_MODEL_IDLE.as_secs() / 60),
|
||||
error: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Start loading the chosen Roto Brush model again if it was unloaded for being idle (before a stroke uses it).
|
||||
/// The load runs on the model worker, so the stroke is never blocked on it (the classic engine segments it
|
||||
/// meanwhile and `poll_models` installs the model when it arrives), and a model that already failed to load is
|
||||
/// not retried on every stroke: choosing it again clears the error.
|
||||
pub fn reload_roto_model(&mut self) {
|
||||
let chosen = self.chosen_model(Task::Mask).to_string();
|
||||
let st = self.models.status(Task::Mask);
|
||||
if chosen != seg::CLASSICAL && st.active.is_none() && st.busy.is_none() && st.load_error.is_none() {
|
||||
self.apply_model(Task::Mask, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish what the model workers did: a toast, and loading a model that just arrived.
|
||||
pub fn poll_models(&mut self) {
|
||||
self.unload_idle_roto_model();
|
||||
for task in [Task::Mask, Task::Face] {
|
||||
let done = self.models.lane(task).lock().ok().and_then(|mut s| s.done.take());
|
||||
if let Some((message, error)) = done {
|
||||
self.events.push(crate::Event::Toast { message, error });
|
||||
// a discarded (stale) load has no message
|
||||
if !message.is_empty() {
|
||||
self.events.push(crate::Event::Toast { message, error });
|
||||
}
|
||||
self.apply_model(task, false);
|
||||
}
|
||||
}
|
||||
@@ -202,6 +439,11 @@ impl Session {
|
||||
|
||||
fn load_file(path: &Path, id: &str) -> std::result::Result<Loaded, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
|
||||
// SAM 2 runs on candle and is native only; every other model loads through the registry.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if effectcraft_sam2::mask::SIZES.iter().any(|m| m.id == id) {
|
||||
return effectcraft_sam2::mask::load(&bytes).map(|m| Loaded::Mask(m));
|
||||
}
|
||||
seg::load(id, &bytes)
|
||||
}
|
||||
|
||||
@@ -212,26 +454,47 @@ fn spawn(
|
||||
label: &'static str,
|
||||
what: String,
|
||||
wait: bool,
|
||||
generation: Option<u64>,
|
||||
model: &'static str,
|
||||
job: impl FnOnce(&Arc<Mutex<Status>>) -> std::result::Result<String, String> + Send + 'static,
|
||||
) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
set(status, |s| s.error = Some("trained models are available in the desktop app".into()));
|
||||
set(status, |s| {
|
||||
let message = "trained models are available in the desktop app".to_string();
|
||||
if generation.is_some() {
|
||||
s.load_error = Some(message.clone());
|
||||
}
|
||||
s.error = Some(message);
|
||||
s.error_model = Some(model);
|
||||
});
|
||||
return;
|
||||
}
|
||||
set(status, |s| {
|
||||
s.busy = Some(what);
|
||||
s.busy_model = Some(model);
|
||||
s.error = None;
|
||||
s.error_model = None;
|
||||
});
|
||||
let st = status.clone();
|
||||
let run = move || {
|
||||
let r = job(&st);
|
||||
set(&st, |s| {
|
||||
s.busy = None;
|
||||
s.busy_model = None;
|
||||
if generation.is_some_and(|g| g != s.generation) {
|
||||
// Keep a completion marker so polling starts the newly selected model.
|
||||
s.done = Some((String::new(), false));
|
||||
return;
|
||||
}
|
||||
match r {
|
||||
Ok(msg) => s.done = Some((msg, false)),
|
||||
Err(e) => {
|
||||
s.done = Some((format!("{label} model: {e}"), true));
|
||||
if generation.is_some() {
|
||||
s.load_error = Some(e.clone());
|
||||
}
|
||||
s.error = Some(e);
|
||||
s.error_model = Some(model);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -241,7 +504,13 @@ fn spawn(
|
||||
} else if let Err(e) = std::thread::Builder::new().name("ec-model".into()).spawn(run) {
|
||||
set(status, |s| {
|
||||
s.busy = None;
|
||||
s.error = Some(format!("cannot start: {e}"));
|
||||
s.busy_model = None;
|
||||
let message = format!("cannot start: {e}");
|
||||
if generation.is_some() {
|
||||
s.load_error = Some(message.clone());
|
||||
}
|
||||
s.error = Some(message);
|
||||
s.error_model = Some(model);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -264,9 +533,17 @@ fn install_bytes(dir: &Path, m: &ModelInfo, bytes: &[u8]) -> std::result::Result
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn models_for_platform(task: Task, wasm: bool) -> impl Iterator<Item = &'static ModelInfo> {
|
||||
seg::models(task).filter(move |m| !wasm || !m.id.starts_with("sam2.1-hiera-"))
|
||||
}
|
||||
|
||||
fn target_models(task: Task) -> impl Iterator<Item = &'static ModelInfo> {
|
||||
models_for_platform(task, cfg!(target_arch = "wasm32"))
|
||||
}
|
||||
|
||||
fn model_p(p: &Value, cmd: &str, task: Task) -> Result<&'static ModelInfo> {
|
||||
let id = str_p(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
|
||||
seg::info(id).filter(|m| m.task == task).ok_or_else(|| bad(cmd, format!("unknown model `{id}` (see {}.models)", kind(task).prefix)))
|
||||
target_models(task).find(|m| m.id == id).ok_or_else(|| bad(cmd, format!("unknown model `{id}` (see {}.models)", kind(task).prefix)))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, task: Task) -> Result<Value> {
|
||||
@@ -282,8 +559,14 @@ fn list(s: &mut Session, task: Task) -> Result<Value> {
|
||||
"selected": chosen == seg::CLASSICAL,
|
||||
"active": st.active.is_none(),
|
||||
})];
|
||||
for m in seg::models(task) {
|
||||
for m in target_models(task) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let (recommended, hint) = (effectcraft_sam2::mask::recommended().id == m.id, effectcraft_sam2::mask::hint(m.id));
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let (recommended, hint) = (false, None::<String>);
|
||||
v.push(json!({
|
||||
"recommended": recommended,
|
||||
"hint": hint,
|
||||
"id": m.id,
|
||||
"name": m.name,
|
||||
"description": m.description,
|
||||
@@ -292,6 +575,8 @@ fn list(s: &mut Session, task: Task) -> Result<Value> {
|
||||
"licenceUrl": m.licence_url,
|
||||
"homepage": m.homepage,
|
||||
"url": m.url,
|
||||
"source": m.source,
|
||||
"sourceFile": m.source.rsplit('/').next().unwrap_or(m.file_name),
|
||||
"size": m.size,
|
||||
"sha256": m.sha256,
|
||||
"installed": s.model_path(m).is_some(),
|
||||
@@ -317,6 +602,9 @@ fn select(s: &mut Session, p: &Value, task: Task) -> Result<Value> {
|
||||
model_p(p, &cmd, task)?;
|
||||
}
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
if wait && id != seg::CLASSICAL && s.models.status(task).active.as_deref() != Some(id) {
|
||||
s.wait_model_worker(task, id).map_err(|error| bad(&cmd, error))?;
|
||||
}
|
||||
s.choose_model(task, id);
|
||||
if wait {
|
||||
s.apply_model(task, true);
|
||||
@@ -337,15 +625,14 @@ fn install(s: &mut Session, p: &Value, task: Task) -> Result<Value> {
|
||||
Some(_) => model_p(p, &cmd, task)?,
|
||||
None => {
|
||||
let h = seg::sha256::hex(&bytes);
|
||||
seg::models(task).find(|m| m.sha256 == h).ok_or_else(|| bad(&cmd, "this file is not a published model in the registry (SHA-256 differs)"))?
|
||||
target_models(task)
|
||||
.find(|m| m.sha256 == h)
|
||||
.ok_or_else(|| bad(&cmd, "this file is not a supported published model in the registry (SHA-256 differs)"))?
|
||||
}
|
||||
};
|
||||
let dest = install_bytes(&dir, m, &bytes).map_err(|e| bad(&cmd, e))?;
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
s.apply_model(task, wait);
|
||||
if wait {
|
||||
waited(s, task, &cmd)?;
|
||||
}
|
||||
s.retry_installed_model(task, m.id, wait).map_err(|error| bad(&cmd, error))?;
|
||||
Ok(json!({"id": m.id, "path": dest.display().to_string()}))
|
||||
}
|
||||
|
||||
@@ -353,13 +640,16 @@ fn download(s: &mut Session, p: &Value, task: Task) -> Result<Value> {
|
||||
let k = kind(task);
|
||||
let cmd = format!("{}.model.download", k.prefix);
|
||||
let m = model_p(p, &cmd, task)?;
|
||||
if m.url.is_empty() {
|
||||
return Err(bad(&cmd, format!("{} has no app download available: use Install from File with the SHA-256-verified weights", m.name)));
|
||||
}
|
||||
let dir = s.models_dir().ok_or_else(|| bad(&cmd, "no settings folder to install into"))?;
|
||||
if s.models.status(task).busy.is_some() {
|
||||
return Err(bad(&cmd, "a model is already downloading or loading"));
|
||||
}
|
||||
let size = if m.size >= 10_000_000 { format!("{} MB", m.size / 1_000_000) } else { format!("{:.1} MB", m.size as f64 / 1e6) };
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
spawn(s.models.lane(task), k.label, format!("Downloading {} ({size})…", m.name), wait, move |_| {
|
||||
spawn(s.models.lane(task), k.label, format!("Downloading {} ({size})…", m.name), wait, None, m.id, move |_| {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
|
||||
let tmp = dir.join(format!("{}.part", m.file_name));
|
||||
let out = std::process::Command::new("curl")
|
||||
@@ -407,7 +697,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Use Roto Brush Model",
|
||||
[],
|
||||
None,
|
||||
"{id: classical|mobilesam, wait?} (Roto Brush 2.0 / 3.0 use it; wait: loaded before returning)",
|
||||
"{id: classical|mobilesam|sam2.1-hiera-tiny|sam2.1-hiera-small|sam2.1-hiera-base-plus|sam2.1-hiera-large, wait?} (Roto Brush 2.0 / 3.0 use it; wait: loaded before returning)",
|
||||
always,
|
||||
|s, p| select(s, p, Task::Mask)
|
||||
),
|
||||
@@ -461,3 +751,260 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("face.model.remove", "Remove Face Tracking Model", [], None, "{id}", always, |s, p| remove(s, p, Task::Face)),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod availability_tests {
|
||||
use super::*;
|
||||
|
||||
/// SAM 2 cannot start a network operation; file installation still verifies the published hash.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn sam2_is_install_from_file_only() {
|
||||
let mut session = Session::default();
|
||||
let listed = session.execute("roto.models", json!({})).unwrap();
|
||||
for model in [seg::SAM2_TINY, seg::SAM2_SMALL, seg::SAM2_BASE_PLUS, seg::SAM2_LARGE] {
|
||||
let error = session.execute("roto.model.download", json!({"id": model.id})).unwrap_err();
|
||||
assert!(error.to_string().contains("Install from File"));
|
||||
assert!(session.models.status(Task::Mask).busy.is_none());
|
||||
assert!(install_bytes(Path::new("unused"), &model, b"invalid weights").unwrap_err().contains("SHA-256 differs"));
|
||||
let row = listed["models"].as_array().unwrap().iter().find(|row| row["id"] == model.id).unwrap();
|
||||
assert_eq!(row["source"], model.source);
|
||||
assert_eq!(row["sourceFile"], "model.safetensors");
|
||||
}
|
||||
}
|
||||
|
||||
/// A waiting selection cannot claim completion while unrelated work prevents its model loading.
|
||||
#[test]
|
||||
fn waiting_selection_refuses_an_unrelated_worker_unless_the_model_is_active() {
|
||||
crate::tests::roto::hold_roto_cache_test_lock();
|
||||
for active in [false, true] {
|
||||
let mut session = Session::default();
|
||||
session.choose_model(Task::Mask, if active { "mobilesam" } else { seg::CLASSICAL });
|
||||
set(session.models.lane(Task::Mask), |status| {
|
||||
status.active = active.then(|| "mobilesam".into());
|
||||
status.busy = Some("Downloading SAM 2".into());
|
||||
status.busy_model = Some("sam2.1-hiera-tiny");
|
||||
});
|
||||
let result = session.execute("roto.model.select", json!({"id": "mobilesam", "wait": true}));
|
||||
if active {
|
||||
assert_eq!(result.unwrap()["active"], "mobilesam");
|
||||
} else {
|
||||
assert!(result.unwrap_err().to_string().contains("another model operation"));
|
||||
assert_eq!(session.chosen_model(Task::Mask), seg::CLASSICAL, "a refused selection must not change settings");
|
||||
}
|
||||
assert!(session.models.status(Task::Mask).busy.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn stale_model_worker_completions_do_not_poison_the_current_choice() {
|
||||
for failure in [false, true] {
|
||||
let lane = Arc::new(Mutex::new(Status::default()));
|
||||
spawn(&lane, "Roto Brush", "Loading old choice".into(), true, Some(0), "mobilesam", move |status| {
|
||||
set(status, |s| s.generation += 1);
|
||||
if failure { Err("old choice failed".into()) } else { Ok("old choice loaded".into()) }
|
||||
});
|
||||
let status = lane.lock().unwrap();
|
||||
assert!(status.busy.is_none());
|
||||
assert!(status.error.is_none(), "an obsolete worker must not block the current model");
|
||||
assert!(status.load_error.is_none());
|
||||
assert_eq!(status.done, Some((String::new(), false)), "polling must still retry the current choice");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn current_load_and_download_failures_remain_actionable() {
|
||||
for generation in [Some(0), None] {
|
||||
let lane = Arc::new(Mutex::new(Status::default()));
|
||||
spawn(&lane, "Roto Brush", "Working".into(), true, generation, "mobilesam", move |status| {
|
||||
if generation.is_none() {
|
||||
set(status, |s| s.generation += 1);
|
||||
}
|
||||
Err("current operation failed".into())
|
||||
});
|
||||
let status = lane.lock().unwrap();
|
||||
assert!(status.busy.is_none());
|
||||
assert_eq!(status.error.as_deref(), Some("current operation failed"));
|
||||
assert_eq!(status.load_error.as_deref(), generation.map(|_| "current operation failed"));
|
||||
assert_eq!(status.done, Some(("Roto Brush model: current operation failed".into(), true)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_model_clears_its_previous_load_error_even_on_retry() {
|
||||
let mut session = Session::default();
|
||||
for id in ["mobilesam", "mobilesam", seg::CLASSICAL] {
|
||||
let previous = session.chosen_model(Task::Mask).to_string();
|
||||
let generation = session.models.status(Task::Mask).generation;
|
||||
set(session.models.lane(Task::Mask), |s| {
|
||||
s.error = Some("previous load failed".into());
|
||||
s.load_error = s.error.clone();
|
||||
});
|
||||
session.choose_model(Task::Mask, id);
|
||||
let status = session.models.status(Task::Mask);
|
||||
assert!(status.error.is_none());
|
||||
assert!(status.load_error.is_none());
|
||||
assert_eq!(status.generation, generation + u64::from(previous != id));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn selected_active_model_is_ready_despite_an_unrelated_download_error() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-model-ready-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let info = seg::info("mobilesam").unwrap();
|
||||
let file = std::fs::File::create(dir.join(info.file_name)).unwrap();
|
||||
file.set_len(info.size).unwrap();
|
||||
drop(file);
|
||||
let mut session = Session { models_dir: Some(dir.clone()), ..Default::default() };
|
||||
session.choose_model(Task::Mask, info.id);
|
||||
set(session.models.lane(Task::Mask), |s| {
|
||||
s.active = Some(info.id.into());
|
||||
s.error = Some("another model could not be downloaded".into());
|
||||
});
|
||||
assert_eq!(session.ensure_roto_model(false), Ok(()));
|
||||
assert!(session.roto_model()["error"].is_null());
|
||||
set(session.models.lane(Task::Mask), |status| {
|
||||
status.error_model = Some("mobilesam");
|
||||
status.error = Some("selected model download failed".into());
|
||||
});
|
||||
assert_eq!(session.roto_model()["error"], "selected model download failed");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_active_model_and_selected_install_do_not_wait_for_other_downloads() {
|
||||
crate::tests::roto::hold_roto_cache_test_lock();
|
||||
let mut session = Session::default();
|
||||
session.choose_model(Task::Mask, "mobilesam");
|
||||
set(session.models.lane(Task::Mask), |status| {
|
||||
status.active = Some("mobilesam".into());
|
||||
status.busy = Some("Downloading SAM 2".into());
|
||||
status.busy_model = Some("sam2.1-hiera-tiny");
|
||||
});
|
||||
assert_eq!(session.ensure_roto_model(true), Ok(()));
|
||||
assert_eq!(session.retry_installed_model(Task::Mask, "mobilesam", true), Ok(true));
|
||||
let status = session.models.status(Task::Mask);
|
||||
assert!(status.busy.is_some(), "the unrelated operation must still be running");
|
||||
assert!(!session.roto_model_loading());
|
||||
assert_eq!(session.roto_model()["loading"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_on_an_inactive_model_refuses_unrelated_work_without_waiting() {
|
||||
crate::tests::roto::hold_roto_cache_test_lock();
|
||||
let mut session = Session::default();
|
||||
session.choose_model(Task::Mask, "mobilesam");
|
||||
set(session.models.lane(Task::Mask), |status| {
|
||||
status.busy = Some("Downloading SAM 2".into());
|
||||
status.busy_model = Some("sam2.1-hiera-tiny");
|
||||
});
|
||||
assert!(session.ensure_roto_model(true).unwrap_err().contains("another model operation"));
|
||||
assert!(session.retry_installed_model(Task::Mask, "mobilesam", true).unwrap_err().contains("another model operation"));
|
||||
assert!(session.models.status(Task::Mask).busy.is_some());
|
||||
assert!(!session.roto_model_loading());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn downloads_do_not_block_an_inactive_model_or_retry_a_failed_load() {
|
||||
crate::tests::roto::hold_roto_cache_test_lock();
|
||||
let dir = std::env::temp_dir().join(format!("ec-model-download-state-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let info = seg::info("mobilesam").unwrap();
|
||||
std::fs::File::create(dir.join(info.file_name)).unwrap().set_len(info.size).unwrap();
|
||||
let mut session = Session { models_dir: Some(dir.clone()), ..Default::default() };
|
||||
session.choose_model(Task::Mask, info.id);
|
||||
let lane = session.models.lane(Task::Mask).clone();
|
||||
spawn(&lane, "Roto Brush", "Downloading another model".into(), true, None, "sam2.1-hiera-tiny", |_| Err("download failed".into()));
|
||||
assert_eq!(session.models.status(Task::Mask).error.as_deref(), Some("download failed"));
|
||||
assert!(session.models.status(Task::Mask).load_error.is_none());
|
||||
let error = session.ensure_roto_model(true).unwrap_err();
|
||||
assert!(!error.contains("download failed"), "the installed model must be loaded despite a download failure");
|
||||
let failure = session.models.status(Task::Mask).load_error;
|
||||
assert!(failure.is_some(), "the synthetic weights were actually loaded and rejected");
|
||||
for download_failed in [true, false] {
|
||||
spawn(&lane, "Roto Brush", "Downloading another model".into(), true, None, "sam2.1-hiera-tiny", move |_| {
|
||||
if download_failed { Err("download failed".into()) } else { Ok("downloaded".into()) }
|
||||
});
|
||||
session.poll_models();
|
||||
session.reload_roto_model();
|
||||
let status = session.models.status(Task::Mask);
|
||||
assert_eq!(status.load_error, failure, "a download must not clear the selected model's load failure");
|
||||
assert!(status.busy.is_none(), "polling and strokes must not retry a failed model");
|
||||
assert!(session.ensure_roto_model(true).unwrap_err().contains(failure.as_deref().unwrap()));
|
||||
assert!(session.roto_model_notice(true).unwrap().contains(failure.as_deref().unwrap()));
|
||||
}
|
||||
crate::effects::roto::set_model(None);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installing_an_unselected_model_keeps_the_selected_models_load_failure() {
|
||||
crate::tests::roto::hold_roto_cache_test_lock();
|
||||
let mut session = Session::default();
|
||||
session.choose_model(Task::Mask, "mobilesam");
|
||||
set(session.models.lane(Task::Mask), |s| {
|
||||
s.error = Some("selected model failed".into());
|
||||
s.load_error = s.error.clone();
|
||||
});
|
||||
let before = session.models.status(Task::Mask);
|
||||
for wait in [false, true] {
|
||||
assert!(!session.retry_installed_model(Task::Mask, "sam2.1-hiera-tiny", wait).unwrap());
|
||||
assert_eq!(session.models.status(Task::Mask), before);
|
||||
}
|
||||
// Installing the selected weights is an explicit retry, even if the installed path
|
||||
// has since gone away (no load is started from this session's missing model folder).
|
||||
assert!(session.retry_installed_model(Task::Mask, "mobilesam", false).unwrap());
|
||||
assert!(session.models.status(Task::Mask).load_error.is_none());
|
||||
assert!(session.models.status(Task::Mask).error.is_none());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn installing_selected_weights_with_wait_finishes_the_replacement_load() {
|
||||
crate::tests::roto::hold_roto_cache_test_lock();
|
||||
let dir = std::env::temp_dir().join(format!("ec-model-install-running-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let info = seg::info("mobilesam").unwrap();
|
||||
std::fs::File::create(dir.join(info.file_name)).unwrap().set_len(info.size).unwrap();
|
||||
let mut session = Session { models_dir: Some(dir.clone()), ..Default::default() };
|
||||
session.choose_model(Task::Mask, info.id);
|
||||
let generation = session.models.status(Task::Mask).generation;
|
||||
let lane = session.models.lane(Task::Mask).clone();
|
||||
spawn(&lane, "Roto Brush", "Loading the previous file".into(), false, Some(generation), info.id, move |status| {
|
||||
let started = Instant::now();
|
||||
while status.lock().unwrap().generation == generation {
|
||||
if started.elapsed() > Duration::from_secs(3) {
|
||||
return Err("test did not start the replacement".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
Err("previous file failed".into())
|
||||
});
|
||||
assert!(session.retry_installed_model(Task::Mask, info.id, true).unwrap_err().contains("SHA-256"));
|
||||
let status = session.models.status(Task::Mask);
|
||||
assert!(status.busy.is_none());
|
||||
assert!(
|
||||
status.load_error.as_ref().is_some_and(|error| error.contains("SHA-256")),
|
||||
"wait must finish loading the replacement weights and report their actual validation error"
|
||||
);
|
||||
assert_ne!(status.error.as_deref(), Some("previous file failed"));
|
||||
crate::effects::roto::set_model(None);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_models_exclude_native_trackers_and_keep_portable_models() {
|
||||
let browser: Vec<_> = models_for_platform(Task::Mask, true).map(|m| m.id).collect();
|
||||
assert_eq!(browser, ["mobilesam"]);
|
||||
let desktop: Vec<_> = models_for_platform(Task::Mask, false).map(|m| m.id).collect();
|
||||
assert_eq!(desktop, ["mobilesam", "sam2.1-hiera-tiny", "sam2.1-hiera-small", "sam2.1-hiera-base-plus", "sam2.1-hiera-large"]);
|
||||
for wasm in [false, true] {
|
||||
assert_eq!(models_for_platform(Task::Face, wasm).map(|m| m.id).collect::<Vec<_>>(), ["mediapipe-face"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_effects::object_matte as om;
|
||||
use effectcraft_effects::roto::{self as fx, FROZEN, FrozenCache, INPUT_KEY};
|
||||
use effectcraft_effects::roto::{self as fx, FROZEN, FrozenCache, INPUT_KEY, TRACKED};
|
||||
use effectcraft_effects::{Params, flatten_params};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, Project, PropGroup, Uid};
|
||||
@@ -51,6 +51,9 @@ pub struct RotoOptions {
|
||||
pub boundary_color: [f64; 3],
|
||||
/// Start propagating in the background after each stroke.
|
||||
pub auto_propagate: bool,
|
||||
/// The object new strokes belong to (0 is the first). With a tracker every object is followed on its own
|
||||
/// and their mattes are united; the classic engine treats all foreground strokes as one object.
|
||||
pub object: u32,
|
||||
}
|
||||
|
||||
impl Default for RotoOptions {
|
||||
@@ -63,10 +66,14 @@ impl Default for RotoOptions {
|
||||
overlay_opacity: 50.0,
|
||||
boundary_color: [1.0, 0.25, 0.75],
|
||||
auto_propagate: true,
|
||||
object: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The highest object id (SAM 2 follows up to 32 objects in one pass).
|
||||
pub const MAX_OBJECT_ID: u32 = 31;
|
||||
|
||||
pub const VIEWS: [&str; 4] = ["alphaBoundary", "alpha", "alphaOverlay", "none"];
|
||||
|
||||
/// What a job does.
|
||||
@@ -112,6 +119,10 @@ pub struct RotoProgress {
|
||||
pub error: Option<String>,
|
||||
/// The job works on an Object Matte (else a Roto Brush & Refine Edge).
|
||||
pub object_matte: bool,
|
||||
/// What the model's tracker found, when one followed the object (SAM 2).
|
||||
pub tracked: Option<fx::TrackSummary>,
|
||||
/// Why the tracker was not used, when a tracking model was chosen but the classic engine ran.
|
||||
pub tracker_note: Option<String>,
|
||||
}
|
||||
|
||||
impl RotoProgress {
|
||||
@@ -131,6 +142,8 @@ pub struct RotoShared {
|
||||
pub cancel: AtomicBool,
|
||||
pub state: Mutex<RotoProgress>,
|
||||
frozen: Mutex<Option<FrozenCache>>,
|
||||
/// The tracker's frames, as the saved tracking (JSON) to store with the instance.
|
||||
saved: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
pub struct RotoJob {
|
||||
@@ -249,6 +262,7 @@ pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerI
|
||||
set_str(g, INPUT_KEY, key);
|
||||
if had {
|
||||
set_str(g, FROZEN, String::new());
|
||||
set_str(g, TRACKED, String::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,7 +293,7 @@ pub(crate) fn params_at(p: &Project, cid: ItemId, layer: &Layer, g: &PropGroup,
|
||||
}
|
||||
|
||||
/// The layer's source size (layer pixels).
|
||||
pub(crate) fn layer_size(p: &Project, comp: &effectcraft_project::Comp, layer: &Layer) -> [f64; 2] {
|
||||
pub fn layer_size(p: &Project, comp: &effectcraft_project::Comp, layer: &Layer) -> [f64; 2] {
|
||||
effectcraft_render::effect_bounds(p, comp, layer).0
|
||||
}
|
||||
|
||||
@@ -432,10 +446,49 @@ async fn run_work(w: Work, shared: &RotoShared) {
|
||||
s.object_matte = w.object_matte;
|
||||
}
|
||||
let cancel = || shared.cancel.load(Ordering::Relaxed);
|
||||
let chain = fx::Chain::new(&w.params, w.size, w.fps, 1.0);
|
||||
let mut chain = fx::Chain::new(&w.params, w.size, w.fps, 1.0);
|
||||
if w.task == RotoTask::Propagate && !w.object_matte {
|
||||
chain.prepare_propagation();
|
||||
}
|
||||
let model = fx::installed_model();
|
||||
let key = if w.object_matte { om::frozen_key(&w.params, w.size, w.fps, model.as_deref()) } else { fx::frozen_key(&w.params, w.size, w.fps) };
|
||||
let mut frozen = FrozenCache { key, ..Default::default() };
|
||||
// A tracking model (SAM 2) follows the object through the whole span in one pass; the loop
|
||||
// below then finds every frame cached. Without one (or if it fails) nothing changes: the
|
||||
// classic engine runs frame by frame.
|
||||
if w.task == RotoTask::Propagate && !w.object_matte && chain.has_tracker() {
|
||||
let mut get = |g: i64| src.get(g);
|
||||
let mut report = |n: usize| {
|
||||
let mut s = lock(&shared.state);
|
||||
s.done = n as u64;
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
|
||||
crate::offload::report(s.done, s.total);
|
||||
};
|
||||
let result = chain.track_frames(Some(&w.frames), &mut get, &cancel, &mut report);
|
||||
if cancel() {
|
||||
let mut s = lock(&shared.state);
|
||||
s.cancelled = true;
|
||||
s.finished = true;
|
||||
return;
|
||||
}
|
||||
// The frames it followed replace the saved tracking (see `poll_roto_job`): none if it failed.
|
||||
*lock(&shared.saved) = Some(match &result {
|
||||
Ok(_) => fx::tracks_of(&chain).map(|t| t.to_json()).unwrap_or_default(),
|
||||
Err(_) => String::new(),
|
||||
});
|
||||
if result.is_err() {
|
||||
chain.forget_tracking();
|
||||
}
|
||||
let mut s = lock(&shared.state);
|
||||
match result {
|
||||
Ok(summary) => s.tracked = summary,
|
||||
Err(e) => s.tracker_note = Some(format!("tracker unavailable, classic engine used: {e}")),
|
||||
}
|
||||
} else if w.task == RotoTask::Propagate && !w.object_matte {
|
||||
// With the classic engine, Propagate leaves no saved tracking behind.
|
||||
*lock(&shared.saved) = Some(String::new());
|
||||
}
|
||||
for (k, f) in w.frames.iter().enumerate() {
|
||||
if cancel() {
|
||||
let mut s = lock(&shared.state);
|
||||
@@ -592,9 +645,37 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the tracking saved with Roto Brush `effect` covers every frame a Freeze would write.
|
||||
fn freeze_is_saved(&self, comp: ItemId, layer: LayerId, effect: Uid) -> bool {
|
||||
let Some(c) = self.project.comp(comp) else { return false };
|
||||
let Some(l) = c.layer(layer) else { return false };
|
||||
let Some(g) = l.effects().and_then(|f| f.groups().find(|g| g.uid == effect)).filter(|g| is_roto(g)) else { return false };
|
||||
let pstat = params_static(g);
|
||||
let d = fx::data(&pstat);
|
||||
if d.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let t = comp_time_of(c, l, d.base.unwrap_or(0));
|
||||
let params = params_at(&self.project, comp, l, g, t, self.expr.as_deref());
|
||||
fx::saved_covers(¶ms, layer_size(&self.project, c, l), c.frame_rate.as_f64(), &job_frames(&d, Direction::Both))
|
||||
}
|
||||
|
||||
/// Whether `effect` on `layer` segments with the model Settings chose: Object Matte always,
|
||||
/// Roto Brush unless it is version 1.0 (the classic engine).
|
||||
pub(crate) fn uses_roto_model(&self, comp: ItemId, layer: LayerId, effect: Uid) -> bool {
|
||||
let g = self.project.comp(comp).and_then(|c| c.layer(layer)).and_then(|l| l.effects()).and_then(|f| f.groups().find(|g| g.uid == effect));
|
||||
g.is_some_and(|g| is_object_matte(g) || fx::uses_model(¶ms_static(g)))
|
||||
}
|
||||
|
||||
/// Start propagating (or freezing) Roto Brush `effect` on `layer`. `wait`: block until done
|
||||
/// (wasm32 always does). Returns the number of frames.
|
||||
pub fn start_roto(&mut self, comp: ItemId, layer: LayerId, effect: Uid, task: RotoTask, dir: Direction, wait: bool) -> Result<usize, String> {
|
||||
// The model must be in use before the job starts: otherwise it would run on the classic
|
||||
// engine without saying so.
|
||||
// A Freeze whose frames all have saved tracking reads them without the model.
|
||||
if self.uses_roto_model(comp, layer, effect) && !(task == RotoTask::Freeze && self.freeze_is_saved(comp, layer, effect)) {
|
||||
self.ensure_roto_model(wait)?;
|
||||
}
|
||||
if self.is_roto_running() {
|
||||
if !wait {
|
||||
return Err("a Roto Brush job is already running".into());
|
||||
@@ -697,6 +778,17 @@ impl Session {
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Save the tracker's frames in the instance, unless its strokes changed while it tracked. No undo
|
||||
/// step: the frames are a cache of the strokes and the span (a stale copy is ignored).
|
||||
fn save_tracks(&mut self, cid: ItemId, lid: LayerId, uid: Uid, strokes: &str, json: String) {
|
||||
let p = Arc::make_mut(&mut self.project);
|
||||
if let Some(g) = p.comp_mut(cid).and_then(|c| c.layer_mut(lid)).and_then(|l| l.props.find_group_mut(uid))
|
||||
&& guard_of(g) == strokes
|
||||
{
|
||||
set_str(g, TRACKED, json);
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish a done job: Freeze writes the frozen mattes (one undo step).
|
||||
fn poll_roto_job(&mut self) -> bool {
|
||||
let Some(job) = &self.roto_job else { return false };
|
||||
@@ -723,6 +815,10 @@ impl Session {
|
||||
RotoTask::Propagate => {
|
||||
let verb = if job.object_matte { "tracked" } else { "propagated" };
|
||||
self.events.push(Event::Toast { message: format!("{name}: {verb} {} frame(s) in {:.1} s", st.done, st.elapsed), error: false });
|
||||
// The frames the tracker followed are saved with the instance.
|
||||
if let Some(json) = lock(&job.shared.saved).take() {
|
||||
self.save_tracks(job.comp, job.layer, job.effect, &job.strokes, json);
|
||||
}
|
||||
// Rendered frames depend on the cache: redraw.
|
||||
self.bump();
|
||||
}
|
||||
@@ -757,17 +853,39 @@ impl Session {
|
||||
pub fn poll_roto(&mut self, auto: bool) -> bool {
|
||||
self.poll_models();
|
||||
let changed = self.poll_roto_job();
|
||||
if auto && self.roto_job.is_none() && self.offloaded_roto().is_none() && self.state.roto.auto_propagate {
|
||||
// Edited instances stay pending while the model they will segment with is still loading.
|
||||
let loading = self.roto_model_loading();
|
||||
if auto && self.roto_job.is_none() && self.offloaded_roto().is_none() && self.state.roto.auto_propagate && !loading {
|
||||
while let Some((c, l, e)) = self.roto_pending.first().copied() {
|
||||
self.roto_pending.remove(0);
|
||||
if self.start_roto(c, l, e, RotoTask::Propagate, Direction::Both, false).is_ok() {
|
||||
return true;
|
||||
match self.start_roto(c, l, e, RotoTask::Propagate, Direction::Both, false) {
|
||||
Ok(_) => return true,
|
||||
Err(message) => self.events.push(Event::Toast { message, error: true }),
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// How far to trust the frames a tracker followed (SAM 2), by layer frame: its confidence and
|
||||
/// whether the object was absent. Frames the classic engine made have no entry.
|
||||
pub fn roto_trust(&self, comp: ItemId, layer: LayerId, effect: Uid) -> BTreeMap<i64, fx::FrameTrust> {
|
||||
let mut out = BTreeMap::new();
|
||||
let Some(c) = self.project.comp(comp) else { return out };
|
||||
let Some(l) = c.layer(layer) else { return out };
|
||||
let Some(g) = l.props.find_group(effect) else { return out };
|
||||
if is_object_matte(g) {
|
||||
return out;
|
||||
}
|
||||
let ch = fx::Chain::new(¶ms_static(g), layer_size(&self.project, c, l), c.frame_rate.as_f64(), 1.0);
|
||||
for f in ch.keys.keys() {
|
||||
if let Some(t) = ch.trust_at(*f) {
|
||||
out.insert(*f, t);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The computed frames of an instance (full resolution), by layer frame.
|
||||
pub fn roto_computed(&self, comp: ItemId, layer: LayerId, effect: Uid) -> BTreeMap<i64, bool> {
|
||||
let mut out = BTreeMap::new();
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Roto Brush Stroke",
|
||||
[],
|
||||
None,
|
||||
"{layer?, kind?: fg|bg|refine|refineErase, points: [[x, y], …] (layer pixels), frame? (layer frame; default current), radius? (layer px; default the tool's diameter / 2), effect?}",
|
||||
"{layer?, kind?: fg|bg|refine|refineErase, points: [[x, y], …] (layer pixels), frame? (layer frame; default current), radius? (layer px; default the tool's diameter / 2), effect?, object? (0–31; default the current object: with a tracker each object is followed on its own and the mattes are united)}",
|
||||
has_comp,
|
||||
stroke
|
||||
),
|
||||
@@ -43,14 +43,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("roto.span", "Set Segmentation Span", [], None, "{layer?, effect?, start?, end? (layer frames)}", has_comp, span),
|
||||
cmd!("roto.freeze", "Freeze", [], None, "{layer?, effect?, wait?}", has_comp, freeze),
|
||||
cmd!("roto.unfreeze", "Unfreeze", [], None, "{layer?, effect?}", has_comp, unfreeze),
|
||||
cmd!("roto.clearStrokes", "Remove Roto Brush Strokes", [], None, "{layer?, effect?, frame? (only this layer frame), kind?}", has_comp, clear_strokes),
|
||||
cmd!(
|
||||
"roto.clearStrokes",
|
||||
"Remove Roto Brush Strokes",
|
||||
[],
|
||||
None,
|
||||
"{layer?, effect?, frame? (only this layer frame), kind?, object? (only this object's strokes)}",
|
||||
has_comp,
|
||||
clear_strokes
|
||||
),
|
||||
cmd!("roto.cancel", "Stop Roto Brush", [], None, "{}", is_running, |s, _| Ok(json!({"stopped": s.stop_roto()}))),
|
||||
cmd!(
|
||||
"roto.options",
|
||||
"Roto Brush Options",
|
||||
[],
|
||||
None,
|
||||
"{diameter?, refineDiameter?, view?: alphaBoundary|alpha|alphaOverlay|none, overlayColor?: [r,g,b], overlayOpacity?, boundaryColor?: [r,g,b], autoPropagate?}",
|
||||
"{diameter?, refineDiameter?, view?: alphaBoundary|alpha|alphaOverlay|none, overlayColor?: [r,g,b], overlayOpacity?, boundaryColor?: [r,g,b], autoPropagate?, object? (the object new strokes belong to, 0–31)}",
|
||||
always,
|
||||
options
|
||||
),
|
||||
@@ -196,6 +204,8 @@ fn points_p(p: &Value, cmd: &str) -> Result<Vec<[f64; 2]>> {
|
||||
|
||||
fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "roto.stroke";
|
||||
// A model unloaded for being idle comes back before the stroke is segmented with it.
|
||||
s.reload_roto_model();
|
||||
let (cid, lid) = match roto_p(s, p, cmd) {
|
||||
Ok((c, l, _)) => (c, l),
|
||||
Err(_) => layer_p(s, p, cmd)?,
|
||||
@@ -230,7 +240,8 @@ fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if existing.is_none() && !kind.is_segmentation() {
|
||||
return Err(bad(cmd, "draw a foreground stroke with the Roto Brush tool first"));
|
||||
}
|
||||
let st = Stroke { kind, frame, radius, points };
|
||||
let object = f_p(p, "object").map_or(s.state.roto.object, |v| v.clamp(0.0, rt::MAX_OBJECT_ID as f64).round() as u32);
|
||||
let st = Stroke { kind, frame, radius, points, object };
|
||||
let label = if kind.is_segmentation() { "Roto Brush Stroke" } else { "Refine Edge Stroke" };
|
||||
let (uid, d) = s.edit(label, None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
@@ -261,7 +272,7 @@ fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.state.selected_layers = vec![lid];
|
||||
}
|
||||
Ok(json!({
|
||||
"layer": lid.0, "effect": uid, "frame": frame, "kind": kind, "radius": radius,
|
||||
"layer": lid.0, "effect": uid, "frame": frame, "kind": kind, "radius": radius, "object": object,
|
||||
"base": d.base, "span": d.span, "strokes": d.strokes.len(),
|
||||
}))
|
||||
}
|
||||
@@ -367,8 +378,20 @@ fn clear_strokes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(k) => Some(StrokeKind::from_name(k).ok_or_else(|| bad(cmd, format!("unknown kind `{k}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let object = match p.get("object") {
|
||||
None => None,
|
||||
Some(value) => {
|
||||
let value = value
|
||||
.as_f64()
|
||||
.filter(|v| v.is_finite() && (0.0..=rt::MAX_OBJECT_ID as f64).contains(v))
|
||||
.ok_or_else(|| bad(cmd, format!("`object` is 0–{}", rt::MAX_OBJECT_ID)))?;
|
||||
Some(value.round() as u32)
|
||||
}
|
||||
};
|
||||
let n0 = d.strokes.len();
|
||||
d.strokes.retain(|st| !(frame.is_none_or(|f| st.frame == f) && kind.is_none_or(|k| st.kind == k)));
|
||||
d.strokes.retain(|st| {
|
||||
!(frame.is_none_or(|f| st.frame == f) && kind.is_none_or(|k| st.kind == k) && object.is_none_or(|o| st.kind.is_segmentation() && st.object == o))
|
||||
});
|
||||
if d.strokes.iter().all(|s| !s.kind.is_segmentation()) {
|
||||
d.base = None;
|
||||
} else if d.base.is_some_and(|b| !d.strokes.iter().any(|s| s.frame == b && s.kind.is_segmentation())) {
|
||||
@@ -410,6 +433,9 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(b) = b_p(p, "autoPropagate") {
|
||||
o.auto_propagate = b;
|
||||
}
|
||||
if let Some(v) = f_p(p, "object") {
|
||||
o.object = v.clamp(0.0, rt::MAX_OBJECT_ID as f64).round() as u32;
|
||||
}
|
||||
s.state.roto = o.clone();
|
||||
Ok(serde_json::to_value(o).unwrap_or(Value::Null))
|
||||
}
|
||||
@@ -428,9 +454,13 @@ fn status(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
out["base"] = json!(d.base);
|
||||
out["span"] = json!(d.span);
|
||||
out["strokes"] = json!(d.strokes.len());
|
||||
out["objects"] = json!(d.objects().iter().map(|o| json!({"id": o, "strokes": d.strokes.iter().filter(|s| s.object == *o).count()})).collect::<Vec<_>>());
|
||||
out["currentObject"] = json!(s.state.roto.object);
|
||||
out["strokeFrames"] = json!(d.stroke_frames());
|
||||
out["frozen"] = json!(frozen_of(&g));
|
||||
out["pending"] = json!(s.roto_pending.contains(&(cid, lid, uid)));
|
||||
// The model Settings chose and whether this layer segments with it (propagate refuses or waits otherwise).
|
||||
out["model"] = s.roto_model();
|
||||
out["limits"] = json!(rt::frame_limits(&comp, &layer));
|
||||
let frame = frame_p(s, p, cid, &layer);
|
||||
out["frame"] = json!(frame);
|
||||
@@ -441,7 +471,7 @@ fn status(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fps = comp.frame_rate.as_f64();
|
||||
let ps = params_static(&g);
|
||||
let chain = fx::Chain::new(&ps, size, fps, 1.0);
|
||||
let seg = match chain.keys.get(&frame).and_then(|k| fx::cached(*k, 1.0)) {
|
||||
let seg = match chain.cached_seg(frame, 1.0) {
|
||||
Some(sg) => Some(sg),
|
||||
None if compute => {
|
||||
let index = layer.effects().and_then(|f| f.groups().position(|x| x.uid == uid)).unwrap_or(0);
|
||||
@@ -463,6 +493,28 @@ fn status(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Where each frame's matte came from, and how far to trust it: frames a tracker followed
|
||||
// report its confidence and whether the object was absent; frames the model segmented on
|
||||
// their own report "model"; the rest came from the classic engine.
|
||||
{
|
||||
let size = rt::layer_size(&s.project, &comp, &layer);
|
||||
let chain = fx::Chain::new(¶ms_static(&g), size, comp.frame_rate.as_f64(), 1.0);
|
||||
let tracked: serde_json::Map<String, Value> = chain
|
||||
.keys
|
||||
.keys()
|
||||
.filter_map(|f| chain.trust_at(*f).map(|t| (f.to_string(), json!({"confidence": t.confidence, "occluded": t.occluded}))))
|
||||
.collect();
|
||||
out["tracker"] = json!({"model": s.models_roto_active(), "device": fx::installed_model().and_then(|m| m.device_name()), "saved": chain.saved_model(), "frames": tracked});
|
||||
let notice = s.roto_model_notice(s.uses_roto_model(cid, lid, uid)).or_else(|| s.saved_tracking_notice(chain.saved_model()));
|
||||
out["notice"] = json!(notice);
|
||||
out["source"] = json!(if tracked.contains_key(&frame.to_string()) {
|
||||
"tracker"
|
||||
} else if chain.model.is_some() {
|
||||
"model"
|
||||
} else {
|
||||
"classical"
|
||||
});
|
||||
}
|
||||
let computed = s.roto_computed(cid, lid, uid);
|
||||
out["computed"] = json!(computed.iter().filter(|(_, c)| **c).map(|(f, _)| *f).collect::<Vec<_>>());
|
||||
Ok(out)
|
||||
|
||||
@@ -15,6 +15,32 @@ fn session(tag: &str) -> (Session, std::path::PathBuf) {
|
||||
(Session { config: Some(store), ..Default::default() }, d)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_list_and_direct_selection_agree_on_target_availability() {
|
||||
super::roto::hold_roto_cache_test_lock();
|
||||
let mut s = Session::default();
|
||||
let list = s.execute_checked("roto.models", json!({})).unwrap();
|
||||
let models = list["models"].as_array().unwrap();
|
||||
for id in ["sam2.1-hiera-tiny", "sam2.1-hiera-small", "sam2.1-hiera-base-plus", "sam2.1-hiera-large"] {
|
||||
let chosen = s.chosen_model(Task::Mask).to_string();
|
||||
let generation = s.models.status(Task::Mask).generation;
|
||||
let selected = s.execute_checked("roto.model.select", json!({"id": id}));
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
assert!(!models.iter().any(|m| m["id"] == id));
|
||||
assert!(selected.is_err());
|
||||
assert_eq!(s.chosen_model(Task::Mask), chosen);
|
||||
assert_eq!(s.models.status(Task::Mask).generation, generation, "rejection must not alter model state");
|
||||
} else {
|
||||
assert!(models.iter().any(|m| m["id"] == id));
|
||||
selected.unwrap();
|
||||
assert_eq!(s.chosen_model(Task::Mask), id);
|
||||
}
|
||||
}
|
||||
s.execute_checked("roto.model.select", json!({"id": "classical"})).unwrap();
|
||||
assert_eq!(s.chosen_model(Task::Mask), "classical");
|
||||
assert!(models.iter().any(|m| m["id"] == "mobilesam"));
|
||||
}
|
||||
|
||||
/// Poll until `task`'s model is in use (or a few seconds pass).
|
||||
fn wait_loaded(s: &mut Session, task: Task) {
|
||||
for _ in 0..400 {
|
||||
@@ -36,7 +62,13 @@ fn models_are_listed_chosen_and_verified() {
|
||||
for (prefix, model, task) in [("roto", "mobilesam", Task::Mask), ("face", "mediapipe-face", Task::Face)] {
|
||||
let list = s.execute_checked(&format!("{prefix}.models"), json!({})).unwrap();
|
||||
let ids: Vec<&str> = list["models"].as_array().unwrap().iter().map(|m| m["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["classical", model]);
|
||||
// Roto Brush also offers SAM 2.1 in four sizes (video trackers; see crates/sam2).
|
||||
let expect: Vec<&str> = if task == Task::Mask && !cfg!(target_arch = "wasm32") {
|
||||
vec!["classical", model, "sam2.1-hiera-tiny", "sam2.1-hiera-small", "sam2.1-hiera-base-plus", "sam2.1-hiera-large"]
|
||||
} else {
|
||||
vec!["classical", model]
|
||||
};
|
||||
assert_eq!(ids, expect);
|
||||
let m = &list["models"][1];
|
||||
assert_eq!((m["licence"].as_str(), m["installed"].as_bool()), (Some("Apache-2.0"), Some(false)));
|
||||
assert!(m["url"].as_str().unwrap().starts_with("https://") && !m["authors"].as_str().unwrap().is_empty());
|
||||
@@ -69,6 +101,8 @@ fn models_are_listed_chosen_and_verified() {
|
||||
s.execute_checked("roto.model.select", json!({"id": "classical"})).unwrap();
|
||||
assert!(crate::effects::roto::model_for(¶ms).is_none());
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
verify_installed_sam2_sizes(&mut s);
|
||||
// EFFECTCRAFT_FACE_LANDMARKER=path/to/face_landmarker.task: installed with its notice,
|
||||
// loaded for face tracking, dropped when the classic tracker is chosen, removed.
|
||||
if let Ok(path) = std::env::var("EFFECTCRAFT_FACE_LANDMARKER") {
|
||||
@@ -86,3 +120,163 @@ fn models_are_listed_chosen_and_verified() {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn verify_installed_sam2_sizes(s: &mut Session) {
|
||||
// The SAM 2.1 sizes, each from its own file (EFFECTCRAFT_SAM2, _SAM2_SMALL, _SAM2_BASE_PLUS, _SAM2_LARGE =
|
||||
// path/to/model.safetensors of facebook/sam2.1-hiera-*): installed with its notice, loaded (verified, native
|
||||
// only) and used by Roto Brush 3.0, then dropped and removed.
|
||||
for (var, id) in [
|
||||
("EFFECTCRAFT_SAM2", "sam2.1-hiera-tiny"),
|
||||
("EFFECTCRAFT_SAM2_SMALL", "sam2.1-hiera-small"),
|
||||
("EFFECTCRAFT_SAM2_BASE_PLUS", "sam2.1-hiera-base-plus"),
|
||||
("EFFECTCRAFT_SAM2_LARGE", "sam2.1-hiera-large"),
|
||||
] {
|
||||
let Ok(path) = std::env::var(var) else { continue };
|
||||
let r = s.execute_checked("roto.model.install", json!({"path": path})).unwrap();
|
||||
assert_eq!(r["id"], id, "{var}");
|
||||
let dir = s.models_dir().unwrap();
|
||||
let notice = std::fs::read_to_string(dir.join(format!("{id}.safetensors.NOTICE.txt"))).unwrap();
|
||||
assert!(notice.contains("Meta AI") && notice.contains("Apache-2.0"), "{notice}");
|
||||
s.execute_checked("roto.model.select", json!({"id": id})).unwrap();
|
||||
wait_loaded(s, Task::Mask);
|
||||
assert_eq!(s.models.status(Task::Mask).active.as_deref(), Some(id), "{var}");
|
||||
let params = crate::effects::Params { values: [("version".to_string(), effectcraft_keyframe::Value::Enum(2))].into_iter().collect() };
|
||||
assert_eq!(crate::effects::roto::model_for(¶ms).map(|m| m.info().id), Some(id));
|
||||
// the list says it is installed, and exactly one size is recommended
|
||||
let list = s.execute_checked("roto.models", json!({})).unwrap();
|
||||
let entry = list["models"].as_array().unwrap().iter().find(|m| m["id"] == id).unwrap();
|
||||
assert_eq!(entry["installed"], true);
|
||||
assert_eq!(list["models"].as_array().unwrap().iter().filter(|m| m["recommended"] == true).count(), 1);
|
||||
s.execute_checked("roto.model.select", json!({"id": "classical"})).unwrap();
|
||||
assert!(crate::effects::roto::model_for(¶ms).is_none());
|
||||
assert_eq!(s.execute_checked("roto.model.remove", json!({"id": id})).unwrap()["removed"], true);
|
||||
assert!(!dir.join(format!("{id}.safetensors")).exists(), "{id} removed");
|
||||
}
|
||||
}
|
||||
|
||||
/// A Roto Brush model that has been idle for ten minutes is unloaded (the choice stays), one in use is not,
|
||||
/// and a model that does not track use is never unloaded.
|
||||
#[test]
|
||||
fn an_idle_roto_model_is_unloaded() {
|
||||
use effectcraft_segment::{Loaded, MaskModel, ModelInfo, Prompt};
|
||||
struct Fake(Option<f64>);
|
||||
impl MaskModel for Fake {
|
||||
fn info(&self) -> &'static ModelInfo {
|
||||
&effectcraft_segment::SAM2_TINY
|
||||
}
|
||||
fn segment(&self, _: &[[f32; 3]], _: usize, _: usize, _: &Prompt) -> effectcraft_segment::Result<Vec<f32>> {
|
||||
Err("no weights".into())
|
||||
}
|
||||
fn idle_secs(&self) -> Option<f64> {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
crate::tests::roto::hold_roto_cache_test_lock();
|
||||
let (mut s, d) = session("idle");
|
||||
for (idle, unloaded) in [(Some(5.0), false), (None, false), (Some(601.0), true)] {
|
||||
let m: Arc<dyn MaskModel> = Arc::new(Fake(idle));
|
||||
s.models.set_active(Task::Mask, Some(Loaded::Mask(m)));
|
||||
assert_eq!(s.models.status(Task::Mask).active.as_deref(), Some("sam2.1-hiera-tiny"));
|
||||
s.poll_models();
|
||||
assert_eq!(s.models.status(Task::Mask).active.is_none(), unloaded, "idle {idle:?}");
|
||||
assert_eq!(crate::effects::roto::installed_model().is_none(), unloaded, "the effect's model follows");
|
||||
if unloaded {
|
||||
assert!(s.events.iter().any(|e| matches!(e, crate::Event::Toast { message, .. } if message.contains("unloaded after 10 minutes"))));
|
||||
}
|
||||
}
|
||||
// the choice survives, and with the classic engine chosen reloading does nothing
|
||||
s.reload_roto_model();
|
||||
assert!(s.models.status(Task::Mask).active.is_none());
|
||||
crate::effects::roto::set_model(None);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
/// A model that finishes loading after the choice changed is dropped, not activated.
|
||||
#[test]
|
||||
fn a_load_for_an_old_choice_does_not_activate() {
|
||||
use effectcraft_segment::{Loaded, MaskModel, ModelInfo, Prompt};
|
||||
struct Fake;
|
||||
impl MaskModel for Fake {
|
||||
fn info(&self) -> &'static ModelInfo {
|
||||
&effectcraft_segment::SAM2_TINY
|
||||
}
|
||||
fn segment(&self, _: &[[f32; 3]], _: usize, _: usize, _: &Prompt) -> effectcraft_segment::Result<Vec<f32>> {
|
||||
Err("no weights".into())
|
||||
}
|
||||
}
|
||||
super::roto::hold_roto_cache_test_lock();
|
||||
let (mut s, d) = session("stale-load");
|
||||
let started = s.models.status(Task::Mask).generation;
|
||||
// the user picks another engine while the load runs
|
||||
s.execute_checked("roto.model.select", json!({"id": "classical"})).unwrap();
|
||||
s.execute_checked("roto.model.select", json!({"id": "sam2.1-hiera-tiny"})).unwrap();
|
||||
assert_ne!(s.models.status(Task::Mask).generation, started, "a changed choice is counted");
|
||||
let m: Arc<dyn MaskModel> = Arc::new(Fake);
|
||||
assert!(!s.models.set_active_if_current(Task::Mask, Loaded::Mask(m.clone()), started), "the stale load is dropped");
|
||||
assert!(s.models.status(Task::Mask).active.is_none() && crate::effects::roto::installed_model().is_none());
|
||||
let now = s.models.status(Task::Mask).generation;
|
||||
assert!(s.models.set_active_if_current(Task::Mask, Loaded::Mask(m), now), "a load for the current choice activates");
|
||||
assert_eq!(s.models.status(Task::Mask).active.as_deref(), Some("sam2.1-hiera-tiny"));
|
||||
crate::effects::roto::set_model(None);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
/// A stroke reloading an unloaded model never waits for the load and never retries one that already failed.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn reloading_for_a_stroke_does_not_block_or_retry_a_failed_load() {
|
||||
super::roto::hold_roto_cache_test_lock();
|
||||
let (mut s, d) = session("reload-failed");
|
||||
let info = effectcraft_segment::info("mobilesam").unwrap();
|
||||
let dir = s.models_dir().unwrap();
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// the right size, the wrong content: found as installed, then fails to load
|
||||
std::fs::File::create(dir.join(info.file_name)).unwrap().set_len(info.size).unwrap();
|
||||
s.execute_checked("roto.model.select", json!({"id": "mobilesam"})).unwrap();
|
||||
let t0 = std::time::Instant::now();
|
||||
while s.models.status(Task::Mask).busy.is_some() && t0.elapsed().as_secs() < 30 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
let failed = s.models.status(Task::Mask).error;
|
||||
assert!(failed.is_some(), "the unreadable file fails to load");
|
||||
s.reload_roto_model();
|
||||
let st = s.models.status(Task::Mask);
|
||||
assert!(st.busy.is_none(), "a failed model is not loaded again by every stroke");
|
||||
assert_eq!(st.error, failed);
|
||||
s.poll_models();
|
||||
let st = s.models.status(Task::Mask);
|
||||
assert_eq!(st.error, failed, "polling must keep the actionable error until an explicit retry");
|
||||
assert!(st.busy.is_none(), "a failed load must not restart on every poll");
|
||||
// an unloaded, not failed model loads on the worker: the call returns with the load still pending or done
|
||||
s.execute_checked("roto.model.select", json!({"id": "classical"})).unwrap();
|
||||
crate::effects::roto::set_model(None);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
/// With `EFFECTCRAFT_DOWNLOAD_SIZE=mobilesam` (or another downloadable registry id): the in-app
|
||||
/// download verifies and loads the file. SAM 2 must refuse until a storytold mirror is available.
|
||||
/// Skipped without the variable: downloadable entries need the network and large files.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn downloading_a_size_installs_and_loads_it() {
|
||||
let Ok(id) = std::env::var("EFFECTCRAFT_DOWNLOAD_SIZE") else { return };
|
||||
crate::tests::roto::hold_roto_cache_test_lock();
|
||||
let (mut s, d) = session("download");
|
||||
let info = effectcraft_segment::info(&id).unwrap();
|
||||
if info.url.is_empty() {
|
||||
assert!(s.execute_checked("roto.model.download", json!({"id": id, "wait": true})).unwrap_err().to_string().contains("Install from File"));
|
||||
return;
|
||||
}
|
||||
let r = s.execute_checked("roto.model.download", json!({"id": id, "wait": true})).unwrap();
|
||||
assert_eq!(r["id"], id.as_str(), "{r}");
|
||||
let dir = s.models_dir().unwrap();
|
||||
let file = dir.join(info.file_name);
|
||||
assert_eq!(std::fs::metadata(&file).unwrap().len(), info.size, "the whole file arrived");
|
||||
assert!(dir.join(format!("{}.NOTICE.txt", info.file_name)).exists());
|
||||
assert!(!dir.join(format!("{}.part", info.file_name)).exists(), "no partial file is left");
|
||||
s.execute_checked("roto.model.select", json!({"id": id})).unwrap();
|
||||
wait_loaded(&mut s, Task::Mask);
|
||||
assert_eq!(s.models.status(Task::Mask).active.as_deref(), Some(id.as_str()));
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
@@ -668,3 +668,494 @@ fn object_matte_mattes_persist_in_the_disk_cache() {
|
||||
assert!(om::ensure(&moved, size, 25.0, 0, None, &mut |_| None, &|| false).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// SAM 2 tracks the clip end to end (needs the real weights: `EFFECTCRAFT_SAM2=path/to/model.safetensors`).
|
||||
/// Every frame comes from the tracker with a confidence, the mattes are right, and nothing changes
|
||||
/// once the model is deselected.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn sam2_tracks_the_clip_through_roto_propagate() {
|
||||
let Ok(weights) = std::env::var("EFFECTCRAFT_SAM2") else { return };
|
||||
let dir = std::env::temp_dir().join(format!("ec-roto-sam2-{}", std::process::id()));
|
||||
hold_roto_cache_test_lock();
|
||||
let (mut s, clip, _) = painted(center, "disk-sam2.mov");
|
||||
s.models_dir = Some(dir.join("models"));
|
||||
std::fs::create_dir_all(dir.join("models")).unwrap();
|
||||
s.execute_checked("roto.model.install", json!({"path": weights})).unwrap();
|
||||
s.execute_checked("roto.model.select", json!({"id": "sam2.1-hiera-tiny", "wait": true})).unwrap();
|
||||
assert_eq!(s.models.status(effectcraft_segment::Task::Mask).active.as_deref(), Some("sam2.1-hiera-tiny"));
|
||||
let r = s.execute_checked("roto.propagate", json!({"layer": clip.0, "direction": "both", "wait": true})).unwrap();
|
||||
assert_eq!(r["frames"], 21, "{r}");
|
||||
let mut worst: f64 = 1.0;
|
||||
for f in 0..=20 {
|
||||
let j = iou_at(&mut s, clip, f, center(f));
|
||||
let st = s.execute("roto.status", json!({"layer": clip.0, "frame": f})).unwrap();
|
||||
eprintln!("frame {f}: IoU {j:.3}, {:?}", st["tracker"]["frames"][f.to_string()]);
|
||||
worst = worst.min(j);
|
||||
}
|
||||
assert!(worst > 0.9, "worst IoU over the span with SAM 2: {worst}");
|
||||
let st = s.execute("roto.status", json!({"layer": clip.0, "frame": 12})).unwrap();
|
||||
assert_eq!(st["source"], "tracker", "{st}");
|
||||
let frames = st["tracker"]["frames"].as_object().unwrap();
|
||||
assert_eq!(frames.len(), 21);
|
||||
assert!(frames.values().all(|t| t["occluded"] == false && t["confidence"].as_f64().unwrap() > 0.3), "{frames:?}");
|
||||
assert_eq!(st["tracker"]["model"], "sam2.1-hiera-tiny");
|
||||
// Settings choose the classic engine: the saved tracking stays until a Propagate replaces it.
|
||||
s.execute_checked("roto.model.select", json!({"id": "classical", "wait": true})).unwrap();
|
||||
let st = s.execute("roto.status", json!({"layer": clip.0, "frame": 12})).unwrap();
|
||||
assert_eq!(st["source"], "tracker", "{st}");
|
||||
assert!(st["notice"].as_str().unwrap().contains("Propagate again"), "{st}");
|
||||
assert_eq!(st["computed"].as_array().unwrap().len(), 21, "still computed under another model: {st}");
|
||||
// A classic Propagate: its own segmentation, no tracker data.
|
||||
s.execute_checked("roto.propagate", json!({"layer": clip.0, "direction": "both", "wait": true})).unwrap();
|
||||
let st = s.execute("roto.status", json!({"layer": clip.0, "frame": 12})).unwrap();
|
||||
assert_eq!(st["source"], "classical", "{st}");
|
||||
assert_eq!(st["tracker"]["frames"].as_object().unwrap().len(), 0);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Choosing a model that is not installed must not fall back to the classic engine silently:
|
||||
/// propagate refuses and `roto.status` says why. Choosing the classic engine runs it again.
|
||||
#[test]
|
||||
fn a_selected_model_that_is_not_installed_refuses_to_propagate() {
|
||||
hold_roto_cache_test_lock();
|
||||
let (mut s, clip, _) = painted(center, "disk-not-installed.mov");
|
||||
s.models_dir = Some(std::env::temp_dir().join(format!("ec-roto-none-{}", std::process::id())));
|
||||
s.execute_checked("roto.model.select", json!({"id": "sam2.1-hiera-tiny"})).unwrap();
|
||||
let st = s.execute("roto.status", json!({"layer": clip.0})).unwrap();
|
||||
assert_eq!(st["model"]["selected"], "sam2.1-hiera-tiny", "{st}");
|
||||
assert_eq!(st["model"]["installed"], false, "{st}");
|
||||
assert!(st["notice"].as_str().unwrap().contains("not installed"), "{st}");
|
||||
let e = s.execute_checked("roto.propagate", json!({"layer": clip.0, "direction": "both", "wait": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("not installed"), "{e}");
|
||||
assert_eq!(s.execute("roto.status", json!({"layer": clip.0, "frame": 10})).unwrap()["source"], "classical");
|
||||
s.execute_checked("roto.model.select", json!({"id": "classical"})).unwrap();
|
||||
let r = s.execute_checked("roto.propagate", json!({"layer": clip.0, "direction": "both", "wait": true})).unwrap();
|
||||
assert_eq!(r["frames"], 21, "{r}");
|
||||
assert!(s.execute("roto.status", json!({"layer": clip.0})).unwrap()["notice"].is_null());
|
||||
}
|
||||
|
||||
/// Selecting SAM 2 without `wait` and propagating at once: propagate loads the model first, so the
|
||||
/// tracker (not the classic engine) does the work. Needs EFFECTCRAFT_SAM2 like the test above.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn sam2_selected_without_wait_is_loaded_before_propagate() {
|
||||
let Ok(weights) = std::env::var("EFFECTCRAFT_SAM2") else { return };
|
||||
hold_roto_cache_test_lock();
|
||||
let dir = std::env::temp_dir().join(format!("ec-roto-sam2-wait-{}", std::process::id()));
|
||||
let (mut s, clip, _) = painted(center, "disk-sam2-wait.mov");
|
||||
s.models_dir = Some(dir.join("models"));
|
||||
std::fs::create_dir_all(dir.join("models")).unwrap();
|
||||
s.execute_checked("roto.model.install", json!({"path": weights})).unwrap();
|
||||
s.execute_checked("roto.model.select", json!({"id": "sam2.1-hiera-tiny"})).unwrap();
|
||||
let r = s.execute_checked("roto.propagate", json!({"layer": clip.0, "direction": "both", "wait": true})).unwrap();
|
||||
assert_eq!(r["frames"], 21, "{r}");
|
||||
let st = s.execute("roto.status", json!({"layer": clip.0, "frame": 12})).unwrap();
|
||||
assert_eq!(st["source"], "tracker", "{st}");
|
||||
assert_eq!(st["tracker"]["model"], "sam2.1-hiera-tiny");
|
||||
assert!(st["notice"].is_null(), "{st}");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A tracker that follows the disc exactly: its true mask on every frame of the request. A stand-in
|
||||
/// for SAM 2, so the saved tracking can be tested without the weights.
|
||||
struct Exact;
|
||||
|
||||
impl effectcraft_segment::MaskModel for Exact {
|
||||
fn info(&self) -> &'static effectcraft_segment::ModelInfo {
|
||||
&effectcraft_segment::SAM2_TINY
|
||||
}
|
||||
fn segment(&self, _: &[[f32; 3]], _: usize, _: usize, _: &effectcraft_segment::Prompt) -> effectcraft_segment::Result<Vec<f32>> {
|
||||
Err("no weights".into())
|
||||
}
|
||||
fn tracker(&self) -> Option<&dyn effectcraft_segment::ObjectTracker> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl effectcraft_segment::ObjectTracker for Exact {
|
||||
fn track(
|
||||
&self,
|
||||
req: &effectcraft_segment::TrackRequest,
|
||||
frame: &mut dyn FnMut(i64) -> Option<std::sync::Arc<effectcraft_segment::ClipFrame>>,
|
||||
_: &dyn Fn() -> bool,
|
||||
out: &mut dyn FnMut(usize, i64, effectcraft_segment::TrackedFrame),
|
||||
) -> effectcraft_segment::Result<()> {
|
||||
for g in req.first..req.first + req.count as i64 {
|
||||
let Some(img) = frame(g) else { return Err(format!("no frame {g}")) };
|
||||
let c = center(g as u32);
|
||||
let prob = (0..img.w * img.h)
|
||||
.map(|i| ((((i % img.w) as f64 + 0.5 - c[0]).hypot((i / img.w) as f64 + 0.5 - c[1])) <= R) as u8 as f32)
|
||||
.collect::<Vec<f32>>();
|
||||
out(0, g, effectcraft_segment::TrackedFrame { prob, w: img.w, h: img.h, confidence: 0.99, occluded: false });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Saved tracking is part of the project: written with the instance, it applies when the project
|
||||
/// opens, with or without the model loaded, and Settings choosing the classic engine leaves it in place
|
||||
/// until a Propagate replaces it.
|
||||
#[test]
|
||||
fn saved_tracking_is_kept_in_the_project_and_applies_when_it_reopens() {
|
||||
let sam2 = "sam2.1-hiera-tiny";
|
||||
let (s, clip, uid, path) = saved_project("saved");
|
||||
// Reopened with SAM 2 chosen but not loaded (none is installed here): the saved frames apply.
|
||||
forget(&s, clip, uid);
|
||||
let mut s2 = Session::default();
|
||||
s2.execute_checked("file.open", json!({"path": path})).unwrap();
|
||||
s2.execute_checked("roto.model.select", json!({"id": sam2})).unwrap();
|
||||
let st = s2.execute("roto.status", json!({"layer": clip.0, "frame": 12})).unwrap();
|
||||
assert_eq!(st["source"], "tracker", "{st}");
|
||||
assert_eq!(st["tracker"]["saved"], sam2, "{st}");
|
||||
assert_eq!(st["tracker"]["frames"].as_object().unwrap().len(), 21, "{st}");
|
||||
// The span bar's "computed" frames include the saved ones before anything has been decoded.
|
||||
assert_eq!(st["computed"].as_array().unwrap().len(), 21, "{st}");
|
||||
let j = iou_at(&mut s2, clip, 12, center(12));
|
||||
assert!(j > 0.9, "IoU of a saved frame: {j}");
|
||||
// The render path (Chain::ensure) reads the same frame.
|
||||
let comp = s2.active_comp().unwrap();
|
||||
let l = comp.layer(clip).unwrap();
|
||||
let g = l.props.find_group(uid).unwrap();
|
||||
let size = crate::roto::layer_size(&s2.project, comp, l);
|
||||
let ch = effectcraft_effects::roto::Chain::new(&crate::roto::params_static(g), size, comp.frame_rate.as_f64(), 1.0);
|
||||
let seg = ch.ensure(12, &mut |_| None, &|| false).expect("the render path reads the saved frame");
|
||||
let truth = rle::decode(>(center(12))).unwrap();
|
||||
let inter = seg.matte.iter().zip(&truth).filter(|(a, b)| **a != 0 && **b != 0).count();
|
||||
let uni = seg.matte.iter().zip(&truth).filter(|(a, b)| **a != 0 || **b != 0).count();
|
||||
assert!(inter as f64 / uni as f64 > 0.9, "the render path's frame: IoU {}", inter as f64 / uni as f64);
|
||||
// Settings choose the classic engine: the saved frames still apply, and the notice says so.
|
||||
s2.execute_checked("roto.model.select", json!({"id": "classical"})).unwrap();
|
||||
let st = s2.execute("roto.status", json!({"layer": clip.0, "frame": 12})).unwrap();
|
||||
assert_eq!(st["source"], "tracker", "{st}");
|
||||
assert!(st["notice"].as_str().unwrap().contains("Propagate again"), "{st}");
|
||||
// A classic Propagate replaces the saved tracking with nothing.
|
||||
// The synthetic source is not a file on disk; give the reopened session its provider before
|
||||
// asking it to recompute frames. The checks above intentionally rendered without that provider.
|
||||
s2.footage = s.footage.clone();
|
||||
s2.execute_checked("roto.propagate", json!({"layer": clip.0, "direction": "both", "wait": true})).unwrap();
|
||||
let st = s2.execute("roto.status", json!({"layer": clip.0, "frame": 12})).unwrap();
|
||||
assert_eq!(st["source"], "classical", "{st}");
|
||||
assert!(st["tracker"]["saved"].is_null() && st["notice"].is_null(), "{st}");
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap());
|
||||
}
|
||||
|
||||
/// A reopened project propagated again with the same model and nothing new to track keeps the tracking it has: the
|
||||
/// saved frames are not replaced by an empty one, so a later reopen still finds them.
|
||||
#[test]
|
||||
fn propagating_with_nothing_new_keeps_the_saved_tracking() {
|
||||
let (s, clip, uid, path) = saved_project("keep-saved");
|
||||
forget(&s, clip, uid);
|
||||
let mut s2 = Session::default();
|
||||
s2.execute_checked("file.open", json!({"path": path})).unwrap();
|
||||
s2.footage = s.footage.clone();
|
||||
effectcraft_effects::roto::set_model(Some(std::sync::Arc::new(Exact)));
|
||||
let result = s2.execute_checked("roto.propagate", json!({"layer": clip.0, "direction": "both", "wait": true}));
|
||||
effectcraft_effects::roto::set_model(None);
|
||||
result.unwrap();
|
||||
let st = s2.execute("roto.status", json!({"layer": clip.0, "frame": 12})).unwrap();
|
||||
assert_eq!(st["tracker"]["saved"], "sam2.1-hiera-tiny", "{st}");
|
||||
assert_eq!(st["tracker"]["frames"].as_object().unwrap().len(), 21, "{st}");
|
||||
// and it is still in the file after saving and reopening
|
||||
s2.execute_checked("file.save", json!({})).unwrap();
|
||||
let mut s3 = Session::default();
|
||||
s3.execute_checked("file.open", json!({"path": path})).unwrap();
|
||||
let st = s3.execute("roto.status", json!({"layer": clip.0, "frame": 12})).unwrap();
|
||||
assert_eq!(st["tracker"]["frames"].as_object().unwrap().len(), 21, "{st}");
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap());
|
||||
}
|
||||
|
||||
/// Freeze reads saved tracking without its model: choosing a model that is not installed does not stop it when
|
||||
/// the saved frames cover every frame it freezes.
|
||||
#[test]
|
||||
fn freeze_uses_complete_saved_tracking_without_the_model() {
|
||||
let (s, clip, uid, path) = saved_project("freeze-saved");
|
||||
forget(&s, clip, uid);
|
||||
let mut s2 = Session::default();
|
||||
s2.execute_checked("file.open", json!({"path": path})).unwrap();
|
||||
s2.footage = s.footage.clone();
|
||||
s2.execute_checked("roto.model.select", json!({"id": "sam2.1-hiera-tiny"})).unwrap();
|
||||
s2.execute_checked("roto.freeze", json!({"layer": clip.0, "wait": true})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap());
|
||||
}
|
||||
|
||||
/// Propagate replaces a fully saved span when a different tracking model is active.
|
||||
#[test]
|
||||
fn propagating_saved_tracking_uses_the_new_model() {
|
||||
struct OtherModel;
|
||||
impl effectcraft_segment::MaskModel for OtherModel {
|
||||
fn info(&self) -> &'static effectcraft_segment::ModelInfo {
|
||||
&effectcraft_segment::SAM2_SMALL
|
||||
}
|
||||
fn segment(&self, _: &[[f32; 3]], _: usize, _: usize, _: &effectcraft_segment::Prompt) -> effectcraft_segment::Result<Vec<f32>> {
|
||||
Err("no weights".into())
|
||||
}
|
||||
fn tracker(&self) -> Option<&dyn effectcraft_segment::ObjectTracker> {
|
||||
Some(&Exact)
|
||||
}
|
||||
}
|
||||
let (mut s, clip, _uid, path) = saved_project("model-switch");
|
||||
effectcraft_effects::roto::set_model(Some(std::sync::Arc::new(OtherModel)));
|
||||
let result = s.execute_checked("roto.propagate", json!({"layer": clip.0, "direction": "both", "wait": true}));
|
||||
effectcraft_effects::roto::set_model(None);
|
||||
result.unwrap();
|
||||
let status = s.execute("roto.status", json!({"layer": clip.0, "frame": 12})).unwrap();
|
||||
assert_eq!(status["tracker"]["saved"], "sam2.1-hiera-small", "{status}");
|
||||
assert_eq!(status["tracker"]["frames"].as_object().unwrap().len(), 21, "{status}");
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap());
|
||||
}
|
||||
|
||||
/// A tracked clip saved to a project file: the session that tracked it, its layer and instance, and the
|
||||
/// file.
|
||||
fn saved_project(name: &str) -> (Session, LayerId, u64, String) {
|
||||
let (mut s, clip, uid) = painted(center, &format!("disk-{name}.mov"));
|
||||
effectcraft_effects::roto::set_model(Some(std::sync::Arc::new(Exact)));
|
||||
let r = s.execute_checked("roto.propagate", json!({"layer": clip.0, "direction": "both", "wait": true}));
|
||||
effectcraft_effects::roto::set_model(None);
|
||||
assert_eq!(r.unwrap()["frames"], 21);
|
||||
let dir = std::env::temp_dir().join(format!("ec-roto-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("tracked.ecproj").to_string_lossy().to_string();
|
||||
s.execute_checked("file.saveAs", json!({"path": path})).unwrap();
|
||||
let saved = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(saved.contains("sam2.1-hiera-tiny"), "the project file carries the tracking and the model that made it");
|
||||
(s, clip, uid, path)
|
||||
}
|
||||
|
||||
/// A stroke change makes the saved tracking stale: the instance is classical until the next Propagate.
|
||||
#[test]
|
||||
fn a_stroke_change_makes_the_saved_tracking_stale() {
|
||||
let (_s, clip, _uid, path) = saved_project("stale");
|
||||
let mut s2 = Session::default();
|
||||
s2.execute_checked("file.open", json!({"path": path})).unwrap();
|
||||
s2.execute_checked("roto.model.select", json!({"id": "sam2.1-hiera-tiny"})).unwrap();
|
||||
assert_eq!(s2.execute("roto.status", json!({"layer": clip.0, "frame": 12})).unwrap()["source"], "tracker");
|
||||
s2.execute_checked("roto.stroke", json!({"layer": clip.0, "kind": "bg", "points": line([10.0, 10.0], [60.0, 12.0]), "radius": 4})).unwrap();
|
||||
let st = s2.execute("roto.status", json!({"layer": clip.0, "frame": 12})).unwrap();
|
||||
assert_eq!(st["source"], "classical", "{st}");
|
||||
assert_eq!(st["tracker"]["frames"].as_object().unwrap().len(), 0, "{st}");
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap());
|
||||
}
|
||||
|
||||
/// A tracker that emits a completed frame before failing must leave only classic results behind.
|
||||
#[test]
|
||||
fn a_partially_failing_tracker_falls_back_to_the_classic_engine() {
|
||||
use effectcraft_segment::{ClipFrame, MaskModel, ModelInfo, ObjectTracker, Prompt, TrackRequest, TrackedFrame};
|
||||
use std::sync::Arc;
|
||||
struct Broken;
|
||||
impl MaskModel for Broken {
|
||||
fn info(&self) -> &'static ModelInfo {
|
||||
&effectcraft_segment::SAM2_TINY
|
||||
}
|
||||
fn segment(&self, _: &[[f32; 3]], _: usize, _: usize, _: &Prompt) -> effectcraft_segment::Result<Vec<f32>> {
|
||||
Err("no weights".into())
|
||||
}
|
||||
fn tracker(&self) -> Option<&dyn ObjectTracker> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
impl ObjectTracker for Broken {
|
||||
fn track(
|
||||
&self,
|
||||
req: &TrackRequest,
|
||||
frame: &mut dyn FnMut(i64) -> Option<Arc<ClipFrame>>,
|
||||
_: &dyn Fn() -> bool,
|
||||
out: &mut dyn FnMut(usize, i64, TrackedFrame),
|
||||
) -> effectcraft_segment::Result<()> {
|
||||
let img = frame(req.first).ok_or("missing test frame")?;
|
||||
for object in 0..req.objects.len() {
|
||||
out(object, req.first, TrackedFrame { prob: vec![1.0; img.w * img.h], w: img.w, h: img.h, confidence: 0.9, occluded: false });
|
||||
}
|
||||
Err("the tracker broke after emitting a frame".into())
|
||||
}
|
||||
}
|
||||
hold_roto_cache_test_lock();
|
||||
let (mut s, clip, uid) = painted(center, "disk-partially-broken-tracker.mov");
|
||||
s.execute_checked("roto.stroke", json!({"layer": clip.0, "object": 1, "points": [center(0)], "radius": 4})).unwrap();
|
||||
effectcraft_effects::roto::set_model(Some(Arc::new(Broken)));
|
||||
let r = s.execute_checked("roto.propagate", json!({"layer": clip.0, "direction": "both", "wait": true}));
|
||||
let st = s.execute("roto.status", json!({"layer": clip.0, "frame": 0}));
|
||||
let comp = s.active_comp().unwrap();
|
||||
let layer = comp.layer(clip).unwrap();
|
||||
let params = crate::roto::params_static(layer.props.find_group(uid).unwrap());
|
||||
let size = crate::roto::layer_size(&s.project, comp, layer);
|
||||
let mut chain = effectcraft_effects::roto::Chain::new(¶ms, size, comp.frame_rate.as_f64(), 1.0);
|
||||
let index = layer.effects().unwrap().groups().position(|g| g.uid == uid).unwrap();
|
||||
let mut source =
|
||||
crate::roto::FrameSource::new(&s.project, s.footage.as_ref(), s.expr.as_deref(), &s.layer_cache, s.active_comp_id().unwrap(), comp, layer, index, size);
|
||||
let mut get = |f: i64| source.get(f);
|
||||
let actual: Option<Vec<_>> = (0..=20).map(|f| chain.ensure(f, &mut get, &|| false)).collect();
|
||||
let retained_object = chain.object_seg(0, 0).is_some() || chain.object_seg(0, 1).is_some();
|
||||
let retained_trust = chain.trust_at(0);
|
||||
// Build the reference only after capturing the fallback, with no model or cached union.
|
||||
effectcraft_effects::roto::forget(chain.keys.values().copied());
|
||||
chain.model = None;
|
||||
let expected: Option<Vec<_>> = (0..=20).map(|f| chain.ensure(f, &mut get, &|| false)).collect();
|
||||
effectcraft_effects::roto::set_model(None);
|
||||
let r = r.unwrap();
|
||||
assert_eq!(r["frames"], 21, "{r}");
|
||||
let st = st.unwrap();
|
||||
assert_ne!(st["source"], "tracker", "{st}");
|
||||
assert_eq!(st["tracker"]["frames"].as_object().unwrap().len(), 0);
|
||||
assert!(!retained_object, "partial per-object mattes must be discarded");
|
||||
assert_eq!(retained_trust, None, "partial tracker confidence must be discarded");
|
||||
for (frame, (actual, expected)) in actual.unwrap().into_iter().zip(expected.unwrap()).enumerate() {
|
||||
assert!(actual == expected, "fallback frame {frame} differs from the classic engine");
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a tracking model the Roto Brush chain is exactly the classic engine: every frame
|
||||
/// equals a direct call of `segment_with` / `propagate_with` (no model), bit for bit.
|
||||
#[test]
|
||||
fn without_a_tracker_the_chain_is_exactly_the_classic_engine() {
|
||||
use effectcraft_track::roto as rb;
|
||||
use std::sync::Arc;
|
||||
let (s, clip, uid) = painted(center, "disk-bitexact.mov");
|
||||
forget(&s, clip, uid);
|
||||
let comp = s.active_comp().unwrap();
|
||||
let l = comp.layer(clip).unwrap();
|
||||
let g = l.props.find_group(uid).unwrap();
|
||||
let size = crate::roto::layer_size(&s.project, comp, l);
|
||||
let chain = effectcraft_effects::roto::Chain::new(&crate::roto::params_static(g), size, comp.frame_rate.as_f64(), 1.0);
|
||||
assert!(!chain.has_tracker());
|
||||
let img = |f: i64| Arc::new(draw(center(f as u32)));
|
||||
let mut prev: Option<(Arc<Image>, rb::FrameSeg)> = None;
|
||||
for f in 0..=5i64 {
|
||||
let got = chain.ensure(f, &mut |g| Some(img(g)), &|| false).unwrap();
|
||||
let cur = img(f);
|
||||
let strokes = chain.data.strokes_at(f);
|
||||
let want = match &prev {
|
||||
None => rb::segment_with(&cur, &strokes, None, &chain.opts, chain.refine_radius, None),
|
||||
Some((pi, ps)) => rb::propagate_with(pi, ps, &cur, &strokes, &chain.opts, chain.refine_radius, None),
|
||||
};
|
||||
assert_eq!(*got, want, "frame {f} differs from the classic engine");
|
||||
prev = Some((cur, want));
|
||||
}
|
||||
forget(&s, clip, uid);
|
||||
}
|
||||
|
||||
/// Records the windows it is asked to track, and follows the disc exactly like [`Exact`].
|
||||
#[derive(Default)]
|
||||
struct Recorder {
|
||||
requests: std::sync::Mutex<Vec<(i64, usize, Vec<i64>)>>,
|
||||
}
|
||||
|
||||
impl effectcraft_segment::MaskModel for Recorder {
|
||||
fn info(&self) -> &'static effectcraft_segment::ModelInfo {
|
||||
&effectcraft_segment::SAM2_TINY
|
||||
}
|
||||
fn segment(&self, _: &[[f32; 3]], _: usize, _: usize, _: &effectcraft_segment::Prompt) -> effectcraft_segment::Result<Vec<f32>> {
|
||||
Err("no weights".into())
|
||||
}
|
||||
fn tracker(&self) -> Option<&dyn effectcraft_segment::ObjectTracker> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl effectcraft_segment::ObjectTracker for Recorder {
|
||||
fn track(
|
||||
&self,
|
||||
req: &effectcraft_segment::TrackRequest,
|
||||
frame: &mut dyn FnMut(i64) -> Option<std::sync::Arc<effectcraft_segment::ClipFrame>>,
|
||||
cancel: &dyn Fn() -> bool,
|
||||
out: &mut dyn FnMut(usize, i64, effectcraft_segment::TrackedFrame),
|
||||
) -> effectcraft_segment::Result<()> {
|
||||
let prompted = req.objects.iter().flatten().map(|(f, _)| *f).collect();
|
||||
self.requests.lock().unwrap().push((req.first, req.count, prompted));
|
||||
effectcraft_segment::ObjectTracker::track(&Exact, req, frame, cancel, out)
|
||||
}
|
||||
}
|
||||
|
||||
/// A correction stroke re-tracks only what it changes: the frames from it on, restarted at the
|
||||
/// corrected frame; the frames before it keep their tracked results.
|
||||
#[test]
|
||||
fn a_correction_re_tracks_only_the_frames_it_changes() {
|
||||
let (mut s, clip, uid) = painted(center, "disk-retrack.mov");
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
assert!(s.roto_trust(cid, clip, uid).is_empty(), "no tracker data before tracking");
|
||||
let rec = std::sync::Arc::new(Recorder::default());
|
||||
effectcraft_effects::roto::set_model(Some(rec.clone()));
|
||||
let run = |s: &mut Session| s.execute_checked("roto.propagate", json!({"layer": clip.0, "direction": "forward", "wait": true}));
|
||||
let first = run(&mut s);
|
||||
assert_eq!(rec.requests.lock().unwrap().as_slice(), [(0, 21, vec![0])], "the first pass tracks the whole span");
|
||||
first.unwrap();
|
||||
// Nothing changed: nothing to track.
|
||||
run(&mut s).unwrap();
|
||||
assert_eq!(rec.requests.lock().unwrap().len(), 1, "an unchanged span is not tracked again");
|
||||
// A correction on frame 8: the frames from 8 on are tracked again, restarting at frame 8.
|
||||
let c = center(8);
|
||||
s.execute_checked("roto.stroke", json!({"layer": clip.0, "frame": 8, "points": line([c[0] - 20.0, c[1]], [c[0] + 20.0, c[1]]), "radius": 6})).unwrap();
|
||||
let again = run(&mut s);
|
||||
let reqs = rec.requests.lock().unwrap().clone();
|
||||
effectcraft_effects::roto::set_model(None);
|
||||
again.unwrap();
|
||||
assert_eq!(reqs.len(), 2);
|
||||
assert_eq!(reqs[1], (8, 13, vec![8]), "the window runs from the corrected frame to the end of the span");
|
||||
// The frames before the correction are still the first pass's, the rest the second's.
|
||||
let st = s.execute("roto.status", json!({"layer": clip.0, "frame": 3})).unwrap();
|
||||
assert_eq!(st["source"], "tracker", "{st}");
|
||||
assert_eq!(st["tracker"]["frames"].as_object().unwrap().len(), 21, "{st}");
|
||||
// The span bar's data: the tracker's confidence for every tracked frame.
|
||||
let trust = s.roto_trust(cid, clip, uid);
|
||||
assert_eq!(trust.len(), 21);
|
||||
assert!(trust.values().all(|t| t.confidence > 0.9 && !t.occluded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strokes_belong_to_the_current_object() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 64, "height": 64, "frameRate": 25, "duration": 1})).unwrap();
|
||||
let layer = s.execute("layer.newSolid", json!({"width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("roto.stroke", json!({"layer": layer, "points": [[10, 10]], "radius": 4})).unwrap();
|
||||
// the current object is an option shared by the tool and the commands
|
||||
s.execute("roto.options", json!({"object": 1})).unwrap();
|
||||
s.execute("roto.stroke", json!({"layer": layer, "points": [[50, 50]], "radius": 4})).unwrap();
|
||||
s.execute("roto.stroke", json!({"layer": layer, "kind": "bg", "points": [[30, 30]], "radius": 4})).unwrap();
|
||||
// an explicit object wins over the option, and is clamped to the supported range
|
||||
s.execute("roto.stroke", json!({"layer": layer, "points": [[20, 50]], "radius": 4, "object": 99})).unwrap();
|
||||
let st = s.execute("roto.status", json!({"layer": layer})).unwrap();
|
||||
assert_eq!(st["currentObject"], 1);
|
||||
assert_eq!(st["objects"], json!([{"id": 0, "strokes": 1}, {"id": 1, "strokes": 2}, {"id": 31, "strokes": 1}]));
|
||||
// removing one object's strokes leaves the others
|
||||
let r = s.execute("roto.clearStrokes", json!({"layer": layer, "object": 1})).unwrap();
|
||||
assert_eq!(r["removed"], 2);
|
||||
let st = s.execute("roto.status", json!({"layer": layer})).unwrap();
|
||||
assert_eq!(st["objects"], json!([{"id": 0, "strokes": 1}, {"id": 31, "strokes": 1}]));
|
||||
// the object survives a save and a load
|
||||
let json = s.project.to_json();
|
||||
assert!(json.contains("\\\"object\\\":31"), "the project file carries the object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_one_object_keeps_shared_refine_edge_strokes() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
let layer = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (object, kind) in [(0, "fg"), (0, "bg"), (0, "refine"), (0, "refineErase"), (1, "fg")] {
|
||||
s.execute("roto.stroke", json!({"layer": layer, "object": object, "kind": kind, "points": [[20, 20]], "radius": 4})).unwrap();
|
||||
}
|
||||
let before = s.project.to_json();
|
||||
let result = s.execute("roto.clearStrokes", json!({"layer": layer, "object": 0})).unwrap();
|
||||
assert_eq!(result["removed"], 2, "only the object's foreground/background strokes are owned by it");
|
||||
assert_eq!(result["strokes"], 3);
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
for kind in ["refine", "refineErase"] {
|
||||
let result = s.execute("roto.clearStrokes", json!({"layer": layer, "kind": kind})).unwrap();
|
||||
assert_eq!(result["removed"], 1, "explicitly clearing shared {kind} still works");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_strokes_rejects_invalid_object_filters_without_editing() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
let layer = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("roto.stroke", json!({"layer": layer, "points": [[20, 20]], "radius": 4})).unwrap();
|
||||
let before = s.project.to_json();
|
||||
for object in [json!(-1), json!(-0.1), json!(32), json!(1e30), json!(null), json!("0")] {
|
||||
let result = s.execute("roto.clearStrokes", json!({"layer": layer, "object": object}));
|
||||
assert!(result.is_err(), "invalid object {object} must not delete object zero: {result:?}");
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "effectcraft-sam2"
|
||||
description = "SAM 2.1 (Hiera) video object tracker for the Roto Brush: a Rust port of the Hugging Face transformers implementation, native only, weights downloaded by the user"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license = "Apache-2.0"
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
effectcraft-segment = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
# Native only (D1): candle is not built for wasm32, where this crate is empty.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
candle-core = "=0.9.2"
|
||||
candle-nn = "=0.9.2"
|
||||
# The optional GPU path (wgpu compute, any vendor); the CPU path stays the reference.
|
||||
wgpu = { workspace = true }
|
||||
pollster = { workspace = true }
|
||||
bytemuck = { workspace = true }
|
||||
half = "2.7"
|
||||
@@ -0,0 +1,11 @@
|
||||
EffectCraft SAM 2 (effectcraft-sam2)
|
||||
Copyright the EffectCraft contributors.
|
||||
|
||||
This crate is a Rust port of the SAM 2 implementation in Hugging Face `transformers`
|
||||
(`models/sam2` and `models/sam2_video`), Copyright 2025 The HuggingFace Inc. team, which in turn
|
||||
implements "SAM 2: Segment Anything in Images and Videos" by Meta AI (facebookresearch/sam2).
|
||||
Both are licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0).
|
||||
|
||||
Each ported source file states that it is a modified work (Apache-2.0 section 4(b)).
|
||||
Model weights are not part of this crate; they are downloaded by the user from the official
|
||||
`facebook/sam2.1-hiera-*` repositories (Apache-2.0).
|
||||
@@ -0,0 +1,164 @@
|
||||
//! A spike for the GPU path (plan phase 6): how fast is a plain tiled WGSL matrix multiply on this
|
||||
//! machine's GPU, against the candle CPU matmul the encoder uses today? Candle has no AMD/Vulkan
|
||||
//! backend; this measures what hand-written wgpu kernels could reach on the shapes SAM 2 needs.
|
||||
//!
|
||||
//! `cargo run --release -p effectcraft-sam2 --example gpu_matmul`
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use candle_core::{Device, Tensor};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
const SHADER: &str = r#"
|
||||
struct P { m: u32, n: u32, k: u32, pad: u32 }
|
||||
@group(0) @binding(0) var<storage, read> a: array<f32>;
|
||||
@group(0) @binding(1) var<storage, read> b: array<f32>;
|
||||
@group(0) @binding(2) var<storage, read_write> c: array<f32>;
|
||||
@group(0) @binding(3) var<uniform> p: P;
|
||||
var<workgroup> sa: array<f32, 1024>;
|
||||
var<workgroup> sb: array<f32, 1024>;
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {
|
||||
let row0 = wg.y * 64u;
|
||||
let col0 = wg.x * 64u;
|
||||
let tid = li.y * 16u + li.x;
|
||||
var acc: array<f32, 16>;
|
||||
for (var i = 0u; i < 16u; i++) { acc[i] = 0.0; }
|
||||
for (var kt = 0u; kt < p.k; kt += 16u) {
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
let idx = tid + i * 256u;
|
||||
let r = idx / 16u;
|
||||
let cc = idx % 16u;
|
||||
let gr = row0 + r;
|
||||
let gc = kt + cc;
|
||||
var va = 0.0;
|
||||
if (gr < p.m && gc < p.k) { va = a[gr * p.k + gc]; }
|
||||
sa[idx] = va;
|
||||
let r2 = idx / 64u;
|
||||
let c2 = idx % 64u;
|
||||
let gr2 = kt + r2;
|
||||
let gc2 = col0 + c2;
|
||||
var vb = 0.0;
|
||||
if (gr2 < p.k && gc2 < p.n) { vb = b[gr2 * p.n + gc2]; }
|
||||
sb[idx] = vb;
|
||||
}
|
||||
workgroupBarrier();
|
||||
for (var kk = 0u; kk < 16u; kk++) {
|
||||
var ar: array<f32, 4>;
|
||||
var br: array<f32, 4>;
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
ar[i] = sa[(li.y * 4u + i) * 16u + kk];
|
||||
br[i] = sb[kk * 64u + li.x * 4u + i];
|
||||
}
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
for (var j = 0u; j < 4u; j++) { acc[i * 4u + j] += ar[i] * br[j]; }
|
||||
}
|
||||
}
|
||||
workgroupBarrier();
|
||||
}
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
for (var j = 0u; j < 4u; j++) {
|
||||
let gr = row0 + li.y * 4u + i;
|
||||
let gc = col0 + li.x * 4u + j;
|
||||
if (gr < p.m && gc < p.n) { c[gr * p.n + gc] = acc[i * 4u + j]; }
|
||||
}
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(
|
||||
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
|
||||
)?;
|
||||
println!("adapter: {:?}", adapter.get_info().name);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { required_limits: adapter.limits(), ..Default::default() }))?;
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
// (name, m, k, n): the matmul shapes of SAM 2's encoder and memory attention
|
||||
let shapes = [
|
||||
("hiera block 0 qkv", 65536usize, 96usize, 288usize),
|
||||
("hiera block 0 mlp in", 65536, 96, 384),
|
||||
("hiera global attention q.kT (one head)", 4096, 96, 4096),
|
||||
("memory attention q.kT (full bank)", 4096, 256, 28736),
|
||||
];
|
||||
for (name, m, k, n) in shapes {
|
||||
let a = Tensor::randn(0f32, 1.0, (m, k), &Device::Cpu)?;
|
||||
let b = Tensor::randn(0f32, 1.0, (k, n), &Device::Cpu)?;
|
||||
let av = a.flatten_all()?.to_vec1::<f32>()?;
|
||||
let bv = b.flatten_all()?.to_vec1::<f32>()?;
|
||||
let ba = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&av),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
});
|
||||
let bb = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&bv),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
});
|
||||
let bc = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: (m * n * 4) as u64,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let params = [m as u32, n as u32, k as u32, 0];
|
||||
let bp = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(¶ms),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: ba.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: bb.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 2, resource: bc.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 3, resource: bp.as_entire_binding() },
|
||||
],
|
||||
});
|
||||
let run = |reps: usize| {
|
||||
let mut enc = device.create_command_encoder(&Default::default());
|
||||
for _ in 0..reps {
|
||||
let mut pass = enc.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &bg, &[]);
|
||||
pass.dispatch_workgroups(n.div_ceil(64) as u32, m.div_ceil(64) as u32, 1);
|
||||
}
|
||||
queue.submit([enc.finish()]);
|
||||
let _ = device.poll(wgpu::PollType::wait_indefinitely());
|
||||
};
|
||||
run(1);
|
||||
let reps = 5;
|
||||
let t = Instant::now();
|
||||
run(reps);
|
||||
let gpu = t.elapsed().as_secs_f64() / reps as f64;
|
||||
let flops = 2.0 * m as f64 * k as f64 * n as f64;
|
||||
// the CPU: candle's matmul, as the encoder uses it
|
||||
let _ = a.matmul(&b)?;
|
||||
let t = Instant::now();
|
||||
let cpu_reps = 3;
|
||||
for _ in 0..cpu_reps {
|
||||
let _ = a.matmul(&b)?;
|
||||
}
|
||||
let cpu = t.elapsed().as_secs_f64() / cpu_reps as f64;
|
||||
println!(
|
||||
"{name:<42} gpu {:>7.1} ms ({:>6.0} GFLOPS) cpu {:>7.1} ms ({:>5.0} GFLOPS) x{:.1}",
|
||||
gpu * 1e3,
|
||||
flops / gpu / 1e9,
|
||||
cpu * 1e3,
|
||||
flops / cpu / 1e9,
|
||||
cpu / gpu
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Times the tensor operations a Hiera block is made of, at the sizes of its first (biggest) block.
|
||||
//! `cargo run --release -p effectcraft-sam2 --example op_bench`
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use candle_core::{DType, Device, Tensor};
|
||||
use candle_nn::{Linear, Module};
|
||||
|
||||
fn time<T>(name: &str, mut f: impl FnMut() -> T) {
|
||||
let _ = f();
|
||||
let n = 5;
|
||||
let t = Instant::now();
|
||||
for _ in 0..n {
|
||||
let _ = f();
|
||||
}
|
||||
println!("{name:<44} {:>8.1} ms", t.elapsed().as_secs_f64() * 1e3 / n as f64);
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let d = Device::Cpu;
|
||||
// block 0: 1 x 256 x 256 x 96 activations, windows of 8 x 8
|
||||
let x = Tensor::randn(0f32, 1.0, (1, 256, 256, 96), &d)?;
|
||||
let w = Tensor::randn(0f32, 0.1, (288, 96), &d)?;
|
||||
let b = Tensor::zeros(288, DType::F32, &d)?;
|
||||
let lin = Linear::new(w, Some(b));
|
||||
let flat = x.reshape((65536, 96))?;
|
||||
time("linear 65536x96 -> 288 (qkv)", || lin.forward(&flat).unwrap());
|
||||
let big = Tensor::randn(0f32, 1.0, (65536, 384), &d)?;
|
||||
time("gelu_erf on 65536 x 384", || big.gelu_erf().unwrap());
|
||||
time("relu on 65536 x 384", || big.relu().unwrap());
|
||||
time("add 65536 x 384", || (&big + &big).unwrap());
|
||||
let xs = x.clone();
|
||||
time("permute+contiguous (window partition)", || x.reshape((1, 32, 8, 32, 8, 96)).unwrap().permute((0, 1, 3, 2, 4, 5)).unwrap().contiguous().unwrap());
|
||||
let lnw = Tensor::ones(96, DType::F32, &d)?;
|
||||
let lnb = Tensor::zeros(96, DType::F32, &d)?;
|
||||
time("fused layer_norm 256x256x96", || candle_nn::ops::layer_norm(&xs, &lnw, &lnb, 1e-6).unwrap());
|
||||
// windowed attention: 1024 windows x 64 tokens x 96 dims, one head
|
||||
let q = Tensor::randn(0f32, 1.0, (1024, 1, 64, 96), &d)?;
|
||||
let kt = Tensor::randn(0f32, 1.0, (1024, 1, 96, 64), &d)?;
|
||||
time("batched matmul 1024 x (64x96)(96x64)", || q.matmul(&kt).unwrap());
|
||||
let sc = Tensor::randn(0f32, 1.0, (1024, 1, 64, 64), &d)?;
|
||||
time("softmax_last_dim 1024x64x64", || candle_nn::ops::softmax_last_dim(&sc).unwrap());
|
||||
let v = Tensor::randn(0f32, 1.0, (1024, 1, 64, 96), &d)?;
|
||||
time("batched matmul 1024 x (64x64)(64x96)", || sc.matmul(&v).unwrap());
|
||||
// the MLP matmuls
|
||||
let w1 = Linear::new(Tensor::randn(0f32, 0.1, (384, 96), &d)?, Some(Tensor::zeros(384, DType::F32, &d)?));
|
||||
time("linear 65536x96 -> 384 (mlp in)", || w1.forward(&flat).unwrap());
|
||||
let w2 = Linear::new(Tensor::randn(0f32, 0.1, (96, 384), &d)?, Some(Tensor::zeros(96, DType::F32, &d)?));
|
||||
time("linear 65536x384 -> 96 (mlp out)", || w2.forward(&big).unwrap());
|
||||
// block 1 (the first stage change): pooling and the wide projections
|
||||
let p1 = Tensor::randn(0f32, 1.0, (1, 192, 256, 256), &d)?;
|
||||
time("max_pool2d 2x2 on 1 x 192 x 256 x 256", || p1.max_pool2d_with_stride(2, 2).unwrap());
|
||||
let p2 = Tensor::randn(0f32, 1.0, (1024, 192, 8, 8), &d)?;
|
||||
time("max_pool2d 2x2 on 1024 x 192 x 8 x 8", || p2.max_pool2d_with_stride(2, 2).unwrap());
|
||||
let nhwc = Tensor::randn(0f32, 1.0, (1, 256, 256, 192), &d)?;
|
||||
time("permute NHWC -> NCHW + contiguous (192ch)", || nhwc.permute((0, 3, 1, 2)).unwrap().contiguous().unwrap());
|
||||
let w3 = Linear::new(Tensor::randn(0f32, 0.1, (192, 96), &d)?, Some(Tensor::zeros(192, DType::F32, &d)?));
|
||||
time("linear 65536x96 -> 192 (block 1 proj)", || w3.forward(&flat).unwrap());
|
||||
time("NHWC 2x2 max via reshape + max(2), max(3)", || nhwc.reshape((1, 128, 2, 128, 2, 192)).unwrap().max(2).unwrap().max(3).unwrap());
|
||||
time("NHWC 2x2 max via two strided maxes of narrowed", || {
|
||||
let a = nhwc.reshape((1, 128, 2, 256, 192)).unwrap();
|
||||
let m = a.narrow(2, 0, 1).unwrap().maximum(&a.narrow(2, 1, 1).unwrap()).unwrap().squeeze(2).unwrap();
|
||||
let b = m.reshape((1, 128, 128, 2, 192)).unwrap();
|
||||
b.narrow(3, 0, 1).unwrap().maximum(&b.narrow(3, 1, 1).unwrap()).unwrap().squeeze(3).unwrap()
|
||||
});
|
||||
println!("threads: {}", std::thread::available_parallelism().map_or(1, |n| n.get()));
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Timing of the SAM 2 image path on this machine: load, encode one frame, decode a click.
|
||||
//!
|
||||
//! `cargo run -p effectcraft-sam2 --example sam2_bench -- [MODEL_DIR]` (default `sam2-tiny`).
|
||||
//! Prints a message and exits when the weights are not there.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use candle_core::{DType, Device, Tensor};
|
||||
use effectcraft_sam2::model::ImageModel;
|
||||
use effectcraft_sam2::prompt::Prompt;
|
||||
|
||||
fn main() {
|
||||
if let Err(e) = run() {
|
||||
eprintln!("sam2_bench: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let dir = std::env::args().nth(1).unwrap_or_else(|| "sam2-tiny".to_string());
|
||||
let file = std::path::Path::new(&dir).join("model.safetensors");
|
||||
if !file.exists() {
|
||||
println!("no weights at {} (download facebook/sam2.1-hiera-tiny first)", file.display());
|
||||
return Ok(());
|
||||
}
|
||||
let t = Instant::now();
|
||||
let model = ImageModel::open(&file)?;
|
||||
println!("load: {:.2} s", t.elapsed().as_secs_f64());
|
||||
let pixels = Tensor::randn(0f32, 1.0, (1, 3, 1024, 1024), &Device::Cpu)?.to_dtype(DType::F32)?;
|
||||
for run in 0..3 {
|
||||
let t = Instant::now();
|
||||
let enc = model.encode(&pixels)?;
|
||||
let encode = t.elapsed().as_secs_f64();
|
||||
let prompt = Prompt { points: vec![([512.0, 512.0], true)], bbox: None };
|
||||
let t = Instant::now();
|
||||
let out = model.segment(&enc, &prompt, true)?;
|
||||
println!("run {run}: encode {encode:.2} s, click {:.0} ms, masks {:?}", t.elapsed().as_secs_f64() * 1e3, out.masks.dims());
|
||||
}
|
||||
// Where the encode time goes: patch embedding, then each Hiera block.
|
||||
let hiera = &model.vision.hiera;
|
||||
let t = Instant::now();
|
||||
let mut x = hiera.patch_embed(&pixels)?;
|
||||
println!("patch embed: {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
for (i, b) in hiera.blocks.iter().enumerate() {
|
||||
let t = Instant::now();
|
||||
x = b.forward(&x)?;
|
||||
println!("block {i}: {:.0} ms, out {:?}", t.elapsed().as_secs_f64() * 1e3, x.dims());
|
||||
}
|
||||
// The rest of the encode: the neck (1x1 convolutions, top-down sums) and its sine position encodings.
|
||||
let stages = hiera.forward(&pixels)?;
|
||||
let t = Instant::now();
|
||||
let _ = model.vision.neck.forward(&stages)?;
|
||||
println!("neck (convolutions + positions): {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
let t = Instant::now();
|
||||
for (h, w) in [(32, 32), (64, 64), (128, 128), (256, 256)] {
|
||||
let _ = effectcraft_sam2::neck::sine_position(128, h, w)?;
|
||||
}
|
||||
println!("sine positions for the four levels: {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
// What a tracked frame adds to the encode: memory attention over the memory bank (7 memory frames and
|
||||
// 16 object pointers of 4 tokens), then the memory encoder.
|
||||
let weights = effectcraft_sam2::weights::Weights::open(&file)?;
|
||||
let attn = effectcraft_sam2::memattn::MemoryAttention::load(&weights)?;
|
||||
let cur = Tensor::randn(0f32, 1.0, (4096, 256), &Device::Cpu)?;
|
||||
let cur_pos = Tensor::randn(0f32, 1.0, (4096, 256), &Device::Cpu)?;
|
||||
let m = 7 * 4096 + 64;
|
||||
let mem = Tensor::randn(0f32, 1.0, (m, 64), &Device::Cpu)?;
|
||||
let mem_pos = Tensor::randn(0f32, 1.0, (m, 64), &Device::Cpu)?;
|
||||
for run in 0..2 {
|
||||
let t = Instant::now();
|
||||
let _ = attn.forward(&cur, &cur_pos, &mem, &mem_pos, 64)?;
|
||||
println!("memory attention (run {run}, {m} memory tokens): {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
// The same two costs on the GPU (wgpu kernels), when there is one.
|
||||
match effectcraft_sam2::gpu::Gpu::shared() {
|
||||
Ok(gpu) => {
|
||||
println!("GPU: {}", gpu.adapter_name());
|
||||
let enc = effectcraft_sam2::gpu::encoder::GpuEncoder::load(gpu.clone(), &weights, model.vision.hiera.clone())?;
|
||||
let gattn = effectcraft_sam2::gpu::memattn::GpuMemoryAttention::load(gpu, &weights)?;
|
||||
for run in 0..3 {
|
||||
let t = Instant::now();
|
||||
let _ = enc.encode(&pixels)?;
|
||||
let encode = t.elapsed().as_secs_f64() * 1e3;
|
||||
let t = Instant::now();
|
||||
let _ = gattn.forward(&cur, &cur_pos, &mem, &mem_pos, 64)?;
|
||||
println!("GPU run {run}: encode {encode:.0} ms, memory attention (full bank) {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
}
|
||||
Err(e) => println!("no GPU: {e}"),
|
||||
}
|
||||
// A whole tracked frame (encode, memory attention, decode, memory encoding) through the video session, on each backend.
|
||||
struct Same(Tensor);
|
||||
impl effectcraft_sam2::session::FrameSource for Same {
|
||||
fn num_frames(&self) -> usize {
|
||||
12
|
||||
}
|
||||
fn frame(&self, _i: usize) -> effectcraft_sam2::Result<Tensor> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
for backend in [effectcraft_sam2::session::Backend::Cpu, effectcraft_sam2::session::Backend::Gpu] {
|
||||
let Ok(vm) = effectcraft_sam2::session::VideoModel::load_with(&weights, backend) else { continue };
|
||||
let vm = std::sync::Arc::new(vm);
|
||||
let mut tracker = effectcraft_sam2::session::VideoTracker::new(vm.clone(), Box::new(Same(pixels.clone())))?;
|
||||
let obj = effectcraft_sam2::session::ObjectId(1);
|
||||
let t = Instant::now();
|
||||
tracker.prompt(0, obj, &Prompt { points: vec![([512.0, 512.0], true)], bbox: None })?;
|
||||
println!("{:?} ({}): prompt frame {:.0} ms", backend, vm.device_name(), t.elapsed().as_secs_f64() * 1e3);
|
||||
let mut last = Instant::now();
|
||||
let mut times = vec![];
|
||||
tracker.propagate(0, effectcraft_sam2::session::Direction::Forward, None, &effectcraft_sam2::session::Cancel::new(), &mut |m| {
|
||||
if m.frame > 0 {
|
||||
times.push(last.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
last = Instant::now();
|
||||
})?;
|
||||
println!(
|
||||
"{:?}: tracked frames {:.0} ms each (first {:.0}, last {:.0})",
|
||||
backend,
|
||||
times.iter().sum::<f64>() / times.len().max(1) as f64,
|
||||
times.first().copied().unwrap_or(0.0),
|
||||
times.last().copied().unwrap_or(0.0)
|
||||
);
|
||||
}
|
||||
println!("threads: {}", std::thread::available_parallelism().map_or(1, |n| n.get()));
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// Modified work: ported from Hugging Face `transformers` `models/sam2/modeling_sam2.py`
|
||||
// (Sam2Attention, Sam2TwoWayAttentionBlock, Sam2TwoWayTransformer, Sam2MaskDecoder; Apache-2.0,
|
||||
// Copyright The HuggingFace Inc. team) to Rust on candle. See NOTICE.
|
||||
//! The two-way mask decoder: output tokens and prompt tokens attend to the image and back, then
|
||||
//! hyper-networks turn the mask tokens into masks over the up-scaled image embedding.
|
||||
//! One object per call (batch 1, one point batch).
|
||||
|
||||
use candle_core::{D, Tensor};
|
||||
use candle_nn::{Linear, ops::sigmoid, ops::softmax};
|
||||
|
||||
use crate::nn::{LayerNorm, apply, gelu, layer_norm, linear};
|
||||
use crate::weights::Weights;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const DIM: usize = 256;
|
||||
const MASK_TOKENS: usize = 4;
|
||||
|
||||
struct Attention {
|
||||
q: Linear,
|
||||
k: Linear,
|
||||
v: Linear,
|
||||
o: Linear,
|
||||
heads: usize,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
impl Attention {
|
||||
fn load(w: &Weights, prefix: &str, downsample: usize, heads: usize) -> Result<Attention> {
|
||||
let inner = DIM / downsample;
|
||||
Ok(Attention {
|
||||
q: linear(w, &format!("{prefix}.q_proj"), DIM, inner)?,
|
||||
k: linear(w, &format!("{prefix}.k_proj"), DIM, inner)?,
|
||||
v: linear(w, &format!("{prefix}.v_proj"), DIM, inner)?,
|
||||
o: linear(w, &format!("{prefix}.o_proj"), inner, DIM)?,
|
||||
heads,
|
||||
scale: ((inner / heads) as f64).powf(-0.5),
|
||||
})
|
||||
}
|
||||
|
||||
/// `q`: `[1, nq, 256]`, `k` and `v`: `[1, nk, 256]`.
|
||||
fn forward(&self, q: &Tensor, k: &Tensor, v: &Tensor) -> Result<Tensor> {
|
||||
let split = |t: Tensor| -> Result<Tensor> { Ok(t.reshape((1, (), self.heads, t.dim(2)? / self.heads))?.transpose(1, 2)?.contiguous()?) };
|
||||
let (q, k, v) = (split(apply(&self.q, q)?)?, split(apply(&self.k, k)?)?, split(apply(&self.v, v)?)?);
|
||||
let att = softmax(&(q.matmul(&k.transpose(2, 3)?.contiguous()?)? * self.scale)?, D::Minus1)?;
|
||||
let out = att.matmul(&v)?.transpose(1, 2)?.contiguous()?;
|
||||
let (n, h, d) = (out.dim(1)?, out.dim(2)?, out.dim(3)?);
|
||||
apply(&self.o, &out.reshape((1, n, h * d))?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed-forward with ReLU, `Sam2FeedForward`: `proj_in`, `layers`, `proj_out`.
|
||||
pub struct FeedForward {
|
||||
proj_in: Linear,
|
||||
layers: Vec<Linear>,
|
||||
proj_out: Linear,
|
||||
sigmoid_output: bool,
|
||||
}
|
||||
|
||||
impl FeedForward {
|
||||
pub fn load(w: &Weights, prefix: &str, input: usize, hidden: usize, output: usize, num_layers: usize, sigmoid_output: bool) -> Result<FeedForward> {
|
||||
Ok(FeedForward {
|
||||
proj_in: linear(w, &format!("{prefix}.proj_in"), input, hidden)?,
|
||||
layers: (0..num_layers.saturating_sub(2)).map(|i| linear(w, &format!("{prefix}.layers.{i}"), hidden, hidden)).collect::<Result<_>>()?,
|
||||
proj_out: linear(w, &format!("{prefix}.proj_out"), hidden, output)?,
|
||||
sigmoid_output,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn forward(&self, x: &Tensor) -> Result<Tensor> {
|
||||
let mut x = apply(&self.proj_in, x)?.relu()?;
|
||||
for l in &self.layers {
|
||||
x = apply(l, &x)?.relu()?;
|
||||
}
|
||||
let x = apply(&self.proj_out, &x)?;
|
||||
Ok(if self.sigmoid_output { sigmoid(&x)? } else { x })
|
||||
}
|
||||
}
|
||||
|
||||
struct Block {
|
||||
self_attn: Attention,
|
||||
ln1: LayerNorm,
|
||||
token_to_image: Attention,
|
||||
ln2: LayerNorm,
|
||||
mlp: FeedForward,
|
||||
ln3: LayerNorm,
|
||||
ln4: LayerNorm,
|
||||
image_to_token: Attention,
|
||||
skip_first_pe: bool,
|
||||
}
|
||||
|
||||
impl Block {
|
||||
fn forward(&self, queries: &Tensor, keys: &Tensor, query_pe: &Tensor, key_pe: &Tensor) -> Result<(Tensor, Tensor)> {
|
||||
let mut queries = if self.skip_first_pe {
|
||||
self.self_attn.forward(queries, queries, queries)?
|
||||
} else {
|
||||
let q = (queries + query_pe)?;
|
||||
(queries + self.self_attn.forward(&q, &q, queries)?)?
|
||||
};
|
||||
queries = self.ln1.forward(&queries)?;
|
||||
let (q, k) = ((&queries + query_pe)?, (keys + key_pe)?);
|
||||
queries = self.ln2.forward(&(&queries + self.token_to_image.forward(&q, &k, keys)?)?)?;
|
||||
queries = self.ln3.forward(&(&queries + self.mlp.forward(&queries)?)?)?;
|
||||
let (q, k) = ((&queries + query_pe)?, (keys + key_pe)?);
|
||||
let keys = self.ln4.forward(&(keys + self.image_to_token.forward(&k, &q, &queries)?)?)?;
|
||||
Ok((queries, keys))
|
||||
}
|
||||
}
|
||||
|
||||
/// What the decoder returns for one object.
|
||||
pub struct DecoderOutput {
|
||||
/// Mask logits `[1, k, 4 * GRID, 4 * GRID]` (`k` is 3 for multimask output, else 1).
|
||||
pub masks: Tensor,
|
||||
/// Predicted IoU per mask `[1, k]`.
|
||||
pub iou: Tensor,
|
||||
/// The output tokens of the chosen masks `[1, k, 256]` (the object pointer is made from these).
|
||||
pub sam_tokens: Tensor,
|
||||
/// Object-presence logit `[1, 1]` (negative: the object is not in the frame).
|
||||
pub object_score: Tensor,
|
||||
}
|
||||
|
||||
pub struct MaskDecoder {
|
||||
obj_token: Tensor,
|
||||
iou_token: Tensor,
|
||||
mask_tokens: Tensor,
|
||||
layers: Vec<Block>,
|
||||
final_attn: Attention,
|
||||
final_ln: LayerNorm,
|
||||
up1: (Tensor, Tensor),
|
||||
up2: (Tensor, Tensor),
|
||||
up_ln: LayerNorm,
|
||||
conv_s0: (Tensor, Tensor),
|
||||
conv_s1: (Tensor, Tensor),
|
||||
hyper: Vec<FeedForward>,
|
||||
iou_head: FeedForward,
|
||||
obj_head: FeedForward,
|
||||
}
|
||||
|
||||
fn conv_w(w: &Weights, name: &str, i: usize, o: usize, k: usize, transposed: bool) -> Result<(Tensor, Tensor)> {
|
||||
let shape = if transposed { [i, o, k, k] } else { [o, i, k, k] };
|
||||
Ok((w.get(&format!("{name}.weight"), &shape)?, w.get(&format!("{name}.bias"), &[o])?))
|
||||
}
|
||||
|
||||
fn add_bias(x: Tensor, b: &Tensor) -> Result<Tensor> {
|
||||
Ok(x.broadcast_add(&b.reshape((1, b.dim(0)?, 1, 1))?)?)
|
||||
}
|
||||
|
||||
impl MaskDecoder {
|
||||
pub fn load(w: &Weights) -> Result<MaskDecoder> {
|
||||
let layers = (0..2)
|
||||
.map(|i| {
|
||||
let p = format!("mask_decoder.transformer.layers.{i}");
|
||||
Ok(Block {
|
||||
self_attn: Attention::load(w, &format!("{p}.self_attn"), 1, 8)?,
|
||||
ln1: layer_norm(w, &format!("{p}.layer_norm1"), DIM, 1e-5)?,
|
||||
token_to_image: Attention::load(w, &format!("{p}.cross_attn_token_to_image"), 2, 8)?,
|
||||
ln2: layer_norm(w, &format!("{p}.layer_norm2"), DIM, 1e-5)?,
|
||||
mlp: FeedForward::load(w, &format!("{p}.mlp"), DIM, 2048, DIM, 2, false)?,
|
||||
ln3: layer_norm(w, &format!("{p}.layer_norm3"), DIM, 1e-5)?,
|
||||
ln4: layer_norm(w, &format!("{p}.layer_norm4"), DIM, 1e-5)?,
|
||||
image_to_token: Attention::load(w, &format!("{p}.cross_attn_image_to_token"), 2, 8)?,
|
||||
skip_first_pe: i == 0,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(MaskDecoder {
|
||||
obj_token: w.get("mask_decoder.obj_score_token.weight", &[1, DIM])?,
|
||||
iou_token: w.get("mask_decoder.iou_token.weight", &[1, DIM])?,
|
||||
mask_tokens: w.get("mask_decoder.mask_tokens.weight", &[MASK_TOKENS, DIM])?,
|
||||
layers,
|
||||
final_attn: Attention::load(w, "mask_decoder.transformer.final_attn_token_to_image", 2, 8)?,
|
||||
final_ln: layer_norm(w, "mask_decoder.transformer.layer_norm_final_attn", DIM, 1e-5)?,
|
||||
up1: conv_w(w, "mask_decoder.upscale_conv1", DIM, DIM / 4, 2, true)?,
|
||||
up2: conv_w(w, "mask_decoder.upscale_conv2", DIM / 4, DIM / 8, 2, true)?,
|
||||
up_ln: layer_norm(w, "mask_decoder.upscale_layer_norm", DIM / 4, 1e-6)?,
|
||||
conv_s0: conv_w(w, "mask_decoder.conv_s0", DIM, DIM / 8, 1, false)?,
|
||||
conv_s1: conv_w(w, "mask_decoder.conv_s1", DIM, DIM / 4, 1, false)?,
|
||||
hyper: (0..MASK_TOKENS)
|
||||
.map(|i| FeedForward::load(w, &format!("mask_decoder.output_hypernetworks_mlps.{i}"), DIM, DIM, DIM / 8, 3, false))
|
||||
.collect::<Result<_>>()?,
|
||||
iou_head: FeedForward::load(w, "mask_decoder.iou_prediction_head", DIM, DIM, MASK_TOKENS, 3, true)?,
|
||||
obj_head: FeedForward::load(w, "mask_decoder.pred_obj_score_head", DIM, DIM, 1, 3, false)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Project the two highest-resolution neck levels to the decoder's skip features
|
||||
/// (`conv_s0` on the 4x level, `conv_s1` on the 2x level), `[1, 32, 4g, 4g]` and `[1, 64, 2g, 2g]`.
|
||||
pub fn high_res(&self, level0: &Tensor, level1: &Tensor) -> Result<(Tensor, Tensor)> {
|
||||
let s0 = add_bias(level0.conv2d(&self.conv_s0.0, 0, 1, 1, 1)?, &self.conv_s0.1)?;
|
||||
let s1 = add_bias(level1.conv2d(&self.conv_s1.0, 0, 1, 1, 1)?, &self.conv_s1.1)?;
|
||||
Ok((s0, s1))
|
||||
}
|
||||
|
||||
/// Predict masks. `image` and `pos`: `[1, 256, g, g]` (the lowest-resolution level with the
|
||||
/// no-memory embedding already added when there is no memory), `sparse`: `[1, n, 256]`,
|
||||
/// `dense`: `[1, 256, g, g]`, `high`: the skip features from [`MaskDecoder::high_res`].
|
||||
pub fn forward(&self, image: &Tensor, pos: &Tensor, sparse: &Tensor, dense: &Tensor, high: &(Tensor, Tensor), multimask: bool) -> Result<DecoderOutput> {
|
||||
let (_, c, g, gw) = image.dims4()?;
|
||||
if c != DIM || g != gw {
|
||||
return Err(Error::Input(format!("image embedding must be 256 x g x g, got {:?}", image.dims())));
|
||||
}
|
||||
let output_tokens = Tensor::cat(&[&self.obj_token, &self.iou_token, &self.mask_tokens], 0)?.unsqueeze(0)?;
|
||||
let tokens = Tensor::cat(&[&output_tokens, sparse], 1)?;
|
||||
let image = (image + dense)?;
|
||||
let flat = |t: &Tensor| -> Result<Tensor> { Ok(t.flatten_from(2)?.transpose(1, 2)?.contiguous()?) };
|
||||
let (mut keys, key_pe) = (flat(&image)?, flat(pos)?);
|
||||
let mut queries = tokens.clone();
|
||||
for l in &self.layers {
|
||||
(queries, keys) = l.forward(&queries, &keys, &tokens, &key_pe)?;
|
||||
}
|
||||
let (q, k) = ((&queries + &tokens)?, (&keys + &key_pe)?);
|
||||
let queries = self.final_ln.forward(&(&queries + self.final_attn.forward(&q, &k, &keys)?)?)?;
|
||||
|
||||
let iou_out = queries.narrow(1, 1, 1)?.squeeze(1)?;
|
||||
let mask_tokens_out = queries.narrow(1, 2, MASK_TOKENS)?;
|
||||
let obj_out = queries.narrow(1, 0, 1)?.squeeze(1)?;
|
||||
|
||||
let emb = keys.transpose(1, 2)?.contiguous()?.reshape((1, DIM, g, g))?;
|
||||
let (s0, s1) = high;
|
||||
let up = add_bias(emb.conv_transpose2d(&self.up1.0, 0, 0, 2, 1)?, &self.up1.1)?;
|
||||
let up = (up + s1)?;
|
||||
let up = self.up_ln.forward(&up.permute((0, 2, 3, 1))?)?.permute((0, 3, 1, 2))?.contiguous()?;
|
||||
let up = gelu(&up)?;
|
||||
let up = (add_bias(up.conv_transpose2d(&self.up2.0, 0, 0, 2, 1)?, &self.up2.1)? + s0)?;
|
||||
let up = gelu(&up)?;
|
||||
|
||||
let hyper: Vec<Tensor> = self.hyper.iter().enumerate().map(|(i, m)| m.forward(&mask_tokens_out.narrow(1, i, 1)?.squeeze(1)?)).collect::<Result<_>>()?;
|
||||
let hyper = Tensor::stack(&hyper, 1)?;
|
||||
let (_, uc, uh, uw) = up.dims4()?;
|
||||
let masks = hyper.matmul(&up.reshape((1, uc, uh * uw))?)?.reshape((1, MASK_TOKENS, uh, uw))?;
|
||||
let iou = self.iou_head.forward(&iou_out)?;
|
||||
let object_score = self.obj_head.forward(&obj_out)?;
|
||||
|
||||
let (masks, iou, sam_tokens) = if multimask {
|
||||
(masks.narrow(1, 1, 3)?, iou.narrow(1, 1, 3)?, mask_tokens_out.narrow(1, 1, 3)?)
|
||||
} else {
|
||||
let (m, i) = dynamic_single(&masks, &iou)?;
|
||||
(m, i, mask_tokens_out.narrow(1, 0, 1)?)
|
||||
};
|
||||
Ok(DecoderOutput { masks, iou, sam_tokens, object_score })
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-mask output with the stability fallback (`_dynamic_multimask_via_stability`): if the
|
||||
/// first mask token's mask is unstable, use the best of the other three.
|
||||
fn dynamic_single(masks: &Tensor, iou: &Tensor) -> Result<(Tensor, Tensor)> {
|
||||
const DELTA: f32 = 0.05;
|
||||
const THRESH: f32 = 0.98;
|
||||
let single = masks.narrow(1, 0, 1)?;
|
||||
let single_iou = iou.narrow(1, 0, 1)?;
|
||||
let logits = single.flatten_all()?.to_vec1::<f32>()?;
|
||||
let area_i = logits.iter().filter(|v| **v > DELTA).count() as f32;
|
||||
let area_u = logits.iter().filter(|v| **v > -DELTA).count() as f32;
|
||||
let stability = if area_u > 0.0 { area_i / area_u } else { 1.0 };
|
||||
if stability >= THRESH {
|
||||
return Ok((single, single_iou));
|
||||
}
|
||||
let multi_iou = iou.narrow(1, 1, 3)?.flatten_all()?.to_vec1::<f32>()?;
|
||||
let best = multi_iou.iter().enumerate().fold((0usize, f32::NEG_INFINITY), |b, (i, v)| if *v > b.1 { (i, *v) } else { b }).0;
|
||||
Ok((masks.narrow(1, 1 + best, 1)?, iou.narrow(1, 1 + best, 1)?))
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
// Original work for EffectCraft (wgpu compute kernels); the maths follows the Hugging Face
|
||||
// `transformers` SAM 2 implementation (Apache-2.0, see NOTICE).
|
||||
//! An optional GPU path for the heavy parts of SAM 2 (the Hiera backbone, the neck and the memory
|
||||
//! attention), as wgpu compute kernels: it runs on AMD, NVIDIA and Intel alike, where candle has
|
||||
//! no backend for the first. The CPU path stays the reference: every kernel is tested against it,
|
||||
//! and any GPU failure (no adapter, an out-of-memory error, a lost device) is an `Err` the caller
|
||||
//! answers by running the CPU path.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
const SHADER: &str = concat!(include_str!("gpu/kernels.wgsl"), include_str!("gpu/kernels_ops.wgsl"));
|
||||
/// Words in a kernel's parameter block.
|
||||
const PARAM_WORDS: usize = 24;
|
||||
|
||||
/// A buffer of f32 on the GPU (cheap to clone: it is a handle).
|
||||
#[derive(Clone)]
|
||||
pub struct Buf {
|
||||
pub(crate) buffer: wgpu::Buffer,
|
||||
/// Elements (f32).
|
||||
pub len: usize,
|
||||
/// Two half-precision values per 32-bit word (a weight matrix only; `len` still counts values).
|
||||
pub(crate) f16: bool,
|
||||
}
|
||||
|
||||
/// One strided matrix operand: `(offset, batch stride, row stride, column stride)` in elements.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Strides {
|
||||
pub offset: usize,
|
||||
pub batch: usize,
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
impl Strides {
|
||||
/// A row-major `[rows, cols]` matrix (one batch).
|
||||
pub fn row_major(cols: usize) -> Strides {
|
||||
Strides { offset: 0, batch: 0, row: cols, col: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
/// What to do to a matrix product after the multiplication.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub enum Act {
|
||||
#[default]
|
||||
None,
|
||||
Gelu,
|
||||
Relu,
|
||||
}
|
||||
|
||||
/// A batched matrix product `C[b] = act(alpha * A[b] . B[b] + bias) (+ residual)`.
|
||||
pub struct Matmul<'a> {
|
||||
pub m: usize,
|
||||
pub n: usize,
|
||||
pub k: usize,
|
||||
pub batch: usize,
|
||||
pub a: (&'a Buf, Strides),
|
||||
pub b: (&'a Buf, Strides),
|
||||
pub bias: Option<&'a Buf>,
|
||||
pub c: (&'a Buf, Strides),
|
||||
pub residual: Option<&'a Buf>,
|
||||
pub alpha: f32,
|
||||
pub act: Act,
|
||||
}
|
||||
|
||||
/// The GPU device and its compiled kernels.
|
||||
pub struct Gpu {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
module: wgpu::ShaderModule,
|
||||
pipelines: Mutex<HashMap<&'static str, Arc<wgpu::ComputePipeline>>>,
|
||||
encoder: Mutex<Option<wgpu::CommandEncoder>>,
|
||||
turn: Mutex<()>,
|
||||
error: Arc<Mutex<Option<String>>>,
|
||||
adapter: String,
|
||||
}
|
||||
|
||||
fn words(v: &[u32]) -> [u32; PARAM_WORDS] {
|
||||
let mut out = [0u32; PARAM_WORDS];
|
||||
for (o, x) in out.iter_mut().zip(v) {
|
||||
*o = *x;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn u32_of(v: usize, what: &str) -> Result<u32> {
|
||||
u32::try_from(v).map_err(|_| Error::Compute(format!("{what} {v} does not fit a GPU index")))
|
||||
}
|
||||
|
||||
/// Whether weight matrices are kept on the GPU in half precision (`EFFECTCRAFT_SAM2_F16=1`).
|
||||
pub fn half_weights() -> bool {
|
||||
std::env::var("EFFECTCRAFT_SAM2_F16").is_ok_and(|v| matches!(v.trim(), "1" | "on" | "true"))
|
||||
}
|
||||
|
||||
/// The process's one GPU device, once something has asked for it.
|
||||
static DEVICE: std::sync::OnceLock<std::result::Result<Arc<Gpu>, String>> = std::sync::OnceLock::new();
|
||||
|
||||
impl Gpu {
|
||||
/// The process's one GPU device, created on first use. Several devices at once upset some Vulkan loaders
|
||||
/// (a crash when two threads create or drop them together), and one is all SAM 2 needs: models come and go,
|
||||
/// the device stays. An error (no GPU) is remembered too.
|
||||
pub fn shared() -> Result<Arc<Gpu>> {
|
||||
DEVICE.get_or_init(|| Gpu::new().map(Arc::new).map_err(|e| e.to_string())).clone().map_err(Error::Compute)
|
||||
}
|
||||
|
||||
/// Whether the shared device has been looked for yet, and found: `None` before anything has asked
|
||||
/// for it (this never creates it, so a caller that only wants to describe the GPU does not stall
|
||||
/// on the adapter and shader compilation), then `Some(true)` or `Some(false)` (no GPU).
|
||||
pub fn probe() -> Option<bool> {
|
||||
DEVICE.get().map(|d| d.is_ok())
|
||||
}
|
||||
|
||||
/// A device on the best real GPU (a software adapter is refused: the CPU path is faster).
|
||||
fn new() -> Result<Gpu> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(
|
||||
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
|
||||
)
|
||||
.map_err(|e| Error::Compute(format!("no GPU adapter: {e}")))?;
|
||||
let info = adapter.get_info();
|
||||
if info.device_type == wgpu::DeviceType::Cpu {
|
||||
return Err(Error::Compute(format!("only a software adapter ({}) is available", info.name)));
|
||||
}
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("effectcraft sam2"),
|
||||
required_limits: adapter.limits(),
|
||||
..Default::default()
|
||||
}))
|
||||
.map_err(|e| Error::Compute(format!("no GPU device: {e}")))?;
|
||||
let error: Arc<Mutex<Option<String>>> = Arc::default();
|
||||
let sink = error.clone();
|
||||
device.on_uncaptured_error(Arc::new(move |e| {
|
||||
log::error!("sam2 gpu: {e}");
|
||||
sink.lock().unwrap_or_else(PoisonError::into_inner).get_or_insert_with(|| e.to_string());
|
||||
}));
|
||||
let sink = error.clone();
|
||||
device.set_device_lost_callback(move |reason, message| {
|
||||
sink.lock().unwrap_or_else(PoisonError::into_inner).get_or_insert_with(|| format!("GPU device lost ({reason:?}): {message}"));
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sam2 kernels"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
|
||||
Ok(Gpu { device, queue, module, pipelines: Mutex::default(), encoder: Mutex::new(None), turn: Mutex::new(()), error, adapter: info.name })
|
||||
}
|
||||
|
||||
/// One GPU pass at a time: a caller holds this while it queues, flushes and reads back a whole job, so
|
||||
/// two threads never interleave their dispatches in one command stream.
|
||||
pub fn exclusive(&self) -> std::sync::MutexGuard<'_, ()> {
|
||||
self.turn.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
pub fn adapter_name(&self) -> &str {
|
||||
&self.adapter
|
||||
}
|
||||
|
||||
fn take_error(&self) -> Result<()> {
|
||||
match self.error.lock().unwrap_or_else(PoisonError::into_inner).take() {
|
||||
Some(e) => Err(Error::Compute(format!("GPU: {e}"))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn upload(&self, data: &[f32]) -> Buf {
|
||||
// `write_buffer` instead of a mapped-at-creation buffer: on a lost or out-of-memory device the latter panics inside wgpu,
|
||||
// while this records the error for `take_error`.
|
||||
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: (data.len().max(1) as u64) * 4,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
if !data.is_empty() {
|
||||
self.queue.write_buffer(&buffer, 0, bytemuck::cast_slice(data));
|
||||
}
|
||||
Buf { buffer, len: data.len(), f16: false }
|
||||
}
|
||||
|
||||
/// A weight matrix: stored as half precision (half the memory) when `EFFECTCRAFT_SAM2_F16` asks for it and every
|
||||
/// value fits, as f32 otherwise.
|
||||
pub fn upload_weights(&self, data: &[f32]) -> Buf {
|
||||
if !half_weights() || data.is_empty() || data.iter().any(|v| v.abs() > 60_000.0) {
|
||||
return self.upload(data);
|
||||
}
|
||||
self.upload_f16(data)
|
||||
}
|
||||
|
||||
/// `data` as half precision, two values per word.
|
||||
pub(crate) fn upload_f16(&self, data: &[f32]) -> Buf {
|
||||
let words: Vec<u32> = data
|
||||
.chunks(2)
|
||||
.map(|p| {
|
||||
let lo = half::f16::from_f32(p.first().copied().unwrap_or(0.0)).to_bits();
|
||||
let hi = half::f16::from_f32(p.get(1).copied().unwrap_or(0.0)).to_bits();
|
||||
u32::from(lo) | (u32::from(hi) << 16)
|
||||
})
|
||||
.collect();
|
||||
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: (words.len() as u64) * 4,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
self.queue.write_buffer(&buffer, 0, bytemuck::cast_slice(&words));
|
||||
Buf { buffer, len: data.len(), f16: true }
|
||||
}
|
||||
|
||||
/// A zeroed buffer of `len` elements.
|
||||
pub fn zeros(&self, len: usize) -> Result<Buf> {
|
||||
let bytes = (len.max(1) as u64).checked_mul(4).ok_or_else(|| Error::Budget("GPU buffer size overflows".into()))?;
|
||||
if bytes > self.device.limits().max_buffer_size || bytes > self.device.limits().max_storage_buffer_binding_size {
|
||||
return Err(Error::Budget(format!("a {} MB GPU buffer is over this GPU's limit", bytes >> 20)));
|
||||
}
|
||||
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
Ok(Buf { buffer, len, f16: false })
|
||||
}
|
||||
|
||||
fn pipeline(&self, entry: &'static str) -> Arc<wgpu::ComputePipeline> {
|
||||
let mut map = self.pipelines.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
map.entry(entry)
|
||||
.or_insert_with(|| {
|
||||
Arc::new(self.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(entry),
|
||||
layout: None,
|
||||
module: &self.module,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
}))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Queue one dispatch of `entry`. `bindings` are the kernel's buffers by binding index.
|
||||
pub(crate) fn run(&self, entry: &'static str, bindings: &[(u32, &Buf)], params: &[u32], groups: (u32, u32, u32)) {
|
||||
let pipeline = self.pipeline(entry);
|
||||
let uniform = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&words(params)),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let mut entries: Vec<wgpu::BindGroupEntry<'_>> =
|
||||
bindings.iter().map(|(i, b)| wgpu::BindGroupEntry { binding: *i, resource: b.buffer.as_entire_binding() }).collect();
|
||||
entries.push(wgpu::BindGroupEntry { binding: 5, resource: uniform.as_entire_binding() });
|
||||
let group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: None, layout: &pipeline.get_bind_group_layout(0), entries: &entries });
|
||||
let mut slot = self.encoder.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let enc = slot.get_or_insert_with(|| self.device.create_command_encoder(&Default::default()));
|
||||
let mut pass = enc.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.dispatch_workgroups(groups.0, groups.1, groups.2);
|
||||
}
|
||||
|
||||
/// Submit what is queued and wait for it to finish.
|
||||
pub fn flush(&self) -> Result<()> {
|
||||
let enc = self.encoder.lock().unwrap_or_else(PoisonError::into_inner).take();
|
||||
if let Some(enc) = enc {
|
||||
self.queue.submit([enc.finish()]);
|
||||
}
|
||||
let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
|
||||
self.take_error()
|
||||
}
|
||||
|
||||
/// Copy a buffer back to the CPU (after submitting everything queued).
|
||||
pub fn download(&self, buf: &Buf) -> Result<Vec<f32>> {
|
||||
let bytes = buf.len as u64 * 4;
|
||||
if bytes == 0 {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let staging = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut enc =
|
||||
self.encoder.lock().unwrap_or_else(PoisonError::into_inner).take().unwrap_or_else(|| self.device.create_command_encoder(&Default::default()));
|
||||
enc.copy_buffer_to_buffer(&buf.buffer, 0, &staging, 0, bytes);
|
||||
self.queue.submit([enc.finish()]);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
staging.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
|
||||
self.take_error()?;
|
||||
rx.recv().map_err(|_| Error::Compute("GPU readback never completed".into()))?.map_err(|e| Error::Compute(format!("GPU readback: {e}")))?;
|
||||
let out = {
|
||||
let view = staging.get_mapped_range(..).map_err(|e| Error::Compute(format!("GPU readback: {e}")))?;
|
||||
bytemuck::cast_slice::<u8, f32>(&view).to_vec()
|
||||
};
|
||||
staging.unmap();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Queue `C[b] = act(alpha * A[b] . B[b] + bias) (+ residual)`.
|
||||
pub fn matmul(&self, op: &Matmul<'_>) -> Result<()> {
|
||||
let (a, sa) = op.a;
|
||||
let (b, sb) = op.b;
|
||||
let (c, sc) = op.c;
|
||||
if op.m == 0 || op.n == 0 || op.batch == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if sc.col != 1 {
|
||||
return Err(Error::Input(format!("matmul output needs a column stride of 1, got {}", sc.col)));
|
||||
}
|
||||
// bounds: the last element each operand touches must lie inside its buffer
|
||||
let last = |s: Strides, rows: usize, cols: usize| {
|
||||
s.offset.checked_add((op.batch - 1).checked_mul(s.batch)?)?.checked_add((rows - 1).checked_mul(s.row)?)?.checked_add((cols - 1).checked_mul(s.col)?)
|
||||
};
|
||||
for (name, buf, s, rows, cols) in [("A", a, sa, op.m, op.k), ("B", b, sb, op.k, op.n), ("C", c, sc, op.m, op.n)] {
|
||||
if name != "C" && op.k == 0 {
|
||||
continue;
|
||||
}
|
||||
let last = last(s, rows, cols).ok_or_else(|| Error::Input(format!("matmul operand {name} layout overflows its element index")))?;
|
||||
if last >= buf.len {
|
||||
return Err(Error::Input(format!("matmul operand {name} reaches element {last} of a {}-element buffer", buf.len)));
|
||||
}
|
||||
}
|
||||
if op.bias.is_some_and(|bias| bias.len < op.n) || op.residual.is_some_and(|r| r.len < c.len) {
|
||||
return Err(Error::Input("matmul bias or residual is too small".into()));
|
||||
}
|
||||
let flags = u32::from(op.bias.is_some())
|
||||
| (match op.act {
|
||||
Act::None => 0,
|
||||
Act::Gelu => 1,
|
||||
Act::Relu => 2,
|
||||
} << 1)
|
||||
| (u32::from(op.residual.is_some()) << 3);
|
||||
let params = [
|
||||
u32_of(op.m, "m")?,
|
||||
u32_of(op.n, "n")?,
|
||||
u32_of(op.k, "k")?,
|
||||
u32_of(op.batch, "batch")?,
|
||||
u32_of(sa.offset, "a offset")?,
|
||||
u32_of(sa.batch, "a batch")?,
|
||||
u32_of(sa.row, "a row")?,
|
||||
u32_of(sa.col, "a col")?,
|
||||
u32_of(sb.offset, "b offset")?,
|
||||
u32_of(sb.batch, "b batch")?,
|
||||
u32_of(sb.row, "b row")?,
|
||||
u32_of(sb.col, "b col")?,
|
||||
u32_of(sc.offset, "c offset")?,
|
||||
u32_of(sc.batch, "c batch")?,
|
||||
u32_of(sc.row, "c row")?,
|
||||
flags,
|
||||
op.alpha.to_bits(),
|
||||
];
|
||||
let dummy;
|
||||
let bias = match op.bias {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
dummy = self.upload(&[0.0]);
|
||||
&dummy
|
||||
}
|
||||
};
|
||||
// a buffer is never bound twice in one dispatch (read and read-write), so no residual means a dummy
|
||||
let dummy_res;
|
||||
let res = match op.residual {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
dummy_res = self.upload(&[0.0]);
|
||||
&dummy_res
|
||||
}
|
||||
};
|
||||
self.run(
|
||||
if b.f16 { "matmul_w16" } else { "matmul" },
|
||||
&[(0, a), (1, b), (2, bias), (3, c), (4, res)],
|
||||
¶ms,
|
||||
(u32_of(op.n.div_ceil(64), "groups")?, u32_of(op.m.div_ceil(64), "groups")?, u32_of(op.batch, "batch")?),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub mod encoder;
|
||||
pub mod memattn;
|
||||
pub mod memenc;
|
||||
mod ops;
|
||||
|
||||
/// A linear layer's weight `[out, in]` and bias `[out]` on the GPU.
|
||||
pub struct GLinear {
|
||||
pub(crate) w: Buf,
|
||||
pub(crate) b: Buf,
|
||||
pub(crate) inp: usize,
|
||||
pub(crate) out: usize,
|
||||
}
|
||||
|
||||
/// A layer norm's scale and shift on the GPU.
|
||||
pub struct GNorm {
|
||||
pub(crate) w: Buf,
|
||||
pub(crate) b: Buf,
|
||||
}
|
||||
|
||||
fn flat(t: candle_core::Tensor) -> Result<Vec<f32>> {
|
||||
Ok(t.flatten_all()?.to_vec1::<f32>()?)
|
||||
}
|
||||
|
||||
impl GLinear {
|
||||
/// From `{name}.weight` (`[out, in]`) and `{name}.bias`.
|
||||
pub fn load(g: &Gpu, w: &crate::weights::Weights, name: &str, inp: usize, out: usize) -> Result<GLinear> {
|
||||
Ok(GLinear {
|
||||
w: g.upload_weights(&flat(w.get(&format!("{name}.weight"), &[out, inp])?)?),
|
||||
b: g.upload(&flat(w.get(&format!("{name}.bias"), &[out])?)?),
|
||||
inp,
|
||||
out,
|
||||
})
|
||||
}
|
||||
|
||||
/// As [`GLinear::load`], with output channel `j` scaled by `scale[j]` (the memory fuser's layer scale).
|
||||
pub fn load_scaled(g: &Gpu, w: &crate::weights::Weights, name: &str, inp: usize, out: usize, scale: &[f32]) -> Result<GLinear> {
|
||||
if scale.len() != out {
|
||||
return Err(Error::Weights(format!("`{name}`: {} scales for {out} outputs", scale.len())));
|
||||
}
|
||||
let mut wt = flat(w.get(&format!("{name}.weight"), &[out, inp])?)?;
|
||||
let mut bias = flat(w.get(&format!("{name}.bias"), &[out])?)?;
|
||||
for (j, s) in scale.iter().enumerate() {
|
||||
if let Some(row) = wt.get_mut(j * inp..(j + 1) * inp) {
|
||||
row.iter_mut().for_each(|v| *v *= s);
|
||||
}
|
||||
if let Some(b) = bias.get_mut(j) {
|
||||
*b *= s;
|
||||
}
|
||||
}
|
||||
Ok(GLinear { w: g.upload_weights(&wt), b: g.upload(&bias), inp, out })
|
||||
}
|
||||
|
||||
/// A 1 x 1 convolution (`[out, in, 1, 1]`) as a linear layer over channels-last tokens.
|
||||
pub fn load_conv1x1(g: &Gpu, w: &crate::weights::Weights, name: &str, inp: usize, out: usize) -> Result<GLinear> {
|
||||
Ok(GLinear {
|
||||
w: g.upload_weights(&flat(w.get(&format!("{name}.weight"), &[out, inp, 1, 1])?)?),
|
||||
b: g.upload(&flat(w.get(&format!("{name}.bias"), &[out])?)?),
|
||||
inp,
|
||||
out,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl GNorm {
|
||||
pub fn load(g: &Gpu, w: &crate::weights::Weights, name: &str, dim: usize) -> Result<GNorm> {
|
||||
Ok(GNorm { w: g.upload(&flat(w.get(&format!("{name}.weight"), &[dim])?)?), b: g.upload(&flat(w.get(&format!("{name}.bias"), &[dim])?)?) })
|
||||
}
|
||||
}
|
||||
|
||||
impl Gpu {
|
||||
/// `out = act(x . W^T + bias) (+ residual)` for `rows` rows of `l.inp` values.
|
||||
pub fn linear(&self, x: &Buf, rows: usize, l: &GLinear, out: &Buf, act: Act, residual: Option<&Buf>) -> Result<()> {
|
||||
// the weight is stored [out, in]: B[k][j] = w[j * in + k]
|
||||
self.matmul(&Matmul {
|
||||
m: rows,
|
||||
n: l.out,
|
||||
k: l.inp,
|
||||
batch: 1,
|
||||
a: (x, Strides::row_major(l.inp)),
|
||||
b: (&l.w, Strides { offset: 0, batch: 0, row: 1, col: l.inp }),
|
||||
bias: Some(&l.b),
|
||||
c: (out, Strides::row_major(l.out)),
|
||||
residual,
|
||||
alpha: 1.0,
|
||||
act,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,289 @@
|
||||
// Original work for EffectCraft; the maths follows the Hugging Face `transformers` SAM 2
|
||||
// implementation (Apache-2.0, see NOTICE).
|
||||
//! The vision encoder on the GPU: patch embedding, the Hiera blocks and the neck, with the decoder's two
|
||||
//! skip convolutions. Activations stay on the GPU between the kernels; only the three tensors the rest of
|
||||
//! the model needs come back.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use candle_core::{Device, Tensor};
|
||||
|
||||
use super::{Act, Buf, GLinear, GNorm, Gpu, Matmul, Strides};
|
||||
use crate::hiera::{BlockPlan, Hiera, HieraConfig};
|
||||
use crate::weights::Weights;
|
||||
use crate::{Error, Result};
|
||||
|
||||
struct GBlock {
|
||||
plan: BlockPlan,
|
||||
norm1: GNorm,
|
||||
qkv: GLinear,
|
||||
proj: GLinear,
|
||||
norm2: GNorm,
|
||||
mlp_in: GLinear,
|
||||
mlp_out: GLinear,
|
||||
block_proj: Option<GLinear>,
|
||||
}
|
||||
|
||||
/// What the rest of the model takes from the encoder (CPU tensors, NCHW).
|
||||
pub struct GpuFeatures {
|
||||
/// The lowest-resolution neck level `[1, 256, 64, 64]`.
|
||||
pub low: Tensor,
|
||||
/// The decoder's skip features: `conv_s0` of the highest level `[1, 32, 256, 256]` and `conv_s1` of the
|
||||
/// next `[1, 64, 128, 128]`.
|
||||
pub high: (Tensor, Tensor),
|
||||
}
|
||||
|
||||
pub struct GpuEncoder {
|
||||
gpu: Arc<Gpu>,
|
||||
cfg: HieraConfig,
|
||||
patch_w: Buf,
|
||||
patch_b: Buf,
|
||||
blocks: Vec<GBlock>,
|
||||
stage_ends: Vec<usize>,
|
||||
neck: Vec<GLinear>,
|
||||
top_down: Vec<usize>,
|
||||
skip0: GLinear,
|
||||
skip1: GLinear,
|
||||
/// The position embedding of each grid size, uploaded on first use.
|
||||
pos: Mutex<HashMap<(usize, usize), Buf>>,
|
||||
/// The CPU backbone, which owns the position embedding tables.
|
||||
cpu: Arc<Hiera>,
|
||||
eps: f32,
|
||||
}
|
||||
|
||||
impl GpuEncoder {
|
||||
/// Upload the backbone, the neck and the skip convolutions. `cpu` supplies the position embedding.
|
||||
pub fn load(gpu: Arc<Gpu>, w: &Weights, cpu: Arc<Hiera>) -> Result<GpuEncoder> {
|
||||
let cfg = cpu.config().clone();
|
||||
let flat = |t: Tensor| -> Result<Vec<f32>> { Ok(t.flatten_all()?.to_vec1::<f32>()?) };
|
||||
let hidden = cfg.embed_dims.first().copied().unwrap_or(96);
|
||||
let mut blocks = vec![];
|
||||
for (total, plan) in BlockPlan::all(&cfg)?.into_iter().enumerate() {
|
||||
let p = format!("vision_encoder.backbone.blocks.{total}");
|
||||
blocks.push(GBlock {
|
||||
plan,
|
||||
norm1: GNorm::load(&gpu, w, &format!("{p}.layer_norm1"), plan.dim)?,
|
||||
qkv: GLinear::load(&gpu, w, &format!("{p}.attn.qkv"), plan.dim, plan.dim_out * 3)?,
|
||||
proj: GLinear::load(&gpu, w, &format!("{p}.attn.proj"), plan.dim_out, plan.dim_out)?,
|
||||
norm2: GNorm::load(&gpu, w, &format!("{p}.layer_norm2"), plan.dim_out)?,
|
||||
mlp_in: GLinear::load(&gpu, w, &format!("{p}.mlp.proj_in"), plan.dim_out, plan.dim_out * cfg.mlp_ratio)?,
|
||||
mlp_out: GLinear::load(&gpu, w, &format!("{p}.mlp.proj_out"), plan.dim_out * cfg.mlp_ratio, plan.dim_out)?,
|
||||
block_proj: (plan.dim != plan.dim_out).then(|| GLinear::load(&gpu, w, &format!("{p}.proj"), plan.dim, plan.dim_out)).transpose()?,
|
||||
});
|
||||
}
|
||||
let mut stage_ends = vec![];
|
||||
let mut n = 0;
|
||||
for b in &cfg.blocks {
|
||||
n += b;
|
||||
stage_ends.push(n.saturating_sub(1));
|
||||
}
|
||||
let channels: Vec<usize> = cfg.embed_dims.iter().rev().copied().collect();
|
||||
let neck = channels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(j, c)| GLinear::load_conv1x1(&gpu, w, &format!("vision_encoder.neck.convs.{j}"), *c, 256))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(GpuEncoder {
|
||||
patch_w: gpu.upload(&flat(w.get("vision_encoder.backbone.patch_embed.projection.weight", &[hidden, 3, 7, 7])?)?),
|
||||
patch_b: gpu.upload(&flat(w.get("vision_encoder.backbone.patch_embed.projection.bias", &[hidden])?)?),
|
||||
skip0: GLinear::load_conv1x1(&gpu, w, "mask_decoder.conv_s0", 256, 32)?,
|
||||
skip1: GLinear::load_conv1x1(&gpu, w, "mask_decoder.conv_s1", 256, 64)?,
|
||||
gpu,
|
||||
eps: cfg.eps as f32,
|
||||
cfg,
|
||||
blocks,
|
||||
stage_ends,
|
||||
neck,
|
||||
top_down: vec![2, 3],
|
||||
pos: Mutex::default(),
|
||||
cpu,
|
||||
})
|
||||
}
|
||||
|
||||
fn linear(&self, x: &Buf, rows: usize, l: &GLinear, out: &Buf, act: Act, residual: Option<&Buf>) -> Result<()> {
|
||||
self.gpu.linear(x, rows, l, out, act, residual)
|
||||
}
|
||||
|
||||
fn norm(&self, x: &Buf, rows: usize, dim: usize, n: &GNorm) -> Result<Buf> {
|
||||
let out = self.gpu.zeros(rows * dim)?;
|
||||
self.gpu.layernorm(x, &n.w, &n.b, &out, rows, dim, self.eps, false)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn pos_buffer(&self, h: usize, w: usize) -> Result<Buf> {
|
||||
let mut map = self.pos.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(b) = map.get(&(h, w)) {
|
||||
return Ok(b.clone());
|
||||
}
|
||||
let table = self.cpu.pos(h, w)?.flatten_all()?.to_vec1::<f32>()?;
|
||||
let buf = self.gpu.upload(&table);
|
||||
map.insert((h, w), buf.clone());
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// One Hiera block on `[h * w, dim]` tokens; returns the new tokens and grid size.
|
||||
fn block(&self, b: &GBlock, x: &Buf, h: usize, w: usize) -> Result<(Buf, usize, usize)> {
|
||||
let g = &self.gpu;
|
||||
let BlockPlan { dim, dim_out, heads, window, query_stride } = b.plan;
|
||||
let n = h * w;
|
||||
let normed = self.norm(x, n, dim, &b.norm1)?;
|
||||
// the skip connection: from the block's input, or (stage change) its projection, pooled with the queries
|
||||
let (oh, ow) = query_stride.map_or((h, w), |s| (h / s, w / s));
|
||||
if query_stride.is_some_and(|s| s != 2) {
|
||||
return Err(Error::Input("only a query stride of 2 is supported on the GPU".into()));
|
||||
}
|
||||
let residual = match (&b.block_proj, query_stride) {
|
||||
(Some(p), Some(_)) => {
|
||||
let proj = g.zeros(n * dim_out)?;
|
||||
self.linear(&normed, n, p, &proj, Act::None, None)?;
|
||||
let pooled = g.zeros(oh * ow * dim_out)?;
|
||||
g.maxpool2(&proj, &pooled, 1, h, w, dim_out, (n * dim_out, w * dim_out, dim_out))?;
|
||||
pooled
|
||||
}
|
||||
(Some(p), None) => {
|
||||
let proj = g.zeros(n * dim_out)?;
|
||||
self.linear(&normed, n, p, &proj, Act::None, None)?;
|
||||
proj
|
||||
}
|
||||
(None, _) => x.clone(),
|
||||
};
|
||||
// windows (or the whole grid for global attention)
|
||||
let (hw, nwin, t, win_h, win_w) = if window > 0 {
|
||||
let (nwx, nwy) = (w.div_ceil(window), h.div_ceil(window));
|
||||
let buf = g.zeros(nwx * nwy * window * window * dim)?;
|
||||
g.window_partition(&normed, &buf, h, w, dim, window)?;
|
||||
(buf, nwx * nwy, window * window, window, window)
|
||||
} else {
|
||||
(normed, 1, n, h, w)
|
||||
};
|
||||
let tok = nwin * t;
|
||||
let qkv = g.zeros(tok * 3 * dim_out)?;
|
||||
self.linear(&hw, tok, &b.qkv, &qkv, Act::None, None)?;
|
||||
// queries, pooled when the stage changes
|
||||
let (q_buf, q_rs, tq) = match query_stride {
|
||||
Some(s) => {
|
||||
let (qh, qw) = (win_h / s, win_w / s);
|
||||
let pooled = g.zeros(nwin * qh * qw * dim_out)?;
|
||||
g.maxpool2(&qkv, &pooled, nwin, win_h, win_w, dim_out, (t * 3 * dim_out, win_w * 3 * dim_out, 3 * dim_out))?;
|
||||
(pooled, dim_out, qh * qw)
|
||||
}
|
||||
None => (qkv.clone(), 3 * dim_out, t),
|
||||
};
|
||||
let hd = dim_out / heads.max(1);
|
||||
let scale = (hd as f32).powf(-0.5);
|
||||
let scores = g.zeros(nwin * tq * t)?;
|
||||
let attn = g.zeros(nwin * tq * dim_out)?;
|
||||
for hh in 0..heads {
|
||||
g.matmul(&Matmul {
|
||||
m: tq,
|
||||
n: t,
|
||||
k: hd,
|
||||
batch: nwin,
|
||||
a: (&q_buf, Strides { offset: hh * hd, batch: tq * q_rs, row: q_rs, col: 1 }),
|
||||
b: (&qkv, Strides { offset: dim_out + hh * hd, batch: t * 3 * dim_out, row: 1, col: 3 * dim_out }),
|
||||
bias: None,
|
||||
c: (&scores, Strides { offset: 0, batch: tq * t, row: t, col: 1 }),
|
||||
residual: None,
|
||||
alpha: scale,
|
||||
act: Act::None,
|
||||
})?;
|
||||
g.softmax(&scores, nwin * tq, t)?;
|
||||
g.matmul(&Matmul {
|
||||
m: tq,
|
||||
n: hd,
|
||||
k: t,
|
||||
batch: nwin,
|
||||
a: (&scores, Strides { offset: 0, batch: tq * t, row: t, col: 1 }),
|
||||
b: (&qkv, Strides { offset: 2 * dim_out + hh * hd, batch: t * 3 * dim_out, row: 3 * dim_out, col: 1 }),
|
||||
bias: None,
|
||||
c: (&attn, Strides { offset: hh * hd, batch: tq * dim_out, row: dim_out, col: 1 }),
|
||||
residual: None,
|
||||
alpha: 1.0,
|
||||
act: Act::None,
|
||||
})?;
|
||||
}
|
||||
let projected = g.zeros(nwin * tq * dim_out)?;
|
||||
self.linear(&attn, nwin * tq, &b.proj, &projected, Act::None, None)?;
|
||||
let attn_img = if window > 0 {
|
||||
let ws = window / query_stride.unwrap_or(1);
|
||||
let img = g.zeros(oh * ow * dim_out)?;
|
||||
g.window_unpartition(&projected, &img, oh, ow, dim_out, ws)?;
|
||||
img
|
||||
} else {
|
||||
projected
|
||||
};
|
||||
let n2 = oh * ow;
|
||||
let x2 = g.zeros(n2 * dim_out)?;
|
||||
g.add(&residual, &attn_img, &x2, n2 * dim_out)?;
|
||||
let normed2 = self.norm(&x2, n2, dim_out, &b.norm2)?;
|
||||
let hidden = g.zeros(n2 * b.mlp_in.out)?;
|
||||
self.linear(&normed2, n2, &b.mlp_in, &hidden, Act::Gelu, None)?;
|
||||
let out = g.zeros(n2 * dim_out)?;
|
||||
self.linear(&hidden, n2, &b.mlp_out, &out, Act::None, Some(&x2))?;
|
||||
Ok((out, oh, ow))
|
||||
}
|
||||
|
||||
/// Encode normalised `[1, 3, H, W]` pixels (`H` and `W` multiples of 32).
|
||||
pub fn encode(&self, pixels: &Tensor) -> Result<GpuFeatures> {
|
||||
let g = &self.gpu;
|
||||
let _turn = g.exclusive();
|
||||
let (_, c, ph, pw) = pixels.dims4()?;
|
||||
let window0 = self.cfg.windows.first().copied().unwrap_or(8);
|
||||
if c != 3 || ph == 0 || pw == 0 || ph % 4 != 0 || pw % 4 != 0 || (ph / 4) % window0 != 0 || (pw / 4) % window0 != 0 {
|
||||
return Err(Error::Input(format!("image size {pw}x{ph} does not fit the encoder")));
|
||||
}
|
||||
let hidden = self.cfg.embed_dims.first().copied().unwrap_or(96);
|
||||
let px = g.upload(&pixels.contiguous()?.flatten_all()?.to_vec1::<f32>()?);
|
||||
let (mut h, mut w) = (ph / 4, pw / 4);
|
||||
let tokens = g.zeros(h * w * hidden)?;
|
||||
g.patch_embed(&px, &self.patch_w, &self.patch_b, &tokens, (ph, pw, hidden))?;
|
||||
let mut x = g.zeros(h * w * hidden)?;
|
||||
g.add(&tokens, &self.pos_buffer(h, w)?, &x, h * w * hidden)?;
|
||||
let mut stages: Vec<(Buf, usize, usize, usize)> = vec![];
|
||||
for (i, b) in self.blocks.iter().enumerate() {
|
||||
let (nx, nh, nw) = self.block(b, &x, h, w)?;
|
||||
(x, h, w) = (nx, nh, nw);
|
||||
// submit per block so the GPU can free the previous block's intermediates (Large would otherwise run out of memory)
|
||||
g.flush()?;
|
||||
if self.stage_ends.contains(&i) {
|
||||
stages.push((x.clone(), h, w, b.plan.dim_out));
|
||||
}
|
||||
}
|
||||
// the neck, from the lowest resolution up: lateral 1x1 convolutions, and the top-down sum on the levels that have it
|
||||
let n = self.neck.len();
|
||||
if stages.len() != n {
|
||||
return Err(Error::Compute("the backbone returned the wrong number of stages".into()));
|
||||
}
|
||||
let mut levels: Vec<(Buf, usize, usize)> = vec![];
|
||||
let mut prev: Option<(Buf, usize, usize)> = None;
|
||||
for i in (0..n).rev() {
|
||||
let (buf, sh, sw, _) = &stages[i];
|
||||
let conv = self.neck.get(n - 1 - i).ok_or_else(|| Error::Compute("missing neck convolution".into()))?;
|
||||
let lateral = g.zeros(sh * sw * 256)?;
|
||||
self.linear(buf, sh * sw, conv, &lateral, Act::None, None)?;
|
||||
let cur = match (&prev, self.top_down.contains(&i) && i != n - 1) {
|
||||
(Some((p, _, _)), true) => {
|
||||
let sum = g.zeros(sh * sw * 256)?;
|
||||
g.upsample_add(&lateral, p, &sum, *sh, *sw, 256)?;
|
||||
sum
|
||||
}
|
||||
_ => lateral,
|
||||
};
|
||||
prev = Some((cur.clone(), *sh, *sw));
|
||||
levels.push((cur, *sh, *sw));
|
||||
}
|
||||
// levels: lowest resolution first. The decoder needs the third (64 x 64) and the skip convolutions of the last two.
|
||||
let [_, (l2, h2, w2), (l1, h1, w1), (l0, h0, w0)] = <[_; 4]>::try_from(levels).map_err(|_| Error::Compute("the neck must have four levels".into()))?;
|
||||
let s0 = g.zeros(h0 * w0 * 32)?;
|
||||
self.linear(&l0, h0 * w0, &self.skip0, &s0, Act::None, None)?;
|
||||
let s1 = g.zeros(h1 * w1 * 64)?;
|
||||
self.linear(&l1, h1 * w1, &self.skip1, &s1, Act::None, None)?;
|
||||
g.flush()?;
|
||||
let nchw = |buf: &Buf, hh: usize, ww: usize, ch: usize| -> Result<Tensor> {
|
||||
let v = g.download(buf)?;
|
||||
Ok(Tensor::from_vec(v, (1, hh, ww, ch), &Device::Cpu)?.permute((0, 3, 1, 2))?.contiguous()?)
|
||||
};
|
||||
Ok(GpuFeatures { low: nchw(&l2, h2, w2, 256)?, high: (nchw(&s0, h0, w0, 32)?, nchw(&s1, h1, w1, 64)?) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// SAM 2 compute kernels (f32). Original work for EffectCraft; the maths follows the Hugging Face
|
||||
// `transformers` SAM 2 implementation (Apache-2.0, see NOTICE).
|
||||
//
|
||||
// Every kernel reads its parameters from one uniform: 24 u32 words (floats are bit-cast).
|
||||
|
||||
struct P { v: array<vec4<u32>, 6> }
|
||||
@group(0) @binding(0) var<storage, read> in0: array<f32>;
|
||||
@group(0) @binding(1) var<storage, read> in1: array<f32>;
|
||||
@group(0) @binding(2) var<storage, read> in2: array<f32>;
|
||||
@group(0) @binding(3) var<storage, read_write> out0: array<f32>;
|
||||
@group(0) @binding(4) var<storage, read> in3: array<f32>;
|
||||
@group(0) @binding(5) var<uniform> p: P;
|
||||
|
||||
fn u(i: u32) -> u32 { return p.v[i / 4u][i % 4u]; }
|
||||
fn f(i: u32) -> f32 { return bitcast<f32>(u(i)); }
|
||||
|
||||
// erf, Abramowitz and Stegun 7.1.26 (abs error 1.5e-7).
|
||||
fn erf_(x: f32) -> f32 {
|
||||
let s = select(1.0, -1.0, x < 0.0);
|
||||
let a = abs(x);
|
||||
let t = 1.0 / (1.0 + 0.3275911 * a);
|
||||
let y = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * exp(-a * a);
|
||||
return s * y;
|
||||
}
|
||||
fn gelu_(x: f32) -> f32 { return (erf_(x * 0.70710678) + 1.0) * 0.5 * x; }
|
||||
|
||||
// ---------------------------------------------------------------- strided batched matmul
|
||||
// C[b] = act(alpha * A[b] (m x k) . B[b] (k x n) + bias) (+ residual), with free strides for A and B.
|
||||
// words: 0 m, 1 n, 2 k, 3 batch, 4 a_off, 5 a_bs, 6 a_rs, 7 a_cs, 8 b_off, 9 b_bs, 10 b_rs, 11 b_cs,
|
||||
// 12 c_off, 13 c_bs, 14 c_rs, 15 flags (bit0 bias, bits 1-2 act: 1 gelu 2 relu, bit3 residual),
|
||||
// 16 alpha (f32)
|
||||
// in0 = A, in1 = B, in2 = bias (n), out0 = C, in3 = residual (same layout as C)
|
||||
var<workgroup> sa: array<f32, 1024>;
|
||||
var<workgroup> sb: array<f32, 1024>;
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn matmul(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {
|
||||
let m = u(0u); let n = u(1u); let k = u(2u);
|
||||
let a_base = u(4u) + wg.z * u(5u);
|
||||
let b_base = u(8u) + wg.z * u(9u);
|
||||
let a_rs = u(6u); let a_cs = u(7u); let b_rs = u(10u); let b_cs = u(11u);
|
||||
let row0 = wg.y * 64u;
|
||||
let col0 = wg.x * 64u;
|
||||
let tid = li.y * 16u + li.x;
|
||||
let a_k_contig = a_cs == 1u;
|
||||
let b_n_contig = b_cs == 1u;
|
||||
var acc: array<f32, 16>;
|
||||
for (var i = 0u; i < 16u; i++) { acc[i] = 0.0; }
|
||||
for (var kt = 0u; kt < k; kt += 16u) {
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
let idx = tid + i * 256u;
|
||||
var r: u32; var c: u32;
|
||||
if (a_k_contig) { r = idx / 16u; c = idx % 16u; } else { r = idx % 64u; c = idx / 64u; }
|
||||
let gr = row0 + r; let gc = kt + c;
|
||||
var va = 0.0;
|
||||
if (gr < m && gc < k) { va = in0[a_base + gr * a_rs + gc * a_cs]; }
|
||||
sa[r * 16u + c] = va;
|
||||
var r2: u32; var c2: u32;
|
||||
if (b_n_contig) { r2 = idx / 64u; c2 = idx % 64u; } else { r2 = idx % 16u; c2 = idx / 16u; }
|
||||
let gr2 = kt + r2; let gc2 = col0 + c2;
|
||||
var vb = 0.0;
|
||||
if (gr2 < k && gc2 < n) { vb = in1[b_base + gr2 * b_rs + gc2 * b_cs]; }
|
||||
sb[r2 * 64u + c2] = vb;
|
||||
}
|
||||
workgroupBarrier();
|
||||
for (var kk = 0u; kk < 16u; kk++) {
|
||||
var ar: array<f32, 4>;
|
||||
var br: array<f32, 4>;
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
ar[i] = sa[(li.y * 4u + i) * 16u + kk];
|
||||
br[i] = sb[kk * 64u + li.x * 4u + i];
|
||||
}
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
for (var j = 0u; j < 4u; j++) { acc[i * 4u + j] += ar[i] * br[j]; }
|
||||
}
|
||||
}
|
||||
workgroupBarrier();
|
||||
}
|
||||
let flags = u(15u);
|
||||
let alpha = f(16u);
|
||||
let c_base = u(12u) + wg.z * u(13u);
|
||||
let c_rs = u(14u);
|
||||
let act = (flags >> 1u) & 3u;
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
for (var j = 0u; j < 4u; j++) {
|
||||
let gr = row0 + li.y * 4u + i;
|
||||
let gc = col0 + li.x * 4u + j;
|
||||
if (gr < m && gc < n) {
|
||||
var v = acc[i * 4u + j] * alpha;
|
||||
if ((flags & 1u) != 0u) { v += in2[gc]; }
|
||||
if (act == 1u) { v = gelu_(v); } else if (act == 2u) { v = max(v, 0.0); }
|
||||
let idx = c_base + gr * c_rs + gc;
|
||||
if ((flags & 8u) != 0u) { v += in3[idx]; }
|
||||
out0[idx] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The same, with B (a weight matrix) stored as half precision, two values per word.
|
||||
@group(0) @binding(1) var<storage, read> w16: array<u32>;
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn matmul_w16(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {
|
||||
let m = u(0u); let n = u(1u); let k = u(2u);
|
||||
let a_base = u(4u) + wg.z * u(5u);
|
||||
let b_base = u(8u) + wg.z * u(9u);
|
||||
let a_rs = u(6u); let a_cs = u(7u); let b_rs = u(10u); let b_cs = u(11u);
|
||||
let row0 = wg.y * 64u;
|
||||
let col0 = wg.x * 64u;
|
||||
let tid = li.y * 16u + li.x;
|
||||
let a_k_contig = a_cs == 1u;
|
||||
let b_n_contig = b_cs == 1u;
|
||||
var acc: array<f32, 16>;
|
||||
for (var i = 0u; i < 16u; i++) { acc[i] = 0.0; }
|
||||
for (var kt = 0u; kt < k; kt += 16u) {
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
let idx = tid + i * 256u;
|
||||
var r: u32; var c: u32;
|
||||
if (a_k_contig) { r = idx / 16u; c = idx % 16u; } else { r = idx % 64u; c = idx / 64u; }
|
||||
let gr = row0 + r; let gc = kt + c;
|
||||
var va = 0.0;
|
||||
if (gr < m && gc < k) { va = in0[a_base + gr * a_rs + gc * a_cs]; }
|
||||
sa[r * 16u + c] = va;
|
||||
var r2: u32; var c2: u32;
|
||||
if (b_n_contig) { r2 = idx / 64u; c2 = idx % 64u; } else { r2 = idx % 16u; c2 = idx / 16u; }
|
||||
let gr2 = kt + r2; let gc2 = col0 + c2;
|
||||
var vb = 0.0;
|
||||
if (gr2 < k && gc2 < n) { let bi = b_base + gr2 * b_rs + gc2 * b_cs; vb = unpack2x16float(w16[bi >> 1u])[bi & 1u]; }
|
||||
sb[r2 * 64u + c2] = vb;
|
||||
}
|
||||
workgroupBarrier();
|
||||
for (var kk = 0u; kk < 16u; kk++) {
|
||||
var ar: array<f32, 4>;
|
||||
var br: array<f32, 4>;
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
ar[i] = sa[(li.y * 4u + i) * 16u + kk];
|
||||
br[i] = sb[kk * 64u + li.x * 4u + i];
|
||||
}
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
for (var j = 0u; j < 4u; j++) { acc[i * 4u + j] += ar[i] * br[j]; }
|
||||
}
|
||||
}
|
||||
workgroupBarrier();
|
||||
}
|
||||
let flags = u(15u);
|
||||
let alpha = f(16u);
|
||||
let c_base = u(12u) + wg.z * u(13u);
|
||||
let c_rs = u(14u);
|
||||
let act = (flags >> 1u) & 3u;
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
for (var j = 0u; j < 4u; j++) {
|
||||
let gr = row0 + li.y * 4u + i;
|
||||
let gc = col0 + li.x * 4u + j;
|
||||
if (gr < m && gc < n) {
|
||||
var v = acc[i * 4u + j] * alpha;
|
||||
if ((flags & 1u) != 0u) { v += in2[gc]; }
|
||||
if (act == 1u) { v = gelu_(v); } else if (act == 2u) { v = max(v, 0.0); }
|
||||
let idx = c_base + gr * c_rs + gc;
|
||||
if ((flags & 8u) != 0u) { v += in3[idx]; }
|
||||
out0[idx] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
// A 1-D index over n items spread on a 2-D grid of 256-thread groups (a dimension holds 65535 groups).
|
||||
fn gid1(wg: vec3<u32>, nw: vec3<u32>, li: vec3<u32>) -> u32 { return (wg.y * nw.x + wg.x) * 256u + li.x; }
|
||||
|
||||
var<workgroup> red: array<f32, 64>;
|
||||
|
||||
// Sum of `red` over the workgroup (64 threads), for a caller that has filled it; all threads get the result.
|
||||
fn reduce_sum(li: u32) -> f32 {
|
||||
workgroupBarrier();
|
||||
for (var st = 32u; st > 0u; st = st / 2u) {
|
||||
if (li < st) { red[li] += red[li + st]; }
|
||||
workgroupBarrier();
|
||||
}
|
||||
let r = red[0];
|
||||
workgroupBarrier();
|
||||
return r;
|
||||
}
|
||||
|
||||
fn reduce_max(li: u32) -> f32 {
|
||||
workgroupBarrier();
|
||||
for (var st = 32u; st > 0u; st = st / 2u) {
|
||||
if (li < st) { red[li] = max(red[li], red[li + st]); }
|
||||
workgroupBarrier();
|
||||
}
|
||||
let r = red[0];
|
||||
workgroupBarrier();
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- layer norm over the last dimension
|
||||
// words: 0 rows, 1 cols, 2 eps (f32), 3 gelu (1: apply GELU to the result). in0 x, in1 weight, in2 bias, out0 y. One workgroup per row.
|
||||
@compute @workgroup_size(64)
|
||||
fn layernorm(@builtin(workgroup_id) wg: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {
|
||||
let row = wg.y * nw.x + wg.x;
|
||||
let rows = u(0u); let cols = u(1u);
|
||||
if (row >= rows) { return; }
|
||||
let base = row * cols;
|
||||
var s = 0.0;
|
||||
for (var c = li.x; c < cols; c += 64u) { s += in0[base + c]; }
|
||||
red[li.x] = s;
|
||||
let mean = reduce_sum(li.x) / f32(cols);
|
||||
var q = 0.0;
|
||||
for (var c = li.x; c < cols; c += 64u) { let d = in0[base + c] - mean; q += d * d; }
|
||||
red[li.x] = q;
|
||||
let inv = inverseSqrt(reduce_sum(li.x) / f32(cols) + f(2u));
|
||||
for (var c = li.x; c < cols; c += 64u) {
|
||||
var y = (in0[base + c] - mean) * inv * in1[c] + in2[c];
|
||||
if (u(3u) == 1u) { y = gelu_(y); }
|
||||
out0[base + c] = y;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- convolution over channels-last [H, W, Ci]
|
||||
// words: 0 n (output elements), 1 H, 2 W, 3 Ci, 4 Co, 5 Ho, 6 Wo, 7 k, 8 stride, 9 pad, 10 groups.
|
||||
// in0 input, in1 weight [Co, Ci / groups, k, k], in2 bias [Co]; output [Ho, Wo, Co].
|
||||
@compute @workgroup_size(256)
|
||||
fn conv2d(@builtin(workgroup_id) wg: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {
|
||||
let i = gid1(wg, nw, li);
|
||||
if (i >= u(0u)) { return; }
|
||||
let H = u(1u); let W = u(2u); let Ci = u(3u); let Co = u(4u); let Wo = u(6u);
|
||||
let k = u(7u); let stride = u(8u); let pad = i32(u(9u)); let groups = u(10u);
|
||||
let co = i % Co; let t = i / Co;
|
||||
let ox = t % Wo; let oy = t / Wo;
|
||||
let cig = Ci / groups;
|
||||
let g0 = co / (Co / groups);
|
||||
var acc = in2[co];
|
||||
for (var c = 0u; c < cig; c++) {
|
||||
let ci = g0 * cig + c;
|
||||
for (var ky = 0u; ky < k; ky++) {
|
||||
let iy = i32(oy * stride + ky) - pad;
|
||||
if (iy < 0 || iy >= i32(H)) { continue; }
|
||||
for (var kx = 0u; kx < k; kx++) {
|
||||
let ix = i32(ox * stride + kx) - pad;
|
||||
if (ix < 0 || ix >= i32(W)) { continue; }
|
||||
acc += in0[(u32(iy) * W + u32(ix)) * Ci + ci] * in1[((co * cig + c) * k + ky) * k + kx];
|
||||
}
|
||||
}
|
||||
}
|
||||
out0[i] = acc;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- softmax over rows, in place on out0
|
||||
// words: 0 rows, 1 cols. One workgroup per row.
|
||||
@compute @workgroup_size(64)
|
||||
fn softmax(@builtin(workgroup_id) wg: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {
|
||||
let row = wg.y * nw.x + wg.x;
|
||||
let rows = u(0u); let cols = u(1u);
|
||||
if (row >= rows) { return; }
|
||||
let base = row * cols;
|
||||
var mx = -3.0e38;
|
||||
for (var c = li.x; c < cols; c += 64u) { mx = max(mx, out0[base + c]); }
|
||||
red[li.x] = mx;
|
||||
let m = reduce_max(li.x);
|
||||
var s = 0.0;
|
||||
for (var c = li.x; c < cols; c += 64u) { let e = exp(out0[base + c] - m); out0[base + c] = e; s += e; }
|
||||
red[li.x] = s;
|
||||
let inv = 1.0 / reduce_sum(li.x);
|
||||
for (var c = li.x; c < cols; c += 64u) { out0[base + c] = out0[base + c] * inv; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- elementwise add: out0 = in0 + in1 (n items)
|
||||
@compute @workgroup_size(256)
|
||||
fn add(@builtin(workgroup_id) wg: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {
|
||||
let i = gid1(wg, nw, li);
|
||||
if (i < u(0u)) { out0[i] = in0[i] + in1[i]; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- windows
|
||||
// words: 0 n (output elements), 1 H, 2 W, 3 C, 4 ws, 5 windows across (nwx)
|
||||
// window_partition: [H, W, C] -> [nwin, ws, ws, C] (zero padded); window_unpartition: the inverse.
|
||||
@compute @workgroup_size(256)
|
||||
fn window_partition(@builtin(workgroup_id) wg: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {
|
||||
let i = gid1(wg, nw, li);
|
||||
if (i >= u(0u)) { return; }
|
||||
let H = u(1u); let W = u(2u); let C = u(3u); let ws = u(4u); let nwx = u(5u);
|
||||
let c = i % C; var t = i / C;
|
||||
let x = t % ws; t = t / ws;
|
||||
let y = t % ws; let win = t / ws;
|
||||
let gy = (win / nwx) * ws + y;
|
||||
let gx = (win % nwx) * ws + x;
|
||||
var v = 0.0;
|
||||
if (gy < H && gx < W) { v = in0[(gy * W + gx) * C + c]; }
|
||||
out0[i] = v;
|
||||
}
|
||||
|
||||
@compute @workgroup_size(256)
|
||||
fn window_unpartition(@builtin(workgroup_id) wg: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {
|
||||
let i = gid1(wg, nw, li);
|
||||
if (i >= u(0u)) { return; }
|
||||
let W = u(2u); let C = u(3u); let ws = u(4u); let nwx = u(5u);
|
||||
let c = i % C; let t = i / C;
|
||||
let gx = t % W; let gy = t / W;
|
||||
let win = (gy / ws) * nwx + gx / ws;
|
||||
out0[i] = in0[((win * ws + gy % ws) * ws + gx % ws) * C + c];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 2 x 2 max pool over [nb, h, w, c] tokens
|
||||
// words: 0 n (output elements), 1 nb, 2 h, 3 w, 4 c, 5 src image stride, 6 src row stride, 7 src token stride
|
||||
// (so it can read the q third of a qkv buffer in place). Output [nb, h/2, w/2, c] contiguous.
|
||||
@compute @workgroup_size(256)
|
||||
fn maxpool2(@builtin(workgroup_id) wg: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {
|
||||
let i = gid1(wg, nw, li);
|
||||
if (i >= u(0u)) { return; }
|
||||
let h = u(2u); let w = u(3u); let c = u(4u);
|
||||
let oh = h / 2u; let ow = w / 2u;
|
||||
let ch = i % c; var t = i / c;
|
||||
let ox = t % ow; t = t / ow;
|
||||
let oy = t % oh; let img = t / oh;
|
||||
let base = img * u(5u) + (oy * 2u) * u(6u) + (ox * 2u) * u(7u) + ch;
|
||||
out0[i] = max(max(in0[base], in0[base + u(7u)]), max(in0[base + u(6u)], in0[base + u(6u) + u(7u)]));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- patch embedding: 7 x 7 stride 4 pad 3 convolution
|
||||
// words: 0 n (output elements), 1 H, 2 W, 3 Co, 4 Ho, 5 Wo. in0 pixels [3, H, W], in1 weight [Co, 3, 7, 7],
|
||||
// in2 bias [Co]. Output [Ho, Wo, Co].
|
||||
@compute @workgroup_size(256)
|
||||
fn patch_embed(@builtin(workgroup_id) wg: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {
|
||||
let i = gid1(wg, nw, li);
|
||||
if (i >= u(0u)) { return; }
|
||||
let H = u(1u); let W = u(2u); let Co = u(3u); let Wo = u(5u);
|
||||
let co = i % Co; let t = i / Co;
|
||||
let ox = t % Wo; let oy = t / Wo;
|
||||
var acc = in2[co];
|
||||
for (var ci = 0u; ci < 3u; ci++) {
|
||||
for (var ky = 0u; ky < 7u; ky++) {
|
||||
let iy = i32(oy * 4u + ky) - 3;
|
||||
if (iy < 0 || iy >= i32(H)) { continue; }
|
||||
for (var kx = 0u; kx < 7u; kx++) {
|
||||
let ix = i32(ox * 4u + kx) - 3;
|
||||
if (ix < 0 || ix >= i32(W)) { continue; }
|
||||
acc += in0[ci * H * W + u32(iy) * W + u32(ix)] * in1[((co * 3u + ci) * 7u + ky) * 7u + kx];
|
||||
}
|
||||
}
|
||||
}
|
||||
out0[i] = acc;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rotary position encoding on [rows, cols]
|
||||
// words: 0 n (= rows * cols), 1 cols, 2 table rows, 3 rows rotated (the rest is copied). in0 x, in1 cos, in2 sin
|
||||
// (tables [table rows, cols]); pairs (x0, x1) -> (x0 c - x1 s, x1 c + x0 s).
|
||||
@compute @workgroup_size(256)
|
||||
fn rope(@builtin(workgroup_id) wg: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {
|
||||
let i = gid1(wg, nw, li);
|
||||
if (i >= u(0u)) { return; }
|
||||
let cols = u(1u);
|
||||
let row = i / cols; let col = i % cols;
|
||||
if (row >= u(3u)) { out0[i] = in0[i]; return; }
|
||||
let t = (row % u(2u)) * cols + col;
|
||||
let x = in0[i];
|
||||
var pair = 0.0;
|
||||
if ((col & 1u) == 1u) { pair = in0[i - 1u]; } else { pair = in0[i + 1u]; }
|
||||
if ((col & 1u) == 0u) { out0[i] = x * in1[t] - pair * in2[t]; } else { out0[i] = x * in1[t] + pair * in2[t]; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- neck: lateral + nearest 2x upsample of prev
|
||||
// words: 0 n (output elements), 1 h, 2 w, 3 c. in0 lateral [h, w, c], in1 prev [h/2, w/2, c].
|
||||
@compute @workgroup_size(256)
|
||||
fn upsample_add(@builtin(workgroup_id) wg: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {
|
||||
let i = gid1(wg, nw, li);
|
||||
if (i >= u(0u)) { return; }
|
||||
let w = u(2u); let c = u(3u);
|
||||
let ch = i % c; let t = i / c;
|
||||
let x = t % w; let y = t / w;
|
||||
out0[i] = in0[i] + in1[(((y / 2u) * (w / 2u)) + x / 2u) * c + ch];
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Original work for EffectCraft; the maths follows the Hugging Face `transformers` SAM 2 video
|
||||
// implementation (Apache-2.0, see NOTICE).
|
||||
//! Memory attention on the GPU: four layers of self-attention over the current frame and cross-attention to
|
||||
//! the memory bank, with axial rotary position encoding. The attention is one head of 256 channels; the
|
||||
//! score matrix against a full memory bank is 4096 x 28736 floats, which stays on the GPU.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use candle_core::{Device, Tensor};
|
||||
|
||||
use super::{Act, Buf, GLinear, GNorm, Gpu, Matmul, Strides};
|
||||
use crate::memattn::{GRID, rope_tables};
|
||||
use crate::weights::Weights;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const DIM: usize = 256;
|
||||
const LAYERS: usize = 4;
|
||||
|
||||
struct GAttn {
|
||||
q: GLinear,
|
||||
k: GLinear,
|
||||
v: GLinear,
|
||||
o: GLinear,
|
||||
}
|
||||
|
||||
struct GLayer {
|
||||
self_attn: GAttn,
|
||||
cross: GAttn,
|
||||
ln1: GNorm,
|
||||
ln2: GNorm,
|
||||
ln3: GNorm,
|
||||
lin1: GLinear,
|
||||
lin2: GLinear,
|
||||
}
|
||||
|
||||
pub struct GpuMemoryAttention {
|
||||
gpu: Arc<Gpu>,
|
||||
layers: Vec<GLayer>,
|
||||
final_ln: GNorm,
|
||||
cos: Buf,
|
||||
sin: Buf,
|
||||
}
|
||||
|
||||
fn attn(g: &Gpu, w: &Weights, prefix: &str, kv: usize) -> Result<GAttn> {
|
||||
Ok(GAttn {
|
||||
q: GLinear::load(g, w, &format!("{prefix}.q_proj"), DIM, DIM)?,
|
||||
k: GLinear::load(g, w, &format!("{prefix}.k_proj"), kv, DIM)?,
|
||||
v: GLinear::load(g, w, &format!("{prefix}.v_proj"), kv, DIM)?,
|
||||
o: GLinear::load(g, w, &format!("{prefix}.o_proj"), DIM, DIM)?,
|
||||
})
|
||||
}
|
||||
|
||||
impl GpuMemoryAttention {
|
||||
pub fn load(gpu: Arc<Gpu>, w: &Weights) -> Result<GpuMemoryAttention> {
|
||||
let layers = (0..LAYERS)
|
||||
.map(|i| {
|
||||
let p = format!("memory_attention.layers.{i}");
|
||||
Ok(GLayer {
|
||||
self_attn: attn(&gpu, w, &format!("{p}.self_attn"), DIM)?,
|
||||
cross: attn(&gpu, w, &format!("{p}.cross_attn_image"), 64)?,
|
||||
ln1: GNorm::load(&gpu, w, &format!("{p}.layer_norm1"), DIM)?,
|
||||
ln2: GNorm::load(&gpu, w, &format!("{p}.layer_norm2"), DIM)?,
|
||||
ln3: GNorm::load(&gpu, w, &format!("{p}.layer_norm3"), DIM)?,
|
||||
lin1: GLinear::load(&gpu, w, &format!("{p}.linear1"), DIM, 2048)?,
|
||||
lin2: GLinear::load(&gpu, w, &format!("{p}.linear2"), 2048, DIM)?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let (cos, sin) = rope_tables(10000.0)?;
|
||||
let flat = |t: Tensor| -> Result<Vec<f32>> { Ok(t.flatten_all()?.to_vec1::<f32>()?) };
|
||||
Ok(GpuMemoryAttention {
|
||||
final_ln: GNorm::load(&gpu, w, "memory_attention.layer_norm", DIM)?,
|
||||
cos: gpu.upload(&flat(cos)?),
|
||||
sin: gpu.upload(&flat(sin)?),
|
||||
layers,
|
||||
gpu,
|
||||
})
|
||||
}
|
||||
|
||||
fn norm(&self, x: &Buf, rows: usize, n: &GNorm) -> Result<Buf> {
|
||||
let out = self.gpu.zeros(rows * DIM)?;
|
||||
self.gpu.layernorm(x, &n.w, &n.b, &out, rows, DIM, 1e-5, false)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn rope(&self, x: &Buf, rows: usize, rotated: usize) -> Result<Buf> {
|
||||
let out = self.gpu.zeros(rows * DIM)?;
|
||||
self.gpu.rope(x, &self.cos, &self.sin, &out, rows, DIM, GRID * GRID, rotated)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `softmax(q . k^T / 16) . v` for `[nq, 256]` queries and `[nk, 256]` keys and values, through `scores`
|
||||
/// (at least `nq * nk` elements); returns `[nq, 256]`.
|
||||
fn attention(&self, q: &Buf, k: &Buf, v: &Buf, nq: usize, nk: usize, scores: &Buf) -> Result<Buf> {
|
||||
let g = &self.gpu;
|
||||
g.matmul(&Matmul {
|
||||
m: nq,
|
||||
n: nk,
|
||||
k: DIM,
|
||||
batch: 1,
|
||||
a: (q, Strides::row_major(DIM)),
|
||||
b: (k, Strides { offset: 0, batch: 0, row: 1, col: DIM }),
|
||||
bias: None,
|
||||
c: (scores, Strides::row_major(nk)),
|
||||
residual: None,
|
||||
alpha: (DIM as f32).powf(-0.5),
|
||||
act: Act::None,
|
||||
})?;
|
||||
g.softmax(scores, nq, nk)?;
|
||||
let out = g.zeros(nq * DIM)?;
|
||||
g.matmul(&Matmul {
|
||||
m: nq,
|
||||
n: DIM,
|
||||
k: nk,
|
||||
batch: 1,
|
||||
a: (scores, Strides::row_major(nk)),
|
||||
b: (v, Strides::row_major(DIM)),
|
||||
bias: None,
|
||||
c: (&out, Strides::row_major(DIM)),
|
||||
residual: None,
|
||||
alpha: 1.0,
|
||||
act: Act::None,
|
||||
})?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// As [`crate::memattn::MemoryAttention::forward`]: `current` and `current_pos` `[4096, 256]`, `memory` and
|
||||
/// `memory_pos` `[M, 64]`, the last `num_pointers` memory rows being object pointers (not rotated).
|
||||
pub fn forward(&self, current: &Tensor, current_pos: &Tensor, memory: &Tensor, memory_pos: &Tensor, num_pointers: usize) -> Result<Tensor> {
|
||||
let g = &self.gpu;
|
||||
let _turn = g.exclusive();
|
||||
let n = GRID * GRID;
|
||||
if current.dims() != [n, DIM] || current_pos.dims() != [n, DIM] {
|
||||
return Err(Error::Input(format!("current features must be [{n}, {DIM}], got {:?}", current.dims())));
|
||||
}
|
||||
let (m, md) = memory.dims2()?;
|
||||
if md != 64 || memory_pos.dims() != [m, 64] {
|
||||
return Err(Error::Input(format!("memory must be [M, 64] with a matching position encoding, got {:?}", memory.dims())));
|
||||
}
|
||||
let rotated = m.checked_sub(num_pointers).ok_or_else(|| Error::Input("more object pointers than memory tokens".into()))?;
|
||||
if rotated % n != 0 {
|
||||
return Err(Error::Input(format!("memory length {rotated} is not a multiple of {n}")));
|
||||
}
|
||||
let flat = |t: &Tensor| -> Result<Vec<f32>> { Ok(t.contiguous()?.flatten_all()?.to_vec1::<f32>()?) };
|
||||
let mut x = g.upload(&flat(&(current + (current_pos * 0.1)?)?)?);
|
||||
let mem = g.upload(&flat(memory)?);
|
||||
let keys_in = g.upload(&flat(&(memory + memory_pos)?)?);
|
||||
let scores = g.zeros(n * n.max(m))?;
|
||||
for l in &self.layers {
|
||||
// self-attention over the frame
|
||||
let q_in = self.norm(&x, n, &l.ln1)?;
|
||||
let (q, k, v) = (g.zeros(n * DIM)?, g.zeros(n * DIM)?, g.zeros(n * DIM)?);
|
||||
g.linear(&q_in, n, &l.self_attn.q, &q, Act::None, None)?;
|
||||
g.linear(&q_in, n, &l.self_attn.k, &k, Act::None, None)?;
|
||||
g.linear(&q_in, n, &l.self_attn.v, &v, Act::None, None)?;
|
||||
let (q, k) = (self.rope(&q, n, n)?, self.rope(&k, n, n)?);
|
||||
let a = self.attention(&q, &k, &v, n, n, &scores)?;
|
||||
let x1 = g.zeros(n * DIM)?;
|
||||
g.linear(&a, n, &l.self_attn.o, &x1, Act::None, Some(&x))?;
|
||||
// cross-attention to the memory bank
|
||||
let q_in = self.norm(&x1, n, &l.ln2)?;
|
||||
let q = g.zeros(n * DIM)?;
|
||||
g.linear(&q_in, n, &l.cross.q, &q, Act::None, None)?;
|
||||
let q = self.rope(&q, n, n)?;
|
||||
let (k, v) = (g.zeros(m * DIM)?, g.zeros(m * DIM)?);
|
||||
g.linear(&keys_in, m, &l.cross.k, &k, Act::None, None)?;
|
||||
g.linear(&mem, m, &l.cross.v, &v, Act::None, None)?;
|
||||
let k = self.rope(&k, m, rotated)?;
|
||||
let a = self.attention(&q, &k, &v, n, m, &scores)?;
|
||||
let x2 = g.zeros(n * DIM)?;
|
||||
g.linear(&a, n, &l.cross.o, &x2, Act::None, Some(&x1))?;
|
||||
// feed-forward
|
||||
let f_in = self.norm(&x2, n, &l.ln3)?;
|
||||
let hidden = g.zeros(n * 2048)?;
|
||||
g.linear(&f_in, n, &l.lin1, &hidden, Act::Relu, None)?;
|
||||
let x3 = g.zeros(n * DIM)?;
|
||||
g.linear(&hidden, n, &l.lin2, &x3, Act::None, Some(&x2))?;
|
||||
x = x3;
|
||||
}
|
||||
let out = self.norm(&x, n, &self.final_ln)?;
|
||||
g.flush()?;
|
||||
Ok(Tensor::from_vec(g.download(&out)?, (n, DIM), &Device::Cpu)?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Original work for EffectCraft; the maths follows the Hugging Face `transformers` SAM 2 video
|
||||
// implementation (Apache-2.0, see NOTICE).
|
||||
//! The memory encoder on the GPU: the mask is down-sampled by four strided convolutions, fused with the
|
||||
//! frame's features by two ConvNeXt-style blocks, and projected to the 64 channels of a memory.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use candle_core::{Device, Tensor};
|
||||
|
||||
use super::{Act, Buf, GLinear, GNorm, Gpu};
|
||||
use crate::weights::Weights;
|
||||
use crate::{Error, Result};
|
||||
|
||||
struct GConv {
|
||||
w: Buf,
|
||||
b: Buf,
|
||||
ci: usize,
|
||||
co: usize,
|
||||
k: usize,
|
||||
}
|
||||
|
||||
struct GFuser {
|
||||
dw: GConv,
|
||||
norm: GNorm,
|
||||
pw1: GLinear,
|
||||
/// The layer scale is folded into this layer's weights.
|
||||
pw2: GLinear,
|
||||
}
|
||||
|
||||
pub struct GpuMemoryEncoder {
|
||||
gpu: Arc<Gpu>,
|
||||
down: Vec<(GConv, GNorm)>,
|
||||
final_conv: GLinear,
|
||||
feature_projection: GLinear,
|
||||
fuser: Vec<GFuser>,
|
||||
projection: GLinear,
|
||||
}
|
||||
|
||||
fn conv(g: &Gpu, w: &Weights, name: &str, ci: usize, co: usize, k: usize, groups: usize) -> Result<GConv> {
|
||||
let flat = |t: Tensor| -> Result<Vec<f32>> { Ok(t.flatten_all()?.to_vec1::<f32>()?) };
|
||||
Ok(GConv {
|
||||
w: g.upload(&flat(w.get(&format!("{name}.weight"), &[co, ci / groups, k, k])?)?),
|
||||
b: g.upload(&flat(w.get(&format!("{name}.bias"), &[co])?)?),
|
||||
ci,
|
||||
co,
|
||||
k,
|
||||
})
|
||||
}
|
||||
|
||||
impl GpuMemoryEncoder {
|
||||
pub fn load(gpu: Arc<Gpu>, w: &Weights) -> Result<GpuMemoryEncoder> {
|
||||
let mut down = vec![];
|
||||
let mut ch = 1;
|
||||
for i in 0..4 {
|
||||
let out = ch * 4;
|
||||
let p = format!("memory_encoder.mask_downsampler.layers.{i}");
|
||||
down.push((conv(&gpu, w, &format!("{p}.conv"), ch, out, 3, 1)?, GNorm::load(&gpu, w, &format!("{p}.layer_norm"), out)?));
|
||||
ch = out;
|
||||
}
|
||||
let fuser = (0..2)
|
||||
.map(|i| {
|
||||
let p = format!("memory_encoder.memory_fuser.layers.{i}");
|
||||
let scale = w.get(&format!("{p}.scale"), &[256])?.flatten_all()?.to_vec1::<f32>()?;
|
||||
Ok(GFuser {
|
||||
dw: conv(&gpu, w, &format!("{p}.depthwise_conv"), 256, 256, 7, 256)?,
|
||||
norm: GNorm::load(&gpu, w, &format!("{p}.layer_norm"), 256)?,
|
||||
pw1: GLinear::load(&gpu, w, &format!("{p}.pointwise_conv1"), 256, 1024)?,
|
||||
pw2: GLinear::load_scaled(&gpu, w, &format!("{p}.pointwise_conv2"), 1024, 256, &scale)?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(GpuMemoryEncoder {
|
||||
final_conv: GLinear::load_conv1x1(&gpu, w, "memory_encoder.mask_downsampler.final_conv", 256, 256)?,
|
||||
feature_projection: GLinear::load_conv1x1(&gpu, w, "memory_encoder.feature_projection", 256, 256)?,
|
||||
projection: GLinear::load_conv1x1(&gpu, w, "memory_encoder.projection", 256, 64)?,
|
||||
down,
|
||||
fuser,
|
||||
gpu,
|
||||
})
|
||||
}
|
||||
|
||||
/// `features`: `[1, 256, 64, 64]`; `mask`: `[1, 1, 1024, 1024]` already scaled (`20 m - 10`). Returns the raw
|
||||
/// memory tokens `[4096, 64]` (the occlusion embedding and bfloat16 rounding are the caller's).
|
||||
pub fn encode(&self, features: &Tensor, mask: &Tensor) -> Result<Tensor> {
|
||||
let g = &self.gpu;
|
||||
let _turn = g.exclusive();
|
||||
let flat = |t: &Tensor| -> Result<Vec<f32>> { Ok(t.contiguous()?.flatten_all()?.to_vec1::<f32>()?) };
|
||||
if features.dims() != [1, 256, 64, 64] || mask.dims() != [1, 1, 1024, 1024] {
|
||||
return Err(Error::Input(format!("memory encoder input shapes {:?} / {:?}", features.dims(), mask.dims())));
|
||||
}
|
||||
let tokens = features.permute((0, 2, 3, 1))?.contiguous()?;
|
||||
let ftok = g.upload(&flat(&tokens)?);
|
||||
let mut cur = g.upload(&flat(mask)?);
|
||||
let (mut h, mut w) = (1024usize, 1024usize);
|
||||
for (c, ln) in &self.down {
|
||||
let (ho, wo) = (h / 2, w / 2);
|
||||
let raw = g.zeros(ho * wo * c.co)?;
|
||||
g.conv2d(&cur, &c.w, &c.b, &raw, (h, w, c.ci, c.co), c.k, 2, 1, 1)?;
|
||||
let normed = g.zeros(ho * wo * c.co)?;
|
||||
g.layernorm(&raw, &ln.w, &ln.b, &normed, ho * wo, c.co, 1e-6, true)?;
|
||||
(cur, h, w) = (normed, ho, wo);
|
||||
}
|
||||
let n = h * w;
|
||||
if n != 4096 {
|
||||
return Err(Error::Compute(format!("the mask down-sampler ended at {w}x{h}, not 64x64")));
|
||||
}
|
||||
let m = g.zeros(n * 256)?;
|
||||
g.linear(&cur, n, &self.final_conv, &m, Act::None, None)?;
|
||||
let mut x = g.zeros(n * 256)?;
|
||||
g.linear(&ftok, n, &self.feature_projection, &x, Act::None, Some(&m))?;
|
||||
for b in &self.fuser {
|
||||
let d = g.zeros(n * 256)?;
|
||||
g.conv2d(&x, &b.dw.w, &b.dw.b, &d, (h, w, 256, 256), 7, 1, 3, 256)?;
|
||||
let normed = g.zeros(n * 256)?;
|
||||
g.layernorm(&d, &b.norm.w, &b.norm.b, &normed, n, 256, 1e-6, false)?;
|
||||
let hidden = g.zeros(n * 1024)?;
|
||||
g.linear(&normed, n, &b.pw1, &hidden, Act::Gelu, None)?;
|
||||
let next = g.zeros(n * 256)?;
|
||||
g.linear(&hidden, n, &b.pw2, &next, Act::None, Some(&x))?;
|
||||
x = next;
|
||||
}
|
||||
let out = g.zeros(n * 64)?;
|
||||
g.linear(&x, n, &self.projection, &out, Act::None, None)?;
|
||||
g.flush()?;
|
||||
Ok(Tensor::from_vec(g.download(&out)?, (n, 64), &Device::Cpu)?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//! The non-matmul kernels, as methods of [`Gpu`] (each queues one dispatch).
|
||||
|
||||
use super::{Buf, Gpu};
|
||||
use crate::{Error, Result};
|
||||
|
||||
fn n32(v: usize, what: &str) -> Result<u32> {
|
||||
u32::try_from(v).map_err(|_| Error::Compute(format!("{what} {v} does not fit a GPU index")))
|
||||
}
|
||||
|
||||
/// A 2-D grid of groups covering `groups` (a dimension holds 65535).
|
||||
fn grid(groups: usize) -> Result<(u32, u32, u32)> {
|
||||
let gx = groups.clamp(1, 65535);
|
||||
Ok((n32(gx, "groups")?, n32(groups.div_ceil(gx).max(1), "groups")?, 1))
|
||||
}
|
||||
|
||||
/// One 256-thread group per 256 items.
|
||||
fn items(n: usize) -> Result<(u32, u32, u32)> {
|
||||
grid(n.div_ceil(256))
|
||||
}
|
||||
|
||||
fn need(buf: &Buf, len: usize, what: &str) -> Result<()> {
|
||||
if buf.len < len {
|
||||
return Err(Error::Input(format!("{what}: a buffer of {} elements is smaller than the {len} needed", buf.len)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Gpu {
|
||||
/// `y = layer_norm(x) * weight + bias` over the last dimension of a `[rows, cols]` buffer.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn layernorm(&self, x: &Buf, weight: &Buf, bias: &Buf, y: &Buf, rows: usize, cols: usize, eps: f32, gelu: bool) -> Result<()> {
|
||||
need(x, rows * cols, "layernorm x")?;
|
||||
need(y, rows * cols, "layernorm y")?;
|
||||
need(weight, cols, "layernorm weight")?;
|
||||
need(bias, cols, "layernorm bias")?;
|
||||
self.run(
|
||||
"layernorm",
|
||||
&[(0, x), (1, weight), (2, bias), (3, y)],
|
||||
&[n32(rows, "rows")?, n32(cols, "cols")?, eps.to_bits(), u32::from(gelu)],
|
||||
grid(rows)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Softmax over each of `rows` rows of `cols`, in place.
|
||||
pub fn softmax(&self, x: &Buf, rows: usize, cols: usize) -> Result<()> {
|
||||
need(x, rows * cols, "softmax")?;
|
||||
self.run("softmax", &[(3, x)], &[n32(rows, "rows")?, n32(cols, "cols")?], grid(rows)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A convolution over channels-last `[h, w, ci]` with weight `[co, ci / groups, k, k]` and bias `[co]`,
|
||||
/// output `[ho, wo, co]` (`groups == ci == co` is depthwise).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn conv2d(
|
||||
&self,
|
||||
x: &Buf,
|
||||
weight: &Buf,
|
||||
bias: &Buf,
|
||||
out: &Buf,
|
||||
dims: (usize, usize, usize, usize),
|
||||
k: usize,
|
||||
stride: usize,
|
||||
pad: usize,
|
||||
groups: usize,
|
||||
) -> Result<()> {
|
||||
let (h, w, ci, co) = dims;
|
||||
if k == 0 || stride == 0 || groups == 0 || ci % groups != 0 || co % groups != 0 || h + 2 * pad < k || w + 2 * pad < k {
|
||||
return Err(Error::Input(format!("a {k}x{k} convolution (stride {stride}, groups {groups}) does not fit {w}x{h}x{ci} -> {co}")));
|
||||
}
|
||||
let (ho, wo) = ((h + 2 * pad - k) / stride + 1, (w + 2 * pad - k) / stride + 1);
|
||||
need(x, h * w * ci, "conv2d input")?;
|
||||
need(weight, co * (ci / groups) * k * k, "conv2d weight")?;
|
||||
need(bias, co, "conv2d bias")?;
|
||||
need(out, ho * wo * co, "conv2d output")?;
|
||||
let n = ho * wo * co;
|
||||
self.run(
|
||||
"conv2d",
|
||||
&[(0, x), (1, weight), (2, bias), (3, out)],
|
||||
&[
|
||||
n32(n, "n")?,
|
||||
n32(h, "h")?,
|
||||
n32(w, "w")?,
|
||||
n32(ci, "ci")?,
|
||||
n32(co, "co")?,
|
||||
n32(ho, "ho")?,
|
||||
n32(wo, "wo")?,
|
||||
n32(k, "k")?,
|
||||
n32(stride, "stride")?,
|
||||
n32(pad, "pad")?,
|
||||
n32(groups, "groups")?,
|
||||
],
|
||||
items(n)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `out = a + b` over `n` elements.
|
||||
pub fn add(&self, a: &Buf, b: &Buf, out: &Buf, n: usize) -> Result<()> {
|
||||
need(a, n, "add a")?;
|
||||
need(b, n, "add b")?;
|
||||
need(out, n, "add out")?;
|
||||
self.run("add", &[(0, a), (1, b), (3, out)], &[n32(n, "n")?], items(n)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `[h, w, c]` to `[windows, ws, ws, c]`, zero padded at the bottom and right.
|
||||
pub fn window_partition(&self, x: &Buf, out: &Buf, h: usize, w: usize, c: usize, ws: usize) -> Result<()> {
|
||||
if ws == 0 {
|
||||
return Err(Error::Input("window size 0".into()));
|
||||
}
|
||||
let (nwx, nwy) = (w.div_ceil(ws), h.div_ceil(ws));
|
||||
let n = nwx * nwy * ws * ws * c;
|
||||
need(x, h * w * c, "window_partition input")?;
|
||||
need(out, n, "window_partition output")?;
|
||||
self.run(
|
||||
"window_partition",
|
||||
&[(0, x), (3, out)],
|
||||
&[n32(n, "n")?, n32(h, "h")?, n32(w, "w")?, n32(c, "c")?, n32(ws, "ws")?, n32(nwx, "windows")?],
|
||||
items(n)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The inverse of [`Gpu::window_partition`] (the padding is dropped).
|
||||
pub fn window_unpartition(&self, x: &Buf, out: &Buf, h: usize, w: usize, c: usize, ws: usize) -> Result<()> {
|
||||
if ws == 0 {
|
||||
return Err(Error::Input("window size 0".into()));
|
||||
}
|
||||
let (nwx, nwy) = (w.div_ceil(ws), h.div_ceil(ws));
|
||||
need(x, nwx * nwy * ws * ws * c, "window_unpartition input")?;
|
||||
need(out, h * w * c, "window_unpartition output")?;
|
||||
self.run(
|
||||
"window_unpartition",
|
||||
&[(0, x), (3, out)],
|
||||
&[n32(h * w * c, "n")?, n32(h, "h")?, n32(w, "w")?, n32(c, "c")?, n32(ws, "ws")?, n32(nwx, "windows")?],
|
||||
items(h * w * c)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 2 x 2 max pool of `nb` images of `[h, w, c]` tokens (`h` and `w` even) read with the given strides
|
||||
/// (image, row, token) so it can pool one third of a qkv buffer in place; output contiguous.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn maxpool2(&self, x: &Buf, out: &Buf, nb: usize, h: usize, w: usize, c: usize, strides: (usize, usize, usize)) -> Result<()> {
|
||||
if !h.is_multiple_of(2) || !w.is_multiple_of(2) {
|
||||
return Err(Error::Input(format!("maxpool2 needs even sizes, got {w}x{h}")));
|
||||
}
|
||||
let n = nb * (h / 2) * (w / 2) * c;
|
||||
let last = (nb.saturating_sub(1)) * strides.0 + (h - 1) * strides.1 + (w - 1) * strides.2 + c;
|
||||
need(x, last, "maxpool2 input")?;
|
||||
need(out, n, "maxpool2 output")?;
|
||||
self.run(
|
||||
"maxpool2",
|
||||
&[(0, x), (3, out)],
|
||||
&[
|
||||
n32(n, "n")?,
|
||||
n32(nb, "nb")?,
|
||||
n32(h, "h")?,
|
||||
n32(w, "w")?,
|
||||
n32(c, "c")?,
|
||||
n32(strides.0, "stride")?,
|
||||
n32(strides.1, "stride")?,
|
||||
n32(strides.2, "stride")?,
|
||||
],
|
||||
items(n)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The patch embedding: `[3, h, w]` pixels, weight `[co, 3, 7, 7]`, bias `[co]`; output `[h/4, w/4, co]`.
|
||||
pub fn patch_embed(&self, pixels: &Buf, weight: &Buf, bias: &Buf, out: &Buf, (h, w, co): (usize, usize, usize)) -> Result<()> {
|
||||
let (ho, wo) = ((h + 6 - 7) / 4 + 1, (w + 6 - 7) / 4 + 1);
|
||||
need(pixels, 3 * h * w, "patch_embed pixels")?;
|
||||
need(weight, co * 3 * 49, "patch_embed weight")?;
|
||||
need(bias, co, "patch_embed bias")?;
|
||||
need(out, ho * wo * co, "patch_embed output")?;
|
||||
let n = ho * wo * co;
|
||||
self.run(
|
||||
"patch_embed",
|
||||
&[(0, pixels), (1, weight), (2, bias), (3, out)],
|
||||
&[n32(n, "n")?, n32(h, "h")?, n32(w, "w")?, n32(co, "co")?, n32(ho, "ho")?, n32(wo, "wo")?],
|
||||
items(n)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rotary encoding of `[rows, cols]`: rows below `rotated` are rotated with the `[table_rows, cols]`
|
||||
/// tables (row `r` uses table row `r % table_rows`), the rest copied.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn rope(&self, x: &Buf, cos: &Buf, sin: &Buf, out: &Buf, rows: usize, cols: usize, table_rows: usize, rotated: usize) -> Result<()> {
|
||||
if !cols.is_multiple_of(2) || table_rows == 0 {
|
||||
return Err(Error::Input("rope needs an even width and a table".into()));
|
||||
}
|
||||
need(x, rows * cols, "rope input")?;
|
||||
need(out, rows * cols, "rope output")?;
|
||||
need(cos, table_rows * cols, "rope cos")?;
|
||||
need(sin, table_rows * cols, "rope sin")?;
|
||||
self.run(
|
||||
"rope",
|
||||
&[(0, x), (1, cos), (2, sin), (3, out)],
|
||||
&[n32(rows * cols, "n")?, n32(cols, "cols")?, n32(table_rows, "table")?, n32(rotated.min(rows), "rotated")?],
|
||||
items(rows * cols)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `out = lateral + nearest_upsample_2x(prev)` for `[h, w, c]` (`h` and `w` even).
|
||||
pub fn upsample_add(&self, lateral: &Buf, prev: &Buf, out: &Buf, h: usize, w: usize, c: usize) -> Result<()> {
|
||||
if !h.is_multiple_of(2) || !w.is_multiple_of(2) {
|
||||
return Err(Error::Input(format!("upsample_add needs even sizes, got {w}x{h}")));
|
||||
}
|
||||
need(lateral, h * w * c, "upsample_add lateral")?;
|
||||
need(prev, h / 2 * (w / 2) * c, "upsample_add prev")?;
|
||||
need(out, h * w * c, "upsample_add out")?;
|
||||
self.run("upsample_add", &[(0, lateral), (1, prev), (3, out)], &[n32(h * w * c, "n")?, n32(h, "h")?, n32(w, "w")?, n32(c, "c")?], items(h * w * c)?);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
//! Each kernel against a plain CPU computation. They skip when there is no GPU.
|
||||
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The one device the tests share (several devices at once upset the Vulkan loader), held for the whole
|
||||
/// test so the tests take turns.
|
||||
pub(crate) struct Shared(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>, Arc<Gpu>);
|
||||
|
||||
impl std::ops::Deref for Shared {
|
||||
type Target = Gpu;
|
||||
fn deref(&self) -> &Gpu {
|
||||
&self.1
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn gpu() -> Option<Shared> {
|
||||
static TURN: Mutex<()> = Mutex::new(());
|
||||
let guard = TURN.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
match Gpu::shared() {
|
||||
Ok(g) => Some(Shared(guard, g)),
|
||||
Err(e) => {
|
||||
eprintln!("skipped: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random values in -1..1.
|
||||
pub(crate) fn noise(n: usize, seed: u32) -> Vec<f32> {
|
||||
let mut s = seed.wrapping_mul(2654435761).wrapping_add(1);
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
(s % 20001) as f32 / 10000.0 - 1.0
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn assert_close(got: &[f32], want: &[f32], tol: f32, what: &str) {
|
||||
assert_eq!(got.len(), want.len(), "{what}: length");
|
||||
let worst = got.iter().zip(want).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max);
|
||||
assert!(worst <= tol, "{what}: max abs error {worst} (tolerance {tol})");
|
||||
}
|
||||
|
||||
fn gelu(x: f32) -> f32 {
|
||||
use candle_core::{Device, Tensor};
|
||||
let t = Tensor::new(&[x], &Device::Cpu).and_then(|t| t.gelu_erf()).and_then(|t| t.to_vec1::<f32>());
|
||||
t.map(|v| v[0]).unwrap_or(f32::NAN)
|
||||
}
|
||||
|
||||
/// `C[b][i][j] = sum_k A[b][i][k] * B[b][k][j]` by the definition, with the strides given.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn naive(a: &[f32], sa: Strides, b: &[f32], sb: Strides, m: usize, n: usize, k: usize, batch: usize) -> Vec<f32> {
|
||||
let mut c = vec![0f32; batch * m * n];
|
||||
for z in 0..batch {
|
||||
for i in 0..m {
|
||||
for j in 0..n {
|
||||
let mut acc = 0f32;
|
||||
for l in 0..k {
|
||||
acc += a[sa.offset + z * sa.batch + i * sa.row + l * sa.col] * b[sb.offset + z * sb.batch + l * sb.row + j * sb.col];
|
||||
}
|
||||
c[z * m * n + i * n + j] = acc;
|
||||
}
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matmul_matches_the_definition_for_awkward_sizes_and_strides() {
|
||||
let Some(g) = gpu() else { return };
|
||||
// (m, n, k, batch): not multiples of the 64 x 64 x 16 tile, and degenerate ones
|
||||
for (m, n, k, batch) in [(100, 50, 37, 1), (1, 1, 1, 1), (65, 129, 17, 2), (64, 64, 16, 1), (3, 200, 96, 3), (130, 7, 300, 1)] {
|
||||
let (av, bv) = (noise(batch * m * k, 1), noise(batch * k * n, 2));
|
||||
let (a, b) = (g.upload(&av), g.upload(&bv));
|
||||
let c = g.zeros(batch * m * n).unwrap();
|
||||
let (sa, sb) = (Strides { offset: 0, batch: m * k, row: k, col: 1 }, Strides { offset: 0, batch: k * n, row: n, col: 1 });
|
||||
let sc = Strides { offset: 0, batch: m * n, row: n, col: 1 };
|
||||
g.matmul(&Matmul { m, n, k, batch, a: (&a, sa), b: (&b, sb), bias: None, c: (&c, sc), residual: None, alpha: 1.0, act: Act::None }).unwrap();
|
||||
g.flush().unwrap();
|
||||
assert_close(&g.download(&c).unwrap(), &naive(&av, sa, &bv, sb, m, n, k, batch), 2e-4 * (k as f32).sqrt(), &format!("{m}x{n}x{k} x{batch}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matmul_rejects_nonunit_output_columns() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let value = g.upload(&[1.0]);
|
||||
let c = g.zeros(8).unwrap();
|
||||
for col in [0, 2] {
|
||||
let broadcast = Strides { offset: 0, batch: 0, row: 0, col: 0 };
|
||||
let out = Strides { offset: 0, batch: 0, row: 4, col };
|
||||
let op = Matmul {
|
||||
m: 2,
|
||||
n: 2,
|
||||
k: 1,
|
||||
batch: 1,
|
||||
a: (&value, broadcast),
|
||||
b: (&value, broadcast),
|
||||
c: (&c, out),
|
||||
bias: None,
|
||||
residual: None,
|
||||
alpha: 1.0,
|
||||
act: Act::None,
|
||||
};
|
||||
assert!(matches!(g.matmul(&op), Err(Error::Input(_))), "output column stride {col}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matmul_zero_inner_dimension_checks_output_and_writes_bias() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let empty = g.upload(&[]);
|
||||
let tiny = g.zeros(1).unwrap();
|
||||
let bias = g.upload(&[3.0, 5.0]);
|
||||
let mut op = Matmul {
|
||||
m: 2,
|
||||
n: 2,
|
||||
k: 0,
|
||||
batch: 1,
|
||||
a: (&empty, Strides::row_major(0)),
|
||||
b: (&empty, Strides::row_major(2)),
|
||||
c: (&tiny, Strides::row_major(2)),
|
||||
bias: Some(&bias),
|
||||
residual: None,
|
||||
alpha: 1.0,
|
||||
act: Act::None,
|
||||
};
|
||||
assert!(matches!(g.matmul(&op), Err(Error::Input(_))), "C is written even when K is zero");
|
||||
let c = g.zeros(4).unwrap();
|
||||
op.c = (&c, Strides::row_major(2));
|
||||
g.matmul(&op).unwrap();
|
||||
g.flush().unwrap();
|
||||
assert_eq!(g.download(&c).unwrap(), [3.0, 5.0, 3.0, 5.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matmul_rejects_overflowing_operand_offsets() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let value = g.upload(&[1.0]);
|
||||
let c = g.zeros(4).unwrap();
|
||||
let broadcast = Strides { offset: 0, batch: 0, row: 0, col: 0 };
|
||||
let out = Strides { offset: usize::MAX, batch: 0, row: 1, col: 1 };
|
||||
let op = Matmul {
|
||||
m: 2,
|
||||
n: 1,
|
||||
k: 1,
|
||||
batch: 1,
|
||||
a: (&value, broadcast),
|
||||
b: (&value, broadcast),
|
||||
c: (&c, out),
|
||||
bias: None,
|
||||
residual: None,
|
||||
alpha: 1.0,
|
||||
act: Act::None,
|
||||
};
|
||||
assert!(matches!(g.matmul(&op), Err(Error::Input(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matmul_with_transposed_operands_bias_activation_and_residual() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (m, n, k) = (70, 90, 41);
|
||||
let (av, wv, bias, res) = (noise(m * k, 3), noise(n * k, 4), noise(n, 5), noise(m * n, 6));
|
||||
let (a, w, bi, r) = (g.upload(&av), g.upload(&wv), g.upload(&bias), g.upload(&res));
|
||||
let c = g.zeros(m * n).unwrap();
|
||||
// B is a weight stored [n, k] (a linear layer): B[k][j] = w[j * k + l]
|
||||
let sb = Strides { offset: 0, batch: 0, row: 1, col: k };
|
||||
let sa = Strides::row_major(k);
|
||||
g.matmul(&Matmul {
|
||||
m,
|
||||
n,
|
||||
k,
|
||||
batch: 1,
|
||||
a: (&a, sa),
|
||||
b: (&w, sb),
|
||||
bias: Some(&bi),
|
||||
c: (&c, Strides::row_major(n)),
|
||||
residual: Some(&r),
|
||||
alpha: 0.5,
|
||||
act: Act::Gelu,
|
||||
})
|
||||
.unwrap();
|
||||
g.flush().unwrap();
|
||||
let base = naive(&av, sa, &wv, sb, m, n, k, 1);
|
||||
let want: Vec<f32> = base.iter().enumerate().map(|(i, v)| gelu(v * 0.5 + bias[i % n]) + res[i]).collect();
|
||||
assert_close(&g.download(&c).unwrap(), &want, 1e-3, "linear + gelu + residual");
|
||||
// relu, and a transposed A (m contiguous)
|
||||
let at: Vec<f32> = (0..k * m).map(|i| av[(i % m) * k + i / m]).collect();
|
||||
let (a2, c2) = (g.upload(&at), g.zeros(m * n).unwrap());
|
||||
g.matmul(&Matmul {
|
||||
m,
|
||||
n,
|
||||
k,
|
||||
batch: 1,
|
||||
a: (&a2, Strides { offset: 0, batch: 0, row: 1, col: m }),
|
||||
b: (&w, sb),
|
||||
bias: None,
|
||||
c: (&c2, Strides::row_major(n)),
|
||||
residual: None,
|
||||
alpha: 1.0,
|
||||
act: Act::Relu,
|
||||
})
|
||||
.unwrap();
|
||||
g.flush().unwrap();
|
||||
let want: Vec<f32> = base.iter().map(|v| v.max(0.0)).collect();
|
||||
assert_close(&g.download(&c2).unwrap(), &want, 1e-3, "transposed A, relu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matmul_refuses_operands_that_overrun_their_buffers() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (a, b, c) = (g.upload(&[1.0; 6]), g.upload(&[1.0; 6]), g.zeros(4).unwrap());
|
||||
let ok = Matmul {
|
||||
m: 2,
|
||||
n: 2,
|
||||
k: 3,
|
||||
batch: 1,
|
||||
a: (&a, Strides::row_major(3)),
|
||||
b: (&b, Strides::row_major(2)),
|
||||
bias: None,
|
||||
c: (&c, Strides::row_major(2)),
|
||||
residual: None,
|
||||
alpha: 1.0,
|
||||
act: Act::None,
|
||||
};
|
||||
assert!(g.matmul(&ok).is_ok());
|
||||
let too_big = Matmul { m: 2, n: 2, k: 4, ..ok };
|
||||
assert!(matches!(g.matmul(&too_big), Err(Error::Input(_))));
|
||||
assert!(g.zeros(usize::MAX / 2).is_err());
|
||||
g.flush().unwrap();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the other kernels
|
||||
|
||||
fn run_and_read(g: &Gpu, out: &Buf) -> Vec<f32> {
|
||||
g.flush().unwrap();
|
||||
g.download(out).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layernorm_and_softmax_match_the_definition() {
|
||||
let Some(g) = gpu() else { return };
|
||||
for (rows, cols) in [(7, 96), (1, 1), (300, 768), (70_000, 8)] {
|
||||
let x = noise(rows * cols, 7);
|
||||
let (w, b) = (noise(cols, 8), noise(cols, 9));
|
||||
let (gx, gw, gb, gy) = (g.upload(&x), g.upload(&w), g.upload(&b), g.zeros(rows * cols).unwrap());
|
||||
g.layernorm(&gx, &gw, &gb, &gy, rows, cols, 1e-6, false).unwrap();
|
||||
let want: Vec<f32> = x
|
||||
.chunks(cols)
|
||||
.flat_map(|r| {
|
||||
let mean = r.iter().sum::<f32>() / cols as f32;
|
||||
let var = r.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / cols as f32;
|
||||
r.iter().enumerate().map(|(i, v)| (v - mean) / (var + 1e-6).sqrt() * w[i] + b[i]).collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
assert_close(&run_and_read(&g, &gy), &want, 2e-4, &format!("layernorm {rows}x{cols}"));
|
||||
}
|
||||
for (rows, cols) in [(5, 64), (3, 1), (100, 4096), (2, 28736), (70_000, 64)] {
|
||||
let x = noise(rows * cols, 10).iter().map(|v| v * 6.0).collect::<Vec<_>>();
|
||||
let gx = g.upload(&x);
|
||||
g.softmax(&gx, rows, cols).unwrap();
|
||||
let want: Vec<f32> = x
|
||||
.chunks(cols)
|
||||
.flat_map(|r| {
|
||||
let m = r.iter().cloned().fold(f32::MIN, f32::max);
|
||||
let e: Vec<f32> = r.iter().map(|v| (v - m).exp()).collect();
|
||||
let s: f32 = e.iter().sum();
|
||||
e.into_iter().map(|v| v / s).collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
assert_close(&run_and_read(&g, &gx), &want, 1e-5, &format!("softmax {rows}x{cols}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_pooling_and_add_match_naive_loops() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (h, w, c, ws) = (13, 22, 5, 8);
|
||||
let x = noise(h * w * c, 11);
|
||||
let (nwx, nwy) = (w.div_ceil(ws), h.div_ceil(ws));
|
||||
let (gx, parts, back) = (g.upload(&x), g.zeros(nwx * nwy * ws * ws * c).unwrap(), g.zeros(h * w * c).unwrap());
|
||||
g.window_partition(&gx, &parts, h, w, c, ws).unwrap();
|
||||
g.window_unpartition(&parts, &back, h, w, c, ws).unwrap();
|
||||
let mut want = vec![0f32; nwx * nwy * ws * ws * c];
|
||||
for win in 0..nwx * nwy {
|
||||
for y in 0..ws {
|
||||
for xx in 0..ws {
|
||||
let (gy, gxx) = ((win / nwx) * ws + y, (win % nwx) * ws + xx);
|
||||
for ch in 0..c {
|
||||
if gy < h && gxx < w {
|
||||
want[((win * ws + y) * ws + xx) * c + ch] = x[(gy * w + gxx) * c + ch];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
g.flush().unwrap();
|
||||
assert_close(&g.download(&parts).unwrap(), &want, 0.0, "window_partition");
|
||||
assert_close(&g.download(&back).unwrap(), &x, 0.0, "window round trip");
|
||||
assert!(g.window_partition(&gx, &parts, h, w, c, 0).is_err());
|
||||
|
||||
// pooling a q third of a [nb, 8, 6, 3 * c] buffer in place
|
||||
let (nb, ph, pw, pc) = (3, 8, 6, 4);
|
||||
let qkv = noise(nb * ph * pw * 3 * pc, 12);
|
||||
let (gq, out) = (g.upload(&qkv), g.zeros(nb * (ph / 2) * (pw / 2) * pc).unwrap());
|
||||
g.maxpool2(&gq, &out, nb, ph, pw, pc, (ph * pw * 3 * pc, pw * 3 * pc, 3 * pc)).unwrap();
|
||||
let mut want = vec![];
|
||||
for img in 0..nb {
|
||||
for oy in 0..ph / 2 {
|
||||
for ox in 0..pw / 2 {
|
||||
for ch in 0..pc {
|
||||
let at = |y: usize, xx: usize| qkv[((img * ph + y) * pw + xx) * 3 * pc + ch];
|
||||
want.push(at(2 * oy, 2 * ox).max(at(2 * oy, 2 * ox + 1)).max(at(2 * oy + 1, 2 * ox)).max(at(2 * oy + 1, 2 * ox + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_close(&run_and_read(&g, &out), &want, 0.0, "maxpool2");
|
||||
assert!(g.maxpool2(&gq, &out, nb, 7, pw, pc, (1, 1, 1)).is_err());
|
||||
|
||||
let (a, b) = (noise(100_001, 13), noise(100_001, 14));
|
||||
let (ga, gb, go) = (g.upload(&a), g.upload(&b), g.zeros(a.len()).unwrap());
|
||||
g.add(&ga, &gb, &go, a.len()).unwrap();
|
||||
let want: Vec<f32> = a.iter().zip(&b).map(|(x, y)| x + y).collect();
|
||||
assert_close(&run_and_read(&g, &go), &want, 0.0, "add");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_embed_rope_and_upsample_match_the_cpu_definitions() {
|
||||
use candle_core::{Device, Tensor};
|
||||
let Some(g) = gpu() else { return };
|
||||
// the 7 x 7 stride 4 convolution against candle's conv2d
|
||||
let (h, w, co) = (64usize, 96usize, 8usize);
|
||||
let (px, wt, bias) = (noise(3 * h * w, 15), noise(co * 147, 16), noise(co, 17));
|
||||
let (gp, gw, gb) = (g.upload(&px), g.upload(&wt), g.upload(&bias));
|
||||
let (ho, wo) = (h / 4, w / 4);
|
||||
let out = g.zeros(ho * wo * co).unwrap();
|
||||
g.patch_embed(&gp, &gw, &gb, &out, (h, w, co)).unwrap();
|
||||
let t = Tensor::from_vec(px, (1, 3, h, w), &Device::Cpu).unwrap();
|
||||
let k = Tensor::from_vec(wt, (co, 3, 7, 7), &Device::Cpu).unwrap();
|
||||
let want =
|
||||
t.conv2d(&k, 3, 4, 1, 1).unwrap().broadcast_add(&Tensor::from_vec(bias, (1, co, 1, 1), &Device::Cpu).unwrap()).unwrap().permute((0, 2, 3, 1)).unwrap();
|
||||
assert_close(&run_and_read(&g, &out), &want.flatten_all().unwrap().to_vec1::<f32>().unwrap(), 2e-4, "patch_embed");
|
||||
|
||||
// rotary encoding: pairs rotated with row % table rows, later rows copied
|
||||
let (rows, cols, table, rotated) = (37usize, 16usize, 10usize, 30usize);
|
||||
let (x, cs, sn) = (noise(rows * cols, 18), noise(table * cols, 19), noise(table * cols, 20));
|
||||
let (gx, gc, gs, go) = (g.upload(&x), g.upload(&cs), g.upload(&sn), g.zeros(rows * cols).unwrap());
|
||||
g.rope(&gx, &gc, &gs, &go, rows, cols, table, rotated).unwrap();
|
||||
let mut want = x.clone();
|
||||
for r in 0..rotated {
|
||||
for j in 0..cols / 2 {
|
||||
let (x0, x1) = (x[r * cols + 2 * j], x[r * cols + 2 * j + 1]);
|
||||
let (t0, t1) = ((r % table) * cols + 2 * j, (r % table) * cols + 2 * j + 1);
|
||||
want[r * cols + 2 * j] = x0 * cs[t0] - x1 * sn[t0];
|
||||
want[r * cols + 2 * j + 1] = x1 * cs[t1] + x0 * sn[t1];
|
||||
}
|
||||
}
|
||||
assert_close(&run_and_read(&g, &go), &want, 1e-6, "rope");
|
||||
assert!(g.rope(&gx, &gc, &gs, &go, rows, 15, table, rotated).is_err());
|
||||
|
||||
// neck: lateral + nearest upsample
|
||||
let (h, w, c) = (6usize, 10usize, 7usize);
|
||||
let (lat, prev) = (noise(h * w * c, 21), noise(h / 2 * (w / 2) * c, 22));
|
||||
let (gl, gp2, go) = (g.upload(&lat), g.upload(&prev), g.zeros(h * w * c).unwrap());
|
||||
g.upsample_add(&gl, &gp2, &go, h, w, c).unwrap();
|
||||
let want: Vec<f32> = (0..h * w * c).map(|i| lat[i] + prev[((i / c / w / 2) * (w / 2) + (i / c % w) / 2) * c + i % c]).collect();
|
||||
assert_close(&run_and_read(&g, &go), &want, 0.0, "upsample_add");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convolutions_and_the_layernorm_gelu_epilogue_match_candle() {
|
||||
use candle_core::{Device, Tensor};
|
||||
let Some(g) = gpu() else { return };
|
||||
// (h, w, ci, co, k, stride, pad, groups): a strided 3 x 3, a depthwise 7 x 7, and a 1-channel input
|
||||
for (h, w, ci, co, k, stride, pad, groups) in
|
||||
[(9usize, 13usize, 3usize, 5usize, 3usize, 2usize, 1usize, 1usize), (10, 10, 4, 4, 7, 1, 3, 4), (16, 16, 1, 4, 3, 2, 1, 1)]
|
||||
{
|
||||
let (x, wt, b) = (noise(h * w * ci, 30), noise(co * (ci / groups) * k * k, 31), noise(co, 32));
|
||||
let (gx, gw, gb) = (g.upload(&x), g.upload(&wt), g.upload(&b));
|
||||
let (ho, wo) = ((h + 2 * pad - k) / stride + 1, (w + 2 * pad - k) / stride + 1);
|
||||
let out = g.zeros(ho * wo * co).unwrap();
|
||||
g.conv2d(&gx, &gw, &gb, &out, (h, w, ci, co), k, stride, pad, groups).unwrap();
|
||||
// candle works on NCHW: bring the channels-last input over, convolve, bring the result back
|
||||
let t = Tensor::from_vec(x, (1, h, w, ci), &Device::Cpu).unwrap().permute((0, 3, 1, 2)).unwrap().contiguous().unwrap();
|
||||
let kern = Tensor::from_vec(wt, (co, ci / groups, k, k), &Device::Cpu).unwrap();
|
||||
let want = t
|
||||
.conv2d(&kern, pad, stride, 1, groups)
|
||||
.unwrap()
|
||||
.broadcast_add(&Tensor::from_vec(b, (1, co, 1, 1), &Device::Cpu).unwrap())
|
||||
.unwrap()
|
||||
.permute((0, 2, 3, 1))
|
||||
.unwrap();
|
||||
assert_close(
|
||||
&run_and_read(&g, &out),
|
||||
&want.flatten_all().unwrap().to_vec1::<f32>().unwrap(),
|
||||
2e-4,
|
||||
&format!("conv {k}x{k} stride {stride} groups {groups}"),
|
||||
);
|
||||
}
|
||||
assert!(g.conv2d(&g.upload(&[0.0; 4]), &g.upload(&[0.0; 4]), &g.upload(&[0.0; 1]), &g.zeros(4).unwrap(), (2, 2, 1, 1), 5, 1, 0, 1).is_err());
|
||||
// layer norm followed by GELU in the same pass
|
||||
let (rows, cols) = (50usize, 96usize);
|
||||
let (x, wt, b) = (noise(rows * cols, 33), noise(cols, 34), noise(cols, 35));
|
||||
let (gx, gw, gb, gy) = (g.upload(&x), g.upload(&wt), g.upload(&b), g.zeros(rows * cols).unwrap());
|
||||
g.layernorm(&gx, &gw, &gb, &gy, rows, cols, 1e-6, true).unwrap();
|
||||
let want: Vec<f32> = x
|
||||
.chunks(cols)
|
||||
.flat_map(|r| {
|
||||
let mean = r.iter().sum::<f32>() / cols as f32;
|
||||
let var = r.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / cols as f32;
|
||||
r.iter().enumerate().map(|(i, v)| gelu((v - mean) / (var + 1e-6).sqrt() * wt[i] + b[i])).collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
assert_close(&run_and_read(&g, &gy), &want, 1e-3, "layernorm + gelu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_precision_weights_give_the_same_product_to_half_precision() {
|
||||
let Some(g) = gpu() else { return };
|
||||
// odd sizes: the last word holds one value; the weight is a linear layer's [n, k]
|
||||
let (m, n, k) = (70, 91, 41);
|
||||
let (av, wv) = (noise(m * k, 3), noise(n * k, 4));
|
||||
let (a, w16, w32) = (g.upload(&av), g.upload_f16(&wv), g.upload(&wv));
|
||||
assert!(w16.f16 && w16.len == n * k);
|
||||
let (c16, c32) = (g.zeros(m * n).unwrap(), g.zeros(m * n).unwrap());
|
||||
let sb = Strides { offset: 0, batch: 0, row: 1, col: k };
|
||||
for (w, c) in [(&w16, &c16), (&w32, &c32)] {
|
||||
g.matmul(&Matmul {
|
||||
m,
|
||||
n,
|
||||
k,
|
||||
batch: 1,
|
||||
a: (&a, Strides::row_major(k)),
|
||||
b: (w, sb),
|
||||
bias: None,
|
||||
c: (c, Strides::row_major(n)),
|
||||
residual: None,
|
||||
alpha: 1.0,
|
||||
act: Act::None,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
g.flush().unwrap();
|
||||
let (got, want) = (g.download(&c16).unwrap(), g.download(&c32).unwrap());
|
||||
// f16 keeps 11 bits: each weight is off by at most 2^-11 relative
|
||||
assert_close(&got, &want, 2e-3 * (k as f32).sqrt(), "f16 vs f32 weights");
|
||||
assert!(got != want, "the weights really were rounded to half precision");
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
// Modified work: ported from Hugging Face `transformers` `models/sam2/modeling_sam2.py`
|
||||
// (Sam2PatchEmbeddings, Sam2MultiScaleAttention, Sam2MultiScaleBlock, Sam2HieraDetModel;
|
||||
// Apache-2.0, Copyright The HuggingFace Inc. team) to Rust on candle. See NOTICE.
|
||||
//! The Hiera backbone: patch embedding, windowed positional embedding, and blocks with window or
|
||||
//! global attention and query pooling at the stage changes. Activations are NHWC like the reference.
|
||||
|
||||
use candle_core::Tensor;
|
||||
use candle_nn::{Linear, ops::softmax_last_dim};
|
||||
|
||||
use crate::nn::{LayerNorm, Mlp, apply, layer_norm, linear};
|
||||
use crate::weights::Weights;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Hiera hyper-parameters (the released SAM 2.1 sizes differ only here).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct HieraConfig {
|
||||
pub embed_dims: Vec<usize>,
|
||||
pub blocks: Vec<usize>,
|
||||
pub heads: Vec<usize>,
|
||||
pub windows: Vec<usize>,
|
||||
pub global_blocks: Vec<usize>,
|
||||
pub query_stride: usize,
|
||||
pub query_pool_stages: usize,
|
||||
pub mlp_ratio: usize,
|
||||
pub eps: f64,
|
||||
pub pos_background: usize,
|
||||
}
|
||||
|
||||
impl HieraConfig {
|
||||
/// `facebook/sam2.1-hiera-tiny`.
|
||||
pub fn tiny() -> HieraConfig {
|
||||
HieraConfig {
|
||||
embed_dims: vec![96, 192, 384, 768],
|
||||
blocks: vec![1, 2, 7, 2],
|
||||
heads: vec![1, 2, 4, 8],
|
||||
windows: vec![8, 4, 14, 7],
|
||||
global_blocks: vec![5, 7, 9],
|
||||
query_stride: 2,
|
||||
query_pool_stages: 3,
|
||||
mlp_ratio: 4,
|
||||
eps: 1e-6,
|
||||
pos_background: 7,
|
||||
}
|
||||
}
|
||||
|
||||
/// `facebook/sam2.1-hiera-small`: the same widths as Tiny with deeper third and fourth stages.
|
||||
pub fn small() -> HieraConfig {
|
||||
HieraConfig { blocks: vec![1, 2, 11, 2], global_blocks: vec![7, 10, 13], ..HieraConfig::tiny() }
|
||||
}
|
||||
|
||||
/// `facebook/sam2.1-hiera-base-plus`.
|
||||
pub fn base_plus() -> HieraConfig {
|
||||
HieraConfig {
|
||||
embed_dims: vec![112, 224, 448, 896],
|
||||
blocks: vec![2, 3, 16, 3],
|
||||
heads: vec![2, 4, 8, 16],
|
||||
global_blocks: vec![12, 16, 20],
|
||||
pos_background: 14,
|
||||
..HieraConfig::tiny()
|
||||
}
|
||||
}
|
||||
|
||||
/// `facebook/sam2.1-hiera-large`.
|
||||
pub fn large() -> HieraConfig {
|
||||
HieraConfig {
|
||||
embed_dims: vec![144, 288, 576, 1152],
|
||||
blocks: vec![2, 6, 36, 4],
|
||||
heads: vec![2, 4, 8, 16],
|
||||
windows: vec![8, 4, 16, 8],
|
||||
global_blocks: vec![23, 33, 43],
|
||||
..HieraConfig::tiny()
|
||||
}
|
||||
}
|
||||
|
||||
/// The configuration of a released size.
|
||||
pub fn for_size(size: crate::Size) -> HieraConfig {
|
||||
match size {
|
||||
crate::Size::Tiny => HieraConfig::tiny(),
|
||||
crate::Size::Small => HieraConfig::small(),
|
||||
crate::Size::BasePlus => HieraConfig::base_plus(),
|
||||
crate::Size::Large => HieraConfig::large(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn at<T: Copy>(v: &[T], i: usize, what: &str) -> Result<T> {
|
||||
v.get(i).copied().ok_or_else(|| Error::Weights(format!("Hiera config has no {what} for stage {i}")))
|
||||
}
|
||||
|
||||
/// Max pool a `[B, H, W, C]` tensor by `stride` (kernel = stride, no padding, floor), without the
|
||||
/// permutes a pooling layer would need: it groups the cells and takes the maximum over each group.
|
||||
fn do_pool(x: &Tensor, stride: usize) -> Result<Tensor> {
|
||||
let (b, h, w, c) = x.dims4()?;
|
||||
if stride == 0 {
|
||||
return Err(Error::Input("pool stride 0".into()));
|
||||
}
|
||||
let (oh, ow) = (h / stride, w / stride);
|
||||
let x = if oh * stride != h || ow * stride != w { x.narrow(1, 0, oh * stride)?.narrow(2, 0, ow * stride)? } else { x.clone() };
|
||||
Ok(x.contiguous()?.reshape((b, oh, stride, ow, stride, c))?.max(2)?.max(3)?.contiguous()?)
|
||||
}
|
||||
|
||||
/// Split `[B, H, W, C]` into `[B * nH * nW, ws, ws, C]` windows, zero padding the bottom/right.
|
||||
fn window_partition(x: &Tensor, ws: usize) -> Result<(Tensor, (usize, usize))> {
|
||||
let (b, h, w, c) = x.dims4()?;
|
||||
if ws == 0 {
|
||||
return Err(Error::Input("window size 0".into()));
|
||||
}
|
||||
let (ph, pw) = ((ws - h % ws) % ws, (ws - w % ws) % ws);
|
||||
let x = x.pad_with_zeros(2, 0, pw)?.pad_with_zeros(1, 0, ph)?;
|
||||
let (nh, nw) = ((h + ph) / ws, (w + pw) / ws);
|
||||
let x = x.reshape((b, nh, ws, nw, ws, c))?.permute((0, 1, 3, 2, 4, 5))?.contiguous()?.reshape((b * nh * nw, ws, ws, c))?;
|
||||
Ok((x, (h + ph, w + pw)))
|
||||
}
|
||||
|
||||
fn window_unpartition(win: &Tensor, ws: usize, padded: (usize, usize), orig: (usize, usize)) -> Result<Tensor> {
|
||||
if ws == 0 {
|
||||
return Err(Error::Input("window size 0".into()));
|
||||
}
|
||||
let (nh, nw) = (padded.0 / ws, padded.1 / ws);
|
||||
let (n, _, _, c) = win.dims4()?;
|
||||
let b = n / (nh * nw).max(1);
|
||||
let x = win.reshape((b, nh, nw, ws, ws, c))?.permute((0, 1, 3, 2, 4, 5))?.contiguous()?.reshape((b, padded.0, padded.1, c))?;
|
||||
Ok(x.narrow(1, 0, orig.0)?.narrow(2, 0, orig.1)?.contiguous()?)
|
||||
}
|
||||
|
||||
struct Attention {
|
||||
qkv: Linear,
|
||||
proj: Linear,
|
||||
heads: usize,
|
||||
scale: f64,
|
||||
query_stride: Option<usize>,
|
||||
}
|
||||
|
||||
impl Attention {
|
||||
fn forward(&self, x: &Tensor) -> Result<Tensor> {
|
||||
let (b, h, w, _) = x.dims4()?;
|
||||
let qkv = apply(&self.qkv, x)?.reshape((b, h * w, 3, self.heads, ()))?;
|
||||
let part = |i: usize| -> Result<Tensor> { Ok(qkv.narrow(2, i, 1)?.squeeze(2)?) };
|
||||
let (mut q, k, v) = (part(0)?, part(1)?, part(2)?);
|
||||
let (mut oh, mut ow) = (h, w);
|
||||
if let Some(s) = self.query_stride {
|
||||
let pooled = do_pool(&q.reshape((b, h, w, ()))?, s)?;
|
||||
(oh, ow) = (pooled.dim(1)?, pooled.dim(2)?);
|
||||
q = pooled.reshape((b, oh * ow, self.heads, ()))?;
|
||||
}
|
||||
let q = q.transpose(1, 2)?.contiguous()?;
|
||||
let k = k.transpose(1, 2)?.contiguous()?;
|
||||
let v = v.transpose(1, 2)?.contiguous()?;
|
||||
// Scaling the (small) queries first equals scaling the scores, and the fused softmax is
|
||||
// much faster than the generic one on the 4096 x 4096 global-attention maps.
|
||||
let att = (q * self.scale)?.matmul(&k.transpose(2, 3)?.contiguous()?)?;
|
||||
let att = softmax_last_dim(&att)?;
|
||||
let out = att.matmul(&v)?.transpose(1, 2)?.contiguous()?.reshape((b, oh, ow, ()))?;
|
||||
apply(&self.proj, &out)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a block is made of: its widths, heads, window (0: global attention) and query pooling.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct BlockPlan {
|
||||
pub dim: usize,
|
||||
pub dim_out: usize,
|
||||
pub heads: usize,
|
||||
pub window: usize,
|
||||
pub query_stride: Option<usize>,
|
||||
}
|
||||
|
||||
impl BlockPlan {
|
||||
pub(crate) fn new(cfg: &HieraConfig, stage: usize, block_in_stage: usize, total: usize) -> Result<BlockPlan> {
|
||||
let first = stage > 0 && block_in_stage == 0;
|
||||
let dim = at(&cfg.embed_dims, if first { stage - 1 } else { stage }, "embed dim")?;
|
||||
let dim_out = at(&cfg.embed_dims, stage, "embed dim")?;
|
||||
let window = if first { at(&cfg.windows, stage - 1, "window")? } else { at(&cfg.windows, stage, "window")? };
|
||||
let window = if cfg.global_blocks.contains(&total) { 0 } else { window };
|
||||
let query_stride = (stage > 0 && stage <= cfg.query_pool_stages && block_in_stage == 0).then_some(cfg.query_stride);
|
||||
Ok(BlockPlan { dim, dim_out, heads: at(&cfg.heads, stage, "heads")?, window, query_stride })
|
||||
}
|
||||
|
||||
/// Every block of the backbone, in order.
|
||||
pub(crate) fn all(cfg: &HieraConfig) -> Result<Vec<BlockPlan>> {
|
||||
let mut out = vec![];
|
||||
for (stage, n) in cfg.blocks.iter().enumerate() {
|
||||
for b in 0..*n {
|
||||
out.push(BlockPlan::new(cfg, stage, b, out.len())?);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// One Hiera block: pre-norm attention (window or global, optionally pooling the queries) and MLP.
|
||||
pub struct Block {
|
||||
norm1: LayerNorm,
|
||||
attn: Attention,
|
||||
norm2: LayerNorm,
|
||||
mlp: Mlp,
|
||||
proj: Option<Linear>,
|
||||
window: usize,
|
||||
query_stride: Option<usize>,
|
||||
}
|
||||
|
||||
impl Block {
|
||||
fn load(w: &Weights, cfg: &HieraConfig, stage: usize, block_in_stage: usize, total: usize) -> Result<Block> {
|
||||
let prefix = format!("vision_encoder.backbone.blocks.{total}");
|
||||
let plan = BlockPlan::new(cfg, stage, block_in_stage, total)?;
|
||||
let (dim, dim_out, heads) = (plan.dim, plan.dim_out, plan.heads);
|
||||
Ok(Block {
|
||||
norm1: layer_norm(w, &format!("{prefix}.layer_norm1"), dim, cfg.eps)?,
|
||||
attn: Attention {
|
||||
qkv: linear(w, &format!("{prefix}.attn.qkv"), dim, dim_out * 3)?,
|
||||
proj: linear(w, &format!("{prefix}.attn.proj"), dim_out, dim_out)?,
|
||||
heads,
|
||||
scale: ((dim_out / heads.max(1)) as f64).powf(-0.5),
|
||||
query_stride: plan.query_stride,
|
||||
},
|
||||
norm2: layer_norm(w, &format!("{prefix}.layer_norm2"), dim_out, cfg.eps)?,
|
||||
mlp: Mlp::load(w, &format!("{prefix}.mlp"), dim_out, dim_out * cfg.mlp_ratio)?,
|
||||
proj: (dim != dim_out).then(|| linear(w, &format!("{prefix}.proj"), dim, dim_out)).transpose()?,
|
||||
window: plan.window,
|
||||
query_stride: plan.query_stride,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn forward(&self, x: &Tensor) -> Result<Tensor> {
|
||||
let h = self.norm1.forward(x)?;
|
||||
let residual = match (&self.proj, self.query_stride) {
|
||||
(Some(p), Some(s)) => do_pool(&apply(p, &h)?, s)?,
|
||||
(Some(p), None) => apply(p, &h)?,
|
||||
(None, _) => x.clone(),
|
||||
};
|
||||
let (hh, ww) = (h.dim(1)?, h.dim(2)?);
|
||||
let mut ws = self.window;
|
||||
let mut padded = (hh, ww);
|
||||
let h = if ws > 0 {
|
||||
let (win, pad) = window_partition(&h, ws)?;
|
||||
padded = pad;
|
||||
win
|
||||
} else {
|
||||
h
|
||||
};
|
||||
let mut h = self.attn.forward(&h)?;
|
||||
let mut orig = (hh, ww);
|
||||
if let Some(s) = self.query_stride {
|
||||
ws /= s.max(1);
|
||||
orig = (residual.dim(1)?, residual.dim(2)?);
|
||||
if ws > 0 {
|
||||
padded = (orig.0 + (ws - orig.0 % ws) % ws, orig.1 + (ws - orig.1 % ws) % ws);
|
||||
}
|
||||
}
|
||||
if ws > 0 {
|
||||
h = window_unpartition(&h, ws, padded, orig)?;
|
||||
}
|
||||
let h = (residual + h)?;
|
||||
let m = self.mlp.forward(&self.norm2.forward(&h)?)?;
|
||||
Ok((h + m)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// PyTorch `F.interpolate(mode="bicubic", align_corners=False)` of `[C, in_h, in_w]` to `[C, out_h, out_w]`.
|
||||
fn bicubic_resize(src: &[f32], c: usize, in_hw: (usize, usize), out_hw: (usize, usize)) -> Vec<f32> {
|
||||
const A: f32 = -0.75;
|
||||
let near = |x: f32| ((A + 2.0) * x - (A + 3.0)) * x * x + 1.0;
|
||||
let far = |x: f32| ((A * x - 5.0 * A) * x + 8.0 * A) * x - 4.0 * A;
|
||||
let axis = |n_in: usize, n_out: usize| -> Vec<([usize; 4], [f32; 4])> {
|
||||
let scale = n_in as f32 / n_out.max(1) as f32;
|
||||
(0..n_out)
|
||||
.map(|o| {
|
||||
let s = (o as f32 + 0.5) * scale - 0.5;
|
||||
let i0 = s.floor();
|
||||
let t = s - i0;
|
||||
let w = [far(t + 1.0), near(t), near(1.0 - t), far(2.0 - t)];
|
||||
let last = n_in as i64 - 1;
|
||||
let idx = [-1i64, 0, 1, 2].map(|d| (i0 as i64 + d).clamp(0, last.max(0)) as usize);
|
||||
(idx, w)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let (ys, xs) = (axis(in_hw.0, out_hw.0), axis(in_hw.1, out_hw.1));
|
||||
let mut out = vec![0f32; c * out_hw.0 * out_hw.1];
|
||||
for ch in 0..c {
|
||||
let plane = src.get(ch * in_hw.0 * in_hw.1..(ch + 1) * in_hw.0 * in_hw.1).unwrap_or(&[]);
|
||||
for (oy, (yi, yw)) in ys.iter().enumerate() {
|
||||
for (ox, (xi, xw)) in xs.iter().enumerate() {
|
||||
let mut acc = 0f32;
|
||||
for (a, wy) in yi.iter().zip(yw) {
|
||||
for (b, wx) in xi.iter().zip(xw) {
|
||||
acc += plane.get(a * in_hw.1 + b).copied().unwrap_or(0.0) * wy * wx;
|
||||
}
|
||||
}
|
||||
if let Some(o) = out.get_mut((ch * out_hw.0 + oy) * out_hw.1 + ox) {
|
||||
*o = acc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub struct Hiera {
|
||||
cfg: HieraConfig,
|
||||
patch_w: Tensor,
|
||||
patch_b: Tensor,
|
||||
pos_embed: Vec<f32>,
|
||||
pos_embed_window: Vec<f32>,
|
||||
window0: usize,
|
||||
hidden: usize,
|
||||
pub blocks: Vec<Block>,
|
||||
stage_ends: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Hiera {
|
||||
pub fn load(w: &Weights, cfg: &HieraConfig) -> Result<Hiera> {
|
||||
let hidden = at(&cfg.embed_dims, 0, "embed dim")?;
|
||||
let window0 = at(&cfg.windows, 0, "window")?;
|
||||
let bg = cfg.pos_background;
|
||||
let mut blocks = vec![];
|
||||
let mut stage_ends = vec![];
|
||||
for (stage, n) in cfg.blocks.iter().enumerate() {
|
||||
for b in 0..*n {
|
||||
blocks.push(Block::load(w, cfg, stage, b, blocks.len())?);
|
||||
}
|
||||
stage_ends.push(blocks.len().saturating_sub(1));
|
||||
}
|
||||
let flat = |t: Tensor| -> Result<Vec<f32>> { Ok(t.flatten_all()?.to_vec1::<f32>()?) };
|
||||
Ok(Hiera {
|
||||
cfg: cfg.clone(),
|
||||
patch_w: w.get("vision_encoder.backbone.patch_embed.projection.weight", &[hidden, 3, 7, 7])?,
|
||||
patch_b: w.get("vision_encoder.backbone.patch_embed.projection.bias", &[hidden])?,
|
||||
pos_embed: flat(w.get("vision_encoder.backbone.pos_embed", &[1, hidden, bg, bg])?)?,
|
||||
pos_embed_window: flat(w.get("vision_encoder.backbone.pos_embed_window", &[1, hidden, window0, window0])?)?,
|
||||
window0,
|
||||
hidden,
|
||||
blocks,
|
||||
stage_ends,
|
||||
})
|
||||
}
|
||||
|
||||
/// Patch embedding of `[B, 3, H, W]` pixels: `[B, H/4, W/4, C]` (before the positional embedding).
|
||||
pub(crate) fn config(&self) -> &HieraConfig {
|
||||
&self.cfg
|
||||
}
|
||||
|
||||
pub fn patch_embed(&self, pixels: &Tensor) -> Result<Tensor> {
|
||||
let x = pixels.conv2d(&self.patch_w, 3, 4, 1, 1)?;
|
||||
let x = x.broadcast_add(&self.patch_b.reshape((1, self.hidden, 1, 1))?)?;
|
||||
Ok(x.permute((0, 2, 3, 1))?.contiguous()?)
|
||||
}
|
||||
|
||||
/// Background embedding resized to the grid plus the tiled window embedding, `[1, H, W, C]`.
|
||||
pub(crate) fn pos(&self, h: usize, w: usize) -> Result<Tensor> {
|
||||
let bg = self.cfg.pos_background;
|
||||
let big = bicubic_resize(&self.pos_embed, self.hidden, (bg, bg), (h, w));
|
||||
let ws = self.window0.max(1);
|
||||
let mut out = vec![0f32; h * w * self.hidden];
|
||||
for c in 0..self.hidden {
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let tile = self.pos_embed_window.get((c * ws + y % ws) * ws + x % ws).copied().unwrap_or(0.0);
|
||||
let base = big.get((c * h + y) * w + x).copied().unwrap_or(0.0);
|
||||
if let Some(o) = out.get_mut((y * w + x) * self.hidden + c) {
|
||||
*o = base + tile;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Tensor::from_vec(out, (1, h, w, self.hidden), &candle_core::Device::Cpu)?)
|
||||
}
|
||||
|
||||
/// Run all blocks. Returns the output of the last block of each stage (NHWC).
|
||||
pub fn forward(&self, pixels: &Tensor) -> Result<Vec<Tensor>> {
|
||||
let (_, _, ph, pw) = pixels.dims4()?;
|
||||
if ph == 0 || pw == 0 || ph % 4 != 0 || pw % 4 != 0 {
|
||||
return Err(Error::Input(format!("image size {pw}x{ph} must be a positive multiple of 4")));
|
||||
}
|
||||
let mut x = self.patch_embed(pixels)?;
|
||||
let (h, w) = (x.dim(1)?, x.dim(2)?);
|
||||
if h % self.window0 != 0 || w % self.window0 != 0 {
|
||||
return Err(Error::Input(format!("patch grid {w}x{h} must be a multiple of {}", self.window0)));
|
||||
}
|
||||
x = x.broadcast_add(&self.pos(h, w)?)?;
|
||||
let mut stages = vec![];
|
||||
for (i, b) in self.blocks.iter().enumerate() {
|
||||
x = b.forward(&x)?;
|
||||
if self.stage_ends.contains(&i) {
|
||||
stages.push(x.clone());
|
||||
}
|
||||
}
|
||||
Ok(stages)
|
||||
}
|
||||
|
||||
/// Run the blocks, returning every block's output (for the stage-by-stage reference test).
|
||||
pub fn forward_all(&self, pixels: &Tensor) -> Result<(Tensor, Vec<Tensor>)> {
|
||||
let emb = self.patch_embed(pixels)?;
|
||||
let (h, w) = (emb.dim(1)?, emb.dim(2)?);
|
||||
let mut x = emb.broadcast_add(&self.pos(h, w)?)?;
|
||||
let mut outs = vec![];
|
||||
for b in &self.blocks {
|
||||
x = b.forward(&x)?;
|
||||
outs.push(x.clone());
|
||||
}
|
||||
Ok((emb, outs))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use candle_core::Device;
|
||||
|
||||
#[test]
|
||||
fn bicubic_of_a_constant_is_that_constant() {
|
||||
let out = bicubic_resize(&[2.5; 49], 1, (7, 7), (32, 32));
|
||||
assert!(out.iter().all(|v| (v - 2.5).abs() < 1e-5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bicubic_identity_size_is_identity() {
|
||||
let src: Vec<f32> = (0..49).map(|i| (i as f32 * 0.37).sin()).collect();
|
||||
let out = bicubic_resize(&src, 1, (7, 7), (7, 7));
|
||||
assert!(src.iter().zip(&out).all(|(a, b)| (a - b).abs() < 1e-5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_round_trip_with_padding() {
|
||||
let x = Tensor::arange(0f32, 60.0, &Device::Cpu).unwrap().reshape((1, 5, 6, 2)).unwrap();
|
||||
let (win, pad) = window_partition(&x, 4).unwrap();
|
||||
assert_eq!((win.dims(), pad), (&[4, 4, 4, 2][..], (8, 8)));
|
||||
let back = window_unpartition(&win, 4, pad, (5, 6)).unwrap();
|
||||
let d = (back - x).unwrap().abs().unwrap().sum_all().unwrap().to_scalar::<f32>().unwrap();
|
||||
assert_eq!(d, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pooling_equals_a_max_pool_layer_including_odd_sizes() {
|
||||
let x = Tensor::randn(0f32, 1.0, (2, 7, 9, 5), &Device::Cpu).unwrap();
|
||||
let want = x.permute((0, 3, 1, 2)).unwrap().contiguous().unwrap().max_pool2d_with_stride(2, 2).unwrap().permute((0, 2, 3, 1)).unwrap();
|
||||
let got = do_pool(&x, 2).unwrap();
|
||||
assert_eq!(got.dims(), want.dims());
|
||||
let d = (got - want).unwrap().abs().unwrap().sum_all().unwrap().to_scalar::<f32>().unwrap();
|
||||
assert_eq!(d, 0.0);
|
||||
assert!(do_pool(&x, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_window_is_an_error_not_a_panic() {
|
||||
let x = Tensor::zeros((1, 4, 4, 2), candle_core::DType::F32, &Device::Cpu).unwrap();
|
||||
assert!(window_partition(&x, 0).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! SAM 2.1 video object tracker (Hiera backbone) for the Roto Brush.
|
||||
//!
|
||||
//! A Rust port of the Hugging Face `transformers` SAM 2 implementation (Apache-2.0; see
|
||||
//! `NOTICE`). Native only: the crate is empty on wasm32 so L0–L4 still build for the web.
|
||||
//! Nothing here loads weights unless asked to; weights are never bundled.
|
||||
//!
|
||||
//! Modules are added stage by stage, each checked against reference tensors produced by
|
||||
//! `tools/sam2_reference.py`.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::fmt;
|
||||
|
||||
pub mod decoder;
|
||||
pub mod gpu;
|
||||
pub mod hiera;
|
||||
pub mod mask;
|
||||
pub mod memattn;
|
||||
pub mod memory;
|
||||
pub mod model;
|
||||
pub mod neck;
|
||||
pub mod nn;
|
||||
pub mod prompt;
|
||||
pub mod resize;
|
||||
pub mod session;
|
||||
pub mod track;
|
||||
pub mod weights;
|
||||
|
||||
/// Everything that can go wrong in this crate. Never a panic.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Error {
|
||||
/// The weights file is missing a tensor, has the wrong shape, or contains non-finite values.
|
||||
Weights(String),
|
||||
/// The input (frame size, prompt, frame index) is unusable.
|
||||
Input(String),
|
||||
/// The work was cancelled by the caller.
|
||||
Cancelled,
|
||||
/// A time or memory budget was exceeded.
|
||||
Budget(String),
|
||||
/// A tensor operation failed (shape mismatch, out of memory, device error).
|
||||
Compute(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::Weights(m) => write!(f, "SAM 2 weights: {m}"),
|
||||
Error::Input(m) => write!(f, "SAM 2 input: {m}"),
|
||||
Error::Cancelled => write!(f, "SAM 2: cancelled"),
|
||||
Error::Budget(m) => write!(f, "SAM 2 budget: {m}"),
|
||||
Error::Compute(m) => write!(f, "SAM 2 compute: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
impl From<candle_core::Error> for Error {
|
||||
fn from(e: candle_core::Error) -> Error {
|
||||
Error::Compute(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// The released SAM 2.1 sizes. Only Tiny is wired up first; the others are configuration.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Size {
|
||||
Tiny,
|
||||
Small,
|
||||
BasePlus,
|
||||
Large,
|
||||
}
|
||||
|
||||
impl Size {
|
||||
/// The Hugging Face repository the official weights come from.
|
||||
pub fn repo(self) -> &'static str {
|
||||
match self {
|
||||
Size::Tiny => "facebook/sam2.1-hiera-tiny",
|
||||
Size::Small => "facebook/sam2.1-hiera-small",
|
||||
Size::BasePlus => "facebook/sam2.1-hiera-base-plus",
|
||||
Size::Large => "facebook/sam2.1-hiera-large",
|
||||
}
|
||||
}
|
||||
|
||||
/// The size a checkpoint is, from its tensors: the width of the patch embedding (96 for Tiny and Small, 112 for
|
||||
/// Base+, 144 for Large) and the number of blocks (12 for Tiny, 16 for Small).
|
||||
pub fn detect(w: &weights::Weights) -> Result<Size> {
|
||||
let width = w.shape("vision_encoder.backbone.patch_embed.projection.weight").and_then(|s| s.first().copied());
|
||||
Ok(match width {
|
||||
Some(112) => Size::BasePlus,
|
||||
Some(144) => Size::Large,
|
||||
Some(96) if w.contains("vision_encoder.backbone.blocks.15.layer_norm1.weight") => Size::Small,
|
||||
Some(96) => Size::Tiny,
|
||||
_ => return Err(Error::Weights("this checkpoint is not a SAM 2.1 Hiera size (patch embedding width)".into())),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sizes_name_their_official_repositories() {
|
||||
assert_eq!(Size::Tiny.repo(), "facebook/sam2.1-hiera-tiny");
|
||||
assert!(Size::Large.repo().ends_with("large"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configs_match_the_released_sizes() {
|
||||
let blocks = |s| hiera::HieraConfig::for_size(s).blocks.iter().sum::<usize>();
|
||||
assert_eq!([Size::Tiny, Size::Small, Size::BasePlus, Size::Large].map(blocks), [12, 16, 24, 48]);
|
||||
for s in [Size::Tiny, Size::Small, Size::BasePlus, Size::Large] {
|
||||
let c = hiera::HieraConfig::for_size(s);
|
||||
assert_eq!((c.embed_dims.len(), c.heads.len(), c.windows.len()), (4, 4, 4));
|
||||
assert!(c.global_blocks.iter().all(|g| *g < blocks(s)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_comes_from_the_patch_embedding_and_depth() {
|
||||
use candle_core::{DType, Device, Tensor};
|
||||
let make = |width: usize, deep: bool| {
|
||||
let mut m = std::collections::HashMap::new();
|
||||
m.insert("vision_encoder.backbone.patch_embed.projection.weight".to_string(), Tensor::zeros((width, 3, 7, 7), DType::F32, &Device::Cpu).unwrap());
|
||||
if deep {
|
||||
m.insert("vision_encoder.backbone.blocks.15.layer_norm1.weight".to_string(), Tensor::zeros(4, DType::F32, &Device::Cpu).unwrap());
|
||||
}
|
||||
weights::Weights::from_map(m).unwrap()
|
||||
};
|
||||
assert_eq!(Size::detect(&make(96, false)).unwrap(), Size::Tiny);
|
||||
assert_eq!(Size::detect(&make(96, true)).unwrap(), Size::Small);
|
||||
assert_eq!(Size::detect(&make(112, false)).unwrap(), Size::BasePlus);
|
||||
assert_eq!(Size::detect(&make(144, false)).unwrap(), Size::Large);
|
||||
assert!(Size::detect(&make(64, false)).is_err());
|
||||
assert!(Size::detect(&weights::Weights::from_map(Default::default()).unwrap()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_say_what_failed() {
|
||||
assert!(Error::Input("empty frame".into()).to_string().contains("empty frame"));
|
||||
assert_eq!(Error::Cancelled.to_string(), "SAM 2: cancelled");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Modified work: wraps the ported SAM 2 image path behind EffectCraft's `MaskModel` interface
|
||||
// (Apache-2.0, see NOTICE).
|
||||
//! SAM 2 as a drop-in [`MaskModel`]: a frame and a prompt in, a foreground probability per pixel
|
||||
//! out. The frame's encoding is cached, so further strokes on the same frame cost one decode.
|
||||
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use candle_core::{Device, Tensor};
|
||||
use effectcraft_segment::{MaskModel, ModelInfo, SAM2_TINY};
|
||||
|
||||
use crate::model::Encoded;
|
||||
use crate::prompt::{INPUT_SIZE, Prompt};
|
||||
use crate::session::VideoModel;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Largest frame accepted (pixels per side and in total).
|
||||
const MAX_SIDE: usize = 16_384;
|
||||
const MAX_PIXELS: usize = 100_000_000;
|
||||
|
||||
/// Resize `w x h` RGB (0..1) to the model input and normalise it: `[1, 3, 1024, 1024]`.
|
||||
pub fn preprocess(rgb: &[[f32; 3]], w: usize, h: usize) -> Result<Tensor> {
|
||||
let pixels = validate_frame_size(w, h, rgb.len())?;
|
||||
const MEAN: [f32; 3] = [0.485, 0.456, 0.406];
|
||||
const STD: [f32; 3] = [0.229, 0.224, 0.225];
|
||||
let mut planar = vec![0f32; 3 * pixels];
|
||||
for (i, px) in rgb.iter().enumerate() {
|
||||
for c in 0..3 {
|
||||
let v = px.get(c).copied().unwrap_or(0.0);
|
||||
if let Some(o) = planar.get_mut(c * pixels + i) {
|
||||
*o = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
let x = Tensor::from_vec(planar, (1, 3, h, w), &Device::Cpu)?.upsample_bilinear2d(INPUT_SIZE, INPUT_SIZE, false)?;
|
||||
let mean = Tensor::from_slice(&MEAN, (1, 3, 1, 1), &Device::Cpu)?;
|
||||
let std = Tensor::from_slice(&STD, (1, 3, 1, 1), &Device::Cpu)?;
|
||||
Ok(x.broadcast_sub(&mean)?.broadcast_div(&std)?)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_frame_size(w: usize, h: usize, len: usize) -> Result<usize> {
|
||||
w.checked_mul(h)
|
||||
.filter(|n| *n > 0 && *n == len && w <= MAX_SIDE && h <= MAX_SIDE && *n <= MAX_PIXELS)
|
||||
.ok_or_else(|| Error::Input(format!("a {w} x {h} frame needs {} pixels (at most {MAX_PIXELS}), got {len}", w.saturating_mul(h))))
|
||||
}
|
||||
|
||||
fn fingerprint(rgb: &[[f32; 3]], w: usize, h: usize) -> u64 {
|
||||
let mut hash = 0xcbf2_9ce4_8422_2325u64 ^ ((w as u64) << 32 | h as u64);
|
||||
for px in rgb {
|
||||
for v in px {
|
||||
hash = (hash ^ u64::from(v.to_bits())).wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
/// SAM 2 for single frames.
|
||||
pub struct Sam2Mask {
|
||||
model: Arc<VideoModel>,
|
||||
info: &'static ModelInfo,
|
||||
last: Mutex<Option<(u64, Arc<Encoded>)>>,
|
||||
used: Mutex<std::time::Instant>,
|
||||
pub(crate) peak: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
impl Sam2Mask {
|
||||
pub fn new(model: Arc<VideoModel>) -> Sam2Mask {
|
||||
Sam2Mask::with_info(model, &SAM2_TINY)
|
||||
}
|
||||
|
||||
/// A model of a given registry entry (its size).
|
||||
pub fn with_info(model: Arc<VideoModel>, info: &'static ModelInfo) -> Sam2Mask {
|
||||
Sam2Mask { model, info, last: Mutex::new(None), used: Mutex::new(std::time::Instant::now()), peak: std::sync::atomic::AtomicU64::new(0) }
|
||||
}
|
||||
|
||||
/// Note that the model is being used (for [`MaskModel::idle_secs`]).
|
||||
pub(crate) fn touch(&self) {
|
||||
*self.used.lock().unwrap_or_else(PoisonError::into_inner) = std::time::Instant::now();
|
||||
}
|
||||
|
||||
/// The video model, for trackers built on the same weights.
|
||||
pub fn video_model(&self) -> &Arc<VideoModel> {
|
||||
&self.model
|
||||
}
|
||||
|
||||
fn encoded(&self, rgb: &[[f32; 3]], w: usize, h: usize) -> Result<Arc<Encoded>> {
|
||||
let key = fingerprint(rgb, w, h);
|
||||
if let Some((k, e)) = self.last.lock().unwrap_or_else(PoisonError::into_inner).as_ref()
|
||||
&& *k == key
|
||||
{
|
||||
return Ok(e.clone());
|
||||
}
|
||||
let enc = Arc::new(self.model.encode(&preprocess(rgb, w, h)?)?);
|
||||
*self.last.lock().unwrap_or_else(PoisonError::into_inner) = Some((key, enc.clone()));
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
fn probability(&self, rgb: &[[f32; 3]], w: usize, h: usize, prompt: &effectcraft_segment::Prompt) -> Result<Vec<f32>> {
|
||||
let enc = self.encoded(rgb, w, h)?;
|
||||
let (sx, sy) = (INPUT_SIZE as f32 / w as f32, INPUT_SIZE as f32 / h as f32);
|
||||
let p = Prompt {
|
||||
points: prompt.points.iter().map(|(c, fg)| ([c[0] * sx, c[1] * sy], *fg)).collect(),
|
||||
bbox: prompt.bbox.map(|b| [b[0] * sx, b[1] * sy, b[2] * sx, b[3] * sy]),
|
||||
};
|
||||
if p.points.is_empty() && p.bbox.is_none() {
|
||||
return Err(Error::Input("SAM 2 needs at least one point or a box".into()));
|
||||
}
|
||||
let multimask = p.points.len() + 2 * usize::from(p.bbox.is_some()) <= 1;
|
||||
let out = self.model.image.segment(&enc, &p, multimask)?;
|
||||
let ious = out.iou.flatten_all()?.to_vec1::<f32>()?;
|
||||
let best = ious.iter().enumerate().fold((0usize, f32::NEG_INFINITY), |b, (i, v)| if *v > b.1 { (i, *v) } else { b }).0;
|
||||
let logits = out.masks.narrow(1, best, 1)?.upsample_bilinear2d(h, w, false)?;
|
||||
Ok(candle_nn::ops::sigmoid(&logits)?.flatten_all()?.to_vec1::<f32>()?)
|
||||
}
|
||||
}
|
||||
|
||||
impl MaskModel for Sam2Mask {
|
||||
fn info(&self) -> &'static ModelInfo {
|
||||
self.info
|
||||
}
|
||||
|
||||
fn segment(&self, rgb: &[[f32; 3]], w: usize, h: usize, prompt: &effectcraft_segment::Prompt) -> effectcraft_segment::Result<Vec<f32>> {
|
||||
self.touch();
|
||||
self.probability(rgb, w, h, prompt).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn tracker(&self) -> Option<&dyn effectcraft_segment::ObjectTracker> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn device_name(&self) -> Option<String> {
|
||||
Some(self.model.device_name())
|
||||
}
|
||||
|
||||
fn peak_state_bytes(&self) -> Option<u64> {
|
||||
Some(self.peak.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
|
||||
fn idle_secs(&self) -> Option<f64> {
|
||||
Some(self.used.lock().unwrap_or_else(PoisonError::into_inner).elapsed().as_secs_f64())
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the installed file against the registry and build the model.
|
||||
pub fn load(bytes: &[u8]) -> std::result::Result<Arc<Sam2Mask>, String> {
|
||||
let got = effectcraft_segment::sha256::hex(bytes);
|
||||
let Some(info) = SIZES.iter().find(|m| m.sha256 == got) else {
|
||||
return Err(format!("SAM 2.1: the file does not match any published size (SHA-256 {got})"));
|
||||
};
|
||||
let w = crate::weights::Weights::from_bytes(bytes).map_err(|e| e.to_string())?;
|
||||
let model = VideoModel::load_auto(&w).map_err(|e| e.to_string())?;
|
||||
Ok(Arc::new(Sam2Mask::with_info(Arc::new(model), info)))
|
||||
}
|
||||
|
||||
/// The size to suggest: Tiny. On the benchmark clips (`bench --roto`, 24 frames, a GPU) no larger size tracked better, they
|
||||
/// cost 1.0x (Small), 1.15x (Base+) and 1.7x (Large) the time, and Large lost the object after it re-entered the frame.
|
||||
pub fn recommended() -> &'static ModelInfo {
|
||||
&SAM2_TINY
|
||||
}
|
||||
|
||||
/// A one-line note for the Settings list: what was measured for this size, and what the bigger ones cost without a GPU.
|
||||
pub fn hint(id: &str) -> Option<String> {
|
||||
hint_for(id, crate::gpu::Gpu::probe())
|
||||
}
|
||||
|
||||
/// [`hint`] given whether a GPU is known (`None`: not looked for yet). The list is built on the
|
||||
/// Settings path, so it never creates the GPU device itself: that happens when a model is loaded,
|
||||
/// and after that the notes say whether one was found.
|
||||
fn hint_for(id: &str, gpu: Option<bool>) -> Option<String> {
|
||||
Some(
|
||||
match id {
|
||||
"sam2.1-hiera-tiny" => "Recommended: the smallest download, and no larger size tracked better on the benchmark clips.",
|
||||
"sam2.1-hiera-small" => "As fast as Tiny on a GPU and no better on the benchmark clips; worth a try on difficult footage.",
|
||||
"sam2.1-hiera-base-plus" => match gpu {
|
||||
Some(false) => "Slow without a GPU (several seconds a frame); none was found.",
|
||||
Some(true) => "About 15% slower than Tiny on a GPU and no better on the benchmark clips.",
|
||||
None => "About 15% slower than Tiny on a GPU and no better on the benchmark clips; several seconds a frame without one.",
|
||||
},
|
||||
"sam2.1-hiera-large" => match gpu {
|
||||
Some(false) => "Slow without a GPU (many seconds a frame); none was found.",
|
||||
Some(true) => {
|
||||
"About 1.7 times Tiny's time on a GPU; it lost the object after it re-entered in one benchmark clip. Try it on difficult footage."
|
||||
}
|
||||
None => "About 1.7 times Tiny's time on a GPU, many seconds a frame without one; it lost the object after it re-entered in one benchmark clip.",
|
||||
},
|
||||
_ => return None,
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The registry entries this crate loads.
|
||||
pub const SIZES: [&ModelInfo; 4] = [&SAM2_TINY, &effectcraft_segment::SAM2_SMALL, &effectcraft_segment::SAM2_BASE_PLUS, &effectcraft_segment::SAM2_LARGE];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn preprocess_rejects_bad_frames_and_cleans_pixels() {
|
||||
assert!(preprocess(&[], 0, 0).is_err());
|
||||
assert!(preprocess(&[[0.0; 3]; 3], 2, 2).is_err());
|
||||
assert!(preprocess(&[[0.5; 3]; 4], usize::MAX, 2).is_err());
|
||||
let t = preprocess(&[[f32::NAN, 2.0, -1.0]; 4], 2, 2).unwrap();
|
||||
assert_eq!(t.dims(), [1, 3, INPUT_SIZE, INPUT_SIZE]);
|
||||
let v = t.flatten_all().unwrap().to_vec1::<f32>().unwrap();
|
||||
assert!(v.iter().all(|x| x.is_finite()));
|
||||
}
|
||||
|
||||
/// The Settings list asks for hints without creating the GPU device: before anything has loaded a
|
||||
/// model there is no answer yet, and the notes say so rather than guess.
|
||||
#[test]
|
||||
fn hints_do_not_need_the_gpu_to_have_been_created() {
|
||||
for id in ["sam2.1-hiera-base-plus", "sam2.1-hiera-large"] {
|
||||
let (unknown, none, found) = (hint_for(id, None), hint_for(id, Some(false)), hint_for(id, Some(true)));
|
||||
assert!(unknown.is_some() && none.is_some() && found.is_some());
|
||||
assert!(none.as_deref().is_some_and(|h| h.contains("none was found")), "{id}: {none:?}");
|
||||
assert!(unknown.as_deref().is_some_and(|h| h.contains("without one") && !h.contains("none was found")), "{id}: {unknown:?}");
|
||||
}
|
||||
assert!(hint_for("mobilesam", None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_file_is_refused_before_parsing() {
|
||||
let e = load(b"not the weights").err().unwrap_or_default();
|
||||
assert!(e.contains("does not match"), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// Modified work: ported from Hugging Face `transformers` `models/sam2_video/modeling_sam2_video.py`
|
||||
// (Sam2VideoMemoryAttention, Sam2VideoMemoryAttentionLayer, Sam2VideoRoPEAttention,
|
||||
// Sam2VideoVisionRotaryEmbedding; Apache-2.0, Copyright The HuggingFace Inc. team) to Rust on
|
||||
// candle. See NOTICE.
|
||||
//! Memory attention: the current frame's features attend to themselves and to the memory bank
|
||||
//! (memory frames plus object pointers) with axial 2-D rotary position encoding.
|
||||
|
||||
use candle_core::{D, Device, Tensor};
|
||||
use candle_nn::{Linear, ops::softmax_last_dim};
|
||||
|
||||
use crate::nn::{LayerNorm, apply, layer_norm, linear};
|
||||
use crate::weights::Weights;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const DIM: usize = 256;
|
||||
const LAYERS: usize = 4;
|
||||
/// Side of the feature grid the rotary encoding is defined on.
|
||||
pub const GRID: usize = 64;
|
||||
|
||||
/// `cos` and `sin` of the axial rotary encoding for the `GRID x GRID` grid, each `[GRID², 256]`.
|
||||
pub(crate) fn rope_tables(theta: f32) -> Result<(Tensor, Tensor)> {
|
||||
let freqs = DIM / 2 / 2; // per axis: head_dim / 4
|
||||
let inv: Vec<f32> = (0..freqs).map(|i| 1.0 / theta.powf(i as f32 / freqs as f32)).collect();
|
||||
let n = GRID * GRID;
|
||||
let (mut cos, mut sin) = (vec![0f32; n * DIM], vec![0f32; n * DIM]);
|
||||
for p in 0..n {
|
||||
let (x, y) = ((p % GRID) as f32, (p / GRID) as f32);
|
||||
for j in 0..DIM / 2 {
|
||||
let f = if j < freqs { x * inv.get(j).copied().unwrap_or(0.0) } else { y * inv.get(j - freqs).copied().unwrap_or(0.0) };
|
||||
for k in 0..2 {
|
||||
if let (Some(c), Some(s)) = (cos.get_mut(p * DIM + 2 * j + k), sin.get_mut(p * DIM + 2 * j + k)) {
|
||||
*c = f.cos();
|
||||
*s = f.sin();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((Tensor::from_vec(cos, (n, DIM), &Device::Cpu)?, Tensor::from_vec(sin, (n, DIM), &Device::Cpu)?))
|
||||
}
|
||||
|
||||
/// `(x0, x1) -> (-x1, x0)` over adjacent pairs of the last dimension.
|
||||
fn rotate_pairwise(x: &Tensor) -> Result<Tensor> {
|
||||
let (n, d) = x.dims2()?;
|
||||
let pairs = x.reshape((n, d / 2, 2))?;
|
||||
let (x1, x2) = (pairs.narrow(2, 0, 1)?.squeeze(2)?, pairs.narrow(2, 1, 1)?.squeeze(2)?);
|
||||
Ok(Tensor::stack(&[&x2.neg()?, &x1], D::Minus1)?.reshape((n, d))?)
|
||||
}
|
||||
|
||||
fn rope(x: &Tensor, cos: &Tensor, sin: &Tensor) -> Result<Tensor> {
|
||||
Ok(((x * cos)? + (rotate_pairwise(x)? * sin)?)?)
|
||||
}
|
||||
|
||||
struct RopeAttention {
|
||||
q: Linear,
|
||||
k: Linear,
|
||||
v: Linear,
|
||||
o: Linear,
|
||||
}
|
||||
|
||||
impl RopeAttention {
|
||||
fn load(w: &Weights, prefix: &str, kv_dim: usize) -> Result<RopeAttention> {
|
||||
Ok(RopeAttention {
|
||||
q: linear(w, &format!("{prefix}.q_proj"), DIM, DIM)?,
|
||||
k: linear(w, &format!("{prefix}.k_proj"), kv_dim, DIM)?,
|
||||
v: linear(w, &format!("{prefix}.v_proj"), kv_dim, DIM)?,
|
||||
o: linear(w, &format!("{prefix}.o_proj"), DIM, DIM)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// `query`: `[N, 256]`, `key`/`value`: `[M, kv]`. The first `M - exclude` keys are rotated
|
||||
/// (their table is `cos`/`sin` tiled to length); the last `exclude` (object pointers) are not.
|
||||
fn forward(&self, query: &Tensor, key: &Tensor, value: &Tensor, tables: &(Tensor, Tensor), exclude: usize) -> Result<Tensor> {
|
||||
let (cos, sin) = tables;
|
||||
let q = rope(&apply(&self.q, query)?, cos, sin)?;
|
||||
let k = apply(&self.k, key)?;
|
||||
let v = apply(&self.v, value)?;
|
||||
let m = k.dim(0)?;
|
||||
let rotated = m.checked_sub(exclude).ok_or_else(|| Error::Input("more object pointers than memory tokens".into()))?;
|
||||
let k = if rotated == 0 {
|
||||
k
|
||||
} else {
|
||||
let n = cos.dim(0)?;
|
||||
let reps = if rotated == n {
|
||||
1
|
||||
} else if rotated % n == 0 {
|
||||
rotated / n
|
||||
} else {
|
||||
return Err(Error::Input(format!("memory length {rotated} is not a multiple of {n}")));
|
||||
};
|
||||
let tile = |t: &Tensor| -> Result<Tensor> { Ok(Tensor::cat(&vec![t; reps], 0)?) };
|
||||
let k_rot = rope(&k.narrow(0, 0, rotated)?, &tile(cos)?, &tile(sin)?)?;
|
||||
if exclude == 0 { k_rot } else { Tensor::cat(&[&k_rot, &k.narrow(0, rotated, exclude)?], 0)? }
|
||||
};
|
||||
let scale = (DIM as f64).powf(-0.5);
|
||||
let att = softmax_last_dim(&((q * scale)?.matmul(&k.t()?.contiguous()?)?))?;
|
||||
apply(&self.o, &att.matmul(&v)?)
|
||||
}
|
||||
}
|
||||
|
||||
struct Layer {
|
||||
self_attn: RopeAttention,
|
||||
cross: RopeAttention,
|
||||
ln1: LayerNorm,
|
||||
ln2: LayerNorm,
|
||||
ln3: LayerNorm,
|
||||
lin1: Linear,
|
||||
lin2: Linear,
|
||||
}
|
||||
|
||||
pub struct MemoryAttention {
|
||||
layers: Vec<Layer>,
|
||||
final_ln: LayerNorm,
|
||||
tables: (Tensor, Tensor),
|
||||
}
|
||||
|
||||
impl MemoryAttention {
|
||||
pub fn load(w: &Weights) -> Result<MemoryAttention> {
|
||||
let layers = (0..LAYERS)
|
||||
.map(|i| {
|
||||
let p = format!("memory_attention.layers.{i}");
|
||||
Ok(Layer {
|
||||
self_attn: RopeAttention::load(w, &format!("{p}.self_attn"), DIM)?,
|
||||
cross: RopeAttention::load(w, &format!("{p}.cross_attn_image"), 64)?,
|
||||
ln1: layer_norm(w, &format!("{p}.layer_norm1"), DIM, 1e-5)?,
|
||||
ln2: layer_norm(w, &format!("{p}.layer_norm2"), DIM, 1e-5)?,
|
||||
ln3: layer_norm(w, &format!("{p}.layer_norm3"), DIM, 1e-5)?,
|
||||
lin1: linear(w, &format!("{p}.linear1"), DIM, 2048)?,
|
||||
lin2: linear(w, &format!("{p}.linear2"), 2048, DIM)?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(MemoryAttention { layers, final_ln: layer_norm(w, "memory_attention.layer_norm", DIM, 1e-5)?, tables: rope_tables(10000.0)? })
|
||||
}
|
||||
|
||||
/// Condition the current frame on memory.
|
||||
///
|
||||
/// `current` and `current_pos`: `[4096, 256]` (the lowest-resolution neck level and its
|
||||
/// position encoding, one row per cell); `memory` and `memory_pos`: `[M, 64]` (memory frames
|
||||
/// followed by `num_pointers` object-pointer tokens). Returns `[4096, 256]`.
|
||||
pub fn forward(&self, current: &Tensor, current_pos: &Tensor, memory: &Tensor, memory_pos: &Tensor, num_pointers: usize) -> Result<Tensor> {
|
||||
if current.dims() != [GRID * GRID, DIM] {
|
||||
return Err(Error::Input(format!("current features must be [{}, {DIM}], got {:?}", GRID * GRID, current.dims())));
|
||||
}
|
||||
let keys_pos = (memory + memory_pos)?;
|
||||
let mut out = (current + (current_pos * 0.1)?)?;
|
||||
for l in &self.layers {
|
||||
let q = l.ln1.forward(&out)?;
|
||||
out = (&out + l.self_attn.forward(&q, &q, &q, &self.tables, 0)?)?;
|
||||
let q = l.ln2.forward(&out)?;
|
||||
out = (&out + l.cross.forward(&q, &keys_pos, memory, &self.tables, num_pointers)?)?;
|
||||
let q = l.ln3.forward(&out)?;
|
||||
out = (&out + apply(&l.lin2, &apply(&l.lin1, &q)?.relu()?)?)?;
|
||||
}
|
||||
self.final_ln.forward(&out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rotation_is_pairwise_and_tables_start_at_identity() {
|
||||
let x = Tensor::new(&[[1f32, 2.0, 3.0, 4.0]], &Device::Cpu).unwrap();
|
||||
let r = rotate_pairwise(&x).unwrap().to_vec2::<f32>().unwrap();
|
||||
assert_eq!(r, vec![vec![-2.0, 1.0, -4.0, 3.0]]);
|
||||
let (cos, sin) = rope_tables(10000.0).unwrap();
|
||||
// cell (0, 0): angle 0 everywhere
|
||||
let c = cos.narrow(0, 0, 1).unwrap().to_vec2::<f32>().unwrap();
|
||||
let s = sin.narrow(0, 0, 1).unwrap().to_vec2::<f32>().unwrap();
|
||||
assert!(c[0].iter().all(|v| *v == 1.0) && s[0].iter().all(|v| *v == 0.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Modified work: ported from Hugging Face `transformers` `models/sam2_video/modeling_sam2_video.py`
|
||||
// (Sam2VideoMemoryEncoder, Sam2VideoMaskDownSampler, Sam2VideoMemoryFuser; Apache-2.0, Copyright
|
||||
// The HuggingFace Inc. team) to Rust on candle. See NOTICE.
|
||||
//! The memory encoder: a frame's mask is down-sampled and fused with the frame's image features
|
||||
//! into a compact memory (64 channels on the 64 x 64 grid) plus its position encoding.
|
||||
|
||||
use candle_core::Tensor;
|
||||
|
||||
use crate::Result;
|
||||
use crate::neck::sine_position;
|
||||
use crate::nn::{LayerNorm, apply, apply_gelu, gelu, layer_norm, linear};
|
||||
use crate::weights::Weights;
|
||||
use candle_nn::Linear;
|
||||
|
||||
struct Conv {
|
||||
w: Tensor,
|
||||
b: Tensor,
|
||||
padding: usize,
|
||||
stride: usize,
|
||||
groups: usize,
|
||||
}
|
||||
|
||||
impl Conv {
|
||||
fn load(w: &Weights, name: &str, shape: &[usize], padding: usize, stride: usize, groups: usize) -> Result<Conv> {
|
||||
let out = shape.first().copied().unwrap_or(0);
|
||||
Ok(Conv { w: w.get(&format!("{name}.weight"), shape)?, b: w.get(&format!("{name}.bias"), &[out])?, padding, stride, groups })
|
||||
}
|
||||
|
||||
fn forward(&self, x: &Tensor) -> Result<Tensor> {
|
||||
let y = x.conv2d(&self.w, self.padding, self.stride, 1, self.groups)?;
|
||||
Ok(y.broadcast_add(&self.b.reshape((1, self.b.dim(0)?, 1, 1))?)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer norm over the channel dimension of `[B, C, H, W]`.
|
||||
fn norm_channels_first(ln: &LayerNorm, x: &Tensor) -> Result<Tensor> {
|
||||
ln.forward(&x.permute((0, 2, 3, 1))?.contiguous()?)?.permute((0, 3, 1, 2))?.contiguous().map_err(Into::into)
|
||||
}
|
||||
|
||||
struct FuserBlock {
|
||||
depthwise: Conv,
|
||||
norm: LayerNorm,
|
||||
pw1: Linear,
|
||||
pw2: Linear,
|
||||
scale: Tensor,
|
||||
}
|
||||
|
||||
impl FuserBlock {
|
||||
fn forward(&self, x: &Tensor) -> Result<Tensor> {
|
||||
let h = norm_channels_first(&self.norm, &self.depthwise.forward(x)?)?;
|
||||
let h = h.permute((0, 2, 3, 1))?.contiguous()?;
|
||||
let h = apply(&self.pw2, &apply_gelu(&self.pw1, &h)?)?;
|
||||
let h = h.broadcast_mul(&self.scale)?.permute((0, 3, 1, 2))?.contiguous()?;
|
||||
Ok((x + h)?)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MemoryEncoder {
|
||||
down: Vec<(Conv, LayerNorm)>,
|
||||
final_conv: Conv,
|
||||
feature_projection: Conv,
|
||||
fuser: Vec<FuserBlock>,
|
||||
projection: Conv,
|
||||
occlusion: Tensor,
|
||||
}
|
||||
|
||||
/// A frame's memory: `features` is `[HW, 64]` rounded through bfloat16 exactly as the reference
|
||||
/// stores it, `pos` is the `[HW, 64]` sine position encoding.
|
||||
pub struct Memory {
|
||||
pub features: Tensor,
|
||||
pub pos: Tensor,
|
||||
}
|
||||
|
||||
impl MemoryEncoder {
|
||||
pub fn load(w: &Weights) -> Result<MemoryEncoder> {
|
||||
let mut down = vec![];
|
||||
let mut ch = 1;
|
||||
for i in 0..4 {
|
||||
let out = ch * 4;
|
||||
let p = format!("memory_encoder.mask_downsampler.layers.{i}");
|
||||
down.push((Conv::load(w, &format!("{p}.conv"), &[out, ch, 3, 3], 1, 2, 1)?, layer_norm(w, &format!("{p}.layer_norm"), out, 1e-6)?));
|
||||
ch = out;
|
||||
}
|
||||
let fuser = (0..2)
|
||||
.map(|i| {
|
||||
let p = format!("memory_encoder.memory_fuser.layers.{i}");
|
||||
Ok(FuserBlock {
|
||||
depthwise: Conv::load(w, &format!("{p}.depthwise_conv"), &[256, 1, 7, 7], 3, 1, 256)?,
|
||||
norm: layer_norm(w, &format!("{p}.layer_norm"), 256, 1e-6)?,
|
||||
pw1: linear(w, &format!("{p}.pointwise_conv1"), 256, 1024)?,
|
||||
pw2: linear(w, &format!("{p}.pointwise_conv2"), 1024, 256)?,
|
||||
scale: w.get(&format!("{p}.scale"), &[256])?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(MemoryEncoder {
|
||||
down,
|
||||
final_conv: Conv::load(w, "memory_encoder.mask_downsampler.final_conv", &[256, 256, 1, 1], 0, 1, 1)?,
|
||||
feature_projection: Conv::load(w, "memory_encoder.feature_projection", &[256, 256, 1, 1], 0, 1, 1)?,
|
||||
fuser,
|
||||
projection: Conv::load(w, "memory_encoder.projection", &[64, 256, 1, 1], 0, 1, 1)?,
|
||||
occlusion: w.get("occlusion_spatial_embedding_parameter", &[1, 64])?,
|
||||
})
|
||||
}
|
||||
|
||||
/// `features`: `[1, 256, g, g]` image features; `mask`: `[1, 1, 16 g, 16 g]` already scaled
|
||||
/// (`20 * m - 10`). Returns the raw `[1, 64, g, g]` features and position encoding.
|
||||
pub fn encode(&self, features: &Tensor, mask: &Tensor) -> Result<(Tensor, Tensor)> {
|
||||
let mut m = mask.clone();
|
||||
for (conv, ln) in &self.down {
|
||||
m = gelu(&norm_channels_first(ln, &conv.forward(&m)?)?)?;
|
||||
}
|
||||
let m = self.final_conv.forward(&m)?;
|
||||
let mut x = (self.feature_projection.forward(features)? + m)?;
|
||||
for b in &self.fuser {
|
||||
x = b.forward(&x)?;
|
||||
}
|
||||
let x = self.projection.forward(&x)?;
|
||||
let pos = sine_position(32, x.dim(2)?, x.dim(3)?)?;
|
||||
Ok((x, pos))
|
||||
}
|
||||
|
||||
/// The memory kept for a frame: the encoder output, plus the occlusion embedding when the
|
||||
/// object is absent, rounded through bfloat16 like the reference.
|
||||
pub fn memory(&self, features: &Tensor, mask: &Tensor, object_present: bool) -> Result<Memory> {
|
||||
let (x, _) = self.encode(features, mask)?;
|
||||
self.finish(&x.flatten_from(2)?.squeeze(0)?.t()?.contiguous()?, object_present)
|
||||
}
|
||||
|
||||
/// A memory from the encoder's raw tokens `[HW, 64]`: the occlusion embedding when the object is absent,
|
||||
/// rounded through bfloat16, with the position encoding (shared by the CPU and GPU encoders).
|
||||
pub fn finish(&self, tokens: &Tensor, object_present: bool) -> Result<Memory> {
|
||||
let tokens = if object_present { tokens.clone() } else { tokens.broadcast_add(&self.occlusion)? };
|
||||
let features = tokens.to_dtype(candle_core::DType::BF16)?.to_dtype(candle_core::DType::F32)?;
|
||||
let pos = sine_position(32, 64, 64)?;
|
||||
Ok(Memory { features, pos: pos.flatten_from(2)?.squeeze(0)?.t()?.contiguous()? })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Modified work: ported from Hugging Face `transformers` `Sam2Model` (Apache-2.0, Copyright The
|
||||
// HuggingFace Inc. team) to Rust on candle. See NOTICE.
|
||||
//! The single-image model: encode a frame once, then decode any number of prompts against it.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use candle_core::{Device, Tensor};
|
||||
|
||||
use crate::decoder::{DecoderOutput, MaskDecoder};
|
||||
use crate::neck::{ImageFeatures, VisionEncoder};
|
||||
use crate::prompt::{INPUT_SIZE, Prompt, PromptEncoder};
|
||||
use crate::weights::Weights;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// A frame after the vision encoder: reusable for every prompt on that frame.
|
||||
pub struct Encoded {
|
||||
/// The three neck levels, highest resolution first (`[1, 256, 256, 256]`, `128`, `64`).
|
||||
pub features: ImageFeatures,
|
||||
/// The decoder's skip features from the two highest-resolution levels.
|
||||
pub high: (Tensor, Tensor),
|
||||
}
|
||||
|
||||
pub struct ImageModel {
|
||||
pub vision: VisionEncoder,
|
||||
pub prompt: PromptEncoder,
|
||||
pub decoder: MaskDecoder,
|
||||
no_memory: Tensor,
|
||||
image_pos: Tensor,
|
||||
}
|
||||
|
||||
impl ImageModel {
|
||||
pub fn load(w: &Weights) -> Result<ImageModel> {
|
||||
let prompt = PromptEncoder::load(w)?;
|
||||
Ok(ImageModel {
|
||||
vision: VisionEncoder::load(w)?,
|
||||
decoder: MaskDecoder::load(w)?,
|
||||
no_memory: w.get("no_memory_embedding", &[1, 1, 256])?.reshape((1, 256, 1, 1))?,
|
||||
image_pos: prompt.image_positions()?,
|
||||
prompt,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open(path: &Path) -> Result<ImageModel> {
|
||||
ImageModel::load(&Weights::open(path)?)
|
||||
}
|
||||
|
||||
/// Encode a normalised `[1, 3, 1024, 1024]` frame.
|
||||
pub fn encode(&self, pixels: &Tensor) -> Result<Encoded> {
|
||||
if pixels.dims() != [1, 3, INPUT_SIZE, INPUT_SIZE] {
|
||||
return Err(Error::Input(format!("pixels must be [1, 3, {INPUT_SIZE}, {INPUT_SIZE}], got {:?}", pixels.dims())));
|
||||
}
|
||||
let features = self.vision.forward(pixels)?;
|
||||
let (l0, l1) = match (features.features.first(), features.features.get(1)) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
_ => return Err(Error::Compute("vision encoder returned too few levels".into())),
|
||||
};
|
||||
let high = self.decoder.high_res(l0, l1)?;
|
||||
Ok(Encoded { features, high })
|
||||
}
|
||||
|
||||
/// Prompt embeddings (sparse tokens, empty dense map) for a click/box prompt.
|
||||
pub fn embed_prompt(&self, p: &Prompt) -> Result<(Tensor, Tensor)> {
|
||||
Ok((self.prompt.sparse(p)?, self.prompt.dense_empty()?))
|
||||
}
|
||||
|
||||
/// Decode masks for a prompt on an encoded frame.
|
||||
pub fn segment(&self, enc: &Encoded, p: &Prompt, multimask: bool) -> Result<DecoderOutput> {
|
||||
let low = enc.features.features.get(2).ok_or_else(|| Error::Compute("vision encoder returned too few levels".into()))?;
|
||||
let image = low.broadcast_add(&self.no_memory)?;
|
||||
let (sparse, dense) = self.embed_prompt(p)?;
|
||||
self.decoder.forward(&image, &self.image_pos, &sparse, &dense, &enc.high, multimask)
|
||||
}
|
||||
|
||||
/// Decode against an already memory-conditioned embedding `[1, 256, g, g]` (video tracking).
|
||||
pub fn decode_embedded(&self, image: &Tensor, high: &(Tensor, Tensor), sparse: &Tensor, dense: &Tensor, multimask: bool) -> Result<DecoderOutput> {
|
||||
self.decoder.forward(image, &self.image_pos, sparse, dense, high, multimask)
|
||||
}
|
||||
|
||||
/// The learned embedding added to a frame's features when there is no memory yet.
|
||||
pub fn no_memory(&self) -> &Tensor {
|
||||
&self.no_memory
|
||||
}
|
||||
|
||||
pub fn device(&self) -> Device {
|
||||
Device::Cpu
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Modified work: ported from Hugging Face `transformers` `models/sam2/modeling_sam2.py`
|
||||
// (Sam2VisionNeck, Sam2SinePositionEmbedding, Sam2VisionModel; Apache-2.0, Copyright The
|
||||
// HuggingFace Inc. team) to Rust on candle. See NOTICE.
|
||||
//! The feature pyramid neck on top of Hiera, and the vision encoder that joins them.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
|
||||
use candle_core::{Device, Tensor};
|
||||
|
||||
use crate::hiera::{Hiera, HieraConfig};
|
||||
use crate::weights::Weights;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Sine position encoding `[1, 2 * num_pos_features, h, w]`, normalised (`Sam2SinePositionEmbedding`).
|
||||
/// Only small tables are retained globally; the neck owns its larger feature-pyramid tables.
|
||||
pub fn sine_position(num_pos_features: usize, h: usize, w: usize) -> Result<Tensor> {
|
||||
static CACHE: OnceLock<PositionCache> = OnceLock::new();
|
||||
CACHE.get_or_init(|| PositionCache::new(1024 * 1024 / size_of::<f32>())).get(num_pos_features, h, w)
|
||||
}
|
||||
|
||||
const MAX_POSITION_ELEMENTS: usize = 128 * 1024 * 1024 / size_of::<f32>();
|
||||
|
||||
struct PositionCache {
|
||||
entries: Mutex<HashMap<(usize, usize, usize), Tensor>>,
|
||||
max_elements: usize,
|
||||
}
|
||||
|
||||
impl PositionCache {
|
||||
fn new(max_elements: usize) -> Self {
|
||||
Self { entries: Mutex::new(HashMap::new()), max_elements }
|
||||
}
|
||||
|
||||
fn get(&self, features: usize, h: usize, w: usize) -> Result<Tensor> {
|
||||
let key = (features, h, w);
|
||||
if let Some(t) = self.entries.lock().unwrap_or_else(PoisonError::into_inner).get(&key) {
|
||||
return Ok(t.clone());
|
||||
}
|
||||
let t = build_sine_position(features, h, w)?;
|
||||
let count = t.elem_count();
|
||||
if count <= self.max_elements {
|
||||
let mut map = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let retained = map.values().fold(0usize, |n, t| n.saturating_add(t.elem_count()));
|
||||
if map.len() >= 16 || retained.saturating_add(count) > self.max_elements {
|
||||
map.clear();
|
||||
}
|
||||
map.insert(key, t.clone());
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_sine_position(num_pos_features: usize, h: usize, w: usize) -> Result<Tensor> {
|
||||
let count = num_pos_features.checked_mul(2).and_then(|n| n.checked_mul(h)).and_then(|n| n.checked_mul(w));
|
||||
let count =
|
||||
count.filter(|n| *n > 0 && *n <= MAX_POSITION_ELEMENTS).ok_or_else(|| Error::Input("sine position dimensions are empty or exceed 128 MiB".into()))?;
|
||||
let eps = 1e-6f32;
|
||||
let scale = 2.0 * std::f32::consts::PI;
|
||||
let dim_t: Vec<f32> = (0..num_pos_features).map(|i| 10000f32.powf(2.0 * (i / 2) as f32 / num_pos_features as f32)).collect();
|
||||
let axis = |n: usize, k: usize| -> Vec<f32> {
|
||||
// value of one coordinate at index k of an axis of length n, over the feature dimension
|
||||
let e = (k + 1) as f32 / (n as f32 + eps) * scale;
|
||||
dim_t.iter().enumerate().map(|(i, d)| if i % 2 == 0 { (e / d).sin() } else { (e / d).cos() }).collect()
|
||||
};
|
||||
let (ys, xs): (Vec<_>, Vec<_>) = ((0..h).map(|k| axis(h, k)).collect(), (0..w).map(|k| axis(w, k)).collect());
|
||||
let mut out = vec![0f32; count];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
for f in 0..num_pos_features {
|
||||
let (py, px) = (ys.get(y).and_then(|v| v.get(f)), xs.get(x).and_then(|v| v.get(f)));
|
||||
if let (Some(py), Some(px)) = (py, px) {
|
||||
if let Some(o) = out.get_mut((f * h + y) * w + x) {
|
||||
*o = *py;
|
||||
}
|
||||
if let Some(o) = out.get_mut(((num_pos_features + f) * h + y) * w + x) {
|
||||
*o = *px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Tensor::from_vec(out, (1, 2 * num_pos_features, h, w), &Device::Cpu)?)
|
||||
}
|
||||
|
||||
struct Conv1x1 {
|
||||
w: Tensor,
|
||||
b: Tensor,
|
||||
out: usize,
|
||||
}
|
||||
|
||||
impl Conv1x1 {
|
||||
fn forward(&self, x: &Tensor) -> Result<Tensor> {
|
||||
Ok(x.conv2d(&self.w, 0, 1, 1, 1)?.broadcast_add(&self.b.reshape((1, self.out, 1, 1))?)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// The FPN neck: a 1x1 conv per stage, top-down additions on the two lowest-resolution levels.
|
||||
pub struct Neck {
|
||||
convs: Vec<Conv1x1>,
|
||||
top_down: Vec<usize>,
|
||||
pos_features: usize,
|
||||
positions: PositionCache,
|
||||
}
|
||||
|
||||
/// What the neck produces, both in the neck's own order (lowest resolution first, all four
|
||||
/// levels) as `[B, C, H, W]`.
|
||||
pub struct NeckOutput {
|
||||
pub features: Vec<Tensor>,
|
||||
pub positions: Vec<Tensor>,
|
||||
}
|
||||
|
||||
impl Neck {
|
||||
/// Position tables shared by the CPU and GPU paths, released with this neck.
|
||||
pub(crate) fn position(&self, h: usize, w: usize) -> Result<Tensor> {
|
||||
self.positions.get(self.pos_features, h, w)
|
||||
}
|
||||
|
||||
pub fn load(w: &Weights, channels: &[usize], fpn: usize, top_down: &[usize]) -> Result<Neck> {
|
||||
let convs = channels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(j, c)| {
|
||||
Ok(Conv1x1 {
|
||||
w: w.get(&format!("vision_encoder.neck.convs.{j}.weight"), &[fpn, *c, 1, 1])?,
|
||||
b: w.get(&format!("vision_encoder.neck.convs.{j}.bias"), &[fpn])?,
|
||||
out: fpn,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(Neck { convs, top_down: top_down.to_vec(), pos_features: fpn / 2, positions: PositionCache::new(MAX_POSITION_ELEMENTS) })
|
||||
}
|
||||
|
||||
/// `stages`: Hiera stage outputs, NHWC, highest resolution first.
|
||||
pub fn forward(&self, stages: &[Tensor]) -> Result<NeckOutput> {
|
||||
let n = self.convs.len().checked_sub(1).ok_or_else(|| Error::Weights("neck has no convolutions".into()))?;
|
||||
if stages.len() != self.convs.len() {
|
||||
return Err(Error::Input(format!("neck expects {} stages, got {}", self.convs.len(), stages.len())));
|
||||
}
|
||||
let (mut features, mut positions) = (vec![], vec![]);
|
||||
let mut prev: Option<Tensor> = None;
|
||||
for i in (0..=n).rev() {
|
||||
let stage = stages.get(i).ok_or_else(|| Error::Input("missing stage".into()))?;
|
||||
let conv = self.convs.get(n - i).ok_or_else(|| Error::Weights("missing neck convolution".into()))?;
|
||||
let lateral = conv.forward(&stage.permute((0, 3, 1, 2))?.contiguous()?)?;
|
||||
let cur = match (&prev, self.top_down.contains(&i) && i != n) {
|
||||
(Some(p), true) => {
|
||||
let (h, w) = (p.dim(2)? * 2, p.dim(3)? * 2);
|
||||
(lateral + p.upsample_nearest2d(h, w)?)?
|
||||
}
|
||||
_ => lateral,
|
||||
};
|
||||
positions.push(self.position(cur.dim(2)?, cur.dim(3)?)?);
|
||||
features.push(cur.clone());
|
||||
prev = Some(cur);
|
||||
}
|
||||
Ok(NeckOutput { features, positions })
|
||||
}
|
||||
}
|
||||
|
||||
/// Hiera plus neck: `Sam2VisionModel`.
|
||||
pub struct VisionEncoder {
|
||||
pub hiera: std::sync::Arc<Hiera>,
|
||||
pub neck: Neck,
|
||||
levels: usize,
|
||||
}
|
||||
|
||||
/// The multi-scale image features the decoder and memory modules use, highest resolution first
|
||||
/// (256², 128², 64² for a 1024 input), `[B, C, H, W]`.
|
||||
pub struct ImageFeatures {
|
||||
pub features: Vec<Tensor>,
|
||||
pub positions: Vec<Tensor>,
|
||||
}
|
||||
|
||||
impl VisionEncoder {
|
||||
/// The encoder of whichever size the checkpoint is.
|
||||
pub fn load(w: &Weights) -> Result<VisionEncoder> {
|
||||
let cfg = HieraConfig::for_size(crate::Size::detect(w)?);
|
||||
let channels: Vec<usize> = cfg.embed_dims.iter().rev().copied().collect();
|
||||
Ok(VisionEncoder { hiera: std::sync::Arc::new(Hiera::load(w, &cfg)?), neck: Neck::load(w, &channels, 256, &[2, 3])?, levels: 3 })
|
||||
}
|
||||
|
||||
pub fn forward(&self, pixels: &Tensor) -> Result<ImageFeatures> {
|
||||
let stages = self.hiera.forward(pixels)?;
|
||||
let out = self.neck.forward(&stages)?;
|
||||
// keep the last `levels` of the neck order and reverse: high to low resolution
|
||||
let keep = |v: Vec<Tensor>| -> Vec<Tensor> {
|
||||
let skip = v.len().saturating_sub(self.levels);
|
||||
v.into_iter().skip(skip).rev().collect()
|
||||
};
|
||||
Ok(ImageFeatures { features: keep(out.features), positions: keep(out.positions) })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn position_tables_reject_empty_overflowing_and_oversized_dimensions() {
|
||||
for (features, h, w) in [(0, 1, 1), (1, 0, 1), (1, 1, 0), (usize::MAX, 2, 2), (128, usize::MAX, 2), (128, 1024, 1024)] {
|
||||
assert!(matches!(sine_position(features, h, w), Err(Error::Input(_))), "{features}, {h}, {w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_cache_bounds_retained_elements_and_preserves_the_encoding() {
|
||||
let neck = Neck { convs: vec![], top_down: vec![], pos_features: 2, positions: PositionCache::new(64) };
|
||||
let cache = &neck.positions;
|
||||
let first = neck.position(2, 4).unwrap();
|
||||
assert_eq!(first.dims(), &[1, 4, 2, 4]);
|
||||
assert_eq!(first.flatten_all().unwrap().to_vec1::<f32>().unwrap(), neck.position(2, 4).unwrap().flatten_all().unwrap().to_vec1::<f32>().unwrap());
|
||||
neck.position(3, 4).unwrap();
|
||||
let entries = cache.entries.lock().unwrap();
|
||||
assert_eq!(entries.len(), 1, "older tables are evicted to fit the budget");
|
||||
assert!(entries.values().map(Tensor::elem_count).sum::<usize>() <= 64);
|
||||
drop(entries);
|
||||
let large = neck.position(8, 8).unwrap();
|
||||
assert_eq!(large.dims(), &[1, 4, 8, 8]);
|
||||
assert_eq!(cache.entries.lock().unwrap().len(), 1, "a table over budget is returned without retention");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Modified work: small layers used by the ported SAM 2 modules (Apache-2.0, see NOTICE).
|
||||
//! Linear, layer norm and a few tensor helpers on candle.
|
||||
|
||||
use candle_core::{CpuStorage, CustomOp1, CustomOp2, Layout, Shape, Tensor};
|
||||
use candle_nn::Linear;
|
||||
|
||||
use crate::weights::Weights;
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub fn linear(w: &Weights, prefix: &str, input: usize, output: usize) -> Result<Linear> {
|
||||
Ok(Linear::new(w.get(&format!("{prefix}.weight"), &[output, input])?, Some(w.get(&format!("{prefix}.bias"), &[output])?)))
|
||||
}
|
||||
|
||||
/// Layer norm over the last dimension using candle's fused kernel (much faster than the
|
||||
/// composed `candle_nn::LayerNorm` on large activations).
|
||||
pub struct LayerNorm {
|
||||
weight: Tensor,
|
||||
bias: Tensor,
|
||||
eps: f32,
|
||||
}
|
||||
|
||||
impl LayerNorm {
|
||||
pub fn forward(&self, x: &Tensor) -> Result<Tensor> {
|
||||
Ok(candle_nn::ops::layer_norm(&x.contiguous()?, &self.weight, &self.bias, self.eps)?)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn layer_norm(w: &Weights, prefix: &str, dim: usize, eps: f64) -> Result<LayerNorm> {
|
||||
Ok(LayerNorm { weight: w.get(&format!("{prefix}.weight"), &[dim])?, bias: w.get(&format!("{prefix}.bias"), &[dim])?, eps: eps as f32 })
|
||||
}
|
||||
|
||||
/// Two-layer MLP with exact (erf) GELU, as `Sam2FeedForward` with `num_layers = 2`.
|
||||
pub struct Mlp {
|
||||
pub proj_in: Linear,
|
||||
pub proj_out: Linear,
|
||||
}
|
||||
|
||||
impl Mlp {
|
||||
pub fn load(w: &Weights, prefix: &str, dim: usize, hidden: usize) -> Result<Mlp> {
|
||||
Ok(Mlp { proj_in: linear(w, &format!("{prefix}.proj_in"), dim, hidden)?, proj_out: linear(w, &format!("{prefix}.proj_out"), hidden, dim)? })
|
||||
}
|
||||
|
||||
pub fn forward(&self, x: &Tensor) -> Result<Tensor> {
|
||||
apply(&self.proj_out, &apply_gelu(&self.proj_in, x)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// `x + bias` (and, for [`bias_gelu`], then GELU) over rows of `x`, on all cores. Candle adds a bias
|
||||
/// on one thread, which cost more than the matmul it followed on the wide activations of the early
|
||||
/// Hiera blocks.
|
||||
struct BiasAdd {
|
||||
gelu: bool,
|
||||
}
|
||||
|
||||
impl CustomOp2 for BiasAdd {
|
||||
fn name(&self) -> &'static str {
|
||||
"bias_add_parallel"
|
||||
}
|
||||
|
||||
fn cpu_fwd(&self, xs: &CpuStorage, xl: &Layout, bs: &CpuStorage, bl: &Layout) -> candle_core::Result<(CpuStorage, Shape)> {
|
||||
use rayon::prelude::*;
|
||||
let (CpuStorage::F32(x), CpuStorage::F32(b)) = (xs, bs) else { candle_core::bail!("bias_add_parallel needs f32") };
|
||||
let (Some((x0, x1)), Some((b0, b1))) = (xl.contiguous_offsets(), bl.contiguous_offsets()) else {
|
||||
candle_core::bail!("bias_add_parallel needs contiguous inputs")
|
||||
};
|
||||
let (x, b) = match (x.get(x0..x1), b.get(b0..b1)) {
|
||||
(Some(x), Some(b)) if !b.is_empty() && x.len() % b.len() == 0 => (x, b),
|
||||
_ => candle_core::bail!("bias_add_parallel: sizes do not fit"),
|
||||
};
|
||||
let cols = b.len();
|
||||
let mut out = vec![0f32; x.len()];
|
||||
let rows_per = (64 * 1024 / cols).max(1);
|
||||
let gelu = self.gelu;
|
||||
out.par_chunks_mut(rows_per * cols).zip(x.par_chunks(rows_per * cols)).for_each(|(o, i)| {
|
||||
for (orow, irow) in o.chunks_mut(cols).zip(i.chunks(cols)) {
|
||||
for ((a, v), bias) in orow.iter_mut().zip(irow).zip(b) {
|
||||
let y = v + bias;
|
||||
*a = if gelu { (candle_core::cpu::erf::erf_f32(y * std::f32::consts::FRAC_1_SQRT_2) + 1.0) * 0.5 * y } else { y };
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok((CpuStorage::F32(out), xl.shape().clone()))
|
||||
}
|
||||
}
|
||||
|
||||
fn bias_op(x: &Tensor, bias: &Tensor, gelu: bool) -> Result<Tensor> {
|
||||
if x.dtype() != candle_core::DType::F32 || !x.device().is_cpu() {
|
||||
let y = x.broadcast_add(bias)?;
|
||||
return Ok(if gelu { y.gelu_erf()? } else { y });
|
||||
}
|
||||
Ok(x.contiguous()?.apply_op2_no_bwd(&bias.contiguous()?, &BiasAdd { gelu })?)
|
||||
}
|
||||
|
||||
fn matmul_bias(l: &Linear, x: &Tensor, gelu: bool) -> Result<Tensor> {
|
||||
let dims = x.dims();
|
||||
let (&last, lead) = dims.split_last().ok_or_else(|| Error::Compute("linear on a scalar".into()))?;
|
||||
let rows: usize = lead.iter().product();
|
||||
let y = x.reshape((rows, last))?.matmul(&l.weight().t()?)?;
|
||||
let y = match l.bias() {
|
||||
Some(b) => bias_op(&y, b, gelu)?,
|
||||
None if gelu => y.gelu_erf()?,
|
||||
None => y,
|
||||
};
|
||||
let mut out: Vec<usize> = lead.to_vec();
|
||||
out.push(y.dim(1)?);
|
||||
Ok(y.reshape(out)?)
|
||||
}
|
||||
|
||||
/// Apply a linear layer to `[.., in]` through a flat 2-D matmul and a parallel bias add. Candle's
|
||||
/// `Linear::forward` broadcasts the weight over every leading dimension (copying it per window) and
|
||||
/// adds the bias on one thread: both slow on the 4-D activations of the backbone.
|
||||
pub fn apply(l: &Linear, x: &Tensor) -> Result<Tensor> {
|
||||
matmul_bias(l, x, false)
|
||||
}
|
||||
|
||||
/// [`apply`] followed by the exact GELU, in the same pass over the output.
|
||||
pub fn apply_gelu(l: &Linear, x: &Tensor) -> Result<Tensor> {
|
||||
matmul_bias(l, x, true)
|
||||
}
|
||||
|
||||
/// Exact (erf) GELU on all cores. Candle's own `gelu_erf` runs on one thread, which made it a third of
|
||||
/// the time of the Hiera blocks; this gives the same values (it calls the same `erf` per element).
|
||||
struct GeluErf;
|
||||
|
||||
impl CustomOp1 for GeluErf {
|
||||
fn name(&self) -> &'static str {
|
||||
"gelu_erf_parallel"
|
||||
}
|
||||
|
||||
fn cpu_fwd(&self, storage: &CpuStorage, layout: &Layout) -> candle_core::Result<(CpuStorage, Shape)> {
|
||||
use rayon::prelude::*;
|
||||
let CpuStorage::F32(data) = storage else { candle_core::bail!("gelu_erf_parallel needs f32") };
|
||||
let Some((start, end)) = layout.contiguous_offsets() else { candle_core::bail!("gelu_erf_parallel needs a contiguous input") };
|
||||
let src = data.get(start..end).ok_or_else(|| candle_core::Error::Msg("gelu_erf_parallel: layout outside the storage".into()))?;
|
||||
let mut out = vec![0f32; src.len()];
|
||||
const CHUNK: usize = 16 * 1024;
|
||||
out.par_chunks_mut(CHUNK).zip(src.par_chunks(CHUNK)).for_each(|(o, i)| {
|
||||
for (a, v) in o.iter_mut().zip(i) {
|
||||
*a = (candle_core::cpu::erf::erf_f32(v * std::f32::consts::FRAC_1_SQRT_2) + 1.0) * 0.5 * v;
|
||||
}
|
||||
});
|
||||
Ok((CpuStorage::F32(out), layout.shape().clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact GELU of `x`, in parallel for f32 tensors.
|
||||
pub fn gelu(x: &Tensor) -> Result<Tensor> {
|
||||
if x.dtype() != candle_core::DType::F32 || !x.device().is_cpu() {
|
||||
return Ok(x.gelu_erf()?);
|
||||
}
|
||||
Ok(x.contiguous()?.apply_op1_no_bwd(&GeluErf)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use candle_core::Device;
|
||||
|
||||
#[test]
|
||||
fn the_parallel_gelu_equals_candles_bit_for_bit() {
|
||||
let v: Vec<f32> = (0..101_000).map(|i| ((i as f32) * 0.00037).sin() * 6.0 - 0.5).collect();
|
||||
let x = Tensor::from_vec(v, (101, 1000), &Device::Cpu).unwrap();
|
||||
let (a, b) = (gelu(&x).unwrap(), x.gelu_erf().unwrap());
|
||||
assert_eq!(a.flatten_all().unwrap().to_vec1::<f32>().unwrap(), b.flatten_all().unwrap().to_vec1::<f32>().unwrap());
|
||||
// a transposed (non-contiguous) input works, and an empty one does too
|
||||
let t = x.t().unwrap();
|
||||
assert_eq!(gelu(&t).unwrap().dims(), t.dims());
|
||||
let e = Tensor::zeros((0, 4), candle_core::DType::F32, &Device::Cpu).unwrap();
|
||||
assert_eq!(gelu(&e).unwrap().elem_count(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Modified work: ported from Hugging Face `transformers` `models/sam2/modeling_sam2.py`
|
||||
// (Sam2PositionalEmbedding, Sam2MaskEmbedding, Sam2PromptEncoder; Apache-2.0, Copyright The
|
||||
// HuggingFace Inc. team) to Rust on candle. See NOTICE.
|
||||
//! The prompt encoder: clicks and boxes become sparse tokens, a mask or "no mask" becomes a dense map.
|
||||
|
||||
use candle_core::{Device, Tensor};
|
||||
|
||||
use crate::nn::{LayerNorm, gelu, layer_norm};
|
||||
use crate::weights::Weights;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Side of the (square) model input in pixels; prompts are given in these coordinates.
|
||||
pub const INPUT_SIZE: usize = 1024;
|
||||
/// Side of the image-embedding grid (input / 16).
|
||||
pub const GRID: usize = INPUT_SIZE / 16;
|
||||
const DIM: usize = 256;
|
||||
|
||||
/// What the user pointed at, in model-input pixels (0..1024).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Prompt {
|
||||
/// Clicks and whether each is foreground.
|
||||
pub points: Vec<([f32; 2], bool)>,
|
||||
/// A box `[x0, y0, x1, y1]`.
|
||||
pub bbox: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
impl Prompt {
|
||||
/// Reject non-finite or absurd coordinates (they come from the user or an agent).
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let bad = |v: f32| !v.is_finite() || v.abs() > 4.0 * INPUT_SIZE as f32;
|
||||
if self.points.iter().any(|(p, _)| p.iter().any(|v| bad(*v))) || self.bbox.is_some_and(|b| b.iter().any(|v| bad(*v))) {
|
||||
return Err(Error::Input("prompt coordinates must be finite and near the frame".into()));
|
||||
}
|
||||
if self.points.len() > 64 {
|
||||
return Err(Error::Input("at most 64 points per prompt".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct MaskEmbed {
|
||||
conv: [(Tensor, Tensor, usize, usize); 3],
|
||||
ln1: LayerNorm,
|
||||
ln2: LayerNorm,
|
||||
}
|
||||
|
||||
/// Sparse and dense prompt embeddings.
|
||||
pub struct PromptEncoder {
|
||||
gauss: Vec<f32>,
|
||||
point_embed: Vec<f32>,
|
||||
not_a_point: Vec<f32>,
|
||||
no_mask: Tensor,
|
||||
mask: MaskEmbed,
|
||||
}
|
||||
|
||||
fn rows(t: Tensor) -> Result<Vec<f32>> {
|
||||
Ok(t.flatten_all()?.to_vec1::<f32>()?)
|
||||
}
|
||||
|
||||
impl PromptEncoder {
|
||||
pub fn load(w: &Weights) -> Result<PromptEncoder> {
|
||||
let conv = |name: &str, i: usize, o: usize, k: usize, stride: usize| -> Result<(Tensor, Tensor, usize, usize)> {
|
||||
Ok((
|
||||
w.get(&format!("prompt_encoder.mask_embed.{name}.weight"), &[o, i, k, k])?,
|
||||
w.get(&format!("prompt_encoder.mask_embed.{name}.bias"), &[o])?,
|
||||
k,
|
||||
stride,
|
||||
))
|
||||
};
|
||||
Ok(PromptEncoder {
|
||||
gauss: rows(w.get("prompt_encoder.shared_embedding.positional_embedding", &[2, DIM / 2])?)?,
|
||||
point_embed: rows(w.get("prompt_encoder.point_embed.weight", &[4, DIM])?)?,
|
||||
not_a_point: rows(w.get("prompt_encoder.not_a_point_embed.weight", &[1, DIM])?)?,
|
||||
no_mask: w.get("prompt_encoder.no_mask_embed.weight", &[1, DIM])?,
|
||||
mask: MaskEmbed {
|
||||
conv: [conv("conv1", 1, 4, 2, 2)?, conv("conv2", 4, 16, 2, 2)?, conv("conv3", 16, DIM, 1, 1)?],
|
||||
ln1: layer_norm(w, "prompt_encoder.mask_embed.layer_norm1", 4, 1e-6)?,
|
||||
ln2: layer_norm(w, "prompt_encoder.mask_embed.layer_norm2", 16, 1e-6)?,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Random-Fourier position encoding of coordinates already normalised to 0..1: `[n * 256]`.
|
||||
fn fourier(&self, coords01: &[[f32; 2]]) -> Vec<f32> {
|
||||
let half = DIM / 2;
|
||||
let mut out = vec![0f32; coords01.len() * DIM];
|
||||
for (n, c) in coords01.iter().enumerate() {
|
||||
let (x, y) = (2.0 * c[0] - 1.0, 2.0 * c[1] - 1.0);
|
||||
for j in 0..half {
|
||||
let g0 = self.gauss.get(j).copied().unwrap_or(0.0);
|
||||
let g1 = self.gauss.get(half + j).copied().unwrap_or(0.0);
|
||||
let v = 2.0 * std::f32::consts::PI * (x * g0 + y * g1);
|
||||
if let Some(o) = out.get_mut(n * DIM + j) {
|
||||
*o = v.sin();
|
||||
}
|
||||
if let Some(o) = out.get_mut(n * DIM + half + j) {
|
||||
*o = v.cos();
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The position encoding of the whole `GRID x GRID` embedding, `[1, 256, GRID, GRID]`.
|
||||
pub fn image_positions(&self) -> Result<Tensor> {
|
||||
let coords: Vec<[f32; 2]> = (0..GRID * GRID).map(|i| [((i % GRID) as f32 + 0.5) / GRID as f32, ((i / GRID) as f32 + 0.5) / GRID as f32]).collect();
|
||||
let flat = self.fourier(&coords);
|
||||
Ok(Tensor::from_vec(flat, (GRID * GRID, DIM), &Device::Cpu)?.t()?.reshape((1, DIM, GRID, GRID))?.contiguous()?)
|
||||
}
|
||||
|
||||
fn point_row(&self, i: usize) -> &[f32] {
|
||||
self.point_embed.get(i * DIM..(i + 1) * DIM).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Sparse tokens `[1, n, 256]`; the pinned reference pads both click-only and box prompts.
|
||||
pub fn sparse(&self, p: &Prompt) -> Result<Tensor> {
|
||||
p.validate()?;
|
||||
let norm = |c: [f32; 2]| [(c[0] + 0.5) / INPUT_SIZE as f32, (c[1] + 0.5) / INPUT_SIZE as f32];
|
||||
let mut tokens: Vec<f32> = vec![];
|
||||
let mut n = 0;
|
||||
let mut push = |v: &[f32]| {
|
||||
tokens.extend_from_slice(v);
|
||||
n += 1;
|
||||
};
|
||||
let pts: Vec<[f32; 2]> = p.points.iter().map(|(c, _)| norm(*c)).collect();
|
||||
let enc = self.fourier(&pts);
|
||||
for (k, (_, fg)) in p.points.iter().enumerate() {
|
||||
let e = enc.get(k * DIM..(k + 1) * DIM).unwrap_or(&[]);
|
||||
let label_row = self.point_row(usize::from(*fg));
|
||||
let v: Vec<f32> = e.iter().zip(label_row).map(|(a, b)| a + b).collect();
|
||||
push(&v);
|
||||
}
|
||||
match p.bbox {
|
||||
None => push(&self.not_a_point.clone()),
|
||||
Some(b) => {
|
||||
let corners = [norm([b[0], b[1]]), norm([b[2], b[3]])];
|
||||
let enc = self.fourier(&corners);
|
||||
for (k, row) in [2usize, 3].into_iter().enumerate() {
|
||||
let e = enc.get(k * DIM..(k + 1) * DIM).unwrap_or(&[]);
|
||||
let v: Vec<f32> = e.iter().zip(self.point_row(row)).map(|(a, b)| a + b).collect();
|
||||
push(&v);
|
||||
}
|
||||
// _embed_boxes adds its own no-point token, separately from _embed_points.
|
||||
push(&self.not_a_point.clone());
|
||||
}
|
||||
}
|
||||
Ok(Tensor::from_vec(tokens, (1, n, DIM), &Device::Cpu)?)
|
||||
}
|
||||
|
||||
/// Tokens for "no prompt" on a tracked frame: the reference feeds one point labelled -1 and
|
||||
/// then pads another, so two `not_a_point` tokens, `[1, 2, 256]`.
|
||||
pub fn sparse_none(&self) -> Result<Tensor> {
|
||||
let mut v = self.not_a_point.clone();
|
||||
v.extend_from_slice(&self.not_a_point);
|
||||
Ok(Tensor::from_vec(v, (1, 2, DIM), &Device::Cpu)?)
|
||||
}
|
||||
|
||||
/// The dense embedding for "no mask prompt", `[1, 256, GRID, GRID]`.
|
||||
pub fn dense_empty(&self) -> Result<Tensor> {
|
||||
Ok(self.no_mask.reshape((1, DIM, 1, 1))?.broadcast_as((1, DIM, GRID, GRID))?.contiguous()?)
|
||||
}
|
||||
|
||||
/// The dense embedding of a mask prompt `[1, 1, 4 * GRID, 4 * GRID]` (low-resolution logits).
|
||||
pub fn dense_mask(&self, mask: &Tensor) -> Result<Tensor> {
|
||||
let (c1, c2, c3) = (&self.mask.conv[0], &self.mask.conv[1], &self.mask.conv[2]);
|
||||
let step = |x: &Tensor, c: &(Tensor, Tensor, usize, usize)| -> Result<Tensor> {
|
||||
Ok(x.conv2d(&c.0, 0, c.3, 1, 1)?.broadcast_add(&c.1.reshape((1, c.1.dim(0)?, 1, 1))?)?)
|
||||
};
|
||||
let cl = |x: &Tensor, ln: &LayerNorm| -> Result<Tensor> { Ok(ln.forward(&x.permute((0, 2, 3, 1))?)?.permute((0, 3, 1, 2))?.contiguous()?) };
|
||||
let x = gelu(&cl(&step(mask, c1)?, &self.mask.ln1)?)?;
|
||||
let x = gelu(&cl(&step(&x, c2)?, &self.mask.ln2)?)?;
|
||||
step(&x, c3)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A tiny deterministic checkpoint exercises token labels without downloading trained weights.
|
||||
fn encoder() -> PromptEncoder {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for (name, shape) in [
|
||||
("shared_embedding.positional_embedding", vec![2, DIM / 2]),
|
||||
("no_mask_embed.weight", vec![1, DIM]),
|
||||
("mask_embed.conv1.weight", vec![4, 1, 2, 2]),
|
||||
("mask_embed.conv1.bias", vec![4]),
|
||||
("mask_embed.conv2.weight", vec![16, 4, 2, 2]),
|
||||
("mask_embed.conv2.bias", vec![16]),
|
||||
("mask_embed.conv3.weight", vec![DIM, 16, 1, 1]),
|
||||
("mask_embed.conv3.bias", vec![DIM]),
|
||||
("mask_embed.layer_norm1.weight", vec![4]),
|
||||
("mask_embed.layer_norm1.bias", vec![4]),
|
||||
("mask_embed.layer_norm2.weight", vec![16]),
|
||||
("mask_embed.layer_norm2.bias", vec![16]),
|
||||
] {
|
||||
map.insert(format!("prompt_encoder.{name}"), Tensor::zeros(shape, candle_core::DType::F32, &Device::Cpu).unwrap());
|
||||
}
|
||||
let rows: Vec<f32> = (1..=4).flat_map(|row| vec![row as f32 * 10.0; DIM]).collect();
|
||||
map.insert("prompt_encoder.point_embed.weight".into(), Tensor::from_vec(rows, (4, DIM), &Device::Cpu).unwrap());
|
||||
map.insert("prompt_encoder.not_a_point_embed.weight".into(), Tensor::from_vec(vec![-1000.0f32; DIM], (1, DIM), &Device::Cpu).unwrap());
|
||||
PromptEncoder::load(&Weights::from_map(map).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Transformers 5.19.0 pads click-only prompts in _embed_points and boxes in _embed_boxes.
|
||||
#[test]
|
||||
fn box_and_click_tokens_match_the_pinned_reference_padding() {
|
||||
let encoder = encoder();
|
||||
let points = vec![([10.0, 20.0], false), ([30.0, 40.0], true)];
|
||||
for points in [vec![], points.clone()] {
|
||||
let sparse = encoder.sparse(&Prompt { points: points.clone(), bbox: Some([0.0, 0.0, 100.0, 100.0]) }).unwrap();
|
||||
assert_eq!(sparse.dims(), &[1, points.len() + 3, DIM]);
|
||||
let rows = sparse.to_vec3::<f32>().unwrap();
|
||||
assert!(rows[0].last().unwrap().iter().all(|value| *value == -1000.0));
|
||||
for (token, label) in rows[0].iter().zip(if points.is_empty() { vec![30.0, 40.0] } else { vec![10.0, 20.0, 30.0, 40.0] }) {
|
||||
assert!(token[..DIM / 2].iter().all(|value| *value == label));
|
||||
assert!(token[DIM / 2..].iter().all(|value| *value == label + 1.0));
|
||||
}
|
||||
}
|
||||
let clicks = encoder.sparse(&Prompt { points, bbox: None }).unwrap();
|
||||
assert_eq!(clicks.dims(), &[1, 3, DIM]);
|
||||
assert!(clicks.to_vec3::<f32>().unwrap()[0][2].iter().all(|value| *value == -1000.0));
|
||||
let none = encoder.sparse_none().unwrap();
|
||||
assert_eq!(none.dims(), &[1, 2, DIM]);
|
||||
assert!(none.flatten_all().unwrap().to_vec1::<f32>().unwrap().iter().all(|value| *value == -1000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_prompts_are_rejected() {
|
||||
let ok = Prompt { points: vec![([10.0, 20.0], true)], bbox: None };
|
||||
assert!(ok.validate().is_ok());
|
||||
assert!(Prompt { points: vec![([f32::NAN, 0.0], true)], bbox: None }.validate().is_err());
|
||||
assert!(Prompt { points: vec![], bbox: Some([0.0, 0.0, f32::INFINITY, 5.0]) }.validate().is_err());
|
||||
assert!(Prompt { points: vec![([1e9, 0.0], false)], bbox: None }.validate().is_err());
|
||||
assert!(Prompt { points: vec![([1.0, 1.0], true); 65], bbox: None }.validate().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Modified work: the antialiased bilinear resize of PyTorch `F.interpolate(mode="bilinear",
|
||||
// antialias=True, align_corners=False)`, as used by the Hugging Face `transformers` SAM 2 mask path
|
||||
// (Apache-2.0 for the model code; the algorithm follows PyTorch's documented behaviour). See NOTICE.
|
||||
//! Resizing masks to and from the model's resolution.
|
||||
|
||||
use candle_core::{Device, Tensor};
|
||||
|
||||
use crate::Result;
|
||||
use crate::mask::validate_frame_size;
|
||||
use crate::prompt::INPUT_SIZE;
|
||||
|
||||
/// For each output index: the first input index and the normalised triangle-filter weights.
|
||||
fn axis_weights(n_in: usize, n_out: usize) -> Vec<(usize, Vec<f32>)> {
|
||||
let scale = n_in as f64 / n_out.max(1) as f64;
|
||||
let support = if scale >= 1.0 { scale } else { 1.0 };
|
||||
let invscale = if scale >= 1.0 { 1.0 / scale } else { 1.0 };
|
||||
(0..n_out)
|
||||
.map(|i| {
|
||||
let center = scale * (i as f64 + 0.5);
|
||||
let xmin = ((center - support + 0.5) as i64).max(0) as usize;
|
||||
let xmax = (((center + support + 0.5) as i64).max(0) as usize).min(n_in);
|
||||
let mut w: Vec<f32> = (xmin..xmax.max(xmin))
|
||||
.map(|x| {
|
||||
let d = ((x as f64 - center + 0.5) * invscale).abs();
|
||||
if d < 1.0 { (1.0 - d) as f32 } else { 0.0 }
|
||||
})
|
||||
.collect();
|
||||
let total: f32 = w.iter().sum();
|
||||
if total > 0.0 {
|
||||
w.iter_mut().for_each(|v| *v /= total);
|
||||
}
|
||||
(xmin, w)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Antialiased bilinear resize of a row-major `h x w` image to `oh x ow`.
|
||||
pub fn antialias_bilinear(src: &[f32], (h, w): (usize, usize), (oh, ow): (usize, usize)) -> Result<Vec<f32>> {
|
||||
validate_frame_size(w, h, src.len())?;
|
||||
let out_len = validate_frame_size(ow, oh, oh.saturating_mul(ow))?;
|
||||
let mid_len = validate_frame_size(ow, h, h.saturating_mul(ow))?;
|
||||
let xs = axis_weights(w, ow);
|
||||
let ys = axis_weights(h, oh);
|
||||
// horizontal pass: h x ow
|
||||
let mut mid = vec![0f32; mid_len];
|
||||
for y in 0..h {
|
||||
let row = src.get(y * w..(y + 1) * w).unwrap_or(&[]);
|
||||
for (x, (x0, wt)) in xs.iter().enumerate() {
|
||||
let acc: f32 = wt.iter().enumerate().map(|(k, a)| a * row.get(x0 + k).copied().unwrap_or(0.0)).sum();
|
||||
if let Some(o) = mid.get_mut(y * ow + x) {
|
||||
*o = acc;
|
||||
}
|
||||
}
|
||||
}
|
||||
// vertical pass: oh x ow
|
||||
let mut out = vec![0f32; out_len];
|
||||
for (y, (y0, wt)) in ys.iter().enumerate() {
|
||||
for x in 0..ow {
|
||||
let acc: f32 = wt.iter().enumerate().map(|(k, a)| a * mid.get((y0 + k) * ow + x).copied().unwrap_or(0.0)).sum();
|
||||
if let Some(o) = out.get_mut(y * ow + x) {
|
||||
*o = acc;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A binary mask of any size as the model's mask prompt: `[1, 1, 1024, 1024]` of 0 and 1 (resized
|
||||
/// like the reference: antialiased bilinear, then thresholded at one half).
|
||||
pub fn mask_to_model(mask: &[u8], w: usize, h: usize) -> Result<Tensor> {
|
||||
validate_frame_size(w, h, mask.len())?;
|
||||
let side = INPUT_SIZE;
|
||||
let f: Vec<f32> = mask.iter().map(|v| f32::from(*v != 0)).collect();
|
||||
let data = if (w, h) == (side, side) { f } else { antialias_bilinear(&f, (h, w), (side, side))?.into_iter().map(|v| f32::from(v >= 0.5)).collect() };
|
||||
Ok(Tensor::from_vec(data, (1, 1, side, side), &Device::Cpu)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resize_rejects_overflowing_oversized_and_mismatched_grids() {
|
||||
for (input, output) in [((usize::MAX, 2), (1, 1)), ((1, 1), (usize::MAX, 2)), ((1, 1), (16_385, 1)), ((0, 1), (1, 1))] {
|
||||
assert!(antialias_bilinear(&[1.0], input, output).is_err(), "{input:?} to {output:?}");
|
||||
}
|
||||
assert!(mask_to_model(&[1], usize::MAX, 2).is_err());
|
||||
assert!(mask_to_model(&[1], INPUT_SIZE, INPUT_SIZE).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_constant_stays_constant_and_weights_sum_to_one() {
|
||||
let out = antialias_bilinear(&[3.0; 64 * 64], (64, 64), (16, 16)).unwrap();
|
||||
assert!(out.iter().all(|v| (v - 3.0).abs() < 1e-5));
|
||||
let up = antialias_bilinear(&[2.0; 16], (4, 4), (9, 7)).unwrap();
|
||||
assert!(up.iter().all(|v| (v - 2.0).abs() < 1e-5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_half_plane_blurs_over_the_filter_width() {
|
||||
// 8 x 1: left half 0, right half 1, down to 2 x 1. The triangle filter is four pixels wide on
|
||||
// each side, so the first output sees the ones at weights 0.375 and 0.125 of a total 3.5.
|
||||
let src = [0., 0., 0., 0., 1., 1., 1., 1.];
|
||||
let out = antialias_bilinear(&src, (1, 8), (1, 2)).unwrap();
|
||||
assert!((out[0] - 0.5 / 3.5).abs() < 1e-6 && (out[1] - 3.0 / 3.5).abs() < 1e-6, "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sizes_are_errors() {
|
||||
assert!(antialias_bilinear(&[1.0; 4], (2, 2), (0, 3)).is_err());
|
||||
assert!(antialias_bilinear(&[1.0; 5], (2, 2), (1, 1)).is_err());
|
||||
assert!(mask_to_model(&[1; 3], 2, 2).is_err());
|
||||
assert!(mask_to_model(&[], 0, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_mask_becomes_a_model_sized_binary_mask() {
|
||||
let m = mask_to_model(&[0, 1, 1, 0], 2, 2).unwrap();
|
||||
assert_eq!(m.dims(), [1, 1, INPUT_SIZE, INPUT_SIZE]);
|
||||
let v = m.flatten_all().unwrap().to_vec1::<f32>().unwrap();
|
||||
assert!(v.iter().all(|x| *x == 0.0 || *x == 1.0));
|
||||
assert!(v.iter().sum::<f32>() > 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
// Modified work: ported from Hugging Face `transformers` `models/sam2_video/modeling_sam2_video.py`
|
||||
// (Sam2VideoModel and Sam2VideoInferenceSession: memory-conditioned single-frame inference,
|
||||
// memory bank selection, object pointers, propagation; Apache-2.0, Copyright The HuggingFace
|
||||
// Inc. team) to Rust on candle. See NOTICE.
|
||||
//! The video tracker: per-object memory banks, prompts on any frame, propagation forwards and
|
||||
//! backwards with streaming results and cancellation.
|
||||
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use candle_core::{DType, Tensor};
|
||||
use candle_nn::{Linear, ops::sigmoid};
|
||||
|
||||
use crate::decoder::FeedForward;
|
||||
use crate::gpu::Gpu;
|
||||
use crate::gpu::encoder::GpuEncoder;
|
||||
use crate::gpu::memattn::GpuMemoryAttention;
|
||||
use crate::gpu::memenc::GpuMemoryEncoder;
|
||||
use crate::memattn::MemoryAttention;
|
||||
use crate::memory::{Memory, MemoryEncoder};
|
||||
use crate::model::{Encoded, ImageModel};
|
||||
use crate::neck::ImageFeatures;
|
||||
use crate::nn::{apply, linear};
|
||||
use crate::prompt::{INPUT_SIZE, Prompt};
|
||||
use crate::resize::antialias_bilinear;
|
||||
use crate::weights::Weights;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Mask logit used where the object is absent (the reference's `NO_OBJ_SCORE`).
|
||||
const NO_OBJECT: f32 = -1024.0;
|
||||
/// Frames of memory the attention looks back over.
|
||||
const MEMORY_FRAMES: usize = 7;
|
||||
/// Object pointers kept per query.
|
||||
const MAX_POINTERS: usize = 16;
|
||||
/// Largest clip the session accepts.
|
||||
const MAX_FRAMES: usize = 1_000_000;
|
||||
const GRID: usize = 64;
|
||||
|
||||
/// Frames of the clip, already resized to the model input and normalised: `[1, 3, 1024, 1024]`.
|
||||
pub trait FrameSource {
|
||||
fn num_frames(&self) -> usize;
|
||||
fn frame(&self, index: usize) -> Result<Tensor>;
|
||||
}
|
||||
|
||||
/// Cooperative cancellation shared with the caller.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Cancel(Arc<AtomicBool>);
|
||||
|
||||
impl Cancel {
|
||||
pub fn new() -> Cancel {
|
||||
Cancel::default()
|
||||
}
|
||||
pub fn cancel(&self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.0.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ObjectId(pub u32);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Direction {
|
||||
Forward,
|
||||
Backward,
|
||||
}
|
||||
|
||||
/// One object's mask on one frame.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FrameMask {
|
||||
pub frame: usize,
|
||||
pub object: ObjectId,
|
||||
/// Mask logits at 256 x 256 (positive is inside the object); `-1024` everywhere when the
|
||||
/// object is absent.
|
||||
pub logits: Vec<f32>,
|
||||
/// The model's own estimate of the mask quality (0..1).
|
||||
pub iou: f32,
|
||||
/// Object-presence logit; at or below zero the object is not in the frame.
|
||||
pub object_score: f32,
|
||||
pub occluded: bool,
|
||||
}
|
||||
|
||||
/// Which device runs the heavy parts (the backbone, the neck and the memory attention).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Backend {
|
||||
/// Candle on the CPU: the reference path.
|
||||
Cpu,
|
||||
/// wgpu compute on the GPU (any vendor); an error if there is none.
|
||||
Gpu,
|
||||
/// The GPU when there is a real one and it loads, else the CPU. The environment variable
|
||||
/// `EFFECTCRAFT_SAM2_DEVICE` (`cpu`, `gpu` or `auto`) overrides the choice.
|
||||
Auto,
|
||||
}
|
||||
|
||||
/// The GPU path: kernels for the backbone and memory attention. Device failures disable it;
|
||||
/// memory-attention budget limits fall back to the CPU for that call only.
|
||||
struct GpuParts {
|
||||
encoder: GpuEncoder,
|
||||
attention: GpuMemoryAttention,
|
||||
memory_encoder: GpuMemoryEncoder,
|
||||
ok: AtomicBool,
|
||||
adapter: String,
|
||||
}
|
||||
|
||||
/// The weights of everything the video tracker needs.
|
||||
pub struct VideoModel {
|
||||
gpu: Option<GpuParts>,
|
||||
pub image: ImageModel,
|
||||
memory_encoder: MemoryEncoder,
|
||||
memory_attention: MemoryAttention,
|
||||
temporal_pos: Vec<f32>,
|
||||
no_object_pointer: Tensor,
|
||||
pointer_proj: FeedForward,
|
||||
pointer_time_proj: Linear,
|
||||
/// `mask_downsample`: a 4 x 4 stride-4 convolution taking a mask prompt to the prompt encoder's resolution.
|
||||
mask_down: (Tensor, Tensor),
|
||||
}
|
||||
|
||||
impl VideoModel {
|
||||
/// On the CPU: the reference path every test compares against.
|
||||
pub fn load(w: &Weights) -> Result<VideoModel> {
|
||||
VideoModel::load_with(w, Backend::Cpu)
|
||||
}
|
||||
|
||||
/// On the GPU when there is one (see [`Backend::Auto`]).
|
||||
pub fn load_auto(w: &Weights) -> Result<VideoModel> {
|
||||
VideoModel::load_with(w, Backend::Auto)
|
||||
}
|
||||
|
||||
pub fn load_with(w: &Weights, backend: Backend) -> Result<VideoModel> {
|
||||
let backend = match (backend, std::env::var("EFFECTCRAFT_SAM2_DEVICE").ok().as_deref()) {
|
||||
(Backend::Auto, Some("cpu")) => Backend::Cpu,
|
||||
(Backend::Auto, Some("gpu")) => Backend::Gpu,
|
||||
(b, _) => b,
|
||||
};
|
||||
let image = ImageModel::load(w)?;
|
||||
let gpu = match backend {
|
||||
Backend::Cpu => None,
|
||||
Backend::Gpu => Some(Self::load_gpu(w, &image)?),
|
||||
Backend::Auto => match Self::load_gpu(w, &image) {
|
||||
Ok(g) => Some(g),
|
||||
Err(e) => {
|
||||
log::info!("SAM 2 runs on the CPU: {e}");
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
Ok(VideoModel {
|
||||
gpu,
|
||||
image,
|
||||
memory_encoder: MemoryEncoder::load(w)?,
|
||||
memory_attention: MemoryAttention::load(w)?,
|
||||
temporal_pos: w.get("memory_temporal_positional_encoding", &[MEMORY_FRAMES, 1, 1, 64])?.flatten_all()?.to_vec1::<f32>()?,
|
||||
no_object_pointer: w.get("no_object_pointer", &[1, 256])?,
|
||||
pointer_proj: FeedForward::load(w, "object_pointer_proj", 256, 256, 256, 3, false)?,
|
||||
pointer_time_proj: linear(w, "temporal_positional_encoding_projection_layer", 256, 64)?,
|
||||
mask_down: (w.get("mask_downsample.weight", &[1, 1, 4, 4])?, w.get("mask_downsample.bias", &[1])?),
|
||||
})
|
||||
}
|
||||
|
||||
fn load_gpu(w: &Weights, image: &ImageModel) -> Result<GpuParts> {
|
||||
let gpu = Gpu::shared()?;
|
||||
Ok(GpuParts {
|
||||
encoder: GpuEncoder::load(gpu.clone(), w, image.vision.hiera.clone())?,
|
||||
attention: GpuMemoryAttention::load(gpu.clone(), w)?,
|
||||
memory_encoder: GpuMemoryEncoder::load(gpu.clone(), w)?,
|
||||
ok: AtomicBool::new(true),
|
||||
adapter: gpu.adapter_name().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The device the heavy parts run on now: the GPU's name, or "CPU".
|
||||
pub fn device_name(&self) -> String {
|
||||
match &self.gpu {
|
||||
Some(g) if g.ok.load(Ordering::Relaxed) => g.adapter.clone(),
|
||||
_ => "CPU".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run on the CPU from now on (a GPU that misbehaves, or the user's choice). Weights and results are
|
||||
/// unchanged; only the device differs.
|
||||
pub fn disable_gpu(&self) {
|
||||
if let Some(g) = &self.gpu {
|
||||
g.ok.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn gpu_failed(&self, what: &str, e: &Error) {
|
||||
if let Some(g) = &self.gpu
|
||||
&& g.ok.swap(false, Ordering::Relaxed)
|
||||
{
|
||||
log::warn!("SAM 2 {what} failed on the GPU ({e}); the CPU takes over");
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a normalised `[1, 3, 1024, 1024]` frame on the GPU if it is working, else on the CPU.
|
||||
pub fn encode(&self, pixels: &Tensor) -> Result<Encoded> {
|
||||
if let Some(g) = self.gpu.as_ref().filter(|g| g.ok.load(Ordering::Relaxed)) {
|
||||
match g.encoder.encode(pixels) {
|
||||
Ok(f) => {
|
||||
let none = || Tensor::zeros((1, 0, 0, 0), DType::F32, &candle_core::Device::Cpu);
|
||||
let pos = self.image.vision.neck.position(GRID, GRID)?;
|
||||
let features = ImageFeatures { features: vec![none()?, none()?, f.low], positions: vec![none()?, none()?, pos] };
|
||||
return Ok(Encoded { features, high: f.high });
|
||||
}
|
||||
Err(e) => self.gpu_failed("encoding", &e),
|
||||
}
|
||||
}
|
||||
self.image.encode(pixels)
|
||||
}
|
||||
|
||||
/// The memory of a frame (its features and the scaled mask), on the GPU if it is working, else on the CPU.
|
||||
fn memorize(&self, features: &Tensor, mask: &Tensor, present: bool) -> Result<Memory> {
|
||||
if let Some(g) = self.gpu.as_ref().filter(|g| g.ok.load(Ordering::Relaxed)) {
|
||||
match g.memory_encoder.encode(features, mask).and_then(|t| self.memory_encoder.finish(&t, present)) {
|
||||
Ok(m) => return Ok(m),
|
||||
Err(e) => self.gpu_failed("memory encoding", &e),
|
||||
}
|
||||
}
|
||||
self.memory_encoder.memory(features, mask, present)
|
||||
}
|
||||
|
||||
/// Memory attention on the GPU if it is working, else on the CPU.
|
||||
fn attend(&self, current: &Tensor, current_pos: &Tensor, memory: &Tensor, memory_pos: &Tensor, pointers: usize) -> Result<Tensor> {
|
||||
if let Some(g) = self.gpu.as_ref().filter(|g| g.ok.load(Ordering::Relaxed)) {
|
||||
match g.attention.forward(current, current_pos, memory, memory_pos, pointers) {
|
||||
Ok(t) => return Ok(t),
|
||||
Err(Error::Budget(reason)) => log::info!("SAM 2 memory attention on the CPU for this call: {reason}"),
|
||||
Err(e) => self.gpu_failed("memory attention", &e),
|
||||
}
|
||||
}
|
||||
self.memory_attention.forward(current, current_pos, memory, memory_pos, pointers)
|
||||
}
|
||||
|
||||
pub fn open(path: &Path) -> Result<VideoModel> {
|
||||
VideoModel::load(&Weights::open(path)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the tracker keeps for a frame it has processed.
|
||||
struct FrameOut {
|
||||
memory: Memory,
|
||||
/// `[1, 256]`
|
||||
pointer: Tensor,
|
||||
/// The mask of a conditioning frame (returned again, unchanged, when propagation passes it).
|
||||
result: Option<FrameMask>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ObjectState {
|
||||
cond: BTreeMap<usize, FrameOut>,
|
||||
non_cond: BTreeMap<usize, FrameOut>,
|
||||
}
|
||||
|
||||
struct FrameFeatures {
|
||||
/// Lowest-resolution level `[1, 256, 64, 64]` and its position encoding.
|
||||
low: Tensor,
|
||||
pos: Tensor,
|
||||
high: (Tensor, Tensor),
|
||||
}
|
||||
|
||||
/// What a tracker may hold and take. A call that would exceed it stops with [`Error::Budget`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Budget {
|
||||
/// Memory banks, results and cached frame features (bytes).
|
||||
pub max_state_bytes: usize,
|
||||
/// Wall-clock seconds one `propagate` call may take (`None`: no limit).
|
||||
pub max_seconds: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for Budget {
|
||||
fn default() -> Budget {
|
||||
Budget { max_state_bytes: 2 << 30, max_seconds: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes of one stored frame: memory features and position encoding `[4096, 64]`, the pointer.
|
||||
const FRAME_BYTES: usize = 2 * GRID * GRID * 64 * 4 + 256 * 4;
|
||||
/// Bytes of a conditioning frame's kept result (`256 x 256` logits).
|
||||
const RESULT_BYTES: usize = 4 * GRID * 4 * GRID * 4;
|
||||
/// Bytes of one frame's cached neck features (lowest level and position, two skip levels).
|
||||
const FEATURE_BYTES: usize = (2 * 256 * GRID * GRID + 32 * 16 * GRID * GRID + 64 * 4 * GRID * GRID) * 4;
|
||||
|
||||
pub struct VideoTracker<'a> {
|
||||
model: Arc<VideoModel>,
|
||||
frames: Box<dyn FrameSource + 'a>,
|
||||
objects: BTreeMap<ObjectId, ObjectState>,
|
||||
cache: VecDeque<(usize, Arc<FrameFeatures>)>,
|
||||
budget: Budget,
|
||||
peak: usize,
|
||||
}
|
||||
|
||||
fn sine_embedding_1d(positions: &[f32], dim: usize) -> Result<Tensor> {
|
||||
let half = dim / 2;
|
||||
let dim_t: Vec<f32> = (0..half).map(|i| 10000f32.powf(2.0 * (i / 2) as f32 / half as f32)).collect();
|
||||
let mut out = Vec::with_capacity(positions.len() * dim);
|
||||
for p in positions {
|
||||
out.extend(dim_t.iter().map(|d| (p / d).sin()));
|
||||
out.extend(dim_t.iter().map(|d| (p / d).cos()));
|
||||
}
|
||||
Ok(Tensor::from_vec(out, (positions.len(), dim), &candle_core::Device::Cpu)?)
|
||||
}
|
||||
|
||||
fn scalar(t: &Tensor) -> Result<f32> {
|
||||
Ok(t.flatten_all()?.to_vec1::<f32>()?.first().copied().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
impl<'a> VideoTracker<'a> {
|
||||
pub fn new(model: Arc<VideoModel>, frames: Box<dyn FrameSource + 'a>) -> Result<VideoTracker<'a>> {
|
||||
let n = frames.num_frames();
|
||||
if n == 0 || n > MAX_FRAMES {
|
||||
return Err(Error::Input(format!("a clip needs between 1 and {MAX_FRAMES} frames, got {n}")));
|
||||
}
|
||||
Ok(VideoTracker { model, frames, objects: BTreeMap::new(), cache: VecDeque::new(), budget: Budget::default(), peak: 0 })
|
||||
}
|
||||
|
||||
pub fn num_frames(&self) -> usize {
|
||||
self.frames.num_frames()
|
||||
}
|
||||
|
||||
/// Replace the limits (see [`Budget`]).
|
||||
pub fn set_budget(&mut self, budget: Budget) {
|
||||
self.budget = budget;
|
||||
}
|
||||
|
||||
/// Bytes held now: stored frames, kept results and cached features.
|
||||
pub fn state_bytes(&self) -> usize {
|
||||
let frames: usize = self.objects.values().map(|o| (o.cond.len() + o.non_cond.len()) * FRAME_BYTES + o.cond.len() * RESULT_BYTES).sum();
|
||||
frames + self.cache.len() * FEATURE_BYTES
|
||||
}
|
||||
|
||||
/// The most [`VideoTracker::state_bytes`] reached so far.
|
||||
pub fn peak_state_bytes(&self) -> usize {
|
||||
self.peak
|
||||
}
|
||||
|
||||
fn check_budget(&mut self) -> Result<()> {
|
||||
let used = self.state_bytes();
|
||||
self.peak = self.peak.max(used);
|
||||
if used > self.budget.max_state_bytes {
|
||||
return Err(Error::Budget(format!("the tracker holds {} MB, over its {} MB limit", used >> 20, self.budget.max_state_bytes >> 20)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget every object and cached frame (the clip stays).
|
||||
pub fn reset(&mut self) {
|
||||
self.objects.clear();
|
||||
self.cache.clear();
|
||||
}
|
||||
|
||||
fn features(&mut self, frame: usize) -> Result<Arc<FrameFeatures>> {
|
||||
if let Some((_, f)) = self.cache.iter().find(|(i, _)| *i == frame) {
|
||||
return Ok(f.clone());
|
||||
}
|
||||
let pixels = self.frames.frame(frame)?;
|
||||
let enc = self.model.encode(&pixels)?;
|
||||
let (low, pos) = match (enc.features.features.get(2), enc.features.positions.get(2)) {
|
||||
(Some(l), Some(p)) => (l.clone(), p.clone()),
|
||||
_ => return Err(Error::Compute("vision encoder returned too few levels".into())),
|
||||
};
|
||||
let f = Arc::new(FrameFeatures { low, pos, high: enc.high });
|
||||
self.cache.push_back((frame, f.clone()));
|
||||
while self.cache.len() > 2 {
|
||||
self.cache.pop_front();
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// The memory bank for `frame`: memory frames and object pointers with their position
|
||||
/// encodings, plus how many trailing tokens are pointers.
|
||||
fn memory_bank(&self, obj: &ObjectState, frame: usize, reverse: bool) -> Result<(Tensor, Tensor, usize)> {
|
||||
let (mut feats, mut poss) = (vec![], vec![]);
|
||||
let temporal = |off: usize| -> Result<Tensor> {
|
||||
// The reference indexes with `offset - 1`, so the conditioning frames (offset 0) use the last row.
|
||||
let row = if off == 0 { MEMORY_FRAMES - 1 } else { off - 1 };
|
||||
let v = self.model.temporal_pos.get(row * 64..(row + 1) * 64).ok_or_else(|| Error::Weights("temporal position table".into()))?;
|
||||
Ok(Tensor::from_slice(v, (1, 64), &candle_core::Device::Cpu)?)
|
||||
};
|
||||
for out in obj.cond.values() {
|
||||
feats.push(out.memory.features.clone());
|
||||
poss.push(out.memory.pos.broadcast_add(&temporal(0)?)?);
|
||||
}
|
||||
for off in (1..MEMORY_FRAMES).rev() {
|
||||
let prev = if reverse { frame.checked_add(off) } else { frame.checked_sub(off) };
|
||||
if let Some(out) = prev.and_then(|p| obj.non_cond.get(&p)) {
|
||||
feats.push(out.memory.features.clone());
|
||||
poss.push(out.memory.pos.broadcast_add(&temporal(off)?)?);
|
||||
}
|
||||
}
|
||||
// Object pointers: conditioning frames on the already-tracked side, then recent frames.
|
||||
let n_frames = self.frames.num_frames();
|
||||
let max_ptrs = n_frames.min(MAX_POINTERS);
|
||||
let (mut offsets, mut pointers): (Vec<f32>, Vec<Tensor>) = (vec![], vec![]);
|
||||
let sign = if reverse { -1.0f32 } else { 1.0 };
|
||||
for (t, out) in &obj.cond {
|
||||
if if reverse { *t >= frame } else { *t <= frame } {
|
||||
offsets.push((frame as f32 - *t as f32) * sign);
|
||||
pointers.push(out.pointer.clone());
|
||||
}
|
||||
}
|
||||
for off in 1..max_ptrs {
|
||||
let r = if reverse { frame.checked_add(off) } else { frame.checked_sub(off) };
|
||||
let Some(r) = r.filter(|r| *r < n_frames) else { break };
|
||||
if let Some(out) = obj.non_cond.get(&r) {
|
||||
offsets.push(off as f32);
|
||||
pointers.push(out.pointer.clone());
|
||||
}
|
||||
}
|
||||
let mut n_pointer_tokens = 0;
|
||||
if !pointers.is_empty() {
|
||||
let max_diff = (max_ptrs.saturating_sub(1)).max(1) as f32;
|
||||
let norm: Vec<f32> = offsets.iter().map(|o| o / max_diff).collect();
|
||||
let pe = apply(&self.model.pointer_time_proj, &sine_embedding_1d(&norm, 256)?)?;
|
||||
let ptrs = Tensor::cat(&pointers, 0)?; // [P, 256]
|
||||
let p = ptrs.dim(0)?;
|
||||
let tokens = ptrs.reshape((p * 4, 64))?;
|
||||
let pe = pe.unsqueeze(1)?.broadcast_as((p, 4, 64))?.reshape((p * 4, 64))?;
|
||||
n_pointer_tokens = p * 4;
|
||||
feats.push(tokens);
|
||||
poss.push(pe.contiguous()?);
|
||||
}
|
||||
if feats.is_empty() {
|
||||
return Err(Error::Input("the object has no conditioning frame; add a prompt first".into()));
|
||||
}
|
||||
Ok((Tensor::cat(&feats, 0)?, Tensor::cat(&poss, 0)?, n_pointer_tokens))
|
||||
}
|
||||
|
||||
/// Run the model on one frame for one object and store the result.
|
||||
fn step(&mut self, id: ObjectId, frame: usize, prompt: Option<&Prompt>, reverse: bool, conditioning: bool) -> Result<FrameMask> {
|
||||
// EFFECTCRAFT_SAM2_PROFILE=1 prints where a frame's time goes (stderr).
|
||||
let profile = std::env::var_os("EFFECTCRAFT_SAM2_PROFILE").is_some();
|
||||
let mut lap = std::time::Instant::now();
|
||||
let mut mark = |what: &str| {
|
||||
if profile {
|
||||
eprintln!("sam2 frame {frame}: {what} {:.0} ms", lap.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
lap = std::time::Instant::now();
|
||||
};
|
||||
let feats = self.features(frame)?;
|
||||
mark("encode");
|
||||
let model = self.model.clone();
|
||||
let tokens = |t: &Tensor| -> Result<Tensor> { Ok(t.flatten_from(2)?.squeeze(0)?.t()?.contiguous()?) };
|
||||
let conditioned = if conditioning {
|
||||
feats.low.broadcast_add(model.image.no_memory())?
|
||||
} else {
|
||||
let obj = self.objects.get(&id).ok_or_else(|| Error::Input("unknown object".into()))?;
|
||||
let (mem, mem_pos, n_ptr) = self.memory_bank(obj, frame, reverse)?;
|
||||
let out = model.attend(&tokens(&feats.low)?, &tokens(&feats.pos)?, &mem, &mem_pos, n_ptr)?;
|
||||
out.t()?.contiguous()?.reshape((1, 256, GRID, GRID))?
|
||||
};
|
||||
mark("memory attention");
|
||||
let num_points = prompt.map_or(0, |p| p.points.len() + 2 * usize::from(p.bbox.is_some()));
|
||||
let multimask = num_points <= 1;
|
||||
let sparse = match prompt {
|
||||
Some(p) => model.image.prompt.sparse(p)?,
|
||||
None => model.image.prompt.sparse_none()?,
|
||||
};
|
||||
let dense = model.image.prompt.dense_empty()?;
|
||||
let out = model.image.decode_embedded(&conditioned, &feats.high, &sparse, &dense, multimask)?;
|
||||
|
||||
mark("decoder");
|
||||
let score = scalar(&out.object_score)?;
|
||||
let present = score > 0.0;
|
||||
let ious = out.iou.flatten_all()?.to_vec1::<f32>()?;
|
||||
let best = if multimask { ious.iter().enumerate().fold((0usize, f32::NEG_INFINITY), |b, (i, v)| if *v > b.1 { (i, *v) } else { b }).0 } else { 0 };
|
||||
let low = out.masks.narrow(1, best, 1)?;
|
||||
let low = if present { low } else { Tensor::full(NO_OBJECT, low.dims(), low.device())? };
|
||||
let token = if multimask { out.sam_tokens.narrow(1, best, 1)? } else { out.sam_tokens.narrow(1, 0, 1)? }.squeeze(1)?;
|
||||
let pointer = if present { model.pointer_proj.forward(&token)? } else { model.no_object_pointer.clone() };
|
||||
|
||||
let high = low.upsample_bilinear2d(INPUT_SIZE, INPUT_SIZE, false)?;
|
||||
let for_memory = if prompt.is_some() { high.gt(0f32)?.to_dtype(DType::F32)? } else { sigmoid(&high)? };
|
||||
let for_memory = ((for_memory * 20.0)? - 10.0)?;
|
||||
mark("upsample and mask");
|
||||
let memory = model.memorize(&feats.low, &for_memory, present)?;
|
||||
mark("memory encoder");
|
||||
|
||||
let iou = ious.get(best).copied().unwrap_or(0.0);
|
||||
let result = FrameMask { frame, object: id, logits: low.flatten_all()?.to_vec1::<f32>()?, iou, object_score: score, occluded: !present };
|
||||
let state = self.objects.entry(id).or_default();
|
||||
let entry = FrameOut { memory, pointer, result: conditioning.then(|| result.clone()) };
|
||||
if conditioning {
|
||||
state.non_cond.remove(&frame);
|
||||
state.cond.insert(frame, entry);
|
||||
} else {
|
||||
state.non_cond.insert(frame, entry);
|
||||
Self::prune(state, frame);
|
||||
}
|
||||
self.check_budget()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Drop memories that no later frame in either direction can still look at.
|
||||
fn prune(state: &mut ObjectState, frame: usize) {
|
||||
let keep = |f: usize, cond: &BTreeMap<usize, FrameOut>| f.abs_diff(frame) <= MAX_POINTERS || cond.keys().any(|c| f.abs_diff(*c) <= MEMORY_FRAMES);
|
||||
let doomed: Vec<usize> = state.non_cond.keys().copied().filter(|f| !keep(*f, &state.cond)).collect();
|
||||
for f in doomed {
|
||||
state.non_cond.remove(&f);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_frame(&self, frame: usize) -> Result<()> {
|
||||
if frame >= self.frames.num_frames() {
|
||||
return Err(Error::Input(format!("frame {frame} is outside the clip of {} frames", self.frames.num_frames())));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prompt an object on a frame (clicks and/or a box). The frame becomes one of the object's
|
||||
/// conditioning frames when it has not been tracked yet; the mask comes back at once.
|
||||
pub fn prompt(&mut self, frame: usize, object: ObjectId, prompt: &Prompt) -> Result<FrameMask> {
|
||||
self.check_frame(frame)?;
|
||||
prompt.validate()?;
|
||||
if prompt.points.is_empty() && prompt.bbox.is_none() {
|
||||
return Err(Error::Input("a prompt needs at least one point or a box".into()));
|
||||
}
|
||||
let tracked = self.objects.get(&object).is_some_and(|o| o.non_cond.contains_key(&frame));
|
||||
if tracked {
|
||||
// A correction on a tracked frame is conditioned on the memory like any tracked frame.
|
||||
let reverse = false;
|
||||
return self.step(object, frame, Some(prompt), reverse, false);
|
||||
}
|
||||
self.objects.entry(object).or_default();
|
||||
self.step(object, frame, Some(prompt), false, true)
|
||||
}
|
||||
|
||||
/// Prompt an object on a frame with a mask: `[1, 1, 1024, 1024]` of 0 and 1 (see
|
||||
/// [`crate::resize::mask_to_model`]). The mask becomes the frame's output as it is (no decode of
|
||||
/// the mask itself); the decoder only makes the object pointer. Like a click prompt, the frame is a
|
||||
/// conditioning frame unless it has been tracked already.
|
||||
pub fn prompt_mask(&mut self, frame: usize, object: ObjectId, mask: &Tensor) -> Result<FrameMask> {
|
||||
self.check_frame(frame)?;
|
||||
if mask.dims() != [1, 1, INPUT_SIZE, INPUT_SIZE] {
|
||||
return Err(Error::Input(format!("a mask prompt must be [1, 1, {INPUT_SIZE}, {INPUT_SIZE}], got {:?}", mask.dims())));
|
||||
}
|
||||
let values = mask.flatten_all()?.to_vec1::<f32>()?;
|
||||
if values.iter().any(|v| !v.is_finite()) {
|
||||
return Err(Error::Input("a mask prompt must be finite".into()));
|
||||
}
|
||||
let tracked = self.objects.get(&object).is_some_and(|o| o.non_cond.contains_key(&frame));
|
||||
self.objects.entry(object).or_default();
|
||||
self.step_mask(object, frame, mask, &values, !tracked)
|
||||
}
|
||||
|
||||
/// The reference's `_use_mask_as_output`: the mask is the output, the decoder makes the pointer.
|
||||
fn step_mask(&mut self, id: ObjectId, frame: usize, mask: &Tensor, values: &[f32], conditioning: bool) -> Result<FrameMask> {
|
||||
let feats = self.features(frame)?;
|
||||
let model = self.model.clone();
|
||||
let present = values.iter().any(|v| *v > 0.0);
|
||||
let high = ((mask * 20.0)? - 10.0)?;
|
||||
let low = antialias_bilinear(&high.flatten_all()?.to_vec1::<f32>()?, (INPUT_SIZE, INPUT_SIZE), (4 * GRID, 4 * GRID))?;
|
||||
// The pointer: the decoder with the mask as its dense prompt and no clicks, on the frame's features
|
||||
// without the no-memory embedding.
|
||||
let down = mask.conv2d(&model.mask_down.0, 0, 4, 1, 1)?.broadcast_add(&model.mask_down.1.reshape((1, 1, 1, 1))?)?;
|
||||
let dense = model.image.prompt.dense_mask(&down)?;
|
||||
let out = model.image.decode_embedded(&feats.low, &feats.high, &model.image.prompt.sparse_none()?, &dense, true)?;
|
||||
let ious = out.iou.flatten_all()?.to_vec1::<f32>()?;
|
||||
let best = ious.iter().enumerate().fold((0usize, f32::NEG_INFINITY), |b, (i, v)| if *v > b.1 { (i, *v) } else { b }).0;
|
||||
let token = out.sam_tokens.narrow(1, best, 1)?.squeeze(1)?;
|
||||
let decoder_present = scalar(&out.object_score)? > 0.0;
|
||||
let pointer = if decoder_present { model.pointer_proj.forward(&token)? } else { model.no_object_pointer.clone() };
|
||||
let pointer = if present { pointer } else { model.no_object_pointer.clone() };
|
||||
let memory = model.memorize(&feats.low, &high, present)?;
|
||||
let score = if present { 10.0 } else { -10.0 };
|
||||
let result = FrameMask { frame, object: id, logits: low, iou: 1.0, object_score: score, occluded: !present };
|
||||
let state = self.objects.entry(id).or_default();
|
||||
let entry = FrameOut { memory, pointer, result: conditioning.then(|| result.clone()) };
|
||||
if conditioning {
|
||||
state.non_cond.remove(&frame);
|
||||
state.cond.insert(frame, entry);
|
||||
} else {
|
||||
state.non_cond.insert(frame, entry);
|
||||
Self::prune(state, frame);
|
||||
}
|
||||
self.check_budget()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Track every object from `start` through `max_frames` frames (the rest of the clip when
|
||||
/// `None`) in `direction`, calling `out` for each object on each frame as it is finished.
|
||||
pub fn propagate(&mut self, start: usize, direction: Direction, max_frames: Option<usize>, cancel: &Cancel, out: &mut dyn FnMut(FrameMask)) -> Result<()> {
|
||||
self.propagate_objects(None, start, direction, max_frames, cancel, out)
|
||||
}
|
||||
|
||||
/// [`VideoTracker::propagate`] for the listed objects only (`None`: every prompted object).
|
||||
pub fn propagate_objects(
|
||||
&mut self,
|
||||
only: Option<&[ObjectId]>,
|
||||
start: usize,
|
||||
direction: Direction,
|
||||
max_frames: Option<usize>,
|
||||
cancel: &Cancel,
|
||||
out: &mut dyn FnMut(FrameMask),
|
||||
) -> Result<()> {
|
||||
self.check_frame(start)?;
|
||||
let n = self.frames.num_frames();
|
||||
let reverse = direction == Direction::Backward;
|
||||
let span = max_frames.unwrap_or(n).min(n);
|
||||
let ids: Vec<ObjectId> = self.objects.iter().filter(|(id, o)| !o.cond.is_empty() && only.is_none_or(|l| l.contains(id))).map(|(id, _)| *id).collect();
|
||||
if ids.is_empty() {
|
||||
return Err(Error::Input("no object has been prompted".into()));
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
for k in 0..=span {
|
||||
let frame = if reverse { start.checked_sub(k) } else { Some(start + k).filter(|f| *f < n) };
|
||||
let Some(frame) = frame else { break };
|
||||
for id in &ids {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
if let Some(limit) = self.budget.max_seconds.filter(|l| started.elapsed().as_secs_f64() > *l) {
|
||||
return Err(Error::Budget(format!("tracking took more than {limit} s")));
|
||||
}
|
||||
let mask = self.mask_at(*id, frame, reverse)?;
|
||||
out(mask);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Object `id` on `frame`: the result stored with its prompt there, else the tracker's step.
|
||||
fn mask_at(&mut self, id: ObjectId, frame: usize, reverse: bool) -> Result<FrameMask> {
|
||||
let stored = self.objects.get(&id).and_then(|o| o.cond.get(&frame)).and_then(|o| o.result.clone());
|
||||
match stored {
|
||||
Some(m) => Ok(m),
|
||||
None => self.step(id, frame, None, reverse, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Track every listed object from its own prompted frame (`starts`: object, frame) in one pass
|
||||
/// in `direction`, frame by frame with all the objects that have started: a frame is finished
|
||||
/// for all of them before the next begins, so a caller that assembles the objects of a frame
|
||||
/// does not hold frames waiting for a pass that has not got there. A backward pass leaves out
|
||||
/// each object's own start frame (the forward pass reported it).
|
||||
pub fn propagate_each(&mut self, starts: &[(ObjectId, usize)], direction: Direction, cancel: &Cancel, out: &mut dyn FnMut(FrameMask)) -> Result<()> {
|
||||
let n = self.frames.num_frames();
|
||||
for (id, start) in starts {
|
||||
self.check_frame(*start)?;
|
||||
if self.objects.get(id).is_none_or(|o| o.cond.is_empty()) {
|
||||
return Err(Error::Input("no object has been prompted".into()));
|
||||
}
|
||||
}
|
||||
if starts.is_empty() {
|
||||
return Err(Error::Input("no object has been prompted".into()));
|
||||
}
|
||||
let reverse = direction == Direction::Backward;
|
||||
let started = std::time::Instant::now();
|
||||
for (id, frame) in pass_order(starts, n, reverse) {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
if let Some(limit) = self.budget.max_seconds.filter(|l| started.elapsed().as_secs_f64() > *l) {
|
||||
return Err(Error::Budget(format!("tracking took more than {limit} s")));
|
||||
}
|
||||
let mask = self.mask_at(id, frame, reverse)?;
|
||||
out(mask);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The (object, frame) steps of [`VideoTracker::propagate_each`], in order: frame by frame from the
|
||||
/// first start (the last, backwards), each frame for the objects whose pass has reached it.
|
||||
fn pass_order(starts: &[(ObjectId, usize)], frames: usize, reverse: bool) -> impl Iterator<Item = (ObjectId, usize)> + '_ {
|
||||
let first = if reverse { starts.iter().map(|s| s.1).max() } else { starts.iter().map(|s| s.1).min() };
|
||||
let span = match first {
|
||||
Some(f) if reverse => f + 1,
|
||||
Some(f) => frames.saturating_sub(f),
|
||||
None => 0,
|
||||
};
|
||||
(0..span).flat_map(move |k| {
|
||||
let frame = if reverse { first.unwrap_or(0).saturating_sub(k) } else { first.unwrap_or(0) + k };
|
||||
starts.iter().filter_map(move |(id, start)| {
|
||||
let reached = if reverse { *start > frame } else { *start <= frame };
|
||||
reached.then_some((*id, frame))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn order(starts: &[(u32, usize)], frames: usize, reverse: bool) -> Vec<(u32, usize)> {
|
||||
let starts: Vec<(ObjectId, usize)> = starts.iter().map(|(o, s)| (ObjectId(*o), *s)).collect();
|
||||
pass_order(&starts, frames, reverse).map(|(o, f)| (o.0, f)).collect()
|
||||
}
|
||||
|
||||
/// Forward, every frame is finished for all the objects that have started before the next frame
|
||||
/// begins; an object joins the pass at its own start frame.
|
||||
#[test]
|
||||
fn the_forward_pass_goes_frame_by_frame_over_the_objects_that_have_started() {
|
||||
assert_eq!(order(&[(1, 0), (2, 3)], 6, false), [(1, 0), (1, 1), (1, 2), (1, 3), (2, 3), (1, 4), (2, 4), (1, 5), (2, 5)]);
|
||||
// Prompts on the same frame go together; the pass starts at the earliest start.
|
||||
assert_eq!(order(&[(1, 2), (2, 2)], 4, false), [(1, 2), (2, 2), (1, 3), (2, 3)]);
|
||||
assert_eq!(order(&[(1, 2)], 4, false), [(1, 2), (1, 3)]);
|
||||
}
|
||||
|
||||
/// Backward, from the last start down to frame 0, leaving out each object's own start frame.
|
||||
#[test]
|
||||
fn the_backward_pass_goes_frame_by_frame_and_skips_each_objects_own_start() {
|
||||
assert_eq!(order(&[(1, 0), (2, 3)], 6, true), [(2, 2), (2, 1), (2, 0)]);
|
||||
assert_eq!(order(&[(1, 2), (2, 3)], 6, true), [(2, 2), (1, 1), (2, 1), (1, 0), (2, 0)]);
|
||||
assert!(order(&[(1, 0)], 6, true).is_empty(), "nothing before frame 0");
|
||||
}
|
||||
|
||||
/// A frame is finished for every object before the next begins, however far apart the prompts
|
||||
/// are: the number of frames waiting for an object never grows with the clip.
|
||||
#[test]
|
||||
fn no_frame_waits_for_an_object_that_has_not_reached_it() {
|
||||
let steps = order(&[(1, 0), (2, 1000)], 2000, false);
|
||||
let mut waiting = 0usize;
|
||||
let mut worst = 0usize;
|
||||
let mut seen = std::collections::BTreeMap::<usize, usize>::new();
|
||||
for (_, f) in steps {
|
||||
let n = seen.entry(f).or_default();
|
||||
*n += 1;
|
||||
// From frame 1000 on, both objects report each frame back to back.
|
||||
if f >= 1000 && *n == 2 {
|
||||
waiting -= 1;
|
||||
} else if f >= 1000 {
|
||||
waiting += 1;
|
||||
}
|
||||
worst = worst.max(waiting);
|
||||
}
|
||||
assert!(worst <= 1, "{worst} frames waiting");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// Modified work: adapts the ported SAM 2 video tracker to EffectCraft's `ObjectTracker`
|
||||
// interface (Apache-2.0, see NOTICE).
|
||||
//! SAM 2 as the Roto Brush's object tracker: prompts on some frames, the whole clip tracked
|
||||
//! forwards and backwards, each frame reported as it is finished.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use candle_core::Tensor;
|
||||
use effectcraft_segment::{ClipFrame, ObjectTracker, TrackRequest, TrackedFrame};
|
||||
|
||||
use crate::mask::{Sam2Mask, preprocess, validate_frame_size};
|
||||
use crate::prompt::{INPUT_SIZE, Prompt};
|
||||
use crate::resize::mask_to_model;
|
||||
use crate::session::{Cancel, Direction, FrameMask, FrameSource, ObjectId, VideoTracker};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Most frames one tracking request may cover.
|
||||
const MAX_CLIP: usize = 100_000;
|
||||
|
||||
type Frames<'a> = &'a mut dyn FnMut(i64) -> Option<Arc<ClipFrame>>;
|
||||
|
||||
/// Feeds the host's frames to the tracker, remembering each frame's size for the output.
|
||||
struct Source<'a> {
|
||||
first: i64,
|
||||
count: usize,
|
||||
fetch: RefCell<Frames<'a>>,
|
||||
sizes: Rc<RefCell<HashMap<usize, (usize, usize)>>>,
|
||||
}
|
||||
|
||||
impl FrameSource for Source<'_> {
|
||||
fn num_frames(&self) -> usize {
|
||||
self.count
|
||||
}
|
||||
|
||||
fn frame(&self, index: usize) -> Result<Tensor> {
|
||||
let number = i64::try_from(index)
|
||||
.ok()
|
||||
.filter(|_| index < self.count)
|
||||
.and_then(|offset| self.first.checked_add(offset))
|
||||
.ok_or_else(|| Error::Input(format!("frame index {index} is outside the clip")))?;
|
||||
let f = (self.fetch.borrow_mut())(number).ok_or_else(|| Error::Input(format!("frame {number} is not available")))?;
|
||||
self.sizes.borrow_mut().insert(index, (f.w, f.h));
|
||||
preprocess(&f.rgb, f.w, f.h)
|
||||
}
|
||||
}
|
||||
|
||||
fn to_frame(m: &FrameMask, size: (usize, usize)) -> Result<TrackedFrame> {
|
||||
let (w, h) = size;
|
||||
let logits = Tensor::from_slice(&m.logits, (1, 1, 256, 256), &candle_core::Device::Cpu)?;
|
||||
let prob = candle_nn::ops::sigmoid(&logits.upsample_bilinear2d(h, w, false)?)?.flatten_all()?.to_vec1::<f32>()?;
|
||||
let presence = 1.0 / (1.0 + (-m.object_score).exp());
|
||||
Ok(TrackedFrame { prob, w, h, confidence: (presence * m.iou.clamp(0.0, 1.0)).clamp(0.0, 1.0), occluded: m.occluded })
|
||||
}
|
||||
|
||||
/// Most objects one request may track.
|
||||
const MAX_OBJECTS: usize = 32;
|
||||
|
||||
impl Sam2Mask {
|
||||
fn run_track(&self, req: &TrackRequest, frames: Frames<'_>, cancel: &dyn Fn() -> bool, out: &mut dyn FnMut(usize, i64, TrackedFrame)) -> Result<()> {
|
||||
if req.count == 0 || req.count > MAX_CLIP {
|
||||
return Err(Error::Input(format!("a tracking request needs 1 to {MAX_CLIP} frames, got {}", req.count)));
|
||||
}
|
||||
req.first.checked_add((req.count - 1) as i64).ok_or_else(|| Error::Input("the clip's last frame overflows its frame number".into()))?;
|
||||
if req.objects.is_empty() || req.objects.len() > MAX_OBJECTS || req.objects.iter().any(|o| o.is_empty()) {
|
||||
return Err(Error::Input(format!("tracking needs 1 to {MAX_OBJECTS} objects, each with a prompt on at least one frame")));
|
||||
}
|
||||
let sizes = Rc::new(RefCell::new(HashMap::new()));
|
||||
let source = Source { first: req.first, count: req.count, fetch: RefCell::new(frames), sizes: sizes.clone() };
|
||||
// Prompt coordinates are in the frame's pixels and the model works at 1024 x 1024, so the
|
||||
// prompted frames' sizes are needed first.
|
||||
let mut prompt_frames = vec![];
|
||||
for (oi, prompts) in req.objects.iter().enumerate() {
|
||||
for (frame, p) in prompts {
|
||||
let idx = frame
|
||||
.checked_sub(req.first)
|
||||
.and_then(|offset| usize::try_from(offset).ok())
|
||||
.filter(|i| *i < req.count)
|
||||
.ok_or_else(|| Error::Input(format!("prompt frame {frame} is outside the clip")))?;
|
||||
if !sizes.borrow().contains_key(&idx) {
|
||||
let f = (source.fetch.borrow_mut())(*frame).ok_or_else(|| Error::Input(format!("frame {frame} is not available")))?;
|
||||
validate_frame_size(f.w, f.h, f.rgb.len())?;
|
||||
sizes.borrow_mut().insert(idx, (f.w, f.h));
|
||||
}
|
||||
prompt_frames.push((oi, idx, p));
|
||||
}
|
||||
}
|
||||
let mut tracker = VideoTracker::new(self.video_model().clone(), Box::new(source))?;
|
||||
let object_id = |oi: usize| ObjectId(oi as u32 + 1);
|
||||
// each object's first prompted frame: its pass starts there, forwards and backwards
|
||||
let mut first_of: HashMap<usize, usize> = HashMap::new();
|
||||
for (oi, idx, p) in prompt_frames {
|
||||
if cancel() {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
let (w, h) = sizes.borrow().get(&idx).copied().ok_or_else(|| Error::Compute("frame size unknown".into()))?;
|
||||
// A prompt that is only a mask (probability per pixel of the frame) prompts with the mask.
|
||||
if let (true, true, Some(m)) = (p.points.is_empty(), p.bbox.is_none(), p.mask.as_ref()) {
|
||||
if m.len() != w * h {
|
||||
return Err(Error::Input(format!("a mask prompt of {} values does not fit a {w}x{h} frame", m.len())));
|
||||
}
|
||||
let binary: Vec<u8> = m.iter().map(|v| u8::from(*v >= 0.5)).collect();
|
||||
tracker.prompt_mask(idx, object_id(oi), &mask_to_model(&binary, w, h)?)?;
|
||||
let e = first_of.entry(oi).or_insert(idx);
|
||||
*e = (*e).min(idx);
|
||||
continue;
|
||||
}
|
||||
let (sx, sy) = (INPUT_SIZE as f32 / w as f32, INPUT_SIZE as f32 / h as f32);
|
||||
let prompt = Prompt {
|
||||
points: p.points.iter().map(|(c, fg)| ([c[0] * sx, c[1] * sy], *fg)).collect(),
|
||||
bbox: p.bbox.map(|b| [b[0] * sx, b[1] * sy, b[2] * sx, b[3] * sy]),
|
||||
};
|
||||
tracker.prompt(idx, object_id(oi), &prompt)?;
|
||||
let e = first_of.entry(oi).or_insert(idx);
|
||||
*e = (*e).min(idx);
|
||||
}
|
||||
let mut starts: Vec<(ObjectId, usize)> = first_of.into_iter().map(|(oi, idx)| (object_id(oi), idx)).collect();
|
||||
starts.sort_by_key(|(id, _)| id.0);
|
||||
let flag = Cancel::new();
|
||||
let mut failure: Option<Error> = None;
|
||||
let mut emit = |m: FrameMask| {
|
||||
if cancel() {
|
||||
flag.cancel();
|
||||
}
|
||||
let oi = (m.object.0 as usize).saturating_sub(1);
|
||||
match to_frame(&m, sizes.borrow().get(&m.frame).copied().unwrap_or((0, 0))) {
|
||||
Ok(t) => out(oi, req.first + m.frame as i64, t),
|
||||
Err(e) => {
|
||||
failure.get_or_insert(e);
|
||||
flag.cancel();
|
||||
}
|
||||
}
|
||||
};
|
||||
// One pass each way over all the objects, frame by frame, so the host can finish a frame (and
|
||||
// let go of its masks) as soon as every object has it, however far apart the prompts are.
|
||||
let passes = (|| {
|
||||
tracker.propagate_each(&starts, Direction::Forward, &flag, &mut emit)?;
|
||||
if starts.iter().any(|(_, start)| *start > 0) {
|
||||
tracker.propagate_each(&starts, Direction::Backward, &flag, &mut emit)?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
self.peak.store(tracker.peak_state_bytes() as u64, std::sync::atomic::Ordering::Relaxed);
|
||||
// A frame that could not be converted cancelled the passes; that is the error to report, not
|
||||
// the cancellation it caused.
|
||||
match failure {
|
||||
Some(e) => Err(e),
|
||||
None => passes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectTracker for Sam2Mask {
|
||||
fn track(
|
||||
&self,
|
||||
req: &TrackRequest,
|
||||
frames: &mut dyn FnMut(i64) -> Option<Arc<ClipFrame>>,
|
||||
cancel: &dyn Fn() -> bool,
|
||||
out: &mut dyn FnMut(usize, i64, TrackedFrame),
|
||||
) -> effectcraft_segment::Result<()> {
|
||||
self.touch();
|
||||
let r = self.run_track(req, frames, cancel, out).map_err(|e| e.to_string());
|
||||
self.touch();
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn source_rejects_overflowing_and_out_of_range_indices_before_fetching() {
|
||||
let mut fetch = |_| panic!("invalid indices must not fetch a frame");
|
||||
let source = Source { first: i64::MAX, count: 2, fetch: RefCell::new(&mut fetch), sizes: Default::default() };
|
||||
assert!(matches!(source.frame(1), Err(Error::Input(_))));
|
||||
assert!(matches!(source.frame(usize::MAX), Err(Error::Input(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Modified work: loads the safetensors checkpoint of Hugging Face `transformers` SAM 2
|
||||
// (Apache-2.0, Copyright The HuggingFace Inc. team). See NOTICE.
|
||||
//! Checkpoint loading with validation: every tensor is f32 and finite, every lookup checks its
|
||||
//! shape, so a truncated, mislabelled or poisoned file is an error, not a crash or garbage.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use candle_core::{DType, Device, Tensor};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Named tensors of one checkpoint.
|
||||
pub struct Weights {
|
||||
map: HashMap<String, Tensor>,
|
||||
}
|
||||
|
||||
impl Weights {
|
||||
/// Read a `.safetensors` file; rejects non-finite values and unsupported dtypes.
|
||||
pub fn open(path: &Path) -> Result<Weights> {
|
||||
let map = candle_core::safetensors::load(path, &Device::Cpu).map_err(|e| Error::Weights(format!("{}: {e}", path.display())))?;
|
||||
Weights::from_map(map)
|
||||
}
|
||||
|
||||
/// Read a checkpoint held in memory (the installed file's bytes, already verified by the caller).
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Weights> {
|
||||
let map = candle_core::safetensors::load_buffer(bytes, &Device::Cpu).map_err(|e| Error::Weights(e.to_string()))?;
|
||||
Weights::from_map(map)
|
||||
}
|
||||
|
||||
/// Validate an already-loaded set of tensors.
|
||||
pub fn from_map(map: HashMap<String, Tensor>) -> Result<Weights> {
|
||||
let mut out = HashMap::with_capacity(map.len());
|
||||
for (name, t) in map {
|
||||
let t = match t.dtype() {
|
||||
DType::F32 => t,
|
||||
DType::F16 | DType::BF16 | DType::F64 => t.to_dtype(DType::F32)?,
|
||||
other => return Err(Error::Weights(format!("`{name}` has unsupported dtype {other:?}"))),
|
||||
};
|
||||
let flat = t.flatten_all()?.to_vec1::<f32>()?;
|
||||
if flat.iter().any(|v| !v.is_finite()) {
|
||||
return Err(Error::Weights(format!("`{name}` contains NaN or infinity")));
|
||||
}
|
||||
out.insert(name, t);
|
||||
}
|
||||
Ok(Weights { map: out })
|
||||
}
|
||||
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.map.contains_key(name)
|
||||
}
|
||||
|
||||
/// The shape of a tensor, if the checkpoint has it.
|
||||
pub fn shape(&self, name: &str) -> Option<Vec<usize>> {
|
||||
self.map.get(name).map(|t| t.dims().to_vec())
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.map.is_empty()
|
||||
}
|
||||
|
||||
/// The tensor `name`, which must have exactly `shape`.
|
||||
pub fn get(&self, name: &str, shape: &[usize]) -> Result<Tensor> {
|
||||
let t = self.map.get(name).ok_or_else(|| Error::Weights(format!("missing tensor `{name}`")))?;
|
||||
if t.dims() != shape {
|
||||
return Err(Error::Weights(format!("`{name}` has shape {:?}, expected {shape:?}", t.dims())));
|
||||
}
|
||||
Ok(t.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn one(name: &str, t: Tensor) -> HashMap<String, Tensor> {
|
||||
HashMap::from([(name.to_string(), t)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_wrong_shape_and_non_finite() {
|
||||
let ok = Tensor::zeros((2, 3), DType::F32, &Device::Cpu).unwrap();
|
||||
let w = Weights::from_map(one("a", ok)).unwrap();
|
||||
assert!(w.get("a", &[2, 3]).is_ok());
|
||||
assert!(matches!(w.get("a", &[3, 2]), Err(Error::Weights(_))));
|
||||
assert!(matches!(w.get("b", &[1]), Err(Error::Weights(_))));
|
||||
let bad = Tensor::new(&[1.0f32, f32::NAN], &Device::Cpu).unwrap();
|
||||
assert!(matches!(Weights::from_map(one("n", bad)), Err(Error::Weights(_))));
|
||||
let inf = Tensor::new(&[f32::INFINITY], &Device::Cpu).unwrap();
|
||||
assert!(Weights::from_map(one("i", inf)).is_err());
|
||||
let ints = Tensor::new(&[1u32], &Device::Cpu).unwrap();
|
||||
assert!(Weights::from_map(one("u", ints)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_file_is_an_error() {
|
||||
let dir = std::env::temp_dir().join(format!("sam2-weights-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join("w.safetensors");
|
||||
let t = Tensor::zeros((4, 4), DType::F32, &Device::Cpu).unwrap();
|
||||
candle_core::safetensors::save(&one("x", t), &p).unwrap();
|
||||
let bytes = std::fs::read(&p).unwrap();
|
||||
std::fs::write(&p, &bytes[..bytes.len() - 8]).unwrap();
|
||||
assert!(matches!(Weights::open(&p), Err(Error::Weights(_))));
|
||||
assert!(matches!(Weights::open(&dir.join("absent")), Err(Error::Weights(_))));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use effectcraft_sam2::model::ImageModel;
|
||||
use effectcraft_sam2::prompt::Prompt;
|
||||
use effectcraft_sam2::weights::Weights;
|
||||
|
||||
fn figure(w: usize, h: usize) -> (Vec<[f32; 3]>, Vec<bool>) {
|
||||
let mut rgb = vec![[0f32; 3]; w * h];
|
||||
let mut gt = vec![false; w * h];
|
||||
let part = |x: f32, y: f32| -> Option<[f32; 3]> {
|
||||
let (cx, _cy) = (128.0, 0.0);
|
||||
let r = |x0: f32, y0: f32, x1: f32, y1: f32| x >= x0 && x < x1 && y >= y0 && y < y1;
|
||||
if (x - cx).powi(2) + (y - 40.0).powi(2) < 18.0f32.powi(2) {
|
||||
return Some([0.8, 0.6, 0.5]);
|
||||
} // head
|
||||
if r(104.0, 60.0, 152.0, 130.0) {
|
||||
return Some([0.2, 0.3, 0.7 + 0.1 * (x / 7.0).sin()]);
|
||||
} // torso
|
||||
if r(76.0, 62.0, 104.0, 74.0) || r(152.0, 62.0, 180.0, 74.0) {
|
||||
return Some([0.8, 0.6, 0.5]);
|
||||
} // arms
|
||||
if r(108.0, 130.0, 124.0, 210.0) || r(132.0, 130.0, 148.0, 210.0) {
|
||||
return Some([0.15, 0.15, 0.2]);
|
||||
} // legs
|
||||
if r(102.0, 210.0, 126.0, 224.0) || r(130.0, 210.0, 154.0, 224.0) {
|
||||
return Some([0.95, 0.95, 0.95]);
|
||||
} // white shoes
|
||||
None
|
||||
};
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let bg = 0.45 + 0.1 * (x as f32 / 11.0).sin() * (y as f32 / 9.0).cos();
|
||||
let p = part(x as f32, y as f32);
|
||||
rgb[y * w + x] = p.unwrap_or([bg, bg * 0.9, bg * 0.8]);
|
||||
gt[y * w + x] = p.is_some();
|
||||
}
|
||||
}
|
||||
(rgb, gt)
|
||||
}
|
||||
|
||||
fn iou(p: &[f32], gt: &[bool]) -> f64 {
|
||||
let (mut i, mut u) = (0usize, 0usize);
|
||||
for (a, b) in p.iter().zip(gt) {
|
||||
let a = *a > 0.0;
|
||||
i += usize::from(a && *b);
|
||||
u += usize::from(a || *b);
|
||||
}
|
||||
i as f64 / u.max(1) as f64
|
||||
}
|
||||
|
||||
/// A multi-part figure (torso, head, legs, arms, white shoes in different colours): clicks alone make
|
||||
/// SAM 2 pick the torso and head, a box around the figure makes it cover all of it. This is why Roto
|
||||
/// Brush prompts the tracker with a box from the classic engine's matte (found on real footage, where
|
||||
/// the stroke frame came out partial).
|
||||
#[test]
|
||||
fn a_box_makes_sam2_cover_every_part_of_the_object() {
|
||||
let wf = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../sam2-tiny/model.safetensors");
|
||||
let wf = std::env::var_os("EFFECTCRAFT_SAM2_DIR").map_or(wf, |d| std::path::PathBuf::from(d).join("model.safetensors"));
|
||||
if !wf.exists() {
|
||||
eprintln!("skipped: no SAM 2 weights");
|
||||
return;
|
||||
}
|
||||
let model = ImageModel::load(&Weights::open(&wf).unwrap()).unwrap();
|
||||
let (w, h) = (256usize, 256usize);
|
||||
let (rgb, gt) = figure(w, h);
|
||||
let enc = model.encode(&effectcraft_sam2::mask::preprocess(&rgb, w, h).unwrap()).unwrap();
|
||||
let s = 4.0f32;
|
||||
let clicks = vec![([128.0 * s, 75.0 * s], true), ([128.0 * s, 100.0 * s], true), ([128.0 * s, 125.0 * s], true), ([20.0 * s, 20.0 * s], false)];
|
||||
let mask = |p: &Prompt| -> (f64, f32) {
|
||||
let out = model.segment(&enc, p, false).unwrap();
|
||||
let m = out.masks.narrow(1, 0, 1).unwrap().upsample_bilinear2d(h, w, false).unwrap().flatten_all().unwrap().to_vec1::<f32>().unwrap();
|
||||
(iou(&m, >), out.iou.flatten_all().unwrap().to_vec1::<f32>().unwrap()[0])
|
||||
};
|
||||
let (points_only, _) = mask(&Prompt { points: clicks.clone(), bbox: None });
|
||||
let (boxed, predicted) = mask(&Prompt { points: clicks, bbox: Some([70.0 * s, 18.0 * s, 186.0 * s, 228.0 * s]) });
|
||||
eprintln!("clicks only: IoU {points_only:.2}; with the box: IoU {boxed:.2} (predicted quality {predicted:.2})");
|
||||
assert!(points_only < 0.6, "the clicks-only failure this test documents is gone ({points_only}): revisit the box prompt");
|
||||
assert!(boxed > 0.95 && predicted > 0.7, "boxed IoU {boxed}, predicted {predicted}");
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod figure;
|
||||
mod reference;
|
||||
@@ -0,0 +1,805 @@
|
||||
//! Stage-by-stage comparison with the PyTorch / transformers reference (`tools/sam2_reference.py`).
|
||||
//!
|
||||
//! Needs the `facebook/sam2.1-hiera-tiny` weights (default `<repo>/sam2-tiny`, or the folder in
|
||||
//! `EFFECTCRAFT_SAM2_DIR`) and the fixture tensors (`<repo>/plan/sam2-ref/image.safetensors`, or
|
||||
//! `EFFECTCRAFT_SAM2_REF`). Without them every test here skips, so CI stays green.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use candle_core::{Device, Tensor};
|
||||
use effectcraft_sam2::hiera::{Hiera, HieraConfig};
|
||||
use effectcraft_sam2::weights::Weights;
|
||||
|
||||
fn repo() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
|
||||
}
|
||||
|
||||
fn weights_file() -> PathBuf {
|
||||
std::env::var_os("EFFECTCRAFT_SAM2_DIR").map_or_else(|| repo().join("sam2-tiny"), PathBuf::from).join("model.safetensors")
|
||||
}
|
||||
|
||||
fn fixture_file() -> PathBuf {
|
||||
std::env::var_os("EFFECTCRAFT_SAM2_REF").map_or_else(|| repo().join("plan/sam2-ref/image.safetensors"), PathBuf::from)
|
||||
}
|
||||
|
||||
/// The weights and the reference tensors, or `None` (the test then skips).
|
||||
fn load() -> Option<(Weights, HashMap<String, Tensor>)> {
|
||||
let (wf, rf) = (weights_file(), fixture_file());
|
||||
if !wf.exists() || !rf.exists() {
|
||||
eprintln!("skipped: SAM 2 weights or reference tensors not found ({} / {})", wf.display(), rf.display());
|
||||
return None;
|
||||
}
|
||||
let w = Weights::open(&wf).unwrap();
|
||||
let r = candle_core::safetensors::load(&rf, &Device::Cpu).unwrap();
|
||||
note_size(&w);
|
||||
Some((w, r))
|
||||
}
|
||||
|
||||
fn max_abs(a: &Tensor, b: &Tensor) -> f32 {
|
||||
assert_eq!(a.dims(), b.dims(), "shape mismatch");
|
||||
(a - b).unwrap().abs().unwrap().flatten_all().unwrap().max(0).unwrap().to_scalar::<f32>().unwrap()
|
||||
}
|
||||
|
||||
/// The largest magnitude in a tensor.
|
||||
fn peak(t: &Tensor) -> f32 {
|
||||
t.abs().unwrap().flatten_all().unwrap().max(0).unwrap().to_scalar::<f32>().unwrap()
|
||||
}
|
||||
|
||||
/// Set when the checkpoint is Base+ or Large (24 and 48 blocks): f32 rounding error is relative and accumulates with
|
||||
/// depth, and these backbones carry much larger activations (Large peaks at 140). Tiny and Small keep the strict bounds.
|
||||
static DEEP: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
fn note_size(w: &Weights) {
|
||||
let size = effectcraft_sam2::Size::detect(w).unwrap();
|
||||
DEEP.store(matches!(size, effectcraft_sam2::Size::BasePlus | effectcraft_sam2::Size::Large), std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// `base`, or for the deep backbones the larger of `base` and 5e-4 of the activations' peak.
|
||||
fn tol(base: f32, want: &Tensor) -> f32 {
|
||||
if DEEP.load(std::sync::atomic::Ordering::Relaxed) { base.max(5e-4 * peak(want)) } else { base }
|
||||
}
|
||||
|
||||
/// `base`, ×4 for the deep backbones.
|
||||
fn loose(base: f32) -> f32 {
|
||||
if DEEP.load(std::sync::atomic::Ordering::Relaxed) { base * 4.0 } else { base }
|
||||
}
|
||||
|
||||
/// f32 activations differ from PyTorch by accumulation order only; the Hiera blocks stay well
|
||||
/// inside this bound (measured max 2.6e-5, 2026-10-15).
|
||||
const HIERA_TOL: f32 = 1e-4;
|
||||
|
||||
#[test]
|
||||
fn hiera_matches_pytorch_block_by_block() {
|
||||
let Some((w, r)) = load() else { return };
|
||||
let hiera = Hiera::load(&w, &HieraConfig::for_size(effectcraft_sam2::Size::detect(&w).unwrap())).unwrap();
|
||||
let pixels = r.get("input.pixel_values").unwrap();
|
||||
let (emb, blocks) = hiera.forward_all(pixels).unwrap();
|
||||
let e = max_abs(&emb, r.get("vision_encoder.backbone.patch_embed.0").unwrap());
|
||||
eprintln!("patch_embed max abs error {e:e}");
|
||||
assert!(e < 1e-4, "patch embed error {e}");
|
||||
assert_eq!(blocks.len(), HieraConfig::for_size(effectcraft_sam2::Size::detect(&w).unwrap()).blocks.iter().sum::<usize>());
|
||||
for (i, b) in blocks.iter().enumerate() {
|
||||
let want = r.get(&format!("vision_encoder.backbone.blocks.{i}.0")).unwrap();
|
||||
let e = max_abs(b, want);
|
||||
eprintln!("block {i}: max abs error {e:e}");
|
||||
let t = tol(HIERA_TOL, want);
|
||||
assert!(e < t, "block {i} error {e} (tolerance {t}, peak {})", peak(want));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiera_is_deterministic_and_rejects_bad_sizes() {
|
||||
let Some((w, r)) = load() else { return };
|
||||
let hiera = Hiera::load(&w, &HieraConfig::for_size(effectcraft_sam2::Size::detect(&w).unwrap())).unwrap();
|
||||
let pixels = r.get("input.pixel_values").unwrap();
|
||||
let a = hiera.forward(pixels).unwrap();
|
||||
let b = hiera.forward(pixels).unwrap();
|
||||
assert_eq!(a.len(), 4);
|
||||
for (x, y) in a.iter().zip(&b) {
|
||||
assert_eq!(max_abs(x, y), 0.0);
|
||||
}
|
||||
let odd = pixels.narrow(2, 0, 1022).unwrap();
|
||||
assert!(hiera.forward(&odd).is_err());
|
||||
}
|
||||
|
||||
/// The neck adds 1x1 convolutions and nearest-neighbour sums to the Hiera output; the sine
|
||||
/// position encodings are exact functions of the grid size.
|
||||
const NECK_TOL: f32 = 1e-4;
|
||||
|
||||
#[test]
|
||||
fn neck_and_vision_encoder_match_pytorch() {
|
||||
let Some((w, r)) = load() else { return };
|
||||
let enc = effectcraft_sam2::neck::VisionEncoder::load(&w).unwrap();
|
||||
let pixels = r.get("input.pixel_values").unwrap();
|
||||
let stages = enc.hiera.forward(pixels).unwrap();
|
||||
let out = enc.neck.forward(&stages).unwrap();
|
||||
assert_eq!((out.features.len(), out.positions.len()), (4, 4));
|
||||
for (i, f) in out.features.iter().enumerate() {
|
||||
let e = max_abs(f, r.get(&format!("vision_encoder.neck.{i}")).unwrap());
|
||||
eprintln!("neck feature {i}: max abs error {e:e}");
|
||||
let want = r.get(&format!("vision_encoder.neck.{i}")).unwrap();
|
||||
assert!(e < tol(NECK_TOL, want), "neck feature {i} error {e} (peak {})", peak(want));
|
||||
}
|
||||
for (i, p) in out.positions.iter().enumerate() {
|
||||
let e = max_abs(p, r.get(&format!("vision_encoder.neck.{}", i + 4)).unwrap());
|
||||
eprintln!("neck position {i}: max abs error {e:e}");
|
||||
assert!(e < NECK_TOL, "neck position {i} error {e}");
|
||||
}
|
||||
// The vision encoder keeps the three highest-resolution levels, highest first.
|
||||
let feats = enc.forward(pixels).unwrap();
|
||||
let dims: Vec<usize> = feats.features.iter().map(|f| f.dim(2).unwrap()).collect();
|
||||
assert_eq!(dims, vec![256, 128, 64]);
|
||||
let e = max_abs(feats.features.first().unwrap(), r.get("vision_encoder.neck.3").unwrap());
|
||||
assert!(e < tol(NECK_TOL, r.get("vision_encoder.neck.3").unwrap()));
|
||||
}
|
||||
|
||||
/// Click-prompted single image: prompt embeddings and decoder outputs against `Sam2Model`.
|
||||
const DECODER_TOL: f32 = 1e-3;
|
||||
|
||||
#[test]
|
||||
fn prompt_encoder_and_decoder_match_pytorch() {
|
||||
use effectcraft_sam2::model::ImageModel;
|
||||
use effectcraft_sam2::prompt::Prompt;
|
||||
let Some((w, r)) = load() else { return };
|
||||
let model = ImageModel::load(&w).unwrap();
|
||||
let pixels = r.get("input.pixel_values").unwrap();
|
||||
let prompt = Prompt { points: vec![([460.0, 512.0], true)], bbox: None };
|
||||
|
||||
let (sparse, dense) = model.embed_prompt(&prompt).unwrap();
|
||||
let want_sparse = r.get("prompt_encoder.0").unwrap().squeeze(0).unwrap();
|
||||
let e = max_abs(&sparse, &want_sparse);
|
||||
eprintln!("sparse prompt: max abs error {e:e}");
|
||||
assert!(e < 1e-5, "sparse prompt error {e}");
|
||||
let e = max_abs(&dense, r.get("prompt_encoder.1").unwrap());
|
||||
assert!(e < 1e-6, "dense prompt error {e}");
|
||||
|
||||
let enc = model.encode(pixels).unwrap();
|
||||
let out = model.segment(&enc, &prompt, true).unwrap();
|
||||
let want_masks = r.get("mask_decoder.0").unwrap().squeeze(0).unwrap();
|
||||
let e = max_abs(&out.masks, &want_masks);
|
||||
eprintln!("masks: max abs error {e:e} (logit range {:?})", want_masks.flatten_all().unwrap().max(0).unwrap().to_scalar::<f32>());
|
||||
assert!(e < loose(DECODER_TOL), "mask logits error {e}");
|
||||
let e = max_abs(&out.iou, &r.get("mask_decoder.1").unwrap().squeeze(0).unwrap());
|
||||
eprintln!("iou: max abs error {e:e}");
|
||||
assert!(e < 1e-4, "iou error {e}");
|
||||
let e = max_abs(&out.object_score, &r.get("mask_decoder.3").unwrap().squeeze(0).unwrap());
|
||||
eprintln!("object score: max abs error {e:e}");
|
||||
assert!(e < 1e-4, "object score error {e}");
|
||||
let e = max_abs(&out.sam_tokens, &r.get("mask_decoder.2").unwrap().squeeze(0).unwrap());
|
||||
assert!(e < loose(1e-4), "sam token error {e}");
|
||||
}
|
||||
|
||||
fn video_fixture_file() -> PathBuf {
|
||||
std::env::var_os("EFFECTCRAFT_SAM2_VIDEO_REF").map_or_else(|| repo().join("plan/sam2-ref/video.safetensors"), PathBuf::from)
|
||||
}
|
||||
|
||||
/// The weights and the video reference tensors (`tools/sam2_video_reference.py`), or `None`.
|
||||
fn load_video() -> Option<(Weights, HashMap<String, Tensor>)> {
|
||||
let (wf, rf) = (weights_file(), video_fixture_file());
|
||||
if !wf.exists() || !rf.exists() {
|
||||
eprintln!("skipped: SAM 2 weights or video reference tensors not found ({} / {})", wf.display(), rf.display());
|
||||
return None;
|
||||
}
|
||||
let w = Weights::open(&wf).unwrap();
|
||||
note_size(&w);
|
||||
Some((w, candle_core::safetensors::load(&rf, &Device::Cpu).unwrap()))
|
||||
}
|
||||
|
||||
/// Measured max 3.5e-6 (memory features), 2.6e-5 (memory attention), 2026-10-15.
|
||||
const MEMORY_TOL: f32 = 1e-4;
|
||||
|
||||
#[test]
|
||||
fn memory_encoder_matches_pytorch() {
|
||||
let Some((w, r)) = load_video() else { return };
|
||||
let enc = effectcraft_sam2::memory::MemoryEncoder::load(&w).unwrap();
|
||||
let (feat, pos) = enc.encode(r.get("mem.enc.vision_features").unwrap(), r.get("mem.enc.masks").unwrap()).unwrap();
|
||||
let e = max_abs(&feat, r.get("mem.enc.out_features").unwrap());
|
||||
eprintln!("memory features: max abs error {e:e}");
|
||||
assert!(e < MEMORY_TOL, "memory feature error {e}");
|
||||
let e = max_abs(&pos, r.get("mem.enc.out_pos").unwrap());
|
||||
eprintln!("memory position encoding: max abs error {e:e}");
|
||||
assert!(e < 1e-5, "memory position error {e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_attention_matches_pytorch() {
|
||||
let Some((w, r)) = load_video() else { return };
|
||||
let att = effectcraft_sam2::memattn::MemoryAttention::load(&w).unwrap();
|
||||
let get = |k: &str| r.get(k).unwrap().squeeze(1).unwrap();
|
||||
let pointers = r.get("mem.attn.num_pointers").unwrap().to_vec1::<f32>().unwrap()[0] as usize;
|
||||
let out = att
|
||||
.forward(
|
||||
&get("mem.attn.current_vision_features"),
|
||||
&get("mem.attn.current_vision_position_embeddings"),
|
||||
&get("mem.attn.memory"),
|
||||
&get("mem.attn.memory_posision_embeddings"),
|
||||
pointers,
|
||||
)
|
||||
.unwrap();
|
||||
let want = r.get("mem.attn.out").unwrap().squeeze(0).unwrap().squeeze(0).unwrap();
|
||||
let e = max_abs(&out, &want);
|
||||
eprintln!("memory attention: max abs error {e:e} (pointers {pointers})");
|
||||
assert!(e < MEMORY_TOL, "memory attention error {e}");
|
||||
}
|
||||
|
||||
/// Frames of the video fixture, served one at a time.
|
||||
struct Clip(Tensor);
|
||||
|
||||
impl effectcraft_sam2::session::FrameSource for Clip {
|
||||
fn num_frames(&self) -> usize {
|
||||
self.0.dim(0).unwrap()
|
||||
}
|
||||
fn frame(&self, i: usize) -> effectcraft_sam2::Result<Tensor> {
|
||||
Ok(self.0.narrow(0, i, 1)?.contiguous()?)
|
||||
}
|
||||
}
|
||||
|
||||
fn iou(a: &[f32], b: &[f32]) -> f64 {
|
||||
let (mut i, mut u) = (0usize, 0usize);
|
||||
for (x, y) in a.iter().zip(b) {
|
||||
let (x, y) = (*x > 0.0, *y > 0.0);
|
||||
i += usize::from(x && y);
|
||||
u += usize::from(x || y);
|
||||
}
|
||||
if u == 0 { 1.0 } else { i as f64 / u as f64 }
|
||||
}
|
||||
|
||||
/// Through the clip the memory bank feeds back on itself (bfloat16 memories, rotary attention), so
|
||||
/// the bound on logits is looser than for a single stage; the masks themselves must agree.
|
||||
/// Measured max 0.0038 over 9 frames forwards and backwards, 2026-10-15.
|
||||
const VIDEO_LOGIT_TOL: f32 = 0.02;
|
||||
const VIDEO_MIN_IOU: f64 = 0.995;
|
||||
|
||||
fn check_video_tracking(backend: effectcraft_sam2::session::Backend) {
|
||||
use effectcraft_sam2::prompt::Prompt;
|
||||
use effectcraft_sam2::session::{Cancel, Direction, ObjectId, VideoModel, VideoTracker};
|
||||
let Some((w, r)) = load_video() else { return };
|
||||
let model = std::sync::Arc::new(match VideoModel::load_with(&w, backend) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
eprintln!("skipped: {e}");
|
||||
return;
|
||||
}
|
||||
});
|
||||
let clip = r.get("clip.pixel_values").unwrap().clone();
|
||||
let mut tracker = VideoTracker::new(model, Box::new(Clip(clip))).unwrap();
|
||||
let obj = ObjectId(1);
|
||||
let first = tracker.prompt(2, obj, &Prompt { points: vec![([416.0, 512.0], true)], bbox: None }).unwrap();
|
||||
|
||||
let check = |tag: &str, m: &effectcraft_sam2::session::FrameMask| {
|
||||
let want = r.get(&format!("video.{tag}.{}.pred_masks", m.frame)).unwrap().flatten_all().unwrap().to_vec1::<f32>().unwrap();
|
||||
let want_score = r.get(&format!("video.{tag}.{}.score", m.frame)).unwrap().flatten_all().unwrap().to_vec1::<f32>().unwrap()[0];
|
||||
let err = m.logits.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max);
|
||||
let j = iou(&m.logits, &want);
|
||||
eprintln!("{tag} frame {}: logit error {err:.4}, IoU {j:.4}, score {:.3} (want {want_score:.3})", m.frame, m.object_score);
|
||||
assert!(j >= VIDEO_MIN_IOU, "{tag} frame {} IoU {j}", m.frame);
|
||||
assert!(err < VIDEO_LOGIT_TOL, "{tag} frame {} logit error {err}", m.frame);
|
||||
assert!((m.object_score - want_score).abs() < 0.05, "{tag} frame {} score {} vs {want_score}", m.frame, m.object_score);
|
||||
assert_eq!(m.occluded, want_score <= 0.0);
|
||||
};
|
||||
check("prompt", &first);
|
||||
let cancel = Cancel::new();
|
||||
let mut fwd = vec![];
|
||||
tracker.propagate(2, Direction::Forward, None, &cancel, &mut |m| fwd.push(m)).unwrap();
|
||||
assert_eq!(fwd.iter().map(|m| m.frame).collect::<Vec<_>>(), vec![2, 3, 4, 5, 6]);
|
||||
for m in &fwd {
|
||||
check("fwd", m);
|
||||
}
|
||||
let mut rev = vec![];
|
||||
tracker.propagate(2, Direction::Backward, None, &cancel, &mut |m| rev.push(m)).unwrap();
|
||||
assert_eq!(rev.iter().map(|m| m.frame).collect::<Vec<_>>(), vec![2, 1, 0]);
|
||||
for m in &rev {
|
||||
check("rev", m);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_tracking_matches_sam2_video_model() {
|
||||
check_video_tracking(effectcraft_sam2::session::Backend::Cpu);
|
||||
}
|
||||
|
||||
/// The same clip on the GPU: every frame's mask against `Sam2VideoModel`, forwards and backwards. The GPU path
|
||||
/// is within 1e-5 of PyTorch stage by stage; through the clip the bfloat16 memories can round the other way
|
||||
/// on a few values, so the bound on logits is the video one, and the masks must agree.
|
||||
#[test]
|
||||
fn video_tracking_on_the_gpu_matches_sam2_video_model() {
|
||||
check_video_tracking(effectcraft_sam2::session::Backend::Gpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_rejects_hostile_input_and_honours_cancel() {
|
||||
use effectcraft_sam2::Error;
|
||||
use effectcraft_sam2::prompt::Prompt;
|
||||
use effectcraft_sam2::session::{Cancel, Direction, ObjectId, VideoModel, VideoTracker};
|
||||
let Some((w, r)) = load_video() else { return };
|
||||
let model = std::sync::Arc::new(VideoModel::load(&w).unwrap());
|
||||
let clip = r.get("clip.pixel_values").unwrap().narrow(0, 0, 3).unwrap();
|
||||
let mut t = VideoTracker::new(model.clone(), Box::new(Clip(clip.clone()))).unwrap();
|
||||
let click = Prompt { points: vec![([416.0, 512.0], true)], bbox: None };
|
||||
let obj = ObjectId(7);
|
||||
// outside the clip, empty and non-finite prompts, nothing prompted yet
|
||||
assert!(matches!(t.prompt(3, obj, &click), Err(Error::Input(_))));
|
||||
assert!(matches!(t.prompt(0, obj, &Prompt::default()), Err(Error::Input(_))));
|
||||
assert!(matches!(t.prompt(0, obj, &Prompt { points: vec![([f32::NAN, 1.0], true)], bbox: None }), Err(Error::Input(_))));
|
||||
let mut sink = |_m| {};
|
||||
assert!(matches!(t.propagate(0, Direction::Forward, None, &Cancel::new(), &mut sink), Err(Error::Input(_))));
|
||||
assert!(matches!(t.propagate(9, Direction::Forward, None, &Cancel::new(), &mut sink), Err(Error::Input(_))));
|
||||
// an empty clip is refused; a cancelled run stops with Cancelled and keeps the tracker usable
|
||||
assert!(VideoTracker::new(model.clone(), Box::new(Clip(clip.narrow(0, 0, 0).unwrap()))).is_err());
|
||||
t.prompt(0, obj, &click).unwrap();
|
||||
let cancel = Cancel::new();
|
||||
cancel.cancel();
|
||||
assert!(matches!(t.propagate(0, Direction::Forward, None, &cancel, &mut sink), Err(Error::Cancelled)));
|
||||
let mut seen = vec![];
|
||||
t.propagate(0, Direction::Forward, Some(1), &Cancel::new(), &mut |m| seen.push(m.frame)).unwrap();
|
||||
assert_eq!(seen, vec![0, 1]);
|
||||
// a frame source that returns the wrong size is an error, not a crash
|
||||
struct Bad;
|
||||
impl effectcraft_sam2::session::FrameSource for Bad {
|
||||
fn num_frames(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn frame(&self, _i: usize) -> effectcraft_sam2::Result<Tensor> {
|
||||
Ok(Tensor::zeros((1, 3, 64, 64), candle_core::DType::F32, &Device::Cpu)?)
|
||||
}
|
||||
}
|
||||
let mut bad = VideoTracker::new(model, Box::new(Bad)).unwrap();
|
||||
assert!(matches!(bad.prompt(0, obj, &click), Err(Error::Input(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sam2_works_as_a_mask_model() {
|
||||
use effectcraft_segment::{MaskModel, Prompt};
|
||||
let Some((w, _)) = load() else { return };
|
||||
let (fw, fh) = (256usize, 192usize);
|
||||
let mut rgb = vec![[0f32; 3]; fw * fh];
|
||||
let inside = |x: usize, y: usize| ((x as f32 - 120.0).powi(2) + (y as f32 - 96.0).powi(2)).sqrt() < 50.0;
|
||||
for y in 0..fh {
|
||||
for x in 0..fw {
|
||||
let bg = 0.3 + 0.2 * (x as f32 / 9.0).sin() * (y as f32 / 7.0).cos();
|
||||
rgb[y * fw + x] = if inside(x, y) { [0.85 + 0.1 * (x as f32 / 5.0).sin(), 0.4, 0.15] } else { [bg, bg * 0.8 + 0.1, bg * 0.6 + 0.3] };
|
||||
}
|
||||
}
|
||||
let model = effectcraft_sam2::mask::Sam2Mask::new(std::sync::Arc::new(effectcraft_sam2::session::VideoModel::load(&w).unwrap()));
|
||||
let prompt = Prompt { points: vec![([120.0, 96.0], true)], bbox: None, mask: None };
|
||||
let p = model.segment(&rgb, fw, fh, &prompt).unwrap();
|
||||
assert_eq!(p.len(), fw * fh);
|
||||
assert!(p.iter().all(|v| (0.0..=1.0).contains(v)));
|
||||
let (mut i, mut u) = (0usize, 0usize);
|
||||
for y in 0..fh {
|
||||
for x in 0..fw {
|
||||
let (a, b) = (p[y * fw + x] > 0.5, inside(x, y));
|
||||
i += usize::from(a && b);
|
||||
u += usize::from(a || b);
|
||||
}
|
||||
}
|
||||
let j = i as f64 / u as f64;
|
||||
eprintln!("SAM 2 as a MaskModel: disc IoU {j:.3}");
|
||||
assert!(j > 0.9, "IoU {j}");
|
||||
// The same frame again is served from the cached encoding, and bad prompts are errors.
|
||||
let again = model.segment(&rgb, fw, fh, &Prompt { points: vec![([20.0, 20.0], false), ([120.0, 96.0], true)], bbox: None, mask: None }).unwrap();
|
||||
assert_eq!(again.len(), fw * fh);
|
||||
assert!(model.segment(&rgb, fw, fh, &Prompt::default()).is_err());
|
||||
assert!(model.segment(&rgb, fw + 1, fh, &prompt).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sam2_tracks_a_clip_through_the_object_tracker_interface() {
|
||||
use effectcraft_segment::{ClipFrame, MaskModel, Prompt, TrackRequest};
|
||||
let Some((w, _)) = load() else { return };
|
||||
let (fw, fh, n) = (128usize, 96usize, 6usize);
|
||||
let centre = |t: usize| (40.0 + 8.0 * t as f32, 48.0f32);
|
||||
let inside = |t: usize, x: usize, y: usize| {
|
||||
let c = centre(t);
|
||||
((x as f32 - c.0).powi(2) + (y as f32 - c.1).powi(2)).sqrt() < 26.0
|
||||
};
|
||||
let frames: Vec<std::sync::Arc<ClipFrame>> = (0..n)
|
||||
.map(|t| {
|
||||
let mut rgb = vec![[0f32; 3]; fw * fh];
|
||||
for y in 0..fh {
|
||||
for x in 0..fw {
|
||||
let bg = 0.3 + 0.2 * (x as f32 / 9.0).sin() * (y as f32 / 7.0).cos();
|
||||
rgb[y * fw + x] =
|
||||
if inside(t, x, y) { [0.85 + 0.1 * ((x as f32 - centre(t).0) / 5.0).sin(), 0.4, 0.15] } else { [bg, bg * 0.8 + 0.1, bg * 0.6 + 0.3] };
|
||||
}
|
||||
}
|
||||
std::sync::Arc::new(ClipFrame { rgb, w: fw, h: fh })
|
||||
})
|
||||
.collect();
|
||||
let model = effectcraft_sam2::mask::Sam2Mask::new(std::sync::Arc::new(effectcraft_sam2::session::VideoModel::load(&w).unwrap()));
|
||||
assert!(model.tracker().is_some());
|
||||
let c = centre(1);
|
||||
let req = TrackRequest::single(100, n, vec![(101, Prompt { points: vec![([c.0, c.1], true)], bbox: None, mask: None })]);
|
||||
let mut seen = std::collections::BTreeMap::new();
|
||||
let mut get = |f: i64| frames.get((f - 100) as usize).cloned();
|
||||
model
|
||||
.tracker()
|
||||
.unwrap()
|
||||
.track(&req, &mut get, &|| false, &mut |o, f, t| {
|
||||
assert_eq!(o, 0);
|
||||
assert!(seen.insert(f, t).is_none(), "frame {f} reported twice");
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(seen.keys().copied().collect::<Vec<_>>(), (100..100 + n as i64).collect::<Vec<_>>());
|
||||
for (f, t) in &seen {
|
||||
assert_eq!((t.w, t.h, t.prob.len()), (fw, fh, fw * fh));
|
||||
let k = (f - 100) as usize;
|
||||
let (mut i, mut u) = (0usize, 0usize);
|
||||
for y in 0..fh {
|
||||
for x in 0..fw {
|
||||
let (a, b) = (t.prob[y * fw + x] > 0.5, inside(k, x, y));
|
||||
i += usize::from(a && b);
|
||||
u += usize::from(a || b);
|
||||
}
|
||||
}
|
||||
let j = i as f64 / u.max(1) as f64;
|
||||
eprintln!("frame {f}: IoU {j:.3}, confidence {:.2}, occluded {}", t.confidence, t.occluded);
|
||||
assert!(j > 0.85 && !t.occluded && t.confidence > 0.3, "frame {f}: IoU {j}");
|
||||
}
|
||||
// Cancelling before the work starts, and bad requests, are errors.
|
||||
assert!(model.tracker().unwrap().track(&req, &mut get, &|| true, &mut |_, _, _| {}).is_err());
|
||||
let none = TrackRequest::single(100, n, vec![]);
|
||||
assert!(model.tracker().unwrap().track(&none, &mut get, &|| false, &mut |_, _, _| {}).is_err());
|
||||
let outside = TrackRequest::single(100, n, vec![(500, Prompt { points: vec![([1.0, 1.0], true)], bbox: None, mask: None })]);
|
||||
assert!(model.tracker().unwrap().track(&outside, &mut get, &|| false, &mut |_, _, _| {}).is_err());
|
||||
}
|
||||
|
||||
/// Two objects in one pass: each follows its own disc, every frame is reported once per object, and
|
||||
/// objects may start on different frames.
|
||||
#[test]
|
||||
fn sam2_tracks_two_objects_in_one_pass() {
|
||||
use effectcraft_segment::{ClipFrame, MaskModel, Prompt, TrackRequest};
|
||||
let Some((w, _)) = load() else { return };
|
||||
let (fw, fh, n) = (160usize, 120usize, 4usize);
|
||||
// A red disc moving right and a blue one moving left, never touching.
|
||||
let a = |t: usize| (36.0 + 6.0 * t as f32, 40.0f32);
|
||||
let b = |t: usize| (124.0 - 6.0 * t as f32, 84.0f32);
|
||||
let hit = |c: (f32, f32), x: usize, y: usize| ((x as f32 - c.0).powi(2) + (y as f32 - c.1).powi(2)).sqrt() < 20.0;
|
||||
let frames: Vec<std::sync::Arc<ClipFrame>> = (0..n)
|
||||
.map(|t| {
|
||||
let mut rgb = vec![[0f32; 3]; fw * fh];
|
||||
for y in 0..fh {
|
||||
for x in 0..fw {
|
||||
let bg = 0.45 + 0.1 * (x as f32 / 9.0).sin() * (y as f32 / 7.0).cos();
|
||||
rgb[y * fw + x] = if hit(a(t), x, y) {
|
||||
[0.9, 0.15, 0.1]
|
||||
} else if hit(b(t), x, y) {
|
||||
[0.1, 0.25, 0.9]
|
||||
} else {
|
||||
[bg, bg * 0.9, bg * 0.8]
|
||||
};
|
||||
}
|
||||
}
|
||||
std::sync::Arc::new(ClipFrame { rgb, w: fw, h: fh })
|
||||
})
|
||||
.collect();
|
||||
let model = effectcraft_sam2::mask::Sam2Mask::new(std::sync::Arc::new(effectcraft_sam2::session::VideoModel::load(&w).unwrap()));
|
||||
let click = |c: (f32, f32)| Prompt { points: vec![([c.0, c.1], true)], bbox: None, mask: None };
|
||||
// object 0 is prompted on frame 0, object 1 on frame 2
|
||||
let req = TrackRequest { first: 10, count: n, objects: vec![vec![(10, click(a(0)))], vec![(12, click(b(2)))]] };
|
||||
let mut get = |f: i64| frames.get((f - 10) as usize).cloned();
|
||||
let mut seen: std::collections::BTreeMap<(usize, i64), effectcraft_segment::TrackedFrame> = Default::default();
|
||||
model.tracker().unwrap().track(&req, &mut get, &|| false, &mut |o, f, t| assert!(seen.insert((o, f), t).is_none(), "object {o} frame {f} twice")).unwrap();
|
||||
assert_eq!(seen.len(), 2 * n, "{:?}", seen.keys().collect::<Vec<_>>());
|
||||
for ((o, f), t) in &seen {
|
||||
let k = (f - 10) as usize;
|
||||
let c = if *o == 0 { a(k) } else { b(k) };
|
||||
let (mut i, mut u) = (0usize, 0usize);
|
||||
for y in 0..fh {
|
||||
for x in 0..fw {
|
||||
let (p, q) = (t.prob[y * fw + x] > 0.5, hit(c, x, y));
|
||||
i += usize::from(p && q);
|
||||
u += usize::from(p || q);
|
||||
}
|
||||
}
|
||||
let j = i as f64 / u.max(1) as f64;
|
||||
eprintln!("object {o} frame {f}: IoU {j:.3}, confidence {:.2}", t.confidence);
|
||||
assert!(j > 0.85, "object {o} frame {f}: IoU {j}");
|
||||
}
|
||||
let too_many = TrackRequest { first: 10, count: n, objects: vec![vec![(10, click(a(0)))]; 40] };
|
||||
assert!(model.tracker().unwrap().track(&too_many, &mut get, &|| false, &mut |_, _, _| {}).is_err());
|
||||
let empty = TrackRequest { first: 10, count: n, objects: vec![] };
|
||||
assert!(model.tracker().unwrap().track(&empty, &mut get, &|| false, &mut |_, _, _| {}).is_err());
|
||||
}
|
||||
|
||||
/// A mask prompt instead of clicks: the mask is the prompt frame's output, and tracking forwards and
|
||||
/// backwards from it matches `Sam2VideoModel` given the same mask.
|
||||
#[test]
|
||||
fn mask_prompted_tracking_matches_sam2_video_model() {
|
||||
use effectcraft_sam2::session::{Cancel, Direction, ObjectId, VideoModel, VideoTracker};
|
||||
let Some((w, r)) = load_video() else { return };
|
||||
let model = std::sync::Arc::new(VideoModel::load(&w).unwrap());
|
||||
let mut tracker = VideoTracker::new(model, Box::new(Clip(r.get("clip.pixel_values").unwrap().clone()))).unwrap();
|
||||
let obj = ObjectId(1);
|
||||
let first = tracker.prompt_mask(2, obj, r.get("maskprompt.input").unwrap()).unwrap();
|
||||
let check = |tag: &str, m: &effectcraft_sam2::session::FrameMask| {
|
||||
let want = r.get(&format!("maskprompt.{tag}.{}.pred_masks", m.frame)).unwrap().flatten_all().unwrap().to_vec1::<f32>().unwrap();
|
||||
let want_score = r.get(&format!("maskprompt.{tag}.{}.score", m.frame)).unwrap().flatten_all().unwrap().to_vec1::<f32>().unwrap()[0];
|
||||
let err = m.logits.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max);
|
||||
let j = iou(&m.logits, &want);
|
||||
eprintln!("mask prompt {tag} frame {}: logit error {err:.4}, IoU {j:.4}, score {:.3} (want {want_score:.3})", m.frame, m.object_score);
|
||||
assert!(j >= VIDEO_MIN_IOU, "{tag} frame {} IoU {j}", m.frame);
|
||||
assert!(err < VIDEO_LOGIT_TOL, "{tag} frame {} logit error {err}", m.frame);
|
||||
assert!((m.object_score - want_score).abs() < 0.05, "{tag} frame {} score {} vs {want_score}", m.frame, m.object_score);
|
||||
};
|
||||
check("prompt", &first);
|
||||
let cancel = Cancel::new();
|
||||
let mut fwd = vec![];
|
||||
tracker.propagate(2, Direction::Forward, None, &cancel, &mut |m| fwd.push(m)).unwrap();
|
||||
fwd.iter().for_each(|m| check("fwd", m));
|
||||
let mut rev = vec![];
|
||||
tracker.propagate(2, Direction::Backward, None, &cancel, &mut |m| rev.push(m)).unwrap();
|
||||
rev.iter().for_each(|m| check("rev", m));
|
||||
assert_eq!((fwd.len(), rev.len()), (5, 3));
|
||||
// hostile masks are errors
|
||||
let bad = Tensor::zeros((1, 1, 8, 8), candle_core::DType::F32, &Device::Cpu).unwrap();
|
||||
assert!(tracker.prompt_mask(2, obj, &bad).is_err());
|
||||
let nan = (Tensor::zeros((1, 1, 1024, 1024), candle_core::DType::F32, &Device::Cpu).unwrap() + f64::NAN).unwrap();
|
||||
assert!(tracker.prompt_mask(2, obj, &nan).is_err());
|
||||
assert!(tracker.prompt_mask(99, obj, r.get("maskprompt.input").unwrap()).is_err());
|
||||
}
|
||||
|
||||
/// The tracker interface takes a mask as a prompt: the disc's true mask on frame 1 tracks the disc.
|
||||
#[test]
|
||||
fn a_mask_prompt_through_the_object_tracker_interface() {
|
||||
use effectcraft_segment::{ClipFrame, MaskModel, Prompt, TrackRequest};
|
||||
let Some((w, _)) = load() else { return };
|
||||
let (fw, fh, n) = (128usize, 96usize, 4usize);
|
||||
let centre = |t: usize| (40.0 + 8.0 * t as f32, 48.0f32);
|
||||
let inside = |t: usize, x: usize, y: usize| ((x as f32 - centre(t).0).powi(2) + (y as f32 - centre(t).1).powi(2)).sqrt() < 26.0;
|
||||
let frames: Vec<std::sync::Arc<ClipFrame>> = (0..n)
|
||||
.map(|t| {
|
||||
let mut rgb = vec![[0f32; 3]; fw * fh];
|
||||
for y in 0..fh {
|
||||
for x in 0..fw {
|
||||
let bg = 0.3 + 0.2 * (x as f32 / 9.0).sin() * (y as f32 / 7.0).cos();
|
||||
rgb[y * fw + x] = if inside(t, x, y) { [0.85, 0.4, 0.15] } else { [bg, bg * 0.8 + 0.1, bg * 0.6 + 0.3] };
|
||||
}
|
||||
}
|
||||
std::sync::Arc::new(ClipFrame { rgb, w: fw, h: fh })
|
||||
})
|
||||
.collect();
|
||||
let model = effectcraft_sam2::mask::Sam2Mask::new(std::sync::Arc::new(effectcraft_sam2::session::VideoModel::load(&w).unwrap()));
|
||||
let mask: Vec<f32> = (0..fw * fh).map(|i| f32::from(inside(1, i % fw, i / fw))).collect();
|
||||
let mut get = |f: i64| frames.get(f as usize).cloned();
|
||||
let req = TrackRequest::single(0, n, vec![(1, Prompt { points: vec![], bbox: None, mask: Some(mask) })]);
|
||||
let mut seen = std::collections::BTreeMap::new();
|
||||
model
|
||||
.tracker()
|
||||
.unwrap()
|
||||
.track(&req, &mut get, &|| false, &mut |_, f, t| {
|
||||
seen.insert(f, t);
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(seen.len(), n);
|
||||
for (f, t) in &seen {
|
||||
let (mut i, mut u) = (0usize, 0usize);
|
||||
for y in 0..fh {
|
||||
for x in 0..fw {
|
||||
let (a, b) = (t.prob[y * fw + x] > 0.5, inside(*f as usize, x, y));
|
||||
i += usize::from(a && b);
|
||||
u += usize::from(a || b);
|
||||
}
|
||||
}
|
||||
let j = i as f64 / u.max(1) as f64;
|
||||
eprintln!("mask-prompted frame {f}: IoU {j:.3}");
|
||||
assert!(j > 0.85 && !t.occluded, "frame {f}: IoU {j}");
|
||||
}
|
||||
// a mask that does not fit the frame is an error
|
||||
let bad = TrackRequest::single(0, n, vec![(1, Prompt { points: vec![], bbox: None, mask: Some(vec![1.0; 5]) })]);
|
||||
assert!(model.tracker().unwrap().track(&bad, &mut get, &|| false, &mut |_, _, _| {}).is_err());
|
||||
}
|
||||
|
||||
/// The same prompts on the same frames give the same masks, bit for bit, run after run (CPU).
|
||||
#[test]
|
||||
fn tracking_is_deterministic() {
|
||||
use effectcraft_sam2::prompt::Prompt;
|
||||
use effectcraft_sam2::session::{Cancel, Direction, ObjectId, VideoModel, VideoTracker};
|
||||
let Some((w, r)) = load_video() else { return };
|
||||
let model = std::sync::Arc::new(VideoModel::load(&w).unwrap());
|
||||
let clip = r.get("clip.pixel_values").unwrap().narrow(0, 1, 3).unwrap();
|
||||
let run = || {
|
||||
let mut t = VideoTracker::new(model.clone(), Box::new(Clip(clip.clone()))).unwrap();
|
||||
t.prompt(0, ObjectId(1), &Prompt { points: vec![([416.0, 512.0], true)], bbox: None }).unwrap();
|
||||
let mut out = vec![];
|
||||
t.propagate(0, Direction::Forward, None, &Cancel::new(), &mut |m| out.push(m)).unwrap();
|
||||
out
|
||||
};
|
||||
let (a, b) = (run(), run());
|
||||
assert_eq!(a.len(), 3);
|
||||
for (x, y) in a.iter().zip(&b) {
|
||||
assert_eq!(x.logits, y.logits, "frame {} differs between runs", x.frame);
|
||||
assert_eq!((x.iou.to_bits(), x.object_score.to_bits()), (y.iou.to_bits(), y.object_score.to_bits()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard limits: a tracker over its memory budget or past its time budget stops with Error::Budget.
|
||||
#[test]
|
||||
fn the_tracker_stops_at_its_budget() {
|
||||
use effectcraft_sam2::Error;
|
||||
use effectcraft_sam2::prompt::Prompt;
|
||||
use effectcraft_sam2::session::{Budget, Cancel, Direction, ObjectId, VideoModel, VideoTracker};
|
||||
let Some((w, r)) = load_video() else { return };
|
||||
let model = std::sync::Arc::new(VideoModel::load(&w).unwrap());
|
||||
let clip = r.get("clip.pixel_values").unwrap().narrow(0, 1, 3).unwrap();
|
||||
let click = Prompt { points: vec![([416.0, 512.0], true)], bbox: None };
|
||||
// memory: one frame's worth is more than a 1 MB limit
|
||||
let mut t = VideoTracker::new(model.clone(), Box::new(Clip(clip.clone()))).unwrap();
|
||||
t.set_budget(Budget { max_state_bytes: 1 << 20, max_seconds: None });
|
||||
assert!(matches!(t.prompt(0, ObjectId(1), &click), Err(Error::Budget(_))));
|
||||
// default limit: fine, and the bytes held are reported
|
||||
let mut t = VideoTracker::new(model.clone(), Box::new(Clip(clip.clone()))).unwrap();
|
||||
t.prompt(0, ObjectId(1), &click).unwrap();
|
||||
let held = t.state_bytes();
|
||||
assert!(held > 2 << 20 && held < 200 << 20, "{held}");
|
||||
// time: a limit of zero stops the first propagation
|
||||
t.set_budget(Budget { max_state_bytes: 2 << 30, max_seconds: Some(0.0) });
|
||||
let r = t.propagate(0, Direction::Forward, None, &Cancel::new(), &mut |_| {});
|
||||
assert!(matches!(r, Err(Error::Budget(_))), "{r:?}");
|
||||
}
|
||||
|
||||
/// The GPU encoder (wgpu kernels, any vendor) against the CPU path and PyTorch on the same image. The two
|
||||
/// float implementations differ by accumulation order and the GPU's erf (1.5e-7), compounded through 12
|
||||
/// blocks. Measured (RX 7900 GRE, 2026-10-15): 6.8e-6 against PyTorch, 1.2e-5 against the CPU path.
|
||||
const GPU_TOL: f32 = 1e-4;
|
||||
|
||||
#[test]
|
||||
fn gpu_encoder_matches_the_cpu_path_and_pytorch() {
|
||||
use effectcraft_sam2::gpu::{Gpu, encoder::GpuEncoder};
|
||||
use effectcraft_sam2::model::ImageModel;
|
||||
let Some((w, r)) = load() else { return };
|
||||
let gpu = match Gpu::shared() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
eprintln!("skipped: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
eprintln!("GPU: {}", gpu.adapter_name());
|
||||
let model = ImageModel::load(&w).unwrap();
|
||||
let pixels = r.get("input.pixel_values").unwrap();
|
||||
let cpu = model.encode(pixels).unwrap();
|
||||
let enc = GpuEncoder::load(gpu, &w, model.vision.hiera.clone()).unwrap();
|
||||
let t = std::time::Instant::now();
|
||||
let got = enc.encode(pixels).unwrap();
|
||||
eprintln!("GPU encode (first run, includes shader compile): {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
let t = std::time::Instant::now();
|
||||
let got = (enc.encode(pixels).unwrap(), got).0;
|
||||
eprintln!("GPU encode: {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
let low_cpu = cpu.features.features.get(2).unwrap();
|
||||
let e = max_abs(&got.low, low_cpu);
|
||||
eprintln!("low level vs CPU: max abs error {e:e}");
|
||||
assert!(e < tol(GPU_TOL, low_cpu), "low level error {e} (peak {})", peak(low_cpu));
|
||||
let e = max_abs(&got.low, r.get("vision_encoder.neck.1").unwrap());
|
||||
eprintln!("low level vs PyTorch: max abs error {e:e}");
|
||||
assert!(e < tol(GPU_TOL, low_cpu), "low level vs PyTorch error {e}");
|
||||
let (e0, e1) = (max_abs(&got.high.0, &cpu.high.0), max_abs(&got.high.1, &cpu.high.1));
|
||||
eprintln!("skip features vs CPU: {e0:e} / {e1:e}");
|
||||
assert!(e0 < tol(GPU_TOL, &cpu.high.0) && e1 < tol(GPU_TOL, &cpu.high.1), "skip feature errors {e0} / {e1}");
|
||||
}
|
||||
|
||||
/// The GPU memory attention against PyTorch (the fixture's frame: one memory frame and four pointer tokens).
|
||||
/// Measured on the RX 7900 GRE, 2026-10-15; the CPU path is at 2.6e-5.
|
||||
const GPU_ATTN_TOL: f32 = 1e-3;
|
||||
|
||||
#[test]
|
||||
fn gpu_memory_attention_matches_pytorch() {
|
||||
use effectcraft_sam2::gpu::{Gpu, memattn::GpuMemoryAttention};
|
||||
let Some((w, r)) = load_video() else { return };
|
||||
let gpu = match Gpu::shared() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
eprintln!("skipped: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let att = GpuMemoryAttention::load(gpu, &w).unwrap();
|
||||
let get = |k: &str| r.get(k).unwrap().squeeze(1).unwrap();
|
||||
let pointers = r.get("mem.attn.num_pointers").unwrap().to_vec1::<f32>().unwrap()[0] as usize;
|
||||
let args = (
|
||||
get("mem.attn.current_vision_features"),
|
||||
get("mem.attn.current_vision_position_embeddings"),
|
||||
get("mem.attn.memory"),
|
||||
get("mem.attn.memory_posision_embeddings"),
|
||||
);
|
||||
let out = att.forward(&args.0, &args.1, &args.2, &args.3, pointers).unwrap();
|
||||
let want = r.get("mem.attn.out").unwrap().squeeze(0).unwrap().squeeze(0).unwrap();
|
||||
let e = max_abs(&out, &want);
|
||||
eprintln!("GPU memory attention vs PyTorch: max abs error {e:e}");
|
||||
assert!(e < GPU_ATTN_TOL, "GPU memory attention error {e}");
|
||||
// a full bank (7 memory frames and 16 pointers of 4 tokens): the same maths on a bigger problem, against the CPU path
|
||||
let cpu = effectcraft_sam2::memattn::MemoryAttention::load(&w).unwrap();
|
||||
let m = 7 * 4096 + 64;
|
||||
let mem = Tensor::randn(0f32, 1.0, (m, 64), &Device::Cpu).unwrap();
|
||||
let mem_pos = Tensor::randn(0f32, 1.0, (m, 64), &Device::Cpu).unwrap();
|
||||
let t = std::time::Instant::now();
|
||||
let big = att.forward(&args.0, &args.1, &mem, &mem_pos, 64).unwrap();
|
||||
eprintln!("GPU memory attention, full bank: {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
let want = cpu.forward(&args.0, &args.1, &mem, &mem_pos, 64).unwrap();
|
||||
let e = max_abs(&big, &want);
|
||||
eprintln!("full bank vs CPU: max abs error {e:e}");
|
||||
assert!(e < GPU_ATTN_TOL, "full-bank error {e}");
|
||||
// bad shapes are errors
|
||||
assert!(att.forward(&args.0, &args.1, &mem, &mem_pos, m + 1).is_err());
|
||||
assert!(att.forward(&args.0.narrow(0, 0, 10).unwrap(), &args.1, &mem, &mem_pos, 64).is_err());
|
||||
}
|
||||
|
||||
/// The GPU can be switched off in the middle of a clip: the CPU takes over, the device name changes, and
|
||||
/// the masks still match `Sam2VideoModel` (the memory banks built on the GPU are read by the CPU path).
|
||||
#[test]
|
||||
fn switching_the_gpu_off_mid_clip_keeps_the_tracking_right() {
|
||||
use effectcraft_sam2::prompt::Prompt;
|
||||
use effectcraft_sam2::session::{Backend, Cancel, Direction, ObjectId, VideoModel, VideoTracker};
|
||||
let Some((w, r)) = load_video() else { return };
|
||||
let Ok(model) = VideoModel::load_with(&w, Backend::Gpu) else {
|
||||
eprintln!("skipped: no GPU");
|
||||
return;
|
||||
};
|
||||
let model = std::sync::Arc::new(model);
|
||||
assert_ne!(model.device_name(), "CPU");
|
||||
let mut t = VideoTracker::new(model.clone(), Box::new(Clip(r.get("clip.pixel_values").unwrap().clone()))).unwrap();
|
||||
t.prompt(2, ObjectId(1), &Prompt { points: vec![([416.0, 512.0], true)], bbox: None }).unwrap();
|
||||
let mut seen = vec![];
|
||||
t.propagate(2, Direction::Forward, Some(1), &Cancel::new(), &mut |m| seen.push(m)).unwrap();
|
||||
model.disable_gpu();
|
||||
assert_eq!(model.device_name(), "CPU");
|
||||
t.propagate(2, Direction::Forward, None, &Cancel::new(), &mut |m| seen.push(m)).unwrap();
|
||||
let last = seen.last().unwrap();
|
||||
assert_eq!(last.frame, 6);
|
||||
let want = r.get("video.fwd.6.pred_masks").unwrap().flatten_all().unwrap().to_vec1::<f32>().unwrap();
|
||||
let j = iou(&last.logits, &want);
|
||||
assert!(j >= VIDEO_MIN_IOU, "frame 6 after the switch: IoU {j}");
|
||||
}
|
||||
|
||||
/// The GPU memory encoder against the CPU one on the fixture's frame (a mask, the frame's features). The CPU
|
||||
/// path matches PyTorch to 3.5e-6; the GPU is held to the same output.
|
||||
#[test]
|
||||
fn gpu_memory_encoder_matches_the_cpu_one() {
|
||||
use effectcraft_sam2::gpu::{Gpu, memenc::GpuMemoryEncoder};
|
||||
let Some((w, r)) = load_video() else { return };
|
||||
let Ok(gpu) = Gpu::shared() else {
|
||||
eprintln!("skipped: no GPU");
|
||||
return;
|
||||
};
|
||||
let cpu = effectcraft_sam2::memory::MemoryEncoder::load(&w).unwrap();
|
||||
let enc = GpuMemoryEncoder::load(gpu, &w).unwrap();
|
||||
let (features, mask) = (r.get("mem.enc.vision_features").unwrap(), r.get("mem.enc.masks").unwrap());
|
||||
let t = std::time::Instant::now();
|
||||
let got = enc.encode(features, mask).unwrap();
|
||||
eprintln!("GPU memory encoder: {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
let want = r.get("mem.enc.out_features").unwrap().flatten_from(2).unwrap().squeeze(0).unwrap().t().unwrap().contiguous().unwrap();
|
||||
let e = max_abs(&got, &want);
|
||||
eprintln!("GPU memory encoder vs PyTorch: max abs error {e:e}");
|
||||
assert!(e < 1e-3, "memory encoder error {e}");
|
||||
// the whole memory (with the occlusion embedding and bfloat16 rounding) equals the CPU one
|
||||
let (a, b) = (cpu.finish(&got, false).unwrap(), cpu.memory(features, mask, false).unwrap());
|
||||
let e = max_abs(&a.features, &b.features);
|
||||
assert!(e < 0.05, "memory features differ by {e} (bfloat16 steps are 2^-8 of the value)");
|
||||
assert_eq!(max_abs(&a.pos, &b.pos), 0.0);
|
||||
// wrong shapes are errors
|
||||
assert!(enc.encode(&features.narrow(1, 0, 8).unwrap(), mask).is_err());
|
||||
}
|
||||
|
||||
/// The tracker reports the most memory it held (memory banks, kept results, cached frames), and the model
|
||||
/// reports it for the last tracking run.
|
||||
#[test]
|
||||
fn the_tracker_reports_its_peak_memory() {
|
||||
use effectcraft_segment::{ClipFrame, MaskModel, Prompt, TrackRequest};
|
||||
let Some((w, _)) = load() else { return };
|
||||
let model = effectcraft_sam2::mask::Sam2Mask::new(std::sync::Arc::new(effectcraft_sam2::session::VideoModel::load(&w).unwrap()));
|
||||
assert_eq!(model.peak_state_bytes(), Some(0));
|
||||
let (fw, fh) = (64usize, 48usize);
|
||||
let frame = std::sync::Arc::new(ClipFrame {
|
||||
rgb: (0..fw * fh).map(|i| if (i % fw).abs_diff(32) < 12 && (i / fw).abs_diff(24) < 12 { [0.9, 0.2, 0.1] } else { [0.2, 0.3, 0.5] }).collect(),
|
||||
w: fw,
|
||||
h: fh,
|
||||
});
|
||||
let mut get = |_f: i64| Some(frame.clone());
|
||||
let req = TrackRequest::single(0, 3, vec![(0, Prompt { points: vec![([32.0, 24.0], true)], bbox: None, mask: None })]);
|
||||
model.tracker().unwrap().track(&req, &mut get, &|| false, &mut |_, _, _| {}).unwrap();
|
||||
let peak = model.peak_state_bytes().unwrap();
|
||||
// three stored frames (about 2 MB each) and the cached frame features
|
||||
assert!(peak > 6 << 20 && peak < 400 << 20, "peak {peak}");
|
||||
}
|
||||
@@ -12,7 +12,10 @@ Trained models for Roto Brush and face tracking, each kind behind one swappable
|
||||
depends on one model's layout. The classical face tracker stays built in as the fallback.
|
||||
- **`MODELS`**: the registry. Every entry must be open source under a licence compatible with
|
||||
EffectCraft's MIT OR Apache-2.0 (`ALLOWED_LICENCES`), with its task, authors, homepage, licence
|
||||
URL, the official weights URL, size and SHA-256. `load` refuses anything else.
|
||||
URL, an app download URL when available, size and SHA-256. `load` refuses anything else.
|
||||
SAM 2 is Install from File only until a storytold-controlled mirror is available.
|
||||
The checkpoint `source` stays separate from the app download URL, so file-install guidance and
|
||||
licence notices identify the verified artifact even when app downloads are unavailable.
|
||||
- **Weights are never bundled.** They are downloaded on demand (or installed from a file) by the
|
||||
engine (`roto.model.*` / `face.model.*`, Settings ▸ Roto Brush / Face Tracking), verified
|
||||
against the registry's SHA-256 (`sha256`, dependency-free) and kept with a notice naming their
|
||||
@@ -36,6 +39,10 @@ kernels at run time). No ML framework, no unsafe code.
|
||||
| id | Task | Model | Authors | Licence | File | Size |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `mobilesam` | Roto Brush | [MobileSAM](https://github.com/ChaoningZhang/MobileSAM): Segment Anything with a 5M-parameter TinyViT encoder | Chaoning Zhang et al., building on Meta AI's Segment Anything and Microsoft's TinyViT | Apache-2.0 | `mobile_sam.pt` from the official repository | 40.7 MB |
|
||||
| `sam2.1-hiera-tiny` | Roto Brush | [SAM 2.1 Tiny](https://github.com/facebookresearch/sam2): Hiera-Tiny video object tracker with streaming memory | Meta AI (Nikhila Ravi et al.) | Apache-2.0 | `model.safetensors` of `facebook/sam2.1-hiera-tiny` (Hugging Face, pinned to a commit); loaded by `effectcraft-sam2` | 155.9 MB |
|
||||
| `sam2.1-hiera-small` | Roto Brush | [SAM 2.1 Small](https://github.com/facebookresearch/sam2): Hiera-Small video object tracker with streaming memory | Meta AI (Nikhila Ravi et al.) | Apache-2.0 | `model.safetensors` of `facebook/sam2.1-hiera-small` (Hugging Face, pinned to a commit); loaded by `effectcraft-sam2` | 184.3 MB |
|
||||
| `sam2.1-hiera-base-plus` | Roto Brush | [SAM 2.1 Base+](https://github.com/facebookresearch/sam2): Hiera-Base+ video object tracker with streaming memory | Meta AI (Nikhila Ravi et al.) | Apache-2.0 | `model.safetensors` of `facebook/sam2.1-hiera-base-plus` (Hugging Face, pinned to a commit); loaded by `effectcraft-sam2` | 323.5 MB |
|
||||
| `sam2.1-hiera-large` | Roto Brush | [SAM 2.1 Large](https://github.com/facebookresearch/sam2): Hiera-Large video object tracker with streaming memory | Meta AI (Nikhila Ravi et al.) | Apache-2.0 | `model.safetensors` of `facebook/sam2.1-hiera-large` (Hugging Face, pinned to a commit); loaded by `effectcraft-sam2` | 897.9 MB |
|
||||
| `mediapipe-face` | Face tracking | [MediaPipe Face Landmarker](https://developers.google.com/edge/mediapipe/solutions/vision/face_landmarker): BlazeFace detector and Face Mesh V2 | Google LLC | Apache-2.0 ([model card](https://storage.googleapis.com/mediapipe-assets/Model%20Card%20MediaPipe%20Face%20Mesh%20V2.pdf)) | `face_landmarker.task` (float16, version 1) from Google's model storage | 3.8 MB |
|
||||
|
||||
How they were checked:
|
||||
|
||||
+199
-9
@@ -45,6 +45,77 @@ pub trait MaskModel: Send + Sync {
|
||||
fn info(&self) -> &'static ModelInfo;
|
||||
/// Foreground probability (0–1) of each pixel of a frame (straight RGB 0–1, row-major `w×h`).
|
||||
fn segment(&self, rgb: &[[f32; 3]], w: usize, h: usize, prompt: &Prompt) -> Result<Vec<f32>>;
|
||||
/// A video tracker built on the same weights, if the model has one.
|
||||
fn tracker(&self) -> Option<&dyn ObjectTracker> {
|
||||
None
|
||||
}
|
||||
/// The most memory (bytes) the last tracking run held for its memory banks and cached frames (`None`: not tracked).
|
||||
fn peak_state_bytes(&self) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
/// What runs the model: the GPU's name or "CPU" (`None`: it does not say).
|
||||
fn device_name(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
/// Seconds since the model was last used (`None`: it does not track use, so it is never unloaded
|
||||
/// for being idle). Large models free their memory after a quiet spell and load again on use.
|
||||
fn idle_secs(&self) -> Option<f64> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// One decoded frame handed to a tracker (straight RGB 0–1, row-major `w×h`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ClipFrame {
|
||||
pub rgb: Vec<[f32; 3]>,
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
}
|
||||
|
||||
/// What to track: the clip's frames `first..first + count` (the host's frame numbers) and, for
|
||||
/// each object, its prompts on their frames. Objects are tracked in one pass, so the cost of
|
||||
/// encoding each frame is shared.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TrackRequest {
|
||||
pub first: i64,
|
||||
pub count: usize,
|
||||
/// One entry per object: its prompts, each on its frame.
|
||||
pub objects: Vec<Vec<(i64, Prompt)>>,
|
||||
}
|
||||
|
||||
impl TrackRequest {
|
||||
/// A request for a single object.
|
||||
pub fn single(first: i64, count: usize, prompts: Vec<(i64, Prompt)>) -> TrackRequest {
|
||||
TrackRequest { first, count, objects: vec![prompts] }
|
||||
}
|
||||
}
|
||||
|
||||
/// A tracker's answer for one frame.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TrackedFrame {
|
||||
/// Foreground probability of every pixel (`w×h`); all zero when the object is absent.
|
||||
pub prob: Vec<f32>,
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
/// How sure the tracker is (0–1), for the timeline.
|
||||
pub confidence: f32,
|
||||
/// The object is not in this frame (occluded or out of view).
|
||||
pub occluded: bool,
|
||||
}
|
||||
|
||||
/// A video tracker with memory (SAM 2): given prompts on some frames, it follows the object
|
||||
/// through the clip in both directions. Optional: a [`MaskModel`] may offer one.
|
||||
pub trait ObjectTracker: Send + Sync {
|
||||
/// Track the objects. `frames(i)` supplies frame `i` (`None`: unavailable); `cancel()` is polled
|
||||
/// between frames; `out(object, i, result)` is called as soon as each object's frame is finished
|
||||
/// (`object` indexes `req.objects`).
|
||||
fn track(
|
||||
&self,
|
||||
req: &TrackRequest,
|
||||
frames: &mut dyn FnMut(i64) -> Option<Arc<ClipFrame>>,
|
||||
cancel: &dyn Fn() -> bool,
|
||||
out: &mut dyn FnMut(usize, i64, TrackedFrame),
|
||||
) -> Result<()>;
|
||||
}
|
||||
|
||||
/// What a model is for.
|
||||
@@ -70,8 +141,10 @@ pub struct ModelInfo {
|
||||
pub licence_url: &'static str,
|
||||
/// The project's page.
|
||||
pub homepage: &'static str,
|
||||
/// The official weights file.
|
||||
/// App download URL; empty when only Install from File is available.
|
||||
pub url: &'static str,
|
||||
/// Checkpoint source for manual installation and notices; never used by the app downloader.
|
||||
pub source: &'static str,
|
||||
pub file_name: &'static str,
|
||||
pub size: u64,
|
||||
/// SHA-256 of the weights file (lower-case hex).
|
||||
@@ -91,11 +164,82 @@ pub const MOBILE_SAM: ModelInfo = ModelInfo {
|
||||
licence_url: "https://github.com/ChaoningZhang/MobileSAM/blob/master/LICENSE",
|
||||
homepage: "https://github.com/ChaoningZhang/MobileSAM",
|
||||
url: "https://raw.githubusercontent.com/ChaoningZhang/MobileSAM/master/weights/mobile_sam.pt",
|
||||
source: "https://raw.githubusercontent.com/ChaoningZhang/MobileSAM/master/weights/mobile_sam.pt",
|
||||
file_name: "mobile_sam.pt",
|
||||
size: 40_728_226,
|
||||
sha256: "6dbb90523a35330fedd7f1d3dfc66f995213d81b29a5ca8108dbcdd4e37d6c2f",
|
||||
};
|
||||
|
||||
/// SAM 2.1 Tiny (Meta AI, Apache-2.0): a video object tracker with streaming memory. The weights are
|
||||
/// the Hugging Face `transformers` checkpoint, identified by SHA-256. Until a storytold mirror
|
||||
/// is available, install it from a file. `effectcraft-sam2` (native only) loads it, not [`load`].
|
||||
pub const SAM2_TINY: ModelInfo = ModelInfo {
|
||||
id: "sam2.1-hiera-tiny",
|
||||
task: Task::Mask,
|
||||
name: "SAM 2.1 Tiny",
|
||||
description: "Meta's Segment Anything Model 2 (Hiera-Tiny, 39M parameters). Follows the object through the clip with a streaming memory: it keeps tracking through occlusion and when the object leaves and re-enters the frame. Slower than MobileSAM on a CPU.",
|
||||
authors: "Meta AI (Nikhila Ravi et al., SAM 2), Hugging Face transformers checkpoint",
|
||||
licence: "Apache-2.0",
|
||||
licence_url: "https://github.com/facebookresearch/sam2/blob/main/LICENSE",
|
||||
homepage: "https://github.com/facebookresearch/sam2",
|
||||
url: "",
|
||||
source: "https://huggingface.co/facebook/sam2.1-hiera-tiny/blob/de431c4043854a71d8101e17995dfe596bf101a5/model.safetensors",
|
||||
file_name: "sam2.1-hiera-tiny.safetensors",
|
||||
size: 155_908_064,
|
||||
sha256: "48c14467e5cf9e51870511feb72c89688e82dd74523142c0538b663e193ac2a7",
|
||||
};
|
||||
|
||||
/// SAM 2.1 Small: the Small checkpoint, pinned to a commit like [`SAM2_TINY`].
|
||||
pub const SAM2_SMALL: ModelInfo = ModelInfo {
|
||||
id: "sam2.1-hiera-small",
|
||||
task: Task::Mask,
|
||||
name: "SAM 2.1 Small",
|
||||
description: "Meta's Segment Anything Model 2 (Hiera-Small, 46M parameters). The same streaming-memory tracker as SAM 2.1 Tiny with a larger backbone: a little more accurate on thin parts and busy backgrounds, a little slower.",
|
||||
authors: "Meta AI (Nikhila Ravi et al., SAM 2), Hugging Face transformers checkpoint",
|
||||
licence: "Apache-2.0",
|
||||
licence_url: "https://github.com/facebookresearch/sam2/blob/main/LICENSE",
|
||||
homepage: "https://github.com/facebookresearch/sam2",
|
||||
url: "",
|
||||
source: "https://huggingface.co/facebook/sam2.1-hiera-small/blob/ee5bba1d82bb8749febdf90f45e84b687142ba03/model.safetensors",
|
||||
file_name: "sam2.1-hiera-small.safetensors",
|
||||
size: 184_305_280,
|
||||
sha256: "0a4067b11ce1e23d5229203f11c718a823060d15a4b23fa2372a7d4b77cbbc60",
|
||||
};
|
||||
|
||||
/// SAM 2.1 Base+: the Base+ checkpoint, pinned to a commit like [`SAM2_TINY`].
|
||||
pub const SAM2_BASE_PLUS: ModelInfo = ModelInfo {
|
||||
id: "sam2.1-hiera-base-plus",
|
||||
task: Task::Mask,
|
||||
name: "SAM 2.1 Base+",
|
||||
description: "Meta's Segment Anything Model 2 (Hiera-Base+, 81M parameters). The same streaming-memory tracker as SAM 2.1 Tiny with a larger backbone: better on difficult footage; wants a GPU for comfortable speed.",
|
||||
authors: "Meta AI (Nikhila Ravi et al., SAM 2), Hugging Face transformers checkpoint",
|
||||
licence: "Apache-2.0",
|
||||
licence_url: "https://github.com/facebookresearch/sam2/blob/main/LICENSE",
|
||||
homepage: "https://github.com/facebookresearch/sam2",
|
||||
url: "",
|
||||
source: "https://huggingface.co/facebook/sam2.1-hiera-base-plus/blob/b7320756a13354e7530a63935656d35b2f91a290/model.safetensors",
|
||||
file_name: "sam2.1-hiera-base-plus.safetensors",
|
||||
size: 323_476_296,
|
||||
sha256: "2012733a0de5d03efd1bba550a2847c4551be9ef2e0d497c83074df66189f780",
|
||||
};
|
||||
|
||||
/// SAM 2.1 Large: the Large checkpoint, pinned to a commit like [`SAM2_TINY`].
|
||||
pub const SAM2_LARGE: ModelInfo = ModelInfo {
|
||||
id: "sam2.1-hiera-large",
|
||||
task: Task::Mask,
|
||||
name: "SAM 2.1 Large",
|
||||
description: "Meta's Segment Anything Model 2 (Hiera-Large, 224M parameters). The same streaming-memory tracker as SAM 2.1 Tiny with a larger backbone: the most accurate; about 900 MB and meant for a GPU.",
|
||||
authors: "Meta AI (Nikhila Ravi et al., SAM 2), Hugging Face transformers checkpoint",
|
||||
licence: "Apache-2.0",
|
||||
licence_url: "https://github.com/facebookresearch/sam2/blob/main/LICENSE",
|
||||
homepage: "https://github.com/facebookresearch/sam2",
|
||||
url: "",
|
||||
source: "https://huggingface.co/facebook/sam2.1-hiera-large/blob/665f8e2ad61cf5f53d65644ff27c8ee525124610/model.safetensors",
|
||||
file_name: "sam2.1-hiera-large.safetensors",
|
||||
size: 897_897_416,
|
||||
sha256: "dc407dce21301fd94abb395c5099b4f2c455fdc8a8f261ac3d0ea6d4cd197230",
|
||||
};
|
||||
|
||||
pub const FACE_LANDMARKER: ModelInfo = ModelInfo {
|
||||
id: "mediapipe-face",
|
||||
task: Task::Face,
|
||||
@@ -106,13 +250,14 @@ pub const FACE_LANDMARKER: ModelInfo = ModelInfo {
|
||||
licence_url: "https://storage.googleapis.com/mediapipe-assets/Model%20Card%20MediaPipe%20Face%20Mesh%20V2.pdf",
|
||||
homepage: "https://developers.google.com/edge/mediapipe/solutions/vision/face_landmarker",
|
||||
url: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task",
|
||||
source: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task",
|
||||
file_name: "face_landmarker.task",
|
||||
size: 3_758_596,
|
||||
sha256: "64184e229b263107bc2b804c6625db1341ff2bb731874b0bcc2fe6544e0bc9ff",
|
||||
};
|
||||
|
||||
/// The trained models, besides the built-in classical engines.
|
||||
pub const MODELS: &[ModelInfo] = &[MOBILE_SAM, FACE_LANDMARKER];
|
||||
pub const MODELS: &[ModelInfo] = &[MOBILE_SAM, SAM2_TINY, SAM2_SMALL, SAM2_BASE_PLUS, SAM2_LARGE, FACE_LANDMARKER];
|
||||
|
||||
/// The id of the built-in classical engines (graph cut segmenter, shape-model face tracker): no
|
||||
/// weights, always available.
|
||||
@@ -144,21 +289,42 @@ pub fn load(id: &str, bytes: &[u8]) -> Result<Loaded> {
|
||||
if got != info.sha256 {
|
||||
return Err(format!("{}: the file does not match (SHA-256 {got}, expected {})", info.name, info.sha256));
|
||||
}
|
||||
match id {
|
||||
"mobilesam" => {
|
||||
match loader(id) {
|
||||
Some(Loader::MobileSam) => {
|
||||
let w = pt::read(bytes)?;
|
||||
Ok(Loaded::Mask(Arc::new(Sam { net: mobilesam::MobileSam::from_weights(&w)? })))
|
||||
}
|
||||
"mediapipe-face" => Ok(Loaded::Face(Arc::new(mediapipe::FaceLandmarker::from_task(bytes)?))),
|
||||
_ => Err(format!("no loader for `{id}`")),
|
||||
Some(Loader::MediaPipe) => Ok(Loaded::Face(Arc::new(mediapipe::FaceLandmarker::from_task(bytes)?))),
|
||||
Some(Loader::Desktop) => Err(format!("{} is loaded by the desktop application (effectcraft-sam2)", info.name)),
|
||||
None => Err(format!("no loader for `{id}`")),
|
||||
}
|
||||
}
|
||||
|
||||
/// What builds a registered model.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Loader {
|
||||
MobileSam,
|
||||
MediaPipe,
|
||||
/// Built outside this crate, by the desktop application (every SAM 2.1 size).
|
||||
Desktop,
|
||||
}
|
||||
|
||||
/// The loader for model `id`: one answer for every size of a family, so a new size cannot fall
|
||||
/// through to "no loader" while its sibling gets the helpful error.
|
||||
fn loader(id: &str) -> Option<Loader> {
|
||||
match id {
|
||||
"mobilesam" => Some(Loader::MobileSam),
|
||||
"mediapipe-face" => Some(Loader::MediaPipe),
|
||||
id if id.starts_with("sam2.1-hiera-") => Some(Loader::Desktop),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The notice kept next to installed weights: what they are, who made them, their licence.
|
||||
pub fn notice(m: &ModelInfo) -> String {
|
||||
format!(
|
||||
"{}\n\nAuthors: {}\nLicence: {} (full text: https://spdx.org/licenses/{}.html; as stated by the authors: {})\nSource: {}\nProject: {}\n\nDownloaded unmodified from the source above. EffectCraft runs it with its own implementation\nand is not affiliated with or endorsed by its authors.\n",
|
||||
m.name, m.authors, m.licence, m.licence, m.licence_url, m.url, m.homepage
|
||||
"{}\n\nAuthors: {}\nLicence: {} (full text: https://spdx.org/licenses/{}.html; as stated by the authors: {})\nSource: {}\nSHA-256: {}\nProject: {}\n\nInstalled unmodified. EffectCraft runs it with its own implementation\nand is not affiliated with or endorsed by its authors.\n",
|
||||
m.name, m.authors, m.licence, m.licence, m.licence_url, m.source, m.sha256, m.homepage
|
||||
)
|
||||
}
|
||||
|
||||
@@ -260,13 +426,37 @@ mod tests {
|
||||
for m in MODELS {
|
||||
assert!(ALLOWED_LICENCES.contains(&m.licence), "{}", m.id);
|
||||
assert_eq!(m.sha256.len(), 64);
|
||||
assert!(m.url.starts_with("https://") && m.licence_url.starts_with("https://") && m.homepage.starts_with("https://"));
|
||||
assert!(m.url.is_empty() || m.url.starts_with("https://"));
|
||||
assert!(m.source.starts_with("https://"));
|
||||
assert!(m.licence_url.starts_with("https://") && m.homepage.starts_with("https://"));
|
||||
assert!(!m.authors.is_empty() && notice(m).contains(m.authors));
|
||||
}
|
||||
assert_eq!(
|
||||
models(Task::Mask).map(|m| m.id).collect::<Vec<_>>(),
|
||||
["mobilesam", "sam2.1-hiera-tiny", "sam2.1-hiera-small", "sam2.1-hiera-base-plus", "sam2.1-hiera-large"]
|
||||
);
|
||||
for model in [SAM2_TINY, SAM2_SMALL, SAM2_BASE_PLUS, SAM2_LARGE] {
|
||||
assert!(model.url.is_empty(), "SAM 2 has no app download until a storytold mirror is available");
|
||||
assert!(model.file_name.ends_with(".safetensors"));
|
||||
assert!(model.source.contains("/blob/") && model.source.ends_with("/model.safetensors"));
|
||||
assert!(notice(&model).contains(&format!("Source: {}", model.source)));
|
||||
assert!(notice(&model).contains(model.sha256));
|
||||
}
|
||||
assert_eq!(models(Task::Face).map(|m| m.id).collect::<Vec<_>>(), ["mediapipe-face"]);
|
||||
assert!(load("mediapipe-face", b"nope").err().is_some_and(|e| e.contains("does not match")));
|
||||
// A file that isn't the published one is refused before anything is parsed.
|
||||
assert!(load("mobilesam", b"not the weights").err().is_some_and(|e| e.contains("does not match")));
|
||||
assert!(load("nope", b"").is_err());
|
||||
}
|
||||
|
||||
/// Every registered model has a loader, so none falls through to "no loader" after its checksum
|
||||
/// matches; every SAM 2.1 size is loaded by the desktop application.
|
||||
#[test]
|
||||
fn every_registered_model_has_a_loader() {
|
||||
for m in MODELS {
|
||||
let l = loader(m.id);
|
||||
assert!(l.is_some(), "{} has no loader", m.id);
|
||||
assert_eq!(l == Some(Loader::Desktop), m.id.starts_with("sam2.1-"), "{}", m.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
//! deterministic function of its *chain*: the base frame's strokes, then every frame's strokes
|
||||
//! on the way out to it ([`RotoData::chain_keys`]).
|
||||
|
||||
pub mod bench;
|
||||
pub mod gmm;
|
||||
pub mod matting;
|
||||
pub mod maxflow;
|
||||
@@ -87,6 +88,15 @@ pub struct Stroke {
|
||||
pub frame: i64,
|
||||
pub radius: f64,
|
||||
pub points: Vec<[f64; 2]>,
|
||||
/// Which object of the instance the stroke belongs to (0 for the first, and for every older project).
|
||||
/// With a tracker each object is followed on its own and the mattes are combined; the classic engine
|
||||
/// treats all foreground strokes as one object.
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
pub object: u32,
|
||||
}
|
||||
|
||||
fn is_zero(v: &u32) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
/// A Roto Brush instance's user data: strokes, the base frame and the segmentation span
|
||||
@@ -129,6 +139,18 @@ impl RotoData {
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
/// The objects that have a foreground stroke, in order.
|
||||
pub fn objects(&self) -> Vec<u32> {
|
||||
let mut v: Vec<u32> = self.strokes.iter().filter(|s| s.kind == StrokeKind::Fg).map(|s| s.object).collect();
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
/// The strokes of `object` on `frame`: its own foreground, background and box strokes, and the Refine Edge
|
||||
/// strokes (which belong to the whole matte).
|
||||
pub fn strokes_of(&self, frame: i64, object: u32) -> Vec<&Stroke> {
|
||||
self.strokes.iter().filter(|s| s.frame == frame && (s.object == object || matches!(s.kind, StrokeKind::Refine | StrokeKind::RefineErase))).collect()
|
||||
}
|
||||
pub fn in_span(&self, f: i64) -> bool {
|
||||
self.base.is_some() && f >= self.span[0] && f <= self.span[1]
|
||||
}
|
||||
@@ -223,6 +245,26 @@ pub struct FrameSeg {
|
||||
}
|
||||
|
||||
impl FrameSeg {
|
||||
/// The union of several objects' segmentations of one frame (the largest keeps its colour models). The
|
||||
/// parts must share one size; any that does not is ignored.
|
||||
pub fn union(parts: Vec<FrameSeg>) -> FrameSeg {
|
||||
let Some(first) = parts.first() else { return FrameSeg::default() };
|
||||
let (w, h) = (first.w, first.h);
|
||||
let mut parts: Vec<FrameSeg> = parts.into_iter().filter(|p| p.w == w && p.h == h && p.matte.len() == w * h).collect();
|
||||
parts.sort_by_key(|p| std::cmp::Reverse(p.area()));
|
||||
let mut it = parts.into_iter();
|
||||
let Some(mut out) = it.next() else { return FrameSeg::default() };
|
||||
for p in it {
|
||||
for (a, b) in out.matte.iter_mut().zip(&p.matte) {
|
||||
*a = (*a).max(*b);
|
||||
}
|
||||
for (a, b) in out.refine.iter_mut().zip(&p.refine) {
|
||||
*a = (*a).max(*b);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn empty(w: usize, h: usize) -> FrameSeg {
|
||||
let Some(n) = w.checked_mul(h).filter(|n| *n <= rle::MAX_PLANE_BYTES) else {
|
||||
return FrameSeg::default();
|
||||
@@ -902,6 +944,70 @@ fn points_along(s: &Stroke, scale: f64, max: usize) -> Vec<[f32; 2]> {
|
||||
out
|
||||
}
|
||||
|
||||
/// A tracker's prompt from a frame's strokes (points along the foreground and background strokes,
|
||||
/// in frame pixels at `scale`).
|
||||
///
|
||||
/// SAM 2 was trained on a handful of clicks: with sixteen or more points it doubts its own mask
|
||||
/// (measured: confidence 0.25 with 16 points, 0.00 with 32, 0.99 with 1 to 7), so this picks at
|
||||
/// most [`TRACKER_FG_POINTS`] foreground and [`TRACKER_BG_POINTS`] background points, spread
|
||||
/// along the strokes. Like [`stroke_prompt`], later corrections win over earlier paint.
|
||||
pub fn tracker_prompt(strokes: &[&Stroke], scale: f64, w: usize, h: usize) -> Prompt {
|
||||
let hard = resolve(strokes, StrokeKind::Fg, StrokeKind::Bg, scale, w, h);
|
||||
let prompt = stroke_prompt(strokes, &hard, scale, w, h);
|
||||
let mut points = vec![];
|
||||
for (fg, max) in [(true, TRACKER_FG_POINTS), (false, TRACKER_BG_POINTS)] {
|
||||
let all: Vec<[f32; 2]> = prompt.points.iter().filter_map(|(p, label)| (*label == fg).then_some(*p)).collect();
|
||||
let n = all.len().min(max);
|
||||
// evenly spaced picks, keeping the first and the last
|
||||
points.extend((0..n).filter_map(|i| all.get(if n > 1 { i * (all.len() - 1) / (n - 1) } else { all.len() / 2 }).map(|p| (*p, fg))));
|
||||
}
|
||||
Prompt { points, ..prompt }
|
||||
}
|
||||
|
||||
/// [`tracker_prompt`] plus a box around the classic engine's matte of the same strokes. Clicks alone
|
||||
/// make SAM 2 pick the part they land on (a torso, not the legs and shoes: measured IoU 0.41 on a
|
||||
/// multi-part figure, against 1.00 with the box); the box says how far the object reaches. A box
|
||||
/// stroke the user drew wins over the matte's.
|
||||
pub fn tracker_prompt_boxed(strokes: &[&Stroke], scale: f64, classic: &FrameSeg) -> Prompt {
|
||||
let mut p = tracker_prompt(strokes, scale, classic.w, classic.h);
|
||||
if p.bbox.is_none() {
|
||||
let (w, h) = (classic.w, classic.h);
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize);
|
||||
for (i, v) in classic.matte.iter().enumerate() {
|
||||
if *v != 0 && w > 0 {
|
||||
let (x, y) = (i % w, i / w);
|
||||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
|
||||
}
|
||||
}
|
||||
if x0 <= x1 {
|
||||
let (mx, my) = (((x1 - x0) as f32 * 0.05).max(4.0), ((y1 - y0) as f32 * 0.05).max(4.0));
|
||||
p.bbox = Some([(x0 as f32 - mx).max(0.0), (y0 as f32 - my).max(0.0), (x1 as f32 + 1.0 + mx).min(w as f32), (y1 as f32 + 1.0 + my).min(h as f32)]);
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Most foreground / background points a tracker prompt carries.
|
||||
pub const TRACKER_FG_POINTS: usize = 3;
|
||||
pub const TRACKER_BG_POINTS: usize = 3;
|
||||
|
||||
/// A frame's segmentation from a tracker's probability map: the map is the shape prior of the
|
||||
/// same graph cut as everywhere else, so the strokes stay hard constraints and the edges snap
|
||||
/// to colour. An absent object gives an empty matte (strokes on the frame still win); a map of
|
||||
/// the wrong size is ignored.
|
||||
pub fn segment_tracked(img: &Image, strokes: &[&Stroke], prob: Vec<f32>, occluded: bool, opts: &SegOpts, refine_radius: f64) -> FrameSeg {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
if prob.len() != w * h || w == 0 || h == 0 {
|
||||
return segment(img, strokes, None, opts, refine_radius);
|
||||
}
|
||||
let has_fg = strokes.iter().any(|s| s.kind == StrokeKind::Fg);
|
||||
if (occluded || !prob.iter().any(|p| *p >= 0.5)) && !has_fg {
|
||||
return FrameSeg::empty(w, h);
|
||||
}
|
||||
let prior = Prior { soft: prob, fg: Gmm::default(), bg: Gmm::default(), refine: vec![0; w * h], weight: MODEL_PRIOR };
|
||||
segment(img, strokes, Some(&prior), opts, refine_radius)
|
||||
}
|
||||
|
||||
/// A model's prompt from the strokes: points along the foreground and background strokes, but
|
||||
/// not where a later stroke of the other kind painted over them (`hard`, from [`resolve`]), and
|
||||
/// the box of a [`StrokeKind::Box`] stroke (matte pixels). A stroke painted over at every point
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
//! Roto Brush benchmark: synthetic clips with known mattes (movement, colour change, occlusion,
|
||||
//! leaving and re-entering the frame) and the metrics that compare trackers on them.
|
||||
//!
|
||||
//! Needs no weights: the classical engine always runs, and a trained [`MaskModel`] (MobileSAM,
|
||||
//! later SAM 2) is added when the caller has one. `effectcraft-cli bench --roto` prints the table.
|
||||
|
||||
use effectcraft_raster::Image;
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_segment::{ClipFrame, MaskModel, TrackRequest};
|
||||
use serde::Serialize;
|
||||
use web_time::Instant;
|
||||
|
||||
use super::{FrameSeg, SegOpts, Stroke, StrokeKind, iou, propagate_with, rgb_of, segment_tracked, segment_with, tracker_prompt_boxed};
|
||||
|
||||
pub const W: u32 = 200;
|
||||
pub const H: u32 = 150;
|
||||
const R: f64 = 32.0;
|
||||
|
||||
/// A clip with the true matte of the visible object on every frame.
|
||||
#[derive(Clone)]
|
||||
pub struct Scenario {
|
||||
pub name: String,
|
||||
pub what: String,
|
||||
/// Frame size in pixels (the synthetic clips are `W × H`; a real clip has its own).
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub frames: Vec<Image>,
|
||||
/// Visible-object matte per frame (`w × h`, 0/1); empty when the object is out of view.
|
||||
pub truth: Vec<Vec<u8>>,
|
||||
/// Frame the strokes are painted on.
|
||||
pub base: usize,
|
||||
pub strokes: Vec<Stroke>,
|
||||
}
|
||||
|
||||
/// One tracker's result on one scenario.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Run {
|
||||
pub scenario: String,
|
||||
pub tracker: String,
|
||||
pub mean_iou: f64,
|
||||
pub min_iou: f64,
|
||||
/// Mean IoU between consecutive mattes minus the same for the truth, as a loss (0 is steady).
|
||||
pub flicker: f64,
|
||||
pub secs_per_frame: f64,
|
||||
/// Mean IoU over the frames after the object's last absence (re-appearance), if it had one.
|
||||
pub after_return: Option<f64>,
|
||||
pub per_frame: Vec<f64>,
|
||||
/// Correction rounds needed before every frame reaches the target IoU (`>N`: not within N); set by
|
||||
/// [`with_fixes`], `None` when not measured.
|
||||
pub fixes: Option<String>,
|
||||
/// The most memory (MB) the tracker held for its memory banks and cached frames (trackers that have none: `None`).
|
||||
pub peak_mb: Option<f64>,
|
||||
/// The matte of every frame (not serialised).
|
||||
#[serde(skip)]
|
||||
pub mattes: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn noise(x: f64, y: f64, seed: u32) -> f64 {
|
||||
fn h(i: i64, j: i64, s: u32) -> f64 {
|
||||
let mut v = (i.wrapping_mul(73856093) ^ j.wrapping_mul(19349663) ^ i64::from(s).wrapping_mul(83492791)) as u64;
|
||||
v ^= v >> 13;
|
||||
v = v.wrapping_mul(0x5bd1e995);
|
||||
v ^= v >> 15;
|
||||
(v % 10_000) as f64 / 10_000.0
|
||||
}
|
||||
let mut out = 0.0;
|
||||
for (cell, amp) in [(9.0, 0.6), (3.5, 0.4)] {
|
||||
let (fx, fy) = (x / cell, y / cell);
|
||||
let (i, j) = (fx.floor() as i64, fy.floor() as i64);
|
||||
let (tx, ty) = (fx - i as f64, fy - j as f64);
|
||||
let (sx, sy) = (tx * tx * (3.0 - 2.0 * tx), ty * ty * (3.0 - 2.0 * ty));
|
||||
let a = h(i, j, seed) + (h(i + 1, j, seed) - h(i, j, seed)) * sx;
|
||||
let b = h(i, j + 1, seed) + (h(i + 1, j + 1, seed) - h(i, j + 1, seed)) * sx;
|
||||
out += amp * (a + (b - a) * sy);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What a frame shows: the disk centre, an optional occluding bar `[x0, y0, x1, y1]` and a
|
||||
/// brightness gain applied to the whole frame.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Look {
|
||||
c: [f64; 2],
|
||||
bar: Option<[f64; 4]>,
|
||||
gain: f32,
|
||||
}
|
||||
|
||||
fn render(l: Look) -> (Image, Vec<u8>) {
|
||||
let mut img = Image::new(W, H);
|
||||
let mut gt = vec![0u8; (W * H) as usize];
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let p = [f64::from(x) + 0.5, f64::from(y) + 0.5];
|
||||
let d = (p[0] - l.c[0]).hypot(p[1] - l.c[1]);
|
||||
let a = (R + 0.5 - d).clamp(0.0, 1.0) as f32;
|
||||
let n = noise(p[0] - l.c[0], p[1] - l.c[1], 11);
|
||||
let f = [(0.75 + 0.25 * n) as f32, (0.35 + 0.3 * n) as f32, (0.1 + 0.15 * n) as f32];
|
||||
let (bn, bm) = (noise(p[0], p[1], 5), noise(p[0] * 1.7, p[1] * 1.7, 9));
|
||||
let b = [(0.05 + 0.25 * bm) as f32, (0.3 + 0.4 * bn) as f32, (0.45 + 0.4 * bm) as f32];
|
||||
let mut px = [0.0f32; 3];
|
||||
for k in 0..3 {
|
||||
px[k] = f[k] * a + b[k] * (1.0 - a);
|
||||
}
|
||||
let under_bar = l.bar.is_some_and(|r| p[0] >= r[0] && p[0] < r[2] && p[1] >= r[1] && p[1] < r[3]);
|
||||
if under_bar {
|
||||
let t = noise(p[0] * 0.8, p[1] * 0.8, 23) as f32;
|
||||
px = [0.15 + 0.1 * t, 0.15 + 0.1 * t, 0.2 + 0.1 * t];
|
||||
}
|
||||
let g = l.gain;
|
||||
img.set(x, y, [(px[0] * g).min(1.0), (px[1] * g).min(1.0), (px[2] * g).min(1.0), 1.0]);
|
||||
if let Some(v) = gt.get_mut((y * W + x) as usize) {
|
||||
*v = (d <= R && !under_bar) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
(img, gt)
|
||||
}
|
||||
|
||||
fn line(kind: StrokeKind, frame: i64, a: [f64; 2], b: [f64; 2], radius: f64) -> Stroke {
|
||||
let pt = |k: u32| {
|
||||
let t = f64::from(k) / 10.0;
|
||||
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
|
||||
};
|
||||
Stroke { kind, frame, radius, points: (0..=10).map(pt).collect(), object: 0 }
|
||||
}
|
||||
|
||||
/// The strokes an artist would paint on the base frame: one through the disk, two around it.
|
||||
fn strokes_for(c: [f64; 2]) -> Vec<Stroke> {
|
||||
vec![
|
||||
line(StrokeKind::Fg, 0, [c[0] - 18.0, c[1] - 6.0], [c[0] + 18.0, c[1] + 6.0], 5.0),
|
||||
line(StrokeKind::Bg, 0, [c[0] - 50.0, c[1] - 45.0], [c[0] + 50.0, c[1] - 45.0], 5.0),
|
||||
line(StrokeKind::Bg, 0, [c[0] + 50.0, c[1] + 45.0], [c[0] - 45.0, c[1] + 45.0], 5.0),
|
||||
]
|
||||
}
|
||||
|
||||
fn scenario(name: &'static str, what: &'static str, n: usize, look: impl Fn(usize) -> Look) -> Scenario {
|
||||
let (mut frames, mut truth) = (Vec::with_capacity(n), Vec::with_capacity(n));
|
||||
for f in 0..n {
|
||||
let (img, gt) = render(look(f));
|
||||
frames.push(img);
|
||||
truth.push(gt);
|
||||
}
|
||||
Scenario { name: name.to_string(), what: what.to_string(), w: W as usize, h: H as usize, frames, truth, base: 0, strokes: strokes_for(look(0).c) }
|
||||
}
|
||||
|
||||
/// A scenario from the user's own clip: frames and their true mattes (0/1, same size as the frames). The strokes
|
||||
/// are made from the first frame with an object: a short foreground stroke through its centre and background
|
||||
/// strokes along the top and bottom edges where the truth is empty. Nothing is bundled: the clip stays with the
|
||||
/// user (a licensed dataset, say), and so does its licence.
|
||||
pub fn scenario_from(name: &str, frames: Vec<Image>, truth: Vec<Vec<u8>>) -> std::result::Result<Scenario, String> {
|
||||
let first = frames.first().ok_or("a clip needs at least one frame")?;
|
||||
let (w, h) = (first.width as usize, first.height as usize);
|
||||
if w == 0
|
||||
|| h == 0
|
||||
|| frames.len() != truth.len()
|
||||
|| frames.iter().any(|f| (f.width as usize, f.height as usize) != (w, h))
|
||||
|| truth.iter().any(|t| t.len() != w * h)
|
||||
{
|
||||
return Err("frames and truth masks must be the same size and count".into());
|
||||
}
|
||||
let base = truth.iter().position(|t| t.iter().any(|v| *v != 0)).ok_or("no frame has the object in its truth mask")?;
|
||||
let t = truth.get(base).map(Vec::as_slice).unwrap_or(&[]);
|
||||
let (mut x0, mut x1, mut sx, mut sy, mut n) = (usize::MAX, 0usize, 0.0f64, 0.0f64, 0.0f64);
|
||||
for i in t.iter().enumerate().filter(|(_, v)| **v != 0).map(|(i, _)| i) {
|
||||
let (x, y) = (i % w, i / w);
|
||||
(x0, x1) = (x0.min(x), x1.max(x));
|
||||
sx += x as f64 + 0.5;
|
||||
sy += y as f64 + 0.5;
|
||||
n += 1.0;
|
||||
}
|
||||
let (cx, cy) = (sx / n, sy / n);
|
||||
let reach = (((x1 - x0) as f64) * 0.15).max(2.0);
|
||||
let radius = ((w.min(h) as f64) * 0.03).max(3.0);
|
||||
let mut strokes = vec![line(StrokeKind::Fg, base as i64, [cx - reach, cy], [cx + reach, cy], radius)];
|
||||
for y in [h as f64 * 0.04, h as f64 * 0.96] {
|
||||
let row = y as usize;
|
||||
let free = t.get(row * w..(row + 1) * w).is_some_and(|r| r.iter().all(|v| *v == 0));
|
||||
if free {
|
||||
strokes.push(line(StrokeKind::Bg, base as i64, [w as f64 * 0.05, y], [w as f64 * 0.95, y], radius));
|
||||
}
|
||||
}
|
||||
Ok(Scenario { name: name.to_string(), what: "a clip supplied by the user".to_string(), w, h, frames, truth, base, strokes })
|
||||
}
|
||||
|
||||
/// The benchmark clips, `n` frames each (at least 12, so every event happens).
|
||||
pub fn scenarios(n: usize) -> Vec<Scenario> {
|
||||
let n = n.clamp(12, 240);
|
||||
let k = n as f64 / 24.0;
|
||||
let drift = move |f: usize| {
|
||||
let t = f as f64 / k;
|
||||
[60.0 + 3.1 * t, 70.0 + 1.3 * t + 4.0 * (t * 0.3).sin()]
|
||||
};
|
||||
vec![
|
||||
scenario("move", "disk drifts across a textured background", n, move |f| Look { c: drift(f), bar: None, gain: 1.0 }),
|
||||
scenario("light", "same motion while the whole frame brightens by 40 %", n, move |f| Look {
|
||||
c: drift(f),
|
||||
bar: None,
|
||||
gain: 1.0 + 0.4 * f as f32 / n as f32,
|
||||
}),
|
||||
scenario("occlusion", "a bar crosses and hides most of the disk, then clears it", n, move |f| {
|
||||
let t = f as f64 / n as f64;
|
||||
let c = drift(f);
|
||||
let x = -40.0 + 320.0 * ((t - 0.3) / 0.4).clamp(0.0, 1.0);
|
||||
Look { c, bar: (0.3..0.7).contains(&t).then_some([x - 40.0, 0.0, x + 40.0, f64::from(H)]), gain: 1.0 }
|
||||
}),
|
||||
scenario("reenter", "disk leaves through the right edge and comes back", n, move |f| {
|
||||
let t = f as f64 / n as f64;
|
||||
let x = 60.0 + 190.0 * (1.0 - (2.0 * t - 1.0).powi(2));
|
||||
Look { c: [x, 70.0 + 4.0 * (t * 6.0).sin()], bar: None, gain: 1.0 }
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn metrics(sc: &Scenario, tracker: &str, mattes: &[Vec<u8>], secs: f64) -> Run {
|
||||
let per_frame: Vec<f64> = mattes.iter().zip(&sc.truth).map(|(m, t)| iou(m, t)).collect();
|
||||
let n = per_frame.len().max(1) as f64;
|
||||
let steady = |v: &[Vec<u8>]| {
|
||||
let pairs: Vec<f64> = v.windows(2).filter_map(|w| Some(iou(w.first()?, w.get(1)?))).collect();
|
||||
pairs.iter().sum::<f64>() / pairs.len().max(1) as f64
|
||||
};
|
||||
let last_gap = sc.truth.iter().rposition(|t| t.iter().all(|v| *v == 0));
|
||||
let after_return = last_gap.and_then(|g| {
|
||||
let tail = per_frame.get(g + 1..)?;
|
||||
(!tail.is_empty()).then(|| tail.iter().sum::<f64>() / tail.len() as f64)
|
||||
});
|
||||
Run {
|
||||
scenario: sc.name.clone(),
|
||||
tracker: tracker.to_string(),
|
||||
mean_iou: per_frame.iter().sum::<f64>() / n,
|
||||
min_iou: per_frame.iter().copied().fold(1.0, f64::min),
|
||||
flicker: (steady(&sc.truth) - steady(mattes)).max(0.0),
|
||||
secs_per_frame: secs / n,
|
||||
after_return,
|
||||
per_frame,
|
||||
fixes: None,
|
||||
peak_mb: None,
|
||||
mattes: mattes.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Segment the base frame from the strokes and propagate to the end of the clip, as the Roto
|
||||
/// Brush does, with `model` (or none: the classical engine).
|
||||
pub fn run_flow(sc: &Scenario, tracker: &str, model: Option<&dyn MaskModel>) -> Run {
|
||||
let opts = SegOpts::default();
|
||||
let start = Instant::now();
|
||||
let refs: Vec<&Stroke> = sc.strokes.iter().filter(|s| s.frame == sc.base as i64).collect();
|
||||
let mut segs: Vec<Option<FrameSeg>> = vec![None; sc.frames.len()];
|
||||
if let Some(first) = sc.frames.get(sc.base) {
|
||||
let base = segment_with(first, &refs, None, &opts, 0.0, model);
|
||||
if let Some(slot) = segs.get_mut(sc.base) {
|
||||
*slot = Some(base);
|
||||
}
|
||||
}
|
||||
for f in sc.base + 1..sc.frames.len() {
|
||||
let (Some(prev_img), Some(img)) = (sc.frames.get(f - 1), sc.frames.get(f)) else { break };
|
||||
let Some(Some(prev)) = segs.get(f - 1) else { break };
|
||||
let here: Vec<&Stroke> = sc.strokes.iter().filter(|s| s.frame == f as i64).collect();
|
||||
let next = propagate_with(prev_img, prev, img, &here, &opts, 0.0, model);
|
||||
if let Some(slot) = segs.get_mut(f) {
|
||||
*slot = Some(next);
|
||||
}
|
||||
}
|
||||
let secs = start.elapsed().as_secs_f64();
|
||||
let empty = vec![0u8; sc.w * sc.h];
|
||||
let mattes: Vec<Vec<u8>> = segs.into_iter().map(|s| s.map_or_else(|| empty.clone(), |s| s.matte)).collect();
|
||||
metrics(sc, tracker, &mattes, secs)
|
||||
}
|
||||
|
||||
/// Follow the object through the whole clip with the model's video tracker, as the Roto Brush's
|
||||
/// tracking pass does (prompt from the strokes, probability maps refined by the same graph cut).
|
||||
/// `None` when the model has no tracker or the tracker fails.
|
||||
pub fn run_tracker(sc: &Scenario, tracker: &str, model: &dyn MaskModel) -> Option<Run> {
|
||||
let t = model.tracker()?;
|
||||
let opts = SegOpts::default();
|
||||
let at = |f: usize| -> Vec<&Stroke> { sc.strokes.iter().filter(|s| s.frame == f as i64).collect() };
|
||||
let clip: Vec<Arc<ClipFrame>> = sc.frames.iter().map(|i| Arc::new(ClipFrame { rgb: rgb_of(i), w: i.width as usize, h: i.height as usize })).collect();
|
||||
// a prompt per stroke frame: a few clicks and the box of the classic matte (as Chain::track does)
|
||||
let mut prompts = vec![];
|
||||
for f in 0..sc.frames.len() {
|
||||
let strokes = at(f);
|
||||
let Some(img) = sc.frames.get(f).filter(|_| strokes.iter().any(|s| s.kind == StrokeKind::Fg)) else { continue };
|
||||
prompts.push((f as i64, tracker_prompt_boxed(&strokes, 1.0, &super::segment(img, &strokes, None, &opts, 0.0))));
|
||||
}
|
||||
let req = TrackRequest::single(0, sc.frames.len(), prompts);
|
||||
let start = Instant::now();
|
||||
let mut mattes: Vec<Vec<u8>> = vec![vec![0u8; sc.w * sc.h]; sc.frames.len()];
|
||||
let mut get = |f: i64| usize::try_from(f).ok().and_then(|i| clip.get(i).cloned());
|
||||
let r = t.track(&req, &mut get, &|| false, &mut |_, f, tf| {
|
||||
let (Ok(i), true) = (usize::try_from(f), tf.prob.len() == sc.w * sc.h) else { return };
|
||||
let Some(img) = sc.frames.get(i) else { return };
|
||||
let strokes = at(i);
|
||||
if let Some(slot) = mattes.get_mut(i) {
|
||||
*slot = segment_tracked(img, &strokes, tf.prob, tf.occluded, &opts, 0.0).matte;
|
||||
}
|
||||
});
|
||||
r.ok()?;
|
||||
let mut run = metrics(sc, tracker, &mattes, start.elapsed().as_secs_f64());
|
||||
run.peak_mb = model.peak_state_bytes().map(|b| b as f64 / (1 << 20) as f64);
|
||||
Some(run)
|
||||
}
|
||||
|
||||
/// The correction a user would paint on frame `f` where `pred` is wrong: a short foreground stroke through
|
||||
/// the true object's centre, and a background stroke at the centroid of all false-positive pixels.
|
||||
fn correction(sc: &Scenario, f: usize, pred: &[u8]) -> Vec<Stroke> {
|
||||
let truth = sc.truth.get(f).map(Vec::as_slice).unwrap_or(&[]);
|
||||
let centre = |keep: &dyn Fn(usize) -> bool| -> Option<[f64; 2]> {
|
||||
let (mut sx, mut sy, mut n) = (0.0, 0.0, 0.0);
|
||||
for i in (0..truth.len().min(pred.len())).filter(|i| keep(*i)) {
|
||||
sx += (i % sc.w.max(1)) as f64 + 0.5;
|
||||
sy += (i / sc.w.max(1)) as f64 + 0.5;
|
||||
n += 1.0;
|
||||
}
|
||||
(n > 0.0).then(|| [sx / n, sy / n])
|
||||
};
|
||||
let mut out = vec![];
|
||||
if let Some(c) = centre(&|i| truth.get(i).is_some_and(|v| *v != 0)) {
|
||||
out.push(line(StrokeKind::Fg, f as i64, [c[0] - 12.0, c[1]], [c[0] + 12.0, c[1]], 5.0));
|
||||
}
|
||||
if let Some(c) = centre(&|i| pred.get(i).is_some_and(|v| *v != 0) && truth.get(i).is_some_and(|v| *v == 0)) {
|
||||
out.push(line(StrokeKind::Bg, f as i64, [c[0] - 6.0, c[1]], [c[0] + 6.0, c[1]], 6.0));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Repeat `run` on `sc`, each time painting the correction a user would on the first frame under `target` IoU,
|
||||
/// until every frame reaches it or `max` correction rounds were needed. Sets `fixes` on the first run's result:
|
||||
/// the number of rounds (each can add foreground and background strokes), or `>max`.
|
||||
pub fn with_fixes(sc: &Scenario, target: f64, max: usize, run: &dyn Fn(&Scenario) -> Option<Run>) -> Option<Run> {
|
||||
let mut cur = sc.clone();
|
||||
let mut first: Option<Run> = None;
|
||||
for k in 0..=max {
|
||||
let r = run(&cur)?;
|
||||
let bad = r.per_frame.iter().position(|v| *v < target);
|
||||
let first_run = first.get_or_insert_with(|| r.clone());
|
||||
let Some(f) = bad else {
|
||||
first_run.fixes = Some(k.to_string());
|
||||
break;
|
||||
};
|
||||
if k == max {
|
||||
first_run.fixes = Some(format!(">{max}"));
|
||||
break;
|
||||
}
|
||||
let pred = r.mattes.get(f).cloned().unwrap_or_default();
|
||||
let fix = correction(&cur, f, &pred);
|
||||
if fix.is_empty() {
|
||||
first_run.fixes = Some(format!(">{max}"));
|
||||
break;
|
||||
}
|
||||
cur.strokes.extend(fix);
|
||||
}
|
||||
first
|
||||
}
|
||||
|
||||
/// A fixed-width table of the runs, grouped by scenario.
|
||||
pub fn table(runs: &[Run]) -> String {
|
||||
let mut out = format!(
|
||||
"{:<10} {:<12} {:>8} {:>8} {:>8} {:>10} {:>9} {:>6} {:>7}\n",
|
||||
"clip", "tracker", "meanIoU", "minIoU", "flicker", "after ret.", "s/frame", "fixes", "peakMB"
|
||||
);
|
||||
for r in runs {
|
||||
let back = r.after_return.map_or_else(|| "-".to_string(), |v| format!("{v:.3}"));
|
||||
out.push_str(&format!(
|
||||
"{:<10} {:<12} {:>8.3} {:>8.3} {:>8.3} {:>10} {:>9.3} {:>6} {:>7}\n",
|
||||
r.scenario,
|
||||
r.tracker,
|
||||
r.mean_iou,
|
||||
r.min_iou,
|
||||
r.flicker,
|
||||
back,
|
||||
r.secs_per_frame,
|
||||
r.fixes.as_deref().unwrap_or("-"),
|
||||
r.peak_mb.map_or_else(|| "-".to_string(), |v| format!("{v:.0}"))
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn area(v: &[u8]) -> usize {
|
||||
v.iter().filter(|x| **x != 0).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenarios_have_the_events_they_claim() {
|
||||
let all = scenarios(24);
|
||||
assert_eq!(all.len(), 4);
|
||||
for s in &all {
|
||||
assert_eq!((s.frames.len(), s.truth.len()), (24, 24), "{}", s.name);
|
||||
}
|
||||
let full = all.first().map(|s| area(&s.truth[0])).unwrap_or(0);
|
||||
assert!(full > 3000, "{full}");
|
||||
let occ = all.iter().find(|s| s.name == "occlusion").map(|s| s.truth.iter().map(|t| area(t)).min().unwrap_or(0)).unwrap_or(full);
|
||||
assert!(occ < full / 2, "occluded minimum {occ} of {full}");
|
||||
let gone = all.iter().find(|s| s.name == "reenter").map(|s| s.truth.iter().filter(|t| area(t) == 0).count()).unwrap_or(0);
|
||||
assert!(gone > 0, "the disk never leaves");
|
||||
assert!(all.iter().find(|s| s.name == "reenter").is_some_and(|s| area(s.truth.last().map_or(&[][..], |v| v)) > 0), "it must come back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classical_tracks_plain_motion_and_the_table_lists_it() {
|
||||
let all = scenarios(24);
|
||||
let Some(sc) = all.first() else { return };
|
||||
let run = run_flow(sc, "classical", None);
|
||||
assert!(run.mean_iou > 0.9, "mean IoU {}", run.mean_iou);
|
||||
assert_eq!(run.per_frame.len(), 24);
|
||||
assert!(table(&[run]).contains("classical"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrections_are_counted_until_every_frame_reaches_the_target() {
|
||||
let all = scenarios(24);
|
||||
let classical = |s: &Scenario| Some(run_flow(s, "classical", None));
|
||||
// plain motion needs none
|
||||
let easy = all.iter().find(|s| s.name == "move").and_then(|s| with_fixes(s, 0.9, 6, &classical)).unwrap();
|
||||
assert_eq!(easy.fixes.as_deref(), Some("0"));
|
||||
// after the disk has left and returned the classic engine has lost it: it needs a correction (or never gets there)
|
||||
let hard = all.iter().find(|s| s.name == "reenter").and_then(|s| with_fixes(s, 0.9, 6, &classical)).unwrap();
|
||||
assert_ne!(hard.fixes.as_deref(), Some("0"), "{:?}", hard.fixes);
|
||||
assert!(table(&[easy, hard]).contains("fixes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_users_clip_becomes_a_scenario_and_bad_ones_are_refused() {
|
||||
let all = scenarios(24);
|
||||
let src = all.iter().find(|s| s.name == "move").unwrap();
|
||||
let sc = scenario_from("mine", src.frames.clone(), src.truth.clone()).unwrap();
|
||||
assert_eq!((sc.w, sc.h, sc.base), (W as usize, H as usize, 0));
|
||||
assert!(sc.strokes.iter().any(|s| s.kind == StrokeKind::Fg) && sc.strokes.iter().any(|s| s.kind == StrokeKind::Bg));
|
||||
let run = run_flow(&sc, "classical", None);
|
||||
assert!(run.mean_iou > 0.9, "{}", run.mean_iou);
|
||||
assert!(scenario_from("x", vec![], vec![]).is_err());
|
||||
assert!(scenario_from("x", src.frames.clone(), src.truth[..3].to_vec()).is_err());
|
||||
assert!(scenario_from("x", src.frames.clone(), vec![vec![0; sc.w * sc.h]; src.frames.len()]).is_err(), "no object anywhere");
|
||||
let mut short = src.truth.clone();
|
||||
short[0].pop();
|
||||
assert!(scenario_from("x", src.frames.clone(), short).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_clip_requests_are_clamped() {
|
||||
assert!(scenarios(0).iter().all(|s| s.frames.len() == 12));
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,13 @@ pub(crate) fn frame_at(c: [f64; 2]) -> (Image, Vec<u8>) {
|
||||
}
|
||||
|
||||
fn line(kind: StrokeKind, frame: i64, a: [f64; 2], b: [f64; 2], radius: f64) -> Stroke {
|
||||
Stroke { kind, frame, radius, points: (0..=10).map(|k| [a[0] + (b[0] - a[0]) * k as f64 / 10.0, a[1] + (b[1] - a[1]) * k as f64 / 10.0]).collect() }
|
||||
Stroke {
|
||||
kind,
|
||||
frame,
|
||||
radius,
|
||||
points: (0..=10).map(|k| [a[0] + (b[0] - a[0]) * k as f64 / 10.0, a[1] + (b[1] - a[1]) * k as f64 / 10.0]).collect(),
|
||||
object: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The strokes an artist would paint on frame `f`: one through the disk, one around it.
|
||||
@@ -231,6 +237,7 @@ fn ring(frame: i64, c: [f64; 2], r: f64, radius: f64) -> Stroke {
|
||||
frame,
|
||||
radius,
|
||||
points: (0..=48).map(|k| k as f64 / 48.0 * std::f64::consts::TAU).map(|a| [c[0] + r * a.cos(), c[1] + r * a.sin()]).collect(),
|
||||
object: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +302,7 @@ fn later_refine_strokes_override_earlier_erases() {
|
||||
let (img, _) = soft_frame(c, 10.0, [0.9, 0.25, 0.1], [0.1, 0.3, 0.9]);
|
||||
let spot = [c[0] + R, c[1]];
|
||||
let i = spot[1] as usize * W as usize + spot[0] as usize;
|
||||
let erase = Stroke { kind: StrokeKind::RefineErase, frame: 0, radius: 6.0, points: vec![spot] };
|
||||
let erase = Stroke { kind: StrokeKind::RefineErase, frame: 0, radius: 6.0, points: vec![spot], object: 0 };
|
||||
let refine = Stroke { kind: StrokeKind::Refine, ..erase.clone() };
|
||||
let band = |st: &[Stroke]| segment(&img, &st.iter().collect::<Vec<_>>(), None, &SegOpts::default(), 9.0).refine[i];
|
||||
let mut st = vec![
|
||||
@@ -389,7 +396,7 @@ fn roto_perf_1080p() {
|
||||
let mut seg2 = next.clone();
|
||||
let c = center(1);
|
||||
let ring: Vec<[f64; 2]> = (0..=64).map(|k| k as f64 / 64.0 * std::f64::consts::TAU).map(|t| [(c[0] + R * t.cos()) * s, (c[1] + R * t.sin()) * s]).collect();
|
||||
let st = [Stroke { kind: StrokeKind::Refine, frame: 1, radius: 20.0, points: ring }];
|
||||
let st = [Stroke { kind: StrokeKind::Refine, frame: 1, radius: 20.0, points: ring, object: 0 }];
|
||||
let r = rasterize(&st.iter().collect::<Vec<_>>(), &[StrokeKind::Refine], 1.0, 1920, 1080);
|
||||
seg2.refine = r.iter().map(|v| *v as u8).collect();
|
||||
let rgb = rgb_of(&b);
|
||||
@@ -500,8 +507,8 @@ fn mobilesam_segments_and_propagates_the_disk() {
|
||||
/// ignores it; a prior's box does not replace it.
|
||||
#[test]
|
||||
fn box_stroke_is_the_model_box_prompt() {
|
||||
let fg = Stroke { kind: StrokeKind::Fg, frame: 0, radius: 2.0, points: vec![[50.0, 40.0]] };
|
||||
let bx = Stroke { kind: StrokeKind::Box, frame: 0, radius: 0.0, points: vec![[80.0, 60.0], [20.0, 10.0]] };
|
||||
let fg = Stroke { kind: StrokeKind::Fg, frame: 0, radius: 2.0, points: vec![[50.0, 40.0]], object: 0 };
|
||||
let bx = Stroke { kind: StrokeKind::Box, frame: 0, radius: 0.0, points: vec![[80.0, 60.0], [20.0, 10.0]], object: 0 };
|
||||
let p = stroke_prompt(&[&fg, &bx], &[], 0.5, 0, 0);
|
||||
assert_eq!(p.bbox, Some([10.0, 5.0, 40.0, 30.0]));
|
||||
assert_eq!(p.points, vec![([25.0, 20.0], true)]);
|
||||
@@ -523,14 +530,14 @@ fn box_stroke_is_the_model_box_prompt() {
|
||||
/// out what this frame's background strokes removed.
|
||||
#[test]
|
||||
fn model_prompts_follow_the_latest_stroke() {
|
||||
let green = Stroke { kind: StrokeKind::Fg, frame: 0, radius: 4.0, points: vec![[50.0, 40.0]] };
|
||||
let green = Stroke { kind: StrokeKind::Fg, frame: 0, radius: 4.0, points: vec![[50.0, 40.0]], object: 0 };
|
||||
let red = Stroke { kind: StrokeKind::Bg, ..green.clone() };
|
||||
let (w, h) = (100, 80);
|
||||
let prompt = |st: &[&Stroke]| stroke_prompt(st, &resolve(st, StrokeKind::Fg, StrokeKind::Bg, 1.0, w, h), 1.0, w, h).points;
|
||||
assert_eq!(prompt(&[&green, &red]), vec![([50.0, 40.0], false)]);
|
||||
assert_eq!(prompt(&[&green, &red, &green]), vec![([50.0, 40.0], true); 2]);
|
||||
let soft: Vec<f32> = (0..w * h).map(|i| ((i % w) < 50) as u8 as f32).collect();
|
||||
let erase = Stroke { kind: StrokeKind::Bg, frame: 0, radius: 15.0, points: vec![[25.0, 40.0]] };
|
||||
let erase = Stroke { kind: StrokeKind::Bg, frame: 0, radius: 15.0, points: vec![[25.0, 40.0]], object: 0 };
|
||||
let hard = resolve(&[&erase], StrokeKind::Fg, StrokeKind::Bg, 1.0, w, h);
|
||||
let mut q = Prompt::default();
|
||||
assert!(add_prior_prompt(&mut q, &soft, &hard, w, h));
|
||||
@@ -561,7 +568,7 @@ impl effectcraft_segment::MaskModel for CountingModel {
|
||||
fn a_correction_keeps_the_model_prompted_with_what_is_left() {
|
||||
let (img, gt, rect) = bar_frame();
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let dot = Stroke { kind: StrokeKind::Fg, frame: 0, radius: 15.0, points: vec![[80.0, 50.0]] };
|
||||
let dot = Stroke { kind: StrokeKind::Fg, frame: 0, radius: 15.0, points: vec![[80.0, 50.0]], object: 0 };
|
||||
let tap = Stroke { kind: StrokeKind::Bg, radius: 2.0, ..dot.clone() };
|
||||
let refs = [&dot, &tap];
|
||||
let model = CountingModel(rect, Default::default());
|
||||
@@ -579,3 +586,139 @@ fn a_correction_keeps_the_model_prompted_with_what_is_left() {
|
||||
let hard = resolve(&refs, StrokeKind::Fg, StrokeKind::Bg, 1.0, w, h);
|
||||
assert!(stroke_prompt(&refs, &hard, 1.0, w, h).points.iter().all(|p| !p.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_prompts_carry_a_handful_of_points() {
|
||||
let st = base_strokes(0);
|
||||
let refs: Vec<&Stroke> = st.iter().collect();
|
||||
let p = tracker_prompt(&refs, 1.0, W as usize, H as usize);
|
||||
let (fg, bg) = (p.points.iter().filter(|(_, f)| *f).count(), p.points.iter().filter(|(_, f)| !*f).count());
|
||||
assert!((1..=TRACKER_FG_POINTS).contains(&fg) && (1..=TRACKER_BG_POINTS).contains(&bg), "{fg} fg, {bg} bg");
|
||||
// a long, finely sampled stroke still gives at most the limit, and an empty one gives none
|
||||
let long = Stroke { kind: StrokeKind::Fg, frame: 0, radius: 2.0, points: (0..400).map(|i| [i as f64 * 0.4, 50.0]).collect(), object: 0 };
|
||||
assert!(tracker_prompt(&[&long], 1.0, W as usize, H as usize).points.len() <= TRACKER_FG_POINTS);
|
||||
assert!(tracker_prompt(&[], 1.0, W as usize, H as usize).points.is_empty());
|
||||
}
|
||||
|
||||
/// Tracker clicks follow the same correction order as the classic matte and model prompts.
|
||||
#[test]
|
||||
fn tracker_prompts_follow_latest_corrections_and_keep_remaining_foreground() {
|
||||
let (w, h) = (100, 80);
|
||||
let green = Stroke { kind: StrokeKind::Fg, frame: 0, radius: 15.0, points: vec![[50.0, 40.0]], object: 0 };
|
||||
let red = Stroke { kind: StrokeKind::Bg, radius: 16.0, ..green.clone() };
|
||||
for strokes in [&[&green, &red][..], &[&red, &green][..]] {
|
||||
let classic = FrameSeg::empty(w, h);
|
||||
for prompt in [tracker_prompt(strokes, 1.0, w, h), tracker_prompt_boxed(strokes, 1.0, &classic)] {
|
||||
let expected = std::ptr::eq(strokes[1], &green);
|
||||
assert_eq!(prompt.points.iter().any(|(_, fg)| *fg), expected, "{:?}", prompt.points);
|
||||
let hard = resolve(strokes, StrokeKind::Fg, StrokeKind::Bg, 1.0, w, h);
|
||||
for (point, fg) in &prompt.points {
|
||||
assert_eq!(owner(&hard, w, h, *point), Some(if *fg { HARD_FG } else { HARD_BG }));
|
||||
}
|
||||
assert!(!prompt.points.is_empty());
|
||||
}
|
||||
}
|
||||
// A small correction removes the sampled centre, while most of the foreground dot remains.
|
||||
let tap = Stroke { radius: 2.0, ..red };
|
||||
let strokes = [&green, &tap];
|
||||
let hard = resolve(&strokes, StrokeKind::Fg, StrokeKind::Bg, 1.0, w, h);
|
||||
for prompt in [tracker_prompt(&strokes, 1.0, w, h), tracker_prompt_boxed(&strokes, 1.0, &FrameSeg::empty(w, h))] {
|
||||
assert!(prompt.points.iter().any(|(_, fg)| *fg));
|
||||
assert!(prompt.points.iter().any(|(_, fg)| !*fg));
|
||||
for (point, fg) in &prompt.points {
|
||||
assert_eq!(owner(&hard, w, h, *point), Some(if *fg { HARD_FG } else { HARD_BG }));
|
||||
}
|
||||
assert!(prompt.points.iter().filter(|(_, fg)| *fg).count() <= TRACKER_FG_POINTS);
|
||||
assert!(prompt.points.iter().filter(|(_, fg)| !*fg).count() <= TRACKER_BG_POINTS);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_segmentation_handles_absent_objects_and_bad_maps() {
|
||||
let (img, gt) = frame_at(center(0));
|
||||
let st = base_strokes(0);
|
||||
let refs: Vec<&Stroke> = st.iter().collect();
|
||||
let opts = SegOpts::default();
|
||||
let n = (W * H) as usize;
|
||||
// a good map (the true disk) reproduces it, with the strokes as constraints
|
||||
let soft: Vec<f32> = gt.iter().map(|v| f32::from(*v)).collect();
|
||||
let seg = segment_tracked(&img, &refs, soft, false, &opts, 0.0);
|
||||
assert!(iou(&seg.matte, >) > 0.95);
|
||||
// an absent object (no strokes on the frame) is an empty matte, whatever the map says
|
||||
let none = segment_tracked(&img, &[], vec![1.0; n], true, &opts, 0.0);
|
||||
assert_eq!(none.area(), 0);
|
||||
assert_eq!(segment_tracked(&img, &[], vec![0.0; n], false, &opts, 0.0).area(), 0);
|
||||
// a map of the wrong size is ignored: the strokes alone segment the frame
|
||||
let wrong = segment_tracked(&img, &refs, vec![1.0; 3], false, &opts, 0.0);
|
||||
assert!(iou(&wrong.matte, >) > 0.95);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_prompts_carry_the_box_of_the_classic_matte() {
|
||||
let (img, gt) = frame_at(center(0));
|
||||
let st = base_strokes(0);
|
||||
let refs: Vec<&Stroke> = st.iter().collect();
|
||||
let classic = segment(&img, &refs, None, &SegOpts::default(), 0.0);
|
||||
let p = tracker_prompt_boxed(&refs, 1.0, &classic);
|
||||
let b = p.bbox.expect("a box");
|
||||
let c = center(0);
|
||||
// the box reaches the disk's edges, with a margin
|
||||
assert!(b[0] <= (c[0] - R) as f32 && b[0] > (c[0] - R) as f32 - 12.0, "{b:?}");
|
||||
assert!(b[2] >= (c[0] + R) as f32 && b[2] < (c[0] + R) as f32 + 12.0, "{b:?}");
|
||||
assert!(b[1] <= (c[1] - R) as f32 && b[3] >= (c[1] + R) as f32, "{b:?}");
|
||||
assert!(iou(&classic.matte, >) > 0.95);
|
||||
// no matte, no box; a box the user drew wins
|
||||
let none = tracker_prompt_boxed(&refs, 1.0, &FrameSeg::empty(W as usize, H as usize));
|
||||
assert_eq!(none.bbox, None);
|
||||
let bx = Stroke { kind: StrokeKind::Box, frame: 0, radius: 0.0, points: vec![[10.0, 10.0], [50.0, 60.0]], object: 0 };
|
||||
let mut with_box: Vec<&Stroke> = refs.clone();
|
||||
with_box.push(&bx);
|
||||
assert_eq!(tracker_prompt_boxed(&with_box, 1.0, &classic).bbox, Some([10.0, 10.0, 50.0, 60.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strokes_carry_an_object_and_old_data_is_object_zero() {
|
||||
// saved before objects existed: no field, and the field is not written for object 0
|
||||
let old = r#"{"base":0,"span":[0,4],"strokes":[{"kind":"fg","frame":0,"radius":3.0,"points":[[1.0,2.0]]}]}"#;
|
||||
let d = RotoData::from_json(old);
|
||||
assert_eq!(d.strokes.first().map(|s| s.object), Some(0));
|
||||
assert!(!d.to_json().contains("object"), "{}", d.to_json());
|
||||
// the keys of existing projects do not change
|
||||
let same = RotoData::from_json(&d.to_json());
|
||||
assert_eq!(d.chain_keys(1), same.chain_keys(1));
|
||||
|
||||
let mut d = d;
|
||||
d.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[9.0, 9.0]], object: 2 }, 4, [0, 10]);
|
||||
d.add(Stroke { kind: StrokeKind::Bg, frame: 0, radius: 3.0, points: vec![[0.0, 0.0]], object: 2 }, 4, [0, 10]);
|
||||
d.add(Stroke { kind: StrokeKind::Refine, frame: 0, radius: 3.0, points: vec![[5.0, 5.0]], object: 0 }, 4, [0, 10]);
|
||||
assert_eq!(d.objects(), vec![0, 2]);
|
||||
assert!(d.to_json().contains("\"object\":2"));
|
||||
assert_eq!(RotoData::from_json(&d.to_json()), d);
|
||||
// an object's own strokes, plus the Refine Edge strokes that belong to the whole matte
|
||||
assert_eq!(d.strokes_of(0, 2).len(), 3);
|
||||
assert_eq!(d.strokes_of(0, 0).len(), 2);
|
||||
assert!(d.strokes_of(0, 1).len() == 1);
|
||||
// moving a stroke to another object changes the frame keys (the tracked frames are stale)
|
||||
let before = d.chain_keys(1);
|
||||
if let Some(s) = d.strokes.first_mut() {
|
||||
s.object = 1;
|
||||
}
|
||||
assert_ne!(before, d.chain_keys(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_union_of_objects_is_their_largest_extent() {
|
||||
let seg = |cells: &[usize]| {
|
||||
let mut s = FrameSeg::empty(4, 1);
|
||||
for c in cells {
|
||||
s.matte[*c] = 255;
|
||||
}
|
||||
s
|
||||
};
|
||||
let u = FrameSeg::union(vec![seg(&[0]), seg(&[2, 3]), seg(&[3])]);
|
||||
assert_eq!(u.matte, vec![255, 0, 255, 255]);
|
||||
assert_eq!(FrameSeg::union(vec![]).w, 0);
|
||||
// a part of another size is ignored
|
||||
let odd = FrameSeg::empty(2, 2);
|
||||
assert_eq!(FrameSeg::union(vec![seg(&[1]), odd]).matte, vec![0, 255, 0, 0]);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ sys-locale = { workspace = true }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
|
||||
pollster = "1"
|
||||
effectcraft-segment = { workspace = true }
|
||||
effectcraft-host = { workspace = true }
|
||||
exr = { workspace = true }
|
||||
|
||||
|
||||
@@ -84,10 +84,20 @@ impl EffectcraftApp {
|
||||
frame: 0,
|
||||
scale: (scale * 1000.0).round() as u32,
|
||||
view: self.view_hash(comp),
|
||||
opts: frames::opts_hash(&self.frame_opts(comp, scale)),
|
||||
opts: frames::opts_hash(&self.frame_opts(comp, scale)) ^ self.roto_model_stamp(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes when the Roto Brush model in use does: a frame drawn with the classic fallback while the model loaded
|
||||
/// is not the frame the model gives, so the two must not share a cache entry.
|
||||
fn roto_model_stamp(&self) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let Some(id) = self.session.models_roto_active() else { return 0 };
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
id.hash(&mut h);
|
||||
h.finish() | 1
|
||||
}
|
||||
|
||||
/// The frame series the viewer of `comp` shows now (its scale, view and options, for the
|
||||
/// comp's current content): what the cache bars count.
|
||||
pub fn shown_series(&self, comp: ItemId) -> FrameKey {
|
||||
|
||||
@@ -8,7 +8,7 @@ use effectcraft_engine::render::{EvalCtx, RenderOpts, Renderer};
|
||||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::i18n::tr;
|
||||
use crate::i18n::{tr, tr_args};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{EffectcraftApp, widgets};
|
||||
|
||||
@@ -21,6 +21,42 @@ fn roto_group(layer: &Layer) -> Option<&effectcraft_engine::project::PropGroup>
|
||||
layer.effects()?.groups().nth(roto_index(layer)?)
|
||||
}
|
||||
|
||||
/// The colour of object `o`'s outline: object 0 keeps the chosen boundary colour, the others cycle through a palette.
|
||||
pub fn object_color(o: u32, base: [f64; 3]) -> Color32 {
|
||||
const PALETTE: [[u8; 3]; 8] =
|
||||
[[60, 200, 255], [255, 170, 40], [150, 230, 60], [180, 110, 255], [255, 225, 60], [40, 210, 170], [255, 90, 80], [90, 130, 255]];
|
||||
if o == 0 {
|
||||
return c32(base, 255);
|
||||
}
|
||||
let [r, g, b] = PALETTE[(o as usize - 1) % PALETTE.len()];
|
||||
Color32::from_rgb(r, g, b)
|
||||
}
|
||||
|
||||
/// Sample the layer-grid matte at each input pixel centre, including cropping and preview scale.
|
||||
fn object_mask_in_input(seg: &fx::FrameSeg, inp: &effectcraft_engine::effects::Buf) -> Vec<bool> {
|
||||
let width = inp.img.width as usize;
|
||||
if width == 0 || !inp.scale.is_finite() || inp.scale <= 0.0 || inp.offset.iter().any(|v| !v.is_finite()) {
|
||||
return vec![false; inp.img.data.len()];
|
||||
}
|
||||
inp.img
|
||||
.data
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, _)| {
|
||||
let x = ((i % width) as f64 + 0.5 - inp.offset[0]) / inp.scale;
|
||||
let y = ((i / width) as f64 + 0.5 - inp.offset[1]) / inp.scale;
|
||||
if x < 0.0 || y < 0.0 || x >= seg.w as f64 || y >= seg.h as f64 {
|
||||
return false;
|
||||
}
|
||||
(y.floor() as usize)
|
||||
.checked_mul(seg.w)
|
||||
.and_then(|row| row.checked_add(x.floor() as usize))
|
||||
.and_then(|index| seg.matte.get(index))
|
||||
.is_some_and(|m| *m != 0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A stroke being drawn.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Drag {
|
||||
@@ -94,6 +130,19 @@ pub fn draw_overlay(
|
||||
let alpha: Vec<f32> = (0..w * h).map(|i| a_at((i % w) as i64, (i / w) as i64)).collect();
|
||||
let inside = |x: i64, y: i64| x >= 0 && y >= 0 && x < w as i64 && y < h as i64 && alpha[y as usize * w + x as usize] >= 0.5;
|
||||
let mut px = vec![Color32::TRANSPARENT; w * h];
|
||||
// With several objects the boundary view outlines each in its own colour.
|
||||
let per_object: Vec<(Color32, Vec<bool>)> = match (o.view.as_str(), roto_group(layer)) {
|
||||
("alphaBoundary", Some(g)) => {
|
||||
let params = effectcraft_engine::effects::flatten_params(g, &mut |p| p.value.clone());
|
||||
let size = effectcraft_engine::roto::layer_size(&app.session.project, comp, layer);
|
||||
let f = fx::frame_of(layer.layer_time(time).seconds(), comp.frame_rate.as_f64());
|
||||
fx::object_mattes(¶ms, size, comp.frame_rate.as_f64(), f)
|
||||
.into_iter()
|
||||
.map(|(obj, seg)| (object_color(obj, o.boundary_color), object_mask_in_input(&seg, &inp)))
|
||||
.collect()
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
let bc = c32(o.boundary_color, 255);
|
||||
let oc = c32(o.overlay_color, (o.overlay_opacity / 100.0 * 255.0).clamp(0.0, 255.0) as u8);
|
||||
// View Search Region: the band around the boundary (yellow).
|
||||
@@ -117,7 +166,30 @@ pub fn draw_overlay(
|
||||
_ => {
|
||||
let me = a >= 0.5;
|
||||
let edge = me && [(-1, 0), (1, 0), (0, -1), (0, 1)].iter().any(|(dx, dy)| !inside(x + dx, y + dy));
|
||||
if edge { bc } else { Color32::TRANSPARENT }
|
||||
if per_object.is_empty() {
|
||||
if edge { bc } else { Color32::TRANSPARENT }
|
||||
} else {
|
||||
// the outline of every object whose own matte has an edge here
|
||||
let inside_of = |m: &Vec<bool>, x: i64, y: i64| {
|
||||
x >= 0
|
||||
&& y >= 0
|
||||
&& x < w as i64
|
||||
&& y < h as i64
|
||||
&& (y as usize)
|
||||
.checked_mul(w)
|
||||
.and_then(|row| row.checked_add(x as usize))
|
||||
.and_then(|index| m.get(index))
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
};
|
||||
per_object
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(_, m)| {
|
||||
inside_of(m, x, y) && [(-1, 0), (1, 0), (0, -1), (0, 1)].iter().any(|(dx, dy)| !inside_of(m, x + dx, y + dy))
|
||||
})
|
||||
.map_or(Color32::TRANSPARENT, |(c, _)| *c)
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some((sd, r)) = &search {
|
||||
@@ -243,6 +315,20 @@ fn run_stroke(app: &mut EffectcraftApp, layer: u64, kind: &str, points: &[[f64;
|
||||
}
|
||||
}
|
||||
|
||||
/// The span bar's colour for a frame a tracker followed: green when it is sure, amber when it is
|
||||
/// doubtful, red when it is not, slate when the object was absent.
|
||||
pub(crate) fn trust_color(confidence: f32, occluded: bool) -> Color32 {
|
||||
if occluded {
|
||||
Color32::from_rgb(110, 118, 150)
|
||||
} else if confidence >= 0.85 {
|
||||
Color32::from_rgb(70, 200, 90)
|
||||
} else if confidence >= 0.5 {
|
||||
Color32::from_rgb(230, 180, 50)
|
||||
} else {
|
||||
Color32::from_rgb(220, 70, 60)
|
||||
}
|
||||
}
|
||||
|
||||
/// The segmentation span bar (above the panel's bottom bar): the layer's frames, the span, the
|
||||
/// base frame (yellow), computed frames (green) and stroke frames. Click to go to a frame; drag
|
||||
/// the span's ends to change it.
|
||||
@@ -264,10 +350,17 @@ pub fn span_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
let sp = Rect::from_min_max(pos2(x_of(d.span[0] as f64), track.min.y), pos2(x_of(d.span[1] as f64 + 1.0), track.max.y));
|
||||
let frozen = fx::is_frozen(¶ms);
|
||||
p.rect_filled(sp, 2.0, if frozen { Color32::from_rgb(60, 110, 190) } else { Color32::from_rgb(90, 90, 96) });
|
||||
// Computed frames: green, or (frames a tracker followed) coloured by its confidence, with
|
||||
// frames where the object was absent in slate.
|
||||
let trust = app.session.roto_trust(cid, layer.id, uid);
|
||||
for (f, done) in app.session.roto_computed(cid, layer.id, uid) {
|
||||
if done {
|
||||
let cr = Rect::from_min_max(pos2(x_of(f as f64), track.max.y - 4.0), pos2(x_of(f as f64 + 1.0), track.max.y));
|
||||
p.rect_filled(cr, 0.0, Color32::from_rgb(70, 200, 90));
|
||||
let (color, h) = match trust.get(&f) {
|
||||
Some(t) => (trust_color(t.confidence, t.occluded), 6.0),
|
||||
None => (Color32::from_rgb(70, 200, 90), 4.0),
|
||||
};
|
||||
let cr = Rect::from_min_max(pos2(x_of(f as f64), track.max.y - h), pos2(x_of(f as f64 + 1.0), track.max.y));
|
||||
p.rect_filled(cr, 0.0, color);
|
||||
}
|
||||
}
|
||||
for f in d.stroke_frames() {
|
||||
@@ -352,6 +445,45 @@ pub fn bar_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
}
|
||||
app.auto.add("layerPanel.roto.freeze", r, label);
|
||||
x += 80.0;
|
||||
// Objects: strokes belong to the current object; with a tracker each is followed on its own and the mattes are united.
|
||||
let objects = fx::data(¶ms).objects();
|
||||
let current = app.session.state.roto.object;
|
||||
let known = objects.contains(¤t);
|
||||
let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(104.0, 20.0));
|
||||
let strokes = fx::data(¶ms).strokes.iter().filter(|st| st.object == current && st.kind.is_segmentation()).count();
|
||||
let label = if strokes == 0 { tr_args("Object {}", &[&(current + 1)]) } else { tr_args("Object {} ({})", &[&(current + 1), &strokes]) };
|
||||
if widgets::text_button(ui, r, &label, false, &t, egui::Id::unique(("roto-object", uid))).clicked() {
|
||||
// cycle through the objects that have strokes (and the empty one being drawn)
|
||||
let mut all = objects.clone();
|
||||
if !known {
|
||||
all.push(current);
|
||||
all.sort_unstable();
|
||||
}
|
||||
let next = all.iter().copied().find(|o| *o > current).or_else(|| all.first().copied()).unwrap_or(0);
|
||||
let _ = app.session.execute("roto.options", json!({"object": next}));
|
||||
}
|
||||
p.circle_filled(pos2(r.min.x + 9.0, r.center().y), 4.0, object_color(current, app.session.state.roto.boundary_color));
|
||||
app.auto.add("layerPanel.roto.object", r, &label);
|
||||
x += 108.0;
|
||||
let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(26.0, 20.0));
|
||||
if widgets::text_button(ui, r, "+", false, &t, egui::Id::unique(("roto-object-new", uid))).clicked() {
|
||||
let next = objects.iter().copied().chain([current]).max().map_or(0, |m| m + 1);
|
||||
let _ = app.session.execute("roto.options", json!({"object": next}));
|
||||
}
|
||||
app.auto.add("layerPanel.roto.newObject", r, "New object");
|
||||
x += 30.0;
|
||||
// remove the current object's strokes (the object stays selected; with none left it is simply empty)
|
||||
if strokes > 0 {
|
||||
let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(26.0, 20.0));
|
||||
let c = widgets::text_button(ui, r, "×", false, &t, egui::Id::unique(("roto-object-clear", uid))).on_hover_text(tr("Remove this object's strokes"));
|
||||
if c.clicked()
|
||||
&& let Err(e) = app.session.execute("roto.clearStrokes", json!({"layer": layer.id.0, "effect": uid, "object": current}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
app.auto.add("layerPanel.roto.clearObject", r, "Remove object strokes");
|
||||
x += 30.0;
|
||||
}
|
||||
if running {
|
||||
let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(60.0, 20.0));
|
||||
if widgets::text_button(ui, r, tr("Stop"), false, &t, egui::Id::unique("roto-stop")).clicked() {
|
||||
@@ -363,3 +495,39 @@ pub fn bar_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn per_object_outlines_follow_cropped_padded_and_scaled_input_coordinates() {
|
||||
use effectcraft_engine::{effects::Buf, raster::Image};
|
||||
let seg = fx::FrameSeg { w: 4, h: 2, matte: vec![0, 0, 255, 255, 0, 0, 255, 255], refine: vec![0; 8], ..Default::default() };
|
||||
for (offset, scale, expected) in [
|
||||
([-2.0, 0.0], 1.0, vec![true, true]),
|
||||
([0.0, 0.0], 0.5, vec![false, true]),
|
||||
([-1.0, 0.0], 0.5, vec![true, false]),
|
||||
([2.0, 0.0], 1.0, vec![false, false]),
|
||||
] {
|
||||
let inp = Buf { img: Image::new(2, 1), offset, scale };
|
||||
assert_eq!(object_mask_in_input(&seg, &inp), expected, "offset={offset:?}, scale={scale}");
|
||||
}
|
||||
let inp = Buf { img: Image::new(6, 4), offset: [1.0, 1.0], scale: 1.0 };
|
||||
let expected: Vec<bool> = (0..24).map(|i| matches!((i % 6, i / 6), (3 | 4, 1 | 2))).collect();
|
||||
assert_eq!(object_mask_in_input(&seg, &inp), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confidence_colours_run_from_green_to_red_and_absent_is_slate() {
|
||||
let (g, a, r, s) = (trust_color(0.99, false), trust_color(0.6, false), trust_color(0.1, false), trust_color(0.99, true));
|
||||
assert_eq!(g, Color32::from_rgb(70, 200, 90));
|
||||
assert_eq!(a, Color32::from_rgb(230, 180, 50));
|
||||
assert_eq!(r, Color32::from_rgb(220, 70, 60));
|
||||
assert_eq!(s, Color32::from_rgb(110, 118, 150));
|
||||
// the boundaries
|
||||
assert_eq!(trust_color(0.85, false), g);
|
||||
assert_eq!(trust_color(0.5, false), a);
|
||||
assert_eq!(trust_color(f32::NAN, false), r);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,6 +489,9 @@ fn models_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, acts: &mut
|
||||
ui.label(RichText::new(state).color(if active { t.accent } else { t.text_dim }));
|
||||
});
|
||||
ui.label(RichText::new(m["description"].as_str().unwrap_or_default()).color(t.text_dim));
|
||||
if let Some(hint) = m["hint"].as_str() {
|
||||
ui.label(RichText::new(hint).color(if m["recommended"].as_bool().unwrap_or(false) { t.accent } else { t.text_dim }));
|
||||
}
|
||||
if let Some(by) = m["authors"].as_str() {
|
||||
ui.label(RichText::new(format!("By {by}")).color(t.text_dim));
|
||||
}
|
||||
@@ -505,12 +508,19 @@ fn models_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, acts: &mut
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (Some(source), Some(file)) = (m["source"].as_str(), m["sourceFile"].as_str()) {
|
||||
let response = ui.link(file).on_hover_text(crate::i18n::tr("Source"));
|
||||
reg(app, &format!("settings.{prefix}.source.{id}"), &response, file);
|
||||
if response.clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(source));
|
||||
}
|
||||
}
|
||||
});
|
||||
if classic {
|
||||
return;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if !installed && !web {
|
||||
if !installed && !web && m["url"].as_str().is_some_and(|url| !url.is_empty()) {
|
||||
let r = ui.add_enabled(busy.is_none(), egui::Button::new(tr("Download")));
|
||||
reg(app, &format!("settings.{prefix}.download.{id}"), &r, "Download");
|
||||
if r.clicked() {
|
||||
@@ -921,3 +931,28 @@ pub fn recovery(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Install-only models must not offer a download that the command will reject.
|
||||
#[test]
|
||||
fn sam2_settings_offer_file_installation_without_downloads() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let ctx = egui::Context::default();
|
||||
let tokens = app.tokens;
|
||||
let mut actions = vec![];
|
||||
let mut output = ctx
|
||||
.run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(900.0, 2200.0))), ..Default::default() }, |ui| {
|
||||
models_ui(&mut app, ui, &tokens, &mut actions, Task::Mask)
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
for id in ["sam2.1-hiera-tiny", "sam2.1-hiera-small", "sam2.1-hiera-base-plus", "sam2.1-hiera-large"] {
|
||||
assert!(app.auto.find(&format!("settings.roto.download.{id}")).is_none());
|
||||
assert!(app.auto.find(&format!("settings.roto.install.{id}")).is_some());
|
||||
assert_eq!(app.auto.find(&format!("settings.roto.source.{id}")).unwrap().label, "model.safetensors");
|
||||
}
|
||||
assert!(actions.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
mod gpu_test;
|
||||
mod native_menu_text_keys;
|
||||
mod render_queue_snapshot;
|
||||
mod roto_model_guard;
|
||||
mod shared_docking_ui;
|
||||
mod ui_3d;
|
||||
mod ui_accessibility;
|
||||
@@ -72,6 +73,8 @@ mod ui_quick_offset;
|
||||
mod ui_ram_preview;
|
||||
mod ui_render_queue;
|
||||
mod ui_render_queue_keyboard;
|
||||
mod ui_roto_objects;
|
||||
mod ui_roto_span_confidence;
|
||||
mod ui_script_console;
|
||||
mod ui_scriptui;
|
||||
mod ui_scrolling;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! The Roto Brush model is process-global (`effects::roto::set_model`), and every test in this binary shares the
|
||||
//! process: a test that installs a scripted model holds this guard, so another cannot swap or clear the model
|
||||
//! mid-run, and the model is cleared again even when the test panics.
|
||||
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
|
||||
|
||||
use effectcraft_segment::MaskModel;
|
||||
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
pub struct InstalledModel {
|
||||
_exclusive: MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
/// Install `model` for the roto effect until the returned guard drops.
|
||||
pub fn install(model: Arc<dyn MaskModel>) -> InstalledModel {
|
||||
let guard = LOCK.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
effectcraft_engine::effects::roto::set_model(Some(model));
|
||||
InstalledModel { _exclusive: guard }
|
||||
}
|
||||
|
||||
impl Drop for InstalledModel {
|
||||
fn drop(&mut self) {
|
||||
effectcraft_engine::effects::roto::set_model(None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! A Roto Brush instance with two objects: the Layer panel's boundary view outlines each in its own colour, and
|
||||
//! Show Object limits the matte to one (egui_kittest, a real render). A stand-in tracker follows two regions, so no
|
||||
//! weights are needed. `ROTO_OBJECTS_SNAPSHOT=/abs/out.png` also saves the rendered panel.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_segment::{ClipFrame, MaskModel, ModelInfo, ObjectTracker, Prompt, TrackRequest, TrackedFrame};
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// Follows two fixed regions of the layer: object 0 on the left, object 1 on the right.
|
||||
struct TwoRegions;
|
||||
|
||||
impl MaskModel for TwoRegions {
|
||||
fn info(&self) -> &'static ModelInfo {
|
||||
&effectcraft_segment::SAM2_TINY
|
||||
}
|
||||
fn segment(&self, _: &[[f32; 3]], _: usize, _: usize, _: &Prompt) -> effectcraft_segment::Result<Vec<f32>> {
|
||||
Err("no weights".into())
|
||||
}
|
||||
fn tracker(&self) -> Option<&dyn ObjectTracker> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectTracker for TwoRegions {
|
||||
fn track(
|
||||
&self,
|
||||
req: &TrackRequest,
|
||||
frame: &mut dyn FnMut(i64) -> Option<Arc<ClipFrame>>,
|
||||
_: &dyn Fn() -> bool,
|
||||
out: &mut dyn FnMut(usize, i64, TrackedFrame),
|
||||
) -> effectcraft_segment::Result<()> {
|
||||
for g in req.first..req.first + req.count as i64 {
|
||||
let img = frame(g).ok_or("no frame")?;
|
||||
for o in 0..req.objects.len() {
|
||||
let (x0, x1) = if o == 0 { (img.w / 8, img.w * 3 / 8) } else { (img.w * 5 / 8, img.w * 7 / 8) };
|
||||
let prob = (0..img.w * img.h)
|
||||
.map(|i| {
|
||||
let (x, y) = (i % img.w, i / img.w);
|
||||
f32::from(x >= x0 && x < x1 && y > img.h / 4 && y < img.h * 3 / 4)
|
||||
})
|
||||
.collect();
|
||||
out(o, g, TrackedFrame { prob, w: img.w, h: img.h, confidence: 0.99, occluded: false });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixels of the viewer (above the panel's bar, whose object swatch has the same colour) within a few levels of `want`.
|
||||
fn count(image: &image::RgbaImage, want: [u8; 3]) -> usize {
|
||||
let limit = image.height() * 54 / 100;
|
||||
image.enumerate_pixels().filter(|(_, y, p)| *y < limit && p.0[..3].iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 12)).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_boundary_view_outlines_each_object_and_show_object_picks_one() {
|
||||
let _gpu = crate::gpu_test::lock();
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "View", "width": 640, "height": 360, "duration": 4})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap();
|
||||
let id = s.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id.0;
|
||||
for (object, x) in [(0, 22.0), (1, 58.0)] {
|
||||
s.execute("roto.stroke", json!({"layer": id, "object": object, "points": [[x, 38.0], [x, 42.0]], "radius": 4})).unwrap();
|
||||
}
|
||||
let _model = crate::roto_model_guard::install(Arc::new(TwoRegions));
|
||||
s.execute("roto.propagate", json!({"layer": id, "direction": "both", "wait": true})).unwrap();
|
||||
let st = s.execute("roto.status", json!({"layer": id})).unwrap();
|
||||
assert_eq!(st["objects"], json!([{"id": 0, "strokes": 1}, {"id": 1, "strokes": 1}]), "{st}");
|
||||
|
||||
// object 0 keeps the boundary colour (pink by default), object 1 is the first palette colour (blue-green)
|
||||
let (pink, cyan) = ([255, 64, 191], [60, 200, 255]);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap();
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.openLayer", json!({"layer": id})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().ui.focused, PanelKind::Layer);
|
||||
let both = h.render().expect("the panel renders");
|
||||
if let Some(path) = std::env::var_os("ROTO_OBJECTS_SNAPSHOT") {
|
||||
both.save(path).unwrap();
|
||||
}
|
||||
// the bar offers to remove the current object's strokes, and says how many it has
|
||||
assert!(h.state().auto.find("layerPanel.roto.clearObject").is_some(), "the remove button is on screen");
|
||||
assert!(h.state().auto.find("layerPanel.roto.object").is_some_and(|e| e.label.contains("(1)")), "the object's stroke count is on the button");
|
||||
let (a, b) = (count(&both, pink), count(&both, cyan));
|
||||
assert!(a > 50 && b > 50, "both objects are outlined: {a} pink, {b} cyan pixels");
|
||||
|
||||
// Show Object 2 (object id 1): only its outline remains
|
||||
h.state_mut().session.execute("prop.set", json!({"layer": id, "path": "effects/#1/rotoBrushMatte/object", "value": 2})).unwrap();
|
||||
h.run_steps(4);
|
||||
let one = h.render().expect("the panel renders");
|
||||
let (a, b) = (count(&one, pink), count(&one, cyan));
|
||||
assert!(a < 10 && b > 50, "only object 1 is shown: {a} pink, {b} cyan pixels");
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! The Roto Brush span bar colours the frames a tracker followed by its confidence: green when it is sure,
|
||||
//! amber when it is doubtful, red when it is not, slate where the object was absent (egui_kittest, UI logic
|
||||
//! and a real render). A stand-in tracker supplies the confidences, so no weights are needed.
|
||||
//! `ROTO_SPAN_SNAPSHOT=/abs/out.png` also saves the rendered panel.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_segment::{ClipFrame, MaskModel, ModelInfo, ObjectTracker, Prompt, TrackRequest, TrackedFrame};
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// Follows a fixed disc; its confidence per frame is scripted.
|
||||
struct Scripted;
|
||||
|
||||
impl MaskModel for Scripted {
|
||||
fn info(&self) -> &'static ModelInfo {
|
||||
&effectcraft_segment::SAM2_TINY
|
||||
}
|
||||
fn segment(&self, _: &[[f32; 3]], _: usize, _: usize, _: &Prompt) -> effectcraft_segment::Result<Vec<f32>> {
|
||||
Err("no weights".into())
|
||||
}
|
||||
fn tracker(&self) -> Option<&dyn ObjectTracker> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// (confidence, occluded) of layer frame `f`: sure, doubtful, unsure, absent, sure again.
|
||||
fn script(f: i64) -> (f32, bool) {
|
||||
match f {
|
||||
0..=5 => (0.99, false),
|
||||
6..=9 => (0.6, false),
|
||||
10..=12 => (0.2, false),
|
||||
13..=15 => (0.0, true),
|
||||
_ => (0.99, false),
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectTracker for Scripted {
|
||||
fn track(
|
||||
&self,
|
||||
req: &TrackRequest,
|
||||
frame: &mut dyn FnMut(i64) -> Option<Arc<ClipFrame>>,
|
||||
_: &dyn Fn() -> bool,
|
||||
out: &mut dyn FnMut(usize, i64, TrackedFrame),
|
||||
) -> effectcraft_segment::Result<()> {
|
||||
for g in req.first..req.first + req.count as i64 {
|
||||
let img = frame(g).ok_or("no frame")?;
|
||||
let (confidence, occluded) = script(g);
|
||||
let prob = (0..img.w * img.h)
|
||||
.map(|i| if !occluded && (i % img.w).abs_diff(img.w / 2) < img.w / 4 && (i / img.w).abs_diff(img.h / 2) < img.h / 4 { 1.0 } else { 0.0 })
|
||||
.collect();
|
||||
out(0, g, TrackedFrame { prob, w: img.w, h: img.h, confidence, occluded });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn close(got: [u8; 4], want: [u8; 3]) -> bool {
|
||||
got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 24)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_span_bar_shows_the_trackers_confidence() {
|
||||
let _gpu = crate::gpu_test::lock();
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "View", "width": 640, "height": 360, "duration": 4})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap();
|
||||
let id = s.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id.0;
|
||||
s.execute("roto.stroke", json!({"layer": id, "points": [[20.0, 40.0], [60.0, 40.0]], "radius": 6})).unwrap();
|
||||
s.execute("roto.stroke", json!({"layer": id, "kind": "bg", "points": [[5.0, 5.0], [75.0, 5.0]], "radius": 4})).unwrap();
|
||||
let _model = crate::roto_model_guard::install(Arc::new(Scripted));
|
||||
// the model stays loaded while the panel draws (its id is part of the cache keys)
|
||||
s.execute("roto.propagate", json!({"layer": id, "direction": "both", "wait": true})).unwrap();
|
||||
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap();
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.openLayer", json!({"layer": id})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().ui.focused, PanelKind::Layer);
|
||||
|
||||
// the object picker is in the bar; "+" starts a new object, which the next stroke would belong to
|
||||
assert!(h.state().auto.find("layerPanel.roto.object").is_some(), "the object picker is on screen");
|
||||
assert!(h.state().auto.find("layerPanel.roto.newObject").is_some());
|
||||
assert_eq!(h.state().session.state.roto.object, 0);
|
||||
h.state_mut().session.execute("roto.options", json!({"object": 1})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.state.roto.object, 1);
|
||||
h.state_mut().session.execute("roto.options", json!({"object": 0})).unwrap();
|
||||
h.run_steps(2);
|
||||
let track = h.state().auto.find("layerPanel.rotoSpan").expect("the span bar is on screen").rect;
|
||||
let (comp, layer) = {
|
||||
let comp = h.state().session.active_comp().unwrap().clone();
|
||||
let layer = comp.layer(effectcraft_project::LayerId(id)).unwrap().clone();
|
||||
(comp, layer)
|
||||
};
|
||||
let lim = effectcraft_engine::roto::frame_limits(&comp, &layer);
|
||||
let n = (lim[1] - lim[0] + 1) as f32;
|
||||
let image = h.render().expect("the panel renders");
|
||||
if let Some(path) = std::env::var_os("ROTO_SPAN_SNAPSHOT") {
|
||||
image.save(path).unwrap();
|
||||
}
|
||||
let scale = image.width() as f32 / 1600.0;
|
||||
let sample = |f: i64| -> [u8; 4] {
|
||||
let x = (track[0] + ((f - lim[0]) as f32 + 0.5) / n * track[2]) * scale;
|
||||
let y = (track[1] + track[3] - 2.0) * scale;
|
||||
image.get_pixel(x as u32, y as u32).0
|
||||
};
|
||||
// the colours the bar uses (see `trust_color`)
|
||||
let (green, amber, red, slate) = ([70, 200, 90], [230, 180, 50], [220, 70, 60], [110, 118, 150]);
|
||||
for (f, want, what) in [(2, green, "sure"), (7, amber, "doubtful"), (11, red, "unsure"), (14, slate, "absent"), (18, green, "sure again")] {
|
||||
let got = sample(f);
|
||||
assert!(close(got, want), "frame {f} ({what}): drew {got:?}, wanted about {want:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR 0002: SAM 2 video tracker for the Roto Brush
|
||||
|
||||
Status: accepted (maintainer answers, 2026-10-15). Plan: `plan/roto-sam2.md` (maintainer-local).
|
||||
|
||||
## Context
|
||||
The Roto Brush's classical engine loses an object that is occluded or leaves and re-enters the frame
|
||||
(`effectcraft-cli bench --roto`: IoU 0.49 on the occlusion clip, 0.62 on re-entry, 0 after the object returns).
|
||||
Meta's SAM 2.1 (Apache-2.0 code and checkpoints) has streaming memory built for this.
|
||||
|
||||
## Decisions
|
||||
- **D1** A native-only `candle` dependency may enter the workspace, in its own crate (`effectcraft-sam2`), excluded from wasm32.
|
||||
- **D2** `effectcraft-sam2` is licensed Apache-2.0 only (it derives from Apache-2.0 Hugging Face `transformers` code); `effectcraft-segment` stays MIT OR Apache-2.0.
|
||||
- **D3** Shared candle helpers are vendored with a NOTICE for now; a shared crate with LightCraft can follow.
|
||||
- **D4** Weights are never hosted or bundled by us: direct from Hugging Face, a mirror, or a user folder, with the SHA-256 pinned in the registry.
|
||||
- **D5** SAM 2 is opt-in at first; it becomes the default for tracking only after the benchmark shows it should.
|
||||
- **D6** Hand-written kernels (the MobileSAM way) remain the Phase 6 route for AMD GPUs if candle cannot get there.
|
||||
|
||||
## Consequences
|
||||
Without the model installed nothing changes. Every ported file carries Apache-2.0 §4(b) modified-work headers.
|
||||
+19
-7
@@ -417,8 +417,9 @@ Face tracking's trained model works like Roto Brush's (below): `face.models` lis
|
||||
tracker and MediaPipe Face Landmarker with its authors and licence; `face.model.download {id}` /
|
||||
`face.model.install {path, id?}` install it (verified); `face.model.select {id:
|
||||
classical|mediapipe-face}` chooses it; `face.model.remove {id}` deletes it. Downloads and loading
|
||||
run in the background; `"wait": true` on download, install or select returns once the model is in
|
||||
use (or with its error), which scripts need before tracking. `track.mask` with a
|
||||
run in the background; `"wait": true` waits for the requested operation (or returns its error).
|
||||
Download/install do not change the selected model. After installing, use
|
||||
`face.model.select {id, wait:true}` to select and load it before tracking. `track.mask` with a
|
||||
face method returns `faceModel`, the engine it tries first (the classical one when the model finds
|
||||
no face in the mask).
|
||||
|
||||
@@ -525,11 +526,22 @@ reference. Matte settings are ordinary properties (`effects/#1/rotoBrushMatte/se
|
||||
|
||||
Trained model (Roto Brush 2.0 / 3.0):
|
||||
- `roto.models` lists the built-in engine and every registered model, with its authors,
|
||||
licence, size, URL, SHA-256 and whether it is installed, selected and active.
|
||||
- `roto.model.download {id}` fetches and verifies the official weights in the background;
|
||||
`roto.model.install {path, id?}` installs a file you already have.
|
||||
- `roto.model.select {id: classical|mobilesam}` chooses which one Roto Brush uses. Loading runs
|
||||
in the background; `"wait": true` (also on download and install) returns once it is in use.
|
||||
licence, size, app download URL (empty for install-only models), checkpoint `source` and `sourceFile`,
|
||||
SHA-256 and whether it is installed, selected and active.
|
||||
- `roto.model.download {id}` fetches and verifies weights when the registry provides an app download URL;
|
||||
`roto.model.install {path, id?}` installs a file you already have. SAM 2 is Install from File only until
|
||||
a storytold-controlled mirror is available; its size and SHA-256 are verified before installation.
|
||||
- `roto.model.select {id: classical|mobilesam|sam2.1-hiera-tiny|sam2.1-hiera-small|sam2.1-hiera-base-plus|sam2.1-hiera-large}` chooses which one Roto Brush uses (SAM 2 also tracks the object through the clip, see [roto-brush-sam2.md](roto-brush-sam2.md); `roto.status` then reports `source` and per-frame `tracker.frames` with `confidence` and `occluded`). Loading runs
|
||||
in the background. `"wait": true` waits for the requested operation; download/install load the model
|
||||
only if it is already selected. They do not activate an unselected model: after installation, use
|
||||
`roto.model.select {id, wait:true}` to select and load it. Unrelated model work can return a retryable
|
||||
busy error rather than reporting that a waiting selection has finished.
|
||||
- `roto.propagate` never falls back to the classic engine silently: a chosen model that is not installed, or failed
|
||||
to load, is an error, and without `"wait": true` so is a model still loading. `roto.status` reports `model`
|
||||
(`selected`, `installed`, `active`, `loading`, `error`) and a `notice` while the chosen model is not the one in use.
|
||||
Tracked frames are saved in the project with the instance (`tracker.saved` names their model): they apply while the
|
||||
strokes and span are unchanged, whichever model Settings choose (`notice` says when that differs), and a new
|
||||
Propagate replaces them.
|
||||
- `roto.model.remove {id}` deletes the installed weights.
|
||||
- The `version` property (`effects/#1/version`: 0 = 1.0, 1 = 2.0, 2 = 3.0) picks between the
|
||||
classic engine (1.0) and the chosen model (2.0, 3.0).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (crate table checked against `crates/`: effect count 306, VP9 inter frames) · **Target:** Adobe After Effects 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (SAM 2 crate added to the layer table) · **Target:** Adobe After Effects 2026
|
||||
|
||||
EffectCraft is a stack of small Rust crates. The engine knows nothing about the user interface;
|
||||
the egui frontend, the command-line tool and the MCP server all sit on top of the same `Session`
|
||||
@@ -39,6 +39,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
|
||||
| L3 | `expr` | The expression engine (JavaScript via boa) with the After Effects object model |
|
||||
| L3 | `export` | Render queue encoding: H.264, HEVC and AV1 MP4, ProRes, WebM (VP9 + alpha or AV1, Opus), PNG/JPEG/TIFF/EXR/Targa sequences, GIF, WAV/AIFF |
|
||||
| L3 | `gpu` | The GPU compositor and GPU effects on wgpu compute shaders (Metal, Vulkan, Direct3D 12, WebGPU), checked against the CPU renderer |
|
||||
| L3 | `sam2` | SAM 2.1 image segmentation and video object tracking for Roto Brush, with candle CPU inference and optional wgpu acceleration; native only, empty on wasm32, with user-installed weights |
|
||||
| L3 | `interchange` | Premiere Pro interop through timeline interchange (FCP7 XML, FCPXML, OTIO, EDL, AAF, OMF via FilmCraft's `filmcraft-interchange`): sequences ↔ compositions (§6a) |
|
||||
| L3 | `lottie` | Lottie JSON / dotLottie import and export (layers, precomps, eased and spatial keyframes, shapes, masks, mattes) with a warnings list for what Lottie cannot express |
|
||||
| L3 | `plugin` | Effect plug-ins: loads sandboxed WebAssembly effect modules (plug-in API v1; the wasmi interpreter behind the `wasm` feature, on for native hosts) into the effect registry ([plugins.md](plugins.md)) |
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
|
||||
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
|
||||
|
||||
EffectCraft ships 308 effects with 3034 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
|
||||
EffectCraft ships 308 effects with 2987 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
|
||||
|
||||
- **GPU**: 280 effects also run on the GPU compositor with identical results.
|
||||
- **32**: 308 effects process 32-bit float (HDR, overbright) pixels without clamping.
|
||||
@@ -259,7 +259,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
||||
| Object Matte | `ec.matte.objectmatte` | 13 | | 32 | Implemented |
|
||||
| Refine Hard Matte | `ec.matte.refinehard` | 16 | GPU | 32 | Implemented |
|
||||
| Refine Soft Matte | `ec.matte.refinesoft` | 18 | GPU | 32 | Implemented |
|
||||
| Roto Brush & Refine Edge | `ec.matte.rotobrush` | 24 | | 32 | Implemented |
|
||||
| Roto Brush & Refine Edge | `ec.matte.rotobrush` | 25 | | 32 | Implemented |
|
||||
| Simple Choker | `ec.matte.simplechoker` | 2 | GPU | 32 | Implemented |
|
||||
|
||||
## Noise & Grain
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# Roto Brush with SAM 2
|
||||
|
||||
Roto Brush can follow an object through a clip with Meta's **SAM 2.1 Tiny** (Hiera-Tiny, 39M parameters,
|
||||
Apache-2.0). It is optional: nothing changes until you install the model and choose it.
|
||||
|
||||
## Using it
|
||||
1. Settings ▸ Roto Brush ▸ SAM 2.1 Tiny ▸ **Install from File…** with the published `model.safetensors`
|
||||
(155.9 MB). The file's size and SHA-256 are checked and the licence notice is kept next to it.
|
||||
Agents: `roto.model.install {id: "sam2.1-hiera-tiny", path: "model.safetensors"}`.
|
||||
SAM 2 has no in-app download until the weights are mirrored under storytold control.
|
||||
Each model card links its pinned Hugging Face `model.safetensors` checkpoint, rather than the
|
||||
project's different `.pt` files. Its checkpoint source and SHA-256 are retained in the notice.
|
||||
2. Choose it (`roto.model.select {id: "sam2.1-hiera-tiny", wait: true}` for agents). Installing an
|
||||
unselected model does not activate it, even with `wait: true` on installation.
|
||||
3. Paint strokes on the base frame as usual, then **Propagate**. One tracking pass follows the object through the
|
||||
whole span in both directions, using SAM 2's streaming memory, so it keeps tracking through occlusion and when the
|
||||
object leaves the frame and returns. Each frame's probability map is then refined by the same graph cut as every other
|
||||
Roto Brush frame: your strokes stay hard constraints and edges snap to colour.
|
||||
4. `roto.status {frame}` reports `source` (`tracker`, `model` for a frame the model segmented on its own, or
|
||||
`classical`) and, for tracked frames, `tracker.frames[f]`: `confidence` (0–1, the model's object score times its
|
||||
predicted mask quality) and `occluded` (the object is not in the frame: an empty matte). `model` says what Settings
|
||||
chose and whether it is installed, loaded and loading; `notice` says when it is not the one in use for the layer.
|
||||
`tracker.saved` names the model the saved frames came from.
|
||||
|
||||
With SAM 2 chosen but not installed, or failing to load, Propagate refuses with the reason. A tracker failure during
|
||||
propagation can fall back to classic; its warning is live progress only, as described under [Memory and limits](#memory-and-limits).
|
||||
While it is still loading, `wait: true` (agents) loads it first; without `wait` it
|
||||
refuses, and the load goes on in the background. Deselecting SAM 2 (choosing Classic) runs Propagate exactly as before,
|
||||
and Roto Brush 1.0 never uses a model.
|
||||
|
||||
Tracked frames are saved in the project with the instance, as Freeze's are, so a saved project renders the same when it
|
||||
reopens, in the app or with `effectcraft-cli`, without the model being loaded or chosen there. They apply while the
|
||||
strokes and span they came from are unchanged. Changing a stroke or the span makes them stale until the next Propagate.
|
||||
Choosing another model, or the classic engine, leaves them in place and `roto.status` says so. They retain the original
|
||||
model tag and per-object cache keys when rendered or re-saved; the next Propagate replaces them using the selected
|
||||
model (or classic engine). Object Matte saves nothing.
|
||||
|
||||
## Several objects in one instance
|
||||
A Roto Brush instance can hold several objects. The **Object N** button in the Layer panel's bar picks the object new
|
||||
strokes belong to (it cycles through the objects that have strokes), and **+** starts a new one. Agents: `roto.options
|
||||
{object}` sets the current object, `roto.stroke` takes `object` (0–31; default the current one), `roto.clearStrokes`
|
||||
takes `object` to remove one object's strokes, and `roto.status` lists `objects` (`{id, strokes}`) and `currentObject`.
|
||||
|
||||
With SAM 2, Propagate follows every object in **one pass** (the frame is encoded once, whatever the number of objects), each
|
||||
from its own foreground and background strokes, and the mattes are united into the instance's one matte. Each object keeps
|
||||
its own occlusion: the frame counts as absent only when every object is, and otherwise the least sure object sets the
|
||||
frame's confidence on the span bar. A background stroke belongs to its object, so it separates that object from what
|
||||
lies under it without cutting a hole in the others. Refine Edge strokes belong to the whole matte. The classic engine has
|
||||
no objects: all foreground strokes make one object, and background strokes apply to it as before. Saved projects are
|
||||
unchanged (strokes without an object are object 0, and nothing is written for object 0).
|
||||
|
||||
**Seeing and choosing objects.** The Layer panel's boundary view outlines each object in its own colour (object 1 in the
|
||||
chosen boundary colour, the others from a palette), and the picker button carries the current object's colour. **Show
|
||||
Object** (Roto Brush Matte, a slider: 0 shows all objects together, N shows object N alone) limits the instance's matte to
|
||||
one object, so an effect after it can treat the pieces separately; an object the instance does not have shows nothing, and
|
||||
with one object, or on frames the classic engine made, the setting changes nothing. Each object's matte is kept next to
|
||||
the united one, in the session and in the saved tracking (`tracked` stores them only when there are several objects), so
|
||||
Show Object renders the same after the project is reopened. Freeze records the Show Object that was set.
|
||||
|
||||
This is not several Roto Brush instances sharing a pass: each instance reads the layer after the effects above it, so
|
||||
instances can see different pixels. Objects inside one instance always see the same ones.
|
||||
|
||||
## Sizes
|
||||
Four checkpoints are in the registry (Settings ▸ Roto Brush); the size is detected from the file, and each is
|
||||
pinned to a Hugging Face commit with its SHA-256. All run the same tracker; a bigger backbone costs time and memory.
|
||||
|
||||
| Model | File size | GPU encode (RX 7900 GRE) | Agreement with PyTorch |
|
||||
|---|---|---|---|
|
||||
| SAM 2.1 Tiny (`sam2.1-hiera-tiny`) | 155.9 MB | 0.24 s | encoder 6.8e-6 |
|
||||
| SAM 2.1 Small (`sam2.1-hiera-small`) | 184.3 MB | 0.20 s | encoder 1.0e-5 |
|
||||
| SAM 2.1 Base+ (`sam2.1-hiera-base-plus`) | 323.5 MB | 0.37 s | encoder 2.3e-5 |
|
||||
| SAM 2.1 Large (`sam2.1-hiera-large`) | 897.9 MB | 0.72 s | encoder 5.2e-4 |
|
||||
|
||||
### How the sizes compare on the benchmark clips
|
||||
`effectcraft-cli bench --roto --frames 24 --fixes --sam2 FILE`, release build, SAM 2 on the GPU (RX 7900 GRE), one run each.
|
||||
Mean IoU (after the object returns, in brackets) and seconds per tracked frame:
|
||||
|
||||
| Clip | Tiny | Small | Base+ | Large |
|
||||
|---|---|---|---|---|
|
||||
| move | 0.992 | 0.988 | 0.986 | 0.994 |
|
||||
| light | 0.990 | 0.985 | 0.987 | 0.993 |
|
||||
| occlusion | 0.989 (0.987) | 0.988 (0.984) | 0.987 (0.984) | 0.989 (0.987) |
|
||||
| reenter | 0.982 (0.975) | 0.981 (0.970) | 0.978 (0.966) | **0.694 (0.110)** |
|
||||
| s / tracked frame | 0.69 | 0.70 | 0.79 | 1.17 |
|
||||
|
||||
The classic engine scores 0.98 / 0.98 / 0.49 / 0.62 on the same clips. Peak tracker state is 88 MB for every size (the
|
||||
weights are not counted). The clips are synthetic and easy, so they cannot tell the sizes apart on accuracy: **no larger
|
||||
size tracked better here, and Large lost the object after it left and came back** (IoU 0.11 after the return, where
|
||||
Tiny, Small and Base+ found it again). That is the model's behaviour on this clip, not the port:
|
||||
Large matches PyTorch stage by stage. Whether a larger size helps on real footage is not measured.
|
||||
|
||||
Settings therefore marks **Tiny** as recommended (the smallest download, and nothing larger did better), and says what was
|
||||
measured for the others; Base+ and Large also warn when no GPU was found (they take several seconds a frame on a CPU).
|
||||
The list carries `recommended` and `hint` per model (`roto.models`).
|
||||
|
||||
### Half-precision weights (optional)
|
||||
`EFFECTCRAFT_SAM2_F16=1` keeps the weight matrices on the GPU as half precision, halving their memory (Large: about
|
||||
900 MB to 450 MB). It is off by default: the video logit error against PyTorch grows from about 0.003 to 0.06 (Tiny)
|
||||
and 0.12 (Large) with the same masks, and the speed gain is under 10% (Large encode 0.72 s to 0.66 s).
|
||||
|
||||
### What stays on the CPU
|
||||
The mask decoder takes about 110 ms of a tracked frame (attention layers 42 ms, the two upscaling convolutions 50 ms).
|
||||
Rewriting the upscaling convolutions as a matrix product and a pixel shuffle did not make them faster, and a GPU port
|
||||
would save under 0.1 s of a 0.7 s frame, so it stays on the CPU.
|
||||
|
||||
Every size was checked stage by stage against PyTorch (patch embedding, each Hiera block, neck, prompt encoder,
|
||||
decoder, memory encoder, memory attention) and on a whole clip forward and backward, on the CPU and the GPU: the video
|
||||
logit error is at most 0.005 with the same masks. Large carries much larger activations (peaks near 300 in the third
|
||||
stage), so its f32 rounding error is larger in absolute terms; its tests use a bound relative to the peak. Large needs
|
||||
the GPU to run per block (the encode submits once per block so intermediates are freed); a GPU error of any kind falls
|
||||
back to the CPU.
|
||||
|
||||
## Corrections re-track only what they change
|
||||
A correction stroke changes the keys of the frames from it on (in both directions from the base), so Propagate tracks
|
||||
only those again: the window starts at the nearest prompted frame at or before the first frame it needs (the corrected
|
||||
frame, which restarts the object with its own prompt) and runs to the last frame it needs. Frames before the correction
|
||||
keep their tracked results, and a Propagate with nothing changed does no tracking. The older non-conditioning memory
|
||||
frames before the corrected frame are not rebuilt, so frames just after it can differ slightly from a from-scratch pass.
|
||||
Tracked frames of a session are in memory; a project reopened in a new process re-tracks the whole span after a stroke
|
||||
change (its saved tracking is stale as a whole).
|
||||
|
||||
## Memory and limits
|
||||
The weights take about 156 MB. A model idle for 10 minutes is unloaded (a toast says so; the choice stays) and loads
|
||||
again when a Propagate or a stroke needs it. The tracker holds the memory banks of the objects it follows (about 2 MB per
|
||||
stored frame, pruned to the frames later ones can still look at) and refuses to grow past 2 GiB, or past a time limit when
|
||||
one is set, with a clear error instead of running out of memory. Two runs of the same prompts give bit-identical masks on
|
||||
the CPU.
|
||||
|
||||
Pending per-object probability maps have a separate 256 MiB budget, including their allocated capacity and frame
|
||||
slots. This bounds the full-resolution results waiting to be united when objects first prompted on different frames
|
||||
report in separate passes. Reaching the budget cancels tracking and falls back to the classic engine. The reason is
|
||||
available as `trackerNote` in live job progress only. Completion removes that progress and shows a success toast, so
|
||||
`wait: true` callers can miss the fallback reason; the completion toast alone does not confirm the tracker was used.
|
||||
A single object's results need no pending buffer.
|
||||
|
||||
## How it works
|
||||
`crates/sam2` is a Rust port (on [candle](https://github.com/huggingface/candle), native only) of the Hugging Face
|
||||
`transformers` SAM 2 implementation, Apache-2.0 (see its `NOTICE`). Every stage is checked against PyTorch by
|
||||
`crates/sam2/tests/it/reference.rs` (they skip without the weights; reference tensors come from
|
||||
`tools/sam2_reference.py` and `tools/sam2_video_reference.py`):
|
||||
|
||||
| Stage | Max abs error vs PyTorch |
|
||||
|---|---|
|
||||
| Hiera backbone, 12 blocks | 2.6e-5 |
|
||||
| Neck features / position encodings | 9.7e-6 / 6e-8 |
|
||||
| Prompt embeddings | 1.2e-7 |
|
||||
| Mask decoder (logits up to 11.6) | 1.1e-4 |
|
||||
| Memory encoder | 3.5e-6 |
|
||||
| Memory attention (rotary) | 2.6e-5 |
|
||||
| 7-frame clip, forwards and backwards (9 frame results) | logits 0.0038, IoU 1.0000 |
|
||||
|
||||
SAM 2 is prompted with at most three foreground and three background points per frame: it was trained on a handful
|
||||
of clicks, and measured confidence collapses with many (0.99 with 1–7 points, 0.25 with 16, 0.00 with 32).
|
||||
|
||||
## Speed (this PC: AMD RX 7900 GRE and a 16-thread CPU, release build)
|
||||
SAM 2 runs on the GPU when there is one (wgpu compute kernels, so AMD, NVIDIA and Intel alike; candle itself has no AMD
|
||||
backend) and on the CPU otherwise. The model loads on the GPU automatically; `EFFECTCRAFT_SAM2_DEVICE=cpu` (or `gpu`)
|
||||
overrides it. A GPU that fails (no adapter, out of memory, a lost device) makes the CPU take over for the rest of the
|
||||
session, and `roto.status` reports `tracker.device` (the GPU's name, or `CPU`).
|
||||
|
||||
| Step | CPU | GPU |
|
||||
|---|---|---|
|
||||
| Encode one 1024 × 1024 frame | 2.05 s | **0.24 s** |
|
||||
| Memory attention (full bank of 7 memory frames) | 2.3 s | **0.29 s** |
|
||||
| Memory encoder | 0.24 s | 0.05 s |
|
||||
| Decoder (CPU on both) | 0.11 s | 0.11 s |
|
||||
| **A tracked frame in all** | **4.3 s** | **0.7 s** |
|
||||
| A click on an encoded frame | 0.10 s | 0.10 s |
|
||||
|
||||
A 300-frame clip takes about 21 minutes on the CPU and about 3.5 minutes on this GPU; the decoder, still on the CPU, is the
|
||||
next 16 % of a GPU frame. The CPU numbers came down from 3.4 s per encode at the start of the speed
|
||||
work (parallel GELU and bias add, cached position encodings, pooling without permutes); chunking the attention queries was
|
||||
tried and did not help. The GPU kernels (`crates/sam2/src/gpu`) are tested against CPU definitions and PyTorch: the
|
||||
encoder's 64 × 64 level matches PyTorch to 7e-6, the memory attention to 1.8e-5, and a clip tracked on the GPU matches
|
||||
`Sam2VideoModel` as the CPU does (worst logit error 0.0032). `cargo run --release -p effectcraft-sam2 --example sam2_bench`
|
||||
times the pieces on both devices (`EFFECTCRAFT_SAM2_PROFILE=1` prints a frame's breakdown).
|
||||
|
||||
## Benchmark
|
||||
`effectcraft-cli bench --roto [--frames N] [--sam2 model.safetensors (any size; default: the first installed)] [--mobilesam mobile_sam.pt]` runs synthetic clips with
|
||||
known mattes through all three. 24 frames, debug build, SAM 2 on the GPU; mean IoU (mean IoU after the object returns), seconds
|
||||
per frame:
|
||||
|
||||
| Clip | Classical | MobileSAM | SAM 2.1 Tiny |
|
||||
|---|---|---|---|
|
||||
| move | 0.980 | 0.989 | 0.992 |
|
||||
| light | 0.979 | 0.990 | 0.992 |
|
||||
| occlusion (a bar hides the disk, then clears it) | 0.491 (0.000) | 0.494 (0.000) | 0.989 (0.988) |
|
||||
| reenter (the disk leaves and comes back) | 0.618 (0.000) | 0.620 (0.000) | 0.982 (0.976) |
|
||||
| s/frame | 0.01 | 0.5–1.3 | 0.8 |
|
||||
|
||||
MobileSAM prompts every frame independently from the flow-warped matte, so it follows the object while the flow does and
|
||||
loses it when it is hidden; SAM 2's streaming memory is what lets it re-acquire the object. The synthetic clips are easy (one
|
||||
textured disc), so the "move" and "light" rows mostly show all three working; the last two rows are the difference.
|
||||
|
||||
### Corrections needed (`--fixes`)
|
||||
`bench --roto --fixes` also counts the correction rounds each tracker needs before every frame reaches IoU 0.9: after each
|
||||
run it paints on the first frame under the target, up to 6 rounds. A round can add a short foreground stroke through the
|
||||
true object's centre and a background stroke at the centroid of all false-positive pixels. With separate false-positive
|
||||
blobs, that centroid can fall between them. The counts below are rounds, not individual strokes. 16 frames, debug build:
|
||||
|
||||
| Clip | Classical | MobileSAM | SAM 2.1 Tiny |
|
||||
|---|---|---|---|
|
||||
| move | 0 | 0 | 0 |
|
||||
| light | 0 | 0 | 0 |
|
||||
| occlusion | more than 6 | more than 6 | 0 |
|
||||
| reenter | more than 6 | more than 6 | 0 |
|
||||
|
||||
The simulated user is simple (one stroke pair per round, always on the first bad frame), so "more than 6" means this
|
||||
correction model never recovers the flow-based engines on those clips, not that no human could. Peak memory is not measured.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Pinned versions the SAM 2 reference fixtures were generated with (CPU).
|
||||
torch==2.7.0
|
||||
transformers==5.19.0
|
||||
safetensors==0.8.0
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python
|
||||
"""Dump per-stage SAM 2 reference tensors (CPU, float32) for the Rust port's reference tests.
|
||||
|
||||
Usage: python -I tools/sam2_reference.py MODEL_DIR OUT.safetensors
|
||||
MODEL_DIR holds the facebook/sam2.1-hiera-tiny files (config.json, model.safetensors, ...).
|
||||
Versions are pinned in tools/requirements-sam2-reference.txt. The input is synthetic, so no
|
||||
media is committed; the weights are never copied into the repo.
|
||||
"""
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from safetensors.torch import save_file
|
||||
import torch.nn.functional as F
|
||||
from transformers import Sam2Model
|
||||
|
||||
|
||||
def synthetic_image(size=256):
|
||||
"""A textured disc over a textured background (deterministic)."""
|
||||
rng = np.random.default_rng(7)
|
||||
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
|
||||
bg = 0.3 + 0.2 * np.sin(xx / 9.0) * np.cos(yy / 7.0)
|
||||
disc = np.hypot(xx - size * 0.45, yy - size * 0.5) < size * 0.22
|
||||
img = np.stack([bg, bg * 0.8 + 0.1, bg * 0.6 + 0.3], -1)
|
||||
img[disc] = np.stack([0.85 + 0.1 * np.sin(xx / 5), 0.4 + 0.1 * np.cos(yy / 5), 0.15 + 0 * xx], -1)[disc]
|
||||
img += rng.normal(0, 0.01, img.shape).astype(np.float32)
|
||||
return (img.clip(0, 1) * 255).astype(np.uint8)
|
||||
|
||||
|
||||
def main(model_dir, out):
|
||||
torch.manual_seed(0)
|
||||
torch.set_num_threads(1)
|
||||
model = Sam2Model.from_pretrained(model_dir).eval()
|
||||
image = synthetic_image()
|
||||
# Preprocess by hand (no torchvision needed): resize to 1024x1024, ImageNet mean/std.
|
||||
x = torch.from_numpy(image).permute(2, 0, 1)[None].float() / 255.0
|
||||
x = F.interpolate(x, size=(1024, 1024), mode="bilinear", align_corners=False)
|
||||
mean = torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1)
|
||||
std = torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1)
|
||||
scale = 1024.0 / image.shape[0]
|
||||
inputs = {
|
||||
"pixel_values": (x - mean) / std,
|
||||
"input_points": torch.tensor([[[[115.0 * scale, 128.0 * scale]]]]),
|
||||
"input_labels": torch.tensor([[[1]]], dtype=torch.int32),
|
||||
}
|
||||
taps = {}
|
||||
|
||||
def tap(name):
|
||||
def hook(_m, _i, o):
|
||||
def flat(x):
|
||||
if isinstance(x, torch.Tensor):
|
||||
yield x
|
||||
elif isinstance(x, (tuple, list)):
|
||||
for y in x:
|
||||
yield from flat(y)
|
||||
elif hasattr(x, "to_tuple"): # a ModelOutput (the neck)
|
||||
yield from flat(x.to_tuple())
|
||||
|
||||
for k, t in enumerate(flat(o)):
|
||||
taps[f"{name}.{k}"] = t.detach().float().clone().contiguous()
|
||||
return hook
|
||||
|
||||
# Stage taps by module path: patch embed, each Hiera block, the neck, prompt encoder, decoder.
|
||||
for name, mod in model.named_modules():
|
||||
parts = name.split(".")
|
||||
if name in ("vision_encoder.backbone.patch_embed", "vision_encoder.neck", "prompt_encoder", "mask_decoder") or (
|
||||
len(parts) == 4 and parts[:3] == ["vision_encoder", "backbone", "blocks"]
|
||||
):
|
||||
mod.register_forward_hook(tap(name))
|
||||
import time
|
||||
with torch.no_grad():
|
||||
t0 = time.perf_counter()
|
||||
res = model(**inputs, multimask_output=True)
|
||||
print(f"forward (1 thread, first run): {time.perf_counter() - t0:.2f} s")
|
||||
taps["input.pixel_values"] = inputs["pixel_values"].float()
|
||||
taps["out.iou_scores"] = res.iou_scores.float()
|
||||
taps["out.pred_masks"] = res.pred_masks.float()
|
||||
save_file({k: v.clone().contiguous() for k, v in taps.items()}, out)
|
||||
print(f"wrote {len(taps)} tensors to {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
sys.exit(__doc__)
|
||||
main(sys.argv[1], sys.argv[2])
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python
|
||||
"""Dump SAM 2 video reference tensors (CPU, float32) for the Rust port's reference tests.
|
||||
|
||||
Usage: python -I tools/sam2_video_reference.py MODEL_DIR OUT.safetensors
|
||||
Runs `Sam2VideoModel` on a 7-frame synthetic clip (a textured disc moving right, hidden behind a bar on
|
||||
frames 4 and 5), prompted with one click on frame 2, then tracks forward (2..6) and backward (2..0).
|
||||
Saves the preprocessed frames, every frame's low-resolution mask and object score, the stored memory
|
||||
features and object pointers, and the inputs/outputs of the memory encoder and memory attention.
|
||||
"""
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from safetensors.torch import save_file
|
||||
from transformers import Sam2VideoModel
|
||||
from transformers.models.sam2_video.modeling_sam2_video import Sam2VideoInferenceSession
|
||||
|
||||
T, SIZE, PROMPT_FRAME = 7, 256, 2
|
||||
|
||||
|
||||
def clip():
|
||||
rng = np.random.default_rng(3)
|
||||
yy, xx = np.mgrid[0:SIZE, 0:SIZE].astype(np.float32)
|
||||
bg = 0.3 + 0.2 * np.sin(xx / 9.0) * np.cos(yy / 7.0)
|
||||
frames = []
|
||||
for t in range(T):
|
||||
cx, cy = 80 + 12 * t, 128
|
||||
disc = np.hypot(xx - cx, yy - cy) < 34
|
||||
img = np.stack([bg, bg * 0.8 + 0.1, bg * 0.6 + 0.3], -1)
|
||||
tex = 0.85 + 0.1 * np.sin((xx - cx) / 5)
|
||||
img[disc] = np.stack([tex, 0.4 + 0.1 * np.cos((yy - cy) / 5), 0.15 + 0 * xx], -1)[disc]
|
||||
if t in (4, 5): # a bar hides the disc
|
||||
img[:, int(cx) - 50 : int(cx) + 50] = 0.2
|
||||
img += rng.normal(0, 0.01, img.shape).astype(np.float32)
|
||||
frames.append((img.clip(0, 1) * 255).astype(np.uint8))
|
||||
return frames, (80 + 12 * PROMPT_FRAME, 128)
|
||||
|
||||
|
||||
def main(model_dir, out):
|
||||
torch.manual_seed(0)
|
||||
torch.set_num_threads(4)
|
||||
model = Sam2VideoModel.from_pretrained(model_dir).eval()
|
||||
frames, click = clip()
|
||||
mean = torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1)
|
||||
std = torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1)
|
||||
x = torch.from_numpy(np.stack(frames)).permute(0, 3, 1, 2).float() / 255.0
|
||||
x = F.interpolate(x, size=(1024, 1024), mode="bilinear", align_corners=False)
|
||||
pixels = (x - mean) / std
|
||||
|
||||
taps = {"clip.pixel_values": pixels}
|
||||
first = {"enc": True, "attn": True}
|
||||
|
||||
def enc_hook(_m, args, kwargs, out):
|
||||
if first["enc"]:
|
||||
first["enc"] = False
|
||||
vf = args[0] if args else kwargs["vision_features"]
|
||||
masks = args[1] if len(args) > 1 else kwargs["masks"]
|
||||
taps["mem.enc.vision_features"] = vf.detach().float().clone()
|
||||
taps["mem.enc.masks"] = masks.detach().float().clone()
|
||||
taps["mem.enc.out_features"] = out[0].detach().float().clone()
|
||||
taps["mem.enc.out_pos"] = out[1].detach().float().clone()
|
||||
|
||||
def attn_hook(_m, args, kwargs, out):
|
||||
if first["attn"]:
|
||||
first["attn"] = False
|
||||
for k in ("current_vision_features", "memory", "current_vision_position_embeddings", "memory_posision_embeddings"):
|
||||
taps[f"mem.attn.{k}"] = kwargs[k].detach().float().clone()
|
||||
taps["mem.attn.num_pointers"] = torch.tensor([float(kwargs["num_object_pointer_tokens"])])
|
||||
taps["mem.attn.out"] = out.detach().float().clone()
|
||||
|
||||
model.memory_encoder.register_forward_hook(enc_hook, with_kwargs=True)
|
||||
model.memory_attention.register_forward_hook(attn_hook, with_kwargs=True)
|
||||
|
||||
sess = Sam2VideoInferenceSession(video=pixels, video_height=SIZE, video_width=SIZE, dtype=torch.float32)
|
||||
obj_idx = sess.obj_id_to_idx(1)
|
||||
scale = 1024.0 / SIZE
|
||||
sess.add_point_inputs(
|
||||
obj_idx,
|
||||
PROMPT_FRAME,
|
||||
{
|
||||
"point_coords": torch.tensor([[[[click[0] * scale, click[1] * scale]]]], dtype=torch.float32),
|
||||
"point_labels": torch.tensor([[[1]]], dtype=torch.int32),
|
||||
},
|
||||
)
|
||||
sess.obj_with_new_inputs = [1]
|
||||
|
||||
def record(tag, o):
|
||||
taps[f"video.{tag}.{o.frame_idx}.pred_masks"] = o.pred_masks.detach().float().clone()
|
||||
taps[f"video.{tag}.{o.frame_idx}.score"] = o.object_score_logits.detach().float().clone()
|
||||
|
||||
with torch.no_grad():
|
||||
record("prompt", model(sess, frame_idx=PROMPT_FRAME))
|
||||
for o in model.propagate_in_video_iterator(sess, start_frame_idx=PROMPT_FRAME):
|
||||
record("fwd", o)
|
||||
for o in model.propagate_in_video_iterator(sess, start_frame_idx=PROMPT_FRAME, reverse=True):
|
||||
record("rev", o)
|
||||
|
||||
# The same object prompted with a mask instead of a click (binary, at the model's 1024 x 1024).
|
||||
msess = Sam2VideoInferenceSession(video=pixels, video_height=SIZE, video_width=SIZE, dtype=torch.float32)
|
||||
mobj = msess.obj_id_to_idx(1)
|
||||
yy, xx = np.mgrid[0:1024, 0:1024].astype(np.float32)
|
||||
cx, cy = (80 + 12 * PROMPT_FRAME) * scale, 128 * scale
|
||||
disc = torch.from_numpy((np.hypot(xx + 0.5 - cx, yy + 0.5 - cy) < 34 * scale).astype(np.float32))[None, None]
|
||||
taps["maskprompt.input"] = disc.clone()
|
||||
msess.add_mask_inputs(mobj, PROMPT_FRAME, disc)
|
||||
msess.obj_with_new_inputs = [1]
|
||||
with torch.no_grad():
|
||||
o = model(msess, frame_idx=PROMPT_FRAME)
|
||||
taps[f"maskprompt.prompt.{o.frame_idx}.pred_masks"] = o.pred_masks.detach().float().clone()
|
||||
taps[f"maskprompt.prompt.{o.frame_idx}.score"] = o.object_score_logits.detach().float().clone()
|
||||
for o in model.propagate_in_video_iterator(msess, start_frame_idx=PROMPT_FRAME):
|
||||
taps[f"maskprompt.fwd.{o.frame_idx}.pred_masks"] = o.pred_masks.detach().float().clone()
|
||||
taps[f"maskprompt.fwd.{o.frame_idx}.score"] = o.object_score_logits.detach().float().clone()
|
||||
for o in model.propagate_in_video_iterator(msess, start_frame_idx=PROMPT_FRAME, reverse=True):
|
||||
taps[f"maskprompt.rev.{o.frame_idx}.pred_masks"] = o.pred_masks.detach().float().clone()
|
||||
taps[f"maskprompt.rev.{o.frame_idx}.score"] = o.object_score_logits.detach().float().clone()
|
||||
md = msess.output_dict_per_obj[mobj]
|
||||
for kind in ("cond_frame_outputs", "non_cond_frame_outputs"):
|
||||
for f, oo in md[kind].items():
|
||||
taps[f"maskstate.{f}.object_pointer"] = oo["object_pointer"].detach().float().clone()
|
||||
|
||||
d = sess.output_dict_per_obj[obj_idx]
|
||||
for kind in ("cond_frame_outputs", "non_cond_frame_outputs"):
|
||||
for f, o in d[kind].items():
|
||||
taps[f"state.{f}.maskmem_features"] = o["maskmem_features"].detach().float().clone()
|
||||
taps[f"state.{f}.maskmem_pos_enc"] = o["maskmem_pos_enc"].detach().float().clone()
|
||||
taps[f"state.{f}.object_pointer"] = o["object_pointer"].detach().float().clone()
|
||||
save_file({k: v.contiguous() for k, v in taps.items()}, out)
|
||||
print(f"wrote {len(taps)} tensors to {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
sys.exit(__doc__)
|
||||
main(sys.argv[1], sys.argv[2])
|
||||
@@ -50,6 +50,7 @@ const LAYERS: &[(&str, u8)] = &[
|
||||
("expr", 3),
|
||||
("export", 3),
|
||||
("gpu", 3),
|
||||
("sam2", 3),
|
||||
("lottie", 3),
|
||||
("format", 3),
|
||||
("plugin", 3),
|
||||
|
||||
Reference in New Issue
Block a user