Zhipeng Claude Sonnet 4.6 commited on
Commit
966d9af
·
1 Parent(s): ad69637

init project

Browse files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

.gitignore ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .claude*
2
+
3
+ # Python
4
+ __pycache__/
5
+ *.py[cod]
6
+ *.pyo
7
+ .venv/
8
+ *.egg-info/
9
+
10
+ # PyTorch / model weights
11
+ *.pt
12
+ *.pth
13
+ *.onnx
14
+ *.torchscript
15
+
16
+ # Ultralytics runs output
17
+ runs/
18
+
19
+ # Downloaded / temp images
20
+ *.mp4
21
+ *.avi
22
+
23
+ # uv / pip
24
+ .python-version
25
+
26
+ # OS
27
+ .DS_Store
28
+ Thumbs.db
README.md CHANGED
@@ -10,26 +10,12 @@ tags:
10
  - real-time
11
  - pytorch
12
  library_name: ultralytics
13
- pipeline_tag: object-detection
14
  datasets:
15
  - coco
16
  model-index:
17
  - name: CSN
18
  results:
19
- - task:
20
- type: object-detection
21
- name: Object Detection
22
- dataset:
23
- name: COCO 2017
24
- type: coco
25
- split: val2017
26
- metrics:
27
- - type: mAP50-95
28
- value: [FILL_ME]
29
- name: Box mAP50-95
30
- - type: mAP50
31
- value: [FILL_ME]
32
- name: Box mAP50
33
  - task:
34
  type: instance-segmentation
35
  name: Instance Segmentation
@@ -39,63 +25,83 @@ model-index:
39
  split: val2017
40
  metrics:
41
  - type: mAP50-95
42
- value: [FILL_ME]
43
  name: Mask mAP50-95
44
  - type: mAP50
45
- value: [FILL_ME]
46
  name: Mask mAP50
47
  ---
48
 
49
  # Symbolic Capsule Network
50
 
51
- **Symbolic Capsule Network (SCN)** is a capsule-enhanced real-time vision model for **COCO object detection** and **instance segmentation**.
52
- This release provides two model variants:
 
 
 
 
 
53
 
54
- - **Symbolic Capsule Network Detection**
55
- - **Symbolic Capsule Network Segmentation**
56
 
57
- SCN introduces capsule-style structured feature representation into a YOLO-style dense prediction pipeline, aiming to improve semantic compositionality while preserving practical real-time performance.
58
 
59
- ## Available Models
 
 
60
 
61
- | Model | Task | Dataset | Input Size | Best Validation Performance |
62
- |---|---|---|---:|---|
63
- | **Symbolic Capsule Network Detection** | Object Detection | COCO 2017 | 640 | Box mAP50 **0.55776**, Box mAP50-95 **0.40319** |
64
- | **Symbolic Capsule Network Segmentation** | Instance Segmentation | COCO 2017 | 640 | Mask mAP50 **0.53316**, Mask mAP50-95 **0.34080** |
65
 
66
- ## Model Description
 
 
 
 
 
 
 
67
 
68
- Symbolic Capsule Network is designed for real-time dense visual prediction.
69
- The model replaces part of the standard feature interaction pipeline with capsule-inspired transformations and structured routing, enabling richer intermediate representations for localization and mask prediction.
70
 
71
- This page focuses on two public COCO releases:
 
 
72
 
73
- 1. **Detection model**
74
- 2. **Segmentation model**
75
 
76
- ## Performance on COCO 2017
77
 
78
- ### Detection
79
 
80
- Best result from the current training record:
81
 
82
- - **Box mAP50:** `0.55776`
83
- - **Box mAP50-95:** `0.40319`
84
 
85
- ### Segmentation
 
 
86
 
87
- Best result from the current training record:
88
 
89
- - **Mask mAP50:** `0.53316`
90
- - **Mask mAP50-95:** `0.34080`
91
 
92
- ## Usage
 
 
93
 
94
- ### Detection
95
 
96
  ```python
97
  from ultralytics import YOLO
98
 
99
- model = YOLO("symbolic_capsule_network_detection.pt")
100
- results = model("image.jpg")
101
- results[0].show()
 
 
 
 
 
 
 
 
 
10
  - real-time
11
  - pytorch
12
  library_name: ultralytics
13
+ pipeline_tag: image-segmentation
14
  datasets:
15
  - coco
16
  model-index:
17
  - name: CSN
18
  results:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  - task:
20
  type: instance-segmentation
21
  name: Instance Segmentation
 
25
  split: val2017
26
  metrics:
27
  - type: mAP50-95
28
+ value: 0.34080
29
  name: Mask mAP50-95
30
  - type: mAP50
31
+ value: 0.53316
32
  name: Mask mAP50
33
  ---
34
 
35
  # Symbolic Capsule Network
36
 
37
+ This repository contains the HuggingFace model page assets for the **Symbolic Capsule Network (SCN)** segmentation checkpoint trained on **COCO 2017**.
38
+
39
+ The repo now includes the custom capsule modules required to load the checkpoint, plus a bundled default weight copied from:
40
+
41
+ `D:\Codes\CSNet\runs\CapsNeckHeadv6_SegV2_AuxDet_Mild\weights\best.pt`
42
+
43
+ The bundled checkpoint is stored at:
44
 
45
+ `weights/symbolic_capsule_network_segmentation.pt`
 
46
 
47
+ ## Model
48
 
49
+ | Model | Task | Dataset | Input Size | Mask mAP50 | Mask mAP50-95 |
50
+ |---|---|---|---:|---:|---:|
51
+ | Symbolic Capsule Network Segmentation | Instance Segmentation | COCO 2017 | 640 | 0.53316 | 0.34080 |
52
 
53
+ ## Files Included
 
 
 
54
 
55
+ - `configs/data/coco-seg.yaml`: COCO segmentation dataset yaml used by the bundled best checkpoint.
56
+ - `configs/data/coco.yaml`: COCO detection dataset yaml retained for the paired detection setup.
57
+ - `configs/seg_model/Capsneck_v6_seg_v2.yaml`: segmentation model yaml used by the bundled best checkpoint.
58
+ - `configs/det_model/yolo26_capsneckhead_v6_gated.yaml`: paired detection model yaml for the v6 gated setup.
59
+ - `modules/`: custom capsule backbone, neck, and head implementations.
60
+ - `models/custom_yolo.py`: Ultralytics registration hook for custom layers.
61
+ - `predict.py`: minimal inference entrypoint with the bundled checkpoint as default.
62
+ - `weights/symbolic_capsule_network_segmentation.pt`: default segmentation checkpoint.
63
 
64
+ ## Environment
 
65
 
66
+ ```bash
67
+ uv sync
68
+ ```
69
 
70
+ This repository now ships the same `pyproject.toml`, `uv.lock`, and `.python-version` setup as the original `D:\Codes\CSNet` project.
 
71
 
72
+ The local `.venv` in this directory is linked to:
73
 
74
+ `D:\Codes\CSNet\.venv`
75
 
76
+ So you can run `uv sync` here without creating a new virtual environment, and both directories will use the same existing environment.
77
 
78
+ ## Run Inference
 
79
 
80
+ ```bash
81
+ python predict.py path/to/image.jpg
82
+ ```
83
 
84
+ By default, `predict.py` loads `weights/symbolic_capsule_network_segmentation.pt`.
85
 
86
+ You can also override the checkpoint explicitly:
 
87
 
88
+ ```bash
89
+ python predict.py path/to/image.jpg --weights path/to/other.pt
90
+ ```
91
 
92
+ ## Python Usage
93
 
94
  ```python
95
  from ultralytics import YOLO
96
 
97
+ from models import register_ultralytics_modules
98
+
99
+ register_ultralytics_modules()
100
+ model = YOLO("weights/symbolic_capsule_network_segmentation.pt")
101
+ results = model.predict("image.jpg", imgsz=640, conf=0.25)
102
+ ```
103
+
104
+ ## Notes
105
+
106
+ - This checkpoint is a segmentation model, so masks are available in `results[0].masks`.
107
+ - The custom code is required before loading the checkpoint with Ultralytics.
configs/data/coco-seg.yaml ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ultralytics AGPL-3.0 License - https://ultralytics.com/license
2
+ # COCO 2017 instance segmentation dataset config
3
+
4
+ path: C:\Datasets\coco-seg
5
+ train: train2017.txt
6
+ val: val2017.txt
7
+
8
+ names:
9
+ 0: person
10
+ 1: bicycle
11
+ 2: car
12
+ 3: motorcycle
13
+ 4: airplane
14
+ 5: bus
15
+ 6: train
16
+ 7: truck
17
+ 8: boat
18
+ 9: traffic light
19
+ 10: fire hydrant
20
+ 11: stop sign
21
+ 12: parking meter
22
+ 13: bench
23
+ 14: bird
24
+ 15: cat
25
+ 16: dog
26
+ 17: horse
27
+ 18: sheep
28
+ 19: cow
29
+ 20: elephant
30
+ 21: bear
31
+ 22: zebra
32
+ 23: giraffe
33
+ 24: backpack
34
+ 25: umbrella
35
+ 26: handbag
36
+ 27: tie
37
+ 28: suitcase
38
+ 29: frisbee
39
+ 30: skis
40
+ 31: snowboard
41
+ 32: sports ball
42
+ 33: kite
43
+ 34: baseball bat
44
+ 35: baseball glove
45
+ 36: skateboard
46
+ 37: surfboard
47
+ 38: tennis racket
48
+ 39: bottle
49
+ 40: wine glass
50
+ 41: cup
51
+ 42: fork
52
+ 43: knife
53
+ 44: spoon
54
+ 45: bowl
55
+ 46: banana
56
+ 47: apple
57
+ 48: sandwich
58
+ 49: orange
59
+ 50: broccoli
60
+ 51: carrot
61
+ 52: hot dog
62
+ 53: pizza
63
+ 54: donut
64
+ 55: cake
65
+ 56: chair
66
+ 57: couch
67
+ 58: potted plant
68
+ 59: bed
69
+ 60: dining table
70
+ 61: toilet
71
+ 62: tv
72
+ 63: laptop
73
+ 64: mouse
74
+ 65: remote
75
+ 66: keyboard
76
+ 67: cell phone
77
+ 68: microwave
78
+ 69: oven
79
+ 70: toaster
80
+ 71: sink
81
+ 72: refrigerator
82
+ 73: book
83
+ 74: clock
84
+ 75: vase
85
+ 76: scissors
86
+ 77: teddy bear
87
+ 78: hair drier
88
+ 79: toothbrush
configs/data/coco.yaml ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
2
+
3
+ # COCO 2017 dataset https://cocodataset.org by Microsoft
4
+ # Documentation: https://docs.ultralytics.com/datasets/detect/coco/
5
+ # Example usage: yolo train data=coco.yaml
6
+ # parent
7
+ # ├── ultralytics
8
+ # └── datasets
9
+ # └── coco ← downloads here (20.1 GB)
10
+
11
+ # Train/val/test sets as 1) dir: path/to/imgs, 2) file: path/to/imgs.txt, or 3) list: [path/to/imgs1, path/to/imgs2, ..]
12
+ path: C:\Datasets\coco-yolo # dataset root dir
13
+ train: train2017.txt # train images (relative to 'path') 118287 images
14
+ val: val2017.txt # val images (relative to 'path') 5000 images
15
+
16
+ # Classes
17
+ names:
18
+ 0: person
19
+ 1: bicycle
20
+ 2: car
21
+ 3: motorcycle
22
+ 4: airplane
23
+ 5: bus
24
+ 6: train
25
+ 7: truck
26
+ 8: boat
27
+ 9: traffic light
28
+ 10: fire hydrant
29
+ 11: stop sign
30
+ 12: parking meter
31
+ 13: bench
32
+ 14: bird
33
+ 15: cat
34
+ 16: dog
35
+ 17: horse
36
+ 18: sheep
37
+ 19: cow
38
+ 20: elephant
39
+ 21: bear
40
+ 22: zebra
41
+ 23: giraffe
42
+ 24: backpack
43
+ 25: umbrella
44
+ 26: handbag
45
+ 27: tie
46
+ 28: suitcase
47
+ 29: frisbee
48
+ 30: skis
49
+ 31: snowboard
50
+ 32: sports ball
51
+ 33: kite
52
+ 34: baseball bat
53
+ 35: baseball glove
54
+ 36: skateboard
55
+ 37: surfboard
56
+ 38: tennis racket
57
+ 39: bottle
58
+ 40: wine glass
59
+ 41: cup
60
+ 42: fork
61
+ 43: knife
62
+ 44: spoon
63
+ 45: bowl
64
+ 46: banana
65
+ 47: apple
66
+ 48: sandwich
67
+ 49: orange
68
+ 50: broccoli
69
+ 51: carrot
70
+ 52: hot dog
71
+ 53: pizza
72
+ 54: donut
73
+ 55: cake
74
+ 56: chair
75
+ 57: couch
76
+ 58: potted plant
77
+ 59: bed
78
+ 60: dining table
79
+ 61: toilet
80
+ 62: tv
81
+ 63: laptop
82
+ 64: mouse
83
+ 65: remote
84
+ 66: keyboard
85
+ 67: cell phone
86
+ 68: microwave
87
+ 69: oven
88
+ 70: toaster
89
+ 71: sink
90
+ 72: refrigerator
91
+ 73: book
92
+ 74: clock
93
+ 75: vase
94
+ 76: scissors
95
+ 77: teddy bear
96
+ 78: hair drier
97
+ 79: toothbrush
98
+
99
+ # Download script/URL (optional)
100
+ download: |
101
+ from pathlib import Path
102
+
103
+ from ultralytics.utils import ASSETS_URL
104
+ from ultralytics.utils.downloads import download
105
+
106
+ # Download labels
107
+ segments = True # segment or box labels
108
+ dir = Path(yaml["path"]) # dataset root dir
109
+ urls = [ASSETS_URL + ("/coco2017labels-segments.zip" if segments else "/coco2017labels.zip")] # labels
110
+ download(urls, dir=dir.parent)
111
+ # Download data
112
+ urls = [
113
+ "http://images.cocodataset.org/zips/train2017.zip", # 19G, 118k images
114
+ "http://images.cocodataset.org/zips/val2017.zip", # 1G, 5k images
115
+ "http://images.cocodataset.org/zips/test2017.zip", # 7G, 41k images (optional)
116
+ ]
117
+ download(urls, dir=dir / "images", threads=3)
configs/det_model/yolo26_capsneckhead_v6_gated.yaml ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ultralytics AGPL-3.0 License - https://ultralytics.com/license
2
+ #
3
+ # YOLO26 + capsule-style neck(v2 route) + CapsuleDetectv6 head
4
+ # This variant keeps the v6 neck/backbone and switches the detect head to the
5
+ # gated act modulation used by CapsuleSegmentv1 for seg -> det transfer.
6
+
7
+ nc: 80
8
+ end2end: True
9
+ reg_max: 1
10
+ scales:
11
+ n: [0.50, 0.25, 1024]
12
+ s: [0.50, 0.50, 1024]
13
+ m: [0.50, 1.00, 512]
14
+ l: [1.00, 1.00, 512]
15
+ x: [1.00, 1.50, 512]
16
+
17
+ backbone:
18
+ - [-1, 1, Conv, [64, 3, 2]]
19
+ - [-1, 1, Conv, [128, 3, 2]]
20
+ - [-1, 2, C3k2, [256, False, 0.25]]
21
+ - [-1, 1, Conv, [256, 3, 2]]
22
+ - [-1, 2, C3k2, [512, False, 0.25]]
23
+ - [-1, 1, Conv, [512, 3, 2]]
24
+ - [-1, 2, C3k2, [512, True]]
25
+ - [-1, 1, Conv, [1024, 3, 2]]
26
+ - [-1, 2, C3k2, [1024, True]]
27
+ - [-1, 1, SPPF, [1024, 5, 3, True]]
28
+ - [-1, 2, C2PSA, [1024]]
29
+
30
+ head:
31
+ - [4, 1, CapsProj, [16, 16]]
32
+ - [6, 1, CapsProj, [32, 16]]
33
+ - [10, 1, CapsProj, [64, 16]]
34
+
35
+ - [13, 1, CapsAlign, [5, 4]]
36
+ - [[14, 12], 1, CapsRoutev2, [[64, 32], [16, 16], 32, 16]]
37
+
38
+ - [15, 1, CapsAlign, [4, 3]]
39
+ - [[16, 11], 1, CapsRoutev2, [[32, 16], [16, 16], 16, 16]]
40
+
41
+ - [17, 1, CapsAlign, [3, 4, 16]]
42
+ - [[18, 15], 1, CapsRoutev2, [[16, 32], [16, 16], 32, 16]]
43
+
44
+ - [19, 1, CapsAlign, [4, 5, 32]]
45
+ - [[20, 13], 1, CapsRoutev2, [[32, 64], [16, 16], 64, 16]]
46
+
47
+ - [[17, 19, 21], 1, CapsuleDetectv6, [nc, [16, 32, 64], [16, 16, 16]]]
configs/seg_model/Capsneck_v6_seg_v2.yaml ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ultralytics AGPL-3.0 License - https://ultralytics.com/license
2
+ #
3
+ # YOLO26 + CapsNeck(v2 route) + CapsuleSegmentv2 head
4
+ # Classification branch uses raw pose features plus cls_priors, without act gating.
5
+
6
+ nc: 80
7
+ end2end: True
8
+ reg_max: 1
9
+ scales:
10
+ n: [0.50, 0.25, 1024]
11
+ s: [0.50, 0.50, 1024]
12
+ m: [0.50, 1.00, 512]
13
+ l: [1.00, 1.00, 512]
14
+ x: [1.00, 1.50, 512]
15
+
16
+ backbone:
17
+ - [-1, 1, Conv, [64, 3, 2]]
18
+ - [-1, 1, Conv, [128, 3, 2]]
19
+ - [-1, 2, C3k2, [256, False, 0.25]]
20
+ - [-1, 1, Conv, [256, 3, 2]]
21
+ - [-1, 2, C3k2, [512, False, 0.25]]
22
+ - [-1, 1, Conv, [512, 3, 2]]
23
+ - [-1, 2, C3k2, [512, True]]
24
+ - [-1, 1, Conv, [1024, 3, 2]]
25
+ - [-1, 2, C3k2, [1024, True]]
26
+ - [-1, 1, SPPF, [1024, 5, 3, True]]
27
+ - [-1, 2, C2PSA, [1024]]
28
+
29
+ head:
30
+ - [4, 1, CapsProj, [16, 16]]
31
+ - [6, 1, CapsProj, [32, 16]]
32
+ - [10, 1, CapsProj, [64, 16]]
33
+
34
+ - [13, 1, CapsAlign, [5, 4]]
35
+ - [[14, 12], 1, CapsRoutev2, [[64, 32], [16, 16], 32, 16]]
36
+
37
+ - [15, 1, CapsAlign, [4, 3]]
38
+ - [[16, 11], 1, CapsRoutev2, [[32, 16], [16, 16], 16, 16]]
39
+
40
+ - [17, 1, CapsAlign, [3, 4, 16]]
41
+ - [[18, 15], 1, CapsRoutev2, [[16, 32], [16, 16], 32, 16]]
42
+
43
+ - [19, 1, CapsAlign, [4, 5, 32]]
44
+ - [[20, 13], 1, CapsRoutev2, [[32, 64], [16, 16], 64, 16]]
45
+
46
+ # [nc, k_list, d_list, nm, npr]
47
+ - [[17, 19, 21], 1, CapsuleSegmentv2, [nc, [16, 32, 64], [16, 16, 16], 32, 256]]
models/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .custom_yolo import register_ultralytics_modules
2
+
3
+ __all__ = ["register_ultralytics_modules"]
models/custom_yolo.py ADDED
@@ -0,0 +1,501 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Register custom modules and provide a parse_model with direct custom-layer support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import math
7
+ import contextlib
8
+ from typing import Any
9
+
10
+ import torch
11
+ import torch.nn as nn
12
+
13
+ from ultralytics.nn.modules import (
14
+ AIFI,
15
+ C1,
16
+ C2,
17
+ C2PSA,
18
+ C3,
19
+ C3TR,
20
+ ELAN1,
21
+ OBB,
22
+ OBB26,
23
+ PSA,
24
+ SPP,
25
+ SPPELAN,
26
+ SPPF,
27
+ A2C2f,
28
+ AConv,
29
+ ADown,
30
+ Bottleneck,
31
+ BottleneckCSP,
32
+ C2f,
33
+ C2fAttn,
34
+ C2fCIB,
35
+ C2fPSA,
36
+ C3Ghost,
37
+ C3k2,
38
+ C3x,
39
+ CBFuse,
40
+ CBLinear,
41
+ Classify,
42
+ Concat,
43
+ Conv,
44
+ ConvTranspose,
45
+ Detect,
46
+ DWConv,
47
+ DWConvTranspose2d,
48
+ Focus,
49
+ GhostBottleneck,
50
+ GhostConv,
51
+ HGBlock,
52
+ HGStem,
53
+ ImagePoolingAttn,
54
+ Index,
55
+ Pose,
56
+ Pose26,
57
+ RepC3,
58
+ RepNCSPELAN4,
59
+ ResNetLayer,
60
+ RTDETRDecoder,
61
+ SCDown,
62
+ Segment,
63
+ Segment26,
64
+ TorchVision,
65
+ WorldDetect,
66
+ YOLOEDetect,
67
+ YOLOESegment,
68
+ YOLOESegment26,
69
+ v10Detect,
70
+ )
71
+ from ultralytics.utils import LOGGER, colorstr
72
+ from ultralytics.utils.ops import make_divisible
73
+
74
+ from modules import (
75
+ CapsAlign,
76
+ CapsDecode,
77
+ CapsProj,
78
+ CapsRoute,
79
+ CapsRoutev2,
80
+ CapsRoutev3,
81
+ CapsRoutev4,
82
+ CapsuleDetect,
83
+ CapsuleDetectv1,
84
+ CapsuleDetectv2,
85
+ CapsuleDetectv4,
86
+ CapsuleDetectv5,
87
+ CapsuleDetectv6,
88
+ CapsuleDetectv7,
89
+ CapsuleDetectv8,
90
+ CapsuleOpenVocabDetect,
91
+ CapsuleSegmentv1,
92
+ CapsuleSegmentv2,
93
+ CapsuleSegmentv3,
94
+ CapsuleTap,
95
+ DeformableCapsBlock,
96
+ )
97
+
98
+
99
+ CUSTOM_MODULES = {
100
+ "DeformableCapsBlock": DeformableCapsBlock,
101
+ "CapsuleDetect": CapsuleDetect,
102
+ "CapsuleDetectv1": CapsuleDetectv1,
103
+ "CapsuleDetectv2": CapsuleDetectv2,
104
+ "CapsuleDetectv4": CapsuleDetectv4,
105
+ "CapsuleDetectv5": CapsuleDetectv5,
106
+ "CapsuleDetectv6": CapsuleDetectv6,
107
+ "CapsuleDetectv7": CapsuleDetectv7,
108
+ "CapsuleDetectv8": CapsuleDetectv8,
109
+ "CapsuleOpenVocabDetect": CapsuleOpenVocabDetect,
110
+ "CapsuleSegmentv1": CapsuleSegmentv1,
111
+ "CapsuleSegmentv2": CapsuleSegmentv2,
112
+ "CapsuleSegmentv3": CapsuleSegmentv3,
113
+ "CapsProj": CapsProj,
114
+ "CapsAlign": CapsAlign,
115
+ "CapsRoute": CapsRoute,
116
+ "CapsRoutev2": CapsRoutev2,
117
+ "CapsRoutev3": CapsRoutev3,
118
+ "CapsRoutev4": CapsRoutev4,
119
+ "CapsDecode": CapsDecode,
120
+ "CapsuleTap": CapsuleTap,
121
+ }
122
+
123
+
124
+ def parse_model(d: dict[str, Any], ch: int, verbose: bool = True):
125
+ """Parse a model.yaml dictionary into a PyTorch model with direct custom-module support."""
126
+ legacy = True
127
+ max_channels = float("inf")
128
+ nc, act, scales, end2end = (d.get(x) for x in ("nc", "activation", "scales", "end2end"))
129
+ reg_max = d.get("reg_max", 16)
130
+ depth, width = (d.get(x, 1.0) for x in ("depth_multiple", "width_multiple"))
131
+ scale = d.get("scale")
132
+ if scales:
133
+ if not scale:
134
+ scale = next(iter(scales.keys()))
135
+ LOGGER.warning(f"no model scale passed. Assuming scale='{scale}'.")
136
+ depth, width, max_channels = scales[scale]
137
+
138
+ if act:
139
+ Conv.default_act = eval(act)
140
+ if verbose:
141
+ LOGGER.info(f"{colorstr('activation:')} {act}")
142
+
143
+ if verbose:
144
+ LOGGER.info(f"\n{'':>3}{'from':>20}{'n':>3}{'params':>10} {'module':<45}{'arguments':<30}")
145
+
146
+ ch = [ch]
147
+ layers, save = [], []
148
+
149
+ base_modules = frozenset(
150
+ {
151
+ Classify,
152
+ Conv,
153
+ ConvTranspose,
154
+ GhostConv,
155
+ Bottleneck,
156
+ GhostBottleneck,
157
+ SPP,
158
+ SPPF,
159
+ C2fPSA,
160
+ C2PSA,
161
+ DWConv,
162
+ Focus,
163
+ BottleneckCSP,
164
+ C1,
165
+ C2,
166
+ C2f,
167
+ C3k2,
168
+ RepNCSPELAN4,
169
+ ELAN1,
170
+ ADown,
171
+ AConv,
172
+ SPPELAN,
173
+ C2fAttn,
174
+ C3,
175
+ C3TR,
176
+ C3Ghost,
177
+ torch.nn.ConvTranspose2d,
178
+ DWConvTranspose2d,
179
+ C3x,
180
+ RepC3,
181
+ PSA,
182
+ SCDown,
183
+ C2fCIB,
184
+ A2C2f,
185
+ }
186
+ )
187
+ repeat_modules = frozenset(
188
+ {
189
+ BottleneckCSP,
190
+ C1,
191
+ C2,
192
+ C2f,
193
+ C3k2,
194
+ C2fAttn,
195
+ C3,
196
+ C3TR,
197
+ C3Ghost,
198
+ C3x,
199
+ RepC3,
200
+ C2fPSA,
201
+ C2fCIB,
202
+ C2PSA,
203
+ A2C2f,
204
+ }
205
+ )
206
+
207
+ detect_modules = frozenset(
208
+ {
209
+ Detect,
210
+ CapsuleDetect,
211
+ CapsuleDetectv1,
212
+ CapsuleDetectv2,
213
+ CapsuleDetectv4,
214
+ CapsuleDetectv5,
215
+ CapsuleDetectv6,
216
+ CapsuleDetectv7,
217
+ CapsuleDetectv8,
218
+ CapsuleDetectv8,
219
+ CapsuleOpenVocabDetect,
220
+ CapsuleSegmentv1,
221
+ CapsuleSegmentv2,
222
+ CapsuleSegmentv3,
223
+ CapsuleSegmentv3,
224
+ WorldDetect,
225
+ YOLOEDetect,
226
+ Segment,
227
+ Segment26,
228
+ YOLOESegment,
229
+ YOLOESegment26,
230
+ Pose,
231
+ Pose26,
232
+ OBB,
233
+ OBB26,
234
+ }
235
+ )
236
+
237
+ for i, (f, n, m, args) in enumerate(d["backbone"] + d["head"]):
238
+ m = (
239
+ getattr(torch.nn, m[3:])
240
+ if "nn." in m
241
+ else getattr(__import__("torchvision").ops, m[16:])
242
+ if "torchvision.ops." in m
243
+ else globals()[m]
244
+ )
245
+
246
+ for j, a in enumerate(args):
247
+ if isinstance(a, str):
248
+ with contextlib.suppress(ValueError):
249
+ args[j] = locals()[a] if a in locals() else ast.literal_eval(a)
250
+
251
+ n = n_ = max(round(n * depth), 1) if n > 1 else n
252
+ c2 = None
253
+
254
+ if m in base_modules:
255
+ c1, c2 = ch[f], args[0]
256
+ if c2 != nc:
257
+ c2 = make_divisible(min(c2, max_channels) * width, 8)
258
+ if m is C2fAttn:
259
+ args[1] = make_divisible(min(args[1], max_channels // 2) * width, 8)
260
+ args[2] = int(max(round(min(args[2], max_channels // 2 // 32)) * width, 1) if args[2] > 1 else args[2])
261
+
262
+ args = [c1, c2, *args[1:]]
263
+ if m in repeat_modules:
264
+ args.insert(2, n)
265
+ n = 1
266
+ if m is C3k2:
267
+ legacy = False
268
+ if scale in "mlx":
269
+ args[3] = True
270
+ if m is A2C2f:
271
+ legacy = False
272
+ if scale in "lx":
273
+ args.extend((True, 1.2))
274
+ if m is C2fCIB:
275
+ legacy = False
276
+
277
+ elif m is CapsProj:
278
+ c1 = ch[f]
279
+ k_base = int(args[0]) if len(args) > 0 else 4
280
+ # Width scaling for capsule type count keeps model-size behavior aligned with YOLO scales.
281
+ k = max(int(round(k_base * width)), 1)
282
+ d_caps = int(args[1]) if len(args) > 1 else 16
283
+ args = [c1, k, d_caps]
284
+ c2 = k * (d_caps + 1)
285
+
286
+ elif m is CapsAlign:
287
+ c1 = ch[f]
288
+ if len(args) < 2:
289
+ raise ValueError('CapsAlign args must be [src_level, tgt_level, (down_groups)].')
290
+ src_level, tgt_level = int(args[0]), int(args[1])
291
+ if len(args) > 2:
292
+ dg_base = int(args[2])
293
+ group_num = max(int(round(dg_base * width)), 1)
294
+ # keep grouped-conv valid: down_groups must divide channels
295
+ else:
296
+ group_num = 1
297
+ args = [c1, src_level, tgt_level, group_num]
298
+ c2 = c1
299
+
300
+ elif m in {CapsRoute, CapsRoutev2, CapsRoutev3, CapsRoutev4}:
301
+ num_src = len(f) if isinstance(f, (list, tuple)) else 1
302
+
303
+ # Preferred YAML args:
304
+ # [K_in_list, P_in_list, K_out, P_out]
305
+ # [K_in_list, P_in_list, K_out, P_out, kernel_size, pre_k, post_k, pre_groups, post_groups]
306
+ # Legacy support:
307
+ # [P_in, K_out, P_out]
308
+ # [P_in]
309
+ if len(args) >= 4:
310
+ K_in_raw, P_in_raw, K_out, P_out = args[0], args[1], int(args[2]), int(args[3])
311
+ kernel_size = int(args[4]) if len(args) > 4 else 3
312
+ pre_k = int(args[5]) if len(args) > 5 else 3
313
+ post_k = int(args[6]) if len(args) > 6 else 3
314
+ pre_groups_raw = int(args[7]) if len(args) > 7 else 0
315
+ post_groups_raw = int(args[8]) if len(args) > 8 else 0
316
+ elif len(args) == 3:
317
+ P_in_raw, K_out, P_out = int(args[0]), int(args[1]), int(args[2])
318
+ K_in_raw = 1
319
+ kernel_size, pre_k, post_k = 3, 3, 3
320
+ pre_groups_raw, post_groups_raw = 0, 0
321
+ elif len(args) == 1:
322
+ P_in_raw = int(args[0])
323
+ K_in_raw, K_out, P_out = 1, 1, int(P_in_raw)
324
+ kernel_size, pre_k, post_k = 3, 3, 3
325
+ pre_groups_raw, post_groups_raw = 0, 0
326
+ else:
327
+ raise ValueError('CapsRoute/CapsRoutev2 args must be [K_in,P_in,K_out,P_out,(kernel_size,pre_k,post_k,pre_groups,post_groups)] or legacy [P_in,(K_out,P_out)].')
328
+
329
+ if isinstance(K_in_raw, (list, tuple)):
330
+ K_in_base = [int(v) for v in K_in_raw]
331
+ else:
332
+ K_in_base = [int(K_in_raw)] * num_src
333
+
334
+ if isinstance(P_in_raw, (list, tuple)):
335
+ P_in = [int(v) for v in P_in_raw]
336
+ else:
337
+ P_in = [int(P_in_raw)] * num_src
338
+
339
+ if len(K_in_base) != num_src or len(P_in) != num_src:
340
+ raise ValueError('CapsRoute/CapsRoutev2 K_in/P_in lists must match number of sources.')
341
+
342
+ # Width scaling follows Ultralytics scale.width behavior.
343
+ K_in = [max(int(round(k * width)), 1) for k in K_in_base]
344
+ K_out = max(int(round(K_out * width)), 1)
345
+
346
+ pre_groups = None
347
+ if pre_groups_raw > 0:
348
+ pre_groups = max(int(round(pre_groups_raw * width)), 1)
349
+ post_groups = None
350
+ if post_groups_raw > 0:
351
+ post_groups = max(int(round(post_groups_raw * width)), 1)
352
+
353
+ args = [K_in, P_in, K_out, int(P_out), kernel_size, pre_k, post_k, pre_groups, post_groups]
354
+ c2 = K_out * (int(P_out) + 1)
355
+
356
+ elif m is CapsDecode:
357
+ c1 = ch[f]
358
+ c2 = int(args[0]) if len(args) else c1
359
+ c2 = make_divisible(min(c2, max_channels) * width, 8)
360
+ args = [c1, c2]
361
+
362
+ elif m is CapsuleTap:
363
+ c2 = ch[f]
364
+
365
+ elif m is AIFI:
366
+ args = [ch[f], *args]
367
+ elif m in frozenset({HGStem, HGBlock}):
368
+ c1, cm, c2 = ch[f], args[0], args[1]
369
+ args = [c1, cm, c2, *args[2:]]
370
+ if m is HGBlock:
371
+ args.insert(4, n)
372
+ n = 1
373
+ elif m is ResNetLayer:
374
+ c2 = args[1] if args[3] else args[1] * 4
375
+ elif m is torch.nn.BatchNorm2d:
376
+ args = [ch[f]]
377
+ c2 = ch[f]
378
+ elif m is Concat:
379
+ c2 = sum(ch[x] for x in f)
380
+ elif m in detect_modules:
381
+ if m in {
382
+ CapsuleDetect,
383
+ CapsuleDetectv1,
384
+ CapsuleDetectv2,
385
+ CapsuleDetectv4,
386
+ CapsuleDetectv5,
387
+ CapsuleDetectv6,
388
+ CapsuleDetectv7,
389
+ CapsuleDetectv8,
390
+ CapsuleDetectv8,
391
+ CapsuleDetectv8,
392
+ CapsuleOpenVocabDetect,
393
+ CapsuleSegmentv1,
394
+ CapsuleSegmentv2,
395
+ CapsuleSegmentv3,
396
+ CapsuleSegmentv3,
397
+ CapsuleSegmentv3,
398
+ }:
399
+ if len(args) < 3:
400
+ raise ValueError('CapsuleDetect/CapsuleDetectv1/CapsuleDetectv2/CapsuleDetectv4/CapsuleDetectv5/CapsuleDetectv6/CapsuleDetectv7/CapsuleDetectv8/CapsuleOpenVocabDetect/CapsuleSegmentv1/CapsuleSegmentv2/CapsuleSegmentv3 args must include [nc, k_list, d_list].')
401
+ if not isinstance(args[1], (list, tuple)) or not isinstance(args[2], (list, tuple)):
402
+ raise TypeError('CapsuleDetect/CapsuleDetectv1/CapsuleDetectv2/CapsuleDetectv4/CapsuleDetectv5/CapsuleDetectv6/CapsuleDetectv7/CapsuleDetectv8/CapsuleOpenVocabDetect/CapsuleSegmentv1/CapsuleSegmentv2/CapsuleSegmentv3 requires k_list and d_list in YAML.')
403
+ # Width-scale capsule type counts per level; keep pose dims as provided.
404
+ args[1] = [max(int(round(int(v) * width)), 1) for v in args[1]]
405
+ args[2] = [int(v) for v in args[2]]
406
+
407
+ if m is CapsuleOpenVocabDetect:
408
+ # Keep YAML order aligned with the head API:
409
+ # [nc, k_list, d_list, embed, with_act_gate, with_objectness_prior]
410
+ args = [*args[:3], reg_max, end2end, *args[3:], [ch[x] for x in f]]
411
+ else:
412
+ args.extend([reg_max, end2end, [ch[x] for x in f]])
413
+ if m in {Segment, Segment26, YOLOESegment, YOLOESegment26}:
414
+ args[2] = make_divisible(min(args[2], max_channels) * width, 8)
415
+ if m in {
416
+ Detect,
417
+ CapsuleDetect,
418
+ CapsuleDetectv1,
419
+ CapsuleDetectv2,
420
+ CapsuleDetectv4,
421
+ CapsuleDetectv5,
422
+ CapsuleDetectv6,
423
+ CapsuleDetectv7,
424
+ CapsuleDetectv8,
425
+ CapsuleDetectv8,
426
+ CapsuleDetectv8,
427
+ CapsuleOpenVocabDetect,
428
+ CapsuleSegmentv1,
429
+ CapsuleSegmentv2,
430
+ CapsuleSegmentv3,
431
+ CapsuleSegmentv3,
432
+ CapsuleSegmentv3,
433
+ YOLOEDetect,
434
+ Segment,
435
+ Segment26,
436
+ YOLOESegment,
437
+ YOLOESegment26,
438
+ Pose,
439
+ Pose26,
440
+ OBB,
441
+ OBB26,
442
+ }:
443
+ m.legacy = legacy
444
+ c2 = ch[f[-1]] if isinstance(f, (list, tuple)) else ch[f]
445
+
446
+ elif m is v10Detect:
447
+ args.append([ch[x] for x in f])
448
+ c2 = ch[f[-1]] if isinstance(f, (list, tuple)) else ch[f]
449
+ elif m is ImagePoolingAttn:
450
+ args.insert(1, [ch[x] for x in f])
451
+ c2 = ch[f[-1]] if isinstance(f, (list, tuple)) else ch[f]
452
+ elif m is RTDETRDecoder:
453
+ args.insert(1, [ch[x] for x in f])
454
+ c2 = ch[f[-1]] if isinstance(f, (list, tuple)) else ch[f]
455
+ elif m is CBLinear:
456
+ c2 = args[0]
457
+ c1 = ch[f]
458
+ args = [c1, c2, *args[1:]]
459
+ elif m is CBFuse:
460
+ c2 = ch[f[-1]]
461
+ elif m in frozenset({TorchVision, Index}):
462
+ c2 = args[0]
463
+ args = [*args[1:]]
464
+ else:
465
+ c2 = ch[f]
466
+
467
+ m_ = nn.Sequential(*(m(*args) for _ in range(n))) if n > 1 else m(*args)
468
+
469
+ if m in {CapsRoute, CapsRoutev2, CapsRoutev3, CapsRoutev4}:
470
+ c2 = int(getattr(m_, "c_out", c2))
471
+
472
+ t = str(m)[8:-2].replace("__main__.", "")
473
+ m_.np = sum(x.numel() for x in m_.parameters())
474
+ m_.i, m_.f, m_.type = i, f, t
475
+ if verbose:
476
+ LOGGER.info(f"{i:>3}{f!s:>20}{n_:>3}{m_.np:10.0f} {t:<45}{args!s:<30}")
477
+
478
+ save.extend(x % i for x in ([f] if isinstance(f, int) else f) if x != -1)
479
+ layers.append(m_)
480
+ if i == 0:
481
+ ch = []
482
+ ch.append(c2)
483
+
484
+ # Keep all intermediate outputs to avoid None entries for custom multi-source routing blocks.
485
+ return nn.Sequential(*layers), sorted(set(save + list(range(len(layers)))))
486
+
487
+
488
+ def register_ultralytics_modules() -> None:
489
+ """Register custom modules and replace Ultralytics parse_model with this custom parse_model."""
490
+ import ultralytics.nn.modules as nn_modules
491
+ import ultralytics.nn.tasks as nn_tasks
492
+
493
+ for name, cls in CUSTOM_MODULES.items():
494
+ setattr(nn_tasks, name, cls)
495
+ setattr(nn_modules, name, cls)
496
+
497
+ if getattr(nn_tasks, "_capsule_parse_patched", False):
498
+ return
499
+
500
+ nn_tasks.parse_model = parse_model
501
+ nn_tasks._capsule_parse_patched = True
modules/__init__.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .backbone import (
2
+ CapsuleBackbone,
3
+ ConvBNAct,
4
+ DeformableCaps2d,
5
+ DeformableCapsBlock,
6
+ PrimaryCaps2d,
7
+ RoutingCaps,
8
+ squash,
9
+ )
10
+ from .head import (
11
+ CapsuleDetect,
12
+ CapsuleDetectv1,
13
+ CapsuleDetectv2,
14
+ CapsuleDetectv4,
15
+ CapsuleDetectv5,
16
+ CapsuleDetectv6,
17
+ CapsuleDetectv7,
18
+ CapsuleDetectv8,
19
+ CapsuleOpenVocabDetect,
20
+ CapsuleDualHead,
21
+ CapsuleSegmentv1,
22
+ CapsuleSegmentv2,
23
+ CapsuleSegmentv3,
24
+ )
25
+ from .neck import CapsAlign, CapsDecode, CapsProj, CapsRoute, CapsRoutev2, CapsRoutev3, CapsRoutev4, CapsuleTap
26
+
27
+ __all__ = [
28
+ "CapsuleBackbone",
29
+ "CapsuleTap",
30
+ "CapsRoute",
31
+ "CapsRoutev2",
32
+ "CapsRoutev3",
33
+ "CapsRoutev4",
34
+ "CapsProj",
35
+ "CapsDecode",
36
+ "CapsAlign",
37
+ "CapsuleDetect",
38
+ "CapsuleDetectv1",
39
+ "CapsuleDetectv2",
40
+ "CapsuleDetectv4",
41
+ "CapsuleDetectv5",
42
+ "CapsuleDetectv6",
43
+ "CapsuleDetectv7",
44
+ "CapsuleDetectv8",
45
+ "CapsuleOpenVocabDetect",
46
+ "CapsuleSegmentv1",
47
+ "CapsuleSegmentv2",
48
+ "CapsuleSegmentv3",
49
+ "CapsuleDualHead",
50
+ "ConvBNAct",
51
+ "DeformableCaps2d",
52
+ "DeformableCapsBlock",
53
+ "PrimaryCaps2d",
54
+ "RoutingCaps",
55
+ "squash",
56
+ ]
modules/backbone.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Iterable, Sequence
4
+
5
+ import torch
6
+ from torch import nn
7
+ from torch.nn import functional as F
8
+
9
+
10
+ def squash(x: torch.Tensor, dim: int = -1, eps: float = 1e-7) -> torch.Tensor:
11
+ """Squash nonlinearity used by capsule networks."""
12
+ squared_norm = (x * x).sum(dim=dim, keepdim=True)
13
+ scale = squared_norm / (1.0 + squared_norm)
14
+ return scale * x / torch.sqrt(squared_norm + eps)
15
+
16
+
17
+ class ConvBNAct(nn.Module):
18
+ """Convolution + BatchNorm + SiLU."""
19
+
20
+ def __init__(
21
+ self,
22
+ in_channels: int,
23
+ out_channels: int,
24
+ kernel_size: int = 3,
25
+ stride: int = 1,
26
+ padding: int | None = None,
27
+ ) -> None:
28
+ super().__init__()
29
+ if padding is None:
30
+ padding = kernel_size // 2
31
+ self.conv = nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding, bias=False)
32
+ self.bn = nn.BatchNorm2d(out_channels)
33
+ self.act = nn.SiLU(inplace=True)
34
+
35
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
36
+ return self.act(self.bn(self.conv(x)))
37
+
38
+
39
+ class PrimaryCaps2d(nn.Module):
40
+ """Primary capsule layer for 2D feature maps."""
41
+
42
+ def __init__(
43
+ self,
44
+ in_channels: int,
45
+ num_caps: int,
46
+ dim_caps: int,
47
+ kernel_size: int = 1,
48
+ stride: int = 1,
49
+ padding: int | None = None,
50
+ ) -> None:
51
+ super().__init__()
52
+ if padding is None:
53
+ padding = kernel_size // 2
54
+ out_channels = num_caps * dim_caps
55
+ self.num_caps = num_caps
56
+ self.dim_caps = dim_caps
57
+ self.conv = nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding, bias=False)
58
+ self.bn = nn.BatchNorm2d(out_channels)
59
+ self.act = nn.SiLU(inplace=True)
60
+
61
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
62
+ x = self.act(self.bn(self.conv(x)))
63
+ bsz, _, h, w = x.shape
64
+ x = x.view(bsz, self.num_caps, self.dim_caps, h, w)
65
+ return squash(x, dim=2)
66
+
67
+
68
+ class RoutingCaps(nn.Module):
69
+ """Dynamic routing between capsules."""
70
+
71
+ def __init__(
72
+ self,
73
+ num_in_caps: int,
74
+ dim_in: int,
75
+ num_out_caps: int,
76
+ dim_out: int,
77
+ routing_iters: int = 3,
78
+ ) -> None:
79
+ super().__init__()
80
+ self.num_in_caps = num_in_caps
81
+ self.dim_in = dim_in
82
+ self.num_out_caps = num_out_caps
83
+ self.dim_out = dim_out
84
+ self.routing_iters = routing_iters
85
+
86
+ weight = torch.randn(1, num_in_caps, num_out_caps, dim_out, dim_in) * 0.01
87
+ self.W = nn.Parameter(weight)
88
+
89
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
90
+ if x.ndim != 3:
91
+ raise ValueError(f"RoutingCaps expects [B, N, D], got {tuple(x.shape)}")
92
+ bsz = x.shape[0]
93
+ x = x.unsqueeze(2).unsqueeze(-1) # [B, N, 1, D, 1]
94
+ u_hat = torch.matmul(self.W, x).squeeze(-1) # [B, N, M, Dout]
95
+ b = x.new_zeros(bsz, self.num_in_caps, self.num_out_caps)
96
+ for idx in range(self.routing_iters):
97
+ c = F.softmax(b, dim=-1)
98
+ s = (c.unsqueeze(-1) * u_hat).sum(dim=1)
99
+ v = squash(s, dim=-1)
100
+ if idx < self.routing_iters - 1:
101
+ b = b + (u_hat * v.unsqueeze(1)).sum(dim=-1)
102
+ return v
103
+
104
+
105
+ class DeformableCaps2d(nn.Module):
106
+ """Deformable capsule layer with learned sampling offsets."""
107
+
108
+ def __init__(
109
+ self,
110
+ in_channels: int,
111
+ num_child_caps: int = 8,
112
+ dim_child: int = 8,
113
+ num_parent_caps: int = 8,
114
+ dim_parent: int = 8,
115
+ num_samples: int = 4,
116
+ routing_iters: int = 3,
117
+ offset_scale: float = 1.0,
118
+ out_channels: int | None = None,
119
+ ) -> None:
120
+ super().__init__()
121
+ self.num_child_caps = num_child_caps
122
+ self.dim_child = dim_child
123
+ self.num_parent_caps = num_parent_caps
124
+ self.dim_parent = dim_parent
125
+ self.num_samples = num_samples
126
+ self.routing_iters = routing_iters
127
+ self.offset_scale = offset_scale
128
+
129
+ self.primary = PrimaryCaps2d(in_channels, num_child_caps, dim_child, kernel_size=1, stride=1, padding=0)
130
+ self.offset = nn.Conv2d(in_channels, 2 * num_samples, kernel_size=3, stride=1, padding=1)
131
+ nn.init.zeros_(self.offset.weight)
132
+ nn.init.zeros_(self.offset.bias)
133
+
134
+ self.routing = RoutingCaps(
135
+ num_in_caps=num_samples * num_child_caps,
136
+ dim_in=dim_child,
137
+ num_out_caps=num_parent_caps,
138
+ dim_out=dim_parent,
139
+ routing_iters=routing_iters,
140
+ )
141
+
142
+ caps_channels = num_parent_caps * dim_parent
143
+ self.out_channels = out_channels or caps_channels
144
+ self.project = None
145
+ if self.out_channels != caps_channels:
146
+ self.project = ConvBNAct(caps_channels, self.out_channels, kernel_size=1, stride=1, padding=0)
147
+
148
+ @staticmethod
149
+ def _base_grid(h: int, w: int, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
150
+ ys = torch.linspace(-1.0, 1.0, h, device=device, dtype=dtype)
151
+ xs = torch.linspace(-1.0, 1.0, w, device=device, dtype=dtype)
152
+ yy, xx = torch.meshgrid(ys, xs, indexing="ij")
153
+ return torch.stack((xx, yy), dim=-1)
154
+
155
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
156
+ child = self.primary(x) # [B, Nc, Dc, H, W]
157
+ bsz, _, _, h, w = child.shape
158
+
159
+ child_flat = child.view(bsz, self.num_child_caps * self.dim_child, h, w)
160
+ offsets = self.offset(x).view(bsz, self.num_samples, 2, h, w)
161
+ offsets = torch.tanh(offsets) * self.offset_scale
162
+
163
+ scale_x = max(w - 1, 1) / 2.0
164
+ scale_y = max(h - 1, 1) / 2.0
165
+ scale = offsets.new_tensor([scale_x, scale_y]).view(1, 1, 2, 1, 1)
166
+ offsets = offsets / scale
167
+
168
+ base = self._base_grid(h, w, x.device, x.dtype).view(1, 1, h, w, 2)
169
+ grids = base + offsets.permute(0, 1, 3, 4, 2)
170
+
171
+ sampled = []
172
+ for idx in range(self.num_samples):
173
+ grid = grids[:, idx]
174
+ feat = F.grid_sample(
175
+ child_flat,
176
+ grid,
177
+ mode="bilinear",
178
+ padding_mode="zeros",
179
+ align_corners=True,
180
+ )
181
+ sampled.append(feat)
182
+
183
+ sampled = torch.stack(sampled, dim=1)
184
+ sampled = sampled.view(bsz, self.num_samples, self.num_child_caps, self.dim_child, h, w)
185
+ sampled = sampled.permute(0, 4, 5, 1, 2, 3).contiguous()
186
+ sampled = sampled.view(bsz * h * w, self.num_samples * self.num_child_caps, self.dim_child)
187
+
188
+ routed = self.routing(sampled)
189
+ routed = routed.view(bsz, h, w, self.num_parent_caps, self.dim_parent)
190
+ routed = routed.permute(0, 3, 4, 1, 2).contiguous()
191
+ out = routed.view(bsz, self.num_parent_caps * self.dim_parent, h, w)
192
+
193
+ if self.project is not None:
194
+ out = self.project(out)
195
+ return out
196
+
197
+
198
+ class DeformableCapsBlock(nn.Module):
199
+ """Backbone block: Conv downsample + deformable capsule routing."""
200
+
201
+ def __init__(
202
+ self,
203
+ c1: int,
204
+ c2: int,
205
+ num_child_caps: int = 8,
206
+ dim_child: int = 8,
207
+ num_parent_caps: int = 8,
208
+ dim_parent: int = 8,
209
+ num_samples: int = 4,
210
+ routing_iters: int = 3,
211
+ stride: int = 1,
212
+ ) -> None:
213
+ super().__init__()
214
+ self.down = ConvBNAct(c1, c2, kernel_size=3, stride=stride)
215
+ self.caps = DeformableCaps2d(
216
+ in_channels=c2,
217
+ num_child_caps=num_child_caps,
218
+ dim_child=dim_child,
219
+ num_parent_caps=num_parent_caps,
220
+ dim_parent=dim_parent,
221
+ num_samples=num_samples,
222
+ routing_iters=routing_iters,
223
+ out_channels=c2,
224
+ )
225
+
226
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
227
+ x = self.down(x)
228
+ return self.caps(x)
229
+
230
+
231
+ class CapsuleBackbone(nn.Module):
232
+ """Simple capsule-based backbone that returns multi-scale features."""
233
+
234
+ def __init__(
235
+ self,
236
+ in_channels: int = 3,
237
+ stem_channels: int = 64,
238
+ stages: Sequence[int] = (128, 256, 512),
239
+ capsule_cfgs: Iterable[dict] | None = None,
240
+ ) -> None:
241
+ super().__init__()
242
+ self.stem = ConvBNAct(in_channels, stem_channels, kernel_size=3, stride=2)
243
+ stage_cfgs = list(capsule_cfgs) if capsule_cfgs is not None else [{}] * len(stages)
244
+ if len(stage_cfgs) != len(stages):
245
+ raise ValueError("capsule_cfgs must match stages length")
246
+
247
+ blocks = []
248
+ in_ch = stem_channels
249
+ for out_ch, cfg in zip(stages, stage_cfgs):
250
+ blocks.append(
251
+ nn.Sequential(
252
+ ConvBNAct(in_ch, out_ch, kernel_size=3, stride=2),
253
+ DeformableCaps2d(out_ch, out_channels=out_ch, **cfg),
254
+ )
255
+ )
256
+ in_ch = out_ch
257
+ self.stages = nn.ModuleList(blocks)
258
+
259
+ def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, ...]:
260
+ x = self.stem(x)
261
+ outputs = []
262
+ for stage in self.stages:
263
+ x = stage(x)
264
+ outputs.append(x)
265
+ return tuple(outputs)
266
+
267
+
268
+ __all__ = [
269
+ "CapsuleBackbone",
270
+ "ConvBNAct",
271
+ "DeformableCaps2d",
272
+ "DeformableCapsBlock",
273
+ "PrimaryCaps2d",
274
+ "RoutingCaps",
275
+ "squash",
276
+ ]
modules/head.py ADDED
@@ -0,0 +1,1801 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import math
5
+ import time
6
+
7
+ import torch
8
+ import torch.nn as nn
9
+ import torch.nn.functional as F
10
+
11
+ from ultralytics.nn.modules import Conv, DWConv, Detect, Segment
12
+ from ultralytics.nn.modules.block import Proto26
13
+
14
+
15
+ class PrimaryCaps(nn.Module):
16
+ r"""Primary convolutional capsules.
17
+
18
+ Outputs pose and activation, plus a concatenated NHWC capsule tensor.
19
+
20
+ Args:
21
+ A: Input feature channels.
22
+ B: Number of capsule types.
23
+ K: Convolution kernel size.
24
+ P: Pose matrix side length (pose size is ``P*P``).
25
+ stride: Convolution stride.
26
+
27
+ Input shape:
28
+ x: ``(N, A, H, W)``
29
+
30
+ Output shape:
31
+ a: ``(N, B, H_out, W_out)``
32
+ p: ``(N, B*P*P, H_out, W_out)``
33
+ out: ``(N, H_out, W_out, B*(P*P+1))``
34
+
35
+ Parameter size:
36
+ pose conv + act conv
37
+ ``(K*K*A*B*P*P + B*P*P) + (K*K*A*B + B)``
38
+ """
39
+
40
+ def __init__(self, A: int = 32, B: int = 32, K: int = 1, P: int = 4, stride: int = 1):
41
+ super().__init__()
42
+ self.B = B
43
+ self.P = P
44
+ self.psize = P * P
45
+
46
+ self.pose = nn.Conv2d(in_channels=A, out_channels=B * self.psize, kernel_size=K, stride=stride, bias=True)
47
+ self.a = nn.Conv2d(in_channels=A, out_channels=B, kernel_size=K, stride=stride, bias=True)
48
+ self.sigmoid = nn.Sigmoid()
49
+
50
+ def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
51
+ # p: (B, B*psize, H, W), a: (B, B, H, W)
52
+ p = self.pose(x)
53
+ a = self.sigmoid(self.a(x))
54
+ out = torch.cat([p, a], dim=1).permute(0, 2, 3, 1).contiguous() # (B, H, W, B*(psize+1))
55
+ return a, p, out
56
+
57
+
58
+ class ConvCaps(nn.Module):
59
+ r"""Convolutional capsules with EM routing.
60
+
61
+ Args:
62
+ B: Input capsule types.
63
+ C: Output capsule types.
64
+ K: Patch kernel size.
65
+ P: Pose matrix side length (pose size is ``P*P``).
66
+ stride: Spatial stride for patch extraction.
67
+ iters: Number of EM routing iterations.
68
+ coor_add: Add coordinate offsets (class-caps style option).
69
+ w_shared: Share transform matrices across spatial positions.
70
+
71
+ Input shape:
72
+ x: ``(N, H, W, B*(P*P+1))``
73
+
74
+ Output shape:
75
+ p_out: ``(N, H_out, W_out, C*P*P)``
76
+ a_out: ``(N, H_out, W_out, C)``
77
+ out: ``(N, H_out, W_out, C*(P*P+1))``
78
+
79
+ Parameter size:
80
+ If ``w_shared=False``:
81
+ ``weights: (K*K*B*C*P*P*P*P)``, ``beta_u: C``, ``beta_a: C``
82
+
83
+ If ``w_shared=True``:
84
+ ``weights: (B*C*P*P*P*P)``, ``beta_u: C``, ``beta_a: C``
85
+
86
+ Total = ``weights + 2*C`` (excluding non-trainable buffers).
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ B: int = 32,
92
+ C: int = 32,
93
+ K: int = 3,
94
+ P: int = 4,
95
+ stride: int = 1,
96
+ iters: int = 3,
97
+ coor_add: bool = False,
98
+ w_shared: bool = False,
99
+ ):
100
+ super().__init__()
101
+ self.B = B
102
+ self.C = C
103
+ self.K = K
104
+ self.P = P
105
+ self.psize = P * P
106
+ self.stride = stride
107
+ self.iters = iters
108
+ self.coor_add = coor_add
109
+ self.w_shared = w_shared
110
+
111
+ self.eps = 1e-6
112
+ self._lambda = 1e-3
113
+ self.register_buffer("ln_2pi", torch.tensor(math.log(2 * math.pi), dtype=torch.float32), persistent=False)
114
+
115
+ # Matrix-caps paper uses per-capsule beta scalars.
116
+ self.beta_u = nn.Parameter(torch.zeros(C))
117
+ self.beta_a = nn.Parameter(torch.zeros(C))
118
+
119
+ # For non-shared conv-caps, input vote count is K*K*B. For shared mode it is B then repeated by HW.
120
+ weight_in = B if w_shared else (K * K * B)
121
+ self.weights = nn.Parameter(torch.randn(1, weight_in, C, self.psize, self.psize) * 0.02)
122
+
123
+ self.sigmoid = nn.Sigmoid()
124
+ self.softmax = nn.Softmax(dim=2)
125
+
126
+ def m_step(
127
+ self,
128
+ a_in: torch.Tensor,
129
+ r: torch.Tensor,
130
+ v: torch.Tensor,
131
+ eps: float,
132
+ b: int,
133
+ B: int,
134
+ C: int,
135
+ psize: int,
136
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
137
+ # a_in: (b, B, 1) or (b, B, 1, 1), r: (b, B, C, 1), v: (b, B, C, psize)
138
+ if a_in.ndim == 3:
139
+ a_in = a_in.unsqueeze(2)
140
+ r = r * a_in
141
+ r = r / (r.sum(dim=2, keepdim=True) + eps)
142
+ r_sum = r.sum(dim=1, keepdim=True)
143
+ coeff = r / (r_sum + eps)
144
+
145
+ mu = torch.sum(coeff * v, dim=1, keepdim=True) # (b, 1, C, psize)
146
+ sigma_sq = torch.sum(coeff * (v - mu).pow(2), dim=1, keepdim=True) + eps
147
+ sigma_sq = sigma_sq.clamp_min(1e-4)
148
+
149
+ r_sum_flat = r_sum.view(b, C, 1)
150
+ sigma_sq_flat = sigma_sq.view(b, C, psize).clamp_min(1e-4)
151
+ cost_h = (self.beta_u.view(1, C, 1) + torch.log(torch.sqrt(sigma_sq_flat))) * r_sum_flat
152
+ a_out = self.sigmoid(self._lambda * (self.beta_a.view(1, C) - cost_h.sum(dim=2))).clamp(1e-4, 1.0 - 1e-4)
153
+
154
+ mu = torch.nan_to_num(mu, nan=0.0, posinf=1e4, neginf=-1e4)
155
+ sigma_sq = torch.nan_to_num(sigma_sq, nan=1e-4, posinf=1e4, neginf=1e-4)
156
+ a_out = torch.nan_to_num(a_out, nan=0.5, posinf=1.0 - 1e-4, neginf=1e-4)
157
+ return a_out, mu, sigma_sq
158
+
159
+ def e_step(
160
+ self,
161
+ mu: torch.Tensor,
162
+ sigma_sq: torch.Tensor,
163
+ a_out: torch.Tensor,
164
+ v: torch.Tensor,
165
+ eps: float,
166
+ b: int,
167
+ C: int,
168
+ ) -> torch.Tensor:
169
+ # mu: (b,1,C,psize), sigma_sq: (b,1,C,psize), a_out: (b,C), v: (b,B,C,psize)
170
+ sigma_sq = sigma_sq.clamp_min(1e-4)
171
+ a_out = a_out.clamp(1e-4, 1.0 - 1e-4)
172
+ ln_p_j_h = -1.0 * (v - mu).pow(2) / (2.0 * sigma_sq) - torch.log(torch.sqrt(sigma_sq)) - 0.5 * self.ln_2pi
173
+ ln_ap = ln_p_j_h.sum(dim=3) + torch.log(a_out.view(b, 1, C) + eps)
174
+ ln_ap = torch.nan_to_num(ln_ap, nan=0.0, posinf=50.0, neginf=-50.0)
175
+ r = self.softmax(ln_ap).unsqueeze(-1) # (b,B,C,1)
176
+ r = torch.nan_to_num(r, nan=(1.0 / max(C, 1)), posinf=1.0, neginf=0.0)
177
+ return r
178
+
179
+ def caps_em_routing(self, v: torch.Tensor, a_in: torch.Tensor, C: int, eps: float) -> tuple[torch.Tensor, torch.Tensor]:
180
+ b, B, _, psize = v.shape
181
+ r = v.new_full((b, B, C, 1), 1.0 / C)
182
+
183
+ for t in range(self.iters):
184
+ a_out, mu, sigma_sq = self.m_step(a_in, r, v, eps, b, B, C, psize)
185
+ if t < self.iters - 1:
186
+ r = self.e_step(mu, sigma_sq, a_out, v, eps, b, C)
187
+
188
+ # p_out: (b, C, psize), a_out: (b, C)
189
+ p_out = torch.nan_to_num(mu.squeeze(1), nan=0.0, posinf=1e4, neginf=-1e4)
190
+ a_out = torch.nan_to_num(a_out, nan=0.5, posinf=1.0 - 1e-4, neginf=1e-4)
191
+ return p_out, a_out
192
+
193
+ def add_pathes(self, x: torch.Tensor, B: int, K: int, psize: int, stride: int) -> tuple[torch.Tensor, int, int]:
194
+ # x: (b, h, w, B*(psize+1)) -> patches: (b, oh, ow, K*K, B*(psize+1))
195
+ b, h, w, c = x.shape
196
+ x_chw = x.permute(0, 3, 1, 2).contiguous()
197
+ pad = K // 2
198
+ patches = F.unfold(x_chw, kernel_size=K, padding=pad, stride=stride)
199
+
200
+ oh = (h + 2 * pad - K) // stride + 1
201
+ ow = (w + 2 * pad - K) // stride + 1
202
+ patches = patches.transpose(1, 2).contiguous().view(b, oh, ow, K * K, c)
203
+ return patches, oh, ow
204
+
205
+ def transform_view(self, x: torch.Tensor, w: torch.Tensor, C: int, P: int, w_shared: bool = False) -> torch.Tensor:
206
+ # x: (b, in_votes, psize), w: (1, in_votes_base, C, psize, psize)
207
+ b, in_votes, psize = x.shape
208
+ assert psize == P * P
209
+
210
+ w0 = w[0]
211
+ if w_shared:
212
+ base = w0.size(0)
213
+ reps = in_votes // base
214
+ w0 = w0.repeat(reps, 1, 1, 1)
215
+
216
+ # (b, in_votes, C, psize)
217
+ v = torch.einsum("bip,icpq->bicq", x, w0)
218
+ return v
219
+
220
+ def add_coord(self, v: torch.Tensor, b: int, h: int, w: int, B: int, C: int, psize: int) -> torch.Tensor:
221
+ # v: (b, h*w*B, C, psize)
222
+ # Supports rectangular feature maps (h != w).
223
+ v = v.view(b, h, w, B, C, psize)
224
+
225
+ device = v.device
226
+ dtype = v.dtype
227
+ coor_h_vals = torch.arange(h, dtype=dtype, device=device) / float(max(h, 1))
228
+ coor_w_vals = torch.arange(w, dtype=dtype, device=device) / float(max(w, 1))
229
+
230
+ coor_h = torch.zeros(1, h, 1, 1, 1, psize, dtype=dtype, device=device)
231
+ coor_w = torch.zeros(1, 1, w, 1, 1, psize, dtype=dtype, device=device)
232
+ coor_h[0, :, 0, 0, 0, 0] = coor_h_vals
233
+ coor_w[0, 0, :, 0, 0, 1] = coor_w_vals
234
+
235
+ v = (v + coor_h + coor_w).view(b, h * w * B, C, psize)
236
+ return v
237
+
238
+ def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
239
+ # x shape: (b, h, w, B*(psize+1))
240
+ b, h, w, c = x.shape
241
+
242
+ if not self.w_shared:
243
+ patches, oh, ow = self.add_pathes(x, self.B, self.K, self.psize, self.stride)
244
+
245
+ p_in = patches[..., : self.B * self.psize].contiguous().view(b * oh * ow, self.K * self.K * self.B, self.psize)
246
+ a_in = patches[..., self.B * self.psize :].contiguous().view(b * oh * ow, self.K * self.K * self.B, 1)
247
+
248
+ v = self.transform_view(p_in, self.weights, self.C, self.P, w_shared=False)
249
+ p_out, a_out = self.caps_em_routing(v, a_in, self.C, self.eps)
250
+
251
+ p_out = p_out.view(b, oh, ow, self.C * self.psize)
252
+ a_out = a_out.view(b, oh, ow, self.C)
253
+ out = torch.cat([p_out, a_out], dim=3)
254
+ else:
255
+ assert c == self.B * (self.psize + 1)
256
+ assert self.K == 1
257
+ assert self.stride == 1
258
+
259
+ p_in = x[..., : self.B * self.psize].contiguous().view(b, h * w * self.B, self.psize)
260
+ a_in = x[..., self.B * self.psize :].contiguous().view(b, h * w * self.B, 1)
261
+
262
+ v = self.transform_view(p_in, self.weights, self.C, self.P, w_shared=True)
263
+ if self.coor_add:
264
+ v = self.add_coord(v, b, h, w, self.B, self.C, self.psize)
265
+
266
+ p_cls, a_cls = self.caps_em_routing(v, a_in, self.C, self.eps)
267
+
268
+ # Broadcast class capsules back to spatial map for Detect-style dense outputs.
269
+ p_out = p_cls.reshape(b, 1, 1, self.C * self.psize).expand(b, h, w, self.C * self.psize)
270
+ a_out = a_cls.unsqueeze(1).unsqueeze(1).expand(b, h, w, self.C)
271
+ out = torch.cat([p_out, a_out], dim=3)
272
+
273
+ return p_out, a_out, out
274
+
275
+
276
+
277
+ class DynamicConvCaps(nn.Module):
278
+ r"""Convolutional capsules with Sabour-style dynamic routing.
279
+
280
+ This layer keeps the same tensor interface as ``ConvCaps``:
281
+ input: (N, H, W, B*(P*P+1))
282
+ output: p_out (N, H_out, W_out, C*P*P), a_out (N, H_out, W_out, C), out concat
283
+
284
+ Args:
285
+ B: Input capsule types.
286
+ C: Output capsule types.
287
+ K: Patch kernel size.
288
+ P: Pose matrix side length.
289
+ stride: Patch stride.
290
+ iters: Routing iterations.
291
+ coor_add: Add coordinates in shared mode.
292
+ w_shared: Share transforms across spatial positions (requires K=1, stride=1).
293
+ """
294
+
295
+ def __init__(
296
+ self,
297
+ B: int = 32,
298
+ C: int = 32,
299
+ K: int = 3,
300
+ P: int = 4,
301
+ stride: int = 1,
302
+ iters: int = 3,
303
+ coor_add: bool = False,
304
+ w_shared: bool = False,
305
+ ):
306
+ super().__init__()
307
+ self.B = B
308
+ self.C = C
309
+ self.K = K
310
+ self.P = P
311
+ self.psize = P * P
312
+ self.stride = stride
313
+ self.iters = iters
314
+ self.coor_add = coor_add
315
+ self.w_shared = w_shared
316
+ self.eps = 1e-6
317
+
318
+ weight_in = B if w_shared else (K * K * B)
319
+ self.weights = nn.Parameter(torch.randn(1, weight_in, C, self.psize, self.psize) * 0.02)
320
+
321
+ @staticmethod
322
+ def squash(s: torch.Tensor, dim: int = -1, eps: float = 1e-6) -> torch.Tensor:
323
+ s2 = (s * s).sum(dim=dim, keepdim=True)
324
+ scale = s2 / (1.0 + s2)
325
+ return scale * s / torch.sqrt(s2 + eps)
326
+
327
+ def add_pathes(self, x: torch.Tensor, K: int, stride: int) -> tuple[torch.Tensor, int, int]:
328
+ b, h, w, c = x.shape
329
+ x_chw = x.permute(0, 3, 1, 2).contiguous()
330
+ pad = K // 2
331
+ patches = F.unfold(x_chw, kernel_size=K, padding=pad, stride=stride)
332
+ oh = (h + 2 * pad - K) // stride + 1
333
+ ow = (w + 2 * pad - K) // stride + 1
334
+ patches = patches.transpose(1, 2).contiguous().view(b, oh, ow, K * K, c)
335
+ return patches, oh, ow
336
+
337
+ def transform_view(self, x: torch.Tensor, w_shared: bool) -> torch.Tensor:
338
+ # x: (b, in_votes, psize) -> votes: (b, in_votes, C, psize)
339
+ b, in_votes, psize = x.shape
340
+ if psize != self.psize:
341
+ raise ValueError('Invalid pose size for DynamicConvCaps')
342
+
343
+ w0 = self.weights[0]
344
+ if w_shared:
345
+ base = w0.size(0)
346
+ reps = in_votes // base
347
+ w0 = w0.repeat(reps, 1, 1, 1)
348
+
349
+ return torch.einsum('bip,icpq->bicq', x, w0)
350
+
351
+ def add_coord(self, v: torch.Tensor, b: int, h: int, w: int, B: int, C: int, psize: int) -> torch.Tensor:
352
+ # v: (b, h*w*B, C, psize)
353
+ v = v.view(b, h, w, B, C, psize)
354
+ device, dtype = v.device, v.dtype
355
+ coor_h_vals = torch.arange(h, dtype=dtype, device=device) / float(max(h, 1))
356
+ coor_w_vals = torch.arange(w, dtype=dtype, device=device) / float(max(w, 1))
357
+
358
+ coor_h = torch.zeros(1, h, 1, 1, 1, psize, dtype=dtype, device=device)
359
+ coor_w = torch.zeros(1, 1, w, 1, 1, psize, dtype=dtype, device=device)
360
+ coor_h[0, :, 0, 0, 0, 0] = coor_h_vals
361
+ coor_w[0, 0, :, 0, 0, 1] = coor_w_vals
362
+
363
+ return (v + coor_h + coor_w).view(b, h * w * B, C, psize)
364
+
365
+ def dynamic_routing(self, v: torch.Tensor, a_in: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
366
+ # v: (n, in_votes, C, psize), a_in: (n, in_votes, 1)
367
+ n, in_votes, C, psize = v.shape
368
+ b_ij = v.new_zeros(n, in_votes, C)
369
+
370
+ a_in = a_in.clamp(1e-4, 1.0)
371
+ for t in range(self.iters):
372
+ c_ij = F.softmax(b_ij, dim=2)
373
+ c_ij = c_ij * a_in
374
+ c_ij = c_ij / (c_ij.sum(dim=2, keepdim=True) + self.eps)
375
+
376
+ s_j = (c_ij.unsqueeze(-1) * v).sum(dim=1)
377
+ v_j = self.squash(s_j, dim=-1, eps=self.eps)
378
+ if t < self.iters - 1:
379
+ agreement = (v * v_j.unsqueeze(1)).sum(dim=-1)
380
+ b_ij = b_ij + agreement
381
+
382
+ # activation from vector length in (0,1)
383
+ a_out = torch.sqrt((v_j * v_j).sum(dim=-1) + self.eps).clamp(1e-4, 1.0 - 1e-4)
384
+ return v_j, a_out
385
+
386
+ def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
387
+ b, h, w, c = x.shape
388
+
389
+ if not self.w_shared:
390
+ patches, oh, ow = self.add_pathes(x, self.K, self.stride)
391
+ p_in = patches[..., : self.B * self.psize].contiguous().view(b * oh * ow, self.K * self.K * self.B, self.psize)
392
+ a_in = patches[..., self.B * self.psize :].contiguous().view(b * oh * ow, self.K * self.K * self.B, 1)
393
+
394
+ votes = self.transform_view(p_in, w_shared=False)
395
+ p_vec, a_vec = self.dynamic_routing(votes, a_in)
396
+
397
+ p_out = p_vec.view(b, oh, ow, self.C * self.psize)
398
+ a_out = a_vec.view(b, oh, ow, self.C)
399
+ out = torch.cat([p_out, a_out], dim=3)
400
+ else:
401
+ if c != self.B * (self.psize + 1) or self.K != 1 or self.stride != 1:
402
+ raise ValueError('DynamicConvCaps shared mode requires K=1, stride=1 and matching capsule channels')
403
+
404
+ p_in = x[..., : self.B * self.psize].contiguous().view(b, h * w * self.B, self.psize)
405
+ a_in = x[..., self.B * self.psize :].contiguous().view(b, h * w * self.B, 1)
406
+ votes = self.transform_view(p_in, w_shared=True)
407
+ if self.coor_add:
408
+ votes = self.add_coord(votes, b, h, w, self.B, self.C, self.psize)
409
+
410
+ p_vec, a_vec = self.dynamic_routing(votes, a_in)
411
+ p_out = p_vec.reshape(b, 1, 1, self.C * self.psize).expand(b, h, w, self.C * self.psize)
412
+ a_out = a_vec.unsqueeze(1).unsqueeze(1).expand(b, h, w, self.C)
413
+ out = torch.cat([p_out, a_out], dim=3)
414
+
415
+ p_out = torch.nan_to_num(p_out, nan=0.0, posinf=1e4, neginf=-1e4)
416
+ a_out = torch.nan_to_num(a_out, nan=0.5, posinf=1.0 - 1e-4, neginf=1e-4)
417
+ out = torch.nan_to_num(out, nan=0.0, posinf=1e4, neginf=-1e4)
418
+ return p_out, a_out, out
419
+
420
+
421
+ class SelfRoutingConvCaps(nn.Module):
422
+ r"""Convolutional self-routing capsules.
423
+
424
+ Keeps the same output contract as ``ConvCaps``/``DynamicConvCaps``:
425
+ input: (N, H, W, B*(P*P+1))
426
+ output: p_out (N, H_out, W_out, C*P*P), a_out (N, H_out, W_out, C), out concat
427
+ """
428
+
429
+ def __init__(
430
+ self,
431
+ B: int = 32,
432
+ C: int = 32,
433
+ K: int = 3,
434
+ P: int = 4,
435
+ stride: int = 1,
436
+ iters: int = 1,
437
+ coor_add: bool = False,
438
+ w_shared: bool = False,
439
+ ):
440
+ super().__init__()
441
+ _ = (iters, w_shared) # kept for API compatibility with other capsule layers.
442
+
443
+ self.B = B
444
+ self.C = C
445
+ self.K = K
446
+ self.P = P
447
+ self.psize = P * P
448
+ self.stride = stride
449
+ self.coor_add = coor_add
450
+ self.eps = 1e-6
451
+
452
+ self.kk = K * K
453
+ self.kkB = self.kk * B
454
+
455
+ # Pose transform for each input capsule vote -> output capsule pose.
456
+ self.W1 = nn.Parameter(torch.empty(self.kkB, C, self.psize, self.psize))
457
+ nn.init.kaiming_uniform_(self.W1, a=math.sqrt(5))
458
+
459
+ # Routing logits from local pose vectors.
460
+ self.W2 = nn.Parameter(torch.zeros(self.kkB, C, self.psize))
461
+ self.b2 = nn.Parameter(torch.zeros(1, 1, self.kkB, C))
462
+
463
+ def _output_hw(self, h: int, w: int) -> tuple[int, int]:
464
+ pad = self.K // 2
465
+ oh = (h + 2 * pad - self.K) // self.stride + 1
466
+ ow = (w + 2 * pad - self.K) // self.stride + 1
467
+ return oh, ow
468
+
469
+ def _add_coord(self, pose_unf: torch.Tensor, oh: int, ow: int) -> torch.Tensor:
470
+ # pose_unf: (b, L, kkB, psize)
471
+ if self.psize < 2:
472
+ return pose_unf
473
+
474
+ b, L, kkB, _ = pose_unf.shape
475
+ device, dtype = pose_unf.device, pose_unf.dtype
476
+ gy = torch.arange(oh, device=device, dtype=dtype) / float(max(oh, 1))
477
+ gx = torch.arange(ow, device=device, dtype=dtype) / float(max(ow, 1))
478
+ yy, xx = torch.meshgrid(gy, gx, indexing='ij')
479
+ coords = torch.stack((yy, xx), dim=-1).view(1, L, 1, 2)
480
+
481
+ pose_unf = pose_unf.clone()
482
+ pose_unf[..., :2] = pose_unf[..., :2] + coords
483
+ return pose_unf
484
+
485
+ def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
486
+ # x: (b, h, w, B*(psize+1))
487
+ b, h, w, c = x.shape
488
+ expected = self.B * (self.psize + 1)
489
+ if c != expected:
490
+ raise ValueError(f'SelfRoutingConvCaps expected {expected} channels, got {c}')
491
+
492
+ pose = x[..., : self.B * self.psize]
493
+ act = x[..., self.B * self.psize :]
494
+
495
+ pose_chw = pose.permute(0, 3, 1, 2).contiguous()
496
+ act_chw = act.permute(0, 3, 1, 2).contiguous()
497
+
498
+ pad = self.K // 2
499
+ pose_unf = F.unfold(pose_chw, kernel_size=self.K, stride=self.stride, padding=pad)
500
+ act_unf = F.unfold(act_chw, kernel_size=self.K, stride=self.stride, padding=pad)
501
+
502
+ oh, ow = self._output_hw(h, w)
503
+ l = pose_unf.shape[-1]
504
+
505
+ pose_unf = pose_unf.view(b, self.B, self.psize, self.kk, l).permute(0, 4, 3, 1, 2).contiguous()
506
+ pose_unf = pose_unf.view(b, l, self.kkB, self.psize)
507
+
508
+ act_unf = act_unf.view(b, self.B, self.kk, l).permute(0, 3, 2, 1).contiguous()
509
+ act_unf = act_unf.view(b, l, self.kkB)
510
+
511
+ if self.coor_add:
512
+ pose_unf = self._add_coord(pose_unf, oh, ow)
513
+
514
+ # Routing logits and couplings.
515
+ logit = torch.einsum('blip,icp->blic', pose_unf, self.W2) + self.b2
516
+ r = F.softmax(logit, dim=3)
517
+
518
+ ar = act_unf.unsqueeze(-1) * r
519
+ ar_sum = ar.sum(dim=2, keepdim=True) + self.eps
520
+ coeff = ar / ar_sum
521
+
522
+ a_norm = act_unf.sum(dim=2, keepdim=True) + self.eps
523
+ a_out = (ar_sum.squeeze(2) / a_norm).clamp(1e-4, 1.0 - 1e-4)
524
+
525
+ pose_votes = torch.einsum('blip,icpq->blicq', pose_unf, self.W1)
526
+ pose_out = (coeff.unsqueeze(-1) * pose_votes).sum(dim=2)
527
+
528
+ p_out = pose_out.view(b, oh, ow, self.C * self.psize)
529
+ a_out = a_out.view(b, oh, ow, self.C)
530
+ out = torch.cat([p_out, a_out], dim=3)
531
+
532
+ p_out = torch.nan_to_num(p_out, nan=0.0, posinf=1e4, neginf=-1e4)
533
+ a_out = torch.nan_to_num(a_out, nan=0.5, posinf=1.0 - 1e-4, neginf=1e-4)
534
+ out = torch.nan_to_num(out, nan=0.0, posinf=1e4, neginf=-1e4)
535
+ return p_out, a_out, out
536
+
537
+
538
+
539
+ class CapsuleDualHead(nn.Module):
540
+ """Capsule detection head for one feature level.
541
+
542
+ Args:
543
+ c_in: Input channels of this feature scale (from parser-provided ``ch``).
544
+ nc: Number of classes (final activation capsule count in ``ConvCaps2``).
545
+ reg_max: Detect DFL bins, box channels are ``4 * reg_max``.
546
+ k: Number of capsule types in ``PrimaryCaps``.
547
+ d: Requested pose descriptor size; internally mapped to square ``P*P``.
548
+
549
+ Input shape:
550
+ x: ``(N, c_in, H, W)``
551
+
552
+ Output shape:
553
+ boxes: ``(N, 4*reg_max, H, W)``
554
+ scores: ``(N, nc, H, W)``
555
+ aux: dict with final capsule activations when ``return_aux=True`` else ``None``
556
+
557
+ Parameter size:
558
+ ``PrimaryCaps(c_in,k) + ConvCaps(k,nc,w_shared=True) + box_bias(4*reg_max)``
559
+
560
+ Structure:
561
+ PrimaryCaps -> ConvCaps(class caps only, shared)
562
+ """
563
+
564
+ def __init__(self, c_in: int, nc: int, reg_max: int, k: int, d: int):
565
+ super().__init__()
566
+ # Matrix-caps pose is square; choose smallest square >= requested d.
567
+ p = max(1, int(math.ceil(math.sqrt(d))))
568
+
569
+ self.nc = nc
570
+ self.reg_max = reg_max
571
+ self.P = p
572
+ self.psize = self.P * self.P
573
+
574
+ # A=c_in, B=k, P controls pose channels as B*(P*P).
575
+ self.primary = PrimaryCaps(A=c_in, B=k, K=1, P=self.P, stride=1)
576
+ # Single class-caps layer with shared transforms for parameter reduction.
577
+ self.conv_caps2 = ConvCaps(B=k, C=nc, K=1, P=self.P, stride=1, iters=1, coor_add=True, w_shared=True)
578
+
579
+ # Detect-style localization prior set in CapsuleDetect.bias_init().
580
+ self.box_bias = nn.Parameter(torch.zeros(4 * reg_max))
581
+
582
+ def _pose_to_box(self, p_out: torch.Tensor, a_out: torch.Tensor) -> torch.Tensor:
583
+ # p_out: (b,h,w,nc*psize), a_out is intentionally unused here.
584
+ # Simple rule requested: use first 4*reg_max pose values as box channels.
585
+ _ = a_out
586
+ box_ch = 4 * self.reg_max
587
+
588
+ if p_out.shape[-1] >= box_ch:
589
+ box = p_out[..., :box_ch]
590
+ else:
591
+ # If pose channels are fewer than required box channels, repeat and trim.
592
+ reps = math.ceil(box_ch / p_out.shape[-1])
593
+ box = p_out.repeat(1, 1, 1, reps)[..., :box_ch]
594
+
595
+ return box + self.box_bias.view(1, 1, 1, box_ch)
596
+
597
+ def forward(self, x: torch.Tensor, return_aux: bool = False) -> tuple[torch.Tensor, torch.Tensor, dict | None]:
598
+ _, _, caps0 = self.primary(x)
599
+ p2, a2, _ = self.conv_caps2(caps0)
600
+
601
+ boxes = self._pose_to_box(p2, a2).permute(0, 3, 1, 2).contiguous() # (b,4*reg_max,h,w)
602
+ a2_logits = torch.logit(a2.clamp(1e-4, 1.0 - 1e-4))
603
+ scores = a2_logits.permute(0, 3, 1, 2).contiguous() # (b,nc,h,w) logits
604
+
605
+ aux = None
606
+ if return_aux:
607
+ aux = {
608
+ "caps2_a": a2.permute(0, 3, 1, 2).contiguous(),
609
+ }
610
+ return boxes, scores, aux
611
+
612
+
613
+ class CapsuleClsHead(nn.Module):
614
+ """Capsule classification branch used as a drop-in replacement for Detect.cv3."""
615
+
616
+ def __init__(self, c_in: int, nc: int, k: int = 4, d: int = 16, iters: int = 1):
617
+ super().__init__()
618
+ p = max(1, int(math.ceil(math.sqrt(d))))
619
+ self.primary = PrimaryCaps(A=c_in, B=k, K=1, P=p, stride=1)
620
+ # Internal capsule refinement layer.
621
+ self.mid_caps = SelfRoutingConvCaps(B=k, C=int((k+nc)/2), K=1, P=p, stride=1, iters=iters, coor_add=False, w_shared=True)
622
+ self.class_caps = SelfRoutingConvCaps(B=int((k+nc)/2), C=nc, K=1, P=p, stride=1, iters=iters, coor_add=False, w_shared=True)
623
+
624
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
625
+ # Output Detect-compatible class logits in BCHW.
626
+ _, _, caps = self.primary(x)
627
+ _, _, caps_mid = self.mid_caps(caps)
628
+ _, a_out, _ = self.class_caps(caps_mid)
629
+ logits = torch.logit(a_out.clamp(1e-4, 1.0 - 1e-4)).permute(0, 3, 1, 2).contiguous()
630
+ return torch.nan_to_num(logits, nan=0.0, posinf=20.0, neginf=-20.0).float()
631
+
632
+
633
+ class CapsuleDetect(Detect):
634
+ """Detect head with capsule vote aggregation for both box and cls branches.
635
+
636
+ Input feature of level i is packed as interleaved channels:
637
+ [pose(d_i), act(1)] repeated k_i times -> C_i = k_i * (d_i + 1)
638
+
639
+ In forward_head:
640
+ - split pose/act per capsule type
641
+ - run Detect box/cls heads on each type-specific pose tensor
642
+ - aggregate type predictions with act-driven vote weights
643
+
644
+ Detect decode/postprocess/end2end flow is reused unchanged.
645
+ """
646
+
647
+ def __init__(
648
+ self,
649
+ nc: int = 80,
650
+ *args,
651
+ reg_max: int = 16,
652
+ end2end: bool = False,
653
+ k: list[int] | tuple[int, ...] = (4, 8, 16),
654
+ d: list[int] | tuple[int, ...] = (16, 16, 16),
655
+ ch: tuple = (),
656
+ ):
657
+ parsed = list(args)
658
+ if parsed and isinstance(parsed[-1], (list, tuple)):
659
+ ch = tuple(parsed.pop(-1))
660
+
661
+ # Parser layout: [k_list, d_list, reg_max, end2end, ch]
662
+ if len(parsed) not in (2, 4):
663
+ raise ValueError('CapsuleDetect expects [k_list, d_list, reg_max, end2end, ch].')
664
+
665
+ k, d = parsed[0], parsed[1]
666
+ if len(parsed) == 4:
667
+ reg_max = int(parsed[2])
668
+ end2end = bool(parsed[3])
669
+
670
+ if not isinstance(k, (list, tuple)) or not isinstance(d, (list, tuple)):
671
+ raise TypeError('CapsuleDetect requires list/tuple k and d (per-level settings).')
672
+
673
+ ch = tuple(int(c) for c in ch)
674
+ nl = len(ch)
675
+ if len(k) != nl or len(d) != nl:
676
+ raise ValueError(f'CapsuleDetect k/d length must equal number of levels ({nl}).')
677
+
678
+ self.k_list = tuple(int(v) for v in k)
679
+ self.d_list = tuple(int(v) for v in d)
680
+
681
+ for i, c in enumerate(ch):
682
+ expected = self.k_list[i] * (self.d_list[i] + 1)
683
+ if c != expected:
684
+ raise ValueError(
685
+ f'CapsuleDetect level-{i} channel mismatch: got {c}, expected {expected} from k={self.k_list[i]}, d={self.d_list[i]}.'
686
+ )
687
+
688
+ # Detect heads operate on per-type pose tensors (d_i channels).
689
+ super().__init__(nc=nc, reg_max=reg_max, end2end=end2end, ch=self.d_list)
690
+
691
+ # Vote weights from activation channels (K_i channels), separate for cls/box.
692
+ self.box_vote = nn.ModuleList(
693
+ nn.Sequential(Conv(k_i, k_i, 3), nn.Conv2d(k_i, k_i, 1, bias=True)) for k_i in self.k_list
694
+ )
695
+ self.cls_vote = nn.ModuleList(
696
+ nn.Sequential(Conv(k_i, k_i, 3), nn.Conv2d(k_i, k_i, 1, bias=True)) for k_i in self.k_list
697
+ )
698
+
699
+ def _split_caps(self, x: list[torch.Tensor]) -> tuple[list[torch.Tensor], list[torch.Tensor]]:
700
+ """Split packed feature into pose and activation tensors per level.
701
+
702
+ Returns:
703
+ pose_caps: list of tensors, each (B, K, D, H, W)
704
+ act_map: list of tensors, each (B, K, H, W)
705
+ """
706
+ pose_caps, act_map = [], []
707
+ for i, xi in enumerate(x):
708
+ k_i = self.k_list[i]
709
+ d_i = self.d_list[i]
710
+ c = int(xi.shape[1])
711
+ expected = k_i * (d_i + 1)
712
+ if c != expected:
713
+ raise ValueError(f'CapsuleDetect level-{i} channel mismatch: got {c}, expected {expected}.')
714
+
715
+ b, _, h, w = xi.shape
716
+ caps = xi.view(b, k_i, d_i + 1, h, w)
717
+ pose_caps.append(caps[:, :, :d_i].contiguous())
718
+ act_map.append(caps[:, :, d_i].contiguous())
719
+ return pose_caps, act_map
720
+
721
+ @staticmethod
722
+ def _normalized_votes(raw: torch.Tensor, eps: float = 1e-4) -> torch.Tensor:
723
+ # No softmax/sigmoid: use softplus + sum-normalization.
724
+ w = F.softplus(raw) + eps
725
+ return w / (w.sum(dim=1, keepdim=True) + eps)
726
+
727
+ def _run_voted_head(
728
+ self,
729
+ pose: torch.Tensor,
730
+ act: torch.Tensor,
731
+ head: torch.nn.Module,
732
+ vote_head: torch.nn.Module,
733
+ out_ch: int,
734
+ ) -> torch.Tensor:
735
+ """Apply one Detect head per type and aggregate by vote weights.
736
+
737
+ Args:
738
+ pose: (B, K, D, H, W)
739
+ act: (B, K, H, W)
740
+ head: Detect box or cls head module for this level
741
+ vote_head: vote logits module for this level
742
+ out_ch: output channels of target prediction
743
+
744
+ Returns:
745
+ (B, out_ch, H, W)
746
+ """
747
+ b, k, d, h, w = pose.shape
748
+
749
+ # No voting needed when there is only one capsule type.
750
+ if k == 1:
751
+ return head(pose[:, 0])
752
+
753
+ pose_bt = pose.reshape(b * k, d, h, w)
754
+ pred_bt = head(pose_bt).reshape(b, k, out_ch, h, w)
755
+
756
+ vote_raw = vote_head(act) # (B, K, H, W)
757
+ vote = self._normalized_votes(vote_raw).unsqueeze(2) # (B, K, 1, H, W)
758
+ pred = (pred_bt * vote).sum(dim=1)
759
+ return pred
760
+
761
+ def forward_head(
762
+ self, x: list[torch.Tensor], box_head: torch.nn.Module = None, cls_head: torch.nn.Module = None
763
+ ) -> dict[str, torch.Tensor]:
764
+ if box_head is None or cls_head is None:
765
+ return dict()
766
+
767
+ pose_caps, act_map = self._split_caps(x)
768
+ bs = x[0].shape[0]
769
+
770
+ box_list = []
771
+ cls_list = []
772
+ for i in range(self.nl):
773
+ box_i = self._run_voted_head(
774
+ pose_caps[i],
775
+ act_map[i],
776
+ box_head[i],
777
+ self.box_vote[i],
778
+ out_ch=4 * self.reg_max,
779
+ )
780
+ cls_i = self._run_voted_head(
781
+ pose_caps[i],
782
+ act_map[i],
783
+ cls_head[i],
784
+ self.cls_vote[i],
785
+ out_ch=self.nc,
786
+ )
787
+ box_list.append(box_i.view(bs, 4 * self.reg_max, -1))
788
+ cls_list.append(cls_i.view(bs, self.nc, -1))
789
+
790
+ boxes = torch.cat(box_list, dim=-1)
791
+ scores = torch.cat(cls_list, dim=-1)
792
+ return dict(boxes=boxes, scores=scores, feats=x)
793
+
794
+
795
+ class CapsuleDetectv1(Detect):
796
+ """Capsule Detect variant with activation-gated pose fusion.
797
+
798
+ Per level:
799
+ 1) Split packed capsule channels into pose/activation (interleaved by type).
800
+ 2) Use a 2-layer 1x1 gate net on activation channels.
801
+ 3) Gate pose channels with residual scaling.
802
+ 4) Flatten to K*D channels and run original Detect cv2/cv3 heads.
803
+ """
804
+
805
+ def __init__(
806
+ self,
807
+ nc: int = 80,
808
+ *args,
809
+ reg_max: int = 16,
810
+ end2end: bool = False,
811
+ k: list[int] | tuple[int, ...] = (4, 8, 16),
812
+ d: list[int] | tuple[int, ...] = (16, 16, 16),
813
+ ch: tuple = (),
814
+ ):
815
+ parsed = list(args)
816
+ if parsed and isinstance(parsed[-1], (list, tuple)):
817
+ ch = tuple(parsed.pop(-1))
818
+
819
+ # Parser layout: [k_list, d_list, reg_max, end2end, ch]
820
+ if len(parsed) not in (2, 4):
821
+ raise ValueError("CapsuleDetectv1 expects [k_list, d_list, reg_max, end2end, ch].")
822
+
823
+ k, d = parsed[0], parsed[1]
824
+ if len(parsed) == 4:
825
+ reg_max = int(parsed[2])
826
+ end2end = bool(parsed[3])
827
+
828
+ if not isinstance(k, (list, tuple)) or not isinstance(d, (list, tuple)):
829
+ raise TypeError("CapsuleDetectv1 requires list/tuple k and d (per-level settings).")
830
+
831
+ ch = tuple(int(c) for c in ch)
832
+ nl = len(ch)
833
+ if len(k) != nl or len(d) != nl:
834
+ raise ValueError(f"CapsuleDetectv1 k/d length must equal number of levels ({nl}).")
835
+
836
+ self.k_list = tuple(int(v) for v in k)
837
+ self.d_list = tuple(int(v) for v in d)
838
+
839
+ # Input from neck is packed as K*(D+1): [pose(D), act(1)] repeated K types.
840
+ for i, c in enumerate(ch):
841
+ expected = self.k_list[i] * (self.d_list[i] + 1)
842
+ if c != expected:
843
+ raise ValueError(
844
+ f"CapsuleDetectv1 level-{i} channel mismatch: got {c}, "
845
+ f"expected {expected} from k={self.k_list[i]}, d={self.d_list[i]}."
846
+ )
847
+
848
+ # Detect heads consume merged pose channels: K*D.
849
+ merged_ch = tuple(k_i * d_i for k_i, d_i in zip(self.k_list, self.d_list))
850
+ super().__init__(nc=nc, reg_max=reg_max, end2end=end2end, ch=merged_ch)
851
+
852
+ self.pose_gates = nn.ModuleList()
853
+ self.gate_alpha = nn.ParameterList()
854
+ for k_i, d_i in zip(self.k_list, self.d_list):
855
+ out_ch = k_i * d_i
856
+ hidden = max(8, k_i * 2)
857
+ self.pose_gates.append(
858
+ nn.Sequential(
859
+ nn.Conv2d(k_i, hidden, 1, bias=True),
860
+ nn.SiLU(inplace=True),
861
+ nn.Conv2d(hidden, out_ch, 1, bias=True),
862
+ )
863
+ )
864
+ self.gate_alpha.append(nn.Parameter(torch.tensor(0.5)))
865
+
866
+ def _split_pose_act(self, x: torch.Tensor, i: int) -> tuple[torch.Tensor, torch.Tensor]:
867
+ """Split one level packed tensor into pose and activation maps."""
868
+ k_i = self.k_list[i]
869
+ d_i = self.d_list[i]
870
+ b, c, h, w = x.shape
871
+ expected = k_i * (d_i + 1)
872
+ if c != expected:
873
+ raise ValueError(f"CapsuleDetectv1 level-{i} channel mismatch: got {c}, expected {expected}.")
874
+
875
+ caps = x.view(b, k_i, d_i + 1, h, w)
876
+ pose = caps[:, :, :d_i].reshape(b, k_i * d_i, h, w).contiguous()
877
+ act = caps[:, :, d_i].contiguous()
878
+ return pose, act
879
+
880
+ def _merge_pose(self, x: list[torch.Tensor]) -> list[torch.Tensor]:
881
+ merged = []
882
+ for i, xi in enumerate(x):
883
+ pose, act = self._split_pose_act(xi, i)
884
+ gate = torch.sigmoid(self.pose_gates[i](act))
885
+ # Residual gating keeps base pose information and improves stability.
886
+ pose = pose * (1.0 + self.gate_alpha[i] * gate)
887
+ merged.append(pose)
888
+ return merged
889
+
890
+ def forward_head(
891
+ self, x: list[torch.Tensor], box_head: torch.nn.Module = None, cls_head: torch.nn.Module = None
892
+ ) -> dict[str, torch.Tensor]:
893
+ if box_head is None or cls_head is None:
894
+ return dict()
895
+
896
+ pose_feats = self._merge_pose(x)
897
+ bs = pose_feats[0].shape[0]
898
+
899
+ box_list = [box_head[i](pose_feats[i]).view(bs, 4 * self.reg_max, -1) for i in range(self.nl)]
900
+ cls_list = [cls_head[i](pose_feats[i]).view(bs, self.nc, -1) for i in range(self.nl)]
901
+ boxes = torch.cat(box_list, dim=-1)
902
+ scores = torch.cat(cls_list, dim=-1)
903
+ return dict(boxes=boxes, scores=scores, feats=x)
904
+
905
+
906
+ class CapsuleDetectv2(Detect):
907
+ """Capsule Detect v2: activation-gated pose + activation bypass for classification."""
908
+
909
+ def __init__(
910
+ self,
911
+ nc: int = 80,
912
+ *args,
913
+ reg_max: int = 16,
914
+ end2end: bool = False,
915
+ k: list[int] | tuple[int, ...] = (4, 8, 16),
916
+ d: list[int] | tuple[int, ...] = (16, 16, 16),
917
+ ch: tuple = (),
918
+ ):
919
+ parsed = list(args)
920
+ if parsed and isinstance(parsed[-1], (list, tuple)):
921
+ ch = tuple(parsed.pop(-1))
922
+
923
+ # Parser layout: [k_list, d_list, reg_max, end2end, ch]
924
+ if len(parsed) not in (2, 4):
925
+ raise ValueError("CapsuleDetectv2 expects [k_list, d_list, reg_max, end2end, ch].")
926
+
927
+ k, d = parsed[0], parsed[1]
928
+ if len(parsed) == 4:
929
+ reg_max = int(parsed[2])
930
+ end2end = bool(parsed[3])
931
+
932
+ if not isinstance(k, (list, tuple)) or not isinstance(d, (list, tuple)):
933
+ raise TypeError("CapsuleDetectv2 requires list/tuple k and d (per-level settings).")
934
+
935
+ ch = tuple(int(c) for c in ch)
936
+ nl = len(ch)
937
+ if len(k) != nl or len(d) != nl:
938
+ raise ValueError(f"CapsuleDetectv2 k/d length must equal number of levels ({nl}).")
939
+
940
+ self.k_list = tuple(int(v) for v in k)
941
+ self.d_list = tuple(int(v) for v in d)
942
+
943
+ # Input from neck is packed as K*(D+1): [pose(D), act(1)] repeated K types.
944
+ for i, c in enumerate(ch):
945
+ expected = self.k_list[i] * (self.d_list[i] + 1)
946
+ if c != expected:
947
+ raise ValueError(
948
+ f"CapsuleDetectv2 level-{i} channel mismatch: got {c}, "
949
+ f"expected {expected} from k={self.k_list[i]}, d={self.d_list[i]}."
950
+ )
951
+
952
+ # Detect heads consume merged pose channels: K*D.
953
+ merged_ch = tuple(k_i * d_i for k_i, d_i in zip(self.k_list, self.d_list))
954
+ super().__init__(nc=nc, reg_max=reg_max, end2end=end2end, ch=merged_ch)
955
+
956
+ self.pose_gates = nn.ModuleList()
957
+ self.gate_alpha = nn.ParameterList()
958
+ self.cls_bypass = nn.ModuleList()
959
+ self.cls_beta = nn.ParameterList()
960
+
961
+ for k_i, d_i in zip(self.k_list, self.d_list):
962
+ pose_ch = k_i * d_i
963
+ gate_hidden = max(8, k_i * 2)
964
+ self.pose_gates.append(
965
+ nn.Sequential(
966
+ nn.Conv2d(k_i, gate_hidden, 1, bias=True),
967
+ nn.SiLU(inplace=True),
968
+ nn.Conv2d(gate_hidden, pose_ch, 1, bias=True),
969
+ )
970
+ )
971
+ self.gate_alpha.append(nn.Parameter(torch.tensor(0.5)))
972
+
973
+ cls_hidden = max(16, k_i * 2)
974
+ self.cls_bypass.append(
975
+ nn.Sequential(
976
+ nn.Conv2d(k_i, cls_hidden, 1, bias=True),
977
+ nn.SiLU(inplace=True),
978
+ nn.Conv2d(cls_hidden, pose_ch, 1, bias=True),
979
+ )
980
+ )
981
+ self.cls_beta.append(nn.Parameter(torch.tensor(0.1)))
982
+
983
+ def _split_pose_act(self, x: torch.Tensor, i: int) -> tuple[torch.Tensor, torch.Tensor]:
984
+ """Split one level packed tensor into pose and activation maps."""
985
+ k_i = self.k_list[i]
986
+ d_i = self.d_list[i]
987
+ b, c, h, w = x.shape
988
+ expected = k_i * (d_i + 1)
989
+ if c != expected:
990
+ raise ValueError(f"CapsuleDetectv2 level-{i} channel mismatch: got {c}, expected {expected}.")
991
+
992
+ caps = x.view(b, k_i, d_i + 1, h, w)
993
+ pose = caps[:, :, :d_i].reshape(b, k_i * d_i, h, w).contiguous()
994
+ act = caps[:, :, d_i].contiguous()
995
+ return pose, act
996
+
997
+ def _fuse_pose(self, x: list[torch.Tensor]) -> tuple[list[torch.Tensor], list[torch.Tensor]]:
998
+ box_feats, cls_feats = [], []
999
+ for i, xi in enumerate(x):
1000
+ pose, act = self._split_pose_act(xi, i)
1001
+ gate = torch.sigmoid(self.pose_gates[i](act))
1002
+ pose_g = pose * (1.0 + self.gate_alpha[i] * gate)
1003
+
1004
+ # Classification bypass from activation channels.
1005
+ act_skip = self.cls_bypass[i](act)
1006
+ cls_in = pose_g + self.cls_beta[i] * act_skip
1007
+
1008
+ box_feats.append(pose_g)
1009
+ cls_feats.append(cls_in)
1010
+ return box_feats, cls_feats
1011
+
1012
+ def forward_head(
1013
+ self, x: list[torch.Tensor], box_head: torch.nn.Module = None, cls_head: torch.nn.Module = None
1014
+ ) -> dict[str, torch.Tensor]:
1015
+ if box_head is None or cls_head is None:
1016
+ return dict()
1017
+
1018
+ box_feats, cls_feats = self._fuse_pose(x)
1019
+ bs = x[0].shape[0]
1020
+
1021
+ box_list = [box_head[i](box_feats[i]).view(bs, 4 * self.reg_max, -1) for i in range(self.nl)]
1022
+ cls_list = [cls_head[i](cls_feats[i]).view(bs, self.nc, -1) for i in range(self.nl)]
1023
+ boxes = torch.cat(box_list, dim=-1)
1024
+ scores = torch.cat(cls_list, dim=-1)
1025
+ return dict(boxes=boxes, scores=scores, feats=x)
1026
+
1027
+
1028
+ class CapsuleDetectv4(Detect):
1029
+ """Capsule Detect v4: box uses raw pose, cls uses act bypass + symbolic type prior."""
1030
+
1031
+ def __init__(
1032
+ self,
1033
+ nc: int = 80,
1034
+ *args,
1035
+ reg_max: int = 16,
1036
+ end2end: bool = False,
1037
+ k: list[int] | tuple[int, ...] = (4, 8, 16),
1038
+ d: list[int] | tuple[int, ...] = (16, 16, 16),
1039
+ ch: tuple = (),
1040
+ ):
1041
+ parsed = list(args)
1042
+ if parsed and isinstance(parsed[-1], (list, tuple)):
1043
+ ch = tuple(parsed.pop(-1))
1044
+
1045
+ if len(parsed) not in (2, 4):
1046
+ raise ValueError("CapsuleDetectv4 expects [k_list, d_list, reg_max, end2end, ch].")
1047
+
1048
+ k, d = parsed[0], parsed[1]
1049
+ if len(parsed) == 4:
1050
+ reg_max = int(parsed[2])
1051
+ end2end = bool(parsed[3])
1052
+
1053
+ if not isinstance(k, (list, tuple)) or not isinstance(d, (list, tuple)):
1054
+ raise TypeError("CapsuleDetectv4 requires list/tuple k and d (per-level settings).")
1055
+
1056
+ ch = tuple(int(c) for c in ch)
1057
+ nl = len(ch)
1058
+ if len(k) != nl or len(d) != nl:
1059
+ raise ValueError(f"CapsuleDetectv4 k/d length must equal number of levels ({nl}).")
1060
+
1061
+ self.k_list = tuple(int(v) for v in k)
1062
+ self.d_list = tuple(int(v) for v in d)
1063
+
1064
+ for i, c in enumerate(ch):
1065
+ expected = self.k_list[i] * (self.d_list[i] + 1)
1066
+ if c != expected:
1067
+ raise ValueError(
1068
+ f"CapsuleDetectv4 level-{i} channel mismatch: got {c}, "
1069
+ f"expected {expected} from k={self.k_list[i]}, d={self.d_list[i]}."
1070
+ )
1071
+
1072
+ merged_ch = tuple(k_i * d_i for k_i, d_i in zip(self.k_list, self.d_list))
1073
+ super().__init__(nc=nc, reg_max=reg_max, end2end=end2end, ch=merged_ch)
1074
+
1075
+ self.cls_bypass = nn.ModuleList()
1076
+ self.cls_beta = nn.ParameterList()
1077
+ self.sym_prior = nn.ModuleList()
1078
+ self.sym_beta = nn.ParameterList()
1079
+
1080
+ for k_i, d_i in zip(self.k_list, self.d_list):
1081
+ pose_ch = k_i * d_i
1082
+ cls_hidden = max(16, k_i * 2)
1083
+ self.cls_bypass.append(
1084
+ nn.Sequential(
1085
+ nn.Conv2d(k_i, cls_hidden, 1, bias=True),
1086
+ nn.SiLU(inplace=True),
1087
+ nn.Conv2d(cls_hidden, pose_ch, 1, bias=True),
1088
+ )
1089
+ )
1090
+ self.cls_beta.append(nn.Parameter(torch.tensor(0.1)))
1091
+ self.sym_prior.append(nn.Conv2d(k_i, self.nc, 1, bias=False))
1092
+ self.sym_beta.append(nn.Parameter(torch.tensor(0.1)))
1093
+
1094
+ def _split_pose_act(self, x: torch.Tensor, i: int) -> tuple[torch.Tensor, torch.Tensor]:
1095
+ k_i = self.k_list[i]
1096
+ d_i = self.d_list[i]
1097
+ b, c, h, w = x.shape
1098
+ expected = k_i * (d_i + 1)
1099
+ if c != expected:
1100
+ raise ValueError(f"CapsuleDetectv4 level-{i} channel mismatch: got {c}, expected {expected}.")
1101
+
1102
+ caps = x.view(b, k_i, d_i + 1, h, w)
1103
+ pose = caps[:, :, :d_i].reshape(b, k_i * d_i, h, w).contiguous()
1104
+ act = caps[:, :, d_i].contiguous()
1105
+ return pose, act
1106
+
1107
+ def _build_feats(self, x: list[torch.Tensor]) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor]]:
1108
+ box_feats, cls_feats, cls_priors = [], [], []
1109
+ for i, xi in enumerate(x):
1110
+ pose, act = self._split_pose_act(xi, i)
1111
+ cls_in = pose + self.cls_beta[i] * self.cls_bypass[i](act)
1112
+ cls_prior = self.sym_beta[i] * self.sym_prior[i](act)
1113
+ box_feats.append(pose)
1114
+ cls_feats.append(cls_in)
1115
+ cls_priors.append(cls_prior)
1116
+ return box_feats, cls_feats, cls_priors
1117
+
1118
+ def forward_head(
1119
+ self, x: list[torch.Tensor], box_head: torch.nn.Module = None, cls_head: torch.nn.Module = None
1120
+ ) -> dict[str, torch.Tensor]:
1121
+ if box_head is None or cls_head is None:
1122
+ return dict()
1123
+
1124
+ box_feats, cls_feats, cls_priors = self._build_feats(x)
1125
+ bs = x[0].shape[0]
1126
+
1127
+ box_list = [box_head[i](box_feats[i]).view(bs, 4 * self.reg_max, -1) for i in range(self.nl)]
1128
+ cls_list = [
1129
+ (cls_head[i](cls_feats[i]) + cls_priors[i]).view(bs, self.nc, -1)
1130
+ for i in range(self.nl)
1131
+ ]
1132
+ boxes = torch.cat(box_list, dim=-1)
1133
+ scores = torch.cat(cls_list, dim=-1)
1134
+ return dict(boxes=boxes, scores=scores, feats=x)
1135
+
1136
+
1137
+ def _setup_capsule_layout(
1138
+ k: list[int] | tuple[int, ...],
1139
+ d: list[int] | tuple[int, ...],
1140
+ ch: tuple,
1141
+ cls_name: str,
1142
+ ) -> tuple[tuple[int, ...], tuple[int, ...], tuple[int, ...]]:
1143
+ if not isinstance(k, (list, tuple)) or not isinstance(d, (list, tuple)):
1144
+ raise TypeError(f"{cls_name} requires list/tuple k and d (per-level settings).")
1145
+
1146
+ ch = tuple(int(c) for c in ch)
1147
+ nl = len(ch)
1148
+ if len(k) != nl or len(d) != nl:
1149
+ raise ValueError(f"{cls_name} k/d length must equal number of levels ({nl}).")
1150
+
1151
+ k_list = tuple(int(v) for v in k)
1152
+ d_list = tuple(int(v) for v in d)
1153
+ for i, c in enumerate(ch):
1154
+ expected = k_list[i] * (d_list[i] + 1)
1155
+ if c != expected:
1156
+ raise ValueError(
1157
+ f"{cls_name} level-{i} channel mismatch: got {c}, "
1158
+ f"expected {expected} from k={k_list[i]}, d={d_list[i]}."
1159
+ )
1160
+ merged_ch = tuple(k_i * d_i for k_i, d_i in zip(k_list, d_list))
1161
+ return k_list, d_list, merged_ch
1162
+
1163
+
1164
+ def _init_capsule_semantic_heads(obj: nn.Module) -> None:
1165
+ obj.cls_bypass = nn.ModuleList()
1166
+ obj.cls_beta = nn.ParameterList()
1167
+ obj.sym_prior = nn.ModuleList()
1168
+ obj.sym_norm = nn.ModuleList()
1169
+ obj.sym_dropout = nn.ModuleList()
1170
+ obj.sym_beta = nn.ParameterList()
1171
+
1172
+ for k_i, d_i in zip(obj.k_list, obj.d_list):
1173
+ pose_ch = k_i * d_i
1174
+ cls_hidden = max(16, k_i * 2)
1175
+ obj.cls_bypass.append(
1176
+ nn.Sequential(
1177
+ nn.Conv2d(k_i, cls_hidden, 1, bias=True),
1178
+ nn.SiLU(inplace=True),
1179
+ nn.Conv2d(cls_hidden, pose_ch, 1, bias=True),
1180
+ )
1181
+ )
1182
+ obj.cls_beta.append(nn.Parameter(torch.tensor(0.1)))
1183
+ obj.sym_dropout.append(nn.Dropout2d(p=0.1))
1184
+ obj.sym_prior.append(nn.Conv2d(k_i, obj.nc, 1, bias=False))
1185
+ obj.sym_norm.append(nn.GroupNorm(1, obj.nc))
1186
+ obj.sym_beta.append(nn.Parameter(torch.tensor(0.1)))
1187
+
1188
+
1189
+ def _capsule_split_pose_act(
1190
+ x: torch.Tensor,
1191
+ k_i: int,
1192
+ d_i: int,
1193
+ cls_name: str,
1194
+ level_i: int,
1195
+ ) -> tuple[torch.Tensor, torch.Tensor]:
1196
+ b, c, h, w = x.shape
1197
+ expected = k_i * (d_i + 1)
1198
+ if c != expected:
1199
+ raise ValueError(f"{cls_name} level-{level_i} channel mismatch: got {c}, expected {expected}.")
1200
+ caps = x.view(b, k_i, d_i + 1, h, w)
1201
+ pose = caps[:, :, :d_i].reshape(b, k_i * d_i, h, w).contiguous()
1202
+ act = caps[:, :, d_i].contiguous()
1203
+ return pose, act
1204
+
1205
+
1206
+ def _capsule_build_feats(obj: nn.Module, x: list[torch.Tensor]) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor]]:
1207
+ box_feats, cls_feats, cls_priors = [], [], []
1208
+ cls_name = obj.__class__.__name__
1209
+ for i, xi in enumerate(x):
1210
+ pose, act = _capsule_split_pose_act(xi, obj.k_list[i], obj.d_list[i], cls_name, i)
1211
+ cls_scale = torch.tanh(obj.cls_beta[i])
1212
+ cls_in = pose + cls_scale * obj.cls_bypass[i](act)
1213
+ act_s = obj.sym_dropout[i](act)
1214
+ prior = obj.sym_prior[i](act_s)
1215
+ prior = obj.sym_norm[i](prior)
1216
+ prior = prior - prior.mean(dim=1, keepdim=True)
1217
+ sym_scale = torch.tanh(obj.sym_beta[i])
1218
+ cls_prior = sym_scale * prior
1219
+ box_feats.append(pose)
1220
+ cls_feats.append(cls_in)
1221
+ cls_priors.append(cls_prior)
1222
+ return box_feats, cls_feats, cls_priors
1223
+
1224
+
1225
+ def _capsule_build_feats_gated(
1226
+ obj: nn.Module, x: list[torch.Tensor]
1227
+ ) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor]]:
1228
+ box_feats, cls_feats, cls_priors = [], [], []
1229
+ cls_name = obj.__class__.__name__
1230
+ for i, xi in enumerate(x):
1231
+ pose, act = _capsule_split_pose_act(xi, obj.k_list[i], obj.d_list[i], cls_name, i)
1232
+ cls_scale = torch.tanh(obj.cls_beta[i])
1233
+ gate = torch.sigmoid(obj.cls_bypass[i](act))
1234
+ cls_in = pose * (1.0 + cls_scale * gate)
1235
+
1236
+ act_s = obj.sym_dropout[i](act)
1237
+ prior = obj.sym_prior[i](act_s)
1238
+ prior = obj.sym_norm[i](prior)
1239
+ prior = prior - prior.mean(dim=1, keepdim=True)
1240
+ sym_scale = torch.tanh(obj.sym_beta[i])
1241
+ cls_prior = sym_scale * prior
1242
+
1243
+ box_feats.append(pose)
1244
+ cls_feats.append(cls_in)
1245
+ cls_priors.append(cls_prior)
1246
+ return box_feats, cls_feats, cls_priors
1247
+
1248
+
1249
+ def _capsule_build_feats_boxcls(
1250
+ obj: nn.Module, x: list[torch.Tensor]
1251
+ ) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor]]:
1252
+ box_feats, cls_feats, cls_priors = [], [], []
1253
+ cls_name = obj.__class__.__name__
1254
+ for i, xi in enumerate(x):
1255
+ pose, act = _capsule_split_pose_act(xi, obj.k_list[i], obj.d_list[i], cls_name, i)
1256
+ act_s = obj.sym_dropout[i](act)
1257
+ prior = obj.sym_prior[i](act_s)
1258
+ prior = obj.sym_norm[i](prior)
1259
+ prior = prior - prior.mean(dim=1, keepdim=True)
1260
+ sym_scale = torch.tanh(obj.sym_beta[i])
1261
+ cls_prior = sym_scale * prior
1262
+
1263
+ box_feats.append(pose)
1264
+ cls_feats.append(pose)
1265
+ cls_priors.append(cls_prior)
1266
+ return box_feats, cls_feats, cls_priors
1267
+
1268
+
1269
+ def _capsule_build_feats_boxcls_simpleprior(
1270
+ obj: nn.Module, x: list[torch.Tensor]
1271
+ ) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor]]:
1272
+ box_feats, cls_feats, cls_priors = [], [], []
1273
+ cls_name = obj.__class__.__name__
1274
+ for i, xi in enumerate(x):
1275
+ pose, act = _capsule_split_pose_act(xi, obj.k_list[i], obj.d_list[i], cls_name, i)
1276
+ act_s = obj.sym_dropout[i](act)
1277
+ prior = obj.sym_prior[i](act_s)
1278
+ sym_scale = torch.tanh(obj.sym_beta[i])
1279
+ cls_prior = sym_scale * prior
1280
+
1281
+ box_feats.append(pose)
1282
+ cls_feats.append(pose)
1283
+ cls_priors.append(cls_prior)
1284
+ return box_feats, cls_feats, cls_priors
1285
+
1286
+
1287
+ def _capsule_build_feats_open_vocab(
1288
+ obj: nn.Module, x: list[torch.Tensor]
1289
+ ) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor]]:
1290
+ box_feats, cls_feats, acts = [], [], []
1291
+ cls_name = obj.__class__.__name__
1292
+ for i, xi in enumerate(x):
1293
+ pose, act = _capsule_split_pose_act(xi, obj.k_list[i], obj.d_list[i], cls_name, i)
1294
+ cls_in = pose
1295
+ if getattr(obj, "with_act_gate", False):
1296
+ cls_scale = torch.tanh(obj.ov_beta[i])
1297
+ gate = torch.sigmoid(obj.ov_gate[i](act))
1298
+ cls_in = pose * (1.0 + cls_scale * gate)
1299
+ box_feats.append(pose)
1300
+ cls_feats.append(cls_in)
1301
+ acts.append(act)
1302
+ return box_feats, cls_feats, acts
1303
+
1304
+
1305
+ class CapsuleDetectv5(Detect):
1306
+ """Capsule Detect v5: box uses raw pose, cls uses stabilized symbolic prior."""
1307
+
1308
+ def __init__(
1309
+ self,
1310
+ nc: int = 80,
1311
+ *args,
1312
+ reg_max: int = 16,
1313
+ end2end: bool = False,
1314
+ k: list[int] | tuple[int, ...] = (4, 8, 16),
1315
+ d: list[int] | tuple[int, ...] = (16, 16, 16),
1316
+ ch: tuple = (),
1317
+ ):
1318
+ parsed = list(args)
1319
+ if parsed and isinstance(parsed[-1], (list, tuple)):
1320
+ ch = tuple(parsed.pop(-1))
1321
+
1322
+ if len(parsed) not in (2, 4):
1323
+ raise ValueError("CapsuleDetectv5 expects [k_list, d_list, reg_max, end2end, ch].")
1324
+
1325
+ k, d = parsed[0], parsed[1]
1326
+ if len(parsed) == 4:
1327
+ reg_max = int(parsed[2])
1328
+ end2end = bool(parsed[3])
1329
+
1330
+ self.k_list, self.d_list, merged_ch = _setup_capsule_layout(k, d, ch, "CapsuleDetectv5")
1331
+ super().__init__(nc=nc, reg_max=reg_max, end2end=end2end, ch=merged_ch)
1332
+ _init_capsule_semantic_heads(self)
1333
+
1334
+ def _split_pose_act(self, x: torch.Tensor, i: int) -> tuple[torch.Tensor, torch.Tensor]:
1335
+ return _capsule_split_pose_act(x, self.k_list[i], self.d_list[i], "CapsuleDetectv5", i)
1336
+
1337
+ def _build_feats(self, x: list[torch.Tensor]) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor]]:
1338
+ return _capsule_build_feats(self, x)
1339
+
1340
+ def forward_head(
1341
+ self, x: list[torch.Tensor], box_head: torch.nn.Module = None, cls_head: torch.nn.Module = None
1342
+ ) -> dict[str, torch.Tensor]:
1343
+ if box_head is None or cls_head is None:
1344
+ return dict()
1345
+
1346
+ box_feats, cls_feats, cls_priors = self._build_feats(x)
1347
+ bs = x[0].shape[0]
1348
+
1349
+ box_list = [box_head[i](box_feats[i]).view(bs, 4 * self.reg_max, -1) for i in range(self.nl)]
1350
+ cls_list = [
1351
+ (cls_head[i](cls_feats[i]) + cls_priors[i]).view(bs, self.nc, -1)
1352
+ for i in range(self.nl)
1353
+ ]
1354
+ boxes = torch.cat(box_list, dim=-1)
1355
+ scores = torch.cat(cls_list, dim=-1)
1356
+ return dict(boxes=boxes, scores=scores, feats=x)
1357
+
1358
+
1359
+ class CapsuleDetectv6(CapsuleDetectv5):
1360
+ """Capsule Detect v6: replace additive cls correction with multiplicative act gate."""
1361
+
1362
+ def _build_feats(self, x: list[torch.Tensor]) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor]]:
1363
+ return _capsule_build_feats_gated(self, x)
1364
+
1365
+
1366
+ class CapsuleDetectv7(CapsuleDetectv5):
1367
+ """Capsule Detect v7: cls head consumes raw pose features plus symbolic priors only."""
1368
+
1369
+ def __init__(self, *args, **kwargs):
1370
+ super().__init__(*args, **kwargs)
1371
+ self.profile_head = False
1372
+ self._head_profile: dict[str, float] = {}
1373
+ self._head_profile_calls = 0
1374
+
1375
+ def _ensure_profile_attrs(self) -> None:
1376
+ if not hasattr(self, "profile_head"):
1377
+ self.profile_head = False
1378
+ if not hasattr(self, "_head_profile"):
1379
+ self._head_profile = {}
1380
+ if not hasattr(self, "_head_profile_calls"):
1381
+ self._head_profile_calls = 0
1382
+
1383
+ def reset_head_profile(self) -> None:
1384
+ self._ensure_profile_attrs()
1385
+ self._head_profile = {
1386
+ "split_pose_act_ms": 0.0,
1387
+ "cls_prior_ms": 0.0,
1388
+ "box_head_ms": 0.0,
1389
+ "cls_head_ms": 0.0,
1390
+ "cat_ms": 0.0,
1391
+ }
1392
+ self._head_profile_calls = 0
1393
+
1394
+ def get_head_profile(self) -> dict[str, float]:
1395
+ self._ensure_profile_attrs()
1396
+ if not self._head_profile:
1397
+ return {}
1398
+ out = dict(self._head_profile)
1399
+ calls = max(self._head_profile_calls, 1)
1400
+ out["calls"] = float(self._head_profile_calls)
1401
+ out["total_ms"] = sum(v for k, v in out.items() if k.endswith("_ms"))
1402
+ for key in list(self._head_profile):
1403
+ out[key.replace("_ms", "_avg_ms")] = self._head_profile[key] / calls
1404
+ return out
1405
+
1406
+ def _sync_profile(self) -> None:
1407
+ self._ensure_profile_attrs()
1408
+ if self.profile_head and torch.cuda.is_available():
1409
+ torch.cuda.synchronize()
1410
+
1411
+ def _build_feats(self, x: list[torch.Tensor]) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor]]:
1412
+ self._ensure_profile_attrs()
1413
+ if not self.profile_head:
1414
+ return _capsule_build_feats_boxcls(self, x)
1415
+
1416
+ if not self._head_profile:
1417
+ self.reset_head_profile()
1418
+
1419
+ box_feats, cls_feats, cls_priors = [], [], []
1420
+ cls_name = self.__class__.__name__
1421
+ for i, xi in enumerate(x):
1422
+ self._sync_profile()
1423
+ t0 = time.perf_counter()
1424
+ pose, act = _capsule_split_pose_act(xi, self.k_list[i], self.d_list[i], cls_name, i)
1425
+ self._sync_profile()
1426
+ self._head_profile["split_pose_act_ms"] += (time.perf_counter() - t0) * 1000.0
1427
+
1428
+ self._sync_profile()
1429
+ t0 = time.perf_counter()
1430
+ act_s = self.sym_dropout[i](act)
1431
+ prior = self.sym_prior[i](act_s)
1432
+ prior = self.sym_norm[i](prior)
1433
+ prior = prior - prior.mean(dim=1, keepdim=True)
1434
+ sym_scale = torch.tanh(self.sym_beta[i])
1435
+ cls_prior = sym_scale * prior
1436
+ self._sync_profile()
1437
+ self._head_profile["cls_prior_ms"] += (time.perf_counter() - t0) * 1000.0
1438
+
1439
+ box_feats.append(pose)
1440
+ cls_feats.append(pose)
1441
+ cls_priors.append(cls_prior)
1442
+ return box_feats, cls_feats, cls_priors
1443
+
1444
+ def forward_head(
1445
+ self, x: list[torch.Tensor], box_head: torch.nn.Module = None, cls_head: torch.nn.Module = None
1446
+ ) -> dict[str, torch.Tensor]:
1447
+ self._ensure_profile_attrs()
1448
+ if box_head is None or cls_head is None:
1449
+ return dict()
1450
+
1451
+ box_feats, cls_feats, cls_priors = self._build_feats(x)
1452
+ bs = x[0].shape[0]
1453
+
1454
+ if not self.profile_head:
1455
+ box_list = [box_head[i](box_feats[i]).view(bs, 4 * self.reg_max, -1) for i in range(self.nl)]
1456
+ cls_list = [
1457
+ (cls_head[i](cls_feats[i]) + cls_priors[i]).view(bs, self.nc, -1)
1458
+ for i in range(self.nl)
1459
+ ]
1460
+ boxes = torch.cat(box_list, dim=-1)
1461
+ scores = torch.cat(cls_list, dim=-1)
1462
+ return dict(boxes=boxes, scores=scores, feats=x)
1463
+
1464
+ if not self._head_profile:
1465
+ self.reset_head_profile()
1466
+ self._head_profile_calls += 1
1467
+
1468
+ box_list, cls_list = [], []
1469
+ for i in range(self.nl):
1470
+ self._sync_profile()
1471
+ t0 = time.perf_counter()
1472
+ box_i = box_head[i](box_feats[i]).view(bs, 4 * self.reg_max, -1)
1473
+ self._sync_profile()
1474
+ self._head_profile["box_head_ms"] += (time.perf_counter() - t0) * 1000.0
1475
+ box_list.append(box_i)
1476
+
1477
+ self._sync_profile()
1478
+ t0 = time.perf_counter()
1479
+ cls_i = (cls_head[i](cls_feats[i]) + cls_priors[i]).view(bs, self.nc, -1)
1480
+ self._sync_profile()
1481
+ self._head_profile["cls_head_ms"] += (time.perf_counter() - t0) * 1000.0
1482
+ cls_list.append(cls_i)
1483
+
1484
+ self._sync_profile()
1485
+ t0 = time.perf_counter()
1486
+ boxes = torch.cat(box_list, dim=-1)
1487
+ scores = torch.cat(cls_list, dim=-1)
1488
+ self._sync_profile()
1489
+ self._head_profile["cat_ms"] += (time.perf_counter() - t0) * 1000.0
1490
+ return dict(boxes=boxes, scores=scores, feats=x)
1491
+
1492
+
1493
+ class CapsuleDetectv8(CapsuleDetectv5):
1494
+ """Capsule Detect v8: raw pose cls path with simplified cls_prior (no norm/centering)."""
1495
+
1496
+ def _build_feats(self, x: list[torch.Tensor]) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor]]:
1497
+ return _capsule_build_feats_boxcls_simpleprior(self, x)
1498
+
1499
+
1500
+ class CapsuleOpenVocabDetect(Detect):
1501
+ """Capsule detection head with open-vocabulary classification via text embedding matching."""
1502
+
1503
+ def __init__(
1504
+ self,
1505
+ nc: int = 80,
1506
+ *args,
1507
+ reg_max: int = 16,
1508
+ end2end: bool = False,
1509
+ embed: int = 256,
1510
+ with_act_gate: bool = False,
1511
+ with_objectness_prior: bool = True,
1512
+ k: list[int] | tuple[int, ...] = (4, 8, 16),
1513
+ d: list[int] | tuple[int, ...] = (16, 16, 16),
1514
+ ch: tuple = (),
1515
+ ):
1516
+ parsed = list(args)
1517
+ if parsed and isinstance(parsed[-1], (list, tuple)):
1518
+ ch = tuple(parsed.pop(-1))
1519
+
1520
+ if len(parsed) not in (2, 4, 7):
1521
+ raise ValueError(
1522
+ "CapsuleOpenVocabDetect expects [k_list, d_list, (reg_max, end2end, embed, with_act_gate, "
1523
+ "with_objectness_prior), ch]."
1524
+ )
1525
+
1526
+ k, d = parsed[0], parsed[1]
1527
+ if len(parsed) == 4:
1528
+ reg_max = int(parsed[2])
1529
+ end2end = bool(parsed[3])
1530
+ elif len(parsed) == 7:
1531
+ # Support both direct args order:
1532
+ # [k_list, d_list, reg_max, end2end, embed, with_act_gate, with_objectness_prior]
1533
+ # and parser-appended order:
1534
+ # [k_list, d_list, embed, with_act_gate, with_objectness_prior, reg_max, end2end]
1535
+ if type(parsed[3]) is bool and type(parsed[4]) is bool and type(parsed[6]) is bool:
1536
+ embed = int(parsed[2])
1537
+ with_act_gate = bool(parsed[3])
1538
+ with_objectness_prior = bool(parsed[4])
1539
+ reg_max = int(parsed[5])
1540
+ end2end = bool(parsed[6])
1541
+ else:
1542
+ reg_max = int(parsed[2])
1543
+ end2end = bool(parsed[3])
1544
+ embed = int(parsed[4])
1545
+ with_act_gate = bool(parsed[5])
1546
+ with_objectness_prior = bool(parsed[6])
1547
+
1548
+ self.k_list, self.d_list, merged_ch = _setup_capsule_layout(k, d, ch, "CapsuleOpenVocabDetect")
1549
+ super().__init__(nc=nc, reg_max=reg_max, end2end=end2end, ch=merged_ch)
1550
+
1551
+ self.embed = int(embed)
1552
+ self.with_act_gate = bool(with_act_gate)
1553
+ self.with_objectness_prior = bool(with_objectness_prior)
1554
+
1555
+ self.emb_head = nn.ModuleList()
1556
+ self.ov_gate = nn.ModuleList()
1557
+ self.ov_beta = nn.ParameterList()
1558
+ self.obj_prior = nn.ModuleList()
1559
+
1560
+ for k_i, d_i in zip(self.k_list, self.d_list):
1561
+ pose_ch = k_i * d_i
1562
+ self.emb_head.append(
1563
+ nn.Sequential(
1564
+ Conv(pose_ch, pose_ch, 3),
1565
+ DWConv(pose_ch, pose_ch, 3),
1566
+ nn.Conv2d(pose_ch, self.embed, 1, bias=True),
1567
+ )
1568
+ )
1569
+ if self.with_act_gate:
1570
+ hidden = max(16, k_i * 2)
1571
+ self.ov_gate.append(
1572
+ nn.Sequential(
1573
+ nn.Conv2d(k_i, hidden, 1, bias=True),
1574
+ nn.SiLU(inplace=True),
1575
+ nn.Conv2d(hidden, pose_ch, 1, bias=True),
1576
+ )
1577
+ )
1578
+ self.ov_beta.append(nn.Parameter(torch.tensor(0.1)))
1579
+ else:
1580
+ self.ov_gate.append(nn.Identity())
1581
+ self.ov_beta.append(nn.Parameter(torch.tensor(0.0), requires_grad=False))
1582
+
1583
+ if self.with_objectness_prior:
1584
+ self.obj_prior.append(nn.Conv2d(k_i, 1, 1, bias=True))
1585
+ else:
1586
+ self.obj_prior.append(nn.Identity())
1587
+
1588
+ self.logit_scale = nn.Parameter(torch.tensor(math.log(1 / 0.07), dtype=torch.float32))
1589
+ self.register_buffer("cached_text_embeddings", torch.empty(0), persistent=False)
1590
+
1591
+ def set_text_embeddings(self, text_embs: torch.Tensor | None) -> None:
1592
+ """Cache normalized text embeddings for inference."""
1593
+ if text_embs is None:
1594
+ self.cached_text_embeddings = torch.empty(0, device=self.logit_scale.device)
1595
+ return
1596
+ if text_embs.ndim != 2:
1597
+ raise ValueError(f"text_embs must be 2D [num_classes, embed_dim], got shape {tuple(text_embs.shape)}.")
1598
+ self.cached_text_embeddings = F.normalize(text_embs.detach().to(self.logit_scale.device), dim=-1)
1599
+
1600
+ def _split_pose_act(self, x: torch.Tensor, i: int) -> tuple[torch.Tensor, torch.Tensor]:
1601
+ return _capsule_split_pose_act(x, self.k_list[i], self.d_list[i], "CapsuleOpenVocabDetect", i)
1602
+
1603
+ def _build_feats(self, x: list[torch.Tensor]) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor]]:
1604
+ return _capsule_build_feats_open_vocab(self, x)
1605
+
1606
+ def _prepare_text_embeddings(self, text_embs: torch.Tensor | None, bs: int, device: torch.device) -> torch.Tensor | None:
1607
+ if text_embs is None:
1608
+ if self.cached_text_embeddings.numel() == 0:
1609
+ return None
1610
+ text = self.cached_text_embeddings
1611
+ else:
1612
+ text = text_embs
1613
+
1614
+ if text.ndim == 2:
1615
+ text = text.unsqueeze(0).expand(bs, -1, -1)
1616
+ elif text.ndim != 3:
1617
+ raise ValueError(f"text_embs must be 2D or 3D, got shape {tuple(text.shape)}.")
1618
+
1619
+ if text.shape[-1] != self.embed:
1620
+ raise ValueError(f"text_embs last dim must equal embed={self.embed}, got {text.shape[-1]}.")
1621
+ return F.normalize(text.to(device=device, dtype=self.logit_scale.dtype), dim=-1)
1622
+
1623
+ def _compute_ov_scores(
1624
+ self, cls_feats: list[torch.Tensor], acts: list[torch.Tensor], text_embs: torch.Tensor | None
1625
+ ) -> tuple[torch.Tensor | None, list[torch.Tensor], torch.Tensor | None]:
1626
+ bs = cls_feats[0].shape[0]
1627
+ level_embeddings = []
1628
+ for i in range(self.nl):
1629
+ emb = self.emb_head[i](cls_feats[i])
1630
+ if self.with_objectness_prior:
1631
+ emb = emb * (1.0 + torch.sigmoid(self.obj_prior[i](acts[i])))
1632
+ level_embeddings.append(emb)
1633
+
1634
+ text = self._prepare_text_embeddings(text_embs, bs, cls_feats[0].device)
1635
+ if text is None:
1636
+ return None, level_embeddings, None
1637
+
1638
+ visual_tokens = torch.cat(
1639
+ [F.normalize(emb.flatten(2).transpose(1, 2), dim=-1) for emb in level_embeddings],
1640
+ dim=1,
1641
+ )
1642
+ scale = self.logit_scale.exp().clamp(max=100.0)
1643
+ scores = torch.einsum("bnd,bcd->bcn", visual_tokens, text) * scale
1644
+ return scores, level_embeddings, text
1645
+
1646
+ def forward_head(
1647
+ self,
1648
+ x: list[torch.Tensor],
1649
+ text_embs: torch.Tensor | None = None,
1650
+ box_head: torch.nn.Module = None,
1651
+ cls_head: torch.nn.Module = None,
1652
+ ) -> dict[str, torch.Tensor]:
1653
+ del cls_head # fixed-class cls head is unused in open-vocabulary mode
1654
+ if box_head is None:
1655
+ return dict()
1656
+
1657
+ box_feats, cls_feats, acts = self._build_feats(x)
1658
+ bs = x[0].shape[0]
1659
+ boxes = torch.cat([box_head[i](box_feats[i]).view(bs, 4 * self.reg_max, -1) for i in range(self.nl)], dim=-1)
1660
+ scores, level_embeddings, text = self._compute_ov_scores(cls_feats, acts, text_embs)
1661
+
1662
+ preds = {
1663
+ "boxes": boxes,
1664
+ "embeddings": level_embeddings,
1665
+ "cls_feats": cls_feats,
1666
+ "acts": acts,
1667
+ "feats": x,
1668
+ }
1669
+ if scores is not None:
1670
+ preds["scores"] = scores
1671
+ preds["text_embeddings"] = text
1672
+ return preds
1673
+
1674
+ def forward(
1675
+ self, x: list[torch.Tensor], text_embs: torch.Tensor | None = None
1676
+ ) -> dict[str, torch.Tensor] | torch.Tensor | tuple[torch.Tensor, dict[str, torch.Tensor]]:
1677
+ preds = self.forward_head(x, text_embs=text_embs, **self.one2many)
1678
+ if self.end2end:
1679
+ x_detach = [xi.detach() for xi in x]
1680
+ one2one = self.forward_head(x_detach, text_embs=text_embs, **self.one2one)
1681
+ preds = {"one2many": preds, "one2one": one2one}
1682
+ if self.training:
1683
+ return preds
1684
+
1685
+ infer_preds = preds["one2one"] if self.end2end else preds
1686
+ if "scores" not in infer_preds:
1687
+ raise ValueError("CapsuleOpenVocabDetect inference requires text_embs or cached text embeddings.")
1688
+
1689
+ original_nc = self.nc
1690
+ self.nc = int(infer_preds["scores"].shape[1])
1691
+ try:
1692
+ y = self._inference(infer_preds)
1693
+ if self.end2end:
1694
+ y = self.postprocess(y.permute(0, 2, 1))
1695
+ finally:
1696
+ self.nc = original_nc
1697
+ return y if self.export else (y, preds)
1698
+
1699
+
1700
+ class CapsuleSegmentv1(Segment):
1701
+ """Capsule-style Segment head aligned with CapsuleDetectv6 semantics."""
1702
+
1703
+ def __init__(
1704
+ self,
1705
+ nc: int = 80,
1706
+ *args,
1707
+ nm: int = 32,
1708
+ npr: int = 256,
1709
+ reg_max: int = 16,
1710
+ end2end: bool = False,
1711
+ k: list[int] | tuple[int, ...] = (4, 8, 16),
1712
+ d: list[int] | tuple[int, ...] = (16, 16, 16),
1713
+ ch: tuple = (),
1714
+ ):
1715
+ parsed = list(args)
1716
+ if parsed and isinstance(parsed[-1], (list, tuple)):
1717
+ ch = tuple(parsed.pop(-1))
1718
+
1719
+ if len(parsed) not in (2, 4, 6):
1720
+ raise ValueError("CapsuleSegmentv1 expects [k_list, d_list, (nm, npr), reg_max, end2end, ch].")
1721
+
1722
+ k, d = parsed[0], parsed[1]
1723
+ if len(parsed) == 4:
1724
+ if isinstance(parsed[3], bool):
1725
+ reg_max = int(parsed[2])
1726
+ end2end = bool(parsed[3])
1727
+ else:
1728
+ nm = int(parsed[2])
1729
+ npr = int(parsed[3])
1730
+ elif len(parsed) == 6:
1731
+ nm = int(parsed[2])
1732
+ npr = int(parsed[3])
1733
+ reg_max = int(parsed[4])
1734
+ end2end = bool(parsed[5])
1735
+
1736
+ self.k_list, self.d_list, merged_ch = _setup_capsule_layout(k, d, ch, "CapsuleSegmentv1")
1737
+ super().__init__(nc=nc, nm=nm, npr=npr, reg_max=reg_max, end2end=end2end, ch=merged_ch)
1738
+ _init_capsule_semantic_heads(self)
1739
+ self.proto = Proto26(merged_ch, self.npr, self.nm, nc)
1740
+
1741
+ def _split_pose_act(self, x: torch.Tensor, i: int) -> tuple[torch.Tensor, torch.Tensor]:
1742
+ return _capsule_split_pose_act(x, self.k_list[i], self.d_list[i], "CapsuleSegmentv1", i)
1743
+
1744
+ def _build_feats(self, x: list[torch.Tensor]) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor]]:
1745
+ return _capsule_build_feats_gated(self, x)
1746
+
1747
+ def forward_head(
1748
+ self,
1749
+ x: list[torch.Tensor],
1750
+ box_head: torch.nn.Module = None,
1751
+ cls_head: torch.nn.Module = None,
1752
+ mask_head: torch.nn.Module = None,
1753
+ ) -> dict[str, torch.Tensor]:
1754
+ if box_head is None or cls_head is None:
1755
+ return dict()
1756
+
1757
+ box_feats, cls_feats, cls_priors = self._build_feats(x)
1758
+ bs = x[0].shape[0]
1759
+ boxes = torch.cat([box_head[i](box_feats[i]).view(bs, 4 * self.reg_max, -1) for i in range(self.nl)], dim=-1)
1760
+ scores = torch.cat(
1761
+ [(cls_head[i](cls_feats[i]) + cls_priors[i]).view(bs, self.nc, -1) for i in range(self.nl)],
1762
+ dim=-1,
1763
+ )
1764
+ preds = dict(boxes=boxes, scores=scores, feats=cls_feats)
1765
+ if mask_head is not None:
1766
+ preds["mask_coefficient"] = torch.cat(
1767
+ [mask_head[i](cls_feats[i]).view(bs, self.nm, -1) for i in range(self.nl)],
1768
+ dim=-1,
1769
+ )
1770
+ return preds
1771
+
1772
+ def forward(self, x: list[torch.Tensor]) -> tuple | list[torch.Tensor] | dict[str, torch.Tensor]:
1773
+ _, cls_feats, _ = self._build_feats(x)
1774
+ outputs = Detect.forward(self, x)
1775
+ preds = outputs[1] if isinstance(outputs, tuple) else outputs
1776
+ proto_in = cls_feats
1777
+ proto = self.proto(proto_in) # multi-level Proto26 over merged capsule features
1778
+ if isinstance(preds, dict):
1779
+ if self.end2end:
1780
+ preds["one2many"]["proto"] = proto
1781
+ preds["one2one"]["proto"] = tuple(p.detach() for p in proto) if isinstance(proto, tuple) else proto.detach()
1782
+ else:
1783
+ preds["proto"] = proto
1784
+ if self.training:
1785
+ return preds
1786
+ return (outputs, proto) if self.export else ((outputs[0], proto), preds)
1787
+
1788
+
1789
+ class CapsuleSegmentv2(CapsuleSegmentv1):
1790
+ """Capsule Segment v2: cls head consumes raw pose features and symbolic priors only."""
1791
+
1792
+ def _build_feats(self, x: list[torch.Tensor]) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor]]:
1793
+ return _capsule_build_feats_boxcls(self, x)
1794
+
1795
+
1796
+ class CapsuleSegmentv3(CapsuleSegmentv1):
1797
+ """Capsule Segment v3: raw pose cls path with simplified cls_prior (no norm/centering)."""
1798
+
1799
+ def _build_feats(self, x: list[torch.Tensor]) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor]]:
1800
+ return _capsule_build_feats_boxcls_simpleprior(self, x)
1801
+
modules/neck.py ADDED
@@ -0,0 +1,769 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CapsNeck: efficient capsule-style neck blocks for Ultralytics YAML models.
3
+
4
+ Design intent:
5
+ - Keep capsule semantics (type/channel grouping + routing-style fusion).
6
+ - Stay lightweight and export-friendly for detection training/inference.
7
+ - Avoid expensive iterative EM/dynamic routing inside the neck path.
8
+
9
+ This neck is "capsule-style" rather than a full matrix-capsule network:
10
+ 1) CapsProj : CNN feature -> packed capsules (K types * D dims)
11
+ 2) CapsAlign : scale alignment between pyramid levels (no global context)
12
+ 3) CapsRoute : efficient self-routing proxy across sources (softmax source gating)
13
+ 4) CapsDecode: packed capsules -> standard feature map for Detect
14
+ 5) CapsuleTap: optional pass-through cache hook for analysis/aux losses
15
+
16
+ Note:
17
+ - Routing here is source-level and single-step by default (iters=1), chosen for speed.
18
+ - If stronger capsule routing is needed, it should be added in the head where cost is lower.
19
+ """
20
+
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import List, Optional, Tuple, Union
25
+
26
+ import math
27
+ import time
28
+ import torch
29
+ import torch.nn as nn
30
+ import torch.nn.functional as F
31
+
32
+ from ultralytics.nn.modules import C3k2, Conv, DWConv
33
+
34
+
35
+ # -------------------------
36
+ # 1) CapsProj
37
+ # -------------------------
38
+
39
+ class CapsProj(nn.Module):
40
+ """
41
+ Project a standard feature map into packed capsule channels using one C3k2 block.
42
+
43
+ Input: x [B, C, H, W]
44
+ Output: u [B, K*(D+1), H, W]
45
+
46
+ Args:
47
+ K: number of capsule types
48
+ D: capsule pose dimension per type
49
+ mix/mix_kernel: kept for backward YAML compatibility (unused)
50
+ """
51
+
52
+ def __init__(self, c1: int, K: int = 4, D: int = 16):
53
+ super().__init__()
54
+ self.K = int(K)
55
+ self.D = int(D)
56
+ self.c_out = self.K * (self.D + 1)
57
+
58
+ # Use a single C3k2 block as the capsule projection operator.
59
+ self.map = C3k2(c1, self.c_out, n=1, c3k=False, e=0.5, g=1, shortcut=True)
60
+
61
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
62
+ return self.map(x)
63
+
64
+
65
+ # -------------------------
66
+ # 2) CapsAlign (no context)
67
+ # -------------------------
68
+
69
+ class CapsAlign(nn.Module):
70
+ """
71
+ Align packed capsules across pyramid levels with YOLO-style ops.
72
+
73
+ - Upsampling uses ``nn.Upsample(scale_factor=2, mode='nearest')``.
74
+ - Downsampling uses stride-2 ``Conv`` blocks.
75
+
76
+ Args:
77
+ c1: input/output channel count.
78
+ src_level: source pyramid level in {3,4,5}.
79
+ tgt_level: target pyramid level in {3,4,5}.
80
+ down_groups: groups for downsample Conv.
81
+ Use capsule-type count K to keep each capsule block isolated.
82
+ """
83
+
84
+ def __init__(self, c1: int, src_level: int, tgt_level: int, down_groups: int = 1):
85
+ super().__init__()
86
+ self.c1 = int(c1)
87
+ self.src_level = int(src_level)
88
+ self.tgt_level = int(tgt_level)
89
+ self.down_groups = int(down_groups)
90
+
91
+ if self.src_level not in (3, 4, 5) or self.tgt_level not in (3, 4, 5):
92
+ raise ValueError("CapsAlign levels must be in {3,4,5}.")
93
+
94
+ if self.down_groups < 1 or self.c1 % self.down_groups != 0:
95
+ raise ValueError(f"CapsAlign down_groups={self.down_groups} must divide c1={self.c1}.")
96
+
97
+ steps = abs(self.src_level - self.tgt_level)
98
+ if self.src_level == self.tgt_level:
99
+ self.mode = 'identity'
100
+ self.ops = nn.ModuleList()
101
+ elif self.src_level > self.tgt_level:
102
+ self.mode = 'up'
103
+ # YOLO-style top-down path: nearest-neighbor upsample x2 per level.
104
+ self.ops = nn.ModuleList(nn.Upsample(scale_factor=2, mode='nearest') for _ in range(steps))
105
+ else:
106
+ self.mode = 'down'
107
+ # YOLO-style bottom-up path: stride-2 grouped Conv per level.
108
+ self.ops = nn.ModuleList(Conv(self.c1, self.c1, 3, 2, g=self.down_groups) for _ in range(steps))
109
+
110
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
111
+ if self.mode == 'identity':
112
+ return x
113
+
114
+ for op in self.ops:
115
+ x = op(x)
116
+ return x
117
+
118
+
119
+ # -------------------------
120
+ # 3) CapsRoute (light, parser-friendly)
121
+ # -------------------------
122
+
123
+ class ConvSelfRouting(nn.Module):
124
+ """Grouped-conv self-routing over stacked capsule sources.
125
+
126
+ Args:
127
+ K_in: input capsule type count.
128
+ P_in: input pose dimension.
129
+ K_out: output capsule type count.
130
+ P_out: output pose dimension.
131
+ kernel_size: grouped conv kernel for local capsule mixing.
132
+ """
133
+
134
+ def __init__(self, K_in: int, P_in: int, K_out: int, P_out: int, kernel_size: int = 3):
135
+ super().__init__()
136
+ self.K_in = int(K_in)
137
+ self.P_in = int(P_in)
138
+ self.K_out = int(K_out)
139
+ self.P_out = int(P_out)
140
+
141
+ if min(self.K_in, self.P_in, self.K_out, self.P_out) <= 0:
142
+ raise ValueError('ConvSelfRouting expects positive K/P values.')
143
+
144
+ self.c_in = self.K_in * (self.P_in + 1)
145
+ self.c_out = self.K_out * (self.P_out + 1)
146
+
147
+ k = int(kernel_size)
148
+ padding = k//2
149
+ self.mix = nn.Conv2d(self.c_in, self.c_in, kernel_size=k, stride=1, padding=padding, groups=self.K_in, bias=False)
150
+ self.gate = nn.Conv2d(self.c_in, self.K_in, kernel_size=1, stride=1, padding=0, groups=self.K_in, bias=True)
151
+
152
+
153
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
154
+ # x: [B,C,H,W], C = K_in*(P_in+1)
155
+
156
+ b, c, h, w = x.shape
157
+ if c != self.c_in:
158
+ raise ValueError(f'ConvSelfRouting expected C={self.c_in}, got C={c}')
159
+
160
+ mixed = self.mix(x)
161
+ logits = self.gate(mixed).reshape(b, self.K_in, h, w)
162
+ weights = logits.softmax(dim=1)
163
+
164
+ caps = mixed.reshape(b, self.K_in, self.P_in + 1, h, w)
165
+ routed = weights.unsqueeze(2) * caps
166
+ routed = routed.reshape(b, self.c_in, h, w)
167
+
168
+ return routed
169
+
170
+
171
+ class SelfRouting(nn.Module):
172
+ """Pose-transform self-routing on packed capsule tensor.
173
+
174
+ Args:
175
+ K_in: input capsule type count.
176
+ P_in: input pose dimension.
177
+ K_out: output capsule type count.
178
+ P_out: output pose dimension.
179
+
180
+ Input:
181
+ x: [B, K_in*(P_in+1), H, W]
182
+
183
+ Output:
184
+ y: [B, K_out*(P_out+1), H, W]
185
+ """
186
+
187
+ def __init__(self, K_in: int, P_in: int, K_out: int, P_out: int):
188
+ super().__init__()
189
+ self.K_in = int(K_in)
190
+ self.P_in = int(P_in)
191
+ self.K_out = int(K_out)
192
+ self.P_out = int(P_out)
193
+ if min(self.K_in, self.P_in, self.K_out, self.P_out) <= 0:
194
+ raise ValueError('SelfRouting expects positive K/P values.')
195
+
196
+ self.c_in = self.K_in * (self.P_in + 1)
197
+ self.c_out = self.K_out * (self.P_out + 1)
198
+ self.eps = 1e-6
199
+
200
+ self.W_pose = nn.Parameter(torch.empty(self.K_in, self.K_out, self.P_in, self.P_out))
201
+ nn.init.kaiming_uniform_(self.W_pose, a=math.sqrt(5))
202
+ self.W_gate = nn.Parameter(torch.zeros(self.K_in, self.K_out, self.P_in))
203
+ self.b_gate = nn.Parameter(torch.zeros(1, self.K_in, self.K_out, 1, 1))
204
+
205
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
206
+ # x: [B, C, H, W], C = K_in*(P_in+1)
207
+ if x.ndim != 4:
208
+ raise TypeError(f'SelfRouting expects [B,C,H,W], got {tuple(x.shape)}')
209
+
210
+ b, c, h, w = x.shape
211
+ if c != self.c_in:
212
+ raise ValueError(f'SelfRouting expected C={self.c_in}, got C={c}')
213
+
214
+ # Packed capsule layout is interleaved per type: [pose(P), act(1)].
215
+ # x_caps: [B, K_in, P_in+1, H, W]
216
+ x_caps = x.reshape(b, self.K_in, self.P_in + 1, h, w)
217
+ pose = x_caps[:, :, :self.P_in] # [B, K_in, P_in, H, W]
218
+ act = x_caps[:, :, self.P_in : self.P_in + 1].sigmoid() # [B, K_in, 1, H, W]
219
+
220
+ # votes: [B, K_in, K_out, H, W, P_out]
221
+ votes = torch.einsum('bkphw,kopq->bkohwq', pose, self.W_pose)
222
+ # logits/weights: [B, K_in, K_out, H, W]
223
+ logits = torch.einsum('bkphw,kop->bkohw', pose, self.W_gate) + self.b_gate
224
+ weights = logits.softmax(dim=2)
225
+
226
+ ar = weights * act # [B, K_in, K_out, H, W]
227
+ ar_sum = ar.sum(dim=1, keepdim=True) + self.eps
228
+ coeff = ar / ar_sum
229
+
230
+ pose_out = (coeff.unsqueeze(-1) * votes).sum(dim=1) # [B, K_out, H, W, P_out]
231
+ pose_out = pose_out.permute(0, 1, 4, 2, 3) # [B, K_out, P_out, H, W]
232
+ act_out = ar_sum.squeeze(1).unsqueeze(2) # [B, K_out, 1, H, W]
233
+
234
+ # Keep interleaved packed output: [pose(P_out), act(1)] per capsule type.
235
+ out = torch.cat([pose_out, act_out], dim=2).reshape(b, self.c_out, h, w)
236
+ return out
237
+
238
+
239
+ class HybridRoute1(nn.Module):
240
+ """Conv-heavy replacement for SelfRouting with lightweight capsule-aware gating."""
241
+
242
+ def __init__(self, K_in: int, P_in: int, K_out: int, P_out: int):
243
+ super().__init__()
244
+ self.K_in = int(K_in)
245
+ self.P_in = int(P_in)
246
+ self.K_out = int(K_out)
247
+ self.P_out = int(P_out)
248
+ self.c_in = self.K_in * (self.P_in + 1)
249
+ self.c_out = self.K_out * (self.P_out + 1)
250
+
251
+ pose_in = self.K_in * self.P_in
252
+ pose_out = self.K_out * self.P_out
253
+ vote_groups = math.gcd(self.K_in, self.K_out)
254
+ vote_groups = max(int(vote_groups), 1)
255
+ self.vote_proj = Conv(pose_in, pose_out, 1, 1, g=vote_groups)
256
+ self.gate_proj = nn.Conv2d(self.c_in, self.K_out, kernel_size=1, stride=1, padding=0, bias=True)
257
+ self.act_proj = nn.Conv2d(self.K_in, self.K_out, kernel_size=1, stride=1, padding=0, bias=True)
258
+
259
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
260
+ if x.ndim != 4:
261
+ raise TypeError(f'HybridRoute1 expects [B,C,H,W], got {tuple(x.shape)}')
262
+
263
+ b, c, h, w = x.shape
264
+ if c != self.c_in:
265
+ raise ValueError(f'HybridRoute1 expected C={self.c_in}, got C={c}')
266
+
267
+ x_caps = x.reshape(b, self.K_in, self.P_in + 1, h, w)
268
+ pose = x_caps[:, :, :self.P_in].reshape(b, self.K_in * self.P_in, h, w)
269
+ act = x_caps[:, :, self.P_in].contiguous()
270
+
271
+ pose_votes = self.vote_proj(pose).reshape(b, self.K_out, self.P_out, h, w)
272
+ gate = self.gate_proj(x).sigmoid().unsqueeze(2)
273
+ pose_out = pose_votes * gate
274
+
275
+ act_out = self.act_proj(act).sigmoid().unsqueeze(2)
276
+ out = torch.cat([pose_out, act_out], dim=2).reshape(b, self.c_out, h, w)
277
+ return out
278
+
279
+
280
+ class CapsRoute(nn.Module):
281
+ """Capsule routing fusion by direct capsule concatenation.
282
+
283
+ Args:
284
+ K_in: list of input capsule type counts per source.
285
+ P_in: list of input pose dimensions per source.
286
+ K_out: target output capsule type count.
287
+ P_out: target output pose dimension.
288
+ kernel_size: grouped-conv kernel for ``ConvSelfRouting``.
289
+
290
+ Notes:
291
+ Inputs are concatenated directly (no pre-projection).
292
+ For direct packed concat, all ``P_in`` must be identical.
293
+ """
294
+
295
+ def __init__(
296
+ self,
297
+ K_in: Union[List[int], Tuple[int, ...]],
298
+ P_in: Union[List[int], Tuple[int, ...]],
299
+ K_out: int,
300
+ P_out: int,
301
+ kernel_size: int = 3,
302
+ pre_k: int = 3,
303
+ post_k: int = 3,
304
+ pre_groups: Optional[int] = None,
305
+ post_groups: Optional[int] = None,
306
+ ):
307
+ super().__init__()
308
+ self.K_in_list = [int(v) for v in K_in]
309
+ self.P_in_list = [int(v) for v in P_in]
310
+ if len(self.K_in_list) < 2 or len(self.K_in_list) != len(self.P_in_list):
311
+ raise ValueError('CapsRoute expects K_in/P_in lists with same length >= 2.')
312
+ if min(*self.K_in_list, *self.P_in_list) <= 0:
313
+ raise ValueError('CapsRoute expects positive K_in/P_in values.')
314
+
315
+ # Direct capsule concat requires a shared pose dimension.
316
+ if len(set(self.P_in_list)) != 1:
317
+ raise ValueError('CapsRoute direct concat requires all P_in to be identical.')
318
+
319
+ self.num_sources = len(self.K_in_list)
320
+ self.P_cat = int(self.P_in_list[0])
321
+ self.K_cat = int(sum(self.K_in_list))
322
+ self.c_cat = self.K_cat * (self.P_cat + 1)
323
+
324
+ self.K_out = int(K_out)
325
+ self.P_out = int(P_out)
326
+ if min(self.K_out, self.P_out) <= 0:
327
+ raise ValueError('CapsRoute expects positive K_out/P_out values.')
328
+ self.c_out = self.K_out * (self.P_out + 1)
329
+
330
+ # self.conv_route = ConvSelfRouting(
331
+ # K_in=self.K_cat,
332
+ # P_in=self.P_cat,
333
+ # K_out=self.K_cat,
334
+ # P_out=self.P_cat,
335
+ # kernel_size=kernel_size,
336
+ # )
337
+ # Grouped Conv before routing: C = K_cat * (P_cat + 1), groups = K_cat.
338
+ self.conv_route = Conv(self.c_cat, self.c_cat, 3, 1, g=self.K_cat)
339
+ self.route1 = SelfRouting(K_in=self.K_cat, P_in=self.P_cat, K_out=self.K_out, P_out=self.P_out)
340
+ # Grouped Conv after routing: C = K_out * (P_out + 1), groups = K_out.
341
+ self.spagg = Conv(self.c_out, self.c_out, 3, 1, g=self.K_out)
342
+ # self.route2 = SelfRouting(K_in=self.K_out, P_in=self.P_out, K_out=self.K_out, P_out=self.P_out)
343
+
344
+ def forward(self, xs: Union[List[torch.Tensor], Tuple[torch.Tensor, ...]]) -> torch.Tensor:
345
+ if not isinstance(xs, (list, tuple)):
346
+ raise TypeError(f'CapsRoute expects list/tuple inputs, got {type(xs)}')
347
+ if len(xs) != self.num_sources:
348
+ raise ValueError(f'CapsRoute expected {self.num_sources} sources, got {len(xs)}')
349
+
350
+ h, w = int(xs[0].shape[-2]), int(xs[0].shape[-1])
351
+ cat_parts = []
352
+ for i, x in enumerate(xs):
353
+ expected_c = self.K_in_list[i] * (self.P_in_list[i] + 1)
354
+ if int(x.shape[1]) != expected_c:
355
+ raise ValueError(f'CapsRoute source-{i} expected C={expected_c} from K_in/P_in, got C={int(x.shape[1])}')
356
+ if int(x.shape[-2]) != h or int(x.shape[-1]) != w:
357
+ raise ValueError('CapsRoute inputs must share H,W. Use CapsAlign before routing.')
358
+ cat_parts.append(x)
359
+
360
+ x_cat = torch.cat(cat_parts, dim=1) # [B, K_cat*(P+1), H, W]
361
+ routed = self.route1(self.conv_route(x_cat))
362
+ routed = self.spagg(routed)
363
+ return routed
364
+
365
+
366
+ class CapsRoutev2(CapsRoute):
367
+ """CapsRoute with per-capsule pose refinement and act residual update."""
368
+
369
+ def __init__(
370
+ self,
371
+ K_in: Union[List[int], Tuple[int, ...]],
372
+ P_in: Union[List[int], Tuple[int, ...]],
373
+ K_out: int,
374
+ P_out: int,
375
+ kernel_size: int = 3,
376
+ pre_k: int = 3,
377
+ post_k: int = 3,
378
+ pre_groups: Optional[int] = None,
379
+ post_groups: Optional[int] = None,
380
+ ):
381
+ super().__init__(K_in, P_in, K_out, P_out, kernel_size, pre_k, post_k, pre_groups, post_groups)
382
+ _ = (post_k, post_groups, pre_k, pre_groups) # kept for YAML/API compatibility
383
+ self.profile_route = False
384
+ self._route_profile = {
385
+ 'cat_ms': 0.0,
386
+ 'conv_route_ms': 0.0,
387
+ 'route1_ms': 0.0,
388
+ 'pose_refine_ms': 0.0,
389
+ 'act_from_pose_ms': 0.0,
390
+ 'pack_ms': 0.0,
391
+ 'calls': 0.0,
392
+ }
393
+
394
+ deep_stage = self.K_out >= 64
395
+ pose_ch = self.K_out * self.P_out
396
+ # Match YOLO26 neck style:
397
+ # - shallow/mid stages: C3k2(n=2, c3k=True, attn=False)
398
+ # - deep stage: C3k2(n=1, c3k=True, attn=True)
399
+ pose_e = 0.5 if (self.P_out % 2 == 0) else 1.0
400
+ self.pose_refine = C3k2(
401
+ pose_ch,
402
+ pose_ch,
403
+ n=1 if deep_stage else 2,
404
+ c3k=True,
405
+ e=pose_e,
406
+ attn=deep_stage,
407
+ g=self.K_out,
408
+ shortcut=True,
409
+ )
410
+ self.act_from_pose = Conv(pose_ch, self.K_out, 1, 1, g=self.K_out)
411
+ self.act_alpha = nn.Parameter(torch.tensor(0.1))
412
+
413
+ @staticmethod
414
+ def _sync_profile() -> None:
415
+ if torch.cuda.is_available():
416
+ torch.cuda.synchronize()
417
+
418
+ def _ensure_route_profile_state(self) -> None:
419
+ if not hasattr(self, "profile_route"):
420
+ self.profile_route = False
421
+ if not hasattr(self, "_route_profile"):
422
+ self._route_profile = {
423
+ 'cat_ms': 0.0,
424
+ 'conv_route_ms': 0.0,
425
+ 'route1_ms': 0.0,
426
+ 'pose_refine_ms': 0.0,
427
+ 'act_from_pose_ms': 0.0,
428
+ 'pack_ms': 0.0,
429
+ 'calls': 0.0,
430
+ }
431
+
432
+ def reset_route_profile(self) -> None:
433
+ self._ensure_route_profile_state()
434
+ for k in self._route_profile:
435
+ self._route_profile[k] = 0.0
436
+
437
+ def get_route_profile(self) -> dict:
438
+ self._ensure_route_profile_state()
439
+ calls = max(float(self._route_profile.get('calls', 0.0)), 1.0)
440
+ total = (
441
+ self._route_profile['cat_ms']
442
+ + self._route_profile['conv_route_ms']
443
+ + self._route_profile['route1_ms']
444
+ + self._route_profile['pose_refine_ms']
445
+ + self._route_profile['act_from_pose_ms']
446
+ + self._route_profile['pack_ms']
447
+ )
448
+ out = dict(self._route_profile)
449
+ out['total_ms'] = total
450
+ out['cat_avg_ms'] = self._route_profile['cat_ms'] / calls
451
+ out['conv_route_avg_ms'] = self._route_profile['conv_route_ms'] / calls
452
+ out['route1_avg_ms'] = self._route_profile['route1_ms'] / calls
453
+ out['pose_refine_avg_ms'] = self._route_profile['pose_refine_ms'] / calls
454
+ out['act_from_pose_avg_ms'] = self._route_profile['act_from_pose_ms'] / calls
455
+ out['pack_avg_ms'] = self._route_profile['pack_ms'] / calls
456
+ out['total_avg_ms'] = total / calls
457
+ return out
458
+
459
+ def forward(self, xs: Union[List[torch.Tensor], Tuple[torch.Tensor, ...]]) -> torch.Tensor:
460
+ if not isinstance(xs, (list, tuple)):
461
+ raise TypeError(f'CapsRoutev2 expects list/tuple inputs, got {type(xs)}')
462
+ if len(xs) != self.num_sources:
463
+ raise ValueError(f'CapsRoutev2 expected {self.num_sources} sources, got {len(xs)}')
464
+
465
+ h, w = int(xs[0].shape[-2]), int(xs[0].shape[-1])
466
+ cat_parts = []
467
+ for i, x in enumerate(xs):
468
+ expected_c = self.K_in_list[i] * (self.P_in_list[i] + 1)
469
+ if int(x.shape[1]) != expected_c:
470
+ raise ValueError(f'CapsRoutev2 source-{i} expected C={expected_c}, got C={int(x.shape[1])}')
471
+ if int(x.shape[-2]) != h or int(x.shape[-1]) != w:
472
+ raise ValueError('CapsRoutev2 inputs must share H,W. Use CapsAlign before routing.')
473
+ cat_parts.append(x)
474
+
475
+ self._ensure_route_profile_state()
476
+ if getattr(self, "profile_route", False):
477
+ self._route_profile['calls'] += 1.0
478
+ self._sync_profile()
479
+ t0 = time.perf_counter()
480
+ x_cat = torch.cat(cat_parts, dim=1) # [B, K_cat*(P+1), H, W]
481
+ self._sync_profile()
482
+ self._route_profile['cat_ms'] += (time.perf_counter() - t0) * 1000.0
483
+
484
+ t0 = time.perf_counter()
485
+ conv_out = self.conv_route(x_cat)
486
+ self._sync_profile()
487
+ self._route_profile['conv_route_ms'] += (time.perf_counter() - t0) * 1000.0
488
+
489
+ t0 = time.perf_counter()
490
+ routed = self.route1(conv_out) # [B, K_out*(P_out+1), H, W]
491
+ self._sync_profile()
492
+ self._route_profile['route1_ms'] += (time.perf_counter() - t0) * 1000.0
493
+ else:
494
+ x_cat = torch.cat(cat_parts, dim=1) # [B, K_cat*(P+1), H, W]
495
+ routed = self.route1(self.conv_route(x_cat)) # [B, K_out*(P_out+1), H, W]
496
+
497
+ b, _, _, _ = routed.shape
498
+ # Packed layout by type: [pose(P), act(1)] repeated K times.
499
+ caps = routed.reshape(b, self.K_out, self.P_out + 1, h, w)
500
+ pose = caps[:, :, :self.P_out].contiguous() # [B, K_out, P_out, H, W]
501
+ act = caps[:, :, self.P_out].contiguous() # [B, K_out, H, W]
502
+
503
+ # Grouped pose refinement across type blocks (equivalent to per-type grouped processing).
504
+ pose_flat = pose.reshape(b, self.K_out * self.P_out, h, w)
505
+ if getattr(self, "profile_route", False):
506
+ t0 = time.perf_counter()
507
+ pose_flat = self.pose_refine(pose_flat)
508
+ self._sync_profile()
509
+ self._route_profile['pose_refine_ms'] += (time.perf_counter() - t0) * 1000.0
510
+
511
+ t0 = time.perf_counter()
512
+ act_delta = self.act_from_pose(pose_flat)
513
+ act_final = act + act_delta
514
+ self._sync_profile()
515
+ self._route_profile['act_from_pose_ms'] += (time.perf_counter() - t0) * 1000.0
516
+ else:
517
+ pose_flat = self.pose_refine(pose_flat)
518
+ act_delta = self.act_from_pose(pose_flat)
519
+ act_final = act + act_delta
520
+
521
+ if getattr(self, "profile_route", False):
522
+ t0 = time.perf_counter()
523
+ pose_pack = pose_flat.reshape(b, self.K_out, self.P_out, h, w)
524
+ out = torch.cat([pose_pack, act_final.unsqueeze(2)], dim=2).reshape(b, self.c_out, h, w)
525
+ self._sync_profile()
526
+ self._route_profile['pack_ms'] += (time.perf_counter() - t0) * 1000.0
527
+ else:
528
+ pose_pack = pose_flat.reshape(b, self.K_out, self.P_out, h, w)
529
+ out = torch.cat([pose_pack, act_final.unsqueeze(2)], dim=2).reshape(b, self.c_out, h, w)
530
+ return out
531
+
532
+
533
+ # -------------------------
534
+ # 4) CapsDecode
535
+ # -------------------------
536
+
537
+ class CapsRoutev3(CapsRoute):
538
+ """CapsRoute with DS-style lightweight pose refinement and act residual update."""
539
+
540
+ def __init__(
541
+ self,
542
+ K_in: Union[List[int], Tuple[int, ...]],
543
+ P_in: Union[List[int], Tuple[int, ...]],
544
+ K_out: int,
545
+ P_out: int,
546
+ kernel_size: int = 3,
547
+ pre_k: int = 3,
548
+ post_k: int = 3,
549
+ pre_groups: Optional[int] = None,
550
+ post_groups: Optional[int] = None,
551
+ ):
552
+ super().__init__(K_in, P_in, K_out, P_out, kernel_size, pre_k, post_k, pre_groups, post_groups)
553
+ _ = (post_k, post_groups, pre_k, pre_groups)
554
+ self.profile_route = False
555
+ self._route_profile = {
556
+ 'cat_ms': 0.0,
557
+ 'conv_route_ms': 0.0,
558
+ 'route1_ms': 0.0,
559
+ 'pose_refine_ms': 0.0,
560
+ 'act_from_pose_ms': 0.0,
561
+ 'pack_ms': 0.0,
562
+ 'calls': 0.0,
563
+ }
564
+
565
+ pose_ch = self.K_out * self.P_out
566
+ # Keep refinement fully type-grouped to preserve capsule semantics:
567
+ # each capsule type only mixes its own pose channels.
568
+ self.pose_refine = nn.Sequential(
569
+ Conv(pose_ch, pose_ch, 1, 1, g=self.K_out),
570
+ Conv(pose_ch, pose_ch, 3, 1, g=self.K_out),
571
+ Conv(pose_ch, pose_ch, 1, 1, g=self.K_out),
572
+ )
573
+ self.act_from_pose = Conv(pose_ch, self.K_out, 1, 1, g=self.K_out)
574
+ self.act_alpha = nn.Parameter(torch.tensor(0.1))
575
+
576
+ @staticmethod
577
+ def _sync_profile() -> None:
578
+ if torch.cuda.is_available():
579
+ torch.cuda.synchronize()
580
+
581
+ def _ensure_route_profile_state(self) -> None:
582
+ if not hasattr(self, "profile_route"):
583
+ self.profile_route = False
584
+ if not hasattr(self, "_route_profile"):
585
+ self._route_profile = {
586
+ 'cat_ms': 0.0,
587
+ 'conv_route_ms': 0.0,
588
+ 'route1_ms': 0.0,
589
+ 'pose_refine_ms': 0.0,
590
+ 'act_from_pose_ms': 0.0,
591
+ 'pack_ms': 0.0,
592
+ 'calls': 0.0,
593
+ }
594
+
595
+ def reset_route_profile(self) -> None:
596
+ self._ensure_route_profile_state()
597
+ for k in self._route_profile:
598
+ self._route_profile[k] = 0.0
599
+
600
+ def get_route_profile(self) -> dict:
601
+ self._ensure_route_profile_state()
602
+ calls = max(float(self._route_profile.get('calls', 0.0)), 1.0)
603
+ total = (
604
+ self._route_profile['cat_ms']
605
+ + self._route_profile['conv_route_ms']
606
+ + self._route_profile['route1_ms']
607
+ + self._route_profile['pose_refine_ms']
608
+ + self._route_profile['act_from_pose_ms']
609
+ + self._route_profile['pack_ms']
610
+ )
611
+ out = dict(self._route_profile)
612
+ out['total_ms'] = total
613
+ out['cat_avg_ms'] = self._route_profile['cat_ms'] / calls
614
+ out['conv_route_avg_ms'] = self._route_profile['conv_route_ms'] / calls
615
+ out['route1_avg_ms'] = self._route_profile['route1_ms'] / calls
616
+ out['pose_refine_avg_ms'] = self._route_profile['pose_refine_ms'] / calls
617
+ out['act_from_pose_avg_ms'] = self._route_profile['act_from_pose_ms'] / calls
618
+ out['pack_avg_ms'] = self._route_profile['pack_ms'] / calls
619
+ out['total_avg_ms'] = total / calls
620
+ return out
621
+
622
+ def forward(self, xs: Union[List[torch.Tensor], Tuple[torch.Tensor, ...]]) -> torch.Tensor:
623
+ if not isinstance(xs, (list, tuple)):
624
+ raise TypeError(f'CapsRoutev3 expects list/tuple inputs, got {type(xs)}')
625
+ if len(xs) != self.num_sources:
626
+ raise ValueError(f'CapsRoutev3 expected {self.num_sources} sources, got {len(xs)}')
627
+
628
+ h, w = int(xs[0].shape[-2]), int(xs[0].shape[-1])
629
+ cat_parts = []
630
+ for i, x in enumerate(xs):
631
+ expected_c = self.K_in_list[i] * (self.P_in_list[i] + 1)
632
+ if int(x.shape[1]) != expected_c:
633
+ raise ValueError(f'CapsRoutev3 source-{i} expected C={expected_c}, got C={int(x.shape[1])}')
634
+ if int(x.shape[-2]) != h or int(x.shape[-1]) != w:
635
+ raise ValueError('CapsRoutev3 inputs must share H,W. Use CapsAlign before routing.')
636
+ cat_parts.append(x)
637
+
638
+ self._ensure_route_profile_state()
639
+ if getattr(self, "profile_route", False):
640
+ self._route_profile['calls'] += 1.0
641
+ self._sync_profile()
642
+ t0 = time.perf_counter()
643
+ x_cat = torch.cat(cat_parts, dim=1)
644
+ self._sync_profile()
645
+ self._route_profile['cat_ms'] += (time.perf_counter() - t0) * 1000.0
646
+
647
+ t0 = time.perf_counter()
648
+ conv_out = self.conv_route(x_cat)
649
+ self._sync_profile()
650
+ self._route_profile['conv_route_ms'] += (time.perf_counter() - t0) * 1000.0
651
+
652
+ t0 = time.perf_counter()
653
+ routed = self.route1(conv_out)
654
+ self._sync_profile()
655
+ self._route_profile['route1_ms'] += (time.perf_counter() - t0) * 1000.0
656
+ else:
657
+ x_cat = torch.cat(cat_parts, dim=1)
658
+ routed = self.route1(self.conv_route(x_cat))
659
+
660
+ b, _, _, _ = routed.shape
661
+ caps = routed.reshape(b, self.K_out, self.P_out + 1, h, w)
662
+ pose = caps[:, :, :self.P_out].contiguous()
663
+ act = caps[:, :, self.P_out].contiguous()
664
+
665
+ pose_flat = pose.reshape(b, self.K_out * self.P_out, h, w)
666
+ if getattr(self, "profile_route", False):
667
+ t0 = time.perf_counter()
668
+ pose_flat = pose_flat + self.pose_refine(pose_flat)
669
+ self._sync_profile()
670
+ self._route_profile['pose_refine_ms'] += (time.perf_counter() - t0) * 1000.0
671
+
672
+ t0 = time.perf_counter()
673
+ act_delta = self.act_from_pose(pose_flat)
674
+ act_final = act + act_delta
675
+ self._sync_profile()
676
+ self._route_profile['act_from_pose_ms'] += (time.perf_counter() - t0) * 1000.0
677
+ else:
678
+ pose_flat = pose_flat + self.pose_refine(pose_flat)
679
+ act_delta = self.act_from_pose(pose_flat)
680
+ act_final = act + act_delta
681
+
682
+ if getattr(self, "profile_route", False):
683
+ t0 = time.perf_counter()
684
+ pose_pack = pose_flat.reshape(b, self.K_out, self.P_out, h, w)
685
+ out = torch.cat([pose_pack, act_final.unsqueeze(2)], dim=2).reshape(b, self.c_out, h, w)
686
+ self._sync_profile()
687
+ self._route_profile['pack_ms'] += (time.perf_counter() - t0) * 1000.0
688
+ else:
689
+ pose_pack = pose_flat.reshape(b, self.K_out, self.P_out, h, w)
690
+ out = torch.cat([pose_pack, act_final.unsqueeze(2)], dim=2).reshape(b, self.c_out, h, w)
691
+ return out
692
+
693
+
694
+ class CapsRoutev4(CapsRoutev2):
695
+ """CapsRoutev2 with conv-heavy HybridRoute1 to reduce routing overhead."""
696
+
697
+ def __init__(
698
+ self,
699
+ K_in: Union[List[int], Tuple[int, ...]],
700
+ P_in: Union[List[int], Tuple[int, ...]],
701
+ K_out: int,
702
+ P_out: int,
703
+ kernel_size: int = 3,
704
+ pre_k: int = 3,
705
+ post_k: int = 3,
706
+ pre_groups: Optional[int] = None,
707
+ post_groups: Optional[int] = None,
708
+ ):
709
+ super().__init__(K_in, P_in, K_out, P_out, kernel_size, pre_k, post_k, pre_groups, post_groups)
710
+ self.route1 = HybridRoute1(K_in=self.K_cat, P_in=self.P_cat, K_out=self.K_out, P_out=self.P_out)
711
+
712
+
713
+ class CapsDecode(nn.Module):
714
+ """
715
+ Decode routed capsule features to standard feature map for Detect.
716
+
717
+ Input: y [B, C_in, H, W] (often concat of weighted sources, so C_in = S*(K*D))
718
+ Output: f [B, C_out, H, W]
719
+
720
+ Args:
721
+ c2: output channels (e.g., 256/512/1024)
722
+ """
723
+
724
+ def __init__(self, c1: int, c2: int):
725
+ super().__init__()
726
+ self.conv = nn.Conv2d(c1, c2, kernel_size=1, stride=1, padding=0, bias=False)
727
+ self.bn = nn.BatchNorm2d(c2)
728
+ self.act = nn.SiLU(inplace=True)
729
+
730
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
731
+ return self.act(self.bn(self.conv(x)))
732
+
733
+
734
+ # -------------------------
735
+ # 5) CapsuleTap
736
+ # -------------------------
737
+
738
+ class CapsuleTap(nn.Module):
739
+ """
740
+ Pass-through hook to cache feature maps for explainability/aux loss.
741
+
742
+ MUST NOT change tensor shape. Returns x unchanged.
743
+
744
+ Args:
745
+ tag: string identifier ("F3"/"F4"/"F5")
746
+ K,D: capsule hyperparams (metadata only)
747
+ cache_enabled: if True, cache during training (disabled in tracing/scripting)
748
+ """
749
+
750
+ def __init__(self, tag: str = "F", K: int = 4, D: int = 16, cache_enabled: bool = True):
751
+ super().__init__()
752
+ self.tag = str(tag)
753
+ self.K = int(K)
754
+ self.D = int(D)
755
+ self.cache_enabled = bool(cache_enabled)
756
+ self.last_x: Optional[torch.Tensor] = None
757
+
758
+ def clear_cache(self) -> None:
759
+ self.last_x = None
760
+
761
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
762
+ if (
763
+ self.cache_enabled
764
+ and self.training
765
+ and (not torch.jit.is_scripting())
766
+ and (not torch.jit.is_tracing())
767
+ ):
768
+ self.last_x = x.detach()
769
+ return x
predict.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from ultralytics import YOLO
7
+
8
+ from models import register_ultralytics_modules
9
+
10
+
11
+ ROOT = Path(__file__).resolve().parent
12
+ DEFAULT_WEIGHTS = ROOT / "weights" / "symbolic_capsule_network_segmentation.pt"
13
+
14
+
15
+ def build_parser() -> argparse.ArgumentParser:
16
+ parser = argparse.ArgumentParser(description="Run Symbolic Capsule Network segmentation inference.")
17
+ parser.add_argument("source", help="Image, directory, video, or glob pattern.")
18
+ parser.add_argument("--weights", default=str(DEFAULT_WEIGHTS), help="Checkpoint path.")
19
+ parser.add_argument("--imgsz", type=int, default=640)
20
+ parser.add_argument("--conf", type=float, default=0.25)
21
+ parser.add_argument("--device", default="")
22
+ parser.add_argument("--save", action="store_true", default=True)
23
+ parser.add_argument("--show", action="store_true")
24
+ return parser
25
+
26
+
27
+ def main() -> None:
28
+ args = build_parser().parse_args()
29
+ weights = Path(args.weights).expanduser().resolve()
30
+ if not weights.exists():
31
+ raise FileNotFoundError(f"Checkpoint not found: {weights}")
32
+
33
+ register_ultralytics_modules()
34
+ model = YOLO(str(weights))
35
+
36
+ predict_kwargs = {k: v for k, v in vars(args).items() if k != "weights"}
37
+ model.predict(**predict_kwargs)
38
+
39
+
40
+ if __name__ == "__main__":
41
+ main()
pyproject.toml ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "csnet"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "fiftyone>=1.11.1",
9
+ "langchain>=1.2.15",
10
+ "langchain-openai>=1.1.12",
11
+ "mobileclip",
12
+ "numpy>=2.4.4",
13
+ "torch>=2.7.0",
14
+ "torchvision>=0.21.0",
15
+ "transformers>=5.5.0",
16
+ "ultralytics>=8.4.9",
17
+ ]
18
+
19
+ [tool.uv.sources]
20
+ torch = { index = "pytorch-cu128" }
21
+ torchvision = { index = "pytorch-cu128" }
22
+ mobileclip = { git = "https://github.com/ultralytics/mobileclip.git" }
23
+
24
+ [[tool.uv.index]]
25
+ name = "pytorch-cu128"
26
+ url = "https://download.pytorch.org/whl/cu128"
27
+ explicit = true
uv.lock ADDED
The diff for this file is too large to render. See raw diff